@sleep2agi/commhub-server 0.9.0-preview.139 → 0.9.0-preview.140

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/agent-department-http.test.ts +148 -0
  3. package/src/chain-attempt-dedup.test.ts +447 -0
  4. package/src/create-node-tool-schema.test.ts +1 -0
  5. package/src/create-node-validate.test.ts +6 -0
  6. package/src/create-node-validate.ts +7 -2
  7. package/src/db.ts +190 -21
  8. package/src/departments.ts +16 -0
  9. package/src/env-transport-gate-http.test.ts +146 -0
  10. package/src/env-transport-gate.ts +74 -0
  11. package/src/env-transport-policy.ts +21 -0
  12. package/src/host-supervisor-identity-http.test.ts +20 -4
  13. package/src/hub-catalog-read-http.test.ts +187 -0
  14. package/src/node-secret-rest-http.test.ts +190 -0
  15. package/src/observer-avatar-http.test.ts +13 -2
  16. package/src/report-status-resume-id-handover.test.ts +34 -0
  17. package/src/report-status-secret-keys.test.ts +140 -0
  18. package/src/requirements-http.test.ts +1 -1
  19. package/src/requirements-people.ts +8 -4
  20. package/src/rest-projections.ts +2 -0
  21. package/src/schedule-agent.ts +1 -1
  22. package/src/scheduled-backoff-http.test.ts +193 -0
  23. package/src/scheduled-backoff.ts +64 -0
  24. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  25. package/src/scheduled-outcome-guidance.ts +9 -0
  26. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  27. package/src/scheduled-requirement-wake.ts +125 -0
  28. package/src/scheduled-tasks.ts +78 -23
  29. package/src/scheduled-work-outcome-http.test.ts +154 -0
  30. package/src/scheduled-work-outcome.ts +50 -0
  31. package/src/scoped-secrets.ts +15 -0
  32. package/src/server.ts +162 -3
  33. package/src/tools.ts +99 -16
