@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/adoption-candidates-http.test.ts +137 -0
  3. package/src/adoption-candidates.ts +158 -0
  4. package/src/agent-acl-http.test.ts +2 -1
  5. package/src/api-host-supervisors-runtime-readiness.test.ts +13 -0
  6. package/src/auth.ts +8 -7
  7. package/src/clone-node.test.ts +248 -0
  8. package/src/clone-node.ts +289 -0
  9. package/src/codex-config-flags.ts +77 -0
  10. package/src/config-apply-validate.test.ts +33 -1
  11. package/src/config-apply-validate.ts +24 -0
  12. package/src/create-node-validate.test.ts +26 -1
  13. package/src/create-node-validate.ts +45 -1
  14. package/src/create-node-workdir.test.ts +13 -0
  15. package/src/daemon-provider-snapshots.test.ts +359 -0
  16. package/src/daemon-provider-snapshots.ts +269 -0
  17. package/src/daemon-token-owner-http.test.ts +3 -1
  18. package/src/db.ts +45 -0
  19. package/src/mcp-context-cost-http.test.ts +3 -1
  20. package/src/member-presence-http.test.ts +8 -4
  21. package/src/node-lifecycle-read-http.test.ts +13 -1
  22. package/src/node-lifecycle-read.ts +6 -0
  23. package/src/node-permissions.ts +3 -1
  24. package/src/report-status-host-schema.test.ts +24 -0
  25. package/src/runtime-readiness.ts +7 -0
  26. package/src/server.ts +40 -4
  27. package/src/shared/codex-inventory.ts +34 -0
  28. package/src/shared/daemon-provider-login.ts +31 -0
  29. package/src/shared/daemon-provider-snapshot.ts +52 -0
  30. package/src/shared/provider-application.ts +29 -0
  31. package/src/tool-audience-http.test.ts +15 -2
  32. package/src/tool-audience.ts +3 -1
  33. package/src/tools.ts +233 -19
  34. package/src/user-avatar-http.test.ts +74 -0
  35. 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.120",
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
+ });