c946b73d11
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool): - server/hub/ holds the hub-only code (devices, remote sessions, upload ingest, LAN gateway), required only in server mode; hub-boundary.test.js keeps local code from importing it. - A local install enrolls with `agenthub remote login` or Settings → AI tools → Company hub; the "Company account" AI Proxy (reserved name agenthub.company) sends a session through the hub's gateway, which adds a pool account's token — no credential reaches the machine. Only those sessions are uploaded, hourly and at their end; secret-shaped strings are redacted on the client. - Gateway refuses: unknown device (401), a message without a session id, an unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30. - New alert rule type remote_unmonitored: a company-account session its device never registered; a late registration clears the flag instead. - The hub imports a session once per upload round (chunks carry `more`), not once per chunk. Artifacts: - Share an artifact with chosen people (artifact_shares, PATCH sharedWith, GET /api/artifacts/people) besides everyone; a "Shared with you" tab. Only the owner or an admin sees or edits who it is shared with; audited. - Folders: each account's own arrangement (agenthub.artifacts.folders pref). - A claude.ai page keeps a copy viewable without the publishing account: its HTML file (and supporting files) snapshotted at publish, or the copy Claude saves for a Doc / Slides / Design in artifacts-export/ (a synchronous PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch), attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF). Fixes: - tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's tmux (stop the last pane, start again within 10 s failed with ENOENT). - shelves: a shelf restores beside changes in other files. - chat: a finished reply ends the turn even with no hook at all. - Settings → AI tools: profile list display tweaks.
137 lines
4.8 KiB
JavaScript
137 lines
4.8 KiB
JavaScript
/**
|
|
* @file Fails when an Express route under /api has no operation in the OpenAPI
|
|
* document. Builds the app with `createApp()`, walks every router layer with
|
|
* its mount prefix (recorded by wrapping `express.application.use` before the
|
|
* server loads), and checks each method + path against `createOpenApiSpec()`.
|
|
* Path parameters are compared by position only (`:token` and `{ref}` match),
|
|
* and the two alias mounts that the spec documents by their tag description
|
|
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
|
|
* are allowlisted. It also fails on a spec operation no route answers. The
|
|
* app is built twice — local and server mode — since only server mode mounts
|
|
* the hub's routes (server/hub/).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const os = require("os");
|
|
const fs = require("fs");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `dashboard-openapi-cov-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
// Record each app.use mount path on the layer it creates — Express keeps only
|
|
// a compiled matcher, which cannot be turned back into the prefix.
|
|
const express = require("express");
|
|
const originalUse = express.application.use;
|
|
express.application.use = function (mount, ...rest) {
|
|
const out = originalUse.call(this, mount, ...rest);
|
|
if (typeof mount === "string" || Array.isArray(mount)) {
|
|
const stack = (this.router || this._router).stack;
|
|
stack[stack.length - 1].__mount = mount;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const { createApp } = require("../index");
|
|
const { createOpenApiSpec } = require("../openapi");
|
|
|
|
/** Mounts that repeat another, documented mount by design (see the tags). */
|
|
const ALIAS_PREFIXES = ["/api/workspaces", "/api/ai-routers"];
|
|
/** Spec paths served by middleware rather than a route (Swagger UI). */
|
|
const MIDDLEWARE_PATHS = ["get /api/docs"];
|
|
const METHODS = new Set(["get", "post", "put", "patch", "delete"]);
|
|
|
|
/** `:id`, `:id(\\d+)`, `{id}` all become `{}`; a trailing slash is dropped. */
|
|
function normalize(p) {
|
|
return (
|
|
p
|
|
.replace(/:[A-Za-z_]\w*(\([^)]*\))?\??/g, "{}")
|
|
.replace(/\{[^}]+\}/g, "{}")
|
|
.replace(/\/$/, "") || "/"
|
|
);
|
|
}
|
|
|
|
function collectRoutes(app) {
|
|
const out = new Set();
|
|
const walk = (stack, prefixes) => {
|
|
for (const layer of stack) {
|
|
if (layer.route) {
|
|
const routePaths = [].concat(layer.route.path);
|
|
for (const prefix of prefixes) {
|
|
for (const rp of routePaths) {
|
|
for (const m of Object.keys(layer.route.methods)) {
|
|
if (METHODS.has(m)) out.add(`${m} ${normalize(prefix + rp)}`);
|
|
}
|
|
}
|
|
}
|
|
} else if (layer.handle && Array.isArray(layer.handle.stack)) {
|
|
const mounts = layer.__mount === undefined ? [""] : [].concat(layer.__mount);
|
|
walk(
|
|
layer.handle.stack,
|
|
prefixes.flatMap((a) => mounts.map((b) => a + b))
|
|
);
|
|
}
|
|
}
|
|
};
|
|
walk((app.router || app._router).stack, [""]);
|
|
return [...out].filter((k) => k.split(" ")[1].startsWith("/api"));
|
|
}
|
|
|
|
describe("OpenAPI coverage", () => {
|
|
let routes;
|
|
let documented;
|
|
|
|
before(() => {
|
|
// Server mode mounts the hub's routes too (server/hub/): check both apps.
|
|
const local = collectRoutes(createApp());
|
|
process.env.AGENTHUB_MODE = "server";
|
|
let hub;
|
|
try {
|
|
hub = collectRoutes(createApp());
|
|
} finally {
|
|
delete process.env.AGENTHUB_MODE;
|
|
}
|
|
routes = [...new Set([...local, ...hub])];
|
|
documented = new Set();
|
|
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
|
|
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
|
|
}
|
|
});
|
|
|
|
it("finds the routes it is meant to check", () => {
|
|
assert.ok(routes.length > 300, `only ${routes.length} routes found — the walk is broken`);
|
|
assert.ok(routes.includes("get /api/lanes/{}"));
|
|
});
|
|
|
|
it("documents every registered /api route", () => {
|
|
const missing = routes.filter(
|
|
(k) =>
|
|
!documented.has(k) &&
|
|
!ALIAS_PREFIXES.some((a) => {
|
|
const p = k.split(" ")[1];
|
|
return p === a || p.startsWith(a + "/");
|
|
})
|
|
);
|
|
assert.deepEqual(missing, [], `routes missing from the OpenAPI spec:\n${missing.join("\n")}`);
|
|
});
|
|
|
|
it("documents no operation that no route answers", () => {
|
|
const have = new Set(routes);
|
|
const stale = [...documented].filter((k) => !have.has(k) && !MIDDLEWARE_PATHS.includes(k));
|
|
assert.deepEqual(stale, [], `spec operations with no route:\n${stale.join("\n")}`);
|
|
});
|
|
});
|
|
|
|
process.on("exit", () => {
|
|
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`]) {
|
|
try {
|
|
fs.unlinkSync(f);
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
}
|
|
});
|