@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 +85 -0
- package/README.md +55 -14
- package/dist/agent/codex.d.ts +83 -2
- package/dist/agent/codex.js +103 -4
- package/dist/agent/command.d.ts +24 -0
- package/dist/agent/command.js +85 -18
- package/dist/agent/contract.d.ts +20 -7
- package/dist/agent/contract.js +18 -7
- package/dist/agent/daemon.d.ts +2 -0
- package/dist/agent/daemon.js +6 -0
- package/dist/agent/exec.d.ts +35 -0
- package/dist/agent/exec.js +100 -3
- package/dist/agent/prompt.d.ts +11 -0
- package/dist/agent/prompt.js +56 -19
- package/dist/agent/run.d.ts +39 -11
- package/dist/agent/run.js +116 -25
- package/dist/index.js +9 -5
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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` | `
|
|
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
|
-
- **
|
|
419
|
-
|
|
420
|
-
the
|
|
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.
|
package/dist/agent/codex.d.ts
CHANGED
|
@@ -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,
|
|
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`.
|
package/dist/agent/codex.js
CHANGED
|
@@ -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,
|
|
489
|
-
|
|
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));
|
package/dist/agent/command.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/agent/command.js
CHANGED
|
@@ -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
|
|
32
|
-
const
|
|
31
|
+
/** Returned by {@link parseSeconds} for a value that isn't a duration. */
|
|
32
|
+
export const INVALID_DURATION = Symbol('invalid-duration');
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* Longest delay a timer can hold, in milliseconds — 2^31-1, just under 24.9
|
|
35
|
+
* days.
|
|
35
36
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
55
|
-
|
|
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
|
|
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 ===
|
|
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 ===
|
|
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,
|