@sleep2agi/commhub-server 0.9.0-preview.121 → 0.9.0-preview.122

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.121",
3
+ "version": "0.9.0-preview.122",
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,248 @@
1
+ // Hub #938 — clone_node on the same daemon: new token, rename collision,
2
+ // cross-daemon refusal. Handler-level, same shape as stop-delete-node.test.ts.
3
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { db, hashToken } from "./db.js";
6
+ import { peekPendingEnvBlob, stopBackgroundTimersForTest } from "./create-node.js";
7
+ import { registerTools } from "./tools.js";
8
+ import { sessionContractForRuntime } from "./clone-node.js";
9
+
10
+ const NET = "net_clone_938";
11
+ const USER = "u_clone_938";
12
+ const DAEMON = "node_clone_daemon_a";
13
+ const DAEMON_B = "node_clone_daemon_b";
14
+ const DAEMON_ALIAS = "clone-daemon-a";
15
+ const DAEMON_B_ALIAS = "clone-daemon-b";
16
+ const DAEMON_TOK = "tok_clone_daemon_a";
17
+ const SOURCE = "node_clone_source";
18
+ const SOURCE_ALIAS = "src-child";
19
+ const SOURCE_TOKEN = "ntok_source_SHOULD_NOT_COPY";
20
+ const OTHER = "node_clone_other";
21
+ const OTHER_ALIAS = "other-child";
22
+
23
+ interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
24
+ interface Reply { ok?: boolean; error?: string; [k: string]: unknown; }
25
+
26
+ function cleanup() {
27
+ for (const n of [NET]) {
28
+ try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [n]); } catch {}
29
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [n]); } catch {}
30
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
31
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
32
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [n]); } catch {}
33
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [n]); } catch {}
34
+ }
35
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
36
+ stopBackgroundTimersForTest();
37
+ }
38
+
39
+ beforeEach(cleanup);
40
+ afterAll(cleanup);
41
+
42
+ function seed() {
43
+ db.run(
44
+ `INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
45
+ [USER, USER],
46
+ );
47
+ db.run(
48
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`,
49
+ [NET, NET, USER],
50
+ );
51
+ db.run(
52
+ `INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'admin', datetime('now'))`,
53
+ [USER, NET],
54
+ );
55
+ const snap = JSON.stringify({
56
+ role: "member",
57
+ runtime: "claude-agent-sdk",
58
+ model: "claude-sonnet",
59
+ flags: { permissionMode: "acceptEdits", timeout: 60000, apiKey: "sk-do-not-copy" },
60
+ channels: ["server:commhub", "feishu:bot-secret"],
61
+ });
62
+ db.run(
63
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, runtime, model, channels, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
64
+ VALUES (?1, ?2, ?3, ?4, 'claude-agent-sdk', 'claude-sonnet', ?5, ?6, 'host-a', datetime('now'), datetime('now'), 'active')`,
65
+ [SOURCE, SOURCE_ALIAS, SOURCE_ALIAS, NET, JSON.stringify(["server:commhub", "feishu:bot-secret"]), snap],
66
+ );
67
+ db.run(
68
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, role) VALUES (?1, ?2, ?3, 'network', ?4, ?5, 'child')`,
69
+ ["tok_clone_source", USER, NET, `node:${SOURCE_ALIAS}`, hashToken(SOURCE_TOKEN)],
70
+ );
71
+ db.run(
72
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
73
+ VALUES (?1, ?2, ?2, ?3, ?4, 'host-a', datetime('now'), datetime('now'), 'active')`,
74
+ [DAEMON, DAEMON_ALIAS, NET, JSON.stringify({ role: "host_supervisor", daemon_capabilities: { can_create_nodes: true, runtimes_supported: ["claude-agent-sdk", "opencode-cli"] } })],
75
+ );
76
+ db.run(
77
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash) VALUES (?1, ?2, ?3, 'network', ?4, ?5)`,
78
+ [DAEMON_TOK, USER, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
79
+ );
80
+ db.run(
81
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
82
+ VALUES (?1, ?2, ?2, ?3, ?4, 'host-b', datetime('now'), datetime('now'), 'active')`,
83
+ [DAEMON_B, DAEMON_B_ALIAS, NET, JSON.stringify({ role: "host_supervisor" })],
84
+ );
85
+ db.run(
86
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
87
+ VALUES (?1, ?2, ?2, ?3, '{}', 'host-a', datetime('now'), datetime('now'), 'active')`,
88
+ [OTHER, OTHER_ALIAS, NET],
89
+ );
90
+ db.run(
91
+ `INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
92
+ VALUES('cr_clone_source', ?1, ?2, ?3, 'claude-agent-sdk', 'claude-sonnet', ?4, '[]', 'succeeded', 1, 't', ?5)`,
93
+ [DAEMON, SOURCE_ALIAS, NET, JSON.stringify({ permissionMode: "plan" }), SOURCE],
94
+ );
95
+ }
96
+
97
+ function handlers(daemonBound = false): Record<string, ToolHandler> {
98
+ const server = new McpServer({ name: "test", version: "0" }) as any;
99
+ const tools: Record<string, ToolHandler> = {};
100
+ const orig = server.tool.bind(server);
101
+ server.tool = (name: string, desc: string, schema: any, handler: ToolHandler) => {
102
+ tools[name] = handler;
103
+ return orig(name, desc, schema, handler);
104
+ };
105
+ registerTools(server, undefined, daemonBound ? NET : null, USER, null, daemonBound, daemonBound ? DAEMON_TOK : null);
106
+ return tools;
107
+ }
108
+
109
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
110
+ const r = await handler(args);
111
+ return JSON.parse(r.content[0].text) as Reply;
112
+ }
113
+
114
+ describe("clone_node — happy path (Hub #938)", () => {
115
+ test("mints a new token, keeps the source token, records clone directives", async () => {
116
+ seed();
117
+ const tools = handlers();
118
+ const r = await call(tools.clone_node, {
119
+ source_node_id: SOURCE,
120
+ name: "cloned-child",
121
+ network_id: NET,
122
+ });
123
+ expect(r.ok).toBe(true);
124
+ expect(r.daemon_node_id).toBe(DAEMON);
125
+ expect(r.child_name).toBe("cloned-child");
126
+ expect(r.copy_session).toBe(true);
127
+ expect(r.workdir_policy).toBe("new_empty");
128
+ expect(r.session_contract).toMatchObject({ mode: "transcript_when_present", runtime: "claude-agent-sdk" });
129
+ const contract = r.contract as { token: string; workdir: string; session: string };
130
+ expect(contract.token).toContain("new ntok_");
131
+ expect(contract.workdir).toContain("new empty directory");
132
+ expect(contract.session).toContain("jsonl");
133
+
134
+ const requestId = r.request_id as string;
135
+ const row = db.get<{ clone_json: string; runtime: string; flags_json: string; child_token_id: string; status: string }>(
136
+ `SELECT clone_json, runtime, flags_json, child_token_id, status FROM node_create_requests WHERE request_id = ?1`,
137
+ requestId,
138
+ );
139
+ expect(row?.status).toBe("pending");
140
+ expect(row?.runtime).toBe("claude-agent-sdk");
141
+ const flags = JSON.parse(row!.flags_json);
142
+ expect(flags.permissionMode).toBe("acceptEdits");
143
+ expect(flags.timeout).toBe(60000);
144
+ expect(JSON.stringify(flags)).not.toContain("sk-do-not-copy");
145
+ const meta = JSON.parse(row!.clone_json);
146
+ expect(meta).toMatchObject({
147
+ source_node_id: SOURCE,
148
+ source_alias: SOURCE_ALIAS,
149
+ copy_session: true,
150
+ workdir_policy: "new_empty",
151
+ channels: ["server:commhub"],
152
+ });
153
+ expect(JSON.stringify(meta.channels)).not.toContain("feishu");
154
+
155
+ const blob = peekPendingEnvBlob(requestId);
156
+ expect(blob?.env_blob).toEqual({});
157
+ expect(blob?.child_token.startsWith("ntok_")).toBe(true);
158
+ expect(blob?.child_token).not.toBe(SOURCE_TOKEN);
159
+ const minted = db.get<{ token_hash: string }>(
160
+ `SELECT token_hash FROM api_tokens WHERE token_id = ?1`, row!.child_token_id,
161
+ );
162
+ expect(minted?.token_hash).toBe(hashToken(blob!.child_token));
163
+ expect(minted?.token_hash).not.toBe(hashToken(SOURCE_TOKEN));
164
+ const sourceTok = db.get<{ revoked_at: string | null }>(
165
+ `SELECT revoked_at FROM api_tokens WHERE token_id = 'tok_clone_source'`,
166
+ );
167
+ expect(sourceTok?.revoked_at ?? null).toBeNull();
168
+
169
+ const daemonTools = handlers(true);
170
+ const listed = await call(daemonTools.list_my_pending_create_requests, {});
171
+ expect(listed.ok).toBe(true);
172
+ expect(listed.requests).toEqual([]);
173
+ expect(listed.clone_requests).toEqual([
174
+ expect.objectContaining({ request_id: requestId, child_name: "cloned-child" }),
175
+ ]);
176
+ const refused = await call(daemonTools.get_create_request, { request_id: requestId });
177
+ expect(refused).toMatchObject({ ok: false, error: "clone_request" });
178
+ expect(peekPendingEnvBlob(requestId)?.child_token).toBe(blob?.child_token);
179
+ const pulled = await call(daemonTools.get_create_request, { request_id: requestId, expect_clone: true });
180
+ expect(pulled.ok).toBe(true);
181
+ expect(pulled.child_token).toBe(blob?.child_token);
182
+ expect((pulled.clone as { source_alias: string }).source_alias).toBe(SOURCE_ALIAS);
183
+ expect(peekPendingEnvBlob(requestId)).toBeNull();
184
+ });
185
+ });
186
+
187
+ describe("clone_node — rename collision", () => {
188
+ test("refuses the source name, an existing alias, and an inflight create name", async () => {
189
+ seed();
190
+ db.run(
191
+ `INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token)
192
+ VALUES('cr_clone_inflight', ?1, 'pending-name', ?2, 'claude-agent-sdk', 'm', '{}', '[]', 'pending', ?3, 't')`,
193
+ [DAEMON, NET, Date.now()],
194
+ );
195
+ const tools = handlers();
196
+ const same = await call(tools.clone_node, { source_node_id: SOURCE, name: SOURCE_ALIAS, network_id: NET });
197
+ expect(same).toMatchObject({ ok: false, error: "node_name_conflict", reason: "same_as_source" });
198
+ const taken = await call(tools.clone_node, { source_node_id: SOURCE, name: OTHER_ALIAS, network_id: NET });
199
+ expect(taken).toMatchObject({ ok: false, error: "node_name_conflict", reason: "alias_taken" });
200
+ const inflight = await call(tools.clone_node, { source_node_id: SOURCE, name: "pending-name", network_id: NET });
201
+ expect(inflight).toMatchObject({ ok: false, error: "node_name_conflict", reason: "inflight_create" });
202
+ const n = db.get<{ n: number }>(
203
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE child_name = 'cloned-child'`,
204
+ );
205
+ expect(n?.n).toBe(0);
206
+ });
207
+ });
208
+
209
+ describe("clone_node — cross-daemon refused", () => {
210
+ test("a daemon_node_id other than the source's daemon is refused", async () => {
211
+ seed();
212
+ const tools = handlers();
213
+ const r = await call(tools.clone_node, {
214
+ source_node_id: SOURCE,
215
+ name: "cloned-child",
216
+ daemon_node_id: DAEMON_B,
217
+ network_id: NET,
218
+ });
219
+ expect(r.ok).toBe(false);
220
+ expect(r.error).toBe("cross_daemon_refused");
221
+ expect(r.source_daemon_node_id).toBe(DAEMON);
222
+ expect(r.requested_daemon_node_id).toBe(DAEMON_B);
223
+ const n = db.get<{ n: number }>(
224
+ `SELECT COUNT(*) AS n FROM api_tokens WHERE name = 'node:cloned-child'`,
225
+ );
226
+ expect(n?.n ?? 0).toBe(0);
227
+ });
228
+
229
+ test("omitting daemon_node_id stays on the source daemon", async () => {
230
+ seed();
231
+ const tools = handlers();
232
+ const r = await call(tools.clone_node, { source_node_id: SOURCE, name: "cloned-child", network_id: NET });
233
+ expect(r.ok).toBe(true);
234
+ expect(r.daemon_node_id).toBe(DAEMON);
235
+ });
236
+ });
237
+
238
+ describe("session contract is honest per runtime", () => {
239
+ test("OpenCode is an aliased server session, not a transcript fork", () => {
240
+ const c = sessionContractForRuntime("opencode-cli");
241
+ expect(c.mode).toBe("id_aliased");
242
+ expect(c.detail).toContain("not a private deep copy");
243
+ });
244
+
245
+ test("an unknown runtime does not claim a session copy", () => {
246
+ expect(sessionContractForRuntime("not-a-runtime").mode).toBe("unsupported");
247
+ });
248
+ });
@@ -0,0 +1,289 @@
1
+ // Hub #938 — clone a daemon child into a new node on the SAME daemon.
2
+ //
3
+ // Contract (also what `clone_node` returns as `contract`):
4
+ //
5
+ // Identity new node_id (minted like create_node: `node_<request id>`),
6
+ // new alias, new ntok_. The source token is never copied and
7
+ // never placed in the env blob.
8
+ // Copied runtime, model / provider ref (the model string, including
9
+ // `provider/model`), flags that pass the create_node flag
10
+ // checks, and commhub channel specs (string entries that are
11
+ // the network itself). Secret-looking flag values are dropped.
12
+ // Not copied token, secrets.env, inline secret env values (key names may
13
+ // remain as env refs with no value), non-commhub channel
14
+ // specs (those carry per-node bot credentials).
15
+ // Session `copy_session` defaults to true. What that can actually do
16
+ // depends on the runtime — see `sessionContractForRuntime`.
17
+ // The daemon performs the copy; this module only states the
18
+ // capability so the caller is not told a deep copy happened
19
+ // when the vendor store cannot be forked.
20
+ // Workdir `new_empty` (default) or `share_source`. The daemon chooses
21
+ // the path; this module only records the policy.
22
+ //
23
+ // Workdir policy, decided on the daemon because only it knows the source
24
+ // directory:
25
+ //
26
+ // new_empty (default)
27
+ // A new empty directory under the same parent as the source workdir:
28
+ // `dirname(sourceWorkdir)/<ascii folder>`. The folder name is the new
29
+ // alias when that alias is already an ASCII directory name, otherwise
30
+ // the usual `node-<hash>` slug. Nothing inside the source workdir is
31
+ // copied (no rules file, no checkout).
32
+ // When the source has no project workdir of its own — it was created
33
+ // in the daemon cwd — there is no sibling project to sit beside. The
34
+ // clone is then placed at `<daemon cwd>/clone-work/<ascii folder>`,
35
+ // still a new empty directory under the daemon root. That is the only
36
+ // case that does not use the source's parent.
37
+ //
38
+ // share_source
39
+ // Explicit opt-in. The clone's cwd is the source workdir. Two nodes
40
+ // share that checkout. The token is still new. Session files that are
41
+ // keyed by cwd do not need to be relocated.
42
+ //
43
+ // Cross-daemon: the clone is dispatched only to the daemon that created
44
+ // the source (`node_create_requests.daemon_node_id` of the succeeded row).
45
+ // Passing a different `daemon_node_id` is `cross_daemon_refused`. A node
46
+ // with no succeeded create row on a daemon is `source_not_daemon_child`.
47
+
48
+ import {
49
+ FLAG_KEYS,
50
+ validateFlagValue,
51
+ validateFlagsForRuntime,
52
+ validateModel,
53
+ validateName,
54
+ validateRuntime,
55
+ ValidationError,
56
+ } from "./create-node-validate.js";
57
+
58
+ export const CLONE_WORKDIR_POLICIES = ["new_empty", "share_source"] as const;
59
+ export type CloneWorkdirPolicy = typeof CLONE_WORKDIR_POLICIES[number];
60
+
61
+ const SECRET_VALUE_RX = /^(sk-|utok_|ntok_|atok_|ak-|gsk_|key-|Bearer\s)/i;
62
+
63
+ export interface CloneSourceFacts {
64
+ nodeId: string;
65
+ alias: string;
66
+ role: string | null;
67
+ runtime: string | null;
68
+ model: string | null;
69
+ flags: Record<string, unknown>;
70
+ channels: unknown;
71
+ /** Daemon that owns the succeeded create row, or null if this node is not a daemon child. */
72
+ owningDaemonId: string | null;
73
+ /** Aliases / node names already present in the network. */
74
+ existingNames: readonly string[];
75
+ /** child_name values with a pending/delivered create on the owning daemon. */
76
+ inflightNames: readonly string[];
77
+ }
78
+
79
+ export interface CloneAsk {
80
+ newName: string;
81
+ copySession: boolean;
82
+ workdirPolicy: CloneWorkdirPolicy;
83
+ /** When set, must equal the owning daemon. */
84
+ requestedDaemonId: string | null;
85
+ }
86
+
87
+ export interface SessionContract {
88
+ /** Stable code. Not a claim that a private transcript fork already exists. */
89
+ mode: "transcript_when_present" | "id_aliased" | "unsupported";
90
+ runtime: string;
91
+ /** What resume/continue will do, in one sentence the caller can show. */
92
+ detail: string;
93
+ }
94
+
95
+ export type CloneDecision =
96
+ | { ok: false; error: string; detail?: Record<string, unknown> }
97
+ | {
98
+ ok: true;
99
+ daemonNodeId: string;
100
+ name: string;
101
+ runtime: string;
102
+ model: string | null;
103
+ flags: Record<string, unknown>;
104
+ channels: string[];
105
+ droppedChannels: string[];
106
+ copySession: boolean;
107
+ workdirPolicy: CloneWorkdirPolicy;
108
+ sessionContract: SessionContract;
109
+ };
110
+
111
+ export function sessionContractForRuntime(runtime: string): SessionContract {
112
+ if (runtime === "claude-agent-sdk" || runtime === "claude-code-cli") {
113
+ return {
114
+ mode: "transcript_when_present",
115
+ runtime,
116
+ detail: "Claude resume is the session id plus ~/.claude/projects/<cwd>/<id>.jsonl. The daemon copies that file into the clone's cwd key and writes the same session id. If the file is not on disk, the id is not copied: a bare id would bind an empty session, not continue the old one.",
117
+ };
118
+ }
119
+ if (runtime === "codex-sdk" || runtime === "codex-app-server") {
120
+ return {
121
+ mode: "transcript_when_present",
122
+ runtime,
123
+ detail: "Codex resume needs the rollout file under the new node's CODEX_HOME. The daemon copies that rollout and keeps the thread id when the file is present. auth.json is not copied. If there is no rollout, the thread id is not copied. This is not `anet node codex fork` (that rewrites a new thread id).",
124
+ };
125
+ }
126
+ if (runtime === "grok-build-acp" || runtime === "grok-build-cli") {
127
+ return {
128
+ mode: "transcript_when_present",
129
+ runtime,
130
+ detail: "Grok resume needs the session directory under the grok home, keyed by cwd. The daemon copies that directory and keeps the session id when it exists. If it does not, the id is not copied: grok refuses to resume a missing directory.",
131
+ };
132
+ }
133
+ if (runtime === "opencode-cli") {
134
+ return {
135
+ mode: "id_aliased",
136
+ runtime,
137
+ detail: "OpenCode sessions live in the OpenCode server, not in the node directory. The daemon copies the session id so session/load addresses that same server session. That is not a private deep copy: both nodes share one server-side transcript. There is no local transcript to fork.",
138
+ };
139
+ }
140
+ return {
141
+ mode: "unsupported",
142
+ runtime,
143
+ detail: "This runtime has no clone session copy. The new node starts without a session id.",
144
+ };
145
+ }
146
+
147
+ function safeFlags(runtime: string, raw: Record<string, unknown>): Record<string, unknown> {
148
+ const out: Record<string, unknown> = {};
149
+ for (const [k, v] of Object.entries(raw)) {
150
+ if (!(FLAG_KEYS as readonly string[]).includes(k)) continue;
151
+ if (typeof v === "string" && SECRET_VALUE_RX.test(v)) continue;
152
+ try {
153
+ validateFlagValue(k, v);
154
+ } catch {
155
+ continue;
156
+ }
157
+ out[k] = v;
158
+ }
159
+ validateFlagsForRuntime(runtime, out);
160
+ return out;
161
+ }
162
+
163
+ /** Commhub channel specs only. Anything else can carry a bot credential. */
164
+ export function safeChannels(raw: unknown): { kept: string[]; dropped: string[] } {
165
+ if (!Array.isArray(raw)) return { kept: [], dropped: [] };
166
+ const kept: string[] = [];
167
+ const dropped: string[] = [];
168
+ for (const ch of raw) {
169
+ if (typeof ch !== "string") {
170
+ dropped.push(typeof ch);
171
+ continue;
172
+ }
173
+ if (ch.includes("commhub") || ch.startsWith("server:")) kept.push(ch);
174
+ else dropped.push(ch.slice(0, 80));
175
+ }
176
+ return { kept, dropped };
177
+ }
178
+
179
+ function readRole(snapshot: unknown): string | null {
180
+ if (!snapshot || typeof snapshot !== "object") return null;
181
+ const role = (snapshot as { role?: unknown }).role;
182
+ return typeof role === "string" ? role : null;
183
+ }
184
+
185
+ export function factsFromRows(input: {
186
+ node: { node_id: string; alias: string | null; node_name?: string | null; runtime?: string | null; model?: string | null; channels?: string | null; config_snapshot?: string | null };
187
+ createRow: { daemon_node_id: string; runtime: string; model: string | null; flags_json: string; child_name: string } | null;
188
+ existingNames: readonly string[];
189
+ inflightNames: readonly string[];
190
+ }): CloneSourceFacts {
191
+ let snap: Record<string, unknown> = {};
192
+ try {
193
+ const parsed = input.node.config_snapshot ? JSON.parse(input.node.config_snapshot) : null;
194
+ if (parsed && typeof parsed === "object") snap = parsed as Record<string, unknown>;
195
+ } catch { /* snapshot is display state; the create row still stands */ }
196
+ let createFlags: Record<string, unknown> = {};
197
+ try {
198
+ const parsed = input.createRow ? JSON.parse(input.createRow.flags_json) : {};
199
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) createFlags = parsed as Record<string, unknown>;
200
+ } catch { /* ignore */ }
201
+ const snapFlags = snap.flags && typeof snap.flags === "object" && !Array.isArray(snap.flags)
202
+ ? snap.flags as Record<string, unknown>
203
+ : {};
204
+ const runtime = (typeof snap.runtime === "string" && snap.runtime) ? snap.runtime
205
+ : (input.node.runtime || input.createRow?.runtime || null);
206
+ const model = (typeof snap.model === "string" || snap.model === null) ? (snap.model as string | null)
207
+ : (input.node.model ?? input.createRow?.model ?? null);
208
+ const channels = Array.isArray(snap.channels) ? snap.channels
209
+ : (input.node.channels ? safeParseArray(input.node.channels) : []);
210
+ return {
211
+ nodeId: input.node.node_id,
212
+ alias: input.node.alias || input.node.node_name || input.createRow?.child_name || "",
213
+ role: readRole(snap),
214
+ runtime,
215
+ model,
216
+ flags: { ...createFlags, ...snapFlags },
217
+ channels,
218
+ owningDaemonId: input.createRow?.daemon_node_id ?? null,
219
+ existingNames: input.existingNames,
220
+ inflightNames: input.inflightNames,
221
+ };
222
+ }
223
+
224
+ function safeParseArray(raw: string): unknown[] {
225
+ try {
226
+ const v = JSON.parse(raw);
227
+ return Array.isArray(v) ? v : [];
228
+ } catch {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ /** Pure. Throws ValidationError for a bad new name, model, or runtime. */
234
+ export function decideClone(source: CloneSourceFacts, ask: CloneAsk): CloneDecision {
235
+ if (!source.alias) {
236
+ return { ok: false, error: "source_alias_missing", detail: { source_node_id: source.nodeId } };
237
+ }
238
+ if (source.role === "host_supervisor") {
239
+ return { ok: false, error: "cannot_clone_daemon", detail: { source_node_id: source.nodeId } };
240
+ }
241
+ if (!source.owningDaemonId) {
242
+ return { ok: false, error: "source_not_daemon_child", detail: { source_node_id: source.nodeId } };
243
+ }
244
+ if (ask.requestedDaemonId && ask.requestedDaemonId !== source.owningDaemonId) {
245
+ return {
246
+ ok: false,
247
+ error: "cross_daemon_refused",
248
+ detail: { source_daemon_node_id: source.owningDaemonId, requested_daemon_node_id: ask.requestedDaemonId },
249
+ };
250
+ }
251
+ const name = validateName(ask.newName);
252
+ if (name === source.alias) {
253
+ return { ok: false, error: "node_name_conflict", detail: { name, reason: "same_as_source" } };
254
+ }
255
+ if (source.existingNames.includes(name)) {
256
+ return { ok: false, error: "node_name_conflict", detail: { name, reason: "alias_taken" } };
257
+ }
258
+ if (source.inflightNames.includes(name)) {
259
+ return { ok: false, error: "node_name_conflict", detail: { name, reason: "inflight_create" } };
260
+ }
261
+ if (!source.runtime) {
262
+ return { ok: false, error: "source_runtime_unknown", detail: { source_node_id: source.nodeId } };
263
+ }
264
+ validateRuntime(source.runtime);
265
+ validateModel(source.model);
266
+ const flags = safeFlags(source.runtime, source.flags);
267
+ const channels = safeChannels(source.channels);
268
+ return {
269
+ ok: true,
270
+ daemonNodeId: source.owningDaemonId,
271
+ name,
272
+ runtime: source.runtime,
273
+ model: source.model ?? null,
274
+ flags,
275
+ channels: channels.kept,
276
+ droppedChannels: channels.dropped,
277
+ copySession: ask.copySession,
278
+ workdirPolicy: ask.workdirPolicy,
279
+ sessionContract: sessionContractForRuntime(source.runtime),
280
+ };
281
+ }
282
+
283
+ export function parseWorkdirPolicy(raw: unknown): CloneWorkdirPolicy {
284
+ if (raw === undefined || raw === null || raw === "") return "new_empty";
285
+ if (raw === "new_empty" || raw === "share_source") return raw;
286
+ throw new ValidationError("workdir_policy_invalid", { value: typeof raw === "string" ? raw.slice(0, 40) : typeof raw, allowed: [...CLONE_WORKDIR_POLICIES] });
287
+ }
288
+
289
+ export { ValidationError };