@prohost/cli 0.3.0 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,91 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
4
4
  a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
5
5
  builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
+ ## 0.4.1
8
+
9
+ Codex runs work again on machines with a Codex **plugin** installed.
10
+
11
+ - **Plugin-provided MCP servers are now isolated instead of blocking the run.**
12
+ A plugin declared in `~/.codex/config.toml` — `[plugins."github@openai-curated"]`
13
+ — contributes an MCP server (`github`) that the per-server `mcp_servers`
14
+ disable cannot touch: it has no `mcp_servers` entry for `{enabled=false}` to
15
+ merge onto, so the override leaves a table with no transport and Codex
16
+ rejects the whole config with `invalid transport in mcp_servers.github`. The
17
+ isolation verifier correctly refused to start a run it could not prove was
18
+ sealed, which meant **every** message to a Codex-backed agent failed with
19
+ `MCP isolation could not be established`. Such servers are now shut off with
20
+ `-c features.plugins=false`, verified by re-listing exactly like every other
21
+ disable.
22
+ - **Only when a plugin actually supplies a server.** An install whose plugins
23
+ contribute no MCP server issues byte-identical commands to 0.4.0 and keeps
24
+ its plugin skills and hooks; the switch is a bigger hammer than the problem
25
+ on those machines. Classic `mcp_servers` entries are still disabled
26
+ individually, and the ProhostAI server and any `extra-mcp.json` servers keep
27
+ working with plugins off.
28
+ - **Nothing is trusted that wasn't verified.** Every per-plugin form that looks
29
+ like it should work — `plugins."github@openai-curated".enabled=false`, the
30
+ same written as a table, `.disabled=true`, a whole-table `plugins={…}` —
31
+ loads without error and changes nothing at all. A silent no-op is the worst
32
+ shape a security override can have, and only the re-list distinguishes it
33
+ from one that took.
34
+ - **The failure message now carries the way out.** When a server still cannot
35
+ be disabled, the error names the remedy — remove it from
36
+ `~/.codex/config.toml`, or run the daemon with a dedicated `CODEX_HOME`
37
+ holding only `auth.json` — rather than leaving the operator with a run that
38
+ will not start and nothing to do about it.
39
+
40
+ ## 0.4.0
41
+
42
+ The wall clock stops being the safety mechanism. 0.3.0 raised the run ceiling
43
+ to two hours and added heartbeats — but a heartbeat proves the *daemon* is
44
+ alive, not that the *work* is progressing. A child wedged on a stdin prompt or
45
+ stuck in a loop heartbeats forever, looks healthy in the UI, burns the
46
+ operator's tokens, and — because runs are serialized — makes the agent deaf to
47
+ every other mention until the two-hour clock finally runs out. Meanwhile that
48
+ same clock was killing runs that were doing fine.
49
+
50
+ - **A run is killed when it goes silent, not when it goes long.** The new
51
+ inactivity watchdog kills a run that produces **no output at all** for 15
52
+ minutes (`--idle-timeout <seconds>`, `0` disables it). Any byte on stdout or
53
+ stderr resets the window, which covers all three brains: Claude Code's
54
+ stream-json events, Codex's output, and whatever a plain `--exec` command
55
+ prints. An agent that is working never trips it — the gaps in a healthy run
56
+ are seconds — while a wedged one is caught in minutes rather than hours. The
57
+ kill is the same SIGTERM → 5s → SIGKILL escalation a timeout or a cancel
58
+ uses, the partial answer is salvaged, and a session id recorded mid-run
59
+ survives, exactly as for a wall-clock kill. The failure reads
60
+ `no output for 15 minutes — treated as stalled; tune with --idle-timeout
61
+ <seconds> (0 = disabled)`, because the server echoes it into the thread.
62
+ - **`--timeout` now defaults to `0`: no wall-clock limit.** It remains fully
63
+ supported for anyone who wants a hard ceiling, but it is no longer the
64
+ backstop and no longer imposes a shape on the work. The safety story is the
65
+ watchdog (15 minutes of silence), the server's lease on the run, and the Stop
66
+ button in ProhostAI.
67
+ - **The prompt tells the agent which regime it is in.** Three shapes now: a
68
+ named wall-clock budget, the watchdog alone ("no fixed time limit… what is
69
+ limited is silence"), or neither. The middle one is the default and is the
70
+ point — a silence limit is advice an agent can act on, where a duration is
71
+ just an instruction to hurry.
72
+ - **A killed plain `--exec` command salvages its output too.** Claude Code runs
73
+ already reported the tail of the answer they were writing, parsed out of the
74
+ stream; a plain command now reports the tail of its stdout instead of
75
+ nothing, since for a plain command the stdout *is* the answer. Deliberately
76
+ not extended to the shaped brains: their structured streams carry the raw
77
+ output of every local command the agent ran, and this string is echoed into a
78
+ workspace thread.
79
+ - **Both duration flags reject values a timer cannot hold** — anything over
80
+ 2^31-1 ms (~24.9 days), where `setTimeout` silently fires after 1 ms instead
81
+ and would kill the first run on the spot. `0` remains how both flags spell
82
+ "no limit". A flag typed with no value at all (`--timeout`, or `--timeout -1`,
83
+ which the parser reads as two flags) is likewise an error now rather than
84
+ silently applying the default.
85
+ - **`--idle-timeout` is passed through `install-daemon`** into the launchd
86
+ plist and systemd unit, like `--timeout`, including an explicit `0`.
87
+ - **The `heartbeat` block in `agent.run_requested` is used when present.** A
88
+ server that spells out where heartbeats go is followed verbatim; one that
89
+ doesn't gets the 0.3.0 behaviour unchanged — derive from the completion path,
90
+ falling back to the documented one. No coordinated upgrade either way.
91
+
7
92
  ## 0.3.0
8
93
 
9
94
  Long runs. Every change here comes out of one production failure: a paired
package/README.md CHANGED
@@ -301,16 +301,47 @@ config layer: probing where the harness was launched and then running in
301
301
  `$PROHOST_HOME/workspace` or an explicit `--workdir` would certify one MCP
302
302
  inventory and execute another.
303
303
 
304
- **The override is checked before it is used**, and one case needs it.
305
- `{enabled=false}` merges onto a server's existing definition, and a server
306
- contributed by a **plugin** has no `mcp_servers` entry to merge onto — so the
307
- override leaves a table with no transport and Codex refuses to start at all.
308
- The CLI therefore runs the disable past `codex mcp list` first, falls back to
309
- checking each server individually if the combined form is rejected, and then
310
- verifies the exact final override. If any configured server cannot be disabled,
311
- or if the final listing does not show the ProhostAI server and every accepted
312
- extra enabled, the run fails before Codex starts. Remove that server/plugin or
313
- use Claude Code.
304
+ **The override is checked before it is used.** `{enabled=false}` merges onto a
305
+ server's existing definition, so an entry Codex cannot make sense of takes the
306
+ whole config down rather than disabling anything. The CLI therefore runs the
307
+ disable past `codex mcp list` first, falls back to checking each server
308
+ individually if the combined form is rejected, and then verifies the exact final
309
+ override. If any configured server cannot be disabled, or if the final listing
310
+ does not show the ProhostAI server and every accepted extra enabled, the run
311
+ fails before Codex starts.
312
+
313
+ **Codex plugins get a second mechanism, because `mcp_servers` cannot reach
314
+ them.** A plugin declared in your config — say `[plugins."github@openai-curated"]`
315
+ — contributes an MCP server (`github`) that has *no* `mcp_servers` entry for
316
+ `{enabled=false}` to merge onto, so forcing one leaves a table with no transport
317
+ and Codex rejects the config outright: `invalid transport in mcp_servers.github`.
318
+ Nothing in `codex mcp list --json` marks a server as plugin-provided, so the CLI
319
+ measures instead of guessing: it lists again with plugins off, and the names that
320
+ vanish were the plugin's. Those are shut off for the run with
321
+ `-c features.plugins=false`, verified by re-listing like any other disable.
322
+
323
+ This is applied **only when a plugin actually supplies an MCP server**. Plugins
324
+ also carry skills and hooks, and switching the system off costs you those, so an
325
+ install whose plugins contribute no server keeps everything and gets exactly the
326
+ command line it got before. Your own `prohost` server and your `extra-mcp.json`
327
+ servers are unaffected either way. Per-plugin spellings are deliberately not
328
+ used: `plugins."github@openai-curated".enabled=false` and every variant of it
329
+ load without error and disable nothing at all, which is precisely the kind of
330
+ override the verification step exists to catch.
331
+
332
+ **If a server still cannot be disabled**, the run fails before Codex starts and
333
+ names it. Two ways out: remove that server or plugin from `~/.codex/config.toml`,
334
+ or give the daemon a `CODEX_HOME` of its own that holds only `auth.json` —
335
+
336
+ ```bash
337
+ mkdir -p ~/.prohost/codex-home
338
+ cp ~/.codex/auth.json ~/.prohost/codex-home/auth.json
339
+ CODEX_HOME=~/.prohost/codex-home prohost agent run --exec 'codex exec'
340
+ ```
341
+
342
+ A dedicated `CODEX_HOME` is the cleanest answer in general: the agent inherits
343
+ your login and nothing else, your own Codex setup is untouched, and no future
344
+ plugin or server you install can change what a paired run can reach.
314
345
 
315
346
  If the enumeration fails outright — an older Codex without `mcp list --json`,
316
347
  or output the CLI doesn't recognise — the run fails before Codex starts. A
@@ -400,7 +431,8 @@ same judgement.
400
431
  | Flag | Default | What it does |
401
432
  | --- | --- | --- |
402
433
  | `--exec` | *(required)* | Command to run for each requested run. Prompt arrives on stdin. |
403
- | `--timeout` | `7200` | Seconds one run may take before the command is killed. `0` removes the local cap entirely — see "Long runs". |
434
+ | `--idle-timeout` | `900` | Seconds a run may produce **no output at all** before it is treated as stalled and killed. Any byte on stdout or stderr resets it. `0` disables it — see "Long runs". |
435
+ | `--timeout` | `0` | Seconds one run may take before the command is killed, however busy it is. `0` — the default — means no wall-clock limit. Set it if you want a hard ceiling as well. |
404
436
  | `--dry-run` | off | Print the reply that would be posted; write nothing. |
405
437
  | `--workdir` | `~/.prohost/workspace` | Working directory for the command. See "Where your agent runs". |
406
438
  | `--cwd` | — | Deprecated alias for `--workdir`. |
@@ -415,9 +447,18 @@ A paired brain is often asked to do work, not just answer — write the change,
415
447
  run the tests, come back. That takes minutes or hours, and the harness is built
416
448
  for it:
417
449
 
418
- - **Two hours by default**, and `--timeout 0` removes the local cap entirely.
419
- The timeout is a backstop against a genuinely wedged process, not a shape for
420
- the work.
450
+ - **There is no wall-clock limit by default.** A run ends when the agent ends
451
+ it. `--timeout <seconds>` still imposes a hard ceiling if you want one, but
452
+ the default is `0`, because "this has been going a while" was never evidence
453
+ that anything was wrong — it only ever distinguished long work from short.
454
+ - **A stalled run is killed after 15 minutes of silence** (`--idle-timeout`).
455
+ This is the backstop the wall clock used to be, asking a question a clock
456
+ can't: is the child *doing* anything? Any byte on stdout or stderr resets the
457
+ window, so an agent that is working — streaming events, calling tools,
458
+ logging — never trips it, while one wedged on a stdin prompt is caught even
459
+ though its heartbeats look perfectly healthy. The failed run reports
460
+ `no output for 15 minutes — treated as stalled`, so the person waiting sees
461
+ what happened rather than a silence. `--idle-timeout 0` disables it.
421
462
  - **Heartbeats keep it alive.** Every run this CLI has accepted — the one
422
463
  executing *and* any queued behind it — is reported to ProhostAI once a minute,
423
464
  which is what stops the server reaping a run that is still being worked on.
@@ -190,7 +190,7 @@ export type CommandRunner = (command: string, cwd?: string) => {
190
190
  * nothing to disable, while `null` means we do not know, and a run that cannot
191
191
  * enumerate must say so rather than claim an isolation it did not apply.
192
192
  */
193
- export declare function listMcpServers(binary: string, runner: CommandRunner, override?: string, cwd?: string): CodexMcpServer[] | null;
193
+ export declare function listMcpServers(binary: string, runner: CommandRunner, overrides?: string | string[], cwd?: string): CodexMcpServer[] | null;
194
194
  /** Disable entries proven to work, and the servers that could not be disabled. */
195
195
  export interface VerifiedDisable {
196
196
  entries: string[];
@@ -216,8 +216,81 @@ export interface VerifiedDisable {
216
216
  * `failed` is the honest part. A server that cannot be disabled is still
217
217
  * reachable from the run, and the caller has to say so out loud rather than
218
218
  * report an isolation it only partly achieved.
219
+ *
220
+ * `baseOverrides` are config overrides the run will also carry — in practice
221
+ * {@link PLUGINS_DISABLED_OVERRIDE}. They have to be present on every probe
222
+ * here, because "does this entry disable this server" has a different answer
223
+ * under a different config, and the answer that matters is the one for the
224
+ * config the run actually gets.
219
225
  */
220
- export declare function disableEntriesThatLoad(binary: string, runner: CommandRunner, targets: string[], cwd?: string): VerifiedDisable;
226
+ export declare function disableEntriesThatLoad(binary: string, runner: CommandRunner, targets: string[], cwd?: string, baseOverrides?: string[]): VerifiedDisable;
227
+ /**
228
+ * The one override that switches off Codex's plugin system for a single run.
229
+ *
230
+ * Needed because a Codex **plugin** contributes MCP servers that the
231
+ * `mcp_servers` mechanism cannot touch. `[plugins."github@openai-curated"]` in
232
+ * `~/.codex/config.toml` puts a `github` server in `codex mcp list` that has no
233
+ * `mcp_servers` entry to merge `{enabled=false}` onto, so the override defines
234
+ * a table with no transport and Codex rejects the entire config: `invalid
235
+ * transport in mcp_servers.github`. That is the failure this harness shipped
236
+ * with — fail-closed and correct, but it meant no run at all on any machine
237
+ * with a Codex plugin installed.
238
+ *
239
+ * Measured against `codex-cli 0.146.0`, and the measurements are the reason
240
+ * this is a whole-system switch rather than the per-plugin one it should be:
241
+ *
242
+ * - `plugins."github@openai-curated".enabled=false`, the same spelled as a
243
+ * table, `plugins.github.enabled=false`, `.disabled=true`, `.mcp_servers={}`
244
+ * and a whole-table `plugins={…}` **all load without error and change
245
+ * nothing** — the server is still listed, still enabled. A silent no-op is
246
+ * the worst shape a security override can have, and it is exactly why
247
+ * {@link disableEntriesThatLoad} verifies instead of trusting.
248
+ * - `features.plugins` is a *stable* Codex feature, on by default. Setting it
249
+ * false removes plugin-contributed servers from `codex mcp list --json`
250
+ * **entirely** (they are absent, not `enabled: false`) while leaving classic
251
+ * `mcp_servers` entries and this harness's own `prohost` server untouched.
252
+ * - The effect is real at runtime, not just in the listing: the same run asked
253
+ * for its tool list reported 28 `mcp__codex_apps__*` tools without this
254
+ * override and none with it.
255
+ * - `--ignore-user-config` also works, and is still the wrong tool: it drops
256
+ * the operator's model, profile and sandbox settings too.
257
+ *
258
+ * Applied only when a plugin actually contributes a server (see
259
+ * {@link isolateConfiguredServers}). Plugins carry skills and hooks as well, so
260
+ * an install whose plugins contribute no MCP servers keeps all of that.
261
+ */
262
+ export declare const PLUGINS_DISABLED_OVERRIDE = "features.plugins=false";
263
+ /** A verified isolation: what to pass Codex, and what could not be shut off. */
264
+ export interface VerifiedIsolation {
265
+ /** `mcp_servers` fragments, for the single {@link mcpServersOverride}. */
266
+ entries: string[];
267
+ /** Whole-config `-c` overrides the run needs on top. Usually empty. */
268
+ configOverrides: string[];
269
+ /** Servers still reachable from the run. Empty is the outcome we want. */
270
+ failed: string[];
271
+ }
272
+ /**
273
+ * Shut off every server the operator's install has, whatever supplies it.
274
+ *
275
+ * Two mechanisms, because Codex has two kinds of MCP server and one lever does
276
+ * not move both: `mcp_servers` entries are disabled individually
277
+ * ({@link disableEntriesThatLoad}), plugin-contributed ones only by
278
+ * {@link PLUGINS_DISABLED_OVERRIDE}. Nothing in `codex mcp list --json` says
279
+ * which is which — a plugin's server is reported with the same fields as any
280
+ * other, no source or plugin marker anywhere in the object — so provenance is
281
+ * *measured* rather than read: list again with plugins off, and the names that
282
+ * vanished were the plugin's.
283
+ *
284
+ * The classic path is tried first and, when it succeeds, returned untouched
285
+ * with no extra override. So an install with no plugin-backed servers issues
286
+ * byte-identical commands to the version before plugins were handled at all,
287
+ * and pays exactly one probe as before.
288
+ *
289
+ * Failing to shut a server off is reported, never worked around. A plugin
290
+ * server that survives even `features.plugins=false` is a mechanism we do not
291
+ * understand yet, and a run that cannot prove the boundary must not start.
292
+ */
293
+ export declare function isolateConfiguredServers(binary: string, runner: CommandRunner, targets: string[], cwd?: string): VerifiedIsolation;
221
294
  /**
222
295
  * The `codex` binary out of the host's `--exec` command.
223
296
  *
@@ -267,6 +340,14 @@ export interface CodexFlags {
267
340
  * {@link buildMcpOverride} for what happens when they don't.
268
341
  */
269
342
  mcpEntries?: string[];
343
+ /**
344
+ * Whole-config `-c` overrides that are not `mcp_servers` — in practice
345
+ * {@link PLUGINS_DISABLED_OVERRIDE}, when the operator has a plugin that
346
+ * contributes MCP servers. They precede the `mcp_servers` override for the
347
+ * same reason they do in the verifier: the run has to be handed the same
348
+ * overrides in the same order the probe certified.
349
+ */
350
+ configOverrides?: string[];
270
351
  /**
271
352
  * Run with approvals and the sandbox bypassed. Default ON — see
272
353
  * {@link codexCommand}. Set false by `agent run --safe-tools`.
@@ -485,8 +485,14 @@ export function mcpServersOverride(entries) {
485
485
  * nothing to disable, while `null` means we do not know, and a run that cannot
486
486
  * enumerate must say so rather than claim an isolation it did not apply.
487
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`;
488
+ export function listMcpServers(binary, runner, overrides, cwd) {
489
+ // Several overrides rather than one, because they land in different config
490
+ // tables: `mcp_servers={…}` cannot also carry `features.plugins=false`. Codex
491
+ // applies repeated `-c` in order, and the run must be given this same list in
492
+ // this same order or the probe certifies a config the run does not get.
493
+ const list = overrides === undefined ? [] : typeof overrides === 'string' ? [overrides] : overrides;
494
+ const flags = list.map((value) => ` -c ${shellQuote(value, process.platform)}`).join('');
495
+ const command = `${binary} mcp list --json${flags}`;
490
496
  let result;
491
497
  try {
492
498
  result = runner(command, cwd);
@@ -537,15 +543,21 @@ export function listMcpServers(binary, runner, override, cwd) {
537
543
  * `failed` is the honest part. A server that cannot be disabled is still
538
544
  * reachable from the run, and the caller has to say so out loud rather than
539
545
  * report an isolation it only partly achieved.
546
+ *
547
+ * `baseOverrides` are config overrides the run will also carry — in practice
548
+ * {@link PLUGINS_DISABLED_OVERRIDE}. They have to be present on every probe
549
+ * here, because "does this entry disable this server" has a different answer
550
+ * under a different config, and the answer that matters is the one for the
551
+ * config the run actually gets.
540
552
  */
541
- export function disableEntriesThatLoad(binary, runner, targets, cwd) {
553
+ export function disableEntriesThatLoad(binary, runner, targets, cwd, baseOverrides = []) {
542
554
  if (targets.length === 0)
543
555
  return { entries: [], failed: [] };
544
556
  const disabled = (names) => {
545
557
  const override = mcpServersOverride(disableMcpEntries(names));
546
558
  if (!override)
547
559
  return [];
548
- const listing = listMcpServers(binary, runner, override, cwd);
560
+ const listing = listMcpServers(binary, runner, [...baseOverrides, override], cwd);
549
561
  // A config that does not load disables nothing, and a server the listing
550
562
  // still reports as enabled was not disabled whatever the override said.
551
563
  if (listing === null)
@@ -570,6 +582,91 @@ export function disableEntriesThatLoad(binary, runner, targets, cwd) {
570
582
  failed: targets.filter((name) => !verified.includes(name)),
571
583
  };
572
584
  }
585
+ /**
586
+ * The one override that switches off Codex's plugin system for a single run.
587
+ *
588
+ * Needed because a Codex **plugin** contributes MCP servers that the
589
+ * `mcp_servers` mechanism cannot touch. `[plugins."github@openai-curated"]` in
590
+ * `~/.codex/config.toml` puts a `github` server in `codex mcp list` that has no
591
+ * `mcp_servers` entry to merge `{enabled=false}` onto, so the override defines
592
+ * a table with no transport and Codex rejects the entire config: `invalid
593
+ * transport in mcp_servers.github`. That is the failure this harness shipped
594
+ * with — fail-closed and correct, but it meant no run at all on any machine
595
+ * with a Codex plugin installed.
596
+ *
597
+ * Measured against `codex-cli 0.146.0`, and the measurements are the reason
598
+ * this is a whole-system switch rather than the per-plugin one it should be:
599
+ *
600
+ * - `plugins."github@openai-curated".enabled=false`, the same spelled as a
601
+ * table, `plugins.github.enabled=false`, `.disabled=true`, `.mcp_servers={}`
602
+ * and a whole-table `plugins={…}` **all load without error and change
603
+ * nothing** — the server is still listed, still enabled. A silent no-op is
604
+ * the worst shape a security override can have, and it is exactly why
605
+ * {@link disableEntriesThatLoad} verifies instead of trusting.
606
+ * - `features.plugins` is a *stable* Codex feature, on by default. Setting it
607
+ * false removes plugin-contributed servers from `codex mcp list --json`
608
+ * **entirely** (they are absent, not `enabled: false`) while leaving classic
609
+ * `mcp_servers` entries and this harness's own `prohost` server untouched.
610
+ * - The effect is real at runtime, not just in the listing: the same run asked
611
+ * for its tool list reported 28 `mcp__codex_apps__*` tools without this
612
+ * override and none with it.
613
+ * - `--ignore-user-config` also works, and is still the wrong tool: it drops
614
+ * the operator's model, profile and sandbox settings too.
615
+ *
616
+ * Applied only when a plugin actually contributes a server (see
617
+ * {@link isolateConfiguredServers}). Plugins carry skills and hooks as well, so
618
+ * an install whose plugins contribute no MCP servers keeps all of that.
619
+ */
620
+ export const PLUGINS_DISABLED_OVERRIDE = 'features.plugins=false';
621
+ /**
622
+ * Shut off every server the operator's install has, whatever supplies it.
623
+ *
624
+ * Two mechanisms, because Codex has two kinds of MCP server and one lever does
625
+ * not move both: `mcp_servers` entries are disabled individually
626
+ * ({@link disableEntriesThatLoad}), plugin-contributed ones only by
627
+ * {@link PLUGINS_DISABLED_OVERRIDE}. Nothing in `codex mcp list --json` says
628
+ * which is which — a plugin's server is reported with the same fields as any
629
+ * other, no source or plugin marker anywhere in the object — so provenance is
630
+ * *measured* rather than read: list again with plugins off, and the names that
631
+ * vanished were the plugin's.
632
+ *
633
+ * The classic path is tried first and, when it succeeds, returned untouched
634
+ * with no extra override. So an install with no plugin-backed servers issues
635
+ * byte-identical commands to the version before plugins were handled at all,
636
+ * and pays exactly one probe as before.
637
+ *
638
+ * Failing to shut a server off is reported, never worked around. A plugin
639
+ * server that survives even `features.plugins=false` is a mechanism we do not
640
+ * understand yet, and a run that cannot prove the boundary must not start.
641
+ */
642
+ export function isolateConfiguredServers(binary, runner, targets, cwd) {
643
+ const direct = disableEntriesThatLoad(binary, runner, targets, cwd);
644
+ if (direct.failed.length === 0)
645
+ return { ...direct, configOverrides: [] };
646
+ // Something resisted. Ask whether turning the plugin system off accounts for
647
+ // it, rather than assuming the survivors are plugin-backed because they look
648
+ // like it.
649
+ const withPluginsOff = listMcpServers(binary, runner, PLUGINS_DISABLED_OVERRIDE, cwd);
650
+ // The override does not load, so it disables nothing and we know nothing.
651
+ if (withPluginsOff === null)
652
+ return { ...direct, configOverrides: [] };
653
+ const remaining = new Set(withPluginsOff.map((server) => server.name));
654
+ const removedByPluginsOff = direct.failed.filter((name) => !remaining.has(name));
655
+ // Plugins were not the problem. Report what the classic path found, so the
656
+ // operator is told about the real survivor instead of a plugin red herring.
657
+ if (removedByPluginsOff.length === 0)
658
+ return { ...direct, configOverrides: [] };
659
+ // Re-verify the classic disables *under* the plugins-off override. They were
660
+ // proven against a different config, and a proof about a config the run does
661
+ // not get is not a proof.
662
+ const classic = targets.filter((name) => remaining.has(name));
663
+ const verified = disableEntriesThatLoad(binary, runner, classic, cwd, [PLUGINS_DISABLED_OVERRIDE]);
664
+ return {
665
+ entries: verified.entries,
666
+ configOverrides: [PLUGINS_DISABLED_OVERRIDE],
667
+ failed: verified.failed,
668
+ };
669
+ }
573
670
  /**
574
671
  * The `codex` binary out of the host's `--exec` command.
575
672
  *
@@ -705,6 +802,8 @@ export function codexCommand(base, flags = {}) {
705
802
  if (!gated && !has(words, ...BYPASS_FLAGS)) {
706
803
  parts.push('--yolo');
707
804
  }
805
+ for (const value of flags.configOverrides ?? [])
806
+ parts.push('-c', quote(value));
708
807
  const override = mcpServersOverride(flags.mcpEntries ?? []);
709
808
  if (override)
710
809
  parts.push('-c', quote(override));
@@ -5,6 +5,30 @@
5
5
  * (and testable) as plain async functions with no argv or process concerns.
6
6
  */
7
7
  type Flags = Record<string, string | boolean>;
8
+ /** Returned by {@link parseSeconds} for a value that isn't a duration. */
9
+ export declare const INVALID_DURATION: unique symbol;
10
+ /**
11
+ * Read a duration flag's value. Pure — exported so it can be tested without a
12
+ * process to write errors to.
13
+ *
14
+ * Returns ``undefined`` only when the flag was genuinely absent (the harness
15
+ * then applies its own default) and {@link INVALID_DURATION} for anything that
16
+ * is not a non-negative, finite number of seconds.
17
+ *
18
+ * ``0`` is legal for both flags that use this, and means "no limit of this
19
+ * kind". For `--timeout` it was rejected until long runs existed, because
20
+ * before heartbeats an uncapped run on a laptop that went to sleep was a row
21
+ * that span forever.
22
+ *
23
+ * Takes the raw flag value rather than a string, because "the operator typed
24
+ * this flag and got it wrong" and "the operator did not type this flag" have
25
+ * to be told apart. `--timeout` with nothing after it, and `--timeout -1` —
26
+ * which the argument parser reads as two flags, since a value cannot begin
27
+ * with `-` — both arrive here as `true`. Treated as absent, they would apply
28
+ * the default silently and leave someone believing they had set a limit they
29
+ * had not.
30
+ */
31
+ export declare function parseSeconds(raw: string | boolean | undefined): number | undefined | typeof INVALID_DURATION;
8
32
  /** Dispatch an ``agent`` subcommand. Returns the process exit code. */
9
33
  export declare function runAgent(subcommand: string | undefined, flags: Flags, printHelp: () => void): Promise<number>;
10
34
  export {};
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
  import { agentLogPath, buildSystemdUnit, harnessArguments, installLaunchdDaemon, launchdLabel, uninstallLaunchdDaemon, } from './daemon.js';
13
13
  import { PairError, runPair } from './pair.js';
14
14
  import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome } from './credentials.js';
15
- import { DEFAULT_EXEC_TIMEOUT_MS, runAgentHarness } from './run.js';
15
+ import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
16
16
  function flagString(flags, name) {
17
17
  const value = flags[name];
18
18
  return typeof value === 'string' ? value : undefined;
@@ -28,33 +28,92 @@ function flagString(flags, name) {
28
28
  function requestedWorkdir(flags) {
29
29
  return flagString(flags, 'workdir') ?? flagString(flags, 'cwd');
30
30
  }
31
- /** Returned by {@link parseTimeoutSeconds} for a value that isn't a timeout. */
32
- const INVALID_TIMEOUT = Symbol('invalid-timeout');
31
+ /** Returned by {@link parseSeconds} for a value that isn't a duration. */
32
+ export const INVALID_DURATION = Symbol('invalid-duration');
33
33
  /**
34
- * Read `--timeout`, complaining once for both commands that take it.
34
+ * Longest delay a timer can hold, in milliseconds — 2^31-1, just under 24.9
35
+ * days.
35
36
  *
36
- * Returns ``undefined`` when the flag was absent (the harness applies its own
37
- * default) and {@link INVALID_TIMEOUT} after printing the error.
37
+ * The bound is `setTimeout`'s, not ours: a delay above this overflows the timer
38
+ * and Node **fires it after 1 ms instead**. So `--idle-timeout 2592000` ("give
39
+ * it thirty days") would not merely be ignored, it would kill the very first
40
+ * run on the spot and report it as stalled — the exact opposite of what was
41
+ * asked for, with nothing to explain it. Rejected at the flag rather than
42
+ * clamped, because anyone typing a number this large means "no limit", and both
43
+ * flags already spell that `0`.
38
44
  *
39
- * ``0`` is legal and means "no local cap": the run ends when the agent ends it,
40
- * when somebody cancels it, or when the server stops renewing its lease. It was
41
- * rejected until long runs existed, because before heartbeats an uncapped run
42
- * on a laptop that went to sleep was a row that span forever.
45
+ * Checked against the *converted* value rather than against seconds, because
46
+ * the flags take fractions: `--timeout 2147483.9` is under any whole-second
47
+ * ceiling and still overflows once multiplied out.
43
48
  */
44
- function parseTimeoutSeconds(flags) {
45
- const raw = flagString(flags, 'timeout');
49
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
50
+ /**
51
+ * Read a duration flag's value. Pure — exported so it can be tested without a
52
+ * process to write errors to.
53
+ *
54
+ * Returns ``undefined`` only when the flag was genuinely absent (the harness
55
+ * then applies its own default) and {@link INVALID_DURATION} for anything that
56
+ * is not a non-negative, finite number of seconds.
57
+ *
58
+ * ``0`` is legal for both flags that use this, and means "no limit of this
59
+ * kind". For `--timeout` it was rejected until long runs existed, because
60
+ * before heartbeats an uncapped run on a laptop that went to sleep was a row
61
+ * that span forever.
62
+ *
63
+ * Takes the raw flag value rather than a string, because "the operator typed
64
+ * this flag and got it wrong" and "the operator did not type this flag" have
65
+ * to be told apart. `--timeout` with nothing after it, and `--timeout -1` —
66
+ * which the argument parser reads as two flags, since a value cannot begin
67
+ * with `-` — both arrive here as `true`. Treated as absent, they would apply
68
+ * the default silently and leave someone believing they had set a limit they
69
+ * had not.
70
+ */
71
+ export function parseSeconds(raw) {
46
72
  if (raw === undefined)
47
73
  return undefined;
74
+ // An empty string is the `--timeout ''` case, and `Number('')` is 0 — which
75
+ // would quietly read as "no limit" rather than as the typo it is.
76
+ if (typeof raw !== 'string' || raw.trim() === '')
77
+ return INVALID_DURATION;
48
78
  const seconds = Number(raw);
49
- if (!Number.isFinite(seconds) || seconds < 0) {
79
+ if (!Number.isFinite(seconds) || seconds < 0 || seconds * 1000 > MAX_TIMER_DELAY_MS) {
80
+ return INVALID_DURATION;
81
+ }
82
+ return seconds;
83
+ }
84
+ /**
85
+ * Read `--timeout`, complaining once for both commands that take it.
86
+ *
87
+ * The wall-clock ceiling is opt-in now (`DEFAULT_EXEC_TIMEOUT_MS` is 0), so an
88
+ * absent flag means "no ceiling" rather than "two hours".
89
+ */
90
+ function parseTimeoutSeconds(flags) {
91
+ const parsed = parseSeconds(flags.timeout);
92
+ if (parsed === INVALID_DURATION) {
50
93
  // Keeps the phrase "--timeout must be a positive number" verbatim: the
51
94
  // desktop app's supervisor matches on it to tell a misconfigured child from
52
95
  // a recoverable crash (`desktop/src/main/agent/supervisor-policy.ts`), and
53
96
  // it reads this CLI's stderr rather than any structured output.
54
- process.stderr.write('--timeout must be a positive number of seconds, or 0 for no local limit.\n');
55
- return INVALID_TIMEOUT;
97
+ process.stderr.write('--timeout must be a positive number of seconds under 2147484 (about 24 days), ' +
98
+ 'or 0 for no local limit.\n');
56
99
  }
57
- return seconds;
100
+ return parsed;
101
+ }
102
+ /**
103
+ * Read `--idle-timeout` — how long a run may produce nothing before it is
104
+ * treated as stalled.
105
+ *
106
+ * Validated in both commands for the same reason `--timeout` is: baked into a
107
+ * plist, a bad value makes every launch exit on this check and KeepAlive
108
+ * restart it forever.
109
+ */
110
+ function parseIdleTimeoutSeconds(flags) {
111
+ const parsed = parseSeconds(flags['idle-timeout']);
112
+ if (parsed === INVALID_DURATION) {
113
+ process.stderr.write('--idle-timeout must be a positive number of seconds under 2147484 (about 24 days), ' +
114
+ 'or 0 to disable the stall watchdog.\n');
115
+ }
116
+ return parsed;
58
117
  }
59
118
  /**
60
119
  * Environment the daemon must be given explicitly.
@@ -129,7 +188,10 @@ async function installDaemonCommand(flags) {
129
188
  // plist makes every launch exit on that same validation and KeepAlive restart
130
189
  // it forever.
131
190
  const timeoutSeconds = parseTimeoutSeconds(flags);
132
- if (timeoutSeconds === INVALID_TIMEOUT)
191
+ if (timeoutSeconds === INVALID_DURATION)
192
+ return 1;
193
+ const idleTimeoutSeconds = parseIdleTimeoutSeconds(flags);
194
+ if (idleTimeoutSeconds === INVALID_DURATION)
133
195
  return 1;
134
196
  // Absolute, and created now. A supervisor does not inherit the installing
135
197
  // shell's current directory, so a relative `--workdir .` copied verbatim into
@@ -151,6 +213,7 @@ async function installDaemonCommand(flags) {
151
213
  exec,
152
214
  workdir,
153
215
  timeoutSeconds,
216
+ idleTimeoutSeconds,
154
217
  machine: flagString(flags, 'machine'),
155
218
  url: flagString(flags, 'url'),
156
219
  allowUnverified: flags['allow-unverified'] === true,
@@ -224,7 +287,10 @@ async function runCommand(flags) {
224
287
  if (!credentials)
225
288
  return 1;
226
289
  const parsedTimeout = parseTimeoutSeconds(flags);
227
- if (parsedTimeout === INVALID_TIMEOUT)
290
+ if (parsedTimeout === INVALID_DURATION)
291
+ return 1;
292
+ const parsedIdleTimeout = parseIdleTimeoutSeconds(flags);
293
+ if (parsedIdleTimeout === INVALID_DURATION)
228
294
  return 1;
229
295
  // First signal stops accepting work and lets the in-flight run finish
230
296
  // reporting itself (so the server doesn't wait on a run we abandoned); a
@@ -248,6 +314,7 @@ async function runCommand(flags) {
248
314
  machine: flagString(flags, 'machine'),
249
315
  cwd: requestedWorkdir(flags),
250
316
  timeoutMs: parsedTimeout === undefined ? DEFAULT_EXEC_TIMEOUT_MS : parsedTimeout * 1000,
317
+ idleTimeoutMs: parsedIdleTimeout === undefined ? DEFAULT_IDLE_TIMEOUT_MS : parsedIdleTimeout * 1000,
251
318
  dryRun: flags['dry-run'] === true,
252
319
  allowUnverified: flags['allow-unverified'] === true,
253
320
  // Two spellings for the same opt-out: `--safe-tools` says what you get,