hilos-agent 0.9.0 → 0.9.2

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.
@@ -0,0 +1,619 @@
1
+ // Codex runtime-permission adapter (0777).
2
+ //
3
+ // WHAT THE LIVE INVESTIGATION FOUND (codex-cli 0.144.1, never docs-trusted):
4
+ //
5
+ // • `codex exec` has NO approval channel. `-a/--ask-for-approval` exists on
6
+ // the INTERACTIVE `codex` only — `codex exec --help` does not list it — and
7
+ // forcing the policy through config (`codex exec -c approval_policy=
8
+ // "untrusted" --json`) changes nothing: a blocked command emits no approval
9
+ // event and no prompt, it just fails inside the sandbox ("Operation not
10
+ // permitted") and the model is told to try something else. There is no
11
+ // stdin answer channel and no approval frame in the `--json` stream. So the
12
+ // exec transport CANNOT be gated, at any flag combination, on this version.
13
+ //
14
+ // • `codex mcp-server` CAN. Codex-as-an-MCP-server takes `approval-policy` as
15
+ // a tool argument and asks its CLIENT for the decision as a standard MCP
16
+ // `elicitation/create` REQUEST, carrying `codex_elicitation:"exec-approval"`,
17
+ // the `codex_command`, its cwd, and a `codex_call_id`. The client answers
18
+ // `{"decision":"approved"|"denied"|…}`. Proven end to end both ways: a
19
+ // denied ask left the file uncreated and the model was told "rejected by
20
+ // user"; an approved ask — deliberately answered 20 SECONDS late — really
21
+ // ran, so the call blocks on the human exactly like the other vendors.
22
+ // (A malformed answer fails closed: codex logs a deserialize error and
23
+ // treats it as a rejection.)
24
+ //
25
+ // So codex's gate is a transport swap, not a flag. This module is the sibling
26
+ // of acp-session.mjs: same `{status, stdout, stderr, error?, sessionId?,
27
+ // aborted?}` return shape so handler.mjs keeps one seam, and the SAME shared
28
+ // permission gate, so codex cannot become the lenient vendor.
29
+ //
30
+ // `codex-reply` ({threadId, prompt}) is this transport's resume, which is why
31
+ // gated codex runs keep session continuity (0778) instead of trading it away.
32
+ //
33
+ // WHAT THE WIRE CARRIES (0785, probed live against the same 0.144.1 binary):
34
+ // `tools/list` says the `codex` tool takes exactly `prompt`, `model`, `cwd`,
35
+ // `approval-policy`, `sandbox`, `config`, `base-instructions`,
36
+ // `developer-instructions`, `compact-prompt` — `additionalProperties:false`,
37
+ // and the server enforces it. Sending an `images` argument came back
38
+ // `isError:true` with the CLI listing those nine fields as the whole vocabulary,
39
+ // so there is NO image lane on this transport under any spelling.
40
+ //
41
+ // • MODEL: real. A call with `model:"gpt-5.4-mini"` answered
42
+ // `session_configured … "model":"gpt-5.4-mini"`, so the run's resolved tier
43
+ // (0783) reaches a gated run exactly like `--model` reaches an exec one.
44
+ // `codex-reply` takes ONLY {threadId, prompt} — a resumed thread keeps the
45
+ // model it was created with, which is why the model rides the first call.
46
+ // • IMAGES: impossible on the wire, so a gated run degrades HONESTLY — the
47
+ // files are already on disk (0779) and the prompt names them. Proven live:
48
+ // given a prompt naming a local png, codex called its own `view_image` tool
49
+ // on that path and described the picture. The one thing that must NOT
50
+ // happen is the exec transport's prompt line ("they are attached to this
51
+ // prompt") on a run where nothing was attached — see `imagesNeedReading`.
52
+
53
+ import { spawn } from "node:child_process";
54
+ import { createNdjsonParser } from "./acp-session.mjs";
55
+ import { codexUsageEvent } from "./agent-events.mjs";
56
+ import {
57
+ DEFAULT_PERMISSION_POLL_MS,
58
+ DEFAULT_PERMISSION_TIMEOUT_MS,
59
+ resolveHilosPermissionReply,
60
+ } from "./permission-gate.mjs";
61
+
62
+ const DEFAULT_TIMEOUT_MS = 30 * 60_000;
63
+ const SHUTDOWN_GRACE_MS = 750;
64
+
65
+ function isObject(value) {
66
+ return typeof value === "object" && value !== null && !Array.isArray(value);
67
+ }
68
+
69
+ function abortError(reason = "cancelled") {
70
+ const error = new Error(String(reason || "cancelled"));
71
+ error.name = "AbortError";
72
+ return error;
73
+ }
74
+
75
+ /**
76
+ * Render codex's argv-array command as the one line a human decides about.
77
+ * Codex wraps shell work as ["/bin/zsh","-lc","<the real command>"]; showing
78
+ * the wrapper would bury the thing being approved.
79
+ */
80
+ export function codexCommandText(command) {
81
+ if (typeof command === "string") return command;
82
+ if (!Array.isArray(command) || command.length === 0) return "";
83
+ const parts = command.map((c) => String(c ?? ""));
84
+ const flagIdx = parts.findIndex((p) => p === "-lc" || p === "-c");
85
+ if (flagIdx >= 0 && parts[flagIdx + 1]) return parts[flagIdx + 1];
86
+ return parts.join(" ");
87
+ }
88
+
89
+ /**
90
+ * Shape a codex elicitation into the SAME vendor-neutral request the other
91
+ * adapters produce, so one card renders every vendor's ask.
92
+ */
93
+ export function normalizeCodexPermissionRequest(params, fallbackId = "0") {
94
+ const kind = typeof params?.codex_elicitation === "string" ? params.codex_elicitation : "";
95
+ const command = codexCommandText(params?.codex_command);
96
+ const cwd = typeof params?.codex_cwd === "string" ? params.codex_cwd : null;
97
+ const vendorRequestId =
98
+ typeof params?.codex_call_id === "string" && params.codex_call_id
99
+ ? params.codex_call_id
100
+ : `codex_${fallbackId}`;
101
+ const message = typeof params?.message === "string" ? params.message : "";
102
+ const title = command || message || kind || "permission";
103
+ const isPatch = kind === "patch-approval" || kind === "apply-patch-approval";
104
+ return {
105
+ vendor: "codex",
106
+ vendorRequestId,
107
+ sessionId: typeof params?.threadId === "string" ? params.threadId : "",
108
+ action: isPatch ? "edit" : "execute",
109
+ resources: command ? [command] : [],
110
+ suggestedSave: undefined,
111
+ metadata: {
112
+ title,
113
+ ...(command ? { command } : {}),
114
+ ...(cwd ? { cwd } : {}),
115
+ ...(kind ? { kind } : {}),
116
+ transport: "codex-mcp",
117
+ },
118
+ source: { type: "tool", name: isPatch ? "apply_patch" : "exec" },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Map a hilos reply onto codex's ReviewDecision vocabulary.
124
+ *
125
+ * A single-use approval is NEVER widened into a session-wide one: "once" must
126
+ * answer plain `approved` even though `approved_for_session` is on offer. The
127
+ * reverse direction is safe — an `always` with no session option narrows to a
128
+ * one-shot approval rather than persisting something nobody agreed to.
129
+ * A rejection is `denied` (the agent continues and tries something else), never
130
+ * `abort`, which would kill work a human only meant to redirect.
131
+ */
132
+ export function mapCodexDecision(reply, availableDecisions) {
133
+ const available = Array.isArray(availableDecisions)
134
+ ? availableDecisions.filter((d) => typeof d === "string")
135
+ : [];
136
+ const offers = (value) => available.length === 0 || available.includes(value);
137
+ if (reply === "once") return "approved";
138
+ if (reply === "always") return offers("approved_for_session") ? "approved_for_session" : "approved";
139
+ return "denied";
140
+ }
141
+
142
+ /**
143
+ * Run one prompt against `codex mcp-server`, gated by hilos permission cards.
144
+ *
145
+ * @param {{
146
+ * cmd?: string,
147
+ * serverArgs?: string[],
148
+ * cwd?: string,
149
+ * prompt?: string,
150
+ * env?: Record<string, string | undefined>,
151
+ * model?: string | null,
152
+ * sandbox?: string,
153
+ * approvalPolicy?: string,
154
+ * resumeThreadId?: string | null,
155
+ * timeoutMs?: number,
156
+ * pollIntervalMs?: number,
157
+ * signal?: AbortSignal,
158
+ * onData?: (chunk: string) => void,
159
+ * onEvent?: (event: object) => void,
160
+ * requestPermission?: (request: object, context: object) => Promise<unknown>,
161
+ * getPermissionDecision?: (handle: unknown, context: object) => Promise<unknown>,
162
+ * spawnImpl?: typeof spawn,
163
+ * sleep?: (ms: number) => Promise<void>,
164
+ * now?: () => number,
165
+ * log?: { error?: (message: string) => void },
166
+ * }} [options]
167
+ * @returns {Promise<{ status: number|null, stdout: string, stderr: string, error?: Error|null, sessionId?: string|null, initialized: boolean, sawFrame: boolean, exitCode: number|null, timedOut?: boolean, aborted?: boolean }>}
168
+ */
169
+ export async function runCodexMcpSession({
170
+ cmd = "codex",
171
+ serverArgs = ["mcp-server"],
172
+ cwd = process.cwd(),
173
+ prompt = "",
174
+ env,
175
+ model = null,
176
+ sandbox = "workspace-write",
177
+ approvalPolicy = "untrusted",
178
+ resumeThreadId = null,
179
+ timeoutMs = DEFAULT_TIMEOUT_MS,
180
+ pollIntervalMs = DEFAULT_PERMISSION_POLL_MS,
181
+ signal,
182
+ onData,
183
+ onEvent,
184
+ requestPermission,
185
+ getPermissionDecision,
186
+ spawnImpl = spawn,
187
+ sleep,
188
+ now = () => Date.now(),
189
+ log,
190
+ } = {}) {
191
+ if (typeof requestPermission !== "function" || typeof getPermissionDecision !== "function") {
192
+ throw new Error("codex mcp session requires hilos permission callbacks");
193
+ }
194
+
195
+ const controller = new AbortController();
196
+ const onOuterAbort = () => controller.abort(signal?.reason ?? "cancelled");
197
+ if (signal?.aborted) onOuterAbort();
198
+ else signal?.addEventListener("abort", onOuterAbort, { once: true });
199
+
200
+ const child = spawnImpl(cmd, serverArgs, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
201
+ const parser = createNdjsonParser();
202
+ const pending = new Map();
203
+ const approvals = new Map(); // codex call_id → its available decisions
204
+ const messages = [];
205
+ let stderr = "";
206
+ let sessionId = null;
207
+ let spawnError = null;
208
+ let nextId = 1;
209
+ let aborted = false;
210
+ let timedOut = false;
211
+ // The three facts a caller needs to tell "this codex has no mcp-server" from
212
+ // "this codex's mcp-server failed" (0785): did it complete the handshake, did
213
+ // it speak MCP AT ALL, and how did it exit. All three are necessary — see
214
+ // codexMcpTransportUnavailable.
215
+ let initialized = false;
216
+ let sawFrame = false;
217
+ let exitCode = null;
218
+
219
+ const write = (frame) => {
220
+ if (child.stdin.destroyed) return;
221
+ try {
222
+ child.stdin.write(JSON.stringify(frame) + "\n");
223
+ } catch {
224
+ /* a dead pipe surfaces through the exit path */
225
+ }
226
+ };
227
+ const respond = (id, result) => write({ jsonrpc: "2.0", id, result });
228
+ /**
229
+ * Reject everything still waiting on an answer.
230
+ *
231
+ * A child that exits normally, or is killed by a signal, emits `close` and
232
+ * NOT `error` — so without this, an in-flight `initialize` or `tools/call`
233
+ * would simply never settle and the run's task queue would hang forever
234
+ * behind a dead process. Cancellation and timeout land here too.
235
+ */
236
+ const failPending = (reason) => {
237
+ if (pending.size === 0) return;
238
+ const error = new Error(reason);
239
+ for (const [, entry] of pending) entry.reject(error);
240
+ pending.clear();
241
+ };
242
+ const rpc = (method, params) =>
243
+ new Promise((resolve, reject) => {
244
+ const id = nextId++;
245
+ pending.set(id, { resolve, reject });
246
+ write({ jsonrpc: "2.0", id, method, params });
247
+ });
248
+
249
+ const emit = (event) => {
250
+ if (!onEvent || !event) return;
251
+ try {
252
+ onEvent(event);
253
+ } catch {
254
+ /* narration must never break the run */
255
+ }
256
+ };
257
+ const feed = (text) => {
258
+ if (!onData || !text) return;
259
+ try {
260
+ onData(text);
261
+ } catch {
262
+ /* progress must never break the run */
263
+ }
264
+ };
265
+
266
+ async function settleElicitation(msg) {
267
+ const params = isObject(msg.params) ? msg.params : {};
268
+ const request = normalizeCodexPermissionRequest(params, String(msg.id));
269
+ const available = approvals.get(request.vendorRequestId) ?? null;
270
+ const { reply } = await resolveHilosPermissionReply({
271
+ request,
272
+ requestPermission,
273
+ getPermissionDecision,
274
+ signal: controller.signal,
275
+ timeoutMs,
276
+ pollIntervalMs,
277
+ sleep,
278
+ now,
279
+ log,
280
+ label: "codex permission",
281
+ });
282
+ respond(msg.id, { decision: mapCodexDecision(reply, available) });
283
+ }
284
+
285
+ function handleEvent(msg) {
286
+ if (!isObject(msg)) return;
287
+ const type = typeof msg.type === "string" ? msg.type : "";
288
+ if (type === "session_configured") {
289
+ const id = msg.thread_id || msg.session_id;
290
+ if (typeof id === "string" && id) sessionId = id;
291
+ return;
292
+ }
293
+ if (type === "exec_approval_request") {
294
+ // Recorded only to learn which decisions this ask actually offers; the
295
+ // elicitation request is the thing that blocks and gets answered.
296
+ if (typeof msg.call_id === "string") {
297
+ approvals.set(msg.call_id, Array.isArray(msg.available_decisions) ? msg.available_decisions : null);
298
+ }
299
+ const command = codexCommandText(msg.command);
300
+ if (command) emit({ t: "run", cmd: command });
301
+ return;
302
+ }
303
+ // 0787 — this transport's usage frame. `codex exec --json` prices a run on
304
+ // `turn.completed`; the MCP server never sends that envelope at all, so a
305
+ // gated codex run (the DEFAULT once a workspace is granted runtime
306
+ // permissions) reported nothing. It sends `token_count` instead, once per
307
+ // API call, and the distinction in it matters:
308
+ //
309
+ // {"type":"token_count","info":{
310
+ // "total_token_usage":{"input_tokens":36419,"cached_input_tokens":24064,
311
+ // "output_tokens":154,"reasoning_output_tokens":0,"total_tokens":36573},
312
+ // "last_token_usage":{"input_tokens":18309,"cached_input_tokens":17152,
313
+ // "output_tokens":27,…},"model_context_window":258400}, "rate_limits":{…}}
314
+ //
315
+ // `total_token_usage` is CUMULATIVE for the thread and `last_token_usage`
316
+ // is the delta for the call that just finished. Live-captured on a
317
+ // two-call run, the deltas sum to the total exactly (18110+18309=36419 in,
318
+ // 6912+17152=24064 cached, 127+27=154 out), so folding the deltas is both
319
+ // correct and additive — folding the totals would have billed 54,529 input
320
+ // tokens for a run that used 36,419. Deltas are also the only safe read
321
+ // across `codex-reply`, where a resumed thread's cumulative figure may
322
+ // already include a previous turn hilos never ran.
323
+ if (type === "token_count") {
324
+ const usage = isObject(msg.info) ? msg.info.last_token_usage : null;
325
+ const event = codexUsageEvent(usage);
326
+ if (event) emit(event);
327
+ return;
328
+ }
329
+ if (type === "agent_message_content_delta" && typeof msg.delta === "string") {
330
+ feed(msg.delta);
331
+ return;
332
+ }
333
+ if (type === "agent_message" && typeof msg.message === "string") {
334
+ messages.push(msg.message);
335
+ return;
336
+ }
337
+ if (type === "item_started" || type === "item_completed") {
338
+ const item = isObject(msg.item) ? msg.item : {};
339
+ if (type === "item_started" && item.type === "CommandExecution") {
340
+ const command = codexCommandText(item.command);
341
+ if (command) emit({ t: "run", cmd: command });
342
+ }
343
+ if (type === "item_completed" && item.type === "FileChange") {
344
+ const changes = Array.isArray(item.changes) ? item.changes : [];
345
+ for (const change of changes) {
346
+ const p = isObject(change) && typeof change.path === "string" ? change.path : null;
347
+ if (p) emit({ t: "edit", path: p });
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ function handleMessage(msg) {
354
+ // Server → client REQUEST (has both id and method): the approval lane.
355
+ if (msg.id !== undefined && typeof msg.method === "string") {
356
+ if (msg.method === "elicitation/create") {
357
+ void settleElicitation(msg).catch((error) => {
358
+ log?.error?.(`codex elicitation: ${error?.message ?? error}`);
359
+ // Fail closed — never leave codex blocked on an unanswered ask.
360
+ respond(msg.id, { decision: "denied" });
361
+ });
362
+ return;
363
+ }
364
+ // Any other server request is answered with a refusal rather than left
365
+ // hanging; an unknown ask is not something hilos can consent to.
366
+ respond(msg.id, { decision: "denied" });
367
+ return;
368
+ }
369
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
370
+ const entry = pending.get(msg.id);
371
+ if (!entry) return;
372
+ pending.delete(msg.id);
373
+ if (msg.error) entry.reject(new Error(msg.error?.message ?? "codex rpc error"));
374
+ else entry.resolve(msg.result);
375
+ return;
376
+ }
377
+ if (msg.method === "codex/event") {
378
+ handleEvent(isObject(msg.params?.msg) ? msg.params.msg : null);
379
+ }
380
+ }
381
+
382
+ child.stdout?.setEncoding?.("utf8");
383
+ child.stdout?.on("data", (chunk) => {
384
+ for (const msg of parser.push(chunk)) {
385
+ // One well-formed frame is proof this process IS an MCP server, whatever
386
+ // goes wrong afterwards.
387
+ sawFrame = true;
388
+ try {
389
+ handleMessage(msg);
390
+ } catch (error) {
391
+ log?.error?.(`codex frame: ${error?.message ?? error}`);
392
+ }
393
+ }
394
+ });
395
+ child.stderr?.setEncoding?.("utf8");
396
+ child.stderr?.on("data", (chunk) => {
397
+ stderr += chunk;
398
+ if (stderr.length > 200_000) stderr = stderr.slice(-100_000);
399
+ });
400
+ child.on("error", (error) => {
401
+ spawnError = error;
402
+ for (const [, entry] of pending) entry.reject(error);
403
+ pending.clear();
404
+ });
405
+
406
+ const exited = new Promise((resolve) =>
407
+ child.on("close", (code) => {
408
+ exitCode = code;
409
+ failPending(`codex mcp-server exited (${code}) with a request in flight`);
410
+ resolve(code);
411
+ }),
412
+ );
413
+ // Some fakes and some runtimes emit only `exit`; either one means no answer
414
+ // is coming.
415
+ child.on("exit", (code) => failPending(`codex mcp-server exited (${code}) with a request in flight`));
416
+ const timer = setTimeout(() => {
417
+ timedOut = true;
418
+ controller.abort("timeout");
419
+ }, Math.max(1, timeoutMs || DEFAULT_TIMEOUT_MS));
420
+ const onAbort = () => {
421
+ aborted = true;
422
+ // Reject first, then kill: a cancelled or timed-out run must stop waiting
423
+ // on answers immediately, not depend on the child's exit arriving.
424
+ failPending(timedOut ? "codex session timed out" : "codex session cancelled");
425
+ try {
426
+ child.kill("SIGTERM");
427
+ } catch {
428
+ /* already gone */
429
+ }
430
+ setTimeout(() => {
431
+ try {
432
+ child.kill("SIGKILL");
433
+ } catch {
434
+ /* already gone */
435
+ }
436
+ }, SHUTDOWN_GRACE_MS).unref?.();
437
+ };
438
+ controller.signal.addEventListener("abort", onAbort, { once: true });
439
+
440
+ let status = null;
441
+ let error = null;
442
+ try {
443
+ await rpc("initialize", {
444
+ protocolVersion: "2025-06-18",
445
+ // Declaring elicitation is what makes codex ask US instead of deciding
446
+ // for itself — without it there is no gate.
447
+ capabilities: { elicitation: {} },
448
+ clientInfo: { name: "hilos-agent", version: "1.0.0" },
449
+ });
450
+ initialized = true;
451
+ write({ jsonrpc: "2.0", method: "notifications/initialized" });
452
+ // `codex-reply` takes {threadId, prompt} and nothing else (its schema, live)
453
+ // — a resumed thread already runs on the model its first call configured,
454
+ // so there is nothing to re-send and nothing lost by not sending it.
455
+ const toolCall = resumeThreadId
456
+ ? { name: "codex-reply", arguments: { threadId: resumeThreadId, prompt } }
457
+ : {
458
+ name: "codex",
459
+ arguments: {
460
+ prompt,
461
+ cwd,
462
+ "approval-policy": approvalPolicy,
463
+ sandbox,
464
+ ...(model ? { model } : {}),
465
+ },
466
+ };
467
+ if (resumeThreadId) sessionId = resumeThreadId;
468
+ const result = await rpc("tools/call", { name: toolCall.name, arguments: toolCall.arguments });
469
+ status = result?.isError === true ? 1 : 0;
470
+ } catch (callError) {
471
+ error = spawnError ?? callError;
472
+ status = null;
473
+ } finally {
474
+ clearTimeout(timer);
475
+ controller.signal.removeEventListener("abort", onAbort);
476
+ signal?.removeEventListener("abort", onOuterAbort);
477
+ try {
478
+ child.stdin.end();
479
+ } catch {
480
+ /* already closed */
481
+ }
482
+ if (!controller.signal.aborted) {
483
+ try {
484
+ child.kill("SIGTERM");
485
+ } catch {
486
+ /* already gone */
487
+ }
488
+ }
489
+ await exited;
490
+ }
491
+
492
+ if (spawnError) error = spawnError;
493
+ if (timedOut && !error) error = new Error("codex session timed out");
494
+ // Cancellation and timeout both tear the child down, but only a cancellation
495
+ // is "aborted" — a timeout is a failed run and must read as one.
496
+ const cancelled = aborted && Boolean(signal?.aborted) && !timedOut;
497
+ const stdout = messages.join("\n\n").trim();
498
+ return {
499
+ status: error ? null : status,
500
+ stdout,
501
+ stderr,
502
+ error: error ?? null,
503
+ sessionId,
504
+ initialized,
505
+ sawFrame,
506
+ exitCode,
507
+ ...(timedOut ? { timedOut: true } : {}),
508
+ ...(cancelled ? { aborted: true } : {}),
509
+ };
510
+ }
511
+
512
+ /**
513
+ * The one thing a codex WITHOUT `mcp-server` says, and nothing else does.
514
+ *
515
+ * A CLI too old to have the subcommand doesn't reject it — it forwards the
516
+ * unrecognized word to the interactive TUI as a PROMPT, which then finds our
517
+ * pipe where it wanted a terminal. Captured verbatim off codex 0.144.1 by
518
+ * handing the real binary a subcommand it doesn't have:
519
+ *
520
+ * exit=1 frames=0 stdout="" stderr="Error: stdin is not a terminal\n"
521
+ *
522
+ * The second pattern is not live-observed on any build I have (0.144.1 forwards
523
+ * unknown subcommands rather than erroring on them) but is accepted because it
524
+ * is still the CLI positively saying the subcommand does not exist, which is
525
+ * the only claim this matcher is allowed to act on.
526
+ */
527
+ const CODEX_NO_MCP_SERVER_RE = /stdin is not a terminal|unrecognized subcommand|unknown subcommand/i;
528
+
529
+ /**
530
+ * Did this run fail because the CLI has no `mcp-server` at all (0785)?
531
+ *
532
+ * This gate fails CLOSED, and the asymmetry is the whole reason: a wrongly
533
+ * failed run is recoverable — a human sees it and runs it again — while a
534
+ * wrongly UNGATED run has already executed the commands nobody approved. So
535
+ * "the handshake didn't happen" is NOT enough. It has to be positive evidence
536
+ * that the subcommand is absent, because failures that say nothing about the
537
+ * subcommand look identical from the outside. All three of these are exit 1
538
+ * with zero frames and no handshake, live-captured:
539
+ *
540
+ * • missing subcommand → "Error: stdin is not a terminal"
541
+ * • broken -c override → "Error: error parsing -c overrides: …"
542
+ * • unreadable CODEX_HOME → "Error: error loading config: …"
543
+ *
544
+ * Only the first may degrade. The other two are failed runs and are reported as
545
+ * such — as is a cancelled run, a timed-out run, a missing binary (ENOENT: there
546
+ * is nothing to degrade TO), anything that spoke a single MCP frame, and any
547
+ * failure after a successful handshake.
548
+ *
549
+ * @param {{ initialized?: boolean, sawFrame?: boolean, exitCode?: number|null,
550
+ * stderr?: string, aborted?: boolean, timedOut?: boolean,
551
+ * error?: (Error & {code?: string})|null }} [run]
552
+ */
553
+ export function codexMcpTransportUnavailable(run) {
554
+ if (!run || run.initialized || run.aborted || run.timedOut) return false;
555
+ if (!run.error) return false;
556
+ if (run.error.code === "ENOENT") return false;
557
+ // It spoke MCP, so the subcommand exists whatever went wrong next.
558
+ if (run.sawFrame) return false;
559
+ // It has to have actually exited, and not cleanly.
560
+ if (typeof run.exitCode !== "number" || run.exitCode === 0) return false;
561
+ return CODEX_NO_MCP_SERVER_RE.test(String(run.stderr || ""));
562
+ }
563
+
564
+ /**
565
+ * Does this run get the codex permission gate?
566
+ *
567
+ * Same rule as every other vendor: runtime permissions granted, and a run
568
+ * configured to answer its own asks (the `skip` tier's bypass flag) is left
569
+ * alone. Codex's sandbox tiers are NOT an escape hatch — `workspace-write`
570
+ * still escalates out-of-workspace work to an ask, which is the whole point.
571
+ *
572
+ * @param {{ vendor?: string, runtimePermissions?: boolean, codeArgs?: string[] }} o
573
+ */
574
+ export function shouldGateCodexPermissions({ vendor, runtimePermissions, codeArgs = [] }) {
575
+ if (vendor !== "codex" || runtimePermissions !== true) return false;
576
+ const args = Array.isArray(codeArgs) ? codeArgs : [];
577
+ if (args.includes("--dangerously-bypass-approvals-and-sandbox")) return false;
578
+ return true;
579
+ }
580
+
581
+ /** The sandbox tiers codex actually accepts. */
582
+ const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
583
+
584
+ /**
585
+ * The codex sandbox tier implied by the run's argv, so the gated transport
586
+ * keeps the operator's chosen posture instead of silently widening it.
587
+ *
588
+ * Both spellings count: `--sandbox read-only` and `--sandbox=read-only` (and
589
+ * the `-s` short forms), because a config that said read-only and quietly got
590
+ * workspace-write is the one failure this function must never produce. When a
591
+ * sandbox flag IS present but its value is missing or unrecognized, the answer
592
+ * is the TIGHTEST tier, not the default — an unreadable intent is never
593
+ * grounds for widening. Only the absence of any flag falls back.
594
+ *
595
+ * @param {string[]} [codeArgs]
596
+ * @param {string} [fallback]
597
+ */
598
+ export function codexSandboxFromArgs(codeArgs = [], fallback = "workspace-write") {
599
+ const args = (Array.isArray(codeArgs) ? codeArgs : []).map((a) => String(a ?? ""));
600
+ let raw = null;
601
+ let sawFlag = false;
602
+ for (let i = 0; i < args.length; i++) {
603
+ const arg = args[i];
604
+ if (arg === "--sandbox" || arg === "-s") {
605
+ sawFlag = true;
606
+ raw = args[i + 1] ?? null;
607
+ continue;
608
+ }
609
+ const eq = /^(?:--sandbox|-s)=(.*)$/.exec(arg);
610
+ if (eq) {
611
+ sawFlag = true;
612
+ raw = eq[1];
613
+ }
614
+ }
615
+ if (!sawFlag) return fallback;
616
+ const value = (raw ?? "").trim();
617
+ // A flag whose value we cannot read fails CLOSED.
618
+ return CODEX_SANDBOXES.has(value) ? value : "read-only";
619
+ }