@sleep2agi/commhub-server 0.9.0-preview.119 → 0.9.0-preview.120
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/ack-create-request-transport.test.ts +23 -0
- package/src/ack-create-request.test.ts +45 -2
- package/src/create-node-tool-schema.test.ts +9 -0
- package/src/create-node-validate.ts +26 -3
- package/src/create-node-workdir.test.ts +21 -0
- package/src/db.ts +8 -0
- package/src/node-create-request-status-http.test.ts +13 -0
- package/src/server.ts +1 -1
- package/src/tools.ts +19 -3
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.120",
|
|
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",
|
|
@@ -98,6 +98,29 @@ beforeEach(() => {
|
|
|
98
98
|
afterAll(cleanup);
|
|
99
99
|
|
|
100
100
|
describe("#344 ack_create_request real in-process MCP transport", () => {
|
|
101
|
+
test("explicit launch proof survives transport while malformed booleans are rejected", async () => {
|
|
102
|
+
const requestId = 'cr_transport_verified';
|
|
103
|
+
seedRequest(requestId);
|
|
104
|
+
const { client, server } = await connectClient();
|
|
105
|
+
try {
|
|
106
|
+
const read = () => db.get<{ launch_verified_at: number | null }>(
|
|
107
|
+
'SELECT launch_verified_at FROM node_create_requests WHERE request_id=?1', requestId);
|
|
108
|
+
const bad = await client.callTool({ name: 'ack_create_request', arguments: {
|
|
109
|
+
request_id: requestId, status: 'started', child_pid: 7777, launch_verified: 'true',
|
|
110
|
+
} });
|
|
111
|
+
expectTransportValidationError(bad);
|
|
112
|
+
expect(read()?.launch_verified_at).toBeNull();
|
|
113
|
+
const good = await client.callTool({ name: 'ack_create_request', arguments: {
|
|
114
|
+
request_id: requestId, status: 'started', child_pid: 7777, launch_verified: true,
|
|
115
|
+
} });
|
|
116
|
+
expect(responseJson(good).ok).toBe(true);
|
|
117
|
+
expect(read()?.launch_verified_at).toBeGreaterThan(0);
|
|
118
|
+
} finally {
|
|
119
|
+
await client.close();
|
|
120
|
+
await server.close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
101
124
|
test("accepts runtime_capability_check_failed plus string runtime through the SDK zod gate", async () => {
|
|
102
125
|
const requestId = "cr_transport_accept";
|
|
103
126
|
seedRequest(requestId);
|
|
@@ -138,8 +138,8 @@ async function callAck(handler: ToolHandler, args: any): Promise<AckReply> {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
function readRequest(request_id: string) {
|
|
141
|
-
return db.get<{ status: string; error: string | null; acked_at: number | null }>(
|
|
142
|
-
`SELECT status, error, acked_at FROM node_create_requests WHERE request_id = ?1`,
|
|
141
|
+
return db.get<{ status: string; error: string | null; acked_at: number | null; launch_verified_at: number | null }>(
|
|
142
|
+
`SELECT status, error, acked_at, launch_verified_at FROM node_create_requests WHERE request_id = ?1`,
|
|
143
143
|
request_id,
|
|
144
144
|
);
|
|
145
145
|
}
|
|
@@ -297,6 +297,29 @@ describe("ack_create_request HANDLER — B2 audit_log daemon_capability_lied", (
|
|
|
297
297
|
});
|
|
298
298
|
|
|
299
299
|
describe("ack_create_request HANDLER — pre-existing happy-path stays green (regression)", () => {
|
|
300
|
+
for (const status of ['failed', 'rejected', 'runtime_capability_check_failed']) {
|
|
301
|
+
test(`late ${status} after registration cannot leave succeeded with a revoked token`, async () => {
|
|
302
|
+
seedDaemonWorld();
|
|
303
|
+
const request_id = `cr_late_${status}`;
|
|
304
|
+
const child_token_id = `tok_late_${status}`;
|
|
305
|
+
seedCreateRequest({ request_id, child_token_id, status: 'succeeded' });
|
|
306
|
+
const handler = buildAckHandler();
|
|
307
|
+
await callAck(handler, { request_id, status: 'started', child_pid: 7777, launch_verified: true });
|
|
308
|
+
expect(readRequest(request_id)?.launch_verified_at).toBeGreaterThan(0);
|
|
309
|
+
const reply = await callAck(handler, { request_id, status, error: 'post-register launch verdict' });
|
|
310
|
+
expect(reply).toMatchObject({ ok: true, status });
|
|
311
|
+
expect(readRequest(request_id)?.status).toBe(status);
|
|
312
|
+
expect(readRequest(request_id)?.error).toBe('post-register launch verdict');
|
|
313
|
+
expect(readRequest(request_id)?.acked_at).toBeGreaterThan(0);
|
|
314
|
+
expect(readToken(child_token_id)?.revoked_at).not.toBeNull();
|
|
315
|
+
// Delayed success must not resurrect either the request or its token.
|
|
316
|
+
await callAck(handler, { request_id, status: 'started', child_pid: 7777, launch_verified: true });
|
|
317
|
+
expect(readRequest(request_id)?.status).toBe(status);
|
|
318
|
+
expect(readRequest(request_id)?.launch_verified_at).toBeNull();
|
|
319
|
+
expect(readToken(child_token_id)?.revoked_at).not.toBeNull();
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
300
323
|
test("status='started' still flips acked_at without terminal teardown", async () => {
|
|
301
324
|
seedDaemonWorld();
|
|
302
325
|
const reqId = "cr_ack_started_regression";
|
|
@@ -310,10 +333,30 @@ describe("ack_create_request HANDLER — pre-existing happy-path stays green (re
|
|
|
310
333
|
// 'started' doesn't flip to terminal; status stays 'delivered'.
|
|
311
334
|
expect(row?.status).toBe("delivered");
|
|
312
335
|
expect(row?.acked_at).toBeGreaterThan(0);
|
|
336
|
+
expect(row?.launch_verified_at).toBeNull();
|
|
313
337
|
// child ntok is NOT revoked on 'started'.
|
|
314
338
|
expect(readToken(childTok)?.revoked_at).toBeNull();
|
|
315
339
|
});
|
|
316
340
|
|
|
341
|
+
for (const status of ['pending', 'delivered', 'succeeded'] as const) {
|
|
342
|
+
test(`verified launch persists independently of registration: ${status}`, async () => {
|
|
343
|
+
seedDaemonWorld();
|
|
344
|
+
const request_id = `cr_verified_${status}`;
|
|
345
|
+
seedCreateRequest({ request_id, child_token_id: `tok_verified_${status}`, status });
|
|
346
|
+
const handler = buildAckHandler();
|
|
347
|
+
expect(readRequest(request_id)?.launch_verified_at).toBeNull();
|
|
348
|
+
const bad = await callAck(handler, { request_id, status: 'started', launch_verified: true });
|
|
349
|
+
expect(bad.ok).toBe(false);
|
|
350
|
+
expect(readRequest(request_id)?.launch_verified_at).toBeNull();
|
|
351
|
+
await callAck(handler, { request_id, status: 'started', child_pid: 7777, launch_verified: true });
|
|
352
|
+
const first = readRequest(request_id)!;
|
|
353
|
+
expect(first.status).toBe(status);
|
|
354
|
+
expect(first.launch_verified_at).toBeGreaterThan(0);
|
|
355
|
+
await callAck(handler, { request_id, status: 'started', child_pid: 7777, launch_verified: true });
|
|
356
|
+
expect(readRequest(request_id)?.launch_verified_at).toBe(first.launch_verified_at);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
317
360
|
test("status='failed' still revokes child + flips to failed (no audit_log daemon_capability_lied)", async () => {
|
|
318
361
|
seedDaemonWorld();
|
|
319
362
|
const reqId = "cr_ack_failed_regression";
|
|
@@ -17,6 +17,15 @@ function captureSchemas(): Record<string, Record<string, any>> {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
describe("create_node MCP schema", () => {
|
|
20
|
+
test("#829 rejects misplaced V2 controls instead of stripping them", () => {
|
|
21
|
+
const nodeSpec = captureSchemas().create_node.node_spec;
|
|
22
|
+
const base = { name: "v2-node", runtime: "opencode-cli" };
|
|
23
|
+
expect(nodeSpec.safeParse({ ...base, opencodeGeneration: "v2" }).success).toBe(false);
|
|
24
|
+
expect(nodeSpec.safeParse({ ...base, opencodeUnsafeTools: true }).success).toBe(false);
|
|
25
|
+
const flags = { opencodeGeneration: "v2", opencodeUnsafeTools: true };
|
|
26
|
+
expect(nodeSpec.parse({ ...base, flags }).flags).toEqual(flags);
|
|
27
|
+
expect(nodeSpec.parse(base)).toEqual(base);
|
|
28
|
+
});
|
|
20
29
|
test("model is optional on daemon create specs but empty strings stay invalid", () => {
|
|
21
30
|
const schemas = captureSchemas();
|
|
22
31
|
const nodeSpec = schemas.create_node.node_spec;
|
|
@@ -52,7 +52,7 @@ export type Runtime = typeof RUNTIMES[number];
|
|
|
52
52
|
// 🔴 故意放在 flags 里而不是 node_spec 顶层:老 hub 的 zod 会静默丢弃未知顶层字段、老 daemon 也静默忽略
|
|
53
53
|
// —— 那正是 #584 的形状(用户选了共存,拿到无头节点,没有任何报错)。flags 的未知键在老 hub(这份
|
|
54
54
|
// FLAG_KEYS)和老 daemon(buildAnetArgsDaemon)都**拒绝**,新 app 撞到老组件得到的是一条报错。
|
|
55
|
-
export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout", "copresence"] as const;
|
|
55
|
+
export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout", "copresence", "opencodeGeneration", "opencodeUnsafeTools"] as const;
|
|
56
56
|
|
|
57
57
|
/** #584 —— 只有这些 runtime 认 `flags.copresence`。目前只有 codex-app-server 的共存靠 config 字段
|
|
58
58
|
* (`codexCopresence`)决定;grok-build-cli / opencode-cli 各有自己的字段,接进来之前一律拒,不静默吞。 */
|
|
@@ -137,6 +137,12 @@ export function isValidTimeoutMs(v: unknown): v is number {
|
|
|
137
137
|
|
|
138
138
|
export function validateFlagValue(k: string, v: unknown): void {
|
|
139
139
|
switch (k) {
|
|
140
|
+
case "opencodeGeneration":
|
|
141
|
+
if (v !== "v1" && v !== "v2") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be v1 or v2" });
|
|
142
|
+
return;
|
|
143
|
+
case "opencodeUnsafeTools":
|
|
144
|
+
if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
|
|
145
|
+
return;
|
|
140
146
|
case "permissionMode":
|
|
141
147
|
if (typeof v !== "string" || !(PERMISSION_MODES as readonly string[]).includes(v)) {
|
|
142
148
|
throw new ValidationError("flag_value_invalid", { field: k, reason: "must be one of default/acceptEdits/plan/bypassPermissions" });
|
|
@@ -171,8 +177,21 @@ export function validateFlagValue(k: string, v: unknown): void {
|
|
|
171
177
|
/** #584 —— 逐键校验之外的跨字段一条:`copresence` 只对 COPRESENCE_FLAG_RUNTIMES 有意义。
|
|
172
178
|
* 在别的 runtime 上收下它而什么都不做,就是又一个「选了共存、拿到无头」。 */
|
|
173
179
|
export function validateFlagsForRuntime(runtime: string, flags: Record<string, unknown> | undefined | null): void {
|
|
174
|
-
if (!flags
|
|
175
|
-
|
|
180
|
+
if (!flags) return;
|
|
181
|
+
// Strict flags envelope: old Hub/daemon reject these keys rather than dropping
|
|
182
|
+
// unknown node_spec fields. Opt-in must be explicit on this create request.
|
|
183
|
+
for (const field of ["opencodeGeneration", "opencodeUnsafeTools"]) {
|
|
184
|
+
if (!Object.prototype.hasOwnProperty.call(flags, field)) continue;
|
|
185
|
+
validateFlagValue(field, flags[field]);
|
|
186
|
+
if (runtime !== "opencode-cli") throw new ValidationError("flag_not_applicable_to_runtime", { field, runtime, applicable: ["opencode-cli"] });
|
|
187
|
+
}
|
|
188
|
+
if (Object.prototype.hasOwnProperty.call(flags, "opencodeUnsafeTools") && flags.opencodeGeneration !== "v2") {
|
|
189
|
+
throw new ValidationError("opencode_unsafe_requires_v2", { reason: "opencodeUnsafeTools requires explicit opencodeGeneration=v2" });
|
|
190
|
+
}
|
|
191
|
+
if (flags.opencodeGeneration === "v2" && flags.opencodeUnsafeTools !== true) {
|
|
192
|
+
throw new ValidationError("opencode_v2_requires_unsafe_opt_in", { reason: "V2 preview enables every local tool; trusted tasks only. Explicit opencodeUnsafeTools=true required." });
|
|
193
|
+
}
|
|
194
|
+
if (Object.prototype.hasOwnProperty.call(flags, "copresence") && !(COPRESENCE_FLAG_RUNTIMES as readonly string[]).includes(runtime)) {
|
|
176
195
|
throw new ValidationError("flag_not_applicable_to_runtime", {
|
|
177
196
|
field: "copresence", runtime, applicable: [...COPRESENCE_FLAG_RUNTIMES],
|
|
178
197
|
});
|
|
@@ -318,6 +337,9 @@ function kebab(k: string): string {
|
|
|
318
337
|
// shell, no string concat). Each value has already passed type/enum
|
|
319
338
|
// validation, so String() coercion is safe.
|
|
320
339
|
export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
340
|
+
for (const field of ["opencodeGeneration", "opencodeUnsafeTools"]) {
|
|
341
|
+
if (Object.prototype.hasOwnProperty.call(spec, field)) throw new ValidationError("flag_location_invalid", { field, reason: "use node_spec.flags" });
|
|
342
|
+
}
|
|
321
343
|
validateName(spec.name);
|
|
322
344
|
validateRuntime(spec.runtime);
|
|
323
345
|
validateModel(spec.model);
|
|
@@ -333,6 +355,7 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
|
333
355
|
validateFlagValue(k, v);
|
|
334
356
|
// `anet node create --copresence` 是布尔开关,不吃值。
|
|
335
357
|
if (k === "copresence") { if (v === true) args.push("--copresence"); continue; }
|
|
358
|
+
if (k === "opencodeUnsafeTools") { if (v === true) args.push("--opencode-unsafe-tools"); continue; }
|
|
336
359
|
args.push(`--${kebab(k)}`, String(v));
|
|
337
360
|
}
|
|
338
361
|
validateFlagsForRuntime(spec.runtime, spec.flags);
|
|
@@ -107,6 +107,27 @@ describe("daemonDefaultWorkdirRoot — sanitize the self-report", () => {
|
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
describe("create_node + workdir", () => {
|
|
110
|
+
test("#829 V2 flags survive Hub storage and daemon pull", async () => {
|
|
111
|
+
seed({ runtimes_supported: ["opencode-cli"] });
|
|
112
|
+
const flags = { opencodeGeneration: "v2", opencodeUnsafeTools: true, timeout: 600000 };
|
|
113
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { name: "v2-child", runtime: "opencode-cli", model: "stub/model", flags } });
|
|
114
|
+
expect(r.ok).toBe(true);
|
|
115
|
+
const row = db.get<{ flags_json: string }>("SELECT flags_json FROM node_create_requests WHERE request_id = ?1", r.request_id);
|
|
116
|
+
expect(JSON.parse(row!.flags_json)).toEqual(flags);
|
|
117
|
+
const pulled = await call(tools(DAEMON_TOK).get_create_request, { request_id: r.request_id });
|
|
118
|
+
expect(pulled.ok).toBe(true);
|
|
119
|
+
expect(pulled.node_spec.flags).toEqual(flags);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("#829 missing opt-in rejected before request or child token mint", async () => {
|
|
123
|
+
seed({ runtimes_supported: ["opencode-cli"] });
|
|
124
|
+
const tokenCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM api_tokens WHERE network_id = ?1", NET)!.n;
|
|
125
|
+
const before = tokenCount();
|
|
126
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { name: "v2-child", runtime: "opencode-cli", flags: { opencodeGeneration: "v2" } } });
|
|
127
|
+
expect(r.error).toBe("opencode_v2_requires_unsafe_opt_in");
|
|
128
|
+
expect(rowCount()).toBe(0);
|
|
129
|
+
expect(tokenCount()).toBe(before);
|
|
130
|
+
});
|
|
110
131
|
test("🔴 old daemon (no default_workdir_root) + workdir → workdir_not_supported_by_daemon, NO row", async () => {
|
|
111
132
|
seed({});
|
|
112
133
|
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { ...baseSpec, workdir: `${ROOT}/wd-child` } });
|
package/src/db.ts
CHANGED
|
@@ -771,6 +771,14 @@ try {
|
|
|
771
771
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
// Registration also stamps acked_at, so it cannot prove daemon launch checks.
|
|
775
|
+
// Keep old rows NULL; run after the legacy model-nullability table rebuild.
|
|
776
|
+
try {
|
|
777
|
+
db.exec(`ALTER TABLE node_create_requests ADD COLUMN launch_verified_at BIGINT`);
|
|
778
|
+
} catch (e: any) {
|
|
779
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
780
|
+
}
|
|
781
|
+
|
|
774
782
|
// #1493 — 把 user_inbox.network_id 升到 **schema 级 NOT NULL**(belt-and-suspenders,
|
|
775
783
|
// 叠在 send_desktop_message 的代码级三闸之上:canWrite / `!effectiveNetId` return /
|
|
776
784
|
// getUserNetworkRole,tools.ts)。「不产生 network_id=NULL 孤儿」现是代码级保证 + #1492
|
|
@@ -78,6 +78,19 @@ describe("GET /api/node-create-requests?request_id=", () => {
|
|
|
78
78
|
const { body } = await get("/api/node-create-requests?request_id=cr_b_pending", bToken);
|
|
79
79
|
expect(body.request.status).toBe("pending");
|
|
80
80
|
expect(body.request.error).toBeNull();
|
|
81
|
+
expect(body.request.child_node_id).toBeNull();
|
|
82
|
+
expect(body.request.launch_verified_at).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
test("registration timestamp is not launch proof; explicit proof and identity round-trip", async () => {
|
|
85
|
+
const { db } = await import('./db.js');
|
|
86
|
+
db.run(`UPDATE node_create_requests SET status='succeeded', child_node_id='node_verified', acked_at=1234 WHERE request_id='cr_b_pending'`);
|
|
87
|
+
const registered = await get('/api/node-create-requests?request_id=cr_b_pending', bToken);
|
|
88
|
+
expect(registered.body.request.acked_at).toBe(1234);
|
|
89
|
+
expect(registered.body.request.launch_verified_at).toBeNull();
|
|
90
|
+
db.run(`UPDATE node_create_requests SET launch_verified_at=5678 WHERE request_id='cr_b_pending'`);
|
|
91
|
+
const verified = await get('/api/node-create-requests?request_id=cr_b_pending', bToken);
|
|
92
|
+
expect(verified.body.request).toMatchObject({ child_node_id: 'node_verified', launch_verified_at: 5678 });
|
|
93
|
+
expect((await get('/api/node-create-requests?request_id=cr_b_pending', aToken)).status).toBe(404);
|
|
81
94
|
});
|
|
82
95
|
test("missing request_id → 400", async () => {
|
|
83
96
|
const { status, body } = await get("/api/node-create-requests", aToken);
|
package/src/server.ts
CHANGED
|
@@ -4489,7 +4489,7 @@ return Bun.serve({
|
|
|
4489
4489
|
}
|
|
4490
4490
|
const params: any[] = [requestId];
|
|
4491
4491
|
let sql = `SELECT request_id, daemon_node_id, child_name, network_id, runtime, model, status, error,
|
|
4492
|
-
created_at, delivered_at, acked_at
|
|
4492
|
+
created_at, delivered_at, acked_at, child_node_id, launch_verified_at
|
|
4493
4493
|
FROM node_create_requests WHERE request_id = ?1`;
|
|
4494
4494
|
sql = addNetworkScope(sql, params, restScope);
|
|
4495
4495
|
const row = db.get<Record<string, unknown>>(sql, ...params);
|
package/src/tools.ts
CHANGED
|
@@ -4279,6 +4279,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4279
4279
|
model: z.string().min(1).max(100).optional().nullable(),
|
|
4280
4280
|
flags: z.record(z.string(), z.unknown()).optional(),
|
|
4281
4281
|
env_refs: z.array(z.string().max(64)).optional(),
|
|
4282
|
+
// Wrong placement must fail, not silently create a legacy V1 node.
|
|
4283
|
+
// These options belong under the strict flags envelope.
|
|
4284
|
+
opencodeGeneration: z.never().optional(),
|
|
4285
|
+
opencodeUnsafeTools: z.never().optional(),
|
|
4282
4286
|
channels: z.array(z.unknown()).optional(),
|
|
4283
4287
|
// app「新建节点」确认页的工作目录(绝对路径或 ~/…,daemon 侧展开并校验)。
|
|
4284
4288
|
// 缺席 = 老行为:落 daemon 的 cwd。
|
|
@@ -4753,8 +4757,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4753
4757
|
error: z.string().max(1000).optional(),
|
|
4754
4758
|
child_pid: z.number().int().optional(),
|
|
4755
4759
|
runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
|
|
4760
|
+
launch_verified: z.boolean().optional(), // finite launcher exit + live generation proof, not mere registration/spawn
|
|
4756
4761
|
},
|
|
4757
|
-
async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
|
|
4762
|
+
async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime, launch_verified }) => {
|
|
4758
4763
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4759
4764
|
if (!callerDaemon.ok) {
|
|
4760
4765
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
@@ -4772,6 +4777,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4772
4777
|
}
|
|
4773
4778
|
const ackedAt = Date.now();
|
|
4774
4779
|
if (status === "started") {
|
|
4780
|
+
if (launch_verified === true && (!Number.isInteger(_pid) || _pid! <= 0)) {
|
|
4781
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "verified_child_pid_required" }) }] };
|
|
4782
|
+
}
|
|
4783
|
+
if (launch_verified === true) {
|
|
4784
|
+
// A child can register before this acknowledgement arrives. Preserve
|
|
4785
|
+
// both event orders, but a late success must never revive a failure.
|
|
4786
|
+
db.run(`UPDATE node_create_requests SET launch_verified_at = COALESCE(launch_verified_at, ?1)
|
|
4787
|
+
WHERE request_id = ?2 AND status IN ('pending', 'delivered', 'succeeded')`, [ackedAt, request_id]);
|
|
4788
|
+
}
|
|
4775
4789
|
// Don't flip to 'succeeded' here — that happens via content-
|
|
4776
4790
|
// match when the child actually registers. We just stamp ack.
|
|
4777
4791
|
db.run(
|
|
@@ -4781,12 +4795,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4781
4795
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
|
|
4782
4796
|
}
|
|
4783
4797
|
// failed / rejected / runtime_capability_check_failed — revoke
|
|
4784
|
-
// child-ntok + mark request terminal.
|
|
4798
|
+
// child-ntok + mark request terminal. Registration can precede the
|
|
4799
|
+
// daemon's final capability verdict; don't leave succeeded visible
|
|
4800
|
+
// while this same failure acknowledgement revokes its child token.
|
|
4785
4801
|
if (row.child_token_id) {
|
|
4786
4802
|
db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
|
|
4787
4803
|
}
|
|
4788
4804
|
db.run(
|
|
4789
|
-
`UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
|
|
4805
|
+
`UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3, launch_verified_at = NULL WHERE request_id = ?4 AND status IN ('pending', 'delivered', 'succeeded')`,
|
|
4790
4806
|
[status, ackError || null, ackedAt, request_id],
|
|
4791
4807
|
);
|
|
4792
4808
|
// RFC-026 §9.3 D2 — surface declaration↔reality gap on a
|