@sleep2agi/commhub-server 0.9.0-preview.117 → 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 +1 -1
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/db.ts +6 -0
- package/src/node-lifecycle-read-http.test.ts +30 -0
- package/src/node-lifecycle-read.ts +17 -1
- package/src/node-permissions.ts +1 -0
- package/src/schedule-agent-mcp-http.test.ts +66 -0
- package/src/schedule-agent.ts +20 -1
- package/src/start-node.test.ts +158 -3
- package/src/tool-audience-http.test.ts +7 -1
- package/src/tool-audience.ts +1 -1
- package/src/tools.ts +64 -17
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.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:
|
|
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
|
}
|
package/src/node-permissions.ts
CHANGED
|
@@ -271,6 +271,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
|
271
271
|
// 定时任务(#733):在 schedule-agent.ts 里按排程判(范围 / 目标 = send_task 的 dispatchVerdict / 配额)
|
|
272
272
|
schedule_list: "read", schedule_get: "read", schedule_runs: "read",
|
|
273
273
|
schedule_create: "schedule", schedule_update: "schedule", schedule_cancel: "schedule", schedule_run_now: "schedule",
|
|
274
|
+
schedule_batch_interval: "schedule",
|
|
274
275
|
// 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
|
|
275
276
|
update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
|
|
276
277
|
stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
|
|
@@ -161,6 +161,72 @@ describe("happy path from a node", () => {
|
|
|
161
161
|
});
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
describe("batch interval updates (#816)", () => {
|
|
165
|
+
const batch = (token: string, schedule_ids: string[], every_seconds = 600) =>
|
|
166
|
+
mcp(token, "schedule_batch_interval", { schedule_ids, every_seconds });
|
|
167
|
+
const get = async (id: string) => (await mcp(node.b.token, "schedule_get", { schedule_id: id })).schedule;
|
|
168
|
+
|
|
169
|
+
test("two updates, deduplicated IDs, paused stays paused, unselected stays untouched", async () => {
|
|
170
|
+
const a = (await create("b")).schedule, b = (await create("b")).schedule;
|
|
171
|
+
const untouched = (await create("b")).schedule;
|
|
172
|
+
await mcp(node.b.token, "schedule_update", { schedule_id: b.schedule_id, status: "paused" });
|
|
173
|
+
const before = Date.now();
|
|
174
|
+
const result = await batch(node.b.token, [a.schedule_id, b.schedule_id, a.schedule_id]);
|
|
175
|
+
expect(result).toMatchObject({ ok: true, updated: 2, failed: 0 });
|
|
176
|
+
expect(result.results).toHaveLength(2);
|
|
177
|
+
expect(result.results[0]).toMatchObject({ schedule_id: a.schedule_id, every_seconds: 600, revision: a.revision + 1, status: "active" });
|
|
178
|
+
expect(Date.parse(result.results[0].next_run_at)).toBeGreaterThanOrEqual(before + 600_000);
|
|
179
|
+
expect(result.results[1]).toMatchObject({ status: "paused", next_run_at: null, every_seconds: 600 });
|
|
180
|
+
for (const original of [a, b]) {
|
|
181
|
+
expect(await get(original.schedule_id)).toMatchObject({ name: original.name, task_content: original.task_content, target_node_id: original.target_node_id, schedule: { type: "interval", every_seconds: 600 } });
|
|
182
|
+
}
|
|
183
|
+
expect(await get(untouched.schedule_id)).toEqual(untouched);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("partial results preserve human, foreign-network, daily and cancelled schedules", async () => {
|
|
187
|
+
const own = (await create("b")).schedule;
|
|
188
|
+
const daily = (await create("b", { schedule: { type: "daily", time: "09:30" } })).schedule;
|
|
189
|
+
const cancelled = (await create("b")).schedule;
|
|
190
|
+
await mcp(node.b.token, "schedule_cancel", { schedule_id: cancelled.schedule_id });
|
|
191
|
+
const human = await rest(user.boss.token, "POST", "/api/scheduled-tasks", { network_id: net, name: "human batch", task: "t", target_node_id: node.b.id, schedule: EVERY_MIN });
|
|
192
|
+
const foreign = (await create("x")).schedule;
|
|
193
|
+
const ids = [human.body.schedule.schedule_id, foreign.schedule_id, "no-such-schedule", daily.schedule_id, cancelled.schedule_id, own.schedule_id];
|
|
194
|
+
const result = await batch(node.b.token, ids);
|
|
195
|
+
expect(result).toMatchObject({ ok: false, updated: 1, failed: 5 });
|
|
196
|
+
expect(result.results.map((r: any) => r.error ?? "ok")).toEqual(["not_schedule_creator", "schedule_not_found", "schedule_not_found", "not_interval_schedule", "schedule_cancelled", "ok"]);
|
|
197
|
+
expect((await get(ids[0])).schedule).toEqual(EVERY_MIN);
|
|
198
|
+
expect((await mcp(node.x.token, "schedule_get", { schedule_id: foreign.schedule_id })).schedule).toEqual(foreign);
|
|
199
|
+
expect(await get(daily.schedule_id)).toEqual(daily);
|
|
200
|
+
expect((await get(cancelled.schedule_id)).status).toBe("cancelled");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("invalid interval or batch shape performs no writes; node-only and readonly gates remain", async () => {
|
|
204
|
+
const own = (await create("b")).schedule, ids = [own.schedule_id];
|
|
205
|
+
for (const seconds of [59, 60.5, 365 * 86400 + 1]) {
|
|
206
|
+
expect(await batch(node.b.token, ids, seconds)).toMatchObject({ ok: false, error: "invalid_interval" });
|
|
207
|
+
}
|
|
208
|
+
for (const invalid of [[], Array(101).fill(own.schedule_id), [""]]) {
|
|
209
|
+
expect((await batch(node.b.token, invalid)).ok).not.toBe(true);
|
|
210
|
+
}
|
|
211
|
+
expect((await batch(user.boss.token, ids)).error).toBe("network_token_required");
|
|
212
|
+
expect(await batch(node.ro.token, ids)).toMatchObject({ error: "node_permission_denied", reason: "mode_readonly" });
|
|
213
|
+
expect(await get(own.schedule_id)).toEqual(own);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("creator still needs permission to reach the current target", async () => {
|
|
217
|
+
const own = (await create("b", { target_node_id: node.a.id })).schedule;
|
|
218
|
+
db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.rst.id, node.b.id]);
|
|
219
|
+
try {
|
|
220
|
+
const result = await batch(node.b.token, [own.schedule_id]);
|
|
221
|
+
expect(result).toMatchObject({ ok: false, updated: 0, failed: 1 });
|
|
222
|
+
expect(result.results[0]).toMatchObject({ reason: "agent_not_granted_to_owner" });
|
|
223
|
+
expect(await get(own.schedule_id)).toEqual(own);
|
|
224
|
+
} finally {
|
|
225
|
+
db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = ?2", [user.boss.id, node.b.id]);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
164
230
|
describe("denials", () => {
|
|
165
231
|
test("another network: cannot target, see or touch it", async () => {
|
|
166
232
|
const mine = (await create("a")).schedule.schedule_id;
|
package/src/schedule-agent.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { db } from "./db.js";
|
|
24
24
|
import {
|
|
25
25
|
cancelSchedule, decodeRowForAgent, dispatchScheduledOccurrence, getScheduleRow, insertSchedule, patchSchedule,
|
|
26
|
-
scheduleRuns, ScheduleInputError, validateScheduleCreate, type ScheduledRow,
|
|
26
|
+
scheduleRuns, ScheduleInputError, validateScheduleCreate, parseScheduleSpec, type ScheduledRow,
|
|
27
27
|
} from "./scheduled-tasks.js";
|
|
28
28
|
import { SCHEDULED_TASK_STORAGE_SELECT } from "./rest-projections.js";
|
|
29
29
|
import { dispatchVerdict, nodePermissionDeniedBody, recordNodePermission, type NodeIdentity } from "./node-permissions.js";
|
|
@@ -145,6 +145,25 @@ export function agentUpdateSchedule(id: NodeIdentity, canWrite: boolean, schedul
|
|
|
145
145
|
} catch (e) { return inputError(e); }
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** #816: partial success, one result per distinct ID; no change to existing write authority. */
|
|
149
|
+
export function agentBatchScheduleInterval(id: NodeIdentity, canWrite: boolean, scheduleIds: string[], everySeconds: number): AgentScheduleResult {
|
|
150
|
+
const gate = writeGate(id, canWrite, "mcp:schedule_batch_interval");
|
|
151
|
+
if (gate) return gate;
|
|
152
|
+
const schedule = { type: "interval", every_seconds: everySeconds };
|
|
153
|
+
try { parseScheduleSpec(schedule, "UTC"); } catch (e) { return inputError(e); }
|
|
154
|
+
const results = [...new Set(scheduleIds)].map(schedule_id => {
|
|
155
|
+
const row = ownRow(id, schedule_id);
|
|
156
|
+
if (!isRow(row)) return { schedule_id, ...row };
|
|
157
|
+
if (row.schedule_type !== "interval") return { schedule_id, ok: false, error: "not_interval_schedule" };
|
|
158
|
+
const result = agentUpdateSchedule(id, canWrite, schedule_id, { schedule, base_revision: row.revision });
|
|
159
|
+
if (!result.ok) return { schedule_id, ...result };
|
|
160
|
+
const updated = result.schedule as ReturnType<typeof decodeRowForAgent>;
|
|
161
|
+
return { schedule_id, ok: true, every_seconds: everySeconds, next_run_at: updated.next_run_at, status: updated.status, revision: updated.revision };
|
|
162
|
+
});
|
|
163
|
+
const updated = results.filter(r => r.ok).length;
|
|
164
|
+
return { ok: updated === results.length, updated, failed: results.length - updated, results };
|
|
165
|
+
}
|
|
166
|
+
|
|
148
167
|
export function agentCancelSchedule(id: NodeIdentity, canWrite: boolean, scheduleId: string): AgentScheduleResult {
|
|
149
168
|
const gate = writeGate(id, canWrite, "mcp:schedule_cancel");
|
|
150
169
|
if (gate) return gate;
|
package/src/start-node.test.ts
CHANGED
|
@@ -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
|
|
@@ -43,6 +43,7 @@ const OVERRIDES: Record<string, Record<string, unknown>> = {
|
|
|
43
43
|
mark_tasks_consumed: { task_ids: ["task_probe"] },
|
|
44
44
|
upsert_network_secret: { key: "PROBE_KEY" },
|
|
45
45
|
update_provider: { provider_id: "prov_probe" },
|
|
46
|
+
schedule_batch_interval: { schedule_ids: ["sched_probe"], every_seconds: 60 },
|
|
46
47
|
};
|
|
47
48
|
function fill(schema: any, key: string, ctx: Record<string, unknown>): unknown {
|
|
48
49
|
if (!schema) return "probe";
|
|
@@ -191,7 +192,12 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
|
191
192
|
// so any new node tool needs this; ceilings keep the same ~400–750 B margin.
|
|
192
193
|
// Review follow-up: descriptions / schemas cut further → node 59 / 61,100 B, all 85 / 76,749 B (−507 B each),
|
|
193
194
|
// leaving ≥ 700 B node margin even with #2488 (+116 B) merged.
|
|
194
|
-
|
|
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;
|
|
195
201
|
test("node / user / all", async () => {
|
|
196
202
|
const node = await listTools(T.node_normal);
|
|
197
203
|
const user = await listTools(T.owner);
|
package/src/tool-audience.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
|
29
29
|
report_status: "node", org_whoami: "node",
|
|
30
30
|
// #733 定时任务:节点工具(用户令牌一律 network_token_required;人走 REST /api/scheduled-tasks)
|
|
31
31
|
schedule_list: "node", schedule_get: "node", schedule_create: "node", schedule_update: "node",
|
|
32
|
-
schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node",
|
|
32
|
+
schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node", schedule_batch_interval: "node",
|
|
33
33
|
// ── 协议:用户令牌一律 network_token_required / node_token_required / caller_not_a_daemon ──
|
|
34
34
|
mark_tasks_runtime_submitted: "protocol", mark_tasks_consumed: "protocol",
|
|
35
35
|
get_config_update: "protocol", ack_config_update: "protocol",
|
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";
|
|
@@ -21,7 +22,7 @@ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
|
21
22
|
import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
22
23
|
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
|
|
23
24
|
import { listedToolFilter, scopeToolsList, type ToolCaller } from "./tool-audience.js";
|
|
24
|
-
import { agentCancelSchedule, agentCreateSchedule, agentGetSchedule, agentListSchedules, agentRunScheduleNow, agentScheduleRuns, agentUpdateSchedule } from "./schedule-agent.js";
|
|
25
|
+
import { agentBatchScheduleInterval, agentCancelSchedule, agentCreateSchedule, agentGetSchedule, agentListSchedules, agentRunScheduleNow, agentScheduleRuns, agentUpdateSchedule } from "./schedule-agent.js";
|
|
25
26
|
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
|
|
26
27
|
import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
27
28
|
import { errorBody } from "./requirements-errors.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`,
|
|
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,
|
|
@@ -6396,6 +6440,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6396
6440
|
server.tool("schedule_update", "Edit your schedule; status=paused|active.",
|
|
6397
6441
|
{ ...schedFields, schedule_id: schedId, base_revision: z.number().int().min(0).optional().describe("Reject if current revision differs."), name: z.string().optional(), task: z.string().optional(), schedule: z.any().optional(), status: z.string().optional() },
|
|
6398
6442
|
async ({ schedule_id, ...rest }) => scheduleCall((id, w) => agentUpdateSchedule(id, w, schedule_id, rest as Record<string, unknown>)));
|
|
6443
|
+
server.tool("schedule_batch_interval", "Batch interval edit; per-ID results.",
|
|
6444
|
+
{ schedule_ids: z.array(z.string()).min(1).max(100), every_seconds: z.number() },
|
|
6445
|
+
async ({ schedule_ids, every_seconds }) => scheduleCall((id, w) => agentBatchScheduleInterval(id, w, schedule_ids, every_seconds)));
|
|
6399
6446
|
server.tool("schedule_cancel", "Cancel your schedule.", { schedule_id: schedId },
|
|
6400
6447
|
async ({ schedule_id }) => scheduleCall((id, w) => agentCancelSchedule(id, w, schedule_id)));
|
|
6401
6448
|
server.tool("schedule_run_now", "Run your schedule now.", { schedule_id: schedId },
|