@@ -0,0 +1,187 @@
1
+ // #1473 (#909) — the Hub's Skills / secrets / Provider catalogs had MCP tools
2
+ // but no HTTP read surface, so the desktop client shipped a fixture page
3
+ // (agent-network-app/src/hub-scope-demo.ts: "Hub 这些写接口还没接通 ... 不发请求").
4
+ //
5
+ // These three endpoints are read-only and user-token only. The load-bearing
6
+ // properties are:
7
+ // * a NODE token cannot enumerate the network's catalogs;
8
+ // * a plain member of another network cannot read this network's catalogs;
9
+ // * pending SkillHub submissions stay invisible unless the caller reviews;
10
+ // * NO secret value ever appears in a response body (asserted with a
11
+ // sentinel that would be trivially visible if a value were serialized).
12
+ //
13
+ // The sentinel test is deliberately byte-level on the raw response text: a
14
+ // future field like `value_preview` would pass a JSON-shape assertion while
15
+ // still leaking the secret.
16
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-hub-catalog-"));
22
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
23
+ // The vault encrypts on write; set the master key before db.ts/vault.ts load.
24
+ process.env.ANET_HUB_SECRET_VAULT_KEY ||= "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
25
+
26
+ let server: ReturnType<typeof Bun.serve>;
27
+ let base = "";
28
+ let ownerToken = "", strangerToken = "", nodeToken = "";
29
+ let ownerNet = "", strangerNet = "", ownerUserId = "";
30
+ const SECRET_NAME = "PROVIDER_SENTINEL_KEY";
31
+ const SECRET_VALUE = "sentinel-value-DO-NOT-LEAK-4f9a2c";
32
+
33
+ const get = async (path: string, token: string) => {
34
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
35
+ const text = await r.text();
36
+ let body: any = null;
37
+ try { body = JSON.parse(text); } catch { body = null; }
38
+ return { status: r.status, body, text };
39
+ };
40
+
41
+ beforeAll(async () => {
42
+ const { db } = await import("./db.js");
43
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
44
+ const { vaultUpsert } = await import("./vault.js");
45
+ const stamp = Date.now();
46
+
47
+ const owner = register(`hc_owner_${stamp}`, "HcOwner-Strong-1!");
48
+ const stranger = register(`hc_stranger_${stamp}`, "HcStranger-Strong-1!");
49
+ expect(owner.ok && stranger.ok).toBe(true);
50
+ ownerToken = owner.token!;
51
+ strangerToken = stranger.token!;
52
+ ownerNet = owner.network_id!;
53
+ strangerNet = stranger.network_id!;
54
+ ownerUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `hc_owner_${stamp}`)!.user_id;
55
+
56
+ // A node token in the SAME network: it must still be refused, because a node
57
+ // is not a person and may not enumerate catalogs.
58
+ const nodeTok = createNetworkTokenForNode(ownerUserId, ownerNet, "hc-node", "n_hc_test");
59
+ expect(nodeTok.ok).toBe(true);
60
+ nodeToken = nodeTok.token!;
61
+
62
+ // Published + pending skills in the owner's network.
63
+ const skill = (id: string, status: string, slug: string) => db.run(
64
+ `INSERT INTO skillhub_skills (skill_id, network_id, slug, name, description, content, content_hash, version, status, source_type, source_alias, created_at, updated_at)
65
+ VALUES (?1, ?2, ?3, ?3, 'd', '# SKILL body', 'hash-' || ?1, '1.0.0', ?4, 'node', 'hc-node', datetime('now'), datetime('now'))`,
66
+ [id, ownerNet, slug, status],
67
+ );
68
+ skill("skill_hc_pub", "published", "hc-published");
69
+ skill("skill_hc_pending", "pending", "hc-pending");
70
+
71
+ // The sentinel secret. Names may be shown; values never.
72
+ vaultUpsert(ownerNet, SECRET_NAME, SECRET_VALUE);
73
+
74
+ db.run(
75
+ `INSERT INTO hub_provider_configs (network_id, provider_id, revision, config_json, secret_key_ref, updated_at)
76
+ VALUES (?1, 'hc-provider', 3, ?2, ?3, ?4)`,
77
+ [ownerNet, JSON.stringify({ id: "hc-provider", label: "HC Provider", enabled: true, protocol: "openai-responses", baseUrl: "https://example.test/v1", models: ["m1"] }), "HC_PROVIDER_KEY", Date.now()],
78
+ );
79
+
80
+ const mod = await import("./server.js");
81
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
82
+ base = `http://127.0.0.1:${server.port}`;
83
+ });
84
+
85
+ afterAll(() => {
86
+ try { server?.stop(true); } catch {}
87
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
88
+ });
89
+
90
+ describe("GET /api/skills", () => {
91
+ test("owner sees published skills and no content body", async () => {
92
+ const r = await get(`/api/skills?network_id=${ownerNet}`, ownerToken);
93
+ expect(r.status).toBe(200);
94
+ expect(r.body.ok).toBe(true);
95
+ expect(r.body.reviewer).toBe(true);
96
+ expect(r.body.network_id).toBe(ownerNet);
97
+ const slugs = r.body.skills.map((s: any) => s.slug);
98
+ expect(slugs).toContain("hc-published");
99
+ // Pending is opt-in even for a reviewer.
100
+ expect(slugs).not.toContain("hc-pending");
101
+ // The list is metadata only — a body field would mean SKILL.md text leaked.
102
+ for (const s of r.body.skills) expect(s.content).toBeUndefined();
103
+ });
104
+
105
+ test("reviewer may ask for pending submissions", async () => {
106
+ const r = await get(`/api/skills?network_id=${ownerNet}&include_pending=1`, ownerToken);
107
+ expect(r.status).toBe(200);
108
+ const slugs = r.body.skills.map((s: any) => s.slug);
109
+ expect(slugs).toContain("hc-pending");
110
+ });
111
+
112
+ test("a member of another network is refused", async () => {
113
+ const r = await get(`/api/skills?network_id=${ownerNet}`, strangerToken);
114
+ expect(r.status).toBe(403);
115
+ // Cross-network access is refused by REST scope resolution BEFORE the role
116
+ // check runs, so the error names the scope gate. Either way: 403, no data.
117
+ expect(String(r.body.error)).toMatch(/access denied|permission_denied/);
118
+ });
119
+
120
+ test("a node token is refused even inside its own network", async () => {
121
+ const r = await get(`/api/skills?network_id=${ownerNet}`, nodeToken);
122
+ expect(r.status).toBe(403);
123
+ expect(r.body.error).toBe("user_token_required");
124
+ });
125
+
126
+ test("include_pending is ignored for a non-reviewer of that network", async () => {
127
+ // The stranger's own network has no skills; the point is that asking for
128
+ // pending must not widen what they can see when they ask about their own.
129
+ const r = await get(`/api/skills?network_id=${strangerNet}&include_pending=1`, strangerToken);
130
+ expect(r.status).toBe(200);
131
+ expect(r.body.reviewer).toBe(true); // they own their own network
132
+ expect(r.body.skills).toEqual([]);
133
+ });
134
+ });
135
+
136
+ describe("GET /api/secrets", () => {
137
+ test("returns key names and timestamps, never the value", async () => {
138
+ const r = await get(`/api/secrets?network_id=${ownerNet}`, ownerToken);
139
+ expect(r.status).toBe(200);
140
+ const names = r.body.keys.map((k: any) => k.key);
141
+ expect(names).toContain(SECRET_NAME);
142
+ // <-- The load-bearing assertion: raw body text must not contain the value.
143
+ expect(r.text).not.toContain(SECRET_VALUE);
144
+ expect(r.text).not.toContain("ciphertext");
145
+ expect(r.text).not.toContain("iv");
146
+ expect(r.text).not.toContain("tag");
147
+ });
148
+
149
+ test("a member of another network is refused and learns no key names", async () => {
150
+ const r = await get(`/api/secrets?network_id=${ownerNet}`, strangerToken);
151
+ expect(r.status).toBe(403);
152
+ expect(r.text).not.toContain(SECRET_NAME);
153
+ expect(r.text).not.toContain(SECRET_VALUE);
154
+ });
155
+
156
+ test("a node token is refused", async () => {
157
+ const r = await get(`/api/secrets?network_id=${ownerNet}`, nodeToken);
158
+ expect(r.status).toBe(403);
159
+ expect(r.body.error).toBe("user_token_required");
160
+ });
161
+ });
162
+
163
+ describe("GET /api/providers", () => {
164
+ test("returns the registry without secret references", async () => {
165
+ const r = await get(`/api/providers?network_id=${ownerNet}`, ownerToken);
166
+ expect(r.status).toBe(200);
167
+ expect(r.body.providers).toHaveLength(1);
168
+ expect(r.body.providers[0]).toMatchObject({ id: "hc-provider", label: "HC Provider", source: "hub" });
169
+ // The vault key NAME is a hint an attacker does not need; `exposeRefs` is
170
+ // false here on purpose.
171
+ expect(r.body.providers[0].secret_key_ref).toBeUndefined();
172
+ expect(r.text).not.toContain("HC_PROVIDER_KEY");
173
+ expect(r.text).not.toContain(SECRET_VALUE);
174
+ });
175
+
176
+ test("a member of another network is refused", async () => {
177
+ const r = await get(`/api/providers?network_id=${ownerNet}`, strangerToken);
178
+ expect(r.status).toBe(403);
179
+ expect(r.text).not.toContain("hc-provider");
180
+ });
181
+
182
+ test("a node token is refused", async () => {
183
+ const r = await get(`/api/providers?network_id=${ownerNet}`, nodeToken);
184
+ expect(r.status).toBe(403);
185
+ expect(r.body.error).toBe("user_token_required");
186
+ });
187
+ });
@@ -0,0 +1,190 @@
1
+ // #1491 E1 — the node-secret lane over REST.
2
+ //
3
+ // The lane itself is old (tools.ts `node_secret_*` → scoped-secrets.ts → node
4
+ // doorbell → agent-node `reconcileNodeSecrets`). What was missing is **any way
5
+ // for the client to drive it**: MCP-only, request/ack shaped. These two routes
6
+ // are deliberately thin — enqueue, then poll — and the authorization stays in
7
+ // the lane (owner/admin), not duplicated here.
8
+ //
9
+ // Load-bearing assertions:
10
+ // * a plain member of the network is refused (403), an owner gets through;
11
+ // * a node that does not advertise `node_secrets_capable` is a 409, not a
12
+ // silent queue entry that expires 120s later;
13
+ // * `put`/`delete` without a revision is a 400 (inventing 0 would clobber a
14
+ // file the operator edited in between);
15
+ // * the poll returns the node's snapshot once it acks — key NAMES and a
16
+ // revision only, never a value.
17
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { mkdtempSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-nodesecret-rest-"));
23
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
24
+
25
+ let server: ReturnType<typeof Bun.serve>;
26
+ let base = "";
27
+ let ownerToken = "", memberToken = "";
28
+ let net = "", ownerId = "", memberId = "";
29
+ const CAPABLE = "n_capable";
30
+ const PLAIN = "n_plain";
31
+
32
+ const post = async (path: string, token: string, body: unknown) => {
33
+ const r = await fetch(`${base}${path}`, {
34
+ method: "POST",
35
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
36
+ body: JSON.stringify(body),
37
+ });
38
+ return { status: r.status, body: await r.json() as any };
39
+ };
40
+ const get = async (path: string, token: string) => {
41
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
42
+ return { status: r.status, body: await r.json() as any };
43
+ };
44
+
45
+ beforeAll(async () => {
46
+ const { db } = await import("./db.js");
47
+ const { register, addNetworkMember } = await import("./auth.js");
48
+ const stamp = Date.now();
49
+
50
+ const owner = register(`ns_owner_${stamp}`, "NsOwner-Strong-1!");
51
+ const member = register(`ns_member_${stamp}`, "NsMember-Strong-1!");
52
+ expect(owner.ok && member.ok).toBe(true);
53
+ ownerToken = owner.token!;
54
+ memberToken = member.token!;
55
+ net = owner.network_id!;
56
+ ownerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ns_owner_${stamp}`)!.user_id;
57
+ memberId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ns_member_${stamp}`)!.user_id;
58
+ // A plain member of the same network: must still be refused by the lane.
59
+ expect(addNetworkMember(net, memberId, "member").ok).toBe(true);
60
+
61
+ const seedNode = (nodeId: string, capable: boolean) => db.run(
62
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, lifecycle_state, config_snapshot)
63
+ VALUES (?1, ?1, ?1, ?2, 'active', ?3)`,
64
+ [nodeId, net, JSON.stringify(capable ? { node_secrets_capable: true } : {})],
65
+ );
66
+ seedNode(CAPABLE, true);
67
+ seedNode(PLAIN, false);
68
+
69
+ const mod = await import("./server.js");
70
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
71
+ base = `http://127.0.0.1:${server.port}`;
72
+ });
73
+
74
+ afterAll(() => {
75
+ try { server?.stop(true); } catch {}
76
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
77
+ });
78
+
79
+ /** The lane allows ONE pending request per node. Every test starts from a clean
80
+ * queue, otherwise ordering decides the outcome (the first version of this file
81
+ * went red exactly that way: a `list` from an earlier test left the node busy). */
82
+ async function drainPending() {
83
+ const { pullNodeSecrets, ackNodeSecrets } = await import("./scoped-secrets.js");
84
+ const daemon = { ok: true as const, networkId: net, daemonNodeId: CAPABLE, daemonAlias: CAPABLE };
85
+ const pulled = pullNodeSecrets(daemon);
86
+ const list = pulled && Array.isArray((pulled as any).requests) ? (pulled as any).requests : [];
87
+ for (const r of list) ackNodeSecrets(daemon, r.request_id, null, true);
88
+ }
89
+
90
+ beforeEach(async () => { await drainPending(); });
91
+
92
+ describe("POST /api/nodes/:id/secrets", () => {
93
+ test("owner can enqueue a list request", async () => {
94
+ const r = await post(`/api/nodes/${CAPABLE}/secrets?network_id=${net}`, ownerToken, { action: "list" });
95
+ expect(r.status).toBe(200);
96
+ expect(r.body.ok).toBe(true);
97
+ expect(String(r.body.request_id)).toStartWith("nsk_");
98
+ });
99
+
100
+ test("a plain member of the same network is refused (403)", async () => {
101
+ const r = await post(`/api/nodes/${CAPABLE}/secrets?network_id=${net}`, memberToken, { action: "list" });
102
+ expect(r.status).toBe(403);
103
+ expect(r.body.error).toBe("secret_admin_required");
104
+ });
105
+
106
+ test("a node without node_secrets_capable is a 409, not a queued request", async () => {
107
+ const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "list" });
108
+ expect(r.status).toBe(409);
109
+ expect(r.body.error).toBe("node_secrets_upgrade_required");
110
+ });
111
+
112
+ test("put without a revision is a 400", async () => {
113
+ const r = await post(`/api/nodes/${CAPABLE}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "A_KEY", value: "v" });
114
+ expect(r.status).toBe(400);
115
+ expect(r.body.error).toBe("revision_required");
116
+ });
117
+
118
+ test("an unknown action is a 400", async () => {
119
+ const r = await post(`/api/nodes/${CAPABLE}/secrets?network_id=${net}`, ownerToken, { action: "wipe" });
120
+ expect(r.status).toBe(400);
121
+ expect(r.body.error).toBe("invalid_action");
122
+ });
123
+
124
+ test("a second pending request on the same node is a 409 (secret_store_busy)", async () => {
125
+ // The previous test cases left requests pending; the lane allows one at a
126
+ // time per node. Drain by acking through the lane's own API.
127
+ const { ackNodeSecrets } = await import("./scoped-secrets.js");
128
+ const { db } = await import("./db.js");
129
+ const pending = db.all<any>("SELECT 1"); // no-op, keeps the import honest
130
+ expect(Array.isArray(pending)).toBe(true);
131
+ const first = await post(`/api/nodes/${CAPABLE}/secrets?network_id=${net}`, ownerToken, { action: "list" });
132
+ if (first.status === 200) {
133
+ const second = await post(`/api/nodes/${CAPABLE}/secrets?network_id=${net}`, ownerToken, { action: "list" });
134
+ expect(second.status).toBe(409);
135
+ expect(second.body.error).toBe("secret_store_busy");
136
+ // Clean up so the poll test below starts from a known state.
137
+ ackNodeSecrets(
138
+ { ok: true, networkId: net, daemonNodeId: CAPABLE, daemonAlias: CAPABLE },
139
+ first.body.request_id, { network_id: net, daemon_node_id: null, node_id: CAPABLE, revision: 1, activation: "next_start", keys: [] }, true,
140
+ );
141
+ } else {
142
+ expect(first.status).toBe(409);
143
+ }
144
+ });
145
+ });
146
+
147
+ describe("GET /api/nodes/:id/secrets/:request_id", () => {
148
+ test("poll returns the node's ack snapshot (names + revision)", async () => {
149
+ const { ackNodeSecrets } = await import("./scoped-secrets.js");
150
+ const created = await post(`/api/nodes/${CAPABLE}/secrets?network_id=${net}`, ownerToken, { action: "list" });
151
+ expect(created.status).toBe(200);
152
+ const rid = created.body.request_id as string;
153
+
154
+ // Before the node answers, the poll says pending.
155
+ const pending = await get(`/api/nodes/${CAPABLE}/secrets/${rid}?network_id=${net}`, ownerToken);
156
+ expect(pending.status).toBe(200);
157
+ expect(pending.body.status).toBe("pending");
158
+
159
+ // The node answers through its own lane call (same code path as the doorbell).
160
+ const acked = ackNodeSecrets(
161
+ { ok: true, networkId: net, daemonNodeId: CAPABLE, daemonAlias: CAPABLE },
162
+ rid,
163
+ { network_id: net, daemon_node_id: null, node_id: CAPABLE, revision: 2, activation: "next_start", keys: [{ key: "ALPHA_KEY", source: "node" }] },
164
+ );
165
+ expect(acked.ok).toBe(true);
166
+
167
+ const done = await get(`/api/nodes/${CAPABLE}/secrets/${rid}?network_id=${net}`, ownerToken);
168
+ expect(done.status).toBe(200);
169
+ expect(done.body.status).toBe("succeeded");
170
+ expect(done.body.snapshot.keys).toEqual([{ key: "ALPHA_KEY", source: "node" }]);
171
+ // Names only — no value field anywhere in the payload.
172
+ expect(JSON.stringify(done.body)).not.toContain("value");
173
+ });
174
+
175
+ test("a plain member cannot poll (403)", async () => {
176
+ const created = await post(`/api/nodes/${CAPABLE}/secrets?network_id=${net}`, ownerToken, { action: "list" });
177
+ const rid = created.status === 200 ? created.body.request_id : "nsk_missing";
178
+ if (created.status === 200) {
179
+ const { ackNodeSecrets } = await import("./scoped-secrets.js");
180
+ ackNodeSecrets({ ok: true, networkId: net, daemonNodeId: CAPABLE, daemonAlias: CAPABLE }, rid, null, true);
181
+ }
182
+ const r = await get(`/api/nodes/${CAPABLE}/secrets/${rid}?network_id=${net}`, memberToken);
183
+ expect(r.status).toBe(403);
184
+ });
185
+
186
+ test("an unknown request id is a 404", async () => {
187
+ const r = await get(`/api/nodes/${CAPABLE}/secrets/nsk_does_not_exist?network_id=${net}`, ownerToken);
188
+ expect(r.status).toBe(404);
189
+ });
190
+ });
@@ -276,10 +276,21 @@ describe("#461 observer receives third-party traffic summaries", () => {
276
276
  await readFrame(reader); // connected
277
277
 
278
278
  const secret = `SECRET-TASK-CONTENT-${Date.now()}`;
279
+ const body = { alias: TARGET_ALIAS, task: secret, priority: "high", from: "third-party-sender", network_id: memberNetworkId };
280
+ // Human tokens send as their authenticated username; they cannot stand
281
+ // in for an Agent. Keep that refusal and exercise the observer with an
282
+ // actual bound sender token instead of weakening the identity contract.
283
+ const spoof = await fetch(`${BASE}/api/task`, {
284
+ method: "POST", headers: { ...auth(memberToken), "Content-Type": "application/json" }, body: JSON.stringify(body),
285
+ });
286
+ expect(spoof.status).toBe(403);
287
+ expect((await spoof.json() as any).error).toBe("from_session_identity_mismatch");
288
+ const sender = createNetworkTokenForNode(memberUserId, memberNetworkId, "third-party-sender", `node_obs_sender_${Date.now()}`);
289
+ expect(sender.ok).toBe(true);
279
290
  const dispatch = await fetch(`${BASE}/api/task`, {
280
291
  method: "POST",
281
- headers: { ...auth(memberToken), "Content-Type": "application/json" },
282
- body: JSON.stringify({ alias: TARGET_ALIAS, task: secret, priority: "high", from: "third-party-sender", network_id: memberNetworkId }),
292
+ headers: { ...auth(sender.token!), "Content-Type": "application/json" },
293
+ body: JSON.stringify(body),
283
294
  });
284
295
  expect([200, 202]).toContain(dispatch.status);
285
296
  const dispatched = await dispatch.json() as any;
@@ -90,6 +90,11 @@ function rows(): Row[] {
90
90
  return db.all("SELECT resume_id, status, version, agent, hostname, cpu_cores, mem_total_gb, registered_at, task FROM sessions WHERE alias = ?1 AND network_id = ?2", [ALIAS, NET_ID]) as Row[];
91
91
  }
92
92
 
93
+ type OsRow = { resume_id: string; os_user: string | null };
94
+ function osRows(): OsRow[] {
95
+ return db.all("SELECT resume_id, os_user FROM sessions WHERE alias = ?1 AND network_id = ?2", [ALIAS, NET_ID]) as OsRow[];
96
+ }
97
+
93
98
  describe("report_status: 同 alias 换 resume_id 时,描述性列从被替换的那行接手", () => {
94
99
  test("第二个上报者(grok-cli)不带 version/agent/遥测 → 这些列保留,status/task 以本次为准,仍只有一行", async () => {
95
100
  seed();
@@ -147,4 +152,33 @@ describe("report_status: 同 alias 换 resume_id 时,描述性列从被替换的
147
152
  expect(all[0]!.version).toBeNull();
148
153
  expect(all[0]!.registered_at).not.toBe(OLD_REGISTERED_AT);
149
154
  });
155
+
156
+ test("same resume_id stores os_user, and a later report that omits it keeps the value", async () => {
157
+ seed();
158
+ const tools = buildHandlers();
159
+ const set = await call(tools.report_status!, { resume_id: RESUME_SDK, alias: ALIAS, status: "working", os_user: "alice", network_id: NET_ID });
160
+ expect(set.ok, JSON.stringify(set)).toBe(true);
161
+ expect(osRows()).toEqual([{ resume_id: RESUME_SDK, os_user: "alice" }]);
162
+ const keep = await call(tools.report_status!, { resume_id: RESUME_SDK, alias: ALIAS, status: "idle", network_id: NET_ID });
163
+ expect(keep.ok, JSON.stringify(keep)).toBe(true);
164
+ expect(osRows()).toEqual([{ resume_id: RESUME_SDK, os_user: "alice" }]);
165
+ });
166
+
167
+ test("换 resume_id 且本次不带 os_user 时,从被替换的那行接手", async () => {
168
+ seed();
169
+ db.run("UPDATE sessions SET os_user = ?1 WHERE resume_id = ?2", ["alice", RESUME_SDK]);
170
+ const tools = buildHandlers();
171
+ const r = await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "working", network_id: NET_ID });
172
+ expect(r.ok, JSON.stringify(r)).toBe(true);
173
+ expect(osRows()).toEqual([{ resume_id: RESUME_CLI, os_user: "alice" }]);
174
+ });
175
+
176
+ test("换 resume_id 且本次带了 os_user 时,以上报为准", async () => {
177
+ seed();
178
+ db.run("UPDATE sessions SET os_user = ?1 WHERE resume_id = ?2", ["alice", RESUME_SDK]);
179
+ const tools = buildHandlers();
180
+ const r = await call(tools.report_status!, { resume_id: RESUME_CLI, alias: ALIAS, status: "idle", os_user: "bob", network_id: NET_ID });
181
+ expect(r.ok, JSON.stringify(r)).toBe(true);
182
+ expect(osRows()).toEqual([{ resume_id: RESUME_CLI, os_user: "bob" }]);
183
+ });
150
184
  });
@@ -0,0 +1,140 @@
1
+ // #1474 (#909) — `report_status` accepts the NAMES of the env keys a node has.
2
+ //
3
+ // Called through a real MCP client (InMemoryTransport) rather than the raw
4
+ // handler: the shape rules live in the tool's zod schema, and invoking the
5
+ // handler directly bypasses them — the first version of this file "passed" a
6
+ // `lower-case` key because no validation ran.
7
+ //
8
+ // Two behaviours are load-bearing:
9
+ // 1. `[]` is a REAL report ("this node has no secrets") and must overwrite the
10
+ // stored list; only an ABSENT field keeps the previous value.
11
+ // 2. A value accidentally placed in the array must be rejected at the
12
+ // boundary, not stored and later rendered.
13
+ import { beforeEach, describe, expect, test } from "bun:test";
14
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { db } from "./db.js";
18
+ import { registerTools } from "./tools.js";
19
+
20
+ const NET = "net_secret_keys";
21
+ const USER = "u_secret_keys";
22
+ const RESUME = "r_secret_keys";
23
+ const ALIAS = "sk-node";
24
+
25
+ type ToolResult = { content: Array<{ type: string; text?: string }>; isError?: boolean };
26
+
27
+ async function connectClient() {
28
+ const server = new McpServer({ name: "secret-keys-test", version: "1" });
29
+ // report_status is a node heartbeat: the harness presents a NETWORK token.
30
+ registerTools(server, undefined, NET, USER, ALIAS, true, null);
31
+ const client = new Client({ name: "secret-keys-client", version: "1" });
32
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
33
+ await server.connect(serverTransport);
34
+ await client.connect(clientTransport);
35
+ return client;
36
+ }
37
+
38
+ const report = (extra: Record<string, unknown> = {}) => ({
39
+ resume_id: RESUME, alias: ALIAS, status: "idle", network_id: NET, ...extra,
40
+ });
41
+
42
+ async function call(client: Client, args: Record<string, unknown>): Promise<{ ok: boolean; body: any }> {
43
+ const res = (await client.callTool({ name: "report_status", arguments: args })) as ToolResult;
44
+ const text = res.content[0]?.text;
45
+ if (!text) throw new Error("missing text tool result");
46
+ // A schema rejection comes back as a plain error string ("MCP error ..."),
47
+ // not JSON: that is exactly the boundary we want to observe.
48
+ let body: any;
49
+ try { body = JSON.parse(text); } catch { body = { error: text }; }
50
+ return { ok: res.isError !== true, body };
51
+ }
52
+
53
+ const stored = () =>
54
+ db.get<{ secret_keys: string | null }>("SELECT secret_keys FROM sessions WHERE resume_id = ?1", RESUME)?.secret_keys ?? null;
55
+
56
+ beforeEach(() => {
57
+ db.run("INSERT OR IGNORE INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [USER, USER]);
58
+ db.run("INSERT OR IGNORE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
59
+ db.run("INSERT OR IGNORE INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
60
+ db.run("DELETE FROM sessions WHERE resume_id = ?1", [RESUME]);
61
+ db.run(
62
+ `INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at)
63
+ VALUES (?1, ?2, 'idle', ?3, datetime('now'), datetime('now'))`,
64
+ [RESUME, ALIAS, NET],
65
+ );
66
+ });
67
+
68
+ describe("report_status — secret key names", () => {
69
+ test("stores the names as a JSON array", async () => {
70
+ const client = await connectClient();
71
+ const r = await call(client, report({ secret_keys: ["ALPHA_KEY", "BETA_KEY"] }));
72
+ expect(r.body.ok).toBe(true);
73
+ expect(stored()).toBe(JSON.stringify(["ALPHA_KEY", "BETA_KEY"]));
74
+ });
75
+
76
+ test("an absent field keeps the previous list", async () => {
77
+ const client = await connectClient();
78
+ await call(client, report({ secret_keys: ["ALPHA_KEY"] }));
79
+ await call(client, report());
80
+ expect(stored()).toBe(JSON.stringify(["ALPHA_KEY"]));
81
+ });
82
+
83
+ test("an EMPTY array is a real report and clears the list", async () => {
84
+ const client = await connectClient();
85
+ await call(client, report({ secret_keys: ["ALPHA_KEY"] }));
86
+ await call(client, report({ secret_keys: [] }));
87
+ expect(stored()).toBe("[]");
88
+ });
89
+
90
+ test("a malformed name is rejected at the boundary and nothing is written", async () => {
91
+ const client = await connectClient();
92
+ await call(client, report({ secret_keys: ["ALPHA_KEY"] }));
93
+ const r = await call(client, report({ secret_keys: ["lower-case"] }));
94
+ expect(r.ok).toBe(false);
95
+ expect(stored()).toBe(JSON.stringify(["ALPHA_KEY"]));
96
+ });
97
+
98
+ test("more than 50 names is rejected", async () => {
99
+ const client = await connectClient();
100
+ const many = Array.from({ length: 51 }, (_, i) => `KEY_${String(i).padStart(3, "0")}`);
101
+ const r = await call(client, report({ secret_keys: many }));
102
+ expect(r.ok).toBe(false);
103
+ expect(stored()).toBeNull();
104
+ });
105
+
106
+ test("a value-shaped string is rejected (the field is a name list, not free text)", async () => {
107
+ const client = await connectClient();
108
+ const value = "ghp_" + "A".repeat(40);
109
+ const r = await call(client, report({ secret_keys: [value] }));
110
+ expect(r.ok).toBe(false);
111
+ expect(stored()).toBeNull();
112
+ });
113
+ });
114
+
115
+ // #1510 — the node reports how it reaches this hub; reporting only.
116
+ describe("report_status — env_transport", () => {
117
+ test("stores the reported link kind", async () => {
118
+ const client = await connectClient();
119
+ const r = await call(client, report({ env_transport: "loopback" }));
120
+ expect(r.body.ok).toBe(true);
121
+ const row = db.get<{ env_transport: string | null }>("SELECT env_transport FROM sessions WHERE resume_id = ?1", RESUME);
122
+ expect(row?.env_transport).toBe("loopback");
123
+ });
124
+
125
+ test("an unknown value is rejected at the boundary", async () => {
126
+ const client = await connectClient();
127
+ const r = await call(client, report({ env_transport: "carrier-pigeon" }));
128
+ expect(r.ok).toBe(false);
129
+ const row = db.get<{ env_transport: string | null }>("SELECT env_transport FROM sessions WHERE resume_id = ?1", RESUME);
130
+ expect(row?.env_transport ?? null).toBeNull();
131
+ });
132
+
133
+ test("an absent field keeps the previous value", async () => {
134
+ const client = await connectClient();
135
+ await call(client, report({ env_transport: "https" }));
136
+ await call(client, report());
137
+ const row = db.get<{ env_transport: string | null }>("SELECT env_transport FROM sessions WHERE resume_id = ?1", RESUME);
138
+ expect(row?.env_transport).toBe("https");
139
+ });
140
+ });
@@ -373,7 +373,7 @@ describe("requirements stay on the hub", () => {
373
373
  expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
374
374
  const listed = await api(ownerToken, "/api/requirements");
375
375
  expect(listed.body.capabilities).toContain("due_datetime");
376
- expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
376
+ expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
377
377
  });
378
378
 
379
379
  test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
@@ -17,7 +17,9 @@ type HiddenNode = ((nodeId: string) => boolean) | null;
17
17
  export const PEOPLE_DEFAULT_LIMIT = 50;
18
18
  export const PEOPLE_MAX_LIMIT = 200;
19
19
 
20
- export type DirectoryAgent = { node_id: string; alias: string };
20
+ /** `department_id`: Agent 的**显式**部门归属(可空)。空 = 仍按「负责人在哪个部门」推断,
21
+ * 客户端据此把 Agent 放对格子 —— 2026-10-19 前没有这一列,328 个 Agent 只能全算「未归属」。 */
22
+ export type DirectoryAgent = { node_id: string; alias: string; department_id?: string | null };
21
23
  export type DirectoryPerson = {
22
24
  user_id: string;
23
25
  username: string;
@@ -62,15 +64,17 @@ export function peopleDirectory(networkId: string, hidden: HiddenNode, query: {
62
64
  ORDER BY u.username, u.user_id`,
63
65
  networkId,
64
66
  );
65
- const nodes = db.all<{ node_id: string; alias: string | null; node_name: string; owner_user_id: string | null }>(
66
- "SELECT node_id, alias, node_name, owner_user_id FROM nodes WHERE network_id = ?1 ORDER BY COALESCE(alias, node_name), node_id",
67
+ const nodes = db.all<{ node_id: string; alias: string | null; node_name: string; owner_user_id: string | null; department_id: string | null }>(
68
+ "SELECT node_id, alias, node_name, owner_user_id, department_id FROM nodes WHERE network_id = ?1 ORDER BY COALESCE(alias, node_name), node_id",
67
69
  networkId,
68
70
  ).filter(n => !hidden?.(n.node_id));
69
71
  const memberIds = new Set(members.map(m => m.user_id));
70
72
  const byOwner = new Map<string, DirectoryAgent[]>();
71
73
  const unowned: DirectoryAgent[] = [];
72
74
  for (const n of nodes) {
73
- const agent = { node_id: n.node_id, alias: n.alias || n.node_name };
75
+ // 只在**显式**归属时带这一格:没设 = 沿用「按负责人的部门推断」的老口径,
76
+ // 也保证旧客户端拿到的行形状不变(多一个键会让严格要求形状的测试/客户端意外)。
77
+ const agent = { node_id: n.node_id, alias: n.alias || n.node_name, ...(n.department_id ? { department_id: n.department_id } : {}) };
74
78
  if (n.owner_user_id && memberIds.has(n.owner_user_id)) {
75
79
  const list = byOwner.get(n.owner_user_id) ?? [];
76
80
  list.push(agent);
@@ -53,6 +53,8 @@ export const SCHEDULED_TASK_STORAGE_COLUMNS = [
53
53
  "target_alias", "task_content", "priority", "schedule_type", "schedule_json",
54
54
  "timezone", "overlap_policy", "misfire_policy", "status", "next_run_at",
55
55
  "last_run_at", "revision", "created_at", "updated_at",
56
+ "no_change_backoff", "backoff_streak", "backoff_effective_seconds", "backoff_last_run_id",
57
+ "requirement_wake_json", "requirement_wake_cursor", "requirement_wake_last_event_id",
56
58
  ] as const;
57
59
 
58
60
  export function sqlColumns(columns: readonly string[], qualifier?: string): string {
@@ -140,7 +140,7 @@ export function agentUpdateSchedule(id: NodeIdentity, canWrite: boolean, schedul
140
140
  const denied = targetDenied(id, route, target);
141
141
  if (denied) return denied;
142
142
  try {
143
- const patched = patchSchedule(row, { ...body, revision: body.revision ?? row.revision });
143
+ const patched = patchSchedule(row, { ...body, revision: body.revision ?? row.revision }, id.ownerUserId);
144
144
  return { ok: true, schedule: decodeRowForAgent(patched) };
145
145
  } catch (e) { return inputError(e); }
146
146
  }