@sleep2agi/commhub-server 0.9.0-preview.118 → 0.9.0-preview.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.118",
3
+ "version": "0.9.0-preview.119",
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",
@@ -0,0 +1,23 @@
1
+ import { z } from "zod/v4";
2
+
3
+ export const forkRecoveryRequestSchema = z.object({
4
+ kind: z.literal("fork_on_missing_ordinal"), confirmed: z.literal(true),
5
+ }).strict();
6
+ const threadId = z.string().length(36).regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
7
+ export const forkRecoveryResultSchema = z.discriminatedUnion("state", [
8
+ z.object({ state: z.literal("forked"), old_thread_id: threadId, new_thread_id: threadId }).strict(),
9
+ z.object({ state: z.literal("not_observed") }).strict(),
10
+ z.object({ state: z.literal("unknown"), reason: z.enum([
11
+ "invalid_request_id", "history_unreadable", "history_invalid", "request_ambiguous", "mapping_invalid",
12
+ "result_not_reported", "result_invalid",
13
+ ]) }).strict(),
14
+ ]).refine(r => r.state !== "forked" || r.old_thread_id !== r.new_thread_id);
15
+
16
+ // Read paths re-validate stored data, not just the write-time MCP schema.
17
+ export function publicForkResult(raw: unknown) {
18
+ try {
19
+ const parsed = forkRecoveryResultSchema.safeParse(typeof raw === "string" ? JSON.parse(raw) : raw);
20
+ if (parsed.success) return parsed.data;
21
+ } catch { /* corrupted/legacy storage must not expose arbitrary fields */ }
22
+ return { state: "unknown", reason: raw == null ? "result_not_reported" : "result_invalid" } as const;
23
+ }
@@ -0,0 +1,38 @@
1
+ import { expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { execFileSync } from "node:child_process";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ test("legacy start table upgrades twice without changing ordinary rows or losing fork receipts", () => {
10
+ const root = mkdtempSync(join(tmpdir(), "fork-migration-"));
11
+ const path = join(root, "legacy.db");
12
+ try {
13
+ let db = new Database(path);
14
+ db.exec(`CREATE TABLE node_start_requests (
15
+ request_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, daemon_node_id TEXT NOT NULL,
16
+ child_node_id TEXT NOT NULL, child_alias TEXT NOT NULL, created_by_token TEXT NOT NULL,
17
+ status TEXT NOT NULL DEFAULT 'pending', error TEXT, child_pid INTEGER,
18
+ created_at INTEGER NOT NULL, delivered_at INTEGER, acked_at INTEGER);
19
+ INSERT INTO node_start_requests(request_id,network_id,daemon_node_id,child_node_id,child_alias,created_by_token,status,created_at)
20
+ VALUES('str_legacy','net_fixture','node_daemon','node_child','fixture','fixture','started',1);`);
21
+ db.close();
22
+ const migrate = () => execFileSync(process.execPath, ["-e",
23
+ `await import(${JSON.stringify(fileURLToPath(new URL("./db.ts", import.meta.url)))}); process.exit(0);`],
24
+ { env: { ...process.env, COMMHUB_DB: path }, timeout: 15_000, stdio: "pipe" });
25
+ migrate();
26
+ db = new Database(path);
27
+ expect(db.query("SELECT status,fork_recovery_json,fork_result_json FROM node_start_requests WHERE request_id='str_legacy'").get())
28
+ .toEqual({ status: "started", fork_recovery_json: null, fork_result_json: null });
29
+ const receipt = JSON.stringify({ state: "not_observed" });
30
+ db.query("UPDATE node_start_requests SET fork_result_json=? WHERE request_id='str_legacy'").run(receipt);
31
+ db.close();
32
+ migrate();
33
+ db = new Database(path);
34
+ expect(db.query("SELECT fork_result_json FROM node_start_requests WHERE request_id='str_legacy'").get())
35
+ .toEqual({ fork_result_json: receipt });
36
+ db.close();
37
+ } finally { rmSync(root, { recursive: true, force: true }); }
38
+ });
package/src/db.ts CHANGED
@@ -504,6 +504,12 @@ db.exec(`
504
504
  `);
505
505
  db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_daemon ON node_start_requests(daemon_node_id, status)`);
506
506
  db.exec(`CREATE INDEX IF NOT EXISTS idx_start_req_child ON node_start_requests(child_node_id)`);
507
+ // Additive: old rows remain ordinary starts (NULL). Keep the request and the
508
+ // observed side effect separate from launcher status, including startup failure.
509
+ for (const column of ["fork_recovery_json", "fork_result_json"]) {
510
+ try { db.exec(`ALTER TABLE node_start_requests ADD COLUMN ${column} TEXT`); }
511
+ catch (e: any) { if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; }
512
+ }
507
513
  try {
508
514
  db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_start_req_child_inflight
509
515
  ON node_start_requests(child_node_id)
@@ -140,6 +140,36 @@ test("cross-network request and node guesses do not disclose data", async () =>
140
140
  expect((await get(query(kind, `request_id=${id}&network_id=${a.network_id}`), b.token)).status).toBe(403);
141
141
  }
142
142
  });
143
+ test("fork receipt uses the same authenticated node visibility and redacts corrupt storage", async () => {
144
+ const receipt = { state: "forked", old_thread_id: "01a11846-d796-72f1-af68-8d9215a65dc8", new_thread_id: "01a11900-0000-7000-8000-000000000001" };
145
+ const path = query("start", "request_id=start_read_failed");
146
+ try {
147
+ db.run("UPDATE node_start_requests SET fork_recovery_json=?1,fork_result_json=?2 WHERE request_id='start_read_failed'",
148
+ [JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: true }), JSON.stringify(receipt)]);
149
+ expect((await get(path)).body.request.fork_recovery).toEqual({ requested: true, result: receipt });
150
+ expect((await get(path, b.token)).status).toBe(404);
151
+ expect((await get(path, limited.token)).status).toBe(404);
152
+ expect((await get(path, daemonToken)).status).toBe(403);
153
+ db.run("INSERT INTO network_member_agent_grants(network_id,user_id,node_id,can_message) VALUES(?1,?2,?3,0)", [a.network_id, limited.user.user_id, ids.active]);
154
+ expect((await get(path, limited.token)).body.request.fork_recovery.result).toEqual(receipt);
155
+ db.run("UPDATE node_start_requests SET fork_result_json=?1 WHERE request_id='start_read_failed'", [JSON.stringify({ ...receipt, snapshot: "/private/secret" })]);
156
+ const corrupted = await get(path, limited.token);
157
+ expect(corrupted.body.request.fork_recovery.result).toEqual({ state: "unknown", reason: "result_invalid" });
158
+ expect(JSON.stringify(corrupted)).not.toContain("/private");
159
+ // Corrupt confirmation cannot assert requested:true, but must not erase an
160
+ // independently valid recorded fork (nor make the request look ordinary).
161
+ for (const raw of ["broken /private/secret", "null", "{}", JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: false }),
162
+ JSON.stringify({ kind: "fork_on_missing_ordinal", confirmed: true, snapshot: "/private/secret" })]) {
163
+ db.run("UPDATE node_start_requests SET fork_recovery_json=?1,fork_result_json=?2 WHERE request_id='start_read_failed'", [raw, JSON.stringify(receipt)]);
164
+ const invalid = await get(path, limited.token);
165
+ expect(invalid.body.request.fork_recovery).toEqual({ requested: null, result: receipt });
166
+ expect(JSON.stringify(invalid)).not.toContain("/private");
167
+ }
168
+ } finally {
169
+ db.run("DELETE FROM network_member_agent_grants WHERE user_id=?1 AND node_id=?2", [limited.user.user_id, ids.active]);
170
+ db.run("UPDATE node_start_requests SET fork_recovery_json=NULL,fork_result_json=NULL WHERE request_id='start_read_failed'");
171
+ }
172
+ });
143
173
  test("same-network restricted viewer without node grants cannot read requests", async () => {
144
174
  for (const kind of ["adopt", "start", "stop"]) {
145
175
  expect((await get(query(kind, `node_id=${ids.active}&network_id=${a.network_id}`), limited.token)).status).toBe(404);
@@ -1,8 +1,11 @@
1
1
  import { db } from "./db.js";
2
2
  import { addAgentNetworkScope, type RestNetworkScope } from "./network-scope.js";
3
+ import { forkRecoveryRequestSchema, publicForkResult } from "./codex-fork-contract.js";
3
4
 
4
5
  // Exact public codes only; daemon exception text must never reach node viewers.
5
6
  const publicLifecycleErrors = new Set([
7
+ "codex_fork_confirmation_invalid", "codex_fork_recovery_unsupported", "codex_fork_cli_unsupported",
8
+ "codex_fork_result_invalid", "codex_fork_outcome_unknown",
6
9
  "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
7
10
  "adopt_active_binding_required", "adopt_binding_unavailable",
8
11
  "adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
@@ -91,9 +94,22 @@ export function lifecycleRequestResponse(url: URL, scope: RestNetworkScope): Res
91
94
  const params: any[] = [requestId ?? nodeId];
92
95
  const sql = scopeNodes(`SELECT r.request_id,r.${nodeColumn} AS node_id,r.network_id,r.daemon_node_id,
93
96
  r.status,r.error,r.created_at,${kind === "adopt" ? "r.updated_at" : "r.acked_at,r.delivered_at"}
97
+ ${kind === "start" ? ",r.fork_recovery_json,r.fork_result_json" : ""}
94
98
  FROM ${table} r JOIN nodes n ON n.node_id=r.${nodeColumn} AND n.network_id=r.network_id
95
99
  WHERE r.${requestId ? "request_id" : nodeColumn}=?1${kind === "stop" ? " AND r.action='stop'" : ""}`, params, scope);
96
100
  const row = db.get<Record<string, unknown>>(sql + " ORDER BY r.created_at DESC,r.request_id DESC LIMIT 1", ...params);
97
101
  if (!row && requestId) return fail("request_not_found", 404);
98
- return Response.json({ ok: true, request: row ? { kind, ...row, error: publicLifecycleError(row.error) } : null });
102
+ if (!row) return Response.json({ ok: true, request: null });
103
+ const { fork_recovery_json, fork_result_json, ...publicRow } = row;
104
+ let confirmed: true | null = null;
105
+ if (kind === "start" && typeof fork_recovery_json === "string") {
106
+ try {
107
+ if (forkRecoveryRequestSchema.safeParse(JSON.parse(fork_recovery_json)).success) confirmed = true;
108
+ } catch { /* damaged confirmation is unknown; keep any independently valid fork evidence */ }
109
+ }
110
+ return Response.json({ ok: true, request: { kind, ...publicRow, error: publicLifecycleError(row.error),
111
+ ...(kind === "start" && fork_recovery_json != null ? { fork_recovery: {
112
+ requested: confirmed, result: publicForkResult(fork_result_json),
113
+ } } : {}),
114
+ } });
99
115
  }
@@ -2,6 +2,15 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { db } from "./db.js";
4
4
  import { registerTools } from "./tools.js";
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+ import { spawn } from "node:child_process";
9
+ import { handleStartDoorbell } from "../../agent-node/src/runtime/start-daemon.js";
10
+ import { lifecycleRequestResponse } from "./node-lifecycle-read.js";
11
+ import { z } from "zod/v4";
12
+ import { createCodexForkCapabilityMonitor } from "../../agent-node/src/runtime/codex-fork-capability.js";
13
+ import { attachCodexForkCapability, buildConfigSnapshot } from "../../agent-node/src/runtime/config-apply.js";
5
14
 
6
15
  const NET = "net_start_node"; const USER = "u_start_node";
7
16
  const DAEMON = "node_start_daemon"; const DAEMON_ALIAS = "start-daemon";
@@ -25,18 +34,132 @@ beforeEach(() => { cleanup();
25
34
  afterAll(cleanup);
26
35
 
27
36
  function handlers(user: string | null, daemon = false) {
28
- const s = new McpServer({ name: "t", version: "0" }) as any; const out: any = {};
29
- const orig = s.tool.bind(s); s.tool = (n: string, d: string, schema: any, h: any) => { out[n] = h; return orig(n,d,schema,h); };
37
+ const s = new McpServer({ name: "t", version: "0" }) as any; const out: any = { $schemas: {} };
38
+ const orig = s.tool.bind(s); s.tool = (n: string, d: string, schema: any, h: any) => { out[n] = h; out.$schemas[n] = schema; return orig(n,d,schema,h); };
30
39
  registerTools(s, undefined, daemon ? NET : null, user, null, daemon, daemon ? "tok_start_daemon" : null); return out;
31
40
  }
32
41
  async function call(h: any, args: any) { return JSON.parse((await h(args)).content[0].text); }
33
42
 
43
+ describe("confirmed fork request/result transport (#822)", () => {
44
+ const recovery = { kind: "fork_on_missing_ordinal", confirmed: true };
45
+ const forked = { state: "forked", old_thread_id: "01a11846-d796-72f1-af68-8d9215a65dc8",
46
+ new_thread_id: "01a11900-0000-7000-8000-000000000001" };
47
+ function enable() {
48
+ db.run(`UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2`, [JSON.stringify({ role: "host_supervisor",
49
+ daemon_capabilities: { codex_fork_recovery: { protocol: 1, cli_supported: true } } }), DAEMON]);
50
+ }
51
+ function read(requestId: string, networkId = NET) {
52
+ return lifecycleRequestResponse(new URL(`http://hub/api/node-lifecycle-requests?kind=start&request_id=${requestId}`),
53
+ { networkId, networkIds: null });
54
+ }
55
+ test("real capability probe survives report schema/storage and opens only the recovery gate", async () => {
56
+ const root = mkdtempSync(join(tmpdir(), "fork-capability-report-"));
57
+ try {
58
+ const bin = join(root, "anet-fixture");
59
+ writeFileSync(bin, `#!${process.execPath}\nconsole.log('--fork-on-resume-failure --fork-recovery-request-id <str_id> --yes');`, { mode: 0o700 });
60
+ const monitor = createCodexForkCapabilityMonitor({ bin: () => bin, cwd: root });
61
+ await monitor.refresh();
62
+ const d = handlers(null, true), u = handlers(USER);
63
+ const snapshot = attachCodexForkCapability(buildConfigSnapshot({ role: "host_supervisor" }, false, 0), monitor.current());
64
+ const wire = z.object(d.$schemas.report_status).parse({ alias: DAEMON_ALIAS, node_id: DAEMON,
65
+ resume_id: "fork-capability-daemon", status: "idle", config_snapshot: snapshot });
66
+ await call(d.report_status, wire);
67
+ const stored = JSON.parse(db.get<any>(`SELECT config_snapshot FROM nodes WHERE node_id=?1`, DAEMON).config_snapshot);
68
+ expect(stored.daemon_capabilities.codex_fork_recovery).toEqual({ protocol: 1, cli_supported: true });
69
+ expect((await call(u.start_node, { node_id: CHILD, fork_recovery: recovery })).ok).toBe(true);
70
+ // Bad diagnostics are dropped, not a reason to reject the entire heartbeat.
71
+ for (const bad of [null, { protocol: 2, cli_supported: true }, { protocol: 1, cli_supported: "yes" }]) {
72
+ const parsed = d.$schemas.report_status.config_snapshot.parse({ ...snapshot,
73
+ daemon_capabilities: { can_create_nodes: true, codex_fork_recovery: bad } });
74
+ expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
75
+ expect(parsed.daemon_capabilities.codex_fork_recovery).toBeUndefined();
76
+ }
77
+ } finally { rmSync(root, { recursive: true, force: true }); }
78
+ });
79
+ test("requires explicit confirmation and affirmative capability before creating any request", async () => {
80
+ const u = handlers(USER);
81
+ for (const value of [null, {}, { ...recovery, confirmed: false }]) {
82
+ expect((await call(u.start_node, { node_id: CHILD, fork_recovery: value })).error).toBe("codex_fork_confirmation_invalid");
83
+ }
84
+ expect((await call(u.start_node, { node_id: CHILD, fork_recovery: recovery })).error).toBe("codex_fork_recovery_unsupported");
85
+ expect(db.all(`SELECT * FROM node_start_requests WHERE network_id=?1`, NET)).toEqual([]);
86
+ expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD).lifecycle_state).toBe("stopped");
87
+ enable();
88
+ expect((await call(u.start_node, { node_id: CHILD, fork_recovery: recovery })).ok).toBe(true);
89
+ });
90
+ test("persists one confirmation and refuses old daemon pull without marking delivered", async () => {
91
+ enable(); const u = handlers(USER), d = handlers(null, true);
92
+ const { request_id } = await call(u.start_node, { node_id: CHILD, fork_recovery: recovery });
93
+ expect((await call(d.get_start_request, { request_id })).error).toBe("codex_fork_recovery_unsupported");
94
+ const row = db.get<any>(`SELECT * FROM node_start_requests WHERE request_id=?1`, request_id);
95
+ expect(row.status).toBe("pending"); expect(JSON.parse(row.fork_recovery_json)).toEqual(recovery);
96
+ expect(await call(d.get_start_request, { request_id, fork_recovery_capable: true })).toMatchObject({
97
+ ok: true, request_id, managed: "created", fork_recovery: recovery,
98
+ });
99
+ });
100
+ test.each([0, 7])("Hub → daemon → real CLI fixture exit %s → persisted fork and scoped read", async exitCode => {
101
+ enable(); const u = handlers(USER), d = handlers(null, true);
102
+ const { request_id } = await call(u.start_node, { node_id: CHILD, fork_recovery: recovery });
103
+ const root = mkdtempSync(join(tmpdir(), "fork-flow-"));
104
+ try {
105
+ const nodeDir = join(root, CHILD_ALIAS); mkdirSync(nodeDir);
106
+ writeFileSync(join(nodeDir, "config.json"), JSON.stringify({ node_id: CHILD, alias: CHILD_ALIAS,
107
+ runtime: "codex-app-server", codexCopresence: true }), { mode: 0o600 });
108
+ const bin = join(root, "anet-fixture");
109
+ // Real executable: validates argv, writes a CLI-shaped mapping and exits.
110
+ // It is not a real Codex/model recovery test.
111
+ writeFileSync(bin, `#!${process.execPath}\nimport {writeFileSync} from 'node:fs';
112
+ const args=process.argv.slice(2);
113
+ if(args.join(' ')==='node start --help') { console.log('--fork-on-resume-failure --fork-recovery-request-id <str_id> --yes'); process.exit(0); }
114
+ if(JSON.stringify(args)!==${JSON.stringify(JSON.stringify(["node", "start", CHILD_ALIAS, "--fork-on-resume-failure", "--yes", "--fork-recovery-request-id", request_id]))}) process.exit(99);
115
+ writeFileSync(${JSON.stringify(join(nodeDir, "codex-fork-recovery.json"))},JSON.stringify({forks:[{requestId:${JSON.stringify(request_id)},oldThreadId:${JSON.stringify(forked.old_thread_id)},newThreadId:${JSON.stringify(forked.new_thread_id)},snapshot:'/private/snapshot'}]}));
116
+ process.exit(${exitCode});`, { mode: 0o700 });
117
+ await handleStartDoorbell({ request_id }, { workDir: root, nodesRoot: root, anetBin: () => bin,
118
+ callCommHub: (tool, args) => call(d[tool], args), log() {}, warn() {} });
119
+ const row = db.get<any>(`SELECT * FROM node_start_requests WHERE request_id=?1`, request_id);
120
+ expect(row.status).toBe(exitCode === 0 ? "started" : "start_failed");
121
+ expect(JSON.parse(row.fork_result_json)).toEqual(forked);
122
+ const publicResult = await read(request_id).json();
123
+ expect(publicResult.request.fork_recovery).toEqual({ requested: true, result: forked });
124
+ expect(JSON.stringify(publicResult)).not.toContain("/private");
125
+ expect(JSON.stringify(publicResult)).not.toContain("fork_result_json");
126
+ expect(read(request_id, "net_unrelated").status).toBe(404);
127
+ // Terminal replay must not overwrite the original side effect receipt.
128
+ expect(await call(d.ack_start_request, { request_id, status: "start_failed", fork_recovery: { state: "not_observed" } }))
129
+ .toMatchObject({ ok: true, idempotent: true });
130
+ expect(JSON.parse(db.get<any>(`SELECT fork_result_json FROM node_start_requests WHERE request_id=?1`, request_id).fork_result_json)).toEqual(forked);
131
+ } finally { rmSync(root, { recursive: true, force: true }); }
132
+ });
133
+ test("missing result is unknown, malformed result rejected; ordinary receipts keep old shape", async () => {
134
+ const u = handlers(USER), d = handlers(null, true);
135
+ const ordinary = await call(u.start_node, { node_id: CHILD });
136
+ expect((await call(d.ack_start_request, { request_id: ordinary.request_id, status: "start_failed", fork_recovery: forked })).error).toBe("codex_fork_result_invalid");
137
+ await call(d.ack_start_request, { request_id: ordinary.request_id, status: "start_failed" });
138
+ expect((await read(ordinary.request_id).json()).request).not.toHaveProperty("fork_recovery");
139
+ enable(); const { request_id } = await call(u.start_node, { node_id: CHILD, fork_recovery: recovery });
140
+ expect((await call(d.ack_start_request, { request_id, status: "start_failed", fork_recovery: { ...forked, snapshot: "/private" } })).error).toBe("codex_fork_result_invalid");
141
+ await call(d.ack_start_request, { request_id, status: "start_failed", error: "codex_fork_cli_unsupported" });
142
+ expect((await read(request_id).json()).request).toMatchObject({ error: "codex_fork_cli_unsupported",
143
+ fork_recovery: { result: { state: "unknown", reason: "result_not_reported" } } });
144
+ db.run(`UPDATE node_start_requests SET fork_result_json=?1 WHERE request_id=?2`, [JSON.stringify({ ...forked, snapshot: "/private" }), request_id]);
145
+ expect((await read(request_id).json()).request.fork_recovery.result).toEqual({ state: "unknown", reason: "result_invalid" });
146
+ });
147
+ test("stale recovery cannot be silently superseded by the ordinary start reaper", async () => {
148
+ enable(); const u = handlers(USER);
149
+ const { request_id } = await call(u.start_node, { node_id: CHILD, fork_recovery: recovery });
150
+ db.run(`UPDATE node_start_requests SET created_at=?1 WHERE request_id=?2`, [Date.now() - 70_000, request_id]);
151
+ expect(await call(u.start_node, { node_id: CHILD })).toMatchObject({ ok: false,
152
+ error: "codex_fork_outcome_unknown", existing_request_id: request_id });
153
+ expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id=?1`, request_id).status).toBe("pending");
154
+ });
155
+ });
156
+
34
157
  describe("start_node Hub -> daemon lifecycle", () => {
35
158
  test("dispatch, authenticated pull, ack transitions stopped -> starting -> active", async () => {
36
159
  const u = handlers(USER); const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
37
160
  expect(dispatch.ok).toBe(true); expect(dispatch.lifecycle_state).toBe("starting");
38
161
  const d = handlers(null, true); const pulled = await call(d.get_start_request, { request_id: dispatch.request_id });
39
- expect(pulled).toMatchObject({ ok: true, child_node_id: CHILD, child_alias: CHILD_ALIAS });
162
+ expect(pulled).toMatchObject({ ok: true, child_node_id: CHILD, child_alias: CHILD_ALIAS, start_completion_capable: true });
40
163
  const ack = await call(d.ack_start_request, { request_id: dispatch.request_id, status: "started", child_pid: 4321 });
41
164
  expect(ack.ok).toBe(true);
42
165
  expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("active");
@@ -49,6 +172,38 @@ describe("start_node Hub -> daemon lifecycle", () => {
49
172
  db.run(`UPDATE nodes SET lifecycle_state='stopped' WHERE node_id=?1`, CHILD);
50
173
  expect((await call(u.start_node, { child_node_id: CHILD, daemon_node_id: "node_wrong", network_id: NET })).error).toBe("daemon_child_mismatch");
51
174
  });
175
+ test("launcher progress refreshes stale-start clock, never marks active, and failure stays stopped", async () => {
176
+ const u = handlers(USER), d = handlers(null, true);
177
+ const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
178
+ const request_id = dispatch.request_id;
179
+ await call(d.get_start_request, { request_id });
180
+ db.run(`UPDATE node_start_requests SET created_at=?1, delivered_at=?1 WHERE request_id=?2`, [Date.now() - 70_000, request_id]);
181
+ expect(await call(d.ack_start_request, { request_id, status: "starting" })).toEqual({ ok: true, status: "starting" });
182
+ expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("starting");
183
+ expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id=?1`, request_id)?.status).toBe("delivered");
184
+ expect((await call(u.start_node, { child_node_id: CHILD, network_id: NET })).error).toBe("node_already_starting");
185
+ expect(await call(d.ack_start_request, { request_id, status: "start_failed", error: "codex_launcher_exit:1" })).toEqual({ ok: true, status: "start_failed" });
186
+ expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe("stopped");
187
+ expect(db.all<any>(`SELECT action FROM audit_log WHERE target_id=?1`, request_id).map(r => r.action)).toEqual(["start_node_dispatched"]);
188
+ // A heartbeat already in flight cannot resurrect a terminal request.
189
+ expect(await call(d.ack_start_request, { request_id, status: "starting" })).toMatchObject({ status: "start_failed", idempotent: true });
190
+ });
191
+ test.each([0, 7])("dispatch → daemon → real launcher exit %s → Hub lifecycle", async (exitCode) => {
192
+ const root = mkdtempSync(join(tmpdir(), "start-flow-"));
193
+ try {
194
+ mkdirSync(join(root, CHILD_ALIAS));
195
+ writeFileSync(join(root, CHILD_ALIAS, "config.json"), JSON.stringify({ node_id: CHILD, alias: CHILD_ALIAS, codexCopresence: true }), { mode: 0o600 });
196
+ const u = handlers(USER), d = handlers(null, true);
197
+ const dispatch = await call(u.start_node, { child_node_id: CHILD, network_id: NET });
198
+ await handleStartDoorbell({ request_id: dispatch.request_id }, {
199
+ workDir: root, nodesRoot: root, anetBin: () => process.execPath,
200
+ spawnChild: ((_bin, _args, opts) => spawn(process.execPath, ["-e", `setTimeout(() => process.exit(${exitCode}), 25)`], opts)) as typeof spawn,
201
+ callCommHub: (tool, args) => call(d[tool], args), log: () => {}, warn: () => {},
202
+ });
203
+ expect(db.get<any>(`SELECT status FROM node_start_requests WHERE request_id=?1`, dispatch.request_id)?.status).toBe(exitCode === 0 ? "started" : "start_failed");
204
+ expect(db.get<any>(`SELECT lifecycle_state FROM nodes WHERE node_id=?1`, CHILD)?.lifecycle_state).toBe(exitCode === 0 ? "active" : "stopped");
205
+ } finally { rmSync(root, { recursive: true, force: true }); }
206
+ });
52
207
  });
