@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.41
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 +125 -7
- 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-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +107 -13
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -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-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +654 -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 +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -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 +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -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/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +163 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/require-explicit-test-db.ts +9 -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 +212 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -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 +437 -0
- package/src/server.ts +3664 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -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 +229 -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 +88 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +471 -0
- package/src/task-diagnostic-http.test.ts +145 -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 +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2994 -191
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-read-http.test.ts +220 -0
- package/src/user-inbox-schema.test.ts +53 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -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,212 @@
|
|
|
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",
|
|
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", "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
|
+
beforeAll(async () => {
|
|
86
|
+
({ db } = await import("./db.js"));
|
|
87
|
+
const { register } = await import("./auth.js");
|
|
88
|
+
const auth = register(`rest_shape_${Date.now()}`, "RestShape-Strong-1!", undefined, "rest-shape");
|
|
89
|
+
expect(auth.ok).toBe(true);
|
|
90
|
+
token = auth.token!;
|
|
91
|
+
networkId = auth.network_id!;
|
|
92
|
+
|
|
93
|
+
// Simulate future migrations. Any REST SELECT * silently broadcasts these
|
|
94
|
+
// columns; every endpoint below must keep them private without needing a
|
|
95
|
+
// code change when the table grows.
|
|
96
|
+
for (const table of ["networks", "sessions", "audit_log", "task_events", "tasks", "completions", "scheduled_tasks"]) {
|
|
97
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${INTERNAL_SENTINEL} TEXT`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
db.run(
|
|
101
|
+
`INSERT INTO sessions (resume_id, alias, status, agent, model, network_id, external_schedules)
|
|
102
|
+
VALUES ('resume-rest-shape', 'rest-shape-node', 'idle', 'agent-node:codex', 'gpt-shape', ?1, ?2)`,
|
|
103
|
+
[networkId, JSON.stringify({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] })],
|
|
104
|
+
);
|
|
105
|
+
db.run(
|
|
106
|
+
`INSERT INTO tasks (task_id, from_name, to_name, status, content, priority, network_id, parent_task_id, meta_json)
|
|
107
|
+
VALUES ('task-rest-shape', 'sender', 'rest-shape-node', 'delivered', 'shape', 'normal', ?1, 'parent-shape', '{"attachment":true}')`,
|
|
108
|
+
[networkId],
|
|
109
|
+
);
|
|
110
|
+
db.run(
|
|
111
|
+
`INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
|
|
112
|
+
VALUES ('task-rest-shape', 'created', 'delivered', 'shape-test', 'shape', ?1)`,
|
|
113
|
+
[networkId],
|
|
114
|
+
);
|
|
115
|
+
db.run(
|
|
116
|
+
`INSERT INTO completions (id, session_name, task, result, network_id)
|
|
117
|
+
VALUES ('completion-rest-shape', 'rest-shape-node', 'shape', 'done', ?1)`,
|
|
118
|
+
[networkId],
|
|
119
|
+
);
|
|
120
|
+
db.run(
|
|
121
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id)
|
|
122
|
+
VALUES ((SELECT owner_id FROM networks WHERE network_id = ?1), 'shape-user', 'shape_action', 'shape', 'shape-id', 'shape', '127.0.0.1', ?1)`,
|
|
123
|
+
[networkId],
|
|
124
|
+
);
|
|
125
|
+
db.run(
|
|
126
|
+
`INSERT INTO scheduled_tasks
|
|
127
|
+
(schedule_id, network_id, created_by, name, target_node_id, target_alias,
|
|
128
|
+
task_content, schedule_type, schedule_json, timezone, next_run_at)
|
|
129
|
+
VALUES ('schedule-rest-shape', ?1, 'private-user-id', 'shape schedule',
|
|
130
|
+
'node-rest-shape', 'rest-shape-node', 'shape task', 'interval',
|
|
131
|
+
'{"type":"interval","every_seconds":3600}', 'UTC', '2099-01-01T00:00:00.000Z')`,
|
|
132
|
+
[networkId],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const mod = await import("./server.js");
|
|
136
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
137
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterAll(() => {
|
|
141
|
+
try { server?.stop(true); } catch {}
|
|
142
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("#311 REST response projections are stable across future ALTER TABLE", () => {
|
|
146
|
+
test("network list and detail preserve their existing public keys", async () => {
|
|
147
|
+
const list = await api("/api/networks");
|
|
148
|
+
const row = list.networks.find((item: any) => item.network_id === networkId);
|
|
149
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkList));
|
|
150
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
151
|
+
|
|
152
|
+
const detail = await api(`/api/networks/${networkId}`);
|
|
153
|
+
expect(sortedKeys(detail.network)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkDetail));
|
|
154
|
+
expect(detail.network[INTERNAL_SENTINEL]).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// 🔴 #1097:这条补的是 test647 抓到的一个**真覆盖洞**。
|
|
158
|
+
// tests/test647-rest-explicit-columns 的 L3b 变异把 auth.ts 的
|
|
159
|
+
// `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role`
|
|
160
|
+
// 改成 `SELECT n.*, …`,而**整套测试仍然全绿** —— 因为 getUserAllNetworks()
|
|
161
|
+
// 只在两处可达:server.ts:985(GET /api/auth/me) 与 server.ts:1165
|
|
162
|
+
// (GET /api/networks 的三元 **else** 分支),而上面那条测试打的是 /api/networks
|
|
163
|
+
// 且它的用户走 **if** 分支(:1156-1162 的内联查询)⇒ 两条可达路径一条都没覆盖。
|
|
164
|
+
test("auth/me networks come from an explicit column list, not SELECT *", async () => {
|
|
165
|
+
const me = await api("/api/auth/me");
|
|
166
|
+
const row = me.networks.find((item: any) => item.network_id === networkId);
|
|
167
|
+
expect(row).toBeDefined();
|
|
168
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.authMeNetwork));
|
|
169
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("full status preserves telemetry compatibility without storage drift", async () => {
|
|
173
|
+
const body = await api(`/api/status?network_id=${networkId}`);
|
|
174
|
+
const row = body.sessions.find((item: any) => item.alias === "rest-shape-node");
|
|
175
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.statusSession));
|
|
176
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
177
|
+
expect(row.external_schedules).toEqual({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("task list and task detail expose the same explicit contract", async () => {
|
|
181
|
+
const list = await api(`/api/tasks?network_id=${networkId}&task_id=task-rest-shape&skip_stats=1`);
|
|
182
|
+
expect(sortedKeys(list.tasks[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
|
|
183
|
+
expect(list.tasks[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
184
|
+
|
|
185
|
+
const detail = await api(`/api/tasks/task-rest-shape?network_id=${networkId}`);
|
|
186
|
+
expect(sortedKeys(detail.task)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
|
|
187
|
+
expect(detail.task[INTERNAL_SENTINEL]).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("audit, task-event, and completion rows are explicit", async () => {
|
|
191
|
+
const audit = await api("/api/audit-log?action=shape_action");
|
|
192
|
+
expect(sortedKeys(audit.logs[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.audit));
|
|
193
|
+
expect(audit.logs[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
194
|
+
|
|
195
|
+
const events = await api(`/api/task_events?network_id=${networkId}&task_id=task-rest-shape`);
|
|
196
|
+
expect(sortedKeys(events.events[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.taskEvent));
|
|
197
|
+
expect(events.events[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
198
|
+
|
|
199
|
+
const completions = await api(`/api/completions?network_id=${networkId}&since=2000-01-01T00:00:00.000Z`);
|
|
200
|
+
expect(sortedKeys(completions.completions[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.completion));
|
|
201
|
+
expect(completions.completions[0][INTERNAL_SENTINEL]).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("scheduled-task decoding keeps storage-only identity and JSON private", async () => {
|
|
205
|
+
const body = await api(`/api/scheduled-tasks?network_id=${networkId}`);
|
|
206
|
+
const row = body.schedules.find((item: any) => item.schedule_id === "schedule-rest-shape");
|
|
207
|
+
expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.scheduledTask));
|
|
208
|
+
expect(row.created_by).toBeUndefined();
|
|
209
|
+
expect(row.schedule_json).toBeUndefined();
|
|
210
|
+
expect(row[INTERNAL_SENTINEL]).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// REST from_session identity binding.
|
|
2
|
+
//
|
|
3
|
+
// Both transports resolve the same `api_tokens` row through resolveToken, and
|
|
4
|
+
// auth.ts already states the invariant:
|
|
5
|
+
//
|
|
6
|
+
// "Network-bound node tokens are an identity boundary: they must not spoof
|
|
7
|
+
// another node via from_session."
|
|
8
|
+
//
|
|
9
|
+
// tools.ts enforced it for MCP (defaultFrom + fromIdentityMismatchReply).
|
|
10
|
+
// POST /api/task did not — it took `body.from` verbatim, so any node holding
|
|
11
|
+
// its own ntok_ could claim another node's alias. That value is stored on the
|
|
12
|
+
// task row and, once sender labels reach the copresence TUIs, rendered to a
|
|
13
|
+
// human as an attribution.
|
|
14
|
+
//
|
|
15
|
+
// This is the same decision expressed as a pure function so every credential
|
|
16
|
+
// kind is a directly constructible test input rather than a hand-assembled
|
|
17
|
+
// Request (the #503 convention in this codebase).
|
|
18
|
+
|
|
19
|
+
export interface RestIdentityInput {
|
|
20
|
+
/** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
|
|
21
|
+
token: string;
|
|
22
|
+
/** `api_tokens.name`; for node tokens it is `node:<alias>`. */
|
|
23
|
+
tokenName: string | null | undefined;
|
|
24
|
+
/** Username resolved from the authenticated token. */
|
|
25
|
+
authenticatedUsername?: string | null;
|
|
26
|
+
/** Caller-supplied `body.from`. */
|
|
27
|
+
requestedFrom: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type RestIdentityResult =
|
|
31
|
+
| { ok: true; fromSession: string }
|
|
32
|
+
| {
|
|
33
|
+
ok: false;
|
|
34
|
+
error: "from_session_identity_mismatch";
|
|
35
|
+
message: string;
|
|
36
|
+
tokenAlias: string;
|
|
37
|
+
requestedFromSession: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Alias a token is bound to, or null when it is not node-scoped. */
|
|
41
|
+
export function nodeAliasForToken(token: string, tokenName: string | null | undefined): string | null {
|
|
42
|
+
if (!token.startsWith("ntok_")) return null;
|
|
43
|
+
if (typeof tokenName !== "string" || !tokenName.startsWith("node:")) return null;
|
|
44
|
+
const alias = tokenName.slice("node:".length).trim();
|
|
45
|
+
return alias || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
|
|
49
|
+
const alias = nodeAliasForToken(input.token, input.tokenName);
|
|
50
|
+
const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
|
|
51
|
+
|
|
52
|
+
// A network-bound token whose name we cannot resolve to an alias (`node:`,
|
|
53
|
+
// `node: `, or a name that was never node-scoped) is the degenerate case.
|
|
54
|
+
// We know it IS a node credential; we just cannot tell WHICH node. Letting it
|
|
55
|
+
// claim anything is the worst available option, so refuse the claim outright
|
|
56
|
+
// rather than falling through to the unbound path.
|
|
57
|
+
//
|
|
58
|
+
// Production always writes `node:${nodeName}`, so this costs nothing real —
|
|
59
|
+
// but "we couldn't identify you, so you may call yourself anything" is
|
|
60
|
+
// exactly the fail-open shape this change exists to remove.
|
|
61
|
+
if (!alias && input.token.startsWith("ntok_")) {
|
|
62
|
+
if (requested) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: "from_session_identity_mismatch",
|
|
66
|
+
message: "network token has no resolvable node alias and may not claim a from_session",
|
|
67
|
+
tokenAlias: "",
|
|
68
|
+
requestedFromSession: requested,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, fromSession: "api" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A user-token client normally does not need to repeat its identity in every
|
|
75
|
+
// request. Prefer an explicit, backwards-compatible claim when present;
|
|
76
|
+
// otherwise attribute the task to the authenticated username. Falling back
|
|
77
|
+
// to the transport label `api` is reserved for legacy auth contexts where
|
|
78
|
+
// the server genuinely has no user identity.
|
|
79
|
+
if (!alias) {
|
|
80
|
+
const authenticatedUsername = typeof input.authenticatedUsername === "string"
|
|
81
|
+
? input.authenticatedUsername.trim()
|
|
82
|
+
: "";
|
|
83
|
+
return { ok: true, fromSession: requested || authenticatedUsername || "api" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Node token claiming somebody else — refuse rather than silently rewrite,
|
|
87
|
+
// so a misconfigured caller is visible instead of quietly relabelled.
|
|
88
|
+
if (requested && requested !== alias) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: "from_session_identity_mismatch",
|
|
92
|
+
message: "network token from_session does not match token-bound node alias",
|
|
93
|
+
tokenAlias: alias,
|
|
94
|
+
requestedFromSession: requested,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Node token, no claim or a matching one: bind to the token's own alias.
|
|
99
|
+
return { ok: true, fromSession: alias };
|
|
100
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable REST row projections.
|
|
3
|
+
*
|
|
4
|
+
* These lists deliberately mirror the fields already exposed by the public
|
|
5
|
+
* endpoints before #311. Keeping them in one place makes the wire contract
|
|
6
|
+
* independent from future ALTER TABLE migrations: adding a storage-only
|
|
7
|
+
* column must never silently add a REST response key.
|
|
8
|
+
*/
|
|
9
|
+
export const NETWORK_REST_COLUMNS = [
|
|
10
|
+
"network_id", "network_name", "owner_id", "description", "settings",
|
|
11
|
+
"created_at", "updated_at", "visibility", "max_members",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export const SESSION_REST_COLUMNS = [
|
|
15
|
+
"resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
|
|
16
|
+
"project_dir", "version", "status", "task", "output", "progress", "score",
|
|
17
|
+
"cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
|
|
18
|
+
"disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
|
|
19
|
+
"process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
|
|
20
|
+
"process_in_flight_count", "network_id", "registered_at", "updated_at",
|
|
21
|
+
"node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
|
|
22
|
+
"external_schedules",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export const AUDIT_LOG_REST_COLUMNS = [
|
|
26
|
+
"id", "user_id", "username", "action", "target_type", "target_id", "detail",
|
|
27
|
+
"ip", "network_id", "created_at",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export const TASK_EVENT_REST_COLUMNS = [
|
|
31
|
+
"id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
|
|
32
|
+
"created_at", "network_id",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export const TASK_REST_COLUMNS = [
|
|
36
|
+
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
|
37
|
+
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
38
|
+
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
|
|
39
|
+
"network_id", "parent_task_id", "meta_json", "thread_id", "turn_id",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export const COMPLETION_REST_COLUMNS = [
|
|
43
|
+
"id", "session_name", "task", "result", "artifacts", "score",
|
|
44
|
+
"duration_minutes", "network_id", "completed_at",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
// Storage projection for scheduled-task rows. `created_by` and
|
|
48
|
+
// `schedule_json` are intentionally selected for server-side auth/decoding,
|
|
49
|
+
// then removed by decodeRow before the REST response is serialized.
|
|
50
|
+
export const SCHEDULED_TASK_STORAGE_COLUMNS = [
|
|
51
|
+
"schedule_id", "network_id", "created_by", "name", "target_node_id",
|
|
52
|
+
"target_alias", "task_content", "priority", "schedule_type", "schedule_json",
|
|
53
|
+
"timezone", "overlap_policy", "misfire_policy", "status", "next_run_at",
|
|
54
|
+
"last_run_at", "revision", "created_at", "updated_at",
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
export function sqlColumns(columns: readonly string[], qualifier?: string): string {
|
|
58
|
+
return columns.map((column) => qualifier ? `${qualifier}.${column}` : column).join(", ");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const NETWORK_REST_SELECT = sqlColumns(NETWORK_REST_COLUMNS);
|
|
62
|
+
export const SESSION_REST_SELECT = sqlColumns(SESSION_REST_COLUMNS);
|
|
63
|
+
export const AUDIT_LOG_REST_SELECT = sqlColumns(AUDIT_LOG_REST_COLUMNS);
|
|
64
|
+
export const TASK_EVENT_REST_SELECT = sqlColumns(TASK_EVENT_REST_COLUMNS);
|
|
65
|
+
export const TASK_REST_SELECT = sqlColumns(TASK_REST_COLUMNS);
|
|
66
|
+
export const COMPLETION_REST_SELECT = sqlColumns(COMPLETION_REST_COLUMNS);
|
|
67
|
+
export const SCHEDULED_TASK_STORAGE_SELECT = sqlColumns(SCHEDULED_TASK_STORAGE_COLUMNS);
|