@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 +1 -1
- package/src/daemon-provider-snapshots.test.ts +14 -3
- package/src/daemon-provider-snapshots.ts +17 -4
- package/src/daemon-token-owner-http.test.ts +4 -1
- package/src/mcp-context-cost-http.test.ts +4 -3
- package/src/node-permissions.ts +2 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/shared/codex-inventory.ts +8 -1
- package/src/stop-delete-node.test.ts +40 -0
- package/src/tool-audience-http.test.ts +6 -9
- package/src/tool-audience.ts +2 -0
- package/src/tools.ts +91 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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
|
|
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
|
|
30
|
-
//
|
|
31
|
-
|
|
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,或者拆短)。 */
|
package/src/node-permissions.ts
CHANGED
|
@@ -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
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
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);
|
package/src/tool-audience.ts
CHANGED
|
@@ -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 / 读项目;删除和管理项目只给人。
|