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

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.120",
3
+ "version": "0.9.0-preview.121",
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,77 @@
1
+ /** Shared Codex node `flags.*` contract (create_node + update_node_config). */
2
+
3
+ export const REASONING_EFFORT_VALUES = [
4
+ "none",
5
+ "minimal",
6
+ "low",
7
+ "medium",
8
+ "high",
9
+ "xhigh",
10
+ ] as const;
11
+
12
+ export type ModelReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
13
+
14
+ export const CODEX_APPROVAL_POLICIES = [
15
+ "untrusted",
16
+ "on-failure",
17
+ "on-request",
18
+ "never",
19
+ ] as const;
20
+
21
+ export const CODEX_SANDBOX_MODES = [
22
+ "read-only",
23
+ "workspace-write",
24
+ "danger-full-access",
25
+ ] as const;
26
+
27
+ /** Runtimes that may carry Codex posture / reasoning flags. */
28
+ export const CODEX_CONFIG_FLAG_RUNTIMES = new Set<string>(["codex-sdk", "codex-app-server"]);
29
+
30
+ export const CODEX_CONFIG_ONLY_FLAG_KEYS = new Set<string>([
31
+ "approvalPolicy",
32
+ "sandboxMode",
33
+ "skipGitRepoCheck",
34
+ "copresenceFullAccess",
35
+ "modelReasoningEffort",
36
+ ]);
37
+
38
+ export function isModelReasoningEffort(v: unknown): v is ModelReasoningEffort {
39
+ return typeof v === "string" && (REASONING_EFFORT_VALUES as readonly string[]).includes(v);
40
+ }
41
+
42
+ export function validateCodexConfigFlag(
43
+ key: string,
44
+ val: unknown,
45
+ runtime: string | null | undefined,
46
+ ): { field: string; reason: string } | null {
47
+ if (!CODEX_CONFIG_ONLY_FLAG_KEYS.has(key)) return null;
48
+ if (runtime && !CODEX_CONFIG_FLAG_RUNTIMES.has(runtime)) {
49
+ return { field: `flags.${key}`, reason: `only supported for codex-sdk / codex-app-server runtimes` };
50
+ }
51
+ if (key === "copresenceFullAccess" && runtime && runtime !== "codex-app-server") {
52
+ return { field: "flags.copresenceFullAccess", reason: "only supported when node runtime is codex-app-server" };
53
+ }
54
+ switch (key) {
55
+ case "approvalPolicy":
56
+ if (typeof val !== "string" || !(CODEX_APPROVAL_POLICIES as readonly string[]).includes(val)) {
57
+ return { field: "flags.approvalPolicy", reason: `must be one of ${CODEX_APPROVAL_POLICIES.join("/")}` };
58
+ }
59
+ return null;
60
+ case "sandboxMode":
61
+ if (typeof val !== "string" || !(CODEX_SANDBOX_MODES as readonly string[]).includes(val)) {
62
+ return { field: "flags.sandboxMode", reason: `must be one of ${CODEX_SANDBOX_MODES.join("/")}` };
63
+ }
64
+ return null;
65
+ case "skipGitRepoCheck":
66
+ case "copresenceFullAccess":
67
+ if (typeof val !== "boolean") return { field: `flags.${key}`, reason: "must be boolean" };
68
+ return null;
69
+ case "modelReasoningEffort":
70
+ if (!isModelReasoningEffort(val)) {
71
+ return { field: "flags.modelReasoningEffort", reason: `must be one of ${REASONING_EFFORT_VALUES.join("/")}` };
72
+ }
73
+ return null;
74
+ default:
75
+ return null;
76
+ }
77
+ }
@@ -26,20 +26,28 @@ describe("ALLOWED_FLAGS — exact contract (no silent extension)", () => {
26
26
  // the BLOCKER 2 schema-mismatch issue. P2 to add a claude-code-cli
27
27
  // config-apply path.
28
28
  expect([...ALLOWED_FLAGS].sort()).toEqual([
29
+ "approvalPolicy",
29
30
  "budget",
31
+ "copresenceFullAccess",
30
32
  "dangerouslySkipPermissions",
31
33
  "maxTurns",
34
+ "modelReasoningEffort",
32
35
  "permissionMode",
36
+ "sandboxMode",
37
+ "skipGitRepoCheck",
33
38
  "timeout",
34
39
  ]);
35
40
  });
36
41
  });
