@prohost/cli 0.2.2 → 0.3.0

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.
@@ -11,12 +11,12 @@
11
11
  *
12
12
  * Everything here was verified against a real `codex-cli 0.144.1` rather than
13
13
  * taken from documentation; the surprises are called out where they are relied
14
- * on. As in `claude.ts`, nothing in this module is load-bearing for
15
- * correctness: if detection misses, if the session is unresumable, if the JSON
16
- * is not what we expect — the run still happens and still completes.
14
+ * on. Output and configuration used to prove the MCP boundary are deliberately
15
+ * load-bearing: if that proof is incomplete, the unsandboxed run must not
16
+ * start. Session continuity and reply parsing still fail independently.
17
17
  */
18
18
  import { MCP_SERVER_NAME } from './mcp.js';
19
- import { SHELL_CONTROL_OPERATORS, shellQuote } from './shell.js';
19
+ import { shellQuote } from './shell.js';
20
20
  /**
21
21
  * Environment variable the API key is handed to Codex in.
22
22
  *
@@ -37,39 +37,79 @@ export const MCP_API_KEY_ENV_VAR = 'PROHOST_MCP_API_KEY';
37
37
  * Both halves matter. Interactive `codex` has no `--json` and no `resume`
38
38
  * subcommand, so shaping it would break a command that works today.
39
39
  *
40
- * Three things this refuses to touch, each because appending to it would do
41
- * harm rather than nothing:
40
+ * Three things are deliberately not shaped, each because appending to it would
41
+ * do harm rather than nothing:
42
42
  *
43
- * * Commands with shell control operators. Flags are appended to the *end* of
44
- * the string, so for `codex exec | jq .` they would land on `jq`.
45
43
  * * `codex exec resume …` and `codex exec review …`. The host already chose a
46
44
  * sub-subcommand; appending our own `resume` would produce two of them.
47
45
  * * Anything after a `--` sentinel, which is the host saying "stop parsing".
48
- * * A command carrying its own prompt, e.g. `codex exec 'Be terse'`. Codex
46
+ * * A command carrying its own unquoted prompt, e.g. `codex exec hello`. Codex
49
47
  * takes exactly one prompt positional, so both the `-` this harness appends
50
48
  * and a `resume <id>` would be a second one — `error: unexpected argument
51
49
  * '-' found`, on a command that works today. Such a host keeps the plain
52
50
  * contract; the place to put standing instructions is now the agent's
53
51
  * ProhostAI profile, which arrives in the prompt itself.
52
+ *
53
+ * Shell syntax is different: recognisable Codex exec commands containing it
54
+ * stay classified as Codex so the harness can fail closed before spawn. Letting
55
+ * one fall through as a plain command would also drop the verified MCP boundary.
54
56
  */
