@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
@@ -0,0 +1,289 @@
1
+ // Hub #938 — clone a daemon child into a new node on the SAME daemon.
2
+ //
3
+ // Contract (also what `clone_node` returns as `contract`):
4
+ //
5
+ // Identity new node_id (minted like create_node: `node_<request id>`),
6
+ // new alias, new ntok_. The source token is never copied and
7
+ // never placed in the env blob.
8
+ // Copied runtime, model / provider ref (the model string, including
9
+ // `provider/model`), flags that pass the create_node flag
10
+ // checks, and commhub channel specs (string entries that are
11
+ // the network itself). Secret-looking flag values are dropped.
12
+ // Not copied token, secrets.env, inline secret env values (key names may
13
+ // remain as env refs with no value), non-commhub channel
14
+ // specs (those carry per-node bot credentials).
15
+ // Session `copy_session` defaults to true. What that can actually do
16
+ // depends on the runtime — see `sessionContractForRuntime`.
17
+ // The daemon performs the copy; this module only states the
18
+ // capability so the caller is not told a deep copy happened
19
+ // when the vendor store cannot be forked.
20
+ // Workdir `new_empty` (default) or `share_source`. The daemon chooses
21
+ // the path; this module only records the policy.
22
+ //
23
+ // Workdir policy, decided on the daemon because only it knows the source
24
+ // directory:
25
+ //
26
+ // new_empty (default)
27
+ // A new empty directory under the same parent as the source workdir:
28
+ // `dirname(sourceWorkdir)/<ascii folder>`. The folder name is the new
29
+ // alias when that alias is already an ASCII directory name, otherwise
30
+ // the usual `node-<hash>` slug. Nothing inside the source workdir is
31
+ // copied (no rules file, no checkout).
32
+ // When the source has no project workdir of its own — it was created
33
+ // in the daemon cwd — there is no sibling project to sit beside. The
34
+ // clone is then placed at `<daemon cwd>/clone-work/<ascii folder>`,
35
+ // still a new empty directory under the daemon root. That is the only
36
+ // case that does not use the source's parent.
37
+ //
38
+ // share_source
39
+ // Explicit opt-in. The clone's cwd is the source workdir. Two nodes
40
+ // share that checkout. The token is still new. Session files that are
41
+ // keyed by cwd do not need to be relocated.
42
+ //
43
+ // Cross-daemon: the clone is dispatched only to the daemon that created
44
+ // the source (`node_create_requests.daemon_node_id` of the succeeded row).
45
+ // Passing a different `daemon_node_id` is `cross_daemon_refused`. A node
46
+ // with no succeeded create row on a daemon is `source_not_daemon_child`.
47
+
48
+ import {
49
+ FLAG_KEYS,
50
+ validateFlagValue,
51
+ validateFlagsForRuntime,
52
+ validateModel,
53
+ validateName,
54
+ validateRuntime,
55
+ ValidationError,
56
+ } from "./create-node-validate.js";
57
+
58
+ export const CLONE_WORKDIR_POLICIES = ["new_empty", "share_source"] as const;
59
+ export type CloneWorkdirPolicy = typeof CLONE_WORKDIR_POLICIES[number];
60
+
61
+ const SECRET_VALUE_RX = /^(sk-|utok_|ntok_|atok_|ak-|gsk_|key-|Bearer\s)/i;
62
+
63
+ export interface CloneSourceFacts {
64
+ nodeId: string;
65
+ alias: string;
66
+ role: string | null;
67
+ runtime: string | null;
68
+ model: string | null;
69
+ flags: Record<string, unknown>;
70
+ channels: unknown;
71
+ /** Daemon that owns the succeeded create row, or null if this node is not a daemon child. */
72
+ owningDaemonId: string | null;
73
+ /** Aliases / node names already present in the network. */
74
+ existingNames: readonly string[];
75
+ /** child_name values with a pending/delivered create on the owning daemon. */
76
+ inflightNames: readonly string[];
77
+ }
78
+
79
+ export interface CloneAsk {
80
+ newName: string;
81
+ copySession: boolean;
82
+ workdirPolicy: CloneWorkdirPolicy;
83
+ /** When set, must equal the owning daemon. */
84
+ requestedDaemonId: string | null;
85
+ }
86
+
87
+ export interface SessionContract {
88
+ /** Stable code. Not a claim that a private transcript fork already exists. */
89
+ mode: "transcript_when_present" | "id_aliased" | "unsupported";
90
+ runtime: string;
91
+ /** What resume/continue will do, in one sentence the caller can show. */
92
+ detail: string;
93
+ }
94
+
95
+ export type CloneDecision =
96
+ | { ok: false; error: string; detail?: Record<string, unknown> }
97
+ | {
98
+ ok: true;
99
+ daemonNodeId: string;
100
+ name: string;
101
+ runtime: string;
102
+ model: string | null;
103
+ flags: Record<string, unknown>;
104
+ channels: string[];
105
+ droppedChannels: string[];
106
+ copySession: boolean;
107
+ workdirPolicy: CloneWorkdirPolicy;
108
+ sessionContract: SessionContract;
109
+ };
110
+
111
+ export function sessionContractForRuntime(runtime: string): SessionContract {
112
+ if (runtime === "claude-agent-sdk" || runtime === "claude-code-cli") {
113
+ return {
114
+ mode: "transcript_when_present",
115
+ runtime,
116
+ detail: "Claude resume is the session id plus ~/.claude/projects/<cwd>/<id>.jsonl. The daemon copies that file into the clone's cwd key and writes the same session id. If the file is not on disk, the id is not copied: a bare id would bind an empty session, not continue the old one.",
117
+ };
118
+ }
119
+ if (runtime === "codex-sdk" || runtime === "codex-app-server") {
120
+ return {
121
+ mode: "transcript_when_present",
122
+ runtime,
123
+ detail: "Codex resume needs the rollout file under the new node's CODEX_HOME. The daemon copies that rollout and keeps the thread id when the file is present. auth.json is not copied. If there is no rollout, the thread id is not copied. This is not `anet node codex fork` (that rewrites a new thread id).",
124
+ };
125
+ }
126
+ if (runtime === "grok-build-acp" || runtime === "grok-build-cli") {
127
+ return {
128
+ mode: "transcript_when_present",
129
+ runtime,
130
+ detail: "Grok resume needs the session directory under the grok home, keyed by cwd. The daemon copies that directory and keeps the session id when it exists. If it does not, the id is not copied: grok refuses to resume a missing directory.",
131
+ };
132
+ }
133
+ if (runtime === "opencode-cli") {
134
+ return {
135
+ mode: "id_aliased",
136
+ runtime,
137
+ detail: "OpenCode sessions live in the OpenCode server, not in the node directory. The daemon copies the session id so session/load addresses that same server session. That is not a private deep copy: both nodes share one server-side transcript. There is no local transcript to fork.",
138
+ };
139
+ }
140
+ return {
141
+ mode: "unsupported",
142
+ runtime,
143
+ detail: "This runtime has no clone session copy. The new node starts without a session id.",
144
+ };
145
+ }
146
+
147
+ function safeFlags(runtime: string, raw: Record<string, unknown>): Record<string, unknown> {
148
+ const out: Record<string, unknown> = {};
149
+ for (const [k, v] of Object.entries(raw)) {
150
+ if (!(FLAG_KEYS as readonly string[]).includes(k)) continue;
151
+ if (typeof v === "string" && SECRET_VALUE_RX.test(v)) continue;
152
+ try {
153
+ validateFlagValue(k, v);
154
+ } catch {
155
+ continue;
156
+ }
157
+ out[k] = v;
158
+ }
159
+ validateFlagsForRuntime(runtime, out);
160
+ return out;
161
+ }
162
+
163
+ /** Commhub channel specs only. Anything else can carry a bot credential. */
164
+ export function safeChannels(raw: unknown): { kept: string[]; dropped: string[] } {
165
+ if (!Array.isArray(raw)) return { kept: [], dropped: [] };
166
+ const kept: string[] = [];
167
+ const dropped: string[] = [];
168
+ for (const ch of raw) {
169
+ if (typeof ch !== "string") {
170
+ dropped.push(typeof ch);
171
+ continue;
172
+ }
173
+ if (ch.includes("commhub") || ch.startsWith("server:")) kept.push(ch);
174
+ else dropped.push(ch.slice(0, 80));
175
+ }
176
+ return { kept, dropped };
177
+ }
178
+
179
+ function readRole(snapshot: unknown): string | null {
180
+ if (!snapshot || typeof snapshot !== "object") return null;
181
+ const role = (snapshot as { role?: unknown }).role;
182
+ return typeof role === "string" ? role : null;
183
+ }
184
+
185
+ export function factsFromRows(input: {
186
+ node: { node_id: string; alias: string | null; node_name?: string | null; runtime?: string | null; model?: string | null; channels?: string | null; config_snapshot?: string | null };
187
+ createRow: { daemon_node_id: string; runtime: string; model: string | null; flags_json: string; child_name: string } | null;
188
+ existingNames: readonly string[];
189
+ inflightNames: readonly string[];
190
+ }): CloneSourceFacts {
191
+ let snap: Record<string, unknown> = {};
192
+ try {
193
+ const parsed = input.node.config_snapshot ? JSON.parse(input.node.config_snapshot) : null;
194
+ if (parsed && typeof parsed === "object") snap = parsed as Record<string, unknown>;
195
+ } catch { /* snapshot is display state; the create row still stands */ }
196
+ let createFlags: Record<string, unknown> = {};
197
+ try {
198
+ const parsed = input.createRow ? JSON.parse(input.createRow.flags_json) : {};
199
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) createFlags = parsed as Record<string, unknown>;
200
+ } catch { /* ignore */ }
201
+ const snapFlags = snap.flags && typeof snap.flags === "object" && !Array.isArray(snap.flags)
202
+ ? snap.flags as Record<string, unknown>
203
+ : {};
204
+ const runtime = (typeof snap.runtime === "string" && snap.runtime) ? snap.runtime
205
+ : (input.node.runtime || input.createRow?.runtime || null);
206
+ const model = (typeof snap.model === "string" || snap.model === null) ? (snap.model as string | null)
207
+ : (input.node.model ?? input.createRow?.model ?? null);
208
+ const channels = Array.isArray(snap.channels) ? snap.channels
209
+ : (input.node.channels ? safeParseArray(input.node.channels) : []);
210
+ return {
211
+ nodeId: input.node.node_id,
212
+ alias: input.node.alias || input.node.node_name || input.createRow?.child_name || "",
213
+ role: readRole(snap),
214
+ runtime,
215
+ model,
216
+ flags: { ...createFlags, ...snapFlags },
217
+ channels,
218
+ owningDaemonId: input.createRow?.daemon_node_id ?? null,
219
+ existingNames: input.existingNames,
220
+ inflightNames: input.inflightNames,
221
+ };
222
+ }
223
+
224
+ function safeParseArray(raw: string): unknown[] {
225
+ try {
226
+ const v = JSON.parse(raw);
227
+ return Array.isArray(v) ? v : [];
228
+ } catch {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ /** Pure. Throws ValidationError for a bad new name, model, or runtime. */
234
+ export function decideClone(source: CloneSourceFacts, ask: CloneAsk): CloneDecision {
235
+ if (!source.alias) {
236
+ return { ok: false, error: "source_alias_missing", detail: { source_node_id: source.nodeId } };
237
+ }
238
+ if (source.role === "host_supervisor") {
239
+ return { ok: false, error: "cannot_clone_daemon", detail: { source_node_id: source.nodeId } };
240
+ }
241
+ if (!source.owningDaemonId) {
242
+ return { ok: false, error: "source_not_daemon_child", detail: { source_node_id: source.nodeId } };
243
+ }
244
+ if (ask.requestedDaemonId && ask.requestedDaemonId !== source.owningDaemonId) {
245
+ return {
246
+ ok: false,
247
+ error: "cross_daemon_refused",
248
+ detail: { source_daemon_node_id: source.owningDaemonId, requested_daemon_node_id: ask.requestedDaemonId },
249
+ };
250
+ }
251
+ const name = validateName(ask.newName);
252
+ if (name === source.alias) {
253
+ return { ok: false, error: "node_name_conflict", detail: { name, reason: "same_as_source" } };
254
+ }
255
+ if (source.existingNames.includes(name)) {
256
+ return { ok: false, error: "node_name_conflict", detail: { name, reason: "alias_taken" } };
257
+ }
258
+ if (source.inflightNames.includes(name)) {
259
+ return { ok: false, error: "node_name_conflict", detail: { name, reason: "inflight_create" } };
260
+ }
261
+ if (!source.runtime) {
262
+ return { ok: false, error: "source_runtime_unknown", detail: { source_node_id: source.nodeId } };
263
+ }
264
+ validateRuntime(source.runtime);
265
+ validateModel(source.model);
266
+ const flags = safeFlags(source.runtime, source.flags);
267
+ const channels = safeChannels(source.channels);
268
+ return {
269
+ ok: true,
270
+ daemonNodeId: source.owningDaemonId,
271
+ name,
272
+ runtime: source.runtime,
273
+ model: source.model ?? null,
274
+ flags,
275
+ channels: channels.kept,
276
+ droppedChannels: channels.dropped,
277
+ copySession: ask.copySession,
278
+ workdirPolicy: ask.workdirPolicy,
279
+ sessionContract: sessionContractForRuntime(source.runtime),
280
+ };
281
+ }
282
+
283
+ export function parseWorkdirPolicy(raw: unknown): CloneWorkdirPolicy {
284
+ if (raw === undefined || raw === null || raw === "") return "new_empty";
285
+ if (raw === "new_empty" || raw === "share_source") return raw;
286
+ throw new ValidationError("workdir_policy_invalid", { value: typeof raw === "string" ? raw.slice(0, 40) : typeof raw, allowed: [...CLONE_WORKDIR_POLICIES] });
287
+ }
288
+
289
+ export { ValidationError };
@@ -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",
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { isReservedEnvKey } from "./shared/reserved-env.js";
11
11
  import { checkNodeName } from "./shared/node-name.js";
12
+ import { CODEX_CONFIG_ONLY_FLAG_KEYS, validateCodexConfigFlag } from "./codex-config-flags.js";
12
13
 
13
14
  export class ValidationError extends Error {
14
15
  constructor(public code: string, public detail?: Record<string, unknown>) {
@@ -52,7 +53,11 @@ export type Runtime = typeof RUNTIMES[number];
52
53
  // 🔴 故意放在 flags 里而不是 node_spec 顶层:老 hub 的 zod 会静默丢弃未知顶层字段、老 daemon 也静默忽略
53
54
  // —— 那正是 #584 的形状(用户选了共存,拿到无头节点,没有任何报错)。flags 的未知键在老 hub(这份
54
55
  // FLAG_KEYS)和老 daemon(buildAnetArgsDaemon)都**拒绝**,新 app 撞到老组件得到的是一条报错。
55
- export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout", "copresence", "opencodeGeneration", "opencodeUnsafeTools"] as const;
56
+ export const FLAG_KEYS = [
57
+ "permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout",
58
+ "copresence", "opencodeGeneration", "opencodeUnsafeTools",
59
+ "approvalPolicy", "sandboxMode", "skipGitRepoCheck", "copresenceFullAccess", "modelReasoningEffort",
60
+ ] as const;
56
61
 
57
62
  /** #584 —— 只有这些 runtime 认 `flags.copresence`。目前只有 codex-app-server 的共存靠 config 字段
58
63
  * (`codexCopresence`)决定;grok-build-cli / opencode-cli 各有自己的字段,接进来之前一律拒,不静默吞。 */
@@ -170,6 +175,11 @@ export function validateFlagValue(k: string, v: unknown): void {
170
175
  if (typeof v !== "boolean") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be boolean" });
171
176
  return;
172
177
  default:
178
+ if (CODEX_CONFIG_ONLY_FLAG_KEYS.has(k)) {
179
+ const fail = validateCodexConfigFlag(k, v, null);
180
+ if (fail) throw new ValidationError("flag_value_invalid", { field: k, reason: fail.reason });
181
+ return;
182
+ }
173
183
  throw new ValidationError("flag_key_unknown", { field: k });
174
184
  }
175
185
  }
@@ -196,6 +206,38 @@ export function validateFlagsForRuntime(runtime: string, flags: Record<string, u
196
206
  field: "copresence", runtime, applicable: [...COPRESENCE_FLAG_RUNTIMES],
197
207
  });
198
208
  }
209
+ for (const [k, v] of Object.entries(flags)) {
210
+ if (!CODEX_CONFIG_ONLY_FLAG_KEYS.has(k)) continue;
211
+ const fail = validateCodexConfigFlag(k, v, runtime);
212
+ if (fail) throw new ValidationError("flag_value_invalid", { field: k, reason: fail.reason });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * V2 co-presence start requires OpenCode's own `provider/model`.
218
+ * Call only after the unsafe-opt-in check: a missing opt-in stays
219
+ * `opencode_v2_requires_unsafe_opt_in`. This API does not take an anet
220
+ * provider preset or a vault key (#2565 owns presets).
221
+ */
222
+ export function validateOpenCodeV2ProviderModel(
223
+ runtime: string,
224
+ model: unknown,
225
+ flags: Record<string, unknown> | undefined | null,
226
+ ): void {
227
+ if (runtime !== "opencode-cli" || !flags || typeof flags !== "object") return;
228
+ if (flags.opencodeGeneration !== "v2" || flags.opencodeUnsafeTools !== true) return;
229
+ const native = typeof model === "string"
230
+ && model.length > 0
231
+ && model.length <= 100
232
+ && MODEL_RE.test(model)
233
+ && !MODEL_DOT_ONLY_SEGMENT.test(model)
234
+ && model.includes("/")
235
+ && model.indexOf("/") === model.lastIndexOf("/");
236
+ if (!native) {
237
+ throw new ValidationError("opencode_v2_requires_provider_model", {
238
+ reason: "OpenCode V2 uses the installed OpenCode provider/model (exactly one slash, for example provider/model). This API does not take an anet provider preset.",
239
+ });
240
+ }
199
241
  }
200
242
 
201
243
  // §4.4.7 — env_refs 7-step gate. networkSecretsGet is injected so
@@ -356,9 +398,11 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
356
398
  // `anet node create --copresence` 是布尔开关,不吃值。
357
399
  if (k === "copresence") { if (v === true) args.push("--copresence"); continue; }
358
400
  if (k === "opencodeUnsafeTools") { if (v === true) args.push("--opencode-unsafe-tools"); continue; }
401
+ if (CODEX_CONFIG_ONLY_FLAG_KEYS.has(k)) continue;
359
402
  args.push(`--${kebab(k)}`, String(v));
360
403
  }
361
404
  validateFlagsForRuntime(spec.runtime, spec.flags);
362
405
  }
406
+ validateOpenCodeV2ProviderModel(spec.runtime, spec.model, spec.flags);
363
407
  return args;
364
408
  }