37
42
 
38
43
  describe("SECURITY_SENSITIVE_FLAGS — privilege-elevation surface", () => {
39
- test("contains the 2 privilege-elevation flags (teammateMode dropped — see ALLOWED_FLAGS comment)", () => {
44
+ test("contains privilege-elevation flags (teammateMode dropped — see ALLOWED_FLAGS comment)", () => {
40
45
  expect([...SECURITY_SENSITIVE_FLAGS].sort()).toEqual([
46
+ "approvalPolicy",
47
+ "copresenceFullAccess",
41
48
  "dangerouslySkipPermissions",
42
49
  "permissionMode",
50
+ "sandboxMode",
43
51
  ]);
44
52
  });
45
53
 
@@ -161,6 +169,10 @@ describe("computeApplyMode — tier classifier (RFC-024 §4)", () => {
161
169
  expect(computeApplyMode("gpt-5", { maxTurns: 50 })).toBe("restart");
162
170
  });
163
171
 
172
+ test("modelReasoningEffort flag alone → restart tier", () => {
173
+ expect(computeApplyMode(undefined, { modelReasoningEffort: "medium" })).toBe("restart");
174
+ });
175
+
164
176
  test("mixed (only hot flags) → hot", () => {
165
177
  expect(computeApplyMode(undefined, { maxTurns: 50, budget: 100 })).toBe("hot");
166
178
  });
@@ -359,3 +371,23 @@ describe("validatePatch — channels defensive gate", () => {
359
371
  expect(r?.field).toBe("channels");
360
372
  });
361
373
  });
374
+
375
+ describe("Codex config flags (#815)", () => {
376
+ test("yolo bundle on codex-app-server passes", () => {
377
+ expect(validatePatch(undefined, {
378
+ approvalPolicy: "never",
379
+ sandboxMode: "danger-full-access",
380
+ skipGitRepoCheck: true,
381
+ copresenceFullAccess: true,
382
+ modelReasoningEffort: "low",
383
+ }, undefined, "codex-app-server")).toBeNull();
384
+ });
385
+ test("modelReasoningEffort invalid enum rejected", () => {
386
+ expect(validatePatch(undefined, { modelReasoningEffort: "max" }, undefined, "codex-app-server")?.field)
387
+ .toBe("flags.modelReasoningEffort");
388
+ });
389
+ test("codex flags on claude runtime rejected", () => {
390
+ expect(validatePatch(undefined, { modelReasoningEffort: "low" }, undefined, "claude-agent-sdk")?.field)
391
+ .toBe("flags.modelReasoningEffort");
392
+ });
393
+ });
@@ -6,6 +6,13 @@
6
6
  // caller identity, role gates).
7
7
 
8
8
  import { isValidTimeoutMs, TIMEOUT_MS_REASON } from "./create-node-validate.js";
9
+ import {
10
+ REASONING_EFFORT_VALUES,
11
+ validateCodexConfigFlag,
12
+ type ModelReasoningEffort,
13
+ } from "./codex-config-flags.js";
14
+
15
+ export { REASONING_EFFORT_VALUES, type ModelReasoningEffort } from "./codex-config-flags.js";
9
16
 
10
17
  /**
11
18
  * Fields the dashboard may change. Anything not in this list is rejected
@@ -18,6 +25,12 @@ export const ALLOWED_FLAGS = new Set<string>([
18
25
  "maxTurns",
19
26
  "budget",
20
27
  "timeout",
28
+ // Codex (#815 / client): posture + co-presence grant + thinking level
29
+ "approvalPolicy",
30
+ "sandboxMode",
31
+ "skipGitRepoCheck",
32
+ "copresenceFullAccess",
33
+ "modelReasoningEffort",
21
34
  ]);
22
35
  // NB on teammateMode (dropped from P1 scope per #290 cross-agent
23
36
  // review): teammateMode is consumed ONLY by the claude-code-cli
@@ -45,6 +58,9 @@ export const ALLOWED_FLAGS = new Set<string>([
45
58
  export const SECURITY_SENSITIVE_FLAGS = new Set<string>([
46
59
  "permissionMode",
47
60
  "dangerouslySkipPermissions",
61
+ "approvalPolicy",
62
+ "sandboxMode",
63
+ "copresenceFullAccess",
48
64
  ]);
49
65
 
50
66
  /**
@@ -57,6 +73,11 @@ export const RESTART_REQUIRED_FLAGS = new Set<string>([
57
73
  "permissionMode",
58
74
  "dangerouslySkipPermissions",
59
75
  "timeout",
76
+ "approvalPolicy",
77
+ "sandboxMode",
78
+ "skipGitRepoCheck",
79
+ "copresenceFullAccess",
80
+ "modelReasoningEffort",
60
81
  ]);
61
82
 
62
83
  /**
@@ -186,6 +207,7 @@ export function validatePatch(
186
207
  model: string | undefined,
187
208
  flags: Record<string, unknown>,
188
209
  channels?: string[] | undefined,
210
+ nodeRuntime?: string | null,
189
211
  ): { field: string; reason: string } | null {
190
212
  if (model !== undefined) {
191
213
  if (typeof model !== "string" || model.length === 0 || model.length > 200) {
@@ -213,6 +235,8 @@ export function validatePatch(
213
235
  if (!ALLOWED_FLAGS.has(key)) {
214
236
  return { field: `flags.${key}`, reason: "not in allowlist" };
215
237
  }
238
+ const codexFail = validateCodexConfigFlag(key, val, nodeRuntime ?? null);
239
+ if (codexFail) return codexFail;
216
240
  switch (key) {
217
241
  case "permissionMode":
218
242
  if (
@@ -3,7 +3,7 @@ import {
3
3
  ValidationError,
4
4
  validateName, validateRuntime, validateModel, validateFlagValue, RUNTIMES,
5
5
  validateEnvRefs, validateChannelsP1, serializeEnvLocal, buildAnetArgs,
6
- MAX_ENV_KEYS_PER_NODE, validateFlagsForRuntime, COPRESENCE_FLAG_RUNTIMES,
6
+ MAX_ENV_KEYS_PER_NODE, validateFlagsForRuntime, validateOpenCodeV2ProviderModel, COPRESENCE_FLAG_RUNTIMES,
7
7
  } from "./create-node-validate.js";
8
8
 
9
9
  const okSecret = (k: string, _net: string, key: string) => k === key ? `value-of-${k}` : undefined;
@@ -216,6 +216,31 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
216
216
  expect(args).toContain("--permission-mode");
217
217
  expect(args[args.indexOf("--permission-mode") + 1]).toBe("plan");
218
218
  });
219
+ test("OpenCode V2 requires provider/model only after unsafe opt-in", () => {
220
+ let unsafe = "";
221
+ try {
222
+ buildAnetArgs({ name: "v2", runtime: "opencode-cli", flags: { opencodeGeneration: "v2" } });
223
+ } catch (e) { unsafe = (e as ValidationError).code; }
224
+ expect(unsafe).toBe("opencode_v2_requires_unsafe_opt_in");
225
+ let missing = "";
226
+ try {
227
+ buildAnetArgs({ name: "v2", runtime: "opencode-cli", flags: { opencodeGeneration: "v2", opencodeUnsafeTools: true } });
228
+ } catch (e) { missing = (e as ValidationError).code; }
229
+ expect(missing).toBe("opencode_v2_requires_provider_model");
230
+ let bare = "";
231
+ try {
232
+ buildAnetArgs({ name: "v2", runtime: "opencode-cli", model: "opencode", flags: { opencodeGeneration: "v2", opencodeUnsafeTools: true } });
233
+ } catch (e) { bare = (e as ValidationError).code; }
234
+ expect(bare).toBe("opencode_v2_requires_provider_model");
235
+ const args = buildAnetArgs({
236
+ name: "v2", runtime: "opencode-cli", model: "stub/model",
237
+ flags: { opencodeGeneration: "v2", opencodeUnsafeTools: true },
238
+ });
239
+ expect(args).toContain("stub/model");
240
+ expect(() => validateOpenCodeV2ProviderModel("opencode-cli", undefined, { opencodeGeneration: "v2" })).not.toThrow();
241
+ expect(() => validateOpenCodeV2ProviderModel("claude-agent-sdk", undefined, { opencodeGeneration: "v2", opencodeUnsafeTools: true })).not.toThrow();
242
+ expect(buildAnetArgs({ name: "x", runtime: "opencode-cli" })).toEqual(["node", "create", "x", "--runtime", "opencode-cli"]);
243
+ });
219
244
  test("omitted model is allowed and does not emit --model", () => {
220
245
  const args = buildAnetArgs({
221
246
  name: "x", runtime: "codex-sdk",