@sleep2agi/commhub-server 0.9.0-preview.122 → 0.9.0-preview.123

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.122",
3
+ "version": "0.9.0-preview.123",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  import "./require-explicit-test-db";
2
2
  import { afterAll, beforeEach, expect, test } from "bun:test";
3
- import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createHash } from 'node:crypto';
@@ -320,15 +320,17 @@ test("real HTTP authentication -> daemon handler -> pinned CLI -> scoped public
320
320
  db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), nodeId]);
321
321
  const dir = join(root, ".anet/nodes", daemonAlias); mkdirSync(dir, { recursive: true, mode: 0o700 });
322
322
  writeFileSync(join(dir, "config.json"), JSON.stringify({ role: "host_supervisor", network_id: netId, node_id: nodeId }), { mode: 0o600 });
323
- for (const child of ['bound', 'neighbour']) {
323
+ for (const child of ['bound', 'neighbour', 'manual']) {
324
324
  const childId = `n_scan_${child}`;
325
325
  db.run('INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,?2,?2,?3,\'codex-app-server\')', [childId, child, netId]);
326
- db.run('INSERT INTO node_daemon_bindings (request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,?6,\'active\',1,1)',
326
+ if (child !== 'manual') db.run('INSERT INTO node_daemon_bindings (request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,?6,\'active\',1,1)',
327
327
  [`binding_scan_${child}`, netId, childId, child === 'bound' ? nodeId : 'another-daemon', root, owner.user!.user_id]);
328
328
  const home = join(root, '.anet/nodes', child, 'codex-home'); mkdirSync(home, { recursive: true, mode: 0o700 });
329
329
  writeFileSync(join(home, '../config.json'), JSON.stringify({ node_id: childId, alias: child, runtime: 'codex-app-server', network_id: netId }), { mode: 0o600 });
330
330
  writeFileSync(join(home, 'config.toml'), 'model_provider = "deepseek"\nmodel = "fixture-model"\n[model_providers.deepseek]\nenv_key = "TEST_API_KEY"\n', { mode: 0o600 });
331
331
  }
332
+ chmodSync(join(root, '.anet'), 0o700);
333
+ chmodSync(join(root, '.anet/nodes'), 0o775);
332
334
  const hub = (await import("./server")).bootServer({ port: 0, hostname: "127.0.0.1" });
333
335
  const url = `http://127.0.0.1:${hub.port}/mcp`;
334
336
  const rpc = (credential: string) => async (name: string, args: any) => {
@@ -352,6 +354,15 @@ test("real HTTP authentication -> daemon handler -> pinned CLI -> scoped public
352
354
  expect(read.status).toBe("succeeded"); expect(read.snapshot).toMatchObject({ network_id: netId, daemon_node_id: nodeId, revision: 0, source: "daemon", providers: [], codex_inventory: { scope: 'hub_bound_nodes' } });
353
355
  expect(read.snapshot.codex_inventory.rows).toHaveLength(1);
354
356
  expect(read.snapshot.codex_inventory.rows[0]).toMatchObject({ node_id: 'n_scan_bound', configured_provider: 'deepseek', configured_model: 'fixture-model', credential_status: 'unknown' });
357
+ expect(read.snapshot.codex_inventory.local_discovery).toBeUndefined(); // old client projection
358
+ const discovered = await human('daemon_provider_snapshot', { action: 'read', id: refresh.request_id, network_id: netId, include_discovery: true });
359
+ expect(discovered.snapshot.codex_inventory.local_discovery).toMatchObject({ status: 'observed', rows: [
360
+ { node_id: 'n_scan_manual', alias: 'manual', configured_model: 'fixture-model', config_revision: null },
361
+ ] });
362
+ expect(discovered.snapshot.codex_inventory.local_discovery.rows).toHaveLength(1); // other daemon filtered
363
+ const deniedApply = await human('daemon_provider_snapshot', { action: 'apply', id: nodeId, network_id: netId, revision: 0,
364
+ provider: { node_id: 'n_scan_manual', node_revision: 'a'.repeat(64), provider_id: 'deepseek', auth_id: 'key', model: 'fixture-model', confirm_restart: true } });
365
+ expect(deniedApply).toMatchObject({ ok: false, error: 'application_target_not_bound' });
355
366
  const foreign = await human('daemon_provider_snapshot', { action: 'refresh', id: nodeId, network_id: netId });
356
367
  const bad = structuredClone(read.snapshot); bad.codex_inventory.rows[0].node_id = 'n_scan_neighbour';
357
368
  expect((await daemon('ack_daemon_provider_snapshot', { request_id: foreign.request_id, snapshot: bad })).ok).toBe(false);
@@ -156,18 +156,21 @@ export function requestProviderLogin(h: Human, daemonId: string, intent: unknown
156
156
  return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
157
157
  } catch { return denied('provider_snapshot_busy'); }
158
158
  }
159
- export function getProviderSnapshot(h: Human, requestId: string) {
159
+ export function getProviderSnapshot(h: Human, requestId: string, includeDiscovery = false) {
160
160
  const refusal = authorize(h); if (refusal) return refusal;
161
161
  expire(h.networkId!);
162
162
  const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2`, requestId, h.networkId!);
163
163
  if (!row) return denied("request_not_found");
164
+ const snapshot = !/^dp[al]_/.test(row.request_id) && row.snapshot_json ? JSON.parse(row.snapshot_json) : null;
165
+ // Old clients validate an exact contract. Their default projection stays unchanged.
166
+ if (!includeDiscovery && snapshot?.codex_inventory) delete snapshot.codex_inventory.local_discovery;
164
167
  return { ok: true, request_id: row.request_id, daemon_node_id: row.daemon_node_id, status: row.status,
165
168
  expires_at: row.expires_at, observed_at: row.observed_at, error: row.error,
166
- snapshot: !/^dp[al]_/.test(row.request_id) && row.snapshot_json ? JSON.parse(row.snapshot_json) : null,
169
+ snapshot,
167
170
  ...(row.request_id.startsWith('dpl_') ? { login: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}),
168
171
  ...(row.request_id.startsWith('dpa_') ? { application: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}) };
169
172
  }
170
- export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false) {
173
+ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false, acceptDiscovery = false) {
171
174
  if (!d.ok) return denied("caller_not_a_daemon");
172
175
  if (!daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
173
176
  expire(d.networkId, d.daemonNodeId);
@@ -194,7 +197,7 @@ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApp
194
197
  }
195
198
  if (!acceptWrites && db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', r.request_id)) return [];
196
199
  const write = privateWrite(r.request_id, d.networkId, d.daemonNodeId);
197
- return [write ? { ...r, operation: 'put', ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId) }];
200
+ return [write ? { ...r, operation: 'put', ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId), ...(acceptDiscovery ? { discover_local: true } : {}) }];
198
201
  }) }; } catch { return denied('provider_write_unavailable'); }
199
202
  }
200
203
  export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown, login?: unknown) {
@@ -244,6 +247,16 @@ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unk
244
247
  const targets = new Map(providerScanTargets(d.networkId, d.daemonNodeId).map(n => [n.node_id, n.alias]));
245
248
  const rows = (safe as any).codex_inventory.rows;
246
249
  if (rows.length !== targets.size || rows.some((r: any) => targets.get(r.node_id) !== r.alias)) return denied('invalid_provider_snapshot');
250
+ const local = (safe as any).codex_inventory.local_discovery;
251
+ if (local) {
252
+ const registered = new Map(db.all<{ node_id: string; alias: string }>('SELECT node_id,alias FROM nodes WHERE network_id=?1', d.networkId).map(n => [n.node_id, n.alias]));
253
+ const ownership = buildControllableMap(
254
+ db.all<{ request_id: string; daemon_node_id: string }>('SELECT request_id,daemon_node_id FROM node_create_requests WHERE network_id=?1', d.networkId),
255
+ db.all<{ node_id: string; daemon_node_id: string }>("SELECT node_id,daemon_node_id FROM node_daemon_bindings WHERE network_id=?1 AND status='active'", d.networkId));
256
+ // Filesystem discovery is not control authority. Other-daemon and
257
+ // unregistered identities cannot enter this network's public receipt.
258
+ local.rows = local.rows.filter((r: any) => registered.get(r.node_id) === r.alias && !ownership.has(r.node_id));
259
+ }
247
260
  } catch { return denied('invalid_provider_snapshot'); }
248
261
  }
249
262
  try {
@@ -80,7 +80,8 @@ test("name-only issuance checks existing ownership atomically", async () => {
80
80
  });
81
81
 
82
82
  // Every daemon-facing handler must fail at the shared identity boundary,
83
- // not incidentally at request lookup or argument validation.
83
+ // not incidentally at request lookup or argument validation. The resolver
84
+ // mutation expects all 15 of these cases to go red together.
84
85
  const endpoints: [string, Record<string, unknown>][] = [
85
86
  ["list_my_pending_create_requests", {}], ["list_my_pending_lifecycle_requests", {}],
86
87
  ["get_create_request", { request_id: "cr_fixture_absent" }],
@@ -92,6 +93,8 @@ const endpoints: [string, Record<string, unknown>][] = [
92
93
  ["list_my_children", {}], ["get_adopt_request", {}], ["ack_adopt_request", { status: "adopted" }],
93
94
  ["get_probe_request", { probe_id: "probe_fixture_absent" }],
94
95
  ["ack_probe_request", { probe_id: "probe_fixture_absent", status: "ok", latency_ms: 1 }],
96
+ ["get_opencode_catalog_request", { request_id: "cat_fixture_absent" }],
97
+ ["ack_opencode_catalog", { request_id: "cat_fixture_absent", error: "unavailable" }],
95
98
  ];
96
99
  for (const [name, arguments_] of endpoints) {
97
100
  test(`daemon identity boundary: ${name}`, async () => {
@@ -26,9 +26,10 @@ if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COM
26
26
  * 加工具或加说明超过它,就在这里改数字并在 PR 里说为什么。
27
27
  */
28
28
  // Board #625: two human adoption tools; measured 58 tools / 56,615 B.
29
- // Hub #938 clone_node is audience both, so this user-token list grows.
30
- // Measured with #906's tools plus clone_node: 60 tools / 58,169 B. Ceiling keeps ~431 B.
31
- const TOOLS_LIST_MAX_BYTES = 58_600;
29
+ // Hub #938 clone_node is audience both (60 tools / 58,169 B). OpenCode V2
30
+ // catalog adds read_opencode_catalog and get_opencode_catalog on that user
31
+ // list. Measured together: 62 tools / 59,409 B. Ceiling keeps ~401 B.
32
+ const TOOLS_LIST_MAX_BYTES = 59_810;
32
33
  /** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
33
34
  const TOOL_MAX_BYTES = 9_000;
34
35
  /** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
@@ -254,6 +254,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
254
254
  get_config_update: "always", ack_config_update: "always", get_rules_file_request: "always", ack_rules_file_request: "always", get_rules_file_result: "always",
255
255
  get_create_request: "always", ack_create_request: "always", get_stop_request: "always", ack_stop_request: "always",
256
256
  get_start_request: "always", ack_start_request: "always", get_probe_request: "always", ack_probe_request: "always",
257
+ get_opencode_catalog_request: "always", ack_opencode_catalog: "always",
257
258
  list_my_pending_create_requests: "always", list_my_pending_lifecycle_requests: "always", list_my_children: "always",
258
259
  request_adopt_node: "human_only", unadopt_node: "human_only",
259
260
  get_adopt_request: "always", ack_adopt_request: "always",
@@ -280,4 +281,5 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
280
281
  // 只有人能做
281
282
  upsert_network_secret: "human_only", upsert_provider: "human_only", update_provider: "human_only",
282
283
  projects_create: "human_only", projects_update: "human_only", review_skill: "human_only",
284
+ read_opencode_catalog: "human_only", get_opencode_catalog: "human_only",
283
285
  };
@@ -0,0 +1,268 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import "./require-explicit-test-db.js";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { db } from "./db.js";
8
+ import { registerTools } from "./tools.js";
9
+ import {
10
+ ackOpenCodeCatalog,
11
+ expireOpenCodeCatalogForTest,
12
+ getOpenCodeCatalogResult,
13
+ queueOpenCodeCatalogRead,
14
+ resetOpenCodeCatalogForTest,
15
+ takeOpenCodeCatalogRequest,
16
+ } from "./opencode-v2-catalog.js";
17
+ import { readOwnedOpenCodeV2Catalog } from "../../agent-node/src/runtime/opencode-v2-catalog-daemon.ts";
18
+ import { readOpenCodeV2CatalogEndpoint, writeOpenCodeV2CatalogEndpoint } from "../../agent-node/src/runtime/opencode-v2-catalog-endpoint.ts";
19
+
20
+ const NET = "net_oc_catalog";
21
+ const OTHER = "net_oc_catalog_other";
22
+ const USER = "u_oc_catalog";
23
+ const VIEWER = "u_oc_catalog_viewer";
24
+ const OUTSIDER = "u_oc_catalog_out";
25
+ const DAEMON = "node_oc_daemon";
26
+ const DAEMON_ALIAS = "oc-daemon";
27
+ const DAEMON_TOK = "tok_oc_daemon";
28
+ const V2_REQ = "cr_ocv2child";
29
+ const V2 = "node_ocv2child";
30
+ const V1 = "node_ocv1child";
31
+ const UNSAFE = "node_ocunsafechild";
32
+ const NULLDIR = "node_ocnullchild";
33
+ const PROJECT = resolve(mkdtempSync(join(tmpdir(), "oc-hub-proj-")));
34
+
35
+ function cleanup() {
36
+ resetOpenCodeCatalogForTest();
37
+ for (const net of [NET, OTHER]) {
38
+ for (const table of ["node_create_requests", "audit_log", "nodes", "sessions", "api_tokens", "network_members"]) {
39
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [net]); } catch {}
40
+ }
41
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
42
+ }
43
+ for (const user of [USER, VIEWER, OUTSIDER]) {
44
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [user]); } catch {}
45
+ }
46
+ }
47
+ beforeEach(cleanup);
48
+ afterAll(() => { cleanup(); rmSync(PROJECT, { recursive: true, force: true }); });
49
+
50
+ function userRow(id: string) {
51
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'user', datetime('now'))`, [id]);
52
+ }
53
+ function member(id: string, net: string, role: string) {
54
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [id, net, role]);
55
+ }
56
+ function node(id: string, alias: string, snapshot: unknown) {
57
+ db.run(
58
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
59
+ VALUES (?1, ?2, ?2, ?3, ?4, 'host-x', datetime('now'), datetime('now'), 'active')`,
60
+ [id, alias, NET, JSON.stringify(snapshot)],
61
+ );
62
+ }
63
+ function created(requestId: string, nodeId: string, flags: unknown, workdir: string | null) {
64
+ node(nodeId, nodeId, {});
65
+ db.run(
66
+ `INSERT INTO node_create_requests
67
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, created_at, created_by_token, workdir)
68
+ VALUES (?1, ?2, ?3, ?4, 'opencode-cli', 'fixture/fixture-model', ?5, '[]', 'succeeded', ?6, 'tok', ?7)`,
69
+ [requestId, DAEMON, nodeId, NET, JSON.stringify(flags), Date.now(), workdir],
70
+ );
71
+ }
72
+
73
+ function seed() {
74
+ userRow(USER); userRow(VIEWER); userRow(OUTSIDER);
75
+ db.run(`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET, USER]);
76
+ db.run(`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [OTHER, OUTSIDER]);
77
+ member(USER, NET, "admin");
78
+ member(VIEWER, NET, "viewer");
79
+ member(OUTSIDER, OTHER, "admin");
80
+ node(DAEMON, DAEMON_ALIAS, { role: "host_supervisor" });
81
+ db.run(
82
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
83
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
84
+ [DAEMON_TOK, USER, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
85
+ );
86
+ created(V2_REQ, V2, { opencodeGeneration: "v2", opencodeUnsafeTools: true }, PROJECT);
87
+ created("cr_ocv1child", V1, { opencodeGeneration: "v1" }, PROJECT);
88
+ created("cr_ocunsafechild", UNSAFE, { opencodeGeneration: "v2", opencodeUnsafeTools: false }, PROJECT);
89
+ created("cr_ocnullchild", NULLDIR, { opencodeGeneration: "v2", opencodeUnsafeTools: true }, null);
90
+ }
91
+
92
+ function queue(userId: string, nodeId: string, networkId: string | null = NET, callerIsNode = false) {
93
+ return queueOpenCodeCatalogRead({ callerUserId: userId, callerIsNode, networkId, daemonNodeId: DAEMON, nodeId });
94
+ }
95
+
96
+ function handlers(userId: string, isNode: boolean, tokenId: string | null) {
97
+ const server = new McpServer({ name: "oc-catalog-test", version: "0" }) as any;
98
+ const out: Record<string, (args: any) => Promise<{ content: Array<{ text: string }> }>> = {};
99
+ const origTool = server.tool.bind(server);
100
+ server.tool = (name: string, d: string, schema: any, handler: any) => { out[name] = handler; return origTool(name, d, schema, handler); };
101
+ const origRegister = server.registerTool.bind(server);
102
+ server.registerTool = (name: string, cfg: any, handler: any) => { out[name] = handler; return origRegister(name, cfg, handler); };
103
+ registerTools(server, undefined, NET, userId, isNode ? DAEMON_ALIAS : null, isNode, tokenId);
104
+ return { out, server };
105
+ }
106
+ async function call(handler: (args: any) => Promise<{ content: Array<{ text: string }> }>, args: any) {
107
+ return JSON.parse((await handler(args)).content[0]!.text);
108
+ }
109
+
110
+ function snapshot(directory = PROJECT, overrides: Record<string, unknown> = {}) {
111
+ return {
112
+ scope: { networkId: NET, daemonId: DAEMON, directory },
113
+ source: "opencode-v2-api",
114
+ state: "snapshot",
115
+ invocationVerified: false,
116
+ completeness: "unknown",
117
+ observedAt: "2026-10-11T00:00:00.000Z",
118
+ providers: [{ id: "custom", activation: "enabled" }],
119
+ models: [{ providerId: "custom", id: "nested/model" }],
120
+ defaultModel: { providerId: "custom", id: "nested/model" },
121
+ ...overrides,
122
+ };
123
+ }
124
+
125
+ describe("hub authorizes an owned OpenCode V2 catalog read", () => {
126
+ test("member queues, daemon reads the owned loopback, ready snapshot stays unverified", async () => {
127
+ seed();
128
+ const user = handlers(USER, false, null);
129
+ const queued = await call(user.out.read_opencode_catalog!, { daemon_node_id: DAEMON, node_id: V2 });
130
+ expect(queued).toEqual({ ok: true, status: "pending", request_id: queued.request_id });
131
+ expect(JSON.stringify(queued)).not.toContain("daemon_alias");
132
+ const strict = await (user.server as any)._registeredTools.read_opencode_catalog.inputSchema.safeParseAsync({
133
+ daemon_node_id: DAEMON, node_id: V2, settings: { apiKey: "sk-FAKE" }, headers: {}, body: {},
134
+ });
135
+ expect(strict.success).toBe(false);
136
+
137
+ expect(takeOpenCodeCatalogRequest(queued.request_id, "node_other")).toEqual({ ok: false, error: "catalog_request_unavailable" });
138
+ const daemon = handlers(USER, true, DAEMON_TOK);
139
+ const pulled = await call(daemon.out.get_opencode_catalog_request!, { request_id: queued.request_id });
140
+ expect(pulled.ok).toBe(true);
141
+ expect(pulled.directory).toBe(PROJECT);
142
+ for (const key of ["url", "authorization", "settings", "headers", "body"]) expect(pulled[key]).toBeUndefined();
143
+
144
+ let requests = 0;
145
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(req) {
146
+ requests++;
147
+ const url = new URL(req.url);
148
+ const row = url.pathname === "/api/provider"
149
+ ? [{ id: "custom", activation: "enabled", settings: { apiKey: "sk-FAKE" } }]
150
+ : [{ providerID: "custom", id: "nested/model", headers: { Authorization: "sk-FAKE" } }];
151
+ return Response.json({ location: { directory: PROJECT }, data: url.pathname === "/api/model/default" ? row[0] : row });
152
+ } });
153
+ const nodeDir = mkdtempSync(join(tmpdir(), "oc-hub-node-"));
154
+ try {
155
+ writeOpenCodeV2CatalogEndpoint(nodeDir, { url: `http://127.0.0.1:${server.port}`, authorization: "Basic fake", directory: PROJECT });
156
+ const read = await readOwnedOpenCodeV2Catalog({
157
+ networkId: NET, daemonId: DAEMON, authorizedDirectory: pulled.directory,
158
+ ownedProjectDirectory: PROJECT, endpoint: readOpenCodeV2CatalogEndpoint(nodeDir),
159
+ });
160
+ expect(read.ok).toBe(true);
161
+ if (!read.ok) return;
162
+ const acked = await call(daemon.out.ack_opencode_catalog!, { request_id: queued.request_id, snapshot: read.snapshot });
163
+ expect(acked).toEqual({ ok: true, status: "ready" });
164
+ const got = await call(user.out.get_opencode_catalog!, { request_id: queued.request_id });
165
+ expect(got.status).toBe("ready");
166
+ expect(got.snapshot.invocationVerified).toBe(false);
167
+ expect(got.snapshot.completeness).toBe("unknown");
168
+ expect(JSON.stringify(got.snapshot)).not.toContain("sk-FAKE");
169
+ expect(requests).toBe(3);
170
+ } finally {
171
+ server.stop(true);
172
+ rmSync(nodeDir, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ test("viewer may queue; outsider, node token, and non-v2 children may not", () => {
177
+ seed();
178
+ expect(queue(VIEWER, V2).ok).toBe(true);
179
+ expect(queue(OUTSIDER, V2, NET)).toEqual({ ok: false, error: "access_denied" });
180
+ expect(queue(OUTSIDER, V2, OTHER)).toEqual({ ok: false, error: "daemon_not_found" });
181
+ expect(queue(USER, V2, NET, true)).toEqual({ ok: false, error: "user_token_required" });
182
+ expect(queue(USER, V1)).toEqual({ ok: false, error: "not_opencode_v2" });
183
+ expect(queue(USER, UNSAFE)).toEqual({ ok: false, error: "not_opencode_v2" });
184
+ expect(queue(USER, "node_missing")).toEqual({ ok: false, error: "node_not_found" });
185
+ });
186
+
187
+ test("pending is not a snapshot; expiry, bad acks, and a missing endpoint stay closed", async () => {
188
+ seed();
189
+ const queued = queue(USER, V2);
190
+ expect(queued.ok).toBe(true);
191
+ if (!queued.ok) return;
192
+ expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id })).toEqual({
193
+ ok: true, status: "pending", request_id: queued.request_id,
194
+ });
195
+ const pulled = takeOpenCodeCatalogRequest(queued.request_id, DAEMON);
196
+ expect(pulled.ok).toBe(true);
197
+
198
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot(), settings: { apiKey: "sk-FAKE" } })).toEqual({ ok: false, error: "invalid_ack" });
199
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot(PROJECT, { invocationVerified: true }) })).toEqual({ ok: false, error: "invalid_ack" });
200
+ expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id }).status).toBe("pending");
201
+
202
+ const wrongDir = ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("/tmp/other-project") });
203
+ expect(wrongDir).toEqual({ ok: true, status: "failed", error: "invalid_response" });
204
+ expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id })).toMatchObject({
205
+ ok: false, status: "failed", error: "invalid_response",
206
+ });
207
+
208
+ const again = queue(USER, V2);
209
+ expect(again.ok).toBe(true);
210
+ if (!again.ok) return;
211
+ takeOpenCodeCatalogRequest(again.request_id, DAEMON);
212
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: again.request_id, error: "unavailable" })).toEqual({ ok: true, status: "failed", error: "unavailable" });
213
+ expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: again.request_id })).toMatchObject({
214
+ ok: false, status: "failed", error: "unavailable",
215
+ });
216
+
217
+ const waiting = queue(USER, NULLDIR);
218
+ expect(waiting.ok).toBe(true);
219
+ if (!waiting.ok) return;
220
+ expireOpenCodeCatalogForTest(waiting.request_id);
221
+ expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: waiting.request_id })).toMatchObject({
222
+ ok: false, status: "expired", error: "unavailable",
223
+ });
224
+ });
225
+
226
+ test("acks a Windows absolute directory without Hub path.resolve", () => {
227
+ seed();
228
+ db.run(`UPDATE node_create_requests SET workdir = ?1 WHERE request_id = ?2`, ["C:\\project", V2_REQ]);
229
+ const queued = queue(USER, V2);
230
+ expect(queued.ok).toBe(true);
231
+ if (!queued.ok) return;
232
+ takeOpenCodeCatalogRequest(queued.request_id, DAEMON);
233
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("project") })).toEqual({ ok: false, error: "invalid_ack" });
234
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("C:relative") })).toEqual({ ok: false, error: "invalid_ack" });
235
+ expect(getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id }).status).toBe("pending");
236
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("C:/project") })).toEqual({ ok: true, status: "ready" });
237
+ const got = getOpenCodeCatalogResult({ callerUserId: USER, callerIsNode: false, networkId: NET, requestId: queued.request_id });
238
+ expect(got.ok).toBe(true);
239
+ if (!got.ok || got.status !== "ready") return;
240
+ expect(got.snapshot.scope.directory).toBe("C:/project");
241
+
242
+ const other = queue(USER, V2);
243
+ expect(other.ok).toBe(true);
244
+ if (!other.ok) return;
245
+ takeOpenCodeCatalogRequest(other.request_id, DAEMON);
246
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: other.request_id, snapshot: snapshot("D:\\other") })).toEqual({
247
+ ok: true, status: "failed", error: "invalid_response",
248
+ });
249
+ });
250
+
251
+ test("a tilde workdir matches a native absolute report, and lexical .. collapses", () => {
252
+ seed();
253
+ db.run(`UPDATE node_create_requests SET workdir = ?1 WHERE request_id = ?2`, ["~/project", V2_REQ]);
254
+ const queued = queue(USER, V2);
255
+ expect(queued.ok).toBe(true);
256
+ if (!queued.ok) return;
257
+ takeOpenCodeCatalogRequest(queued.request_id, DAEMON);
258
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: queued.request_id, snapshot: snapshot("/home/user/project") })).toEqual({ ok: true, status: "ready" });
259
+
260
+ db.run(`UPDATE node_create_requests SET workdir = ?1 WHERE request_id = ?2`, ["/tmp/foo/../bar", V2_REQ]);
261
+ const lexical = queue(USER, V2);
262
+ expect(lexical.ok).toBe(true);
263
+ if (!lexical.ok) return;
264
+ takeOpenCodeCatalogRequest(lexical.request_id, DAEMON);
265
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: lexical.request_id, snapshot: snapshot("/tmp/foo/../bar") })).toEqual({ ok: false, error: "invalid_ack" });
266
+ expect(ackOpenCodeCatalog(DAEMON, { request_id: lexical.request_id, snapshot: snapshot("/tmp/bar") })).toEqual({ ok: true, status: "ready" });
267
+ });
268
+ });
@@ -0,0 +1,352 @@
1
+ // Hub authorization for a native OpenCode V2 catalog read.
2
+ //
3
+ // The caller names a daemon and a child node. Identity, generation, and the
4
+ // project directory come from Hub rows. The queued payload has no URL,
5
+ // authorization, settings, headers, or body. The daemon reads its own
6
+ // already-running loopback and acks a whitelist snapshot.
7
+ //
8
+ // `status: "pending"` means the read was authorized and stored. It does not
9
+ // mean a daemon received the doorbell or produced a snapshot. Only
10
+ // `status: "ready"` is a snapshot, and that snapshot stays completeness
11
+ // unknown / invocation unverified.
12
+
13
+ import { posix, win32 } from "node:path";
14
+ import { z } from "zod/v4";
15
+ import { db, uuidv4 } from "./db.js";
16
+ import { getUserNetworkRole } from "./auth.js";
17
+
18
+ const TTL_MS = 60_000;
19
+ const MAX_PENDING = 100;
20
+ const SECRET = /^(sk-|gh[pousr]_|github_pat_|xox[baprs]-|AKIA|AIza|ya29\.|glpat-)/i;
21
+
22
+ export const CATALOG_ERROR_CODES = [
23
+ "invalid_target", "unauthorized", "unavailable", "invalid_response", "scope_mismatch", "too_large",
24
+ ] as const;
25
+ export type CatalogErrorCode = (typeof CATALOG_ERROR_CODES)[number];
26
+
27
+ const identifier = z.string().min(1).max(512).refine(value => !/[\s\u0000-\u001f\u007f]/.test(value) && !SECRET.test(value));
28
+ const CONTROL = /[\u0000-\u001f\u007f]/;
29
+ const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
30
+
31
+ /** Absolute in the path's own format. Hub `path.resolve` is not used: on a
32
+ * POSIX Hub it treats `C:\project` as a relative path under the process cwd. */
33
+ function windowsStrictAbsolute(value: string): boolean {
34
+ if (!WINDOWS_DRIVE.test(value)) return false;
35
+ const parts = value.slice(2).split(/[\\/]/);
36
+ if (parts[0] !== "" || parts.length < 2) return false;
37
+ return parts.slice(1).every(part => part !== "" && part !== "." && part !== "..");
38
+ }
39
+
40
+ export function strictNativeAbsoluteDirectory(value: string): boolean {
41
+ if (value.length < 1 || value.length > 1024 || CONTROL.test(value)) return false;
42
+ if (WINDOWS_DRIVE.test(value)) return windowsStrictAbsolute(value);
43
+ if (value.startsWith("/")) return posix.resolve(value) === value;
44
+ return false;
45
+ }
46
+
47
+ function tildeWorkdir(value: string): boolean {
48
+ const trimmed = value.trim();
49
+ return trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\");
50
+ }
51
+
52
+ /** Compare a stored create workdir with the directory the daemon reported.
53
+ * Windows keys ignore slash style and case. POSIX keys are lexical (`..` and
54
+ * a trailing slash collapse) and are not realpath'd — Hub does not see the
55
+ * daemon disk. A `~` / `~/…` workdir has no Hub-side home to expand; a
56
+ * native-absolute report is accepted because the daemon already refused the
57
+ * read unless that path is the endpoint it owns. A compromised daemon can
58
+ * name another absolute path for a tilde row, same as a null workdir. */
59
+ export function catalogDirectoriesMatch(stored: string, reported: string): boolean {
60
+ if (!strictNativeAbsoluteDirectory(reported)) return false;
61
+ const storedTrim = stored.trim();
62
+ if (tildeWorkdir(storedTrim)) return true;
63
+ const storedWindows = WINDOWS_DRIVE.test(storedTrim);
64
+ const reportedWindows = WINDOWS_DRIVE.test(reported);
65
+ if (storedWindows !== reportedWindows) return false;
66
+ if (storedWindows) {
67
+ const left = win32.resolve(storedTrim);
68
+ const right = win32.resolve(reported);
69
+ return /^[A-Za-z]:\\/.test(left) && left.toLowerCase() === right.toLowerCase();
70
+ }
71
+ if (storedTrim.startsWith("/") && reported.startsWith("/")) return posix.resolve(storedTrim) === posix.resolve(reported);
72
+ return false;
73
+ }
74
+
75
+ const directoryText = z.string().min(1).max(1024).refine(strictNativeAbsoluteDirectory);
76
+
77
+ export const opencodeCatalogSnapshotSchema = z.object({
78
+ scope: z.object({
79
+ networkId: z.string().min(1).max(200),
80
+ daemonId: z.string().min(1).max(200),
81
+ directory: directoryText,
82
+ }).strict(),
83
+ source: z.literal("opencode-v2-api"),
84
+ state: z.literal("snapshot"),
85
+ invocationVerified: z.literal(false),
86
+ completeness: z.literal("unknown"),
87
+ observedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/),
88
+ providers: z.array(z.object({
89
+ id: identifier,
90
+ activation: z.enum(["auto", "enabled", "disabled"]),
91
+ }).strict()).max(20_000),
92
+ models: z.array(z.object({
93
+ providerId: identifier,
94
+ id: identifier,
95
+ }).strict()).max(20_000),
96
+ defaultModel: z.object({ providerId: identifier, id: identifier }).strict().nullable(),
97
+ }).strict();
98
+
99
+ export type OpenCodeCatalogSnapshotWire = z.infer<typeof opencodeCatalogSnapshotSchema>;
100
+
101
+ export const opencodeCatalogAckSchema = z.object({
102
+ request_id: z.string().min(1).max(200),
103
+ error: z.enum(CATALOG_ERROR_CODES).optional(),
104
+ snapshot: opencodeCatalogSnapshotSchema.optional(),
105
+ }).strict();
106
+
107
+ interface PendingCatalogRead {
108
+ requestId: string;
109
+ daemonNodeId: string;
110
+ daemonAlias: string;
111
+ networkId: string;
112
+ nodeId: string;
113
+ alias: string;
114
+ directory: string | null;
115
+ expiresAt: number;
116
+ taken: boolean;
117
+ }
118
+
119
+ export interface StoredCatalogRead {
120
+ requestId: string;
121
+ networkId: string;
122
+ daemonNodeId: string;
123
+ nodeId: string;
124
+ status: "pending" | "ready" | "failed" | "expired";
125
+ error?: CatalogErrorCode;
126
+ snapshot?: OpenCodeCatalogSnapshotWire;
127
+ expiresAt: number;
128
+ }
129
+
130
+ const pending = new Map<string, PendingCatalogRead>();
131
+ const stored = new Map<string, StoredCatalogRead>();
132
+
133
+ export function resetOpenCodeCatalogForTest(): void {
134
+ pending.clear();
135
+ stored.clear();
136
+ }
137
+
138
+ export function expireOpenCodeCatalogForTest(requestId: string): void {
139
+ // Just past the read deadline, still inside the retention window. Setting
140
+ // the deadline to 0 would also drop the stored row and look like not_found.
141
+ const expiredAt = Date.now() - 1;
142
+ const row = pending.get(requestId);
143
+ if (row) row.expiresAt = expiredAt;
144
+ const result = stored.get(requestId);
145
+ if (result) result.expiresAt = expiredAt;
146
+ }
147
+
148
+ function childNodeId(requestId: string): string | null {
149
+ return requestId.startsWith("cr_") ? `node_${requestId.slice(3)}` : null;
150
+ }
151
+
152
+ function supervisorRole(snapshot: string | null): boolean {
153
+ if (!snapshot) return false;
154
+ try {
155
+ const parsed = JSON.parse(snapshot) as { role?: unknown };
156
+ return parsed.role === "host_supervisor";
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ function v2Flags(flagsJson: string): boolean {
163
+ try {
164
+ const flags = JSON.parse(flagsJson) as { opencodeGeneration?: unknown; opencodeUnsafeTools?: unknown };
165
+ return flags.opencodeGeneration === "v2" && flags.opencodeUnsafeTools === true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ export interface QueueCatalogInput {
172
+ callerUserId: string | null;
173
+ callerIsNode: boolean;
174
+ networkId: string | null;
175
+ daemonNodeId: string;
176
+ nodeId: string;
177
+ }
178
+
179
+ function evictExpired(now = Date.now()): void {
180
+ for (const [id, row] of pending) {
181
+ if (now > row.expiresAt) pending.delete(id);
182
+ }
183
+ for (const [id, row] of stored) {
184
+ if (row.status === "pending" && now > row.expiresAt) row.status = "expired";
185
+ if (now > row.expiresAt + TTL_MS) stored.delete(id);
186
+ }
187
+ }
188
+
189
+ export function queueOpenCodeCatalogRead(input: QueueCatalogInput):
190
+ | { ok: true; request_id: string; status: "pending"; daemon_alias: string; network_id: string }
191
+ | { ok: false; error: string } {
192
+ evictExpired();
193
+ if (input.callerIsNode) return { ok: false, error: "user_token_required" };
194
+ if (!input.callerUserId) return { ok: false, error: "auth_required" };
195
+ if (!input.networkId) return { ok: false, error: "network_id_required" };
196
+ const role = getUserNetworkRole(input.callerUserId, input.networkId);
197
+ if (!role) return { ok: false, error: "access_denied" };
198
+ if (pending.size >= MAX_PENDING) return { ok: false, error: "catalog_busy" };
199
+
200
+ const daemon = db.get<{ node_id: string; alias: string; config_snapshot: string | null }>(
201
+ `SELECT node_id, alias, config_snapshot FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
202
+ input.daemonNodeId, input.networkId,
203
+ );
204
+ if (!daemon) return { ok: false, error: "daemon_not_found" };
205
+ if (!supervisorRole(daemon.config_snapshot)) return { ok: false, error: "daemon_not_supervisor" };
206
+
207
+ const child = db.get<{ node_id: string }>(
208
+ `SELECT node_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
209
+ input.nodeId, input.networkId,
210
+ );
211
+ if (!child) return { ok: false, error: "node_not_found" };
212
+
213
+ const creates = db.all<{
214
+ request_id: string; daemon_node_id: string; child_name: string; runtime: string;
215
+ flags_json: string; workdir: string | null; status: string;
216
+ }>(
217
+ `SELECT request_id, daemon_node_id, child_name, runtime, flags_json, workdir, status
218
+ FROM node_create_requests
219
+ WHERE network_id = ?1 AND daemon_node_id = ?2 AND status IN ('succeeded', 'delivered')`,
220
+ input.networkId, input.daemonNodeId,
221
+ );
222
+ const owned = creates.filter(row => childNodeId(row.request_id) === input.nodeId && row.daemon_node_id === input.daemonNodeId);
223
+ if (owned.length !== 1) return { ok: false, error: "node_not_found" };
224
+ const row = owned[0]!;
225
+ if (row.runtime !== "opencode-cli" || !v2Flags(row.flags_json)) return { ok: false, error: "not_opencode_v2" };
226
+
227
+ const requestId = `cat_${uuidv4()}`;
228
+ const directory = typeof row.workdir === "string" && row.workdir.length > 0 ? row.workdir : null;
229
+ const record: PendingCatalogRead = {
230
+ requestId,
231
+ daemonNodeId: daemon.node_id,
232
+ daemonAlias: daemon.alias,
233
+ networkId: input.networkId,
234
+ nodeId: input.nodeId,
235
+ alias: row.child_name,
236
+ directory,
237
+ expiresAt: Date.now() + TTL_MS,
238
+ taken: false,
239
+ };
240
+ pending.set(requestId, record);
241
+ stored.set(requestId, {
242
+ requestId,
243
+ networkId: input.networkId,
244
+ daemonNodeId: daemon.node_id,
245
+ nodeId: input.nodeId,
246
+ status: "pending",
247
+ expiresAt: record.expiresAt,
248
+ });
249
+ return { ok: true, request_id: requestId, status: "pending", daemon_alias: daemon.alias, network_id: input.networkId };
250
+ }
251
+
252
+ export function takeOpenCodeCatalogRequest(requestId: string, daemonNodeId: string):
253
+ | { ok: true; request_id: string; node_id: string; alias: string; directory: string | null; scope: { networkId: string; daemonId: string } }
254
+ | { ok: false; error: "catalog_request_unavailable" } {
255
+ const row = pending.get(requestId);
256
+ if (!row || row.daemonNodeId !== daemonNodeId || row.taken) return { ok: false, error: "catalog_request_unavailable" };
257
+ if (Date.now() > row.expiresAt) {
258
+ row.taken = true;
259
+ const result = stored.get(requestId);
260
+ if (result && result.status === "pending") result.status = "expired";
261
+ return { ok: false, error: "catalog_request_unavailable" };
262
+ }
263
+ row.taken = true;
264
+ return {
265
+ ok: true,
266
+ request_id: row.requestId,
267
+ node_id: row.nodeId,
268
+ alias: row.alias,
269
+ directory: row.directory,
270
+ scope: { networkId: row.networkId, daemonId: row.daemonNodeId },
271
+ };
272
+ }
273
+
274
+ function consistent(snapshot: OpenCodeCatalogSnapshotWire): boolean {
275
+ const providers = new Set<string>();
276
+ for (const row of snapshot.providers) {
277
+ if (providers.has(row.id)) return false;
278
+ providers.add(row.id);
279
+ }
280
+ const models = new Set<string>();
281
+ for (const row of snapshot.models) {
282
+ if (!providers.has(row.providerId)) return false;
283
+ const key = JSON.stringify([row.providerId, row.id]);
284
+ if (models.has(key)) return false;
285
+ models.add(key);
286
+ }
287
+ if (snapshot.defaultModel && !models.has(JSON.stringify([snapshot.defaultModel.providerId, snapshot.defaultModel.id]))) return false;
288
+ return Buffer.byteLength(JSON.stringify(snapshot), "utf8") <= 2 * 1024 * 1024;
289
+ }
290
+
291
+ export function ackOpenCodeCatalog(daemonNodeId: string, body: unknown):
292
+ | { ok: true; status: "ready" | "failed"; error?: CatalogErrorCode }
293
+ | { ok: false; error: "catalog_request_unavailable" | "invalid_ack" } {
294
+ const parsed = opencodeCatalogAckSchema.safeParse(body);
295
+ if (!parsed.success) return { ok: false, error: "invalid_ack" };
296
+ const ack = parsed.data;
297
+ const hasSnapshot = ack.snapshot !== undefined;
298
+ const hasError = ack.error !== undefined;
299
+ if (hasSnapshot === hasError) return { ok: false, error: "invalid_ack" };
300
+ const row = pending.get(ack.request_id);
301
+ const result = stored.get(ack.request_id);
302
+ if (!row || !result || row.daemonNodeId !== daemonNodeId || !row.taken) {
303
+ return { ok: false, error: "catalog_request_unavailable" };
304
+ }
305
+ if (Date.now() > row.expiresAt || result.status !== "pending") {
306
+ if (result.status === "pending") result.status = "expired";
307
+ return { ok: false, error: "catalog_request_unavailable" };
308
+ }
309
+ if (ack.error) {
310
+ result.status = "failed";
311
+ result.error = ack.error;
312
+ return { ok: true, status: "failed", error: ack.error };
313
+ }
314
+ const snapshot = ack.snapshot!;
315
+ // A stored workdir must match. A null workdir (legacy create) has no
316
+ // hub-side path to compare; the daemon binds that read to the project
317
+ // directory it already owns. Hub does not see that filesystem.
318
+ const directoryOk = row.directory ? catalogDirectoriesMatch(row.directory, snapshot.scope.directory) : true;
319
+ if (snapshot.scope.networkId !== row.networkId || snapshot.scope.daemonId !== row.daemonNodeId || !directoryOk || !consistent(snapshot)) {
320
+ result.status = "failed";
321
+ result.error = "invalid_response";
322
+ return { ok: true, status: "failed", error: "invalid_response" };
323
+ }
324
+ result.status = "ready";
325
+ result.snapshot = snapshot;
326
+ return { ok: true, status: "ready" };
327
+ }
328
+
329
+ export function getOpenCodeCatalogResult(input: {
330
+ callerUserId: string | null;
331
+ callerIsNode: boolean;
332
+ networkId: string | null;
333
+ requestId: string;
334
+ }):
335
+ | { ok: true; status: "pending"; request_id: string }
336
+ | { ok: true; status: "ready"; request_id: string; snapshot: OpenCodeCatalogSnapshotWire }
337
+ | { ok: false; status?: "failed" | "expired"; error: string; request_id?: string } {
338
+ if (input.callerIsNode) return { ok: false, error: "user_token_required" };
339
+ if (!input.callerUserId) return { ok: false, error: "auth_required" };
340
+ if (!input.networkId) return { ok: false, error: "network_id_required" };
341
+ if (!getUserNetworkRole(input.callerUserId, input.networkId)) return { ok: false, error: "access_denied" };
342
+ evictExpired();
343
+ const result = stored.get(input.requestId);
344
+ if (!result || result.networkId !== input.networkId) return { ok: false, error: "not_found" };
345
+ if (result.status === "pending" && Date.now() > result.expiresAt) result.status = "expired";
346
+ if (result.status === "pending") return { ok: true, status: "pending", request_id: result.requestId };
347
+ if (result.status === "ready" && result.snapshot) {
348
+ return { ok: true, status: "ready", request_id: result.requestId, snapshot: result.snapshot };
349
+ }
350
+ if (result.status === "expired") return { ok: false, status: "expired", error: "unavailable", request_id: result.requestId };
351
+ return { ok: false, status: "failed", error: result.error || "unavailable", request_id: result.requestId };
352
+ }
@@ -4,12 +4,19 @@ export function validCodexInventory(v: any): boolean {
4
4
  && Object.keys(o).every(k => allowed.includes(k));
5
5
  const id = (s: any) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
6
6
  const nullable = (s: any) => s === null || id(s);
7
- if (!fields(v, ['observed_at', 'scope', 'rows', 'installation']) || !Number.isSafeInteger(v.observed_at) || v.observed_at <= 0
7
+ if (!fields(v, ['observed_at', 'scope', 'rows', 'installation', 'local_discovery']) || !Number.isSafeInteger(v.observed_at) || v.observed_at <= 0
8
8
  || v.scope !== 'hub_bound_nodes' || !Array.isArray(v.rows) || v.rows.length > 200
9
9
  || new Set(v.rows.map((r: any) => r?.node_id)).size !== v.rows.length) return false;
10
10
  if (v.installation !== undefined && (!fields(v.installation, ['status', 'version'])
11
11
  || !['found', 'missing', 'unknown'].includes(v.installation.status)
12
12
  || !(v.installation.version === null || (typeof v.installation.version === 'string' && /^\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+)?$/.test(v.installation.version) && v.installation.version.length <= 64)))) return false;
13
+ if (v.local_discovery !== undefined) {
14
+ const local = v.local_discovery;
15
+ if (!fields(local, ['status', 'rows']) || !['observed', 'unavailable', 'limit'].includes(local.status)
16
+ || !Array.isArray(local.rows) || (local.status !== 'observed' && local.rows.length !== 0)
17
+ || !validCodexInventory({ observed_at: v.observed_at, scope: v.scope, rows: local.rows })
18
+ || local.rows.some((r: any) => r.status !== 'observed' || r.config_revision !== null || v.rows.some((b: any) => b.node_id === r.node_id))) return false;
19
+ }
13
20
  for (const r of v.rows) {
14
21
  if (!r || !id(r.node_id) || typeof r.alias !== 'string' || !/^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,99}$/u.test(r.alias)) return false;
15
22
  if (['unavailable', 'not_codex'].includes(r.status)) {
@@ -419,6 +419,46 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
419
419
  expect(JSON.parse(audits[0].detail).backup_path).toBe("/home/u/.anet/deleted/1719647200000-sd-child-a");
420
420
  });
421
421
 
422
+ // Daemon-mediated delete used to drop only the nodes row. The child's sessions
423
+ // row stayed behind as "offline", so /api/status — and every client list built
424
+ // on it — kept showing the deleted node forever (desktop: the node stays put
425
+ // under 「删除请求已提交」). REST DELETE /api/nodes/:id already drops both.
426
+ test("daemon ack 'stopped' on delete branch → the child's session row is gone too", async () => {
427
+ setupAlphaNetwork();
428
+ db.run(
429
+ "INSERT INTO sessions (resume_id, alias, node_id, network_id, status) VALUES (?1, ?2, ?3, ?4, 'idle')",
430
+ ["resume-sd-child-a", CHILD_A_ALIAS, CHILD_A_ID, NET_A],
431
+ );
432
+ const userTools = buildHandlers(USER_A_ID);
433
+ const dispatch = await call(userTools.delete_node, {
434
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
435
+ });
436
+ expect(dispatch.ok).toBe(true);
437
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
438
+ await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
439
+ const left = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM sessions WHERE node_id = ?1", CHILD_A_ID);
440
+ expect(Number(left?.n ?? 0)).toBe(0);
441
+ });
442
+
443
+ test("delete finalize drops the session by node_id, sparing a same-alias rebuilt node", async () => {
444
+ setupAlphaNetwork();
445
+ const userTools = buildHandlers(USER_A_ID);
446
+ const dispatch = await call(userTools.delete_node, {
447
+ child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS,
448
+ });
449
+ expect(dispatch.ok).toBe(true);
450
+ // Before the ack lands, a new node takes the same alias and registers.
451
+ const REBUILT_ID = "node_sd_child_a_rebuilt";
452
+ db.run(
453
+ "INSERT INTO sessions (resume_id, alias, node_id, network_id, status) VALUES (?1, ?2, ?3, ?4, 'idle')",
454
+ ["resume-sd-child-a-rebuilt", CHILD_A_ALIAS, REBUILT_ID, NET_A],
455
+ );
456
+ const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A);
457
+ await call(daemonTools.ack_stop_request, { request_id: dispatch.request_id as string, status: "stopped", exit_signal: "SIGTERM" });
458
+ const rebuilt = db.get<{ status: string }>("SELECT status FROM sessions WHERE node_id = ?1", REBUILT_ID);
459
+ expect(rebuilt?.status).toBe("idle");
460
+ });
461
+
422
462
  // #1448 finding-4 — stop/delete 必须校验 daemon 是 child 的权威创建者(镜像 start)。
423
463
  // 改前只校验 daemon 存在+同网 → 同网调用方传错 daemon_node_id,门铃路由到错 daemon。
424
464
  // f3 合入后错 daemon 会 sweep 空 + ack stopped → hub 假收敛 stopped 而 child 仍在跑。
@@ -202,15 +202,12 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
202
202
  // node 61 / 62,433 B → 62,946 B (+513); user 58 / 57,228 B unchanged
203
203
  // (report_status is not on the user list). Budget only this measured addition
204
204
  // (~154/509 B node/all margin).
205
- // #906 registers daemon_provider_snapshot (user) plus pull/ack (protocol).
206
- // Measured there: all 90 / 80,307 B; node 61 / 62,556 B; user 59 / 57,372 B,
207
- // ceilings 80,800 / 63,100 / 57,500.
208
- // Hub #938 adds clone_node (audience both, so it lands on the node list and
209
- // the user list) plus optional expect_clone on get_create_request (protocol,
210
- // so only the full list). Measured on this combined tree:
211
- // all 91 / 81,228 B; node 62 / 63,281 B; user 60 / 58,169 B.
212
- // Ceilings keep about 400 B of margin and nothing else.
213
- const CEILINGS = { node: 63_700, user: 58_600, all: 81_700 } as const;
205
+ // #906 plus Hub #938 clone_node (all 91 / 81,228 B; node 62 / 63,281 B;
206
+ // user 60 / 58,169 B). OpenCode V2 catalog adds 2 user tools and 2 protocol
207
+ // tools. Measured on this combined tree: all 95 / 85,069 B; node 62 / 63,281 B
208
+ // unchanged (protocol tools stay off the node list); user 62 / 59,409 B.
209
+ // Ceilings keep about 400 B and nothing else.
210
+ const CEILINGS = { node: 63_700, user: 59_810, all: 85_500 } as const;
214
211
  test("node / user / all", async () => {
215
212
  const node = await listTools(T.node_normal);
216
213
  const user = await listTools(T.owner);
@@ -41,8 +41,10 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
41
41
  get_stop_request: "protocol", ack_stop_request: "protocol",
42
42
  get_start_request: "protocol", ack_start_request: "protocol",
43
43
  get_probe_request: "protocol", ack_probe_request: "protocol",
44
+ get_opencode_catalog_request: "protocol", ack_opencode_catalog: "protocol",
44
45
  // ── 人:节点令牌一律 user_token_required(项目管理)/ node_token_cannot_browse_files / node_token_cannot_read_logs ──
45
46
  projects_create: "user", projects_update: "user",
47
+ read_opencode_catalog: "user", get_opencode_catalog: "user",
46
48
  list_node_files: "user", read_node_file: "user", tail_node_logs: "user",
47
49
  // ── 两边都能调 ──
48
50
  submit_skill: "both", list_skills: "both", get_skill: "both", review_skill: "both",
package/src/tools.ts CHANGED
@@ -73,6 +73,13 @@ import {
73
73
  startPendingProbeGcTimer,
74
74
  startProbeSweeperTimer,
75
75
  } from "./probe.js";
76
+ import {
77
+ ackOpenCodeCatalog,
78
+ getOpenCodeCatalogResult,
79
+ opencodeCatalogAckSchema,
80
+ queueOpenCodeCatalogRead,
81
+ takeOpenCodeCatalogRequest,
82
+ } from "./opencode-v2-catalog.js";
76
83
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
77
84
  import {
78
85
  ALLOWED_FLAGS,
@@ -4122,10 +4129,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4122
4129
  const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
4123
4130
  server.tool(
4124
4131
  "daemon_provider_snapshot", "Admin Provider: refresh/put/apply/login use daemon id; read uses request id. put: revision+provider; apply: revision+node selection; login: provider={action:start|read|cancel|save|accounts,session_id?,label?}. No login tokens.",
4125
- { action: z.enum(["refresh", "read", "put", "apply", "login"]), id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), revision: z.number().int().min(0).optional(), provider: z.unknown().optional() },
4126
- async ({ action, id, network_id, revision, provider }) => {
4132
+ { action: z.enum(["refresh", "read", "put", "apply", "login"]), id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), revision: z.number().int().min(0).optional(), provider: z.unknown().optional(), include_discovery: z.boolean().optional() },
4133
+ async ({ action, id, network_id, revision, provider, include_discovery }) => {
4127
4134
  const human = providerHuman(network_id);
4128
- if (action === "read") return providerReply(getProviderSnapshot(human, id));
4135
+ if (action === "read") return providerReply(getProviderSnapshot(human, id, include_discovery));
4129
4136
  const daemon_node_id = id;
4130
4137
  const result = action === 'login' ? requestProviderLogin(human, daemon_node_id, provider)
4131
4138
  : action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
@@ -4138,8 +4145,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4138
4145
  },
4139
4146
  );
4140
4147
  server.tool(
4141
- "pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional() },
4142
- async ({ accept_writes, accept_applications, accept_logins }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins)),
4148
+ "pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional(), accept_local_discovery: z.boolean().optional() },
4149
+ async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins, accept_local_discovery)),
4143
4150
  );
4144
4151
  server.tool(
4145
4152
  "ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
@@ -5581,6 +5588,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5581
5588
  );
5582
5589
  }
5583
5590
  db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
5591
+ // Same as REST DELETE /api/nodes/:id: without this the sessions row
5592
+ // stays "offline" and /api/status lists the deleted node forever.
5593
+ // By node_id, not alias — a same-alias rebuild keeps its session.
5594
+ db.run(`DELETE FROM sessions WHERE node_id = ?1`, [row.child_node_id]);
5584
5595
  clearNodeFromAgentTeams(row.child_node_id); // #764
5585
5596
  auditCreateNodeStrict({
5586
5597
  action: "delete_node_completed",
@@ -5606,7 +5617,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5606
5617
  // 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
5607
5618
  // 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
5608
5619
  const did = status === "stopped" && row.action === "delete"
5609
- ? "revoked ntok + DELETE FROM nodes"
5620
+ ? "revoked ntok + DELETE FROM nodes + sessions"
5610
5621
  : status === "stopped" && row.action === "stop"
5611
5622
  ? "lifecycle_state=stopped"
5612
5623
  : status === "stop_failed"
@@ -6380,6 +6391,80 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6380
6391
  },
6381
6392
  );
6382
6393
 
6394
+ // OpenCode V2 catalog. Human tokens queue; the owning daemon pulls scope
6395
+ // only and acks a whitelist snapshot. pending is stored authorization, not
6396
+ // a snapshot and not proof the doorbell was received.
6397
+ server.registerTool(
6398
+ "read_opencode_catalog",
6399
+ {
6400
+ description: "Queue a native OpenCode V2 catalog read for one owned child. ok with status pending means authorized and stored in memory, not a snapshot and not proof a daemon received the doorbell. Does not start a server. url, authorization, directory, settings, headers, and body are rejected.",
6401
+ inputSchema: z.object({
6402
+ daemon_node_id: z.string().min(1).max(200),
6403
+ node_id: z.string().min(1).max(200),
6404
+ network_id: z.string().max(200).optional(),
6405
+ }).strict() as any,
6406
+ },
6407
+ async (args: { daemon_node_id: string; node_id: string; network_id?: string }) => {
6408
+ const networkId = getNetworkId(args.network_id);
6409
+ const queued = queueOpenCodeCatalogRead({
6410
+ callerUserId: enforceUserId ?? null,
6411
+ callerIsNode: callerTokenIsNetwork,
6412
+ networkId,
6413
+ daemonNodeId: args.daemon_node_id,
6414
+ nodeId: args.node_id,
6415
+ });
6416
+ if (!queued.ok) return { content: [{ type: "text" as const, text: JSON.stringify(queued) }] };
6417
+ pushEvent(queued.daemon_alias, { type: "opencode_catalog", request_id: queued.request_id }, queued.network_id);
6418
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "pending", request_id: queued.request_id }) }] };
6419
+ },
6420
+ );
6421
+
6422
+ server.registerTool(
6423
+ "get_opencode_catalog",
6424
+ {
6425
+ description: "Poll one queued OpenCode V2 catalog read. status pending is not a snapshot. Only status ready returns a whitelist snapshot, still completeness unknown and invocationVerified false.",
6426
+ inputSchema: z.object({
6427
+ request_id: z.string().min(1).max(200),
6428
+ network_id: z.string().max(200).optional(),
6429
+ }).strict() as any,
6430
+ },
6431
+ async (args: { request_id: string; network_id?: string }) => {
6432
+ const result = getOpenCodeCatalogResult({
6433
+ callerUserId: enforceUserId ?? null,
6434
+ callerIsNode: callerTokenIsNetwork,
6435
+ networkId: getNetworkId(args.network_id),
6436
+ requestId: args.request_id,
6437
+ });
6438
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
6439
+ },
6440
+ );
6441
+
6442
+ server.registerTool(
6443
+ "get_opencode_catalog_request",
6444
+ {
6445
+ description: "Daemon pulls one authorized OpenCode V2 catalog read. Scope only: no URL, authorization, settings, headers, or body.",
6446
+ inputSchema: z.object({ request_id: z.string().min(1).max(200) }).strict() as any,
6447
+ },
6448
+ async (args: { request_id: string }) => {
6449
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
6450
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
6451
+ return { content: [{ type: "text" as const, text: JSON.stringify(takeOpenCodeCatalogRequest(args.request_id, callerDaemon.daemonNodeId)) }] };
6452
+ },
6453
+ );
6454
+
6455
+ server.registerTool(
6456
+ "ack_opencode_catalog",
6457
+ {
6458
+ description: "Daemon stores a whitelist OpenCode V2 catalog snapshot or a closed error code. Extra fields are rejected. A stored failure is status failed, not a transport error.",
6459
+ inputSchema: opencodeCatalogAckSchema as any,
6460
+ },
6461
+ async (args: unknown) => {
6462
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
6463
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
6464
+ return { content: [{ type: "text" as const, text: JSON.stringify(ackOpenCodeCatalog(callerDaemon.daemonNodeId, args)) }] };
6465
+ },
6466
+ );
6467
+
6383
6468
  // ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
6384
6469
  // 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
6385
6470
  // 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。