55
- export function isCodexExec(command) {
56
- if (SHELL_CONTROL_OPERATORS.test(command))
57
- return false;
58
- const words = command.trim().split(/\s+/);
59
- const first = words[0] ?? '';
60
- const base = first.split('/').pop() ?? '';
57
+ export function isCodexExec(command, platform = process.platform) {
58
+ // Strip one matching quote pair for classification only. The real command is
59
+ // never rebuilt from these words: any quote makes the shell-syntax check
60
+ // below fail closed before a run. This small normalization is what prevents
61
+ // `codex exec '--enable' plugins` from falling through to the unshaped/plain
62
+ // path merely because the shell removes quotes our whitespace scan cannot.
63
+ const words = command
64
+ .trim()
65
+ .split(/\s+/)
66
+ .map((word) => {
67
+ const match = /^(['"])(.*)\1$/.exec(word);
68
+ return match?.[2] ?? word;
69
+ });
70
+ const trimmed = command.trim();
71
+ const quote = trimmed[0];
72
+ const closingQuote = quote === "'" || quote === '"' ? trimmed.indexOf(quote, 1) : -1;
73
+ const first = closingQuote > 0 ? trimmed.slice(1, closingQuote) : (words[0] ?? '');
74
+ const base = first.split(/[\\/]/).pop() ?? '';
61
75
  if (base !== 'codex' && base !== 'codex.cmd' && base !== 'codex.exe')
62
76
  return false;
63
- if (words.includes('--'))
64
- return false;
65
77
  const rest = words.slice(1);
66
78
  if (!rest.includes('exec') && !rest.includes('e'))
67
79
  return false;
80
+ // Once this is recognisably a Codex exec command, unsupported shell syntax
81
+ // must stay on the Codex path so preparation can refuse it. Treating it as a
82
+ // plain command would run the same Codex binary without the verified MCP
83
+ // boundary, exactly the failure this classifier exists to prevent.
84
+ if (codexShellSyntax(command, platform))
85
+ return true;
86
+ if (words.includes('--'))
87
+ return false;
68
88
  // A host who already picked `resume` or `review` gets left alone entirely.
69
89
  if (rest.includes('resume') || rest.includes('review'))
70
90
  return false;
71
91
  return !hasExplicitPrompt(words);
72
92
  }
93
+ /**
94
+ * Shell syntax a shaped Codex command cannot safely preserve.
95
+ *
96
+ * Codex commands are eventually executed with `shell: true`, while the harness
97
+ * currently operates on whitespace-delimited words. Quotes, escapes,
98
+ * expansions, globs, comments and control/redirection operators all make those
99
+ * two views differ. Rather than guess at POSIX plus cmd.exe tokenization, only
100
+ * the deliberately boring argv-shaped subset is accepted. A matching command
101
+ * is still classified as Codex and then rejected before spawn.
102
+ */
103
+ export function codexShellSyntax(command, platform = process.platform) {
104
+ // Backslash is a POSIX escape, but an ordinary path separator to cmd.exe.
105
+ // Windows's own expansion/escape characters (`%`, `^`, `!`) remain rejected,
106
+ // along with the control and quoting syntax shared by both shells.
107
+ const unsupported = platform === 'win32'
108
+ ? /['"$`|&;<>(){}*?\[\]~#\r\n%^!]/
109
+ : /['"\\$`|&;<>(){}*?\[\]~#\r\n%^!]/;
110
+ const match = unsupported.exec(command);
111
+ return match?.[0];
112
+ }
73
113
  /**
74
114
  * Does the command already carry a prompt of the host's own?
75
115
  *
@@ -165,10 +205,446 @@ function tomlString(value) {
165
205
  * keeps a live API key off a command line that anyone on the machine can read
166
206
  * out of `ps`.
167
207
  */
168
- export function buildMcpOverride(mcpUrl) {
169
- return (`mcp_servers.${MCP_SERVER_NAME}={url=${tomlString(mcpUrl)}, ` +
208
+ export function prohostMcpEntry(mcpUrl) {
209
+ return (`${tomlString(MCP_SERVER_NAME)}={url=${tomlString(mcpUrl)}, ` +
170
210
  `env_http_headers={"X-API-Key"=${tomlString(MCP_API_KEY_ENV_VAR)}}}`);
171
211
  }
212
+ /**
213
+ * The whole `mcp_servers` override for a run, ProhostAI server included.
214
+ *
215
+ * It is one override, and it has to be: `-c` overrides are applied in order,
216
+ * and a later whole-table assignment **replaces** an earlier dotted one rather
217
+ * than merging with it. `-c mcp_servers.prohost={…} -c mcp_servers={…}` — the
218
+ * shape this had until it was measured — therefore drops the ProhostAI server
219
+ * outright, on every run that disables anything, while the harness went on
220
+ * logging "ProhostAI tools wired in" and the prompt went on telling the agent
221
+ * it had them. (Deep-merging is what happens between the CLI override and the
222
+ * *config file*, which is what made the wrong shape look right.)
223
+ */
224
+ export function buildMcpOverride(mcpUrl) {
225
+ return mcpServersOverride([prohostMcpEntry(mcpUrl)]);
226
+ }
227
+ /**
228
+ * Encode a JSON value as a TOML inline value.
229
+ *
230
+ * Only needed for `extra-mcp.json`, whose entries are written by the operator
231
+ * in Claude Code's JSON shape and have to reach Codex through `-c`, which parses
232
+ * its value as TOML. Deliberately total over what JSON can hold and nothing
233
+ * more: a value it cannot represent returns `null` and the whole server is
234
+ * skipped with a warning, because a half-encoded table would not merely lose
235
+ * that server — a `-c` override that fails to parse takes the entire config
236
+ * down with it, ProhostAI server included.
237
+ *
238
+ * `null` is dropped from tables rather than encoded, TOML having no null.
239
+ */
240
+ function tomlValue(value) {
241
+ if (typeof value === 'string')
242
+ return tomlString(value);
243
+ if (typeof value === 'boolean')
244
+ return value ? 'true' : 'false';
245
+ if (typeof value === 'number')
246
+ return Number.isFinite(value) ? String(value) : null;
247
+ if (Array.isArray(value)) {
248
+ const items = value.map(tomlValue);
249
+ return items.some((item) => item === null) ? null : `[${items.join(', ')}]`;
250
+ }
251
+ if (value && typeof value === 'object') {
252
+ const pairs = [];
253
+ for (const [key, entry] of Object.entries(value)) {
254
+ if (entry === null || entry === undefined)
255
+ continue;
256
+ const encoded = tomlValue(entry);
257
+ if (encoded === null)
258
+ return null;
259
+ pairs.push(`${tomlString(key)}=${encoded}`);
260
+ }
261
+ return `{${pairs.join(', ')}}`;
262
+ }
263
+ return null;
264
+ }
265
+ /**
266
+ * Turn `extra-mcp.json` entries into `mcp_servers` table fragments.
267
+ *
268
+ * The name is quoted rather than written as a dotted path, which is what lets
269
+ * *any* name work. `-c` splits on the first `=` and reads everything before it
270
+ * as a dotted path, so `-c mcp_servers.fly.io.enabled=false` addresses
271
+ * `mcp_servers.fly.io` — three segments, not two — and Codex rejects the whole
272
+ * config with `invalid transport in mcp_servers.fly`. Quoting the segment does
273
+ * not help either: `mcp_servers."fly.io"` fails the same way, because the
274
+ * splitting happens before any TOML is parsed. Putting the name inside the
275
+ * *value* sidesteps the path parser entirely.
276
+ *
277
+ * Claude's JSON shape carries literal `headers` and stdio `env` values. Those
278
+ * must not be copied literally into Codex's `-c` argument: command lines are
279
+ * readable by other local processes. Translate them to Codex's indirection
280
+ * fields and put the values in the child environment instead. `type` is also a
281
+ * Claude-only discriminator; Codex infers the transport from `url`/`command`.
282
+ */
283
+ export function extraMcpEntries(servers, platform = process.platform) {
284
+ const entries = [];
285
+ const skipped = [];
286
+ const env = {};
287
+ let secretIndex = 0;
288
+ const stringRecord = (value) => {
289
+ if (!value || typeof value !== 'object' || Array.isArray(value))
290
+ return null;
291
+ const pairs = Object.entries(value);
292
+ if (pairs.some(([, entry]) => typeof entry !== 'string'))
293
+ return null;
294
+ return Object.fromEntries(pairs);
295
+ };
296
+ // Literal headers need generated environment names. Reserve every name any
297
+ // extra already references *before* allocating one: otherwise an earlier
298
+ // header can claim PROHOST_EXTRA_MCP_SECRET_0 and a later extra's env_vars or
299
+ // env_http_headers can read that credential across the server boundary.
300
+ // Windows environment-variable lookup is case-insensitive. Keep the emitted
301
+ // spelling untouched, but compare identities the same way the child process
302
+ // will, so a lower-case alias cannot read the paired or generated credential.
303
+ const envNameKey = (name) => (platform === 'win32' ? name.toUpperCase() : name);
304
+ const prohibitedEnvName = envNameKey(MCP_API_KEY_ENV_VAR);
305
+ const isProhibitedEnvName = (name) => envNameKey(name) === prohibitedEnvName;
306
+ const reservedEnvNames = new Set([prohibitedEnvName]);
307
+ const assignedEnvValues = new Map();
308
+ for (const definition of Object.values(servers)) {
309
+ const declaredEnvVars = definition.env_vars;
310
+ if (Array.isArray(declaredEnvVars)) {
311
+ for (const name of declaredEnvVars) {
312
+ if (typeof name === 'string')
313
+ reservedEnvNames.add(envNameKey(name));
314
+ }
315
+ }
316
+ const declaredHeaderEnv = stringRecord(definition.env_http_headers);
317
+ if (declaredHeaderEnv) {
318
+ for (const name of Object.values(declaredHeaderEnv))
319
+ reservedEnvNames.add(envNameKey(name));
320
+ }
321
+ const literalEnv = definition.env;
322
+ if (literalEnv && typeof literalEnv === 'object' && !Array.isArray(literalEnv)) {
323
+ for (const name of Object.keys(literalEnv))
324
+ reservedEnvNames.add(envNameKey(name));
325
+ }
326
+ }
327
+ const secretVariable = () => {
328
+ let variable;
329
+ do
330
+ variable = `PROHOST_EXTRA_MCP_SECRET_${secretIndex++}`;
331
+ while (reservedEnvNames.has(envNameKey(variable)) || assignedEnvValues.has(envNameKey(variable)));
332
+ reservedEnvNames.add(envNameKey(variable));
333
+ return variable;
334
+ };
335
+ for (const [name, original] of Object.entries(servers)) {
336
+ const definition = { ...original };
337
+ const definitionEnv = {};
338
+ let invalid = false;
339
+ delete definition.type;
340
+ const declaredHeaderEnv = definition.env_http_headers;
341
+ if (declaredHeaderEnv !== undefined) {
342
+ const indirect = stringRecord(declaredHeaderEnv);
343
+ if (indirect === null || Object.values(indirect).some(isProhibitedEnvName)) {
344
+ skipped.push(name);
345
+ continue;
346
+ }
347
+ }
348
+ const declaredEnvVars = definition.env_vars;
349
+ if (declaredEnvVars !== undefined &&
350
+ (!Array.isArray(declaredEnvVars) ||
351
+ declaredEnvVars.some((entry) => typeof entry !== 'string') ||
352
+ declaredEnvVars.some((entry) => typeof entry === 'string' && isProhibitedEnvName(entry)))) {
353
+ skipped.push(name);
354
+ continue;
355
+ }
356
+ const literalHeaders = definition.headers ?? definition.http_headers;
357
+ if (literalHeaders !== undefined) {
358
+ const headers = stringRecord(literalHeaders);
359
+ const existing = definition.env_http_headers;
360
+ const indirect = existing === undefined ? {} : stringRecord(existing);
361
+ if (headers === null || indirect === null) {
362
+ skipped.push(name);
363
+ continue;
364
+ }
365
+ if (Object.values(indirect).some(isProhibitedEnvName)) {
366
+ skipped.push(name);
367
+ continue;
368
+ }
369
+ for (const [header, value] of Object.entries(headers)) {
370
+ const variable = secretVariable();
371
+ definitionEnv[variable] = value;
372
+ indirect[header] = variable;
373
+ }
374
+ definition.env_http_headers = indirect;
375
+ delete definition.headers;
376
+ delete definition.http_headers;
377
+ }
378
+ if (definition.env !== undefined) {
379
+ const variables = stringRecord(definition.env);
380
+ const existing = definition.env_vars;
381
+ if (variables === null ||
382
+ (existing !== undefined &&
383
+ (!Array.isArray(existing) || existing.some((entry) => typeof entry !== 'string')))) {
384
+ skipped.push(name);
385
+ continue;
386
+ }
387
+ const names = new Set(existing);
388
+ const definitionEnvNames = new Set(Object.keys(definitionEnv).map(envNameKey));
389
+ for (const [variable, value] of Object.entries(variables)) {
390
+ const envKey = envNameKey(variable);
391
+ const assignedValue = assignedEnvValues.get(envKey);
392
+ if (isProhibitedEnvName(variable) ||
393
+ (assignedValue !== undefined && assignedValue !== value) ||
394
+ definitionEnvNames.has(envKey)) {
395
+ invalid = true;
396
+ break;
397
+ }
398
+ definitionEnv[variable] = value;
399
+ definitionEnvNames.add(envKey);
400
+ names.add(variable);
401
+ }
402
+ if (invalid || [...names].some(isProhibitedEnvName)) {
403
+ skipped.push(name);
404
+ continue;
405
+ }
406
+ definition.env_vars = [...names];
407
+ delete definition.env;
408
+ }
409
+ const encoded = tomlValue(definition);
410
+ if (encoded === null) {
411
+ skipped.push(name);
412
+ continue;
413
+ }
414
+ entries.push(`${tomlString(name)}=${encoded}`);
415
+ for (const [variable, value] of Object.entries(definitionEnv)) {
416
+ if (value === undefined)
417
+ continue;
418
+ const envKey = envNameKey(variable);
419
+ if (!assignedEnvValues.has(envKey))
420
+ env[variable] = value;
421
+ assignedEnvValues.set(envKey, value);
422
+ }
423
+ }
424
+ return { entries, skipped, env };
425
+ }
426
+ /**
427
+ * Table fragments that switch off the servers the operator's Codex install has.
428
+ *
429
+ * This is the Codex answer to `--strict-mcp-config`, and it took a real binary
430
+ * to find. Two mechanisms that look like they should work do not: replacing the
431
+ * table wholesale does **not** replace it — Codex deep-merges config overrides,
432
+ * so the operator's servers survive alongside whatever the override adds — and
433
+ * `--ignore-user-config` does work but drops the operator's *entire* config
434
+ * with it, taking their model, profile and sandbox settings along for a
435
+ * security fix they did not ask to pay for that way.
436
+ *
437
+ * Setting `enabled=false` per server is the surgical one, verified end to end
438
+ * against `codex-cli 0.144.1`: the server is then reported `disabled` by
439
+ * `codex mcp list`, its own definition is left intact, other servers are
440
+ * untouched, and its tools are genuinely absent from the model's tool list
441
+ * mid-run.
442
+ *
443
+ * Names the extras file defines are left alone. Codex would deep-merge
444
+ * `enabled=false` into the operator's definition of the same name and produce a
445
+ * server the operator asked for twice and got zero times, which is a worse
446
+ * outcome than the collision itself.
447
+ */
448
+ export function disableMcpEntries(configuredNames, keep = []) {
449
+ const kept = new Set([MCP_SERVER_NAME, ...keep]);
450
+ return configuredNames.filter((name) => !kept.has(name)).map((name) => `${tomlString(name)}={enabled=false}`);
451
+ }
452
+ /**
453
+ * Fold every entry into the single `-c mcp_servers={…}` override.
454
+ *
455
+ * One override rather than one per server, because they all reach the same
456
+ * table and Codex merges rather than replaces — so the combined form is
457
+ * equivalent, shorter on the command line, and (unlike the dotted-path form)
458
+ * imposes no constraint on what a server may be called.
459
+ */
460
+ export function mcpServersOverride(entries) {
461
+ return entries.length > 0 ? `mcp_servers={${entries.join(', ')}}` : undefined;
462
+ }
463
+ /**
464
+ * Ask the binary what MCP servers it has, optionally under a `-c` override.
465
+ *
466
+ * Asking rather than parsing `~/.codex/config.toml` is deliberate twice over.
467
+ * `codex mcp list --json` already honours `CODEX_HOME` and the binary's normal
468
+ * config layering — and, measured on an ordinary install, it
469
+ * reports servers that appear **nowhere** in that file (plugins contribute
470
+ * their own). A config file we half-understand is exactly how an isolation
471
+ * guarantee ends up with a hole in it.
472
+ *
473
+ * Passing an override makes this a *verifier*: the listing then reflects the
474
+ * config the run would actually get, so "did the disable take" is a question
475
+ * with an answer rather than an assumption.
476
+ *
477
+ * The working directory is equally load-bearing. Codex discovers trusted
478
+ * project `.codex/config.toml` layers from cwd, so a probe in the harness's
479
+ * launch directory cannot certify a run executed in `$PROHOST_HOME/workspace`
480
+ * or an explicit `--workdir`. Every probe uses the eventual run cwd.
481
+ *
482
+ * Returns `null` — distinct from `[]` — when the question could not be
483
+ * answered, which for an override also means "that override does not load".
484
+ * The caller must treat the two differently: an empty list means there is
485
+ * nothing to disable, while `null` means we do not know, and a run that cannot
486
+ * enumerate must say so rather than claim an isolation it did not apply.
487
+ */
488
+ export function listMcpServers(binary, runner, override, cwd) {
489
+ const command = override ? `${binary} mcp list --json -c ${shellQuote(override, process.platform)}` : `${binary} mcp list --json`;
490
+ let result;
491
+ try {
492
+ result = runner(command, cwd);
493
+ }
494
+ catch {
495
+ return null;
496
+ }
497
+ if (result.status !== 0)
498
+ return null;
499
+ try {
500
+ const parsed = JSON.parse(result.stdout);
501
+ if (!Array.isArray(parsed))
502
+ return null;
503
+ const servers = [];
504
+ for (const entry of parsed) {
505
+ // Skipping an entry would turn an output shape we do not understand into
506
+ // "there is no server here". This parser is an isolation verifier, so one
507
+ // malformed item makes the entire answer unknown and the run fails closed.
508
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
509
+ return null;
510
+ const record = entry;
511
+ if (typeof record.name !== 'string' || record.name.length === 0)
512
+ return null;
513
+ servers.push({ name: record.name, enabled: record.enabled !== false });
514
+ }
515
+ return servers;
516
+ }
517
+ catch {
518
+ return null;
519
+ }
520
+ }
521
+ /**
522
+ * Disable entries, checked against the binary before they are used.
523
+ *
524
+ * Not paranoia — a measured failure. `{enabled=false}` merges onto a server's
525
+ * existing definition, and a server contributed by a **plugin** has no entry in
526
+ * `mcp_servers` to merge onto, so the override defines a table with no
527
+ * transport and Codex refuses the entire config: `invalid transport in
528
+ * mcp_servers.github`. Shipped unverified, that would not have leaked a
529
+ * server — it would have broken *every* run for anyone with a Codex plugin
530
+ * installed, which is a far worse outcome than the hole it was closing.
531
+ *
532
+ * So the entries are tried before they are trusted: one probe for the whole
533
+ * set, and only if that fails, one probe per server to find which ones can be
534
+ * disabled and which cannot. The happy path costs a single extra command per
535
+ * survey; the slow path is paid only by installs that need it.
536
+ *
537
+ * `failed` is the honest part. A server that cannot be disabled is still
538
+ * reachable from the run, and the caller has to say so out loud rather than
539
+ * report an isolation it only partly achieved.
540
+ */
541
+ export function disableEntriesThatLoad(binary, runner, targets, cwd) {
542
+ if (targets.length === 0)
543
+ return { entries: [], failed: [] };
544
+ const disabled = (names) => {
545
+ const override = mcpServersOverride(disableMcpEntries(names));
546
+ if (!override)
547
+ return [];
548
+ const listing = listMcpServers(binary, runner, override, cwd);
549
+ // A config that does not load disables nothing, and a server the listing
550
+ // still reports as enabled was not disabled whatever the override said.
551
+ if (listing === null)
552
+ return [];
553
+ const off = new Set(listing.filter((server) => !server.enabled).map((server) => server.name));
554
+ return names.filter((name) => off.has(name));
555
+ };
556
+ const all = disabled(targets);
557
+ if (all.length === targets.length)
558
+ return { entries: disableMcpEntries(targets), failed: [] };
559
+ // The combined override was rejected, or took only partially. Fall back to
560
+ // asking about each server on its own, so one plugin-provided server cannot
561
+ // cost the isolation of every other server the operator has.
562
+ const usable = targets.filter((name) => disabled([name]).length === 1);
563
+ // Then ask again about the survivors *together*. Individually-valid entries
564
+ // combining into a valid one is an inference, and an inference is what put
565
+ // the unverified override in here to begin with — a combination Codex
566
+ // rejected would break every run rather than leak a server.
567
+ const verified = disabled(usable);
568
+ return {
569
+ entries: disableMcpEntries(verified),
570
+ failed: targets.filter((name) => !verified.includes(name)),
571
+ };
572
+ }
573
+ /**
574
+ * The `codex` binary out of the host's `--exec` command.
575
+ *
576
+ * The host may have written a path (`/opt/homebrew/bin/codex`), and the
577
+ * enumeration above has to ask the *same* binary the run will use — a different
578
+ * one on `PATH` could be a different version reading a different `CODEX_HOME`.
579
+ */
580
+ export function codexBinary(command) {
581
+ return command.trim().split(/\s+/)[0] ?? 'codex';
582
+ }
583
+ /**
584
+ * Is the host running Codex under a named profile?
585
+ *
586
+ * Worth asking because it is a hole in the isolation that cannot be closed from
587
+ * here. `codex exec -p work` additionally loads `$CODEX_HOME/work.config.toml`,
588
+ * and `codex mcp list` has no way to be asked about a profile: `-p` and
589
+ * `--profile` are rejected outright, and `-c profile="work"` is refused as a
590
+ * removed legacy form. So a server defined only inside a profile is invisible
591
+ * to the enumeration and stays reachable from the run.
592
+ *
593
+ * Nothing here can fix that — reading the profile file ourselves is the
594
+ * config-parsing this deliberately avoids, and it is exactly how an isolation
595
+ * guarantee ends up with a hole nobody knows about. What it can do is tell the
596
+ * operator, which is what the caller does with this.
597
+ */
598
+ export function codexProfileName(command) {
599
+ const words = command.trim().split(/\s+/);
600
+ for (let i = 0; i < words.length; i += 1) {
601
+ if (words[i] === '-p' || words[i] === '--profile')
602
+ return words[i + 1];
603
+ const long = /^--profile=(.+)$/.exec(words[i] ?? '');
604
+ if (long)
605
+ return long[1];
606
+ // clap accepts a short option's value joined to the flag (`-pwork`) and
607
+ // with an equals sign (`-p=work`). Missing these would let the real run load
608
+ // profile-only MCP servers while the profile-less probe certified the base
609
+ // config.
610
+ const short = /^-p=?(.+)$/.exec(words[i] ?? '');
611
+ if (short)
612
+ return short[1];
613
+ }
614
+ return undefined;
615
+ }
616
+ /**
617
+ * A host flag that can make the actual run's MCP inventory differ from the
618
+ * plain `codex mcp list` probe.
619
+ *
620
+ * Replaying arbitrary config arguments into a separate shell command would
621
+ * require parsing the operator's shell syntax exactly. Guessing is not an
622
+ * isolation boundary, so reject the small set of flags that can alter cwd,
623
+ * config, or plugin loading. `-C`/`--cd` matters because Codex discovers
624
+ * project-local config from the directory it runs in. Dedicated model/sandbox
625
+ * flags remain supported.
626
+ */
627
+ export function codexMcpInventoryFlag(command) {
628
+ const words = command.trim().split(/\s+/);
629
+ for (const word of words) {
630
+ if (word === '-c' ||
631
+ /^-c(?:=)?.+/.test(word) ||
632
+ word === '--config' ||
633
+ word.startsWith('--config=') ||
634
+ word === '--enable' ||
635
+ word.startsWith('--enable=') ||
636
+ word === '--disable' ||
637
+ word.startsWith('--disable=') ||
638
+ word === '--ignore-user-config' ||
639
+ word === '-C' ||
640
+ /^-C(?:=)?.+/.test(word) ||
641
+ word === '--cd' ||
642
+ word.startsWith('--cd=')) {
643
+ return word;
644
+ }
645
+ }
646
+ return undefined;
647
+ }
172
648
  /** Already present in the host's own command — never add a second one. */
173
649
  function has(words, ...flags) {
174
650
  return flags.some((flag) => words.includes(flag));
@@ -229,8 +705,9 @@ export function codexCommand(base, flags = {}) {
229
705
  if (!gated && !has(words, ...BYPASS_FLAGS)) {
230
706
  parts.push('--yolo');
231
707
  }
232
- if (flags.mcpUrl)
233
- parts.push('-c', quote(buildMcpOverride(flags.mcpUrl)));
708
+ const override = mcpServersOverride(flags.mcpEntries ?? []);
709
+ if (override)
710
+ parts.push('-c', quote(override));
234
711
  // The prompt arrives on stdin, and `-` is how Codex is told to read it.
235
712
  // Explicit rather than implied: `codex exec` with no positional does read
236
713
  // stdin, but `codex exec resume <id>` treats a missing positional as "no
@@ -239,6 +716,17 @@ export function codexCommand(base, flags = {}) {
239
716
  parts.push('-');
240
717
  return parts.join(' ');
241
718
  }
719
+ /**
720
+ * Wording Codex uses when a tool call was refused rather than attempted.
721
+ *
722
+ * The verified string is `user cancelled MCP tool call`, emitted on a
723
+ * `mcp_tool_call` item with `status: "failed"` when there is no approver — the
724
+ * exact situation every run of this harness is in. The rest of the alternation
725
+ * is defensive: the phrasing is not ours to depend on across Codex versions,
726
+ * and the cost of matching one word too many is a log line, while the cost of
727
+ * matching one too few is the failure mode this whole function exists to end.
728
+ */
729
+ const DENIAL_WORDING = /cancell?ed|rejected|denied|not (approved|permitted|allowed)|requires approval/i;
242
730
  function asObject(candidate) {
243
731
  try {
244
732
  const parsed = JSON.parse(candidate);
@@ -283,11 +771,21 @@ function failureMessage(event) {
283
771
  * than a fallback to posting the raw text. We asked for JSON; anything else
284
772
  * means something went wrong that we do not understand, and the one thing that
285
773
  * must not happen is that reaching a customer's conversation.
774
+ *
775
+ * Refused tool calls come back too, as the Codex counterpart of Claude Code's
776
+ * `permission_denials`. Codex has no such field, but it does emit the fact: a
777
+ * refused call is an `item.completed` carrying a `mcp_tool_call` with
778
+ * `status: "failed"` and `error.message: "user cancelled MCP tool call"`, which
779
+ * is what a run with nobody at the terminal gets every time. Names are spelled
780
+ * `mcp__<server>__<tool>` so the harness's log line reads identically whichever
781
+ * brain produced it — and because that is genuinely how Codex names the tool to
782
+ * the model (verified against 0.144.1, same convention as Claude Code).
286
783
  */
287
784
  export function parseCodexOutput(stdout) {
288
785
  let sessionId;
289
786
  let reply;
290
787
  let failure;
788
+ const denials = [];
291
789
  for (const line of stdout.split('\n')) {
292
790
  const trimmed = line.trim();
293
791
  if (!trimmed || !trimmed.startsWith('{'))
@@ -312,6 +810,9 @@ export function parseCodexOutput(stdout) {
312
810
  // answer the run ended on.
313
811
  if (record.type === 'agent_message')
314
812
  reply = text(record.text) ?? reply;
813
+ const denied = denialName(record);
814
+ if (denied && !denials.includes(denied))
815
+ denials.push(denied);
315
816
  }
316
817
  }
317
818
  }
@@ -320,18 +821,49 @@ export function parseCodexOutput(stdout) {
320
821
  // it would put the failure in front of a customer as if the agent had chosen
321
822
  // to say it.
322
823
  if (failure)
323
- return { ok: false, sessionId, error: failure };
824
+ return { ok: false, sessionId, denials, error: failure };
324
825
  if (reply)
325
- return { ok: true, sessionId, text: reply };
826
+ return { ok: true, sessionId, denials, text: reply };
326
827
  const sample = stdout.trim().slice(0, 200);
327
828
  return {
328
829
  ok: false,
329
830
  sessionId,
831
+ denials,
330
832
  error: sample
331
833
  ? `codex produced no agent message (asked for --json). First 200 chars: ${sample}`
332
834
  : 'codex returned no output',
333
835
  };
334
836
  }
837
+ /**
838
+ * Name the tool this item was refused, or `undefined` if it wasn't refused.
839
+ *
840
+ * A *failed* call is not a *denied* one — an MCP server that 500s also lands
841
+ * here — so the error text has to say refusal before this reports one.
842
+ * Over-reporting would put "the agent was refused these tools" in the log for a
843
+ * server that was merely down, sending the operator to their permissions when
844
+ * the problem is their network.
845
+ */
846
+ function denialName(item) {
847
+ if (item.status !== 'failed')
848
+ return undefined;
849
+ const error = item.error;
850
+ const message = typeof error === 'string' ? error : error && typeof error === 'object' ? text(error.message) : undefined;
851
+ if (!message || !DENIAL_WORDING.test(message))
852
+ return undefined;
853
+ if (item.type === 'mcp_tool_call') {
854
+ const server = text(item.server);
855
+ const tool = text(item.tool);
856
+ if (server && tool)
857
+ return `mcp__${server}__${tool}`;
858
+ if (tool)
859
+ return tool;
860
+ }
861
+ // Only the MCP shape above was verified against a real binary. Anything else
862
+ // that reports itself refused is still worth naming — by its item type, which
863
+ // is the only handle we can be sure of — rather than dropped for not matching
864
+ // a shape we have seen.
865
+ return text(item.type);
866
+ }
335
867
  /**
336
868
  * Does this look like the resume having been rejected, rather than the agent
337
869
  * having failed at its actual work?