@@ -119,6 +119,19 @@ describe("create_node + workdir", () => {
119
119
  expect(pulled.node_spec.flags).toEqual(flags);
120
120
  });
121
121
 
122
+ test("#829 V2 without native provider/model rejected before request or child token mint", async () => {
123
+ seed({ runtimes_supported: ["opencode-cli"] });
124
+ const tokenCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM api_tokens WHERE network_id = ?1", NET)!.n;
125
+ const before = tokenCount();
126
+ const flags = { opencodeGeneration: "v2", opencodeUnsafeTools: true };
127
+ const missing = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { name: "v2-child", runtime: "opencode-cli", flags } });
128
+ expect(missing.error).toBe("opencode_v2_requires_provider_model");
129
+ const bare = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: { name: "v2-child", runtime: "opencode-cli", model: "opencode", flags } });
130
+ expect(bare.error).toBe("opencode_v2_requires_provider_model");
131
+ expect(rowCount()).toBe(0);
132
+ expect(tokenCount()).toBe(before);
133
+ });
134
+
122
135
  test("#829 missing opt-in rejected before request or child token mint", async () => {
123
136
  seed({ runtimes_supported: ["opencode-cli"] });
124
137
  const tokenCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM api_tokens WHERE network_id = ?1", NET)!.n;