@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3446 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_PORT, resolvePort } from "./resolve-port";
|
|
3
|
+
|
|
4
|
+
// The whole reason this module exists.
|
|
5
|
+
test("PORT=0 means an ephemeral port, not the production default", () => {
|
|
6
|
+
expect(resolvePort("0")).toBe(0);
|
|
7
|
+
expect(resolvePort("0")).not.toBe(DEFAULT_PORT);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("unset or empty falls back to the default", () => {
|
|
11
|
+
expect(resolvePort(undefined)).toBe(DEFAULT_PORT);
|
|
12
|
+
expect(resolvePort("")).toBe(DEFAULT_PORT);
|
|
13
|
+
expect(resolvePort(" ")).toBe(DEFAULT_PORT);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("surrounding whitespace is tolerated — it is trimmed, not treated as malformed", () => {
|
|
17
|
+
expect(resolvePort(" 9201 ")).toBe(9201);
|
|
18
|
+
expect(resolvePort("\t0\n")).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("an explicit port is used verbatim", () => {
|
|
22
|
+
expect(resolvePort("9201")).toBe(9201);
|
|
23
|
+
expect(resolvePort("1")).toBe(1);
|
|
24
|
+
expect(resolvePort("65535")).toBe(65535);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Defaulting on a malformed value means a typo silently starts the server on
|
|
28
|
+
// the production port — on this fleet, on top of the running Hub.
|
|
29
|
+
test("a malformed value is rejected, never quietly defaulted", () => {
|
|
30
|
+
for (const bad of ["abc", "80a", "-1", "65536", "1.5", "0x10", "NaN", "Infinity", "9 200", "+80"]) {
|
|
31
|
+
expect(() => resolvePort(bad)).toThrow();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("the rejection names the value and the accepted range", () => {
|
|
36
|
+
try {
|
|
37
|
+
resolvePort("abc");
|
|
38
|
+
throw new Error("should have thrown");
|
|
39
|
+
} catch (e: any) {
|
|
40
|
+
expect(e.message).toContain("abc");
|
|
41
|
+
expect(e.message).toContain("0 and 65535");
|
|
42
|
+
expect(e.message).toContain("ephemeral");
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a caller can override the fallback without touching the default", () => {
|
|
47
|
+
expect(resolvePort(undefined, 3000)).toBe(3000);
|
|
48
|
+
expect(resolvePort("0", 3000)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("server.ts resolves PORT through this module, not through `|| DEFAULT`", () => {
|
|
52
|
+
const src = require("fs").readFileSync(require("path").join(import.meta.dir, "server.ts"), "utf8");
|
|
53
|
+
const code = src.split("\n").filter((l: string) => !l.trim().startsWith("//")).join("\n");
|
|
54
|
+
expect(code).toContain("resolvePort(process.env.PORT)");
|
|
55
|
+
// `Number(env) || default` is the exact shape that swallowed the 0.
|
|
56
|
+
expect(code).not.toMatch(/Number\(process\.env\.PORT\)\s*\|\|/);
|
|
57
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Resolve the listen port from the environment.
|
|
2
|
+
//
|
|
3
|
+
// `Number(process.env.PORT) || 9200` swallows a legitimate `0`. `Number("0")`
|
|
4
|
+
// is `0`, which is falsy, so `PORT=0` — the conventional way to ask the OS for
|
|
5
|
+
// an ephemeral port — silently became 9200, the production Hub port.
|
|
6
|
+
//
|
|
7
|
+
// Three consequences, and the middle one is the worst:
|
|
8
|
+
//
|
|
9
|
+
// 1. On a host where 9200 is already taken (a running Hub), a test that sets
|
|
10
|
+
// PORT=0 dies with EADDRINUSE and reads as a product bug.
|
|
11
|
+
// task-lifecycle-watcher.test.ts fails on main today for exactly this.
|
|
12
|
+
// 2. On a host where 9200 is FREE, the same test passes — by binding 9200.
|
|
13
|
+
// It is green because it grabbed the production port, not because PORT=0
|
|
14
|
+
// worked. Green for the wrong reason is worse than red.
|
|
15
|
+
// 3. Anyone asking for an ephemeral port gets the production port instead.
|
|
16
|
+
//
|
|
17
|
+
// The file already knew: `bootServer` uses `opts.port ?? PORT` with a comment
|
|
18
|
+
// saying `||` "would swallow a legitimate 0". The rule was one level up from
|
|
19
|
+
// where it was needed.
|
|
20
|
+
//
|
|
21
|
+
// A malformed value is rejected rather than defaulted. Falling back to 9200 on
|
|
22
|
+
// `PORT=abc` means a typo silently starts the server somewhere the operator did
|
|
23
|
+
// not ask for — and on this fleet that somewhere is production.
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_PORT = 9200;
|
|
26
|
+
|
|
27
|
+
export function resolvePort(raw: string | undefined, fallback = DEFAULT_PORT): number {
|
|
28
|
+
// Unset or empty means "not specified". An empty string is what a shell
|
|
29
|
+
// exports for an unset variable it still passes along, so treating it as 0
|
|
30
|
+
// would make `PORT= anet hub start` bind an ephemeral port by accident.
|
|
31
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
32
|
+
|
|
33
|
+
// Decimal digits only, after trimming. `Number()` alone accepts "0x10" (16)
|
|
34
|
+
// and " 9200 ", so a value that does not look like a port would still resolve
|
|
35
|
+
// to one — quietly, and to a different number than the operator typed.
|
|
36
|
+
const text = raw.trim();
|
|
37
|
+
const n = /^\d+$/.test(text) ? Number(text) : NaN;
|
|
38
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`PORT must be an integer between 0 and 65535 (0 asks the OS for an ephemeral port); got ${JSON.stringify(raw)}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// #426 unit tests for the withUtf8CharsetContentType helper — the
|
|
2
|
+
// piece that rewrites text-y Content-Type headers to include
|
|
3
|
+
// charset=utf-8 so legacy Windows clients don't fall back to
|
|
4
|
+
// ISO-8859-1 decoding. The helper lives in server/src/index.ts;
|
|
5
|
+
// to keep the test standalone we re-implement its pure semantics here
|
|
6
|
+
// and assert those. Any drift between this pure copy and the
|
|
7
|
+
// production helper is caught by the "identical semantics" test at
|
|
8
|
+
// the bottom via a source grep.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// Pure re-implementation — MUST match server/src/index.ts:withUtf8CharsetContentType.
|
|
15
|
+
function withUtf8CharsetContentType(res: Response): Response {
|
|
16
|
+
const ct = res.headers.get("content-type");
|
|
17
|
+
if (!ct) return res;
|
|
18
|
+
if (/;\s*charset=/i.test(ct)) return res;
|
|
19
|
+
const trimmed = ct.trim();
|
|
20
|
+
if (
|
|
21
|
+
trimmed === "application/json" ||
|
|
22
|
+
trimmed.startsWith("text/") ||
|
|
23
|
+
trimmed.startsWith("application/json;") ||
|
|
24
|
+
trimmed === "application/ld+json" ||
|
|
25
|
+
trimmed === "application/xml"
|
|
26
|
+
) {
|
|
27
|
+
res.headers.set("content-type", `${trimmed}; charset=utf-8`);
|
|
28
|
+
}
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeRes(contentType: string | null, body = ""): Response {
|
|
33
|
+
return new Response(body, { headers: contentType ? { "content-type": contentType } : {} });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("withUtf8CharsetContentType — #426", () => {
|
|
37
|
+
test("appends charset to text/event-stream", () => {
|
|
38
|
+
const res = withUtf8CharsetContentType(makeRes("text/event-stream"));
|
|
39
|
+
expect(res.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("appends charset to application/json (bare)", () => {
|
|
43
|
+
const res = withUtf8CharsetContentType(makeRes("application/json"));
|
|
44
|
+
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("appends charset to text/plain", () => {
|
|
48
|
+
const res = withUtf8CharsetContentType(makeRes("text/plain"));
|
|
49
|
+
expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("appends charset to text/html", () => {
|
|
53
|
+
const res = withUtf8CharsetContentType(makeRes("text/html"));
|
|
54
|
+
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("no-op when charset already present (case-insensitive)", () => {
|
|
58
|
+
for (const ct of [
|
|
59
|
+
"application/json; charset=utf-8",
|
|
60
|
+
"application/json;charset=UTF-8",
|
|
61
|
+
"text/event-stream;Charset=us-ascii",
|
|
62
|
+
"text/plain; CHARSET=iso-8859-1",
|
|
63
|
+
]) {
|
|
64
|
+
const res = withUtf8CharsetContentType(makeRes(ct));
|
|
65
|
+
expect(res.headers.get("content-type")).toBe(ct);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("no-op on binary types (opaque)", () => {
|
|
70
|
+
for (const ct of [
|
|
71
|
+
"application/octet-stream",
|
|
72
|
+
"image/png",
|
|
73
|
+
"image/jpeg",
|
|
74
|
+
"video/mp4",
|
|
75
|
+
"application/pdf",
|
|
76
|
+
"font/woff2",
|
|
77
|
+
]) {
|
|
78
|
+
const res = withUtf8CharsetContentType(makeRes(ct));
|
|
79
|
+
expect(res.headers.get("content-type")).toBe(ct);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("no-op when Content-Type header is absent entirely (returns as-is)", () => {
|
|
84
|
+
const res = withUtf8CharsetContentType(makeRes(null));
|
|
85
|
+
expect(res.headers.get("content-type")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("handles application/ld+json / application/xml", () => {
|
|
89
|
+
expect(withUtf8CharsetContentType(makeRes("application/ld+json")).headers.get("content-type"))
|
|
90
|
+
.toBe("application/ld+json; charset=utf-8");
|
|
91
|
+
expect(withUtf8CharsetContentType(makeRes("application/xml")).headers.get("content-type"))
|
|
92
|
+
.toBe("application/xml; charset=utf-8");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("preserves non-charset parameters and appends charset (application/json; boundary=…)", () => {
|
|
96
|
+
// Bun's Response.json emits `application/json;charset=utf-8`; the test
|
|
97
|
+
// above covers that no-op path. When a caller sends a parametrised
|
|
98
|
+
// header like `application/json; profile=…`, the helper still recognises
|
|
99
|
+
// it via the `application/json;` prefix and appends the charset.
|
|
100
|
+
const res = withUtf8CharsetContentType(makeRes("application/json; profile=example"));
|
|
101
|
+
expect(res.headers.get("content-type")).toBe("application/json; profile=example; charset=utf-8");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns the same Response instance (mutates in place)", () => {
|
|
105
|
+
const input = makeRes("text/event-stream");
|
|
106
|
+
const output = withUtf8CharsetContentType(input);
|
|
107
|
+
expect(output).toBe(input);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Drift guard: the pure copy in this file MUST match the production
|
|
111
|
+
// helper's branching set. If someone edits one without touching the
|
|
112
|
+
// other, this test fails visibly instead of the helper going silently
|
|
113
|
+
// out of sync. Kept as fragment-level greps (not whole-body match) so
|
|
114
|
+
// added comments don't break the assertion.
|
|
115
|
+
test("production helper covers the same content-type branches", () => {
|
|
116
|
+
const src = readFileSync(join(import.meta.dir, "server.ts"), "utf-8");
|
|
117
|
+
expect(src).toContain("function withUtf8CharsetContentType(res: Response): Response {");
|
|
118
|
+
expect(src).toContain("if (/;\\s*charset=/i.test(ct)) return res;");
|
|
119
|
+
for (const branch of [
|
|
120
|
+
'trimmed === "application/json"',
|
|
121
|
+
'trimmed.startsWith("text/")',
|
|
122
|
+
'trimmed.startsWith("application/json;")',
|
|
123
|
+
'trimmed === "application/ld+json"',
|
|
124
|
+
'trimmed === "application/xml"',
|
|
125
|
+
]) {
|
|
126
|
+
expect(src).toContain(branch);
|
|
127
|
+
}
|
|
128
|
+
expect(src).toContain('res.headers.set("content-type", `${trimmed}; charset=utf-8`);');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-rest-columns-"));
|
|
7
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
8
|
+
|
|
9
|
+
let db: typeof import("./db.js").db;
|
|
10
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
11
|
+
let base = "";
|
|
12
|
+
let token = "";
|
|
13
|
+
let networkId = "";
|
|
14
|
+
|
|
15
|
+
const INTERNAL_SENTINEL = "future_internal_only";
|
|
16
|
+
const sortedKeys = (value: Record<string, unknown>): string[] => Object.keys(value).sort();
|
|
17
|
+
const sorted = (values: readonly string[]): string[] => [...values].sort();
|
|
18
|
+
|
|
19
|
+
// Independent wire-contract golden. Do not derive these expectations from
|
|
20
|
+
// rest-projections.ts: doing so makes a deleted projection column disappear
|
|
21
|
+
// from both the response and the expected value, producing a false green.
|
|
22
|
+
const GOLDEN_RESPONSE_KEYS = {
|
|
23
|
+
networkList: [
|
|
24
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
25
|
+
"created_at", "updated_at", "visibility", "max_members", "member_role", "name",
|
|
26
|
+
],
|
|
27
|
+
// 🔴 #1097:GET /api/auth/me 走的是 auth.ts 的 getUserAllNetworks(),
|
|
28
|
+
// 与 GET /api/networks 的 admin 分支**不是同一条查询**。
|
|
29
|
+
// server.ts:985 直接返回原始行,没有 :1166 那个 withNetworkNameAlias,
|
|
30
|
+
// 所以比 networkList 少一个 "name"。这里逐字写死,不从 rest-projections.ts 推导
|
|
31
|
+
// —— 理由见本文件顶部:从投影推导会让被删掉的列同时从响应和期望里消失,产生假绿。
|
|
32
|
+
authMeNetwork: [
|
|
33
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
34
|
+
"created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access",
|
|
35
|
+
],
|
|
36
|
+
networkDetail: [
|
|
37
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
38
|
+
"created_at", "updated_at", "visibility", "max_members",
|
|
39
|
+
],
|
|
40
|
+
statusSession: [
|
|
41
|
+
"resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
|
|
42
|
+
"project_dir", "version", "status", "task", "output", "progress", "score",
|
|
43
|
+
"cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
|
|
44
|
+
"disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
|
|
45
|
+
"process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
|
|
46
|
+
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
47
|
+
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
48
|
+
"external_schedules", "rules_file_capable", "skills_capable", "files_capable", "logs_capable", "runtime", "host", "process_telemetry",
|
|
49
|
+
],
|
|
50
|
+
task: [
|
|
51
|
+
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
|
52
|
+
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
53
|
+
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
|
|
54
|
+
"completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
|
|
55
|
+
"thread_id", "turn_id",
|
|
56
|
+
],
|
|
57
|
+
audit: [
|
|
58
|
+
"id", "user_id", "username", "action", "target_type", "target_id", "detail",
|
|
59
|
+
"ip", "network_id", "created_at",
|
|
60
|
+
],
|
|
61
|
+
taskEvent: [
|
|
62
|
+
"id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
|
|
63
|
+
"created_at", "network_id",
|
|
64
|
+
],
|
|
65
|
+
completion: [
|
|
66
|
+
"id", "session_name", "task", "result", "artifacts", "score",
|
|
67
|
+
"duration_minutes", "network_id", "completed_at",
|
|
68
|
+
],
|
|
69
|
+
scheduledTask: [
|
|
70
|
+
"schedule_id", "network_id", "name", "target_node_id", "target_alias",
|
|
71
|
+
"task_content", "priority", "schedule_type", "timezone", "overlap_policy",
|
|
72
|
+
"misfire_policy", "status", "next_run_at", "last_run_at", "revision",
|
|
73
|
+
"created_at", "updated_at", "schedule",
|
|
74
|
+
],
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
async function api(path: string): Promise<any> {
|
|
78
|
+
const response = await fetch(`${base}${path}`, {
|
|
79
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
80
|
+
});
|
|
81
|
+
expect(response.status).toBe(200);
|
|
82
|
+
return response.json();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
86
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
87
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
88
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
89
|
+
beforeAll(async () => {
|
|
90
|
+
({ db } = await import("./db.js"));
|
|
91
|
+
const { register } = await import("./auth.js");
|
|
92
|
+
const auth = register(`rest_shape_${Date.now()}`, "RestShape-Strong-1!", undefined, "rest-shape");
|
|
93
|
+
expect(auth.ok).toBe(true);
|
|
94
|
+
token = auth.token!;
|
|
95
|
+
networkId = auth.network_id!;
|
|
96
|
+
|
|
97
|
+
// Simulate future migrations. Any REST SELECT * silently broadcasts these
|
|
98
|
+
// columns; every endpoint below must keep them private without needing a
|
|
99
|
+
// code change when the table grows.
|
|
100
|
+
for (const table of ["networks", "sessions", "audit_log", "task_events", "tasks", "completions", "scheduled_tasks"]) {
|
|
101
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${INTERNAL_SENTINEL} TEXT`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
db.run(
|
|
105
|
+
`INSERT INTO sessions (resume_id, alias, status, agent, model, network_id, external_schedules)
|
|
106
|
+
VALUES ('resume-rest-shape', 'rest-shape-node', 'idle', 'agent-node:codex', 'gpt-shape', ?1, ?2)`,
|
|
107
|
+
[networkId, JSON.stringify({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] })],
|
|
108
|
+
);
|
|
109
|
+
db.run(
|
|
110
|
+
`INSERT INTO tasks (task_id, from_name, to_name, status, content, priority, network_id, parent_task_id, meta_json)
|
|
111
|
+
VALUES ('task-rest-shape', 'sender', 'rest-shape-node', 'delivered', 'shape', 'normal', ?1, 'parent-shape', '{"attachment":true}')`,
|
|
112
|
+
[networkId],
|
|
113
|
+
);
|
|
114
|
+
db.run(
|
|
115
|
+
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
116
|
+
VALUES ('task-rest-shape', 'created', 'delivered', 'shape-test', 'shape', ?1)`,
|
|
117
|
+
[networkId],
|
|
118
|
+
);
|
|
119
|
+
db.run(
|
|
120
|
+
`INSERT INTO completions (id, session_name, task, result, network_id)
|
|
121
|
+
VALUES ('completion-rest-shape', 'rest-shape-node', 'shape', 'done', ?1)`,
|
|
122
|
+
[networkId],
|
|
123
|
+
);
|
|
124
|
+
db.run(
|
|
125
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id)
|
|
126
|
+
VALUES ((SELECT owner_id FROM networks WHERE network_id = ?1), 'shape-user', 'shape_action', 'shape', 'shape-id', 'shape', '127.0.0.1', ?1)`,
|
|
127
|
+
[networkId],
|
|
128
|
+
);
|
|
129
|
+
db.run(
|
|
130
|
+
`INSERT INTO scheduled_tasks
|
|
131
|
+
(schedule_id, network_id, created_by, name, target_node_id, target_alias,
|
|
132
|
+
task_content, schedule_type, schedule_json, timezone, next_run_at)
|
|
133
|
+
VALUES ('schedule-rest-shape', ?1, 'private-user-id', 'shape schedule',
|
|
134
|
+
'node-rest-shape', 'rest-shape-node', 'shape task', 'interval',
|
|
135
|
+
'{"type":"interval","every_seconds":3600}', 'UTC', '2099-01-01T00:00:00.000Z')`,
|
|
136
|
+
[networkId],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const mod = await import("./server.js");
|
|
140
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
141
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
142
|
+
}, 30_000);
|
|
143
|
+
|
|
144
|
+
afterAll(() => {
|
|
145
|
+
try { server?.stop(true); } catch {}
|
|
146
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("#311 REST response projections are stable across future ALTER TABLE", () => {
|
|
150
|
+
test("network list and detail preserve their existing public keys", async () => {
|
|
151
|
+
const list = await api("/api/networks");
|
|
152
|
+
const row = list.networks.find((item: any) => item.network_id === networkId);
|
|
153
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkList));
|
|
154
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
155
|
+
|
|
156
|
+
const detail = await api(`/api/networks/${networkId}`);
|
|
157
|
+
expect(sortedKeys(detail.network)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkDetail));
|
|
158
|
+
expect(detail.network[INTERNAL_SENTINEL]).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// 🔴 #1097:这条补的是 test647 抓到的一个**真覆盖洞**。
|
|
162
|
+
// tests/test647-rest-explicit-columns 的 L3b 变异把 auth.ts 的
|
|
163
|
+
// `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role`
|
|
164
|
+
// 改成 `SELECT n.*, …`,而**整套测试仍然全绿** —— 因为 getUserAllNetworks()
|
|
165
|
+
// 只在两处可达:server.ts:985(GET /api/auth/me) 与 server.ts:1165
|
|
166
|
+
// (GET /api/networks 的三元 **else** 分支),而上面那条测试打的是 /api/networks
|
|
167
|
+
// 且它的用户走 **if** 分支(:1156-1162 的内联查询)⇒ 两条可达路径一条都没覆盖。
|
|
168
|
+
test("auth/me networks come from an explicit column list, not SELECT *", async () => {
|
|
169
|
+
const me = await api("/api/auth/me");
|
|
170
|
+
const row = me.networks.find((item: any) => item.network_id === networkId);
|
|
171
|
+
expect(row).toBeDefined();
|
|
172
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.authMeNetwork));
|
|
173
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("full status preserves telemetry compatibility without storage drift", async () => {
|
|
177
|
+
const body = await api(`/api/status?network_id=${networkId}`);
|
|
178
|
+
const row = body.sessions.find((item: any) => item.alias === "rest-shape-node");
|
|
179
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.statusSession));
|
|
180
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
181
|
+
expect(row.external_schedules).toEqual({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("task list and task detail expose the same explicit contract", async () => {
|
|
185
|
+
const list = await api(`/api/tasks?network_id=${networkId}&task_id=task-rest-shape&skip_stats=1`);
|
|
186
|
+
expect(sortedKeys(list.tasks[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
|
|
187
|
+
expect(list.tasks[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
188
|
+
|
|
189
|
+
const detail = await api(`/api/tasks/task-rest-shape?network_id=${networkId}`);
|
|
190
|
+
expect(sortedKeys(detail.task)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
|
|
191
|
+
expect(detail.task[INTERNAL_SENTINEL]).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("audit, task-event, and completion rows are explicit", async () => {
|
|
195
|
+
const audit = await api("/api/audit-log?action=shape_action");
|
|
196
|
+
expect(sortedKeys(audit.logs[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.audit));
|
|
197
|
+
expect(audit.logs[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
198
|
+
|
|
199
|
+
const events = await api(`/api/task_events?network_id=${networkId}&task_id=task-rest-shape`);
|
|
200
|
+
expect(sortedKeys(events.events[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.taskEvent));
|
|
201
|
+
expect(events.events[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
202
|
+
|
|
203
|
+
const completions = await api(`/api/completions?network_id=${networkId}&since=2000-01-01T00:00:00.000Z`);
|
|
204
|
+
expect(sortedKeys(completions.completions[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.completion));
|
|
205
|
+
expect(completions.completions[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("scheduled-task decoding keeps storage-only identity and JSON private", async () => {
|
|
209
|
+
const body = await api(`/api/scheduled-tasks?network_id=${networkId}`);
|
|
210
|
+
const row = body.schedules.find((item: any) => item.schedule_id === "schedule-rest-shape");
|
|
211
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.scheduledTask));
|
|
212
|
+
expect(row.created_by).toBeUndefined();
|
|
213
|
+
expect(row.schedule_json).toBeUndefined();
|
|
214
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// REST from_session identity binding.
|
|
2
|
+
//
|
|
3
|
+
// Run: COMMHUB_DB=/tmp/rest-identity-test.db bun test src/rest-identity.test.ts
|
|
4
|
+
// (COMMHUB_DB must be set explicitly — unset defaults to the production DB.)
|
|
5
|
+
//
|
|
6
|
+
// Assertion discipline: results are compared with toEqual against the whole
|
|
7
|
+
// object, so a widened accepted-set fails instead of silently passing.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { nodeAliasForToken, resolveRestFromSession } from "./rest-identity.js";
|
|
11
|
+
|
|
12
|
+
const NTOK = "ntok_abc123";
|
|
13
|
+
const UTOK = "utok_abc123";
|
|
14
|
+
|
|
15
|
+
describe("REST from_session identity binding", () => {
|
|
16
|
+
test("node token claiming another node's alias is refused", () => {
|
|
17
|
+
expect(resolveRestFromSession({
|
|
18
|
+
token: NTOK,
|
|
19
|
+
tokenName: "node:通信IM牛",
|
|
20
|
+
requestedFrom: "通信龙",
|
|
21
|
+
})).toEqual({
|
|
22
|
+
ok: false,
|
|
23
|
+
error: "from_session_identity_mismatch",
|
|
24
|
+
message: "network token from_session does not match token-bound node alias",
|
|
25
|
+
tokenAlias: "通信IM牛",
|
|
26
|
+
requestedFromSession: "通信龙",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("node token omitting from is bound to its own alias, not 'api'", () => {
|
|
31
|
+
expect(resolveRestFromSession({
|
|
32
|
+
token: NTOK,
|
|
33
|
+
tokenName: "node:通信IM牛",
|
|
34
|
+
requestedFrom: undefined,
|
|
35
|
+
})).toEqual({ ok: true, fromSession: "通信IM牛" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("node token claiming its own alias is accepted", () => {
|
|
39
|
+
expect(resolveRestFromSession({
|
|
40
|
+
token: NTOK,
|
|
41
|
+
tokenName: "node:通信IM牛",
|
|
42
|
+
requestedFrom: "通信IM牛",
|
|
43
|
+
})).toEqual({ ok: true, fromSession: "通信IM牛" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("whitespace padding does not defeat the comparison", () => {
|
|
47
|
+
expect(resolveRestFromSession({
|
|
48
|
+
token: NTOK,
|
|
49
|
+
tokenName: "node:通信IM牛",
|
|
50
|
+
requestedFrom: " 通信IM牛 ",
|
|
51
|
+
})).toEqual({ ok: true, fromSession: "通信IM牛" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// The Dashboard posts as its logged-in user; this path must not change.
|
|
55
|
+
test("user token keeps the previous behaviour", () => {
|
|
56
|
+
expect(resolveRestFromSession({
|
|
57
|
+
token: UTOK,
|
|
58
|
+
tokenName: "dashboard",
|
|
59
|
+
requestedFrom: "admin",
|
|
60
|
+
})).toEqual({ ok: true, fromSession: "admin" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("user token with no from defaults to the authenticated username", () => {
|
|
64
|
+
expect(resolveRestFromSession({
|
|
65
|
+
token: UTOK,
|
|
66
|
+
tokenName: null,
|
|
67
|
+
authenticatedUsername: "admin",
|
|
68
|
+
requestedFrom: undefined,
|
|
69
|
+
})).toEqual({ ok: true, fromSession: "admin" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("legacy user auth without a username still falls back to 'api'", () => {
|
|
73
|
+
expect(resolveRestFromSession({
|
|
74
|
+
token: UTOK,
|
|
75
|
+
tokenName: null,
|
|
76
|
+
requestedFrom: undefined,
|
|
77
|
+
})).toEqual({ ok: true, fromSession: "api" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// A non-string `from` must not be coerced into an identity.
|
|
81
|
+
test("non-string from is not treated as a claim", () => {
|
|
82
|
+
expect(resolveRestFromSession({
|
|
83
|
+
token: NTOK,
|
|
84
|
+
tokenName: "node:通信IM牛",
|
|
85
|
+
requestedFrom: { toString: () => "通信龙" },
|
|
86
|
+
})).toEqual({ ok: true, fromSession: "通信IM牛" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// A network-bound token we cannot resolve to an alias must not be able to
|
|
90
|
+
// call itself anything. Found by independent review of this change.
|
|
91
|
+
describe("ntok_ with an unresolvable alias fails closed", () => {
|
|
92
|
+
for (const tokenName of ["node:", "node: ", "dashboard", null, undefined]) {
|
|
93
|
+
test(`claim is refused when tokenName is ${JSON.stringify(tokenName)}`, () => {
|
|
94
|
+
expect(resolveRestFromSession({
|
|
95
|
+
token: NTOK,
|
|
96
|
+
tokenName,
|
|
97
|
+
requestedFrom: "通信龙",
|
|
98
|
+
})).toEqual({
|
|
99
|
+
ok: false,
|
|
100
|
+
error: "from_session_identity_mismatch",
|
|
101
|
+
message: "network token has no resolvable node alias and may not claim a from_session",
|
|
102
|
+
tokenAlias: "",
|
|
103
|
+
requestedFromSession: "通信龙",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("no claim at all still resolves to 'api'", () => {
|
|
109
|
+
expect(resolveRestFromSession({
|
|
110
|
+
token: NTOK,
|
|
111
|
+
tokenName: "node:",
|
|
112
|
+
requestedFrom: undefined,
|
|
113
|
+
})).toEqual({ ok: true, fromSession: "api" });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("nodeAliasForToken", () => {
|
|
118
|
+
test("only ntok_ + node: prefix yields an alias", () => {
|
|
119
|
+
expect(nodeAliasForToken(NTOK, "node:X")).toBe("X");
|
|
120
|
+
// A user token whose name merely looks node-shaped must not bind.
|
|
121
|
+
expect(nodeAliasForToken(UTOK, "node:X")).toBeNull();
|
|
122
|
+
// A node token whose name is not node-scoped must not bind.
|
|
123
|
+
expect(nodeAliasForToken(NTOK, "dashboard")).toBeNull();
|
|
124
|
+
expect(nodeAliasForToken(NTOK, null)).toBeNull();
|
|
125
|
+
// `node:` with nothing after it is not an identity.
|
|
126
|
+
expect(nodeAliasForToken(NTOK, "node: ")).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|