@sleep2agi/commhub-server 0.9.0-preview.120 → 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 +1 -1
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-acl-http.test.ts +2 -1
- package/src/api-host-supervisors-runtime-readiness.test.ts +13 -0
- package/src/auth.ts +8 -7
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/config-apply-validate.test.ts +33 -1
- package/src/config-apply-validate.ts +24 -0
- package/src/create-node-validate.test.ts +26 -1
- package/src/create-node-validate.ts +45 -1
- package/src/create-node-workdir.test.ts +13 -0
- package/src/daemon-provider-snapshots.test.ts +359 -0
- package/src/daemon-provider-snapshots.ts +269 -0
- package/src/daemon-token-owner-http.test.ts +3 -1
- package/src/db.ts +45 -0
- package/src/mcp-context-cost-http.test.ts +3 -1
- package/src/member-presence-http.test.ts +8 -4
- package/src/node-lifecycle-read-http.test.ts +13 -1
- package/src/node-lifecycle-read.ts +6 -0
- package/src/node-permissions.ts +3 -1
- package/src/report-status-host-schema.test.ts +24 -0
- package/src/runtime-readiness.ts +7 -0
- package/src/server.ts +40 -4
- package/src/shared/codex-inventory.ts +34 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/tool-audience-http.test.ts +15 -2
- package/src/tool-audience.ts +3 -1
- package/src/tools.ts +233 -19
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
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.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,137 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
const dir = mkdtempSync(join(tmpdir(), "adoption-candidates-"));
|
|
7
|
+
process.env.COMMHUB_DB ||= join(dir, "hub.db");
|
|
8
|
+
process.env.COMMHUB_AUTH_TOKEN ||= "adoption-candidates-master-fixture";
|
|
9
|
+
const WORKDIR = "/home/example/adoption-candidate-workdir-654";
|
|
10
|
+
const SECRET = "ntok_should_not_surface";
|
|
11
|
+
let hub: { stop: (closeActiveConnections?: boolean) => void; port: number }, base: string;
|
|
12
|
+
let db: any, owner: any, member: any, nodeOwner: any, other: any, daemonToken: string;
|
|
13
|
+
const ids = { daemon: "n_cand_daemon", hand: "n_cand_hand", foreign: "n_cand_foreign" };
|
|
14
|
+
|
|
15
|
+
const get = async (path: string, token = owner.token) => {
|
|
16
|
+
const res = await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } });
|
|
17
|
+
return { status: res.status, body: await res.json() as any, text: "" };
|
|
18
|
+
};
|
|
19
|
+
const raw = async (path: string, token = owner.token) => {
|
|
20
|
+
const res = await fetch(base + path, { headers: { Authorization: `Bearer ${token}` } });
|
|
21
|
+
const text = await res.text();
|
|
22
|
+
return { status: res.status, text, body: JSON.parse(text) };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function snapshot(candidates: unknown, adopt = true) {
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
role: "host_supervisor",
|
|
28
|
+
daemon_capabilities: { adopt_capable: adopt, adoption_candidates: candidates },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
({ db } = await import("./db.js"));
|
|
34
|
+
const { register } = await import("./auth.js");
|
|
35
|
+
const suffix = Date.now();
|
|
36
|
+
owner = register(`cand_owner_${suffix}`, "CandFixture-Strong1!");
|
|
37
|
+
member = register(`cand_member_${suffix}`, "CandFixture-Strong1!");
|
|
38
|
+
nodeOwner = register(`cand_node_owner_${suffix}`, "CandFixture-Strong1!");
|
|
39
|
+
other = register(`cand_other_${suffix}`, "CandFixture-Strong1!");
|
|
40
|
+
expect(owner.ok && member.ok && nodeOwner.ok && other.ok).toBe(true);
|
|
41
|
+
for (const user of [owner, member, nodeOwner, other]) db.run("UPDATE users SET role='user' WHERE user_id=?1", [user.user.user_id]);
|
|
42
|
+
db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')", [owner.network_id, member.user.user_id]);
|
|
43
|
+
db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')", [owner.network_id, nodeOwner.user.user_id]);
|
|
44
|
+
const candidate = { node_id: ids.hand, alias: "hand-node", workdir: WORKDIR, runtime: "claude-agent-sdk", launch_hint: "tmux", token: SECRET, env: { API_KEY: SECRET } };
|
|
45
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot) VALUES(?1,?2,?2,?3,?4,'fixture-host',?5)",
|
|
46
|
+
[ids.daemon, "cand-daemon", owner.network_id, owner.user.user_id, snapshot([candidate, { node_id: "n_missing", alias: "nope", workdir: "/tmp/nope", launch_hint: "bare" }])]);
|
|
47
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot) VALUES(?1,?2,?2,?3,?4,'fixture-host','{}')",
|
|
48
|
+
[ids.hand, "hand-node", owner.network_id, nodeOwner.user.user_id]);
|
|
49
|
+
db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname) VALUES(?1,?2,?2,?3,?4,'other-host')",
|
|
50
|
+
[ids.foreign, "foreign-node", other.network_id, other.user.user_id]);
|
|
51
|
+
db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES('cand-daemon-session','cand-daemon',?1,?2,'idle',datetime('now'))",
|
|
52
|
+
[ids.daemon, owner.network_id]);
|
|
53
|
+
const { generateNetworkToken, hashToken } = await import("./db.js");
|
|
54
|
+
daemonToken = generateNetworkToken();
|
|
55
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('cand-daemon-token',?1,?2,'network','node:cand-daemon',?3)",
|
|
56
|
+
[owner.user.user_id, owner.network_id, hashToken(daemonToken)]);
|
|
57
|
+
const { bootServer } = await import("./server.js");
|
|
58
|
+
hub = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
59
|
+
base = `http://127.0.0.1:${hub.port}`;
|
|
60
|
+
}, 30_000);
|
|
61
|
+
afterAll(() => hub?.stop(true));
|
|
62
|
+
|
|
63
|
+
test("owner and node owner see the candidate; the report token stays out", async () => {
|
|
64
|
+
for (const token of [owner.token, nodeOwner.token]) {
|
|
65
|
+
const r = await raw(`/api/adoption-candidates?network_id=${owner.network_id}`, token);
|
|
66
|
+
expect(r.status).toBe(200);
|
|
67
|
+
expect(r.body.count).toBe(1);
|
|
68
|
+
expect(r.body.candidates).toEqual([{
|
|
69
|
+
node_id: ids.hand, alias: "hand-node", daemon_node_id: ids.daemon, daemon_alias: "cand-daemon",
|
|
70
|
+
hostname: "fixture-host", workdir: WORKDIR, runtime: "claude-agent-sdk", launch_hint: "tmux",
|
|
71
|
+
}]);
|
|
72
|
+
expect(r.text).not.toContain(SECRET);
|
|
73
|
+
expect(r.text).toContain(WORKDIR);
|
|
74
|
+
}
|
|
75
|
+
const supervisors = await raw(`/api/host-supervisors?network_id=${owner.network_id}`);
|
|
76
|
+
expect(supervisors.body.daemons.find((d: any) => d.daemon_node_id === ids.daemon).adoption_discovery).toBe(true);
|
|
77
|
+
expect(supervisors.text).not.toContain(WORKDIR);
|
|
78
|
+
expect(supervisors.text).not.toContain(SECRET);
|
|
79
|
+
const nodes = await raw(`/api/nodes?network_id=${owner.network_id}`);
|
|
80
|
+
expect(nodes.text).not.toContain("adoption_candidate");
|
|
81
|
+
expect(nodes.text).not.toContain(WORKDIR);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("member, other network, daemon token, and query token do not receive the workdir", async () => {
|
|
85
|
+
const memberRes = await raw(`/api/adoption-candidates?network_id=${owner.network_id}`, member.token);
|
|
86
|
+
expect(memberRes.status).toBe(200);
|
|
87
|
+
expect(memberRes.body.candidates).toEqual([]);
|
|
88
|
+
expect(memberRes.text).not.toContain(WORKDIR);
|
|
89
|
+
const otherRes = await raw(`/api/adoption-candidates?network_id=${owner.network_id}`, other.token);
|
|
90
|
+
expect(otherRes.status).toBe(403);
|
|
91
|
+
expect(otherRes.text).not.toContain(WORKDIR);
|
|
92
|
+
expect((await get("/api/adoption-candidates", daemonToken)).status).toBe(403);
|
|
93
|
+
expect((await get("/api/adoption-candidates", process.env.COMMHUB_AUTH_TOKEN!)).status).toBe(403);
|
|
94
|
+
const queryOnly = await fetch(base + `/api/adoption-candidates?network_id=${owner.network_id}&token=${encodeURIComponent(owner.token)}`);
|
|
95
|
+
expect([401, 403]).toContain(queryOnly.status);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("offline, hostname, alias, binding, and created-node filters drop the hint", async () => {
|
|
99
|
+
const path = `/api/adoption-candidates?network_id=${owner.network_id}`;
|
|
100
|
+
db.run("UPDATE sessions SET last_seen_at='2000-01-01 00:00:00' WHERE alias='cand-daemon'");
|
|
101
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
102
|
+
db.run("UPDATE sessions SET last_seen_at=datetime('now') WHERE alias='cand-daemon'");
|
|
103
|
+
expect((await get(path)).body.count).toBe(1);
|
|
104
|
+
|
|
105
|
+
db.run("UPDATE nodes SET hostname='elsewhere' WHERE node_id=?1", [ids.hand]);
|
|
106
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
107
|
+
db.run("UPDATE nodes SET hostname='fixture-host' WHERE node_id=?1", [ids.hand]);
|
|
108
|
+
|
|
109
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [snapshot([{ node_id: ids.hand, alias: "other-name", workdir: WORKDIR, launch_hint: "stopped" }]), ids.daemon]);
|
|
110
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
111
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [snapshot([{ node_id: ids.hand, alias: "hand-node", workdir: WORKDIR, runtime: "claude-agent-sdk", launch_hint: "tmux", token: SECRET }]), ids.daemon]);
|
|
112
|
+
expect((await get(path)).body.count).toBe(1);
|
|
113
|
+
|
|
114
|
+
db.run(`INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at)
|
|
115
|
+
VALUES('adopt_cand_pending',?1,?2,?3,?4,?5,'pending',1,1)`, [owner.network_id, ids.hand, ids.daemon, WORKDIR, owner.user.user_id]);
|
|
116
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
117
|
+
db.run("UPDATE node_daemon_bindings SET status='revoked' WHERE request_id='adopt_cand_pending'");
|
|
118
|
+
expect((await get(path)).body.count).toBe(1);
|
|
119
|
+
|
|
120
|
+
db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_node_id,child_name,network_id,runtime,flags_json,env_keys,status,created_at,created_by_token)
|
|
121
|
+
VALUES('cr_cand_hand',?1,?2,'hand-node',?3,'claude-agent-sdk','{}','[]','succeeded',1,'private')`, [ids.daemon, ids.hand, owner.network_id]);
|
|
122
|
+
expect((await get(path)).body.candidates).toEqual([]);
|
|
123
|
+
db.run("DELETE FROM node_create_requests WHERE request_id='cr_cand_hand'");
|
|
124
|
+
expect((await get(path)).body.count).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("discovery flag is absent unless the daemon actually reported a list", async () => {
|
|
128
|
+
const path = `/api/host-supervisors?network_id=${owner.network_id}`;
|
|
129
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }), ids.daemon]);
|
|
130
|
+
expect((await get(path)).body.daemons[0].adoption_discovery).toBeUndefined();
|
|
131
|
+
expect((await get(path)).body.daemons[0].adopt_capable).toBe(true);
|
|
132
|
+
db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [snapshot([], true), ids.daemon]);
|
|
133
|
+
expect((await get(path)).body.daemons[0].adoption_discovery).toBe(true);
|
|
134
|
+
expect((await get(`/api/adoption-candidates?network_id=${owner.network_id}`)).body.candidates).toEqual([]);
|
|
135
|
+
const limited = await get(path, member.token);
|
|
136
|
+
expect(limited.body.daemons.find((d: any) => d.daemon_node_id === ids.daemon).adoption_discovery).toBe(true);
|
|
137
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Board #654 — project daemon-reported hand-started adoption candidates.
|
|
2
|
+
//
|
|
3
|
+
// The daemon report is a hint, not authority. This module does not adopt,
|
|
4
|
+
// bind, signal, start, or stop. Co-presence / three-stage nodes are not
|
|
5
|
+
// part of the report contract: discovery on the daemon excludes them.
|
|
6
|
+
// `GET /api/nodes` managed/adoption and `/api/node-lifecycle-requests`
|
|
7
|
+
// stay records of requests that already exist; they are not this list.
|
|
8
|
+
import { z } from "zod/v4";
|
|
9
|
+
import { db } from "./db.js";
|
|
10
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
11
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
12
|
+
import { addAgentNetworkScope, addHumanNetworkScope, type RestNetworkScope } from "./network-scope.js";
|
|
13
|
+
|
|
14
|
+
const HINTS = new Set(["bare", "tmux", "stopped", "unverified"]);
|
|
15
|
+
const ONLINE_MS = 5 * 60_000;
|
|
16
|
+
const MAX_PUBLIC = 64;
|
|
17
|
+
|
|
18
|
+
export interface AdoptionCandidateReport {
|
|
19
|
+
node_id: string;
|
|
20
|
+
alias: string;
|
|
21
|
+
workdir: string;
|
|
22
|
+
runtime: string | null;
|
|
23
|
+
launch_hint: "bare" | "tmux" | "stopped" | "unverified";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sanitizeAdoptionCandidate(raw: unknown): AdoptionCandidateReport | null {
|
|
27
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
28
|
+
const row = raw as Record<string, unknown>;
|
|
29
|
+
const { node_id, alias, workdir, launch_hint } = row;
|
|
30
|
+
if (typeof node_id !== "string" || node_id.length < 1 || node_id.length > 128 || /[\x00-\x1f\x7f]/.test(node_id)) return null;
|
|
31
|
+
if (typeof alias !== "string" || alias.length < 1 || alias.length > 128 || /[\x00-\x1f\x7f/\\]/.test(alias)
|
|
32
|
+
|| alias === "." || alias === ".." || alias.startsWith("-")) return null;
|
|
33
|
+
if (typeof workdir !== "string" || !workdir.startsWith("/") || workdir.length > 1024 || /[\x00-\x1f]/.test(workdir)) return null;
|
|
34
|
+
if (typeof launch_hint !== "string" || !HINTS.has(launch_hint)) return null;
|
|
35
|
+
let runtime: string | null = null;
|
|
36
|
+
if (row.runtime != null) {
|
|
37
|
+
if (typeof row.runtime !== "string" || row.runtime.length < 1 || row.runtime.length > 64 || !/^[A-Za-z0-9._-]+$/.test(row.runtime)) return null;
|
|
38
|
+
runtime = row.runtime;
|
|
39
|
+
}
|
|
40
|
+
return { node_id, alias, workdir, runtime, launch_hint: launch_hint as AdoptionCandidateReport["launch_hint"] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const adoptionCandidateItemSchema = z.object({
|
|
44
|
+
node_id: z.string().min(1).max(128),
|
|
45
|
+
alias: z.string().min(1).max(128),
|
|
46
|
+
workdir: z.string().regex(/^\/[^\u0000-\u001f]{0,1023}$/),
|
|
47
|
+
runtime: z.string().min(1).max(64).nullable().optional(),
|
|
48
|
+
launch_hint: z.enum(["bare", "tmux", "stopped", "unverified"]),
|
|
49
|
+
}).strip().catch(null);
|
|
50
|
+
|
|
51
|
+
/** Ingest path for report_status. One bad item becomes null; a bad list never fails the heartbeat.
|
|
52
|
+
* No transform: tools/list JSON Schema cannot represent one. Readers drop nulls. */
|
|
53
|
+
export const adoptionCandidatesReportSchema = z.array(adoptionCandidateItemSchema).max(32).optional().catch(undefined);
|
|
54
|
+
|
|
55
|
+
function snapshotValue(snapshot: unknown): Record<string, any> | null {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = typeof snapshot === "string" ? JSON.parse(snapshot) : snapshot;
|
|
58
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
59
|
+
} catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function snapshotReportsAdoptionDiscovery(snapshot: unknown): boolean {
|
|
63
|
+
const caps = snapshotValue(snapshot)?.daemon_capabilities;
|
|
64
|
+
return caps?.adopt_capable === true && Array.isArray(caps.adoption_candidates);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function reportedCandidates(snapshot: unknown): AdoptionCandidateReport[] {
|
|
68
|
+
const raw = snapshotValue(snapshot)?.daemon_capabilities?.adoption_candidates;
|
|
69
|
+
if (!Array.isArray(raw)) return [];
|
|
70
|
+
const out: AdoptionCandidateReport[] = [];
|
|
71
|
+
for (const item of raw) {
|
|
72
|
+
const clean = sanitizeAdoptionCandidate(item);
|
|
73
|
+
if (clean) out.push(clean);
|
|
74
|
+
if (out.length >= 32) break;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type NodeRow = {
|
|
80
|
+
node_id: string; alias: string; hostname: string | null; network_id: string;
|
|
81
|
+
owner_user_id?: string | null; config_snapshot?: string | null; last_seen_at?: string | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function listAdoptionCandidates(
|
|
85
|
+
scope: RestNetworkScope,
|
|
86
|
+
viewer: { userId: string; isAdmin: boolean },
|
|
87
|
+
nowMs = Date.now(),
|
|
88
|
+
): { ok: true; candidates: Array<AdoptionCandidateReport & { daemon_node_id: string; daemon_alias: string; hostname: string }>; count: number } {
|
|
89
|
+
const daemonParams: any[] = [];
|
|
90
|
+
let daemonSql = `SELECT n.node_id, n.alias, n.hostname, n.network_id, n.config_snapshot, s.last_seen_at
|
|
91
|
+
FROM nodes n
|
|
92
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
93
|
+
WHERE EXISTS (
|
|
94
|
+
SELECT 1 FROM api_tokens t
|
|
95
|
+
WHERE t.network_id = n.network_id AND t.name = 'node:' || n.alias AND t.revoked_at IS NULL
|
|
96
|
+
)`;
|
|
97
|
+
daemonSql = addHumanNetworkScope(daemonSql, daemonParams, scope, "n.network_id");
|
|
98
|
+
const daemonRows = db.all<NodeRow>(daemonSql, ...daemonParams);
|
|
99
|
+
const daemons = new Map<string, NodeRow>();
|
|
100
|
+
for (const row of daemonRows) {
|
|
101
|
+
const prev = daemons.get(row.node_id);
|
|
102
|
+
if (!prev || parseDbTimestampMs(row.last_seen_at ?? "") > parseDbTimestampMs(prev.last_seen_at ?? "")) daemons.set(row.node_id, row);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const nodeParams: any[] = [];
|
|
106
|
+
let nodeSql = "SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id, n.config_snapshot FROM nodes n WHERE 1=1";
|
|
107
|
+
nodeSql = addAgentNetworkScope(nodeSql, nodeParams, scope, { network: "n.network_id", alias: "n.alias", nodeId: "n.node_id" });
|
|
108
|
+
const visible = new Map(db.all<NodeRow>(nodeSql, ...nodeParams).map((n) => [n.node_id, n]));
|
|
109
|
+
|
|
110
|
+
const managed = new Set<string>();
|
|
111
|
+
const bindingParams: any[] = [];
|
|
112
|
+
let bindingSql = `SELECT b.node_id FROM node_daemon_bindings b
|
|
113
|
+
JOIN nodes n ON n.node_id = b.node_id AND n.network_id = b.network_id
|
|
114
|
+
WHERE b.status IN ('pending','active')`;
|
|
115
|
+
bindingSql = addHumanNetworkScope(bindingSql, bindingParams, scope, "b.network_id");
|
|
116
|
+
for (const row of db.all<{ node_id: string }>(bindingSql, ...bindingParams)) managed.add(row.node_id);
|
|
117
|
+
const createdParams: any[] = [];
|
|
118
|
+
let createdSql = `SELECT COALESCE(c.child_node_id, CASE WHEN substr(c.request_id,1,3)='cr_' THEN 'node_' || substr(c.request_id,4) END) AS node_id
|
|
119
|
+
FROM node_create_requests c WHERE 1=1`;
|
|
120
|
+
createdSql = addHumanNetworkScope(createdSql, createdParams, scope, "c.network_id");
|
|
121
|
+
for (const row of db.all<{ node_id: string | null }>(createdSql, ...createdParams)) if (row.node_id) managed.add(row.node_id);
|
|
122
|
+
|
|
123
|
+
const roles = new Map<string, string | null>();
|
|
124
|
+
const maySee = (node: NodeRow) => {
|
|
125
|
+
if (viewer.isAdmin) return true;
|
|
126
|
+
if (node.owner_user_id && node.owner_user_id === viewer.userId) return true;
|
|
127
|
+
if (!roles.has(node.network_id)) roles.set(node.network_id, getUserNetworkRole(viewer.userId, node.network_id));
|
|
128
|
+
const role = roles.get(node.network_id);
|
|
129
|
+
return role === "owner" || role === "admin";
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const candidates: Array<AdoptionCandidateReport & { daemon_node_id: string; daemon_alias: string; hostname: string }> = [];
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
for (const daemon of daemons.values()) {
|
|
135
|
+
const snap = snapshotValue(daemon.config_snapshot);
|
|
136
|
+
if (snap?.role !== "host_supervisor" || snap.daemon_capabilities?.adopt_capable !== true) continue;
|
|
137
|
+
if (!daemon.hostname) continue;
|
|
138
|
+
const seenAt = parseDbTimestampMs(daemon.last_seen_at ?? "");
|
|
139
|
+
if (!Number.isFinite(seenAt) || nowMs - seenAt > ONLINE_MS) continue;
|
|
140
|
+
for (const reported of reportedCandidates(daemon.config_snapshot)) {
|
|
141
|
+
if (candidates.length >= MAX_PUBLIC) break;
|
|
142
|
+
const node = visible.get(reported.node_id);
|
|
143
|
+
if (!node || node.network_id !== daemon.network_id || node.alias !== reported.alias) continue;
|
|
144
|
+
if (!node.hostname || node.hostname !== daemon.hostname) continue;
|
|
145
|
+
if (snapshotValue(node.config_snapshot)?.role === "host_supervisor") continue;
|
|
146
|
+
if (managed.has(node.node_id) || !maySee(node)) continue;
|
|
147
|
+
const key = `${daemon.node_id}\0${node.node_id}`;
|
|
148
|
+
if (seen.has(key)) continue;
|
|
149
|
+
seen.add(key);
|
|
150
|
+
candidates.push({
|
|
151
|
+
node_id: node.node_id, alias: node.alias, daemon_node_id: daemon.node_id, daemon_alias: daemon.alias,
|
|
152
|
+
hostname: daemon.hostname, workdir: reported.workdir, runtime: reported.runtime, launch_hint: reported.launch_hint,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
candidates.sort((a, b) => a.alias.localeCompare(b.alias) || a.node_id.localeCompare(b.node_id) || a.daemon_node_id.localeCompare(b.daemon_node_id));
|
|
157
|
+
return { ok: true, candidates, count: candidates.length };
|
|
158
|
+
}
|
|
@@ -255,7 +255,8 @@ describe("humans: restricted members can still see and DM other humans", () => {
|
|
|
255
255
|
const r = await get(aliceToken, `/api/networks/${NET}/humans`);
|
|
256
256
|
expect(r.status).toBe(200);
|
|
257
257
|
expect(r.body.humans.map((h: any) => h.user_id).sort()).toEqual([adminId, aliceId, bobId].sort());
|
|
258
|
-
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
258
|
+
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["avatar_url", "display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
259
|
+
expect(r.body.humans.every((h: any) => h.avatar_url === null)).toBe(true);
|
|
259
260
|
// 非成员拿不到。
|
|
260
261
|
const outsider = register(`acl_out_${Date.now()}`, PW);
|
|
261
262
|
expect((await get(outsider.token!, `/api/networks/${NET}/humans`)).status).toBe(403);
|
|
@@ -104,6 +104,19 @@ describe("#622 sanitizeRuntimeReadiness(读取侧)", () => {
|
|
|
104
104
|
expect(s.a.network).toBeUndefined();
|
|
105
105
|
expect(s.a.auth).toBe("present");
|
|
106
106
|
});
|
|
107
|
+
test("generation and accepted stay only on opencode-cli", () => {
|
|
108
|
+
const s = sanitizeRuntimeReadiness({
|
|
109
|
+
"opencode-cli": { ...READY, state: "unknown", ok: false, generation: "v2", accepted: false },
|
|
110
|
+
"claude-code-cli": { ...READY, generation: "v2", accepted: true },
|
|
111
|
+
})!;
|
|
112
|
+
expect(s["opencode-cli"].generation).toBe("v2");
|
|
113
|
+
expect(s["opencode-cli"].accepted).toBe(false);
|
|
114
|
+
expect(s["claude-code-cli"].generation).toBeUndefined();
|
|
115
|
+
expect(s["claude-code-cli"].accepted).toBeUndefined();
|
|
116
|
+
const lone = sanitizeRuntimeReadiness({ "opencode-cli": { ...READY, accepted: true, generation: "nope" } })!;
|
|
117
|
+
expect(lone["opencode-cli"].generation).toBeUndefined();
|
|
118
|
+
expect(lone["opencode-cli"].accepted).toBeUndefined();
|
|
119
|
+
});
|
|
107
120
|
});
|
|
108
121
|
|
|
109
122
|
let BASE = "";
|
package/src/auth.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface AuthUser {
|
|
|
35
35
|
user_id: string;
|
|
36
36
|
username: string;
|
|
37
37
|
display_name: string | null;
|
|
38
|
+
avatar_url?: string | null;
|
|
38
39
|
email: string | null;
|
|
39
40
|
role: string;
|
|
40
41
|
}
|
|
@@ -161,7 +162,7 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
161
162
|
|
|
162
163
|
export function login(username: string, password: string, client: SessionClientInfo = {}): AuthResult {
|
|
163
164
|
const user = db.get<any>(
|
|
164
|
-
"SELECT user_id, username, password_hash, display_name, email, role, must_change_password FROM users WHERE username = ?1",
|
|
165
|
+
"SELECT user_id, username, password_hash, display_name, avatar_url, email, role, must_change_password FROM users WHERE username = ?1",
|
|
165
166
|
username);
|
|
166
167
|
|
|
167
168
|
if (!user) {
|
|
@@ -232,7 +233,7 @@ export function login(username: string, password: string, client: SessionClientI
|
|
|
232
233
|
|
|
233
234
|
return {
|
|
234
235
|
ok: true,
|
|
235
|
-
user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
|
|
236
|
+
user: { user_id: user.user_id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url, email: user.email, role: user.role },
|
|
236
237
|
token,
|
|
237
238
|
token_id: tokenId,
|
|
238
239
|
network_id: networkId,
|
|
@@ -384,7 +385,7 @@ function lookupToken(token: string): { row: any; rejected?: undefined } | { row?
|
|
|
384
385
|
const cutoff = idleCutoffOffset();
|
|
385
386
|
const row = db.get<any>(
|
|
386
387
|
`SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
|
|
387
|
-
u.username, u.display_name, u.email, u.role,
|
|
388
|
+
u.username, u.display_name, u.avatar_url, u.email, u.role,
|
|
388
389
|
CASE WHEN t.network_id IS NULL THEN 1
|
|
389
390
|
WHEN EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = t.network_id AND nm.user_id = t.user_id) THEN 1
|
|
390
391
|
ELSE 0 END AS is_member
|
|
@@ -433,7 +434,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
433
434
|
);
|
|
434
435
|
|
|
435
436
|
return {
|
|
436
|
-
user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
|
|
437
|
+
user: { user_id: row.user_id, username: row.username, display_name: row.display_name, avatar_url: row.avatar_url, email: row.email, role: row.role },
|
|
437
438
|
networkId: row.network_id,
|
|
438
439
|
tokenId: row.token_id || null,
|
|
439
440
|
// tokenName carries the binding identity. For node-scoped ntok_, it's
|
|
@@ -702,7 +703,7 @@ export function resetUserPassword(targetUsername: string, callerIsHubAdmin: bool
|
|
|
702
703
|
|
|
703
704
|
export function getNetworkMembers(networkId: string) {
|
|
704
705
|
return db.all<any>(
|
|
705
|
-
`SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name,
|
|
706
|
+
`SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name, u.avatar_url,
|
|
706
707
|
CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
|
|
707
708
|
(SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count,
|
|
708
709
|
CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.task_access = 'all' THEN 'all' ELSE 'scoped' END AS task_access,
|
|
@@ -716,8 +717,8 @@ export function getNetworkMembers(networkId: string) {
|
|
|
716
717
|
|
|
717
718
|
/** 网络里的人类成员(给受限成员也能看的通讯录):只有身份字段,不含角色与授权。 */
|
|
718
719
|
export function getNetworkHumans(networkId: string) {
|
|
719
|
-
return db.all<{ user_id: string; username: string; display_name: string | null }>(
|
|
720
|
-
`SELECT u.user_id, u.username, u.display_name
|
|
720
|
+
return db.all<{ user_id: string; username: string; display_name: string | null; avatar_url: string | null }>(
|
|
721
|
+
`SELECT u.user_id, u.username, u.display_name, u.avatar_url
|
|
721
722
|
FROM network_members nm JOIN users u ON nm.user_id = u.user_id
|
|
722
723
|
WHERE nm.network_id = ?1 ORDER BY COALESCE(NULLIF(u.display_name, ''), u.username), u.user_id`,
|
|
723
724
|
networkId);
|
|
@@ -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
|
+
});
|