@sleep2agi/commhub-server 0.9.0-preview.121 → 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/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/daemon-provider-snapshots.test.ts +117 -3
- package/src/daemon-provider-snapshots.ts +64 -8
- package/src/daemon-token-owner-http.test.ts +7 -2
- package/src/db.ts +14 -0
- package/src/mcp-context-cost-http.test.ts +4 -1
- package/src/node-permissions.ts +3 -1
- 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/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +5 -2
- package/src/shared/provider-application.ts +8 -3
- package/src/stop-delete-node.test.ts +40 -0
- package/src/tool-audience-http.test.ts +7 -9
- package/src/tool-audience.ts +3 -1
- package/src/tools.ts +282 -16
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// #906 managed login transport: only explicit actions and secret-free projections.
|
|
2
|
+
export type LoginIntent = { action: 'start' | 'read' | 'cancel' | 'save' | 'accounts'; session_id?: string; label?: string };
|
|
3
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
4
|
+
const object = (v: any) => v && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
const keys = (v: any, names: string[]) => Object.keys(v).every(k => names.includes(k));
|
|
6
|
+
export function validLoginIntent(v: any): v is LoginIntent {
|
|
7
|
+
return object(v) && keys(v, ['action','session_id','label'])
|
|
8
|
+
&& ['start','read','cancel','save','accounts'].includes(v.action)
|
|
9
|
+
&& (v.action === 'accounts' ? v.session_id === undefined : typeof v.session_id === 'string' && uuid.test(v.session_id))
|
|
10
|
+
&& (v.action === 'save' ? typeof v.label === 'string' && !!v.label.trim() && v.label.length <= 100 && !/[\x00-\x1f\x7f]/.test(v.label) : v.label === undefined);
|
|
11
|
+
}
|
|
12
|
+
export function validLoginReceipt(v: any, intent: LoginIntent, network: string, daemon: string): boolean {
|
|
13
|
+
if (!object(v)) return false;
|
|
14
|
+
if (intent.action === 'accounts') return keys(v, ['accounts']) && Array.isArray(v.accounts) && v.accounts.length <= 100
|
|
15
|
+
&& v.accounts.every((a: any) => object(a) && keys(a, ['version','network_id','daemon_node_id','account_id','label','created_at','auth_state'])
|
|
16
|
+
&& a.version === 1 && a.network_id === network && a.daemon_node_id === daemon
|
|
17
|
+
&& typeof a.account_id === 'string' && uuid.test(a.account_id)
|
|
18
|
+
&& typeof a.label === 'string' && a.label.length <= 100
|
|
19
|
+
&& Number.isSafeInteger(a.created_at) && a.auth_state === 'not_checked');
|
|
20
|
+
if (!keys(v, ['request_id','status','error','challenge','account_id']) || v.request_id !== intent.session_id
|
|
21
|
+
|| !['idle','starting','awaiting_user','verifying','authenticated','cancelled','failed'].includes(v.status)) return false;
|
|
22
|
+
if (v.error !== undefined && !['login_start_failed','login_rejected','account_read_failed','login_process_exited','login_expired'].includes(v.error)) return false;
|
|
23
|
+
if (v.account_id !== undefined && (v.status !== 'authenticated' || typeof v.account_id !== 'string' || !uuid.test(v.account_id))) return false;
|
|
24
|
+
if (intent.action === 'cancel' && v.status !== 'cancelled') return false;
|
|
25
|
+
if (intent.action === 'save' && (v.status !== 'authenticated' || !v.account_id)) return false;
|
|
26
|
+
if (v.status === 'awaiting_user') return object(v.challenge) && keys(v.challenge, ['loginId','verificationUrl','userCode'])
|
|
27
|
+
&& typeof v.challenge.loginId === 'string' && uuid.test(v.challenge.loginId)
|
|
28
|
+
&& v.challenge.verificationUrl === 'https://auth.openai.com/codex/device'
|
|
29
|
+
&& typeof v.challenge.userCode === 'string' && /^[A-Z0-9-]{4,32}$/i.test(v.challenge.userCode);
|
|
30
|
+
return v.challenge === undefined;
|
|
31
|
+
}
|
|
@@ -28,13 +28,16 @@ export function validateDaemonProviderSnapshot(value: unknown, networkId: string
|
|
|
28
28
|
obj(r, ["runtime", "auth"]); if (r.runtime !== "codex-tui") fail();
|
|
29
29
|
const auths = arr(r.auth, 32); unique(auths, "id");
|
|
30
30
|
for (const a of auths) {
|
|
31
|
-
obj(a, ["id", "kind", "models", "baseUrl", "credential_present", "verification", "application"]);
|
|
31
|
+
obj(a, ["id", "kind", "models", "baseUrl", "account_id", "credential_present", "verification", "application"]);
|
|
32
32
|
if (!id(a.id) || a.verification !== "not_checked" || a.application !== "not_applied") fail();
|
|
33
33
|
const models = arr(a.models, 200);
|
|
34
34
|
if (new Set(models).size !== models.length || models.some(m => typeof m !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(m))) fail();
|
|
35
35
|
if (a.kind === "chatgpt") {
|
|
36
|
-
if (p.id !== "openai" || a.baseUrl !== undefined || a.credential_present !==
|
|
36
|
+
if (p.id !== "openai" || a.baseUrl !== undefined || typeof a.credential_present !== 'boolean') fail();
|
|
37
|
+
if (a.account_id !== undefined && (typeof a.account_id !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(a.account_id))) fail();
|
|
38
|
+
if (a.credential_present && !a.account_id) fail();
|
|
37
39
|
} else if (a.kind === "api_key") {
|
|
40
|
+
if (a.account_id !== undefined) fail();
|
|
38
41
|
if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
|
|
39
42
|
let u: URL; try { u = new URL(a.baseUrl); } catch { return fail(); }
|
|
40
43
|
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) fail();
|
|
@@ -2,23 +2,28 @@
|
|
|
2
2
|
export type ProviderApplicationSelection = {
|
|
3
3
|
node_id: string; node_revision: string; provider_id: string; auth_id: string;
|
|
4
4
|
model: string; confirm_restart: true;
|
|
5
|
+
account_id?: string;
|
|
5
6
|
};
|
|
6
7
|
const fields = (v: any, names: string[]) => v && typeof v === 'object' && !Array.isArray(v)
|
|
7
8
|
&& Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k));
|
|
8
9
|
const id = (v: any) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(v);
|
|
9
10
|
const hash = (v: any) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
11
|
+
const account = (v: any) => typeof v === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(v);
|
|
10
12
|
export function validProviderApplicationSelection(v: any): v is ProviderApplicationSelection {
|
|
11
|
-
return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart'
|
|
13
|
+
return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart',
|
|
14
|
+
...(v?.account_id !== undefined ? ['account_id'] : [])])
|
|
15
|
+
&& (v.account_id === undefined || (v.provider_id === 'openai' && account(v.account_id)))
|
|
12
16
|
&& id(v.node_id) && hash(v.node_revision) && id(v.provider_id) && id(v.auth_id) && id(v.model)
|
|
13
17
|
&& v.confirm_restart === true;
|
|
14
18
|
}
|
|
15
19
|
export function validProviderApplicationReceipt(v: any, requestId: string, revision: number,
|
|
16
20
|
selection: ProviderApplicationSelection): boolean {
|
|
17
21
|
return fields(v, ['status', 'request_id', 'node_id', 'provider_id', 'auth_id', 'model', 'runtime_provider',
|
|
18
|
-
'node_revision', 'provider_revision', 'verification', 'applied_at'])
|
|
22
|
+
'node_revision', 'provider_revision', 'verification', 'applied_at', ...(selection.account_id ? ['account_id'] : [])])
|
|
19
23
|
&& v.status === 'applied' && v.request_id === requestId && v.node_id === selection.node_id
|
|
20
24
|
&& v.provider_id === selection.provider_id && v.auth_id === selection.auth_id && v.model === selection.model
|
|
21
|
-
&&
|
|
25
|
+
&& (selection.account_id ? v.runtime_provider === 'openai' && v.account_id === selection.account_id
|
|
26
|
+
: typeof v.runtime_provider === 'string' && /^anet_[a-f0-9]{20}$/.test(v.runtime_provider))
|
|
22
27
|
&& hash(v.node_revision) && v.provider_revision === revision && v.verification === 'runtime_confirmed'
|
|
23
28
|
&& Number.isSafeInteger(v.applied_at) && v.applied_at > 0 && v.applied_at <= Date.now() + 30_000;
|
|
24
29
|
}
|
|
@@ -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 仍在跑。
|
|
@@ -163,7 +163,7 @@ describe("what each caller sees", () => {
|
|
|
163
163
|
|
|
164
164
|
test("node modes: readonly / restricted drop broadcast + node writes + human-only; enforce drops human-only for normal nodes", async () => {
|
|
165
165
|
const ro = new Set((await listTools(T.node_readonly)).names);
|
|
166
|
-
for (const n of ["broadcast", "update_node_config", "create_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
|
|
166
|
+
for (const n of ["broadcast", "update_node_config", "create_node", "clone_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
|
|
167
167
|
for (const n of ["send_task", "requirements_update", "report_status", "get_all_status"]) expect([n, ro.has(n)]).toEqual([n, true]); // 看参数才拒的照列
|
|
168
168
|
expect(new Set((await listTools(T.node_normal)).names).has("upsert_provider")).toBe(true); // log 模式:调得通,照列
|
|
169
169
|
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
@@ -202,14 +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
|
-
// (493 B margin, same order as the previous all margin).
|
|
212
|
-
const CEILINGS = { node: 63_100, user: 57_500, all: 80_800 } 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;
|
|
213
211
|
test("node / user / all", async () => {
|
|
214
212
|
const node = await listTools(T.node_normal);
|
|
215
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",
|
|
@@ -53,7 +55,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
|
53
55
|
send_desktop_message: "both", broadcast: "both", get_completions: "both",
|
|
54
56
|
update_node_config: "both", read_node_rules_file: "both", write_node_rules_file: "both",
|
|
55
57
|
list_node_skills: "both", read_node_skill: "both", get_rules_file_result: "both",
|
|
56
|
-
restart_node: "both", list_host_supervisors: "both", create_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
|
|
58
|
+
restart_node: "both", list_host_supervisors: "both", create_node: "both", clone_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
|
|
57
59
|
upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
|
|
58
60
|
list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
|
|
59
61
|
requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
|
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
2
|
+
import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
3
3
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
4
4
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
5
5
|
import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
auditCreateNodeStrict,
|
|
56
56
|
resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
|
|
57
57
|
} from "./create-node.js";
|
|
58
|
+
import { decideClone, factsFromRows, parseWorkdirPolicy } from "./clone-node.js";
|
|
58
59
|
import {
|
|
59
60
|
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
60
61
|
VaultError,
|
|
@@ -72,6 +73,13 @@ import {
|
|
|
72
73
|
startPendingProbeGcTimer,
|
|
73
74
|
startProbeSweeperTimer,
|
|
74
75
|
} from "./probe.js";
|
|
76
|
+
import {
|
|
77
|
+
ackOpenCodeCatalog,
|
|
78
|
+
getOpenCodeCatalogResult,
|
|
79
|
+
opencodeCatalogAckSchema,
|
|
80
|
+
queueOpenCodeCatalogRead,
|
|
81
|
+
takeOpenCodeCatalogRequest,
|
|
82
|
+
} from "./opencode-v2-catalog.js";
|
|
75
83
|
import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
|
|
76
84
|
import {
|
|
77
85
|
ALLOWED_FLAGS,
|
|
@@ -4120,13 +4128,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4120
4128
|
const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
|
|
4121
4129
|
const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
|
|
4122
4130
|
server.tool(
|
|
4123
|
-
"daemon_provider_snapshot", "Admin Provider
|
|
4124
|
-
{ action: z.enum(["refresh", "read", "put", "apply"]), 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() },
|
|
4125
|
-
async ({ action, id, network_id, revision, provider }) => {
|
|
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.",
|
|
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 }) => {
|
|
4126
4134
|
const human = providerHuman(network_id);
|
|
4127
|
-
if (action === "read") return providerReply(getProviderSnapshot(human, id));
|
|
4135
|
+
if (action === "read") return providerReply(getProviderSnapshot(human, id, include_discovery));
|
|
4128
4136
|
const daemon_node_id = id;
|
|
4129
|
-
const result = action === '
|
|
4137
|
+
const result = action === 'login' ? requestProviderLogin(human, daemon_node_id, provider)
|
|
4138
|
+
: action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
|
|
4130
4139
|
: action === 'put' ? requestProviderWrite(human, daemon_node_id, revision!, provider) : requestProviderSnapshot(human, daemon_node_id);
|
|
4131
4140
|
if (result.ok) {
|
|
4132
4141
|
const target = db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2", daemon_node_id, human.networkId!);
|
|
@@ -4136,13 +4145,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4136
4145
|
},
|
|
4137
4146
|
);
|
|
4138
4147
|
server.tool(
|
|
4139
|
-
"pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/apply capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional() },
|
|
4140
|
-
async ({ accept_writes, accept_applications }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications)),
|
|
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)),
|
|
4141
4150
|
);
|
|
4142
4151
|
server.tool(
|
|
4143
4152
|
"ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
|
|
4144
|
-
{ request_id: z.string().min(1).max(200), snapshot: z.unknown().optional(), failed: z.boolean().optional(), applied_revision: z.number().int().min(0).optional(), application: z.unknown().optional() },
|
|
4145
|
-
async ({ request_id, snapshot, failed, applied_revision, application }) => providerReply(ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application)),
|
|
4153
|
+
{ request_id: z.string().min(1).max(200), snapshot: z.unknown().optional(), failed: z.boolean().optional(), applied_revision: z.number().int().min(0).optional(), application: z.unknown().optional(), login: z.unknown().optional() },
|
|
4154
|
+
async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application, login)),
|
|
4146
4155
|
);
|
|
4147
4156
|
|
|
4148
4157
|
// Helper — map a ValidationError thrown from create-node-validate
|
|
@@ -4610,6 +4619,156 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4610
4619
|
},
|
|
4611
4620
|
);
|
|
4612
4621
|
|
|
4622
|
+
// Hub #938 — clone a child that already lives on a daemon into a new
|
|
4623
|
+
// node on THAT daemon. New alias, new ntok_. Session copy is done by
|
|
4624
|
+
// the daemon from the source config on disk; this tool only records
|
|
4625
|
+
// the policy and the runtime's session capability (see clone-node.ts).
|
|
4626
|
+
server.tool(
|
|
4627
|
+
"clone_node",
|
|
4628
|
+
"Clone a daemon child into a new node on the same daemon. New name and new token. copy_session defaults to true. workdir_policy new_empty (default) or share_source. Hub #938.",
|
|
4629
|
+
{
|
|
4630
|
+
source_node_id: z.string().min(1).max(200),
|
|
4631
|
+
name: z.string().min(1).max(256),
|
|
4632
|
+
copy_session: z.boolean().optional(),
|
|
4633
|
+
workdir_policy: z.enum(["new_empty", "share_source"]).optional(),
|
|
4634
|
+
daemon_node_id: z.string().min(1).max(200).optional(),
|
|
4635
|
+
network_id: z.string().max(200).optional(),
|
|
4636
|
+
},
|
|
4637
|
+
async ({ source_node_id, name, copy_session, workdir_policy, daemon_node_id, network_id: clientNetId }) => {
|
|
4638
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4639
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
4640
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
4641
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
4642
|
+
: null;
|
|
4643
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
4644
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_create_node", required_role: "admin", caller_role: callerRole }) }] };
|
|
4645
|
+
}
|
|
4646
|
+
const { row: source, sec1Ok } = resolveTargetNode(source_node_id, effectiveNetId);
|
|
4647
|
+
if (!source) {
|
|
4648
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: source_node_id }) }] };
|
|
4649
|
+
}
|
|
4650
|
+
if (!sec1Ok) {
|
|
4651
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
4652
|
+
}
|
|
4653
|
+
const createRow = db.get<{ daemon_node_id: string; runtime: string; model: string | null; flags_json: string; child_name: string }>(
|
|
4654
|
+
`SELECT daemon_node_id, runtime, model, flags_json, child_name
|
|
4655
|
+
FROM node_create_requests
|
|
4656
|
+
WHERE child_node_id = ?1 AND network_id = ?2 AND status = 'succeeded'
|
|
4657
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
4658
|
+
source_node_id, source.network_id || effectiveNetId || "default",
|
|
4659
|
+
);
|
|
4660
|
+
const net = source.network_id || effectiveNetId || "default";
|
|
4661
|
+
const nameRows = db.all<{ alias: string | null; node_name: string | null }>(
|
|
4662
|
+
`SELECT alias, node_name FROM nodes WHERE network_id = ?1`,
|
|
4663
|
+
net,
|
|
4664
|
+
);
|
|
4665
|
+
const existingNames = nameRows.flatMap((r) => [r.alias, r.node_name].filter((x): x is string => !!x));
|
|
4666
|
+
const owning = createRow?.daemon_node_id ?? "";
|
|
4667
|
+
const inflight = owning
|
|
4668
|
+
? db.all<{ child_name: string }>(
|
|
4669
|
+
`SELECT child_name FROM node_create_requests WHERE daemon_node_id = ?1 AND status IN ('pending', 'delivered')`,
|
|
4670
|
+
owning,
|
|
4671
|
+
).map((r) => r.child_name)
|
|
4672
|
+
: [];
|
|
4673
|
+
let policy: ReturnType<typeof parseWorkdirPolicy>;
|
|
4674
|
+
try { policy = parseWorkdirPolicy(workdir_policy); }
|
|
4675
|
+
catch (e) { return validationFailReply(e); }
|
|
4676
|
+
const facts = factsFromRows({
|
|
4677
|
+
node: source,
|
|
4678
|
+
createRow: createRow ?? null,
|
|
4679
|
+
existingNames,
|
|
4680
|
+
inflightNames: inflight,
|
|
4681
|
+
});
|
|
4682
|
+
let decision;
|
|
4683
|
+
try {
|
|
4684
|
+
decision = decideClone(facts, {
|
|
4685
|
+
newName: name,
|
|
4686
|
+
copySession: copy_session !== false,
|
|
4687
|
+
workdirPolicy: policy,
|
|
4688
|
+
requestedDaemonId: daemon_node_id ?? null,
|
|
4689
|
+
});
|
|
4690
|
+
} catch (e) {
|
|
4691
|
+
return validationFailReply(e);
|
|
4692
|
+
}
|
|
4693
|
+
if (!decision.ok) {
|
|
4694
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: decision.error, ...decision.detail }) }] };
|
|
4695
|
+
}
|
|
4696
|
+
const { row: daemon, sec1Ok: daemonOk } = resolveTargetNode(decision.daemonNodeId, effectiveNetId);
|
|
4697
|
+
if (!daemon || !daemonOk) {
|
|
4698
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found", daemon_node_id: decision.daemonNodeId }) }] };
|
|
4699
|
+
}
|
|
4700
|
+
if (!enforceUserId) {
|
|
4701
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
|
|
4702
|
+
}
|
|
4703
|
+
const childToken = generateNetworkToken();
|
|
4704
|
+
const childTokenId = generateId("tok");
|
|
4705
|
+
const requestId = newRequestId();
|
|
4706
|
+
const networkIdForChild = daemon.network_id || net;
|
|
4707
|
+
const cloneMeta = {
|
|
4708
|
+
source_node_id,
|
|
4709
|
+
source_alias: facts.alias,
|
|
4710
|
+
copy_session: decision.copySession,
|
|
4711
|
+
workdir_policy: decision.workdirPolicy,
|
|
4712
|
+
channels: decision.channels,
|
|
4713
|
+
};
|
|
4714
|
+
db.run(
|
|
4715
|
+
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2)`,
|
|
4716
|
+
[childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${decision.name}`, "network", "child", requestId],
|
|
4717
|
+
);
|
|
4718
|
+
putPendingEnvBlob({
|
|
4719
|
+
request_id: requestId,
|
|
4720
|
+
daemon_node_id: decision.daemonNodeId,
|
|
4721
|
+
env_blob: {},
|
|
4722
|
+
child_token: childToken,
|
|
4723
|
+
child_token_id: childTokenId,
|
|
4724
|
+
});
|
|
4725
|
+
db.run(
|
|
4726
|
+
`INSERT INTO node_create_requests
|
|
4727
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, clone_json)
|
|
4728
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, '[]', 'pending', ?8, ?9, ?10, ?11)`,
|
|
4729
|
+
[
|
|
4730
|
+
requestId, decision.daemonNodeId, decision.name, networkIdForChild,
|
|
4731
|
+
decision.runtime, decision.model, JSON.stringify(decision.flags),
|
|
4732
|
+
childTokenId, Date.now(), callerTokenId || "unknown", JSON.stringify(cloneMeta),
|
|
4733
|
+
],
|
|
4734
|
+
);
|
|
4735
|
+
pushEvent(daemon.alias, { type: "clone_node", request_id: requestId }, networkIdForChild);
|
|
4736
|
+
auditCreateNode({
|
|
4737
|
+
action: "clone_node_dispatch_attempted",
|
|
4738
|
+
user_id: enforceUserId,
|
|
4739
|
+
network_id: networkIdForChild,
|
|
4740
|
+
target_id: requestId,
|
|
4741
|
+
detail: {
|
|
4742
|
+
daemon_node_id: decision.daemonNodeId,
|
|
4743
|
+
source_node_id,
|
|
4744
|
+
child_name: decision.name,
|
|
4745
|
+
runtime: decision.runtime,
|
|
4746
|
+
copy_session: decision.copySession,
|
|
4747
|
+
workdir_policy: decision.workdirPolicy,
|
|
4748
|
+
session_mode: decision.sessionContract.mode,
|
|
4749
|
+
},
|
|
4750
|
+
});
|
|
4751
|
+
return {
|
|
4752
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
4753
|
+
ok: true,
|
|
4754
|
+
request_id: requestId,
|
|
4755
|
+
daemon_node_id: decision.daemonNodeId,
|
|
4756
|
+
child_name: decision.name,
|
|
4757
|
+
copy_session: decision.copySession,
|
|
4758
|
+
workdir_policy: decision.workdirPolicy,
|
|
4759
|
+
session_contract: decision.sessionContract,
|
|
4760
|
+
contract: {
|
|
4761
|
+
token: "new ntok_ minted for the clone; the source token is not copied",
|
|
4762
|
+
workdir: decision.workdirPolicy === "new_empty"
|
|
4763
|
+
? "new empty directory under the source workdir's parent (or <daemon cwd>/clone-work when the source has no project workdir)"
|
|
4764
|
+
: "share the source workdir (explicit)",
|
|
4765
|
+
session: decision.sessionContract.detail,
|
|
4766
|
+
},
|
|
4767
|
+
}) }],
|
|
4768
|
+
};
|
|
4769
|
+
},
|
|
4770
|
+
);
|
|
4771
|
+
|
|
4613
4772
|
// #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
|
|
4614
4773
|
// intentionally best-effort; after a daemon connects or reconnects, it pulls
|
|
4615
4774
|
// any still-pending requests bound to its own daemon token.
|
|
@@ -4628,10 +4787,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4628
4787
|
WHERE daemon_node_id = ?1
|
|
4629
4788
|
AND network_id = ?2
|
|
4630
4789
|
AND status = 'pending'
|
|
4790
|
+
AND COALESCE(clone_json, '') = ''
|
|
4791
|
+
ORDER BY created_at ASC`,
|
|
4792
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4793
|
+
);
|
|
4794
|
+
// Hub #938 — clone rows stay out of `requests` so an older daemon
|
|
4795
|
+
// that replays every pending create does not spawn a blank node.
|
|
4796
|
+
const clone_requests = db.all<{ request_id: string; child_name: string; created_at: number }>(
|
|
4797
|
+
`SELECT request_id, child_name, created_at
|
|
4798
|
+
FROM node_create_requests
|
|
4799
|
+
WHERE daemon_node_id = ?1
|
|
4800
|
+
AND network_id = ?2
|
|
4801
|
+
AND status = 'pending'
|
|
4802
|
+
AND COALESCE(clone_json, '') != ''
|
|
4631
4803
|
ORDER BY created_at ASC`,
|
|
4632
4804
|
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4633
4805
|
);
|
|
4634
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4806
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4807
|
+
ok: true,
|
|
4808
|
+
count: rows.length,
|
|
4809
|
+
requests: rows,
|
|
4810
|
+
clone_count: clone_requests.length,
|
|
4811
|
+
clone_requests,
|
|
4812
|
+
}) }] };
|
|
4635
4813
|
},
|
|
4636
4814
|
);
|
|
4637
4815
|
|
|
@@ -4699,21 +4877,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4699
4877
|
// isn't a host_supervisor, reject.
|
|
4700
4878
|
server.tool(
|
|
4701
4879
|
"get_create_request",
|
|
4702
|
-
"Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026.",
|
|
4880
|
+
"Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026. expect_clone pulls a clone_node row.",
|
|
4703
4881
|
{
|
|
4704
4882
|
request_id: z.string().min(1).max(200),
|
|
4883
|
+
expect_clone: z.boolean().optional(),
|
|
4705
4884
|
},
|
|
4706
|
-
async ({ request_id }) => {
|
|
4885
|
+
async ({ request_id, expect_clone }) => {
|
|
4707
4886
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4708
4887
|
if (!callerDaemon.ok) {
|
|
4709
4888
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4710
4889
|
}
|
|
4711
4890
|
|
|
4712
|
-
const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
|
|
4713
|
-
`SELECT request_id, daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
|
|
4891
|
+
const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string; clone_json: string | null }>(
|
|
4892
|
+
`SELECT request_id, daemon_node_id, status, child_token_id, network_id, clone_json FROM node_create_requests WHERE request_id = ?1`,
|
|
4714
4893
|
request_id,
|
|
4715
4894
|
);
|
|
4716
4895
|
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4896
|
+
const isClone = typeof row.clone_json === "string" && row.clone_json.trim().length > 0;
|
|
4897
|
+
// Do not consume the env blob or flip status when the wrong handler asks.
|
|
4898
|
+
if (isClone && expect_clone !== true) {
|
|
4899
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "clone_request" }) }] };
|
|
4900
|
+
}
|
|
4901
|
+
if (!isClone && expect_clone === true) {
|
|
4902
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_a_clone" }) }] };
|
|
4903
|
+
}
|
|
4717
4904
|
// §4.1.4 — strict daemon binding by token-derived node_id.
|
|
4718
4905
|
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
4719
4906
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
@@ -4763,6 +4950,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4763
4950
|
},
|
|
4764
4951
|
child_token: blob.child_token,
|
|
4765
4952
|
env_blob: blob.env_blob,
|
|
4953
|
+
...(isClone ? { clone: JSON.parse(row.clone_json as string) } : {}),
|
|
4766
4954
|
}) }],
|
|
4767
4955
|
};
|
|
4768
4956
|
},
|
|
@@ -5400,6 +5588,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5400
5588
|
);
|
|
5401
5589
|
}
|
|
5402
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]);
|
|
5403
5595
|
clearNodeFromAgentTeams(row.child_node_id); // #764
|
|
5404
5596
|
auditCreateNodeStrict({
|
|
5405
5597
|
action: "delete_node_completed",
|
|
@@ -5425,7 +5617,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5425
5617
|
// 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
|
|
5426
5618
|
// 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
|
|
5427
5619
|
const did = status === "stopped" && row.action === "delete"
|
|
5428
|
-
? "revoked ntok + DELETE FROM nodes"
|
|
5620
|
+
? "revoked ntok + DELETE FROM nodes + sessions"
|
|
5429
5621
|
: status === "stopped" && row.action === "stop"
|
|
5430
5622
|
? "lifecycle_state=stopped"
|
|
5431
5623
|
: status === "stop_failed"
|
|
@@ -6199,6 +6391,80 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6199
6391
|
},
|
|
6200
6392
|
);
|
|
6201
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
|
+
|
|
6202
6468
|
// ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
|
|
6203
6469
|
// 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
|
|
6204
6470
|
// 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。
|