@sleep2agi/commhub-server 0.9.0-preview.122 → 0.9.0-preview.124
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/create-node-validate.ts +11 -1
- package/src/daemon-provider-snapshots.test.ts +65 -3
- package/src/daemon-provider-snapshots.ts +64 -6
- package/src/daemon-token-owner-http.test.ts +4 -1
- package/src/db.ts +13 -0
- package/src/hub-provider-config.test.ts +79 -0
- package/src/hub-provider-config.ts +98 -0
- 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/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- 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 +106 -8
|
@@ -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
2
|
+
import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
3
|
+
import { readHubProviders, putHubProvider, bindHubProvider } from './hub-provider-config.js';
|
|
3
4
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
4
5
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
5
6
|
import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
|
|
@@ -73,6 +74,13 @@ import {
|
|
|
73
74
|
startPendingProbeGcTimer,
|
|
74
75
|
startProbeSweeperTimer,
|
|
75
76
|
} from "./probe.js";
|
|
77
|
+
import {
|
|
78
|
+
ackOpenCodeCatalog,
|
|
79
|
+
getOpenCodeCatalogResult,
|
|
80
|
+
opencodeCatalogAckSchema,
|
|
81
|
+
queueOpenCodeCatalogRead,
|
|
82
|
+
takeOpenCodeCatalogRequest,
|
|
83
|
+
} from "./opencode-v2-catalog.js";
|
|
76
84
|
import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
|
|
77
85
|
import {
|
|
78
86
|
ALLOWED_FLAGS,
|
|
@@ -4121,13 +4129,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4121
4129
|
const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
|
|
4122
4130
|
const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
|
|
4123
4131
|
server.tool(
|
|
4124
|
-
"daemon_provider_snapshot", "
|
|
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
|
+
"daemon_provider_snapshot", "Provider: daemon refresh/put/collect/apply/login; read=request id. login provider={action:start|read|cancel|save|accounts,session_id?,label?}. hub_read=catalog; hub_put=owner config; hub_bind=id+revision,provider={daemon_node_id,daemon_revision,confirm:true}. No login tokens.",
|
|
4133
|
+
{ action: z.enum(["refresh", "read", "put", "collect", "apply", "login", "hub_read", "hub_put", "hub_bind"]), 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() },
|
|
4134
|
+
async ({ action, id, network_id, revision, provider, include_discovery }) => {
|
|
4127
4135
|
const human = providerHuman(network_id);
|
|
4128
|
-
if (action ===
|
|
4136
|
+
if (action === 'hub_read') return providerReply(readHubProviders(human));
|
|
4137
|
+
if (action === 'hub_put') return providerReply(putHubProvider(human, revision!, provider));
|
|
4138
|
+
if (action === 'hub_bind') {
|
|
4139
|
+
const selection = provider as { daemon_node_id?: string; daemon_revision?: number; confirm?: boolean } | undefined;
|
|
4140
|
+
const result = bindHubProvider(human, { id, revision: revision!, daemon_node_id: selection?.daemon_node_id!, daemon_revision: selection?.daemon_revision!, confirm: selection?.confirm! });
|
|
4141
|
+
if (result.ok && 'request_id' in result) {
|
|
4142
|
+
const target = db.get<{ alias: string }>('SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2', selection?.daemon_node_id!, human.networkId!);
|
|
4143
|
+
if (target) pushEvent(target.alias, { type: 'daemon_provider_snapshot', request_id: result.request_id }, human.networkId!);
|
|
4144
|
+
}
|
|
4145
|
+
return providerReply(result);
|
|
4146
|
+
}
|
|
4147
|
+
if (action === "read") return providerReply(getProviderSnapshot(human, id, include_discovery));
|
|
4129
4148
|
const daemon_node_id = id;
|
|
4130
4149
|
const result = action === 'login' ? requestProviderLogin(human, daemon_node_id, provider)
|
|
4150
|
+
: action === 'collect' ? requestProviderCollection(human, daemon_node_id, revision!, provider)
|
|
4131
4151
|
: action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
|
|
4132
4152
|
: action === 'put' ? requestProviderWrite(human, daemon_node_id, revision!, provider) : requestProviderSnapshot(human, daemon_node_id);
|
|
4133
4153
|
if (result.ok) {
|
|
@@ -4138,8 +4158,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4138
4158
|
},
|
|
4139
4159
|
);
|
|
4140
4160
|
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)),
|
|
4161
|
+
"pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/collect/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional(), accept_local_discovery: z.boolean().optional(), accept_collections: z.boolean().optional() },
|
|
4162
|
+
async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections)),
|
|
4143
4163
|
);
|
|
4144
4164
|
server.tool(
|
|
4145
4165
|
"ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
|
|
@@ -5581,6 +5601,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5581
5601
|
);
|
|
5582
5602
|
}
|
|
5583
5603
|
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
|
|
5604
|
+
// Same as REST DELETE /api/nodes/:id: without this the sessions row
|
|
5605
|
+
// stays "offline" and /api/status lists the deleted node forever.
|
|
5606
|
+
// By node_id, not alias — a same-alias rebuild keeps its session.
|
|
5607
|
+
db.run(`DELETE FROM sessions WHERE node_id = ?1`, [row.child_node_id]);
|
|
5584
5608
|
clearNodeFromAgentTeams(row.child_node_id); // #764
|
|
5585
5609
|
auditCreateNodeStrict({
|
|
5586
5610
|
action: "delete_node_completed",
|
|
@@ -5606,7 +5630,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5606
5630
|
// 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
|
|
5607
5631
|
// 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
|
|
5608
5632
|
const did = status === "stopped" && row.action === "delete"
|
|
5609
|
-
? "revoked ntok + DELETE FROM nodes"
|
|
5633
|
+
? "revoked ntok + DELETE FROM nodes + sessions"
|
|
5610
5634
|
: status === "stopped" && row.action === "stop"
|
|
5611
5635
|
? "lifecycle_state=stopped"
|
|
5612
5636
|
: status === "stop_failed"
|
|
@@ -6380,6 +6404,80 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6380
6404
|
},
|
|
6381
6405
|
);
|
|
6382
6406
|
|
|
6407
|
+
// OpenCode V2 catalog. Human tokens queue; the owning daemon pulls scope
|
|
6408
|
+
// only and acks a whitelist snapshot. pending is stored authorization, not
|
|
6409
|
+
// a snapshot and not proof the doorbell was received.
|
|
6410
|
+
server.registerTool(
|
|
6411
|
+
"read_opencode_catalog",
|
|
6412
|
+
{
|
|
6413
|
+
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.",
|
|
6414
|
+
inputSchema: z.object({
|
|
6415
|
+
daemon_node_id: z.string().min(1).max(200),
|
|
6416
|
+
node_id: z.string().min(1).max(200),
|
|
6417
|
+
network_id: z.string().max(200).optional(),
|
|
6418
|
+
}).strict() as any,
|
|
6419
|
+
},
|
|
6420
|
+
async (args: { daemon_node_id: string; node_id: string; network_id?: string }) => {
|
|
6421
|
+
const networkId = getNetworkId(args.network_id);
|
|
6422
|
+
const queued = queueOpenCodeCatalogRead({
|
|
6423
|
+
callerUserId: enforceUserId ?? null,
|
|
6424
|
+
callerIsNode: callerTokenIsNetwork,
|
|
6425
|
+
networkId,
|
|
6426
|
+
daemonNodeId: args.daemon_node_id,
|
|
6427
|
+
nodeId: args.node_id,
|
|
6428
|
+
});
|
|
6429
|
+
if (!queued.ok) return { content: [{ type: "text" as const, text: JSON.stringify(queued) }] };
|
|
6430
|
+
pushEvent(queued.daemon_alias, { type: "opencode_catalog", request_id: queued.request_id }, queued.network_id);
|
|
6431
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "pending", request_id: queued.request_id }) }] };
|
|
6432
|
+
},
|
|
6433
|
+
);
|
|
6434
|
+
|
|
6435
|
+
server.registerTool(
|
|
6436
|
+
"get_opencode_catalog",
|
|
6437
|
+
{
|
|
6438
|
+
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.",
|
|
6439
|
+
inputSchema: z.object({
|
|
6440
|
+
request_id: z.string().min(1).max(200),
|
|
6441
|
+
network_id: z.string().max(200).optional(),
|
|
6442
|
+
}).strict() as any,
|
|
6443
|
+
},
|
|
6444
|
+
async (args: { request_id: string; network_id?: string }) => {
|
|
6445
|
+
const result = getOpenCodeCatalogResult({
|
|
6446
|
+
callerUserId: enforceUserId ?? null,
|
|
6447
|
+
callerIsNode: callerTokenIsNetwork,
|
|
6448
|
+
networkId: getNetworkId(args.network_id),
|
|
6449
|
+
requestId: args.request_id,
|
|
6450
|
+
});
|
|
6451
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
6452
|
+
},
|
|
6453
|
+
);
|
|
6454
|
+
|
|
6455
|
+
server.registerTool(
|
|
6456
|
+
"get_opencode_catalog_request",
|
|
6457
|
+
{
|
|
6458
|
+
description: "Daemon pulls one authorized OpenCode V2 catalog read. Scope only: no URL, authorization, settings, headers, or body.",
|
|
6459
|
+
inputSchema: z.object({ request_id: z.string().min(1).max(200) }).strict() as any,
|
|
6460
|
+
},
|
|
6461
|
+
async (args: { request_id: string }) => {
|
|
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(takeOpenCodeCatalogRequest(args.request_id, callerDaemon.daemonNodeId)) }] };
|
|
6465
|
+
},
|
|
6466
|
+
);
|
|
6467
|
+
|
|
6468
|
+
server.registerTool(
|
|
6469
|
+
"ack_opencode_catalog",
|
|
6470
|
+
{
|
|
6471
|
+
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.",
|
|
6472
|
+
inputSchema: opencodeCatalogAckSchema as any,
|
|
6473
|
+
},
|
|
6474
|
+
async (args: unknown) => {
|
|
6475
|
+
const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|
|
6476
|
+
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
6477
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(ackOpenCodeCatalog(callerDaemon.daemonNodeId, args)) }] };
|
|
6478
|
+
},
|
|
6479
|
+
);
|
|
6480
|
+
|
|
6383
6481
|
// ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
|
|
6384
6482
|
// 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
|
|
6385
6483
|
// 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。
|