53
208
 
54
209
  // #1448 finding-2 — stale-starting reaper(对齐 update_node_config/restart_node 的 60s
@@ -192,7 +192,12 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
192
192
  // so any new node tool needs this; ceilings keep the same ~400–750 B margin.
193
193
  // Review follow-up: descriptions / schemas cut further → node 59 / 61,100 B, all 85 / 76,749 B (−507 B each),
194
194
  // leaving ≥ 700 B node margin even with #2488 (+116 B) merged.
195
- const CEILINGS = { node: 62_000, user: 57_500, all: 78_000 } as const;
195
+ // Board #822 adds confirmed fork fields, not tools. Same-image main 102dd0f2 → candidate:
196
+ // all 87 / 77,646 B → 78,976 B (+1,330); node 61 / 61,986 B → 62,368 B (+382);
197
+ // user 58 / 56,950 B → 57,163 B (+213). start_node adds 213 B, report_status 169 B;
198
+ // hidden pull/ack schemas account for the remaining 948 B. Keep strict receipt validation
199
+ // and unchanged audiences; budget only this measured addition (~130/520 B node/all margin).
200
+ const CEILINGS = { node: 62_500, user: 57_500, all: 79_500 } as const;
196
201
  test("node / user / all", async () => {
197
202
  const node = await listTools(T.node_normal);
198
203
  const user = await listTools(T.owner);
package/src/tools.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
3
3
  import { runtimeReadinessSchema } from "./runtime-readiness.js";
4
+ import { forkRecoveryRequestSchema, forkRecoveryResultSchema, publicForkResult } from "./codex-fork-contract.js";
4
5
  import { lockNodeAlias } from "./node-token-ownership.js";
5
6
  import { clearNodeFromAgentTeams, agentTeamWhoami } from "./agent-teams.js";
6
7
  import { parseDbTimestampMs } from "./db-timestamp.js";
@@ -840,6 +841,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
840
841
  // 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
841
842
  // 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
842
843
  daemon_capabilities: z.object({
844
+ codex_fork_recovery: z.object({ protocol: z.literal(1), cli_supported: z.boolean() }).optional().catch(undefined),
843
845
  adopt_capable: z.boolean().optional(),
844
846
  runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
845
847
  allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
@@ -5393,9 +5395,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5393
5395
  {
5394
5396
  ...NODE_ID_ALIAS_FIELDS,
5395
5397
  daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
5398
+ fork_recovery: forkRecoveryRequestSchema.optional(),
5396
5399
  network_id: z.string().max(200).optional(),
5397
5400
  },
5398
- async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
5401
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId, fork_recovery }) => {
5399
5402
  const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
5400
5403
  if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
5401
5404
  const child_node_id = idArg.node_id;
@@ -5414,6 +5417,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5414
5417
  );
5415
5418
  if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
5416
5419
  if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
5420
+ if (fork_recovery !== undefined) {
5421
+ const fail = (error: string) => ({ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error }) }] });
5422
+ if (!forkRecoveryRequestSchema.safeParse(fork_recovery).success) return fail("codex_fork_confirmation_invalid");
5423
+ // Confirmation is additional to the existing caller/child authority.
5424
+ // Require an affirmative new capability; missing means old/unsupported.
5425
+ if (createdDaemon(child_node_id) !== resolvedDaemonId || activeBinding(child_node_id)) return fail("codex_fork_recovery_unsupported");
5426
+ const daemon = db.get<{ config_snapshot: string | null; network_id: string }>(
5427
+ `SELECT config_snapshot, network_id FROM nodes WHERE node_id=?1`, resolvedDaemonId);
5428
+ let snapshot: any;
5429
+ try { snapshot = JSON.parse(daemon?.config_snapshot || "{}"); } catch { /* unsupported */ }
5430
+ const capability = snapshot?.daemon_capabilities?.codex_fork_recovery;
5431
+ if (daemon?.network_id !== child.network_id || snapshot?.role !== "host_supervisor"
5432
+ || capability?.protocol !== 1 || capability?.cli_supported !== true) return fail("codex_fork_recovery_unsupported");
5433
+ }
5417
5434
  // #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
