@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 +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/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 +256 -0
- package/src/daemon-provider-snapshots.ts +226 -0
- package/src/db.ts +31 -0
- 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 +2 -0
- 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-snapshot.ts +49 -0
- package/src/shared/provider-application.ts +24 -0
- package/src/tool-audience-http.test.ts +13 -1
- package/src/tool-audience.ts +2 -0
- package/src/tools.ts +47 -14
- 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.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
|
|
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",
|