@sleep2agi/commhub-server 0.9.0-preview.120 → 0.9.0-preview.122
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-acl-http.test.ts +2 -1
- package/src/api-host-supervisors-runtime-readiness.test.ts +13 -0
- package/src/auth.ts +8 -7
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/config-apply-validate.test.ts +33 -1
- package/src/config-apply-validate.ts +24 -0
- package/src/create-node-validate.test.ts +26 -1
- package/src/create-node-validate.ts +45 -1
- package/src/create-node-workdir.test.ts +13 -0
- package/src/daemon-provider-snapshots.test.ts +359 -0
- package/src/daemon-provider-snapshots.ts +269 -0
- package/src/daemon-token-owner-http.test.ts +3 -1
- package/src/db.ts +45 -0
- package/src/mcp-context-cost-http.test.ts +3 -1
- package/src/member-presence-http.test.ts +8 -4
- package/src/node-lifecycle-read-http.test.ts +13 -1
- package/src/node-lifecycle-read.ts +6 -0
- package/src/node-permissions.ts +3 -1
- package/src/report-status-host-schema.test.ts +24 -0
- package/src/runtime-readiness.ts +7 -0
- package/src/server.ts +40 -4
- package/src/shared/codex-inventory.ts +34 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/tool-audience-http.test.ts +15 -2
- package/src/tool-audience.ts +3 -1
- package/src/tools.ts +233 -19
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
|
@@ -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
|
|
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 = [
|
|
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;
|