5418
5435
  // 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
5419
5436
  // (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
@@ -5426,8 +5443,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5426
5443
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
5427
5444
  }
5428
5445
  const STALE_STARTING_MS = 60_000;
5429
- const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
5430
- `SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
5446
+ const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null; fork_recovery_json: string | null }>(
5447
+ `SELECT request_id, created_at, delivered_at, acked_at, fork_recovery_json FROM node_start_requests
5431
5448
  WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
5432
5449
  ORDER BY created_at DESC LIMIT 1`, child.node_id,
5433
5450
  );
@@ -5443,6 +5460,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5443
5460
  hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
5444
5461
  }) }] };
5445
5462
  }
5463
+ // A timed-out receipt cannot prove a confirmed fork did not happen.
5464
+ // Do not create a second recovery generation via the ordinary reaper.
5465
+ if (inFlight.fork_recovery_json) return { content: [{ type: "text" as const, text: JSON.stringify({
5466
+ ok: false, error: "codex_fork_outcome_unknown", existing_request_id: inFlight.request_id,
5467
+ }) }] };
5446
5468
  // Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
5447
5469
  // node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
5448
5470
  db.run(
@@ -5472,9 +5494,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5472
5494
  db.run(
5473
5495
  `INSERT INTO node_start_requests
5474
5496
  (request_id, network_id, daemon_node_id, child_node_id, child_alias,
5475
- created_by_token, status, created_at)
5476
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
5477
- [requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
5497
+ created_by_token, status, created_at, fork_recovery_json)
5498
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8)`,
5499
+ [requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now,
5500
+ fork_recovery ? JSON.stringify(fork_recovery) : null],
5478
5501
  );
5479
5502
  // #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
5480
5503
  // starting);两种入态都合法地转/留到 'starting'。
@@ -5497,19 +5520,27 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5497
5520
  server.tool(
5498
5521
  "get_start_request",
5499
5522
  "Host supervisor pulls an authenticated pending start request.",
5500
- { request_id: z.string().min(1).max(200) },
5501
- async ({ request_id }) => {
5523
+ { request_id: z.string().min(1).max(200), fork_recovery_capable: z.boolean().optional() },
5524
+ async ({ request_id, fork_recovery_capable }) => {
5502
5525
  const callerDaemon = resolveCallerDaemonTokenBound();
5503
5526
  if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
5504
- const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
5505
- `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
5527
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string; fork_recovery_json: string | null }>(
5528
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status, fork_recovery_json FROM node_start_requests WHERE request_id = ?1`, request_id,
5506
5529
  );
5507
5530
  if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
5508
5531
  if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
5509
5532
  if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
5510
5533
  if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
5534
+ let recovery: unknown;
5535
+ if (row.fork_recovery_json !== null) {
5536
+ if (fork_recovery_capable !== true || createdDaemon(row.child_node_id) !== row.daemon_node_id || activeBinding(row.child_node_id))
5537
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "codex_fork_recovery_unsupported" }) }] };
5538
+ try { recovery = forkRecoveryRequestSchema.parse(JSON.parse(row.fork_recovery_json)); }
5539
+ catch { return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "codex_fork_confirmation_invalid" }) }] }; }
5540
+ }
5511
5541
  db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
5512
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id, child_node_id: row.child_node_id, child_alias: row.child_alias }) }] };
5542
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id, child_node_id: row.child_node_id, child_alias: row.child_alias, start_completion_capable: true,
5543
+ ...(recovery ? { managed: "created", fork_recovery: recovery } : {}) }) }] };
5513
5544
  },
5514
5545
  );
5515
5546
 
@@ -5518,15 +5549,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5518
5549
  "Host supervisor reports start completion or failure.",
5519
5550
  {
5520
5551
  request_id: z.string().min(1).max(200),
5521
- status: z.enum(["started", "start_failed"]),
5552
+ status: z.enum(["starting", "started", "start_failed"]),
5522
5553
  child_pid: z.number().int().positive().optional(),
5523
5554
  error: z.string().max(1000).optional(),
5555
+ fork_recovery: forkRecoveryResultSchema.optional(),
5524
5556
  },
5525
- async ({ request_id, status, child_pid, error: ackError }) => {
5557
+ async ({ request_id, status, child_pid, error: ackError, fork_recovery }) => {
5526
5558
  const callerDaemon = resolveCallerDaemonTokenBound();
5527
5559
  if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
5528
- const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
5529
- `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
5560
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string; fork_recovery_json: string | null }>(
5561
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status, fork_recovery_json FROM node_start_requests WHERE request_id=?1`, request_id,
5530
5562
  );
5531
5563
  if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
5532
5564
  if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
@@ -5537,13 +5569,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5537
5569
  if (!['pending', 'delivered'].includes(row.status)) {
5538
5570
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
5539
5571
  }
5572
+ if (fork_recovery !== undefined && (!row.fork_recovery_json || status === "starting"
5573
+ || !forkRecoveryResultSchema.safeParse(fork_recovery).success)) {
5574
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "codex_fork_result_invalid" }) }] };
5575
+ }
5576
+ // A finite co-presence launcher can spend minutes restoring a large thread.
5577
+ // Refresh the existing stale-start clock, without claiming startup completed.
5578
+ if (status === "starting") {
5579
+ db.run(`UPDATE node_start_requests SET acked_at=?1 WHERE request_id=?2`, [Date.now(), request_id]);
5580
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "starting" }) }] };
5581
+ }
5540
5582
  if (status === "started" && !child_pid) {
5541
5583
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
5542
5584
  }
5543
5585
  const now = Date.now();
5544
5586
  try {
5545
5587
  db.transaction(() => {
5546
- db.run(`UPDATE node_start_requests SET status=?1,error=?2,child_pid=?3,acked_at=?4 WHERE request_id=?5`, [status, ackError || null, child_pid || null, now, request_id]);
5588
+ db.run(`UPDATE node_start_requests SET status=?1,error=?2,child_pid=?3,acked_at=?4,fork_result_json=?6 WHERE request_id=?5`,
5589
+ [status, ackError || null, child_pid || null, now, request_id,
5590
+ row.fork_recovery_json ? JSON.stringify(publicForkResult(fork_recovery)) : null]);
5547
5591
  db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
5548
5592
  if (status === "started") auditCreateNodeStrict({
5549
5593
  action: "start_node_completed", network_id: row.network_id, target_id: request_id,