@prohost/cli 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,82 @@ 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.2
8
+
9
+ Isolated Codex runs no longer receive the operator's **apps (connectors)**.
10
+
11
+ - **`codex_apps` tools are kept out of every run.** A Codex "app" is a set of
12
+ MCP tools served under the name `codex_apps`, and it appears in **no**
13
+ `codex mcp list --json` output at any setting: apps are account-level, arrive
14
+ with the ChatGPT login, and are cached under `$CODEX_HOME/cache/codex_apps_tools`
15
+ rather than declared in `mcp_servers`. The isolation contract is that a run
16
+ gets the ProhostAI server and the operator's explicitly-allowed extras and
17
+ nothing else, and this broke it silently — the enumeration the guarantee is
18
+ built on cannot see these tools at all. Every isolated run now carries
19
+ `-c features.apps=false`.
20
+ - **It was not hypothetical, and not limited to plugin installs.** Measured
21
+ against codex-cli 0.146.0: a `CODEX_HOME` whose config declares no plugins
22
+ and one ordinary MCP server — where 0.4.1 disables that server cleanly, finds
23
+ nothing that resists, and therefore applies no whole-config override at all —
24
+ still handed the run 28 `mcp__codex_apps__*` tools. They are not read-only:
25
+ `sites_create_site`, `sites_deploy_site_version`, `sites_add_custom_domain`,
26
+ `sites_update_environment_variables`, `sites_generate_siwc_bypass_token`,
27
+ `plugin_management_update_app_permissions` and
28
+ `plugin_management_uninstall_app` publish sites and rewrite app permissions on
29
+ the operator's own account.
30
+ - **A dedicated `CODEX_HOME` was never a fix for this one.** A directory holding
31
+ only `auth.json` — 0.4.1's own suggested remedy — fetches the account's apps
32
+ on its first run, writes them to its own cache, and serves the full set from
33
+ the second run onwards. The login carries them, so a fresh directory delays
34
+ the exposure by exactly one run. Hence unconditional rather than opt-in.
35
+ - **`features.plugins=false` was the wrong lever, and only ever worked by
36
+ accident.** It closed this on plugin-bearing machines because apps happened to
37
+ arrive through that pipeline there, but it leaves the apps instructions in the
38
+ prompt, costs the operator their plugin skills and hooks, and is never applied
39
+ at all on a classic-only install. `features.apps=false` is the narrow switch:
40
+ it leaves `plugins` on, so skills and hooks survive.
41
+ - **Proven, not assumed.** There is no listing to verify against, so the switch
42
+ is read back out of `codex features list` — the `apps` row must actually read
43
+ `false` under the exact overrides the run will carry. A renamed feature, an
44
+ older Codex with no `features` subcommand, or an override that loads and
45
+ changes nothing all read as *unknown*, and a run that cannot prove the
46
+ boundary fails before Codex starts with a one-line remedy. 0.4.1's plugin
47
+ handling, three-tier probe path and classic `mcp_servers` disabling are
48
+ unchanged and regression-tested.
49
+
50
+ ## 0.4.1
51
+
52
+ Codex runs work again on machines with a Codex **plugin** installed.
53
+
54
+ - **Plugin-provided MCP servers are now isolated instead of blocking the run.**
55
+ A plugin declared in `~/.codex/config.toml` — `[plugins."github@openai-curated"]`
56
+ — contributes an MCP server (`github`) that the per-server `mcp_servers`
57
+ disable cannot touch: it has no `mcp_servers` entry for `{enabled=false}` to
58
+ merge onto, so the override leaves a table with no transport and Codex
59
+ rejects the whole config with `invalid transport in mcp_servers.github`. The
60
+ isolation verifier correctly refused to start a run it could not prove was
61
+ sealed, which meant **every** message to a Codex-backed agent failed with
62
+ `MCP isolation could not be established`. Such servers are now shut off with
63
+ `-c features.plugins=false`, verified by re-listing exactly like every other
64
+ disable.
65
+ - **Only when a plugin actually supplies a server.** An install whose plugins
66
+ contribute no MCP server issues byte-identical commands to 0.4.0 and keeps
67
+ its plugin skills and hooks; the switch is a bigger hammer than the problem
68
+ on those machines. Classic `mcp_servers` entries are still disabled
69
+ individually, and the ProhostAI server and any `extra-mcp.json` servers keep
70
+ working with plugins off.
71
+ - **Nothing is trusted that wasn't verified.** Every per-plugin form that looks
72
+ like it should work — `plugins."github@openai-curated".enabled=false`, the
73
+ same written as a table, `.disabled=true`, a whole-table `plugins={…}` —
74
+ loads without error and changes nothing at all. A silent no-op is the worst
75
+ shape a security override can have, and only the re-list distinguishes it
76
+ from one that took.
77
+ - **The failure message now carries the way out.** When a server still cannot
78
+ be disabled, the error names the remedy — remove it from
79
+ `~/.codex/config.toml`, or run the daemon with a dedicated `CODEX_HOME`
80
+ holding only `auth.json` — rather than leaving the operator with a run that
81
+ will not start and nothing to do about it.
82
+
7
83
  ## 0.4.0
8
84
 
9
85
  The wall clock stops being the safety mechanism. 0.3.0 raised the run ceiling
package/README.md CHANGED
@@ -301,16 +301,83 @@ 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
+ **Your apps (connectors) are switched off for every run, unconditionally.**
333
+ Codex "apps" are the third way tools reach a run, and the only one with no
334
+ inventory to read: an app *is* a set of MCP tools, served under the name
335
+ `codex_apps`, and it appears in **no** `codex mcp list --json` output at any
336
+ setting. Apps are account-level — they come with your ChatGPT login and are
337
+ cached under `$CODEX_HOME/cache/codex_apps_tools`, not declared in
338
+ `mcp_servers`. On the install this was measured against they were 28 tools, and
339
+ they are not read-only: `sites_create_site`, `sites_deploy_site_version`,
340
+ `sites_add_custom_domain`, `sites_update_environment_variables`,
341
+ `sites_generate_siwc_bypass_token`, `plugin_management_update_app_permissions`
342
+ and `plugin_management_uninstall_app` publish sites and rewrite app permissions
343
+ on your account.
344
+
345
+ So this one is not applied on evidence, the way the plugins switch is — there is
346
+ no listing to take evidence from. Every isolated run carries
347
+ `-c features.apps=false`, and the CLI reads the result back out of
348
+ `codex features list` to confirm the `apps` row really flipped. If it cannot
349
+ confirm that, the run fails before Codex starts, for the same reason a server
350
+ that will not disable fails it: an override that loads happily and changes
351
+ nothing is the failure mode this whole path exists to catch.
352
+
353
+ `features.plugins=false` is **not** a substitute. It costs you your plugin
354
+ skills and hooks, it leaves the apps instructions in the prompt, and on an
355
+ install with no plugin-backed server it is never applied at all — which is
356
+ exactly the shape that was quietly receiving these tools before 0.4.2. Turning
357
+ apps off leaves `plugins` on, so plugin skills and hooks survive; what a run
358
+ loses is your connectors, which is the intent. The run executes on behalf of
359
+ whoever mentioned the agent, and your connectors are yours.
360
+
361
+ **If a server still cannot be disabled**, the run fails before Codex starts and
362
+ names it. Two ways out: remove that server or plugin from `~/.codex/config.toml`,
363
+ or give the daemon a `CODEX_HOME` of its own that holds only `auth.json` —
364
+
365
+ ```bash
366
+ mkdir -p ~/.prohost/codex-home
367
+ cp ~/.codex/auth.json ~/.prohost/codex-home/auth.json
368
+ CODEX_HOME=~/.prohost/codex-home prohost agent run --exec 'codex exec'
369
+ ```
370
+
371
+ A dedicated `CODEX_HOME` is the cleanest answer in general: the agent inherits
372
+ your login and nothing else, your own Codex setup is untouched, and no future
373
+ plugin or server you install can change what a paired run can reach.
374
+
375
+ It is not, however, an answer to apps. A `CODEX_HOME` holding only `auth.json`
376
+ fetches your account's apps on its first run, writes them to its own cache, and
377
+ serves the full set from the second run onwards — the login is what carries
378
+ them, so a fresh directory only delays it by one run. That is why the apps
379
+ switch is unconditional rather than something an operator can opt out of by
380
+ tidying their config.
314
381
 
315
382
  If the enumeration fails outright — an older Codex without `mcp list --json`,
316
383
  or output the CLI doesn't recognise — the run fails before Codex starts. A
@@ -190,7 +190,44 @@ 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
+ /**
195
+ * The Codex feature that supplies the `codex_apps` MCP.
196
+ *
197
+ * Its own prompt text is the definition, and it is worth quoting because
198
+ * nothing else in the CLI says it: *"An app is equivalent to a set of MCP tools
199
+ * within the `codex_apps` MCP."* Apps are the operator's **connectors** —
200
+ * account-level, not config-level.
201
+ */
202
+ export declare const APPS_FEATURE = "apps";
203
+ /**
204
+ * Read one feature's *effective* state, under the overrides the run will carry.
205
+ *
206
+ * This is {@link listMcpServers}'s counterpart for the thing that listing cannot
207
+ * see, and it exists because `codex_apps` appears in **no** MCP inventory:
208
+ * `codex mcp list --json` reports the operator's servers and says nothing about
209
+ * apps, so the entire 0.4.1 verification path is blind to 28 tools the run
210
+ * actually gets.
211
+ *
212
+ * `codex features list` answers it — it prints `<name> <stage> <state>` per
213
+ * feature and honours `-c`, so asking it under the run's own overrides turns
214
+ * "did the switch take" into a measurement rather than an assumption. Measured
215
+ * at ~0.14s with no network, so it is cheap enough to run every time.
216
+ *
217
+ * Only the one requested row is read, deliberately unlike `listMcpServers`,
218
+ * which fails the whole answer on a single malformed entry. There the *set* is
219
+ * the boundary, so an unparsed member is a hole; here the question is about one
220
+ * named key, and refusing to answer because some unrelated future row gained a
221
+ * column would break every run to protect nothing. The last whitespace-separated
222
+ * token is the state — the stage column is sometimes two words (`under
223
+ * development`), so the row is read from both ends rather than by field index.
224
+ *
225
+ * Returns `null` when the answer is unknown: the command failed (a malformed
226
+ * override makes `features list` exit 1 and print nothing), the feature has no
227
+ * row, or its state is not a boolean. A renamed or retired feature therefore
228
+ * reads as unknown rather than as "off", and the caller fails the run closed.
229
+ */
230
+ export declare function featureState(binary: string, runner: CommandRunner, feature: string, overrides?: string | string[], cwd?: string): boolean | null;
194
231
  /** Disable entries proven to work, and the servers that could not be disabled. */
195
232
  export interface VerifiedDisable {
196
233
  entries: string[];
@@ -216,8 +253,135 @@ export interface VerifiedDisable {
216
253
  * `failed` is the honest part. A server that cannot be disabled is still
217
254
  * reachable from the run, and the caller has to say so out loud rather than
218
255
  * report an isolation it only partly achieved.
256
+ *
257
+ * `baseOverrides` are config overrides the run will also carry —
258
+ * {@link APPS_DISABLED_OVERRIDE} always, and {@link PLUGINS_DISABLED_OVERRIDE}
259
+ * when a plugin supplies a server. They have to be present on every probe here,
260
+ * because "does this entry disable this server" has a different answer under a
261
+ * different config, and the answer that matters is the one for the config the
262
+ * run actually gets.
219
263
  */
220
- export declare function disableEntriesThatLoad(binary: string, runner: CommandRunner, targets: string[], cwd?: string): VerifiedDisable;
264
+ export declare function disableEntriesThatLoad(binary: string, runner: CommandRunner, targets: string[], cwd?: string, baseOverrides?: string[]): VerifiedDisable;
265
+ /**
266
+ * The one override that switches off Codex's plugin system for a single run.
267
+ *
268
+ * Needed because a Codex **plugin** contributes MCP servers that the
269
+ * `mcp_servers` mechanism cannot touch. `[plugins."github@openai-curated"]` in
270
+ * `~/.codex/config.toml` puts a `github` server in `codex mcp list` that has no
271
+ * `mcp_servers` entry to merge `{enabled=false}` onto, so the override defines
272
+ * a table with no transport and Codex rejects the entire config: `invalid
273
+ * transport in mcp_servers.github`. That is the failure this harness shipped
274
+ * with — fail-closed and correct, but it meant no run at all on any machine
275
+ * with a Codex plugin installed.
276
+ *
277
+ * Measured against `codex-cli 0.146.0`, and the measurements are the reason
278
+ * this is a whole-system switch rather than the per-plugin one it should be:
279
+ *
280
+ * - `plugins."github@openai-curated".enabled=false`, the same spelled as a
281
+ * table, `plugins.github.enabled=false`, `.disabled=true`, `.mcp_servers={}`
282
+ * and a whole-table `plugins={…}` **all load without error and change
283
+ * nothing** — the server is still listed, still enabled. A silent no-op is
284
+ * the worst shape a security override can have, and it is exactly why
285
+ * {@link disableEntriesThatLoad} verifies instead of trusting.
286
+ * - `features.plugins` is a *stable* Codex feature, on by default. Setting it
287
+ * false removes plugin-contributed servers from `codex mcp list --json`
288
+ * **entirely** (they are absent, not `enabled: false`) while leaving classic
289
+ * `mcp_servers` entries and this harness's own `prohost` server untouched.
290
+ * - The effect is real at runtime, not just in the listing: the same run asked
291
+ * for its tool list reported 28 `mcp__codex_apps__*` tools without this
292
+ * override and none with it.
293
+ * - `--ignore-user-config` also works, and is still the wrong tool: it drops
294
+ * the operator's model, profile and sandbox settings too.
295
+ *
296
+ * Applied only when a plugin actually contributes a server (see
297
+ * {@link isolateConfiguredServers}). Plugins carry skills and hooks as well, so
298
+ * an install whose plugins contribute no MCP servers keeps all of that.
299
+ */
300
+ export declare const PLUGINS_DISABLED_OVERRIDE = "features.plugins=false";
301
+ /**
302
+ * The one override that keeps the operator's **apps (connectors)** out of a run.
303
+ *
304
+ * Applied to every isolated run, unconditionally, which is the whole point of
305
+ * it. Measured against `codex-cli 0.146.0`:
306
+ *
307
+ * - `codex_apps` is the {@link APPS_FEATURE} surface, and `apps` is a *stable*
308
+ * Codex feature that is **on by default**. Turning it off removes the
309
+ * `<apps_instructions>` block from the prompt and all 28 `mcp__codex_apps__*`
310
+ * tools from the run, while leaving `plugins` — and so the operator's plugin
311
+ * skills and hooks — switched on.
312
+ * - Those tools are not read-only. They include `sites_create_site`,
313
+ * `sites_deploy_site_version`, `sites_add_custom_domain`,
314
+ * `sites_update_environment_variables`, `sites_generate_siwc_bypass_token`,
315
+ * `plugin_management_update_app_permissions` and
316
+ * `plugin_management_uninstall_app`: publishing sites and rewriting app
317
+ * permissions on the operator's own ChatGPT account.
318
+ * - **Nothing in `codex mcp list --json` mentions any of it.** Apps are
319
+ * account-level and cached under `$CODEX_HOME/cache/codex_apps_tools`, not
320
+ * declared in `mcp_servers`, so the entire server-enumeration path this module
321
+ * is built on cannot see them. That is why this is unconditional rather than
322
+ * applied on evidence, as {@link PLUGINS_DISABLED_OVERRIDE} is: there is no
323
+ * listing to take evidence from.
324
+ * - It is not enough to have no plugins and no configured servers. A
325
+ * `CODEX_HOME` holding **only `auth.json`** — 0.4.1's own suggested remedy —
326
+ * fetches the account's apps on its first run, writes them to that cache, and
327
+ * serves all 28 tools from the second run onwards. Measured, not reasoned.
328
+ *
329
+ * `features.plugins=false` also happens to remove the tools on installs where
330
+ * apps arrive through the plugin pipeline, which is why 0.4.1 closed this by
331
+ * accident on plugin-bearing machines. It is the wrong lever regardless: it
332
+ * leaves `<apps_instructions>` in the prompt, it costs the operator their plugin
333
+ * skills and hooks, and it does nothing at all on a classic-only install —
334
+ * exactly the shape where 0.4.1 applies no override.
335
+ */
336
+ export declare const APPS_DISABLED_OVERRIDE = "features.apps=false";
337
+ /** A verified isolation: what to pass Codex, and what could not be shut off. */
338
+ export interface VerifiedIsolation {
339
+ /** `mcp_servers` fragments, for the single {@link mcpServersOverride}. */
340
+ entries: string[];
341
+ /** Whole-config `-c` overrides the run needs on top. Never empty. */
342
+ configOverrides: string[];
343
+ /** Servers still reachable from the run. Empty is the outcome we want. */
344
+ failed: string[];
345
+ /**
346
+ * Why a whole-config switch could not be *proven* to have taken effect.
347
+ *
348
+ * Separate from {@link failed}, which names MCP servers and earns a remedy
349
+ * about `mcp_servers`. This one is not a server and has no such remedy, so it
350
+ * carries its own sentence. Set means the run must not start.
351
+ */
352
+ unproven?: string;
353
+ }
354
+ /**
355
+ * Shut off every server the operator's install has, whatever supplies it.
356
+ *
357
+ * Two mechanisms, because Codex has two kinds of MCP server and one lever does
358
+ * not move both: `mcp_servers` entries are disabled individually
359
+ * ({@link disableEntriesThatLoad}), plugin-contributed ones only by
360
+ * {@link PLUGINS_DISABLED_OVERRIDE}. Nothing in `codex mcp list --json` says
361
+ * which is which — a plugin's server is reported with the same fields as any
362
+ * other, no source or plugin marker anywhere in the object — so provenance is
363
+ * *measured* rather than read: list again with plugins off, and the names that
364
+ * vanished were the plugin's.
365
+ *
366
+ * A **third** mechanism sits underneath both and is not a server at all: the
367
+ * operator's apps (connectors), which reach the run as the `codex_apps` MCP and
368
+ * appear in no listing this module can read. {@link APPS_DISABLED_OVERRIDE} is
369
+ * therefore applied to every run unconditionally — there is no inventory to
370
+ * decide from — and every probe below carries it, so each disable is proven
371
+ * against the config the run is actually handed.
372
+ *
373
+ * The classic path is still tried first and, when it succeeds, returned with no
374
+ * *plugins* override, so an install with no plugin-backed servers keeps its
375
+ * plugin skills and hooks and pays the same one probe it did before.
376
+ *
377
+ * Failing to shut a server off is reported, never worked around. A plugin
378
+ * server that survives even `features.plugins=false` is a mechanism we do not
379
+ * understand yet, and a run that cannot prove the boundary must not start. The
380
+ * apps switch is held to the same standard: it is *measured* off through
381
+ * {@link featureState} under the final override set, because an override that
382
+ * silently does nothing is the failure mode this whole module exists to catch.
383
+ */
384
+ export declare function isolateConfiguredServers(binary: string, runner: CommandRunner, targets: string[], cwd?: string): VerifiedIsolation;
221
385
  /**
222
386
  * The `codex` binary out of the host's `--exec` command.
223
387
  *
@@ -267,6 +431,15 @@ export interface CodexFlags {
267
431
  * {@link buildMcpOverride} for what happens when they don't.
268
432
  */
269
433
  mcpEntries?: string[];
434
+ /**
435
+ * Whole-config `-c` overrides that are not `mcp_servers`:
436
+ * {@link APPS_DISABLED_OVERRIDE} on every isolated run, plus
437
+ * {@link PLUGINS_DISABLED_OVERRIDE} when the operator has a plugin that
438
+ * contributes MCP servers. They precede the `mcp_servers` override for the
439
+ * same reason they do in the verifier: the run has to be handed the same
440
+ * overrides in the same order the probe certified.
441
+ */
442
+ configOverrides?: string[];
270
443
  /**
271
444
  * Run with approvals and the sandbox bypassed. Default ON — see
272
445
  * {@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);
@@ -518,6 +524,67 @@ export function listMcpServers(binary, runner, override, cwd) {
518
524
  return null;
519
525
  }
520
526
  }
527
+ /**
528
+ * The Codex feature that supplies the `codex_apps` MCP.
529
+ *
530
+ * Its own prompt text is the definition, and it is worth quoting because
531
+ * nothing else in the CLI says it: *"An app is equivalent to a set of MCP tools
532
+ * within the `codex_apps` MCP."* Apps are the operator's **connectors** —
533
+ * account-level, not config-level.
534
+ */
535
+ export const APPS_FEATURE = 'apps';
536
+ /**
537
+ * Read one feature's *effective* state, under the overrides the run will carry.
538
+ *
539
+ * This is {@link listMcpServers}'s counterpart for the thing that listing cannot
540
+ * see, and it exists because `codex_apps` appears in **no** MCP inventory:
541
+ * `codex mcp list --json` reports the operator's servers and says nothing about
542
+ * apps, so the entire 0.4.1 verification path is blind to 28 tools the run
543
+ * actually gets.
544
+ *
545
+ * `codex features list` answers it — it prints `<name> <stage> <state>` per
546
+ * feature and honours `-c`, so asking it under the run's own overrides turns
547
+ * "did the switch take" into a measurement rather than an assumption. Measured
548
+ * at ~0.14s with no network, so it is cheap enough to run every time.
549
+ *
550
+ * Only the one requested row is read, deliberately unlike `listMcpServers`,
551
+ * which fails the whole answer on a single malformed entry. There the *set* is
552
+ * the boundary, so an unparsed member is a hole; here the question is about one
553
+ * named key, and refusing to answer because some unrelated future row gained a
554
+ * column would break every run to protect nothing. The last whitespace-separated
555
+ * token is the state — the stage column is sometimes two words (`under
556
+ * development`), so the row is read from both ends rather than by field index.
557
+ *
558
+ * Returns `null` when the answer is unknown: the command failed (a malformed
559
+ * override makes `features list` exit 1 and print nothing), the feature has no
560
+ * row, or its state is not a boolean. A renamed or retired feature therefore
561
+ * reads as unknown rather than as "off", and the caller fails the run closed.
562
+ */
563
+ export function featureState(binary, runner, feature, overrides, cwd) {
564
+ const list = overrides === undefined ? [] : typeof overrides === 'string' ? [overrides] : overrides;
565
+ const flags = list.map((value) => ` -c ${shellQuote(value, process.platform)}`).join('');
566
+ let result;
567
+ try {
568
+ result = runner(`${binary} features list${flags}`, cwd);
569
+ }
570
+ catch {
571
+ return null;
572
+ }
573
+ if (result.status !== 0)
574
+ return null;
575
+ for (const line of result.stdout.split('\n')) {
576
+ const words = line.trim().split(/\s+/);
577
+ if (words.length < 2 || words[0] !== feature)
578
+ continue;
579
+ const state = words[words.length - 1];
580
+ if (state === 'true')
581
+ return true;
582
+ if (state === 'false')
583
+ return false;
584
+ return null;
585
+ }
586
+ return null;
587
+ }
521
588
  /**
522
589
  * Disable entries, checked against the binary before they are used.
523
590
  *
@@ -537,15 +604,22 @@ export function listMcpServers(binary, runner, override, cwd) {
537
604
  * `failed` is the honest part. A server that cannot be disabled is still
538
605
  * reachable from the run, and the caller has to say so out loud rather than
539
606
  * report an isolation it only partly achieved.
607
+ *
608
+ * `baseOverrides` are config overrides the run will also carry —
609
+ * {@link APPS_DISABLED_OVERRIDE} always, and {@link PLUGINS_DISABLED_OVERRIDE}
610
+ * when a plugin supplies a server. They have to be present on every probe here,
611
+ * because "does this entry disable this server" has a different answer under a
612
+ * different config, and the answer that matters is the one for the config the
613
+ * run actually gets.
540
614
  */
541
- export function disableEntriesThatLoad(binary, runner, targets, cwd) {
615
+ export function disableEntriesThatLoad(binary, runner, targets, cwd, baseOverrides = []) {
542
616
  if (targets.length === 0)
543
617
  return { entries: [], failed: [] };
544
618
  const disabled = (names) => {
545
619
  const override = mcpServersOverride(disableMcpEntries(names));
546
620
  if (!override)
547
621
  return [];
548
- const listing = listMcpServers(binary, runner, override, cwd);
622
+ const listing = listMcpServers(binary, runner, [...baseOverrides, override], cwd);
549
623
  // A config that does not load disables nothing, and a server the listing
550
624
  // still reports as enabled was not disabled whatever the override said.
551
625
  if (listing === null)
@@ -570,6 +644,149 @@ export function disableEntriesThatLoad(binary, runner, targets, cwd) {
570
644
  failed: targets.filter((name) => !verified.includes(name)),
571
645
  };
572
646
  }
647
+ /**
648
+ * The one override that switches off Codex's plugin system for a single run.
649
+ *
650
+ * Needed because a Codex **plugin** contributes MCP servers that the
651
+ * `mcp_servers` mechanism cannot touch. `[plugins."github@openai-curated"]` in
652
+ * `~/.codex/config.toml` puts a `github` server in `codex mcp list` that has no
653
+ * `mcp_servers` entry to merge `{enabled=false}` onto, so the override defines
654
+ * a table with no transport and Codex rejects the entire config: `invalid
655
+ * transport in mcp_servers.github`. That is the failure this harness shipped
656
+ * with — fail-closed and correct, but it meant no run at all on any machine
657
+ * with a Codex plugin installed.
658
+ *
659
+ * Measured against `codex-cli 0.146.0`, and the measurements are the reason
660
+ * this is a whole-system switch rather than the per-plugin one it should be:
661
+ *
662
+ * - `plugins."github@openai-curated".enabled=false`, the same spelled as a
663
+ * table, `plugins.github.enabled=false`, `.disabled=true`, `.mcp_servers={}`
664
+ * and a whole-table `plugins={…}` **all load without error and change
665
+ * nothing** — the server is still listed, still enabled. A silent no-op is
666
+ * the worst shape a security override can have, and it is exactly why
667
+ * {@link disableEntriesThatLoad} verifies instead of trusting.
668
+ * - `features.plugins` is a *stable* Codex feature, on by default. Setting it
669
+ * false removes plugin-contributed servers from `codex mcp list --json`
670
+ * **entirely** (they are absent, not `enabled: false`) while leaving classic
671
+ * `mcp_servers` entries and this harness's own `prohost` server untouched.
672
+ * - The effect is real at runtime, not just in the listing: the same run asked
673
+ * for its tool list reported 28 `mcp__codex_apps__*` tools without this
674
+ * override and none with it.
675
+ * - `--ignore-user-config` also works, and is still the wrong tool: it drops
676
+ * the operator's model, profile and sandbox settings too.
677
+ *
678
+ * Applied only when a plugin actually contributes a server (see
679
+ * {@link isolateConfiguredServers}). Plugins carry skills and hooks as well, so
680
+ * an install whose plugins contribute no MCP servers keeps all of that.
681
+ */
682
+ export const PLUGINS_DISABLED_OVERRIDE = 'features.plugins=false';
683
+ /**
684
+ * The one override that keeps the operator's **apps (connectors)** out of a run.
685
+ *
686
+ * Applied to every isolated run, unconditionally, which is the whole point of
687
+ * it. Measured against `codex-cli 0.146.0`:
688
+ *
689
+ * - `codex_apps` is the {@link APPS_FEATURE} surface, and `apps` is a *stable*
690
+ * Codex feature that is **on by default**. Turning it off removes the
691
+ * `<apps_instructions>` block from the prompt and all 28 `mcp__codex_apps__*`
692
+ * tools from the run, while leaving `plugins` — and so the operator's plugin
693
+ * skills and hooks — switched on.
694
+ * - Those tools are not read-only. They include `sites_create_site`,
695
+ * `sites_deploy_site_version`, `sites_add_custom_domain`,
696
+ * `sites_update_environment_variables`, `sites_generate_siwc_bypass_token`,
697
+ * `plugin_management_update_app_permissions` and
698
+ * `plugin_management_uninstall_app`: publishing sites and rewriting app
699
+ * permissions on the operator's own ChatGPT account.
700
+ * - **Nothing in `codex mcp list --json` mentions any of it.** Apps are
701
+ * account-level and cached under `$CODEX_HOME/cache/codex_apps_tools`, not
702
+ * declared in `mcp_servers`, so the entire server-enumeration path this module
703
+ * is built on cannot see them. That is why this is unconditional rather than
704
+ * applied on evidence, as {@link PLUGINS_DISABLED_OVERRIDE} is: there is no
705
+ * listing to take evidence from.
706
+ * - It is not enough to have no plugins and no configured servers. A
707
+ * `CODEX_HOME` holding **only `auth.json`** — 0.4.1's own suggested remedy —
708
+ * fetches the account's apps on its first run, writes them to that cache, and
709
+ * serves all 28 tools from the second run onwards. Measured, not reasoned.
710
+ *
711
+ * `features.plugins=false` also happens to remove the tools on installs where
712
+ * apps arrive through the plugin pipeline, which is why 0.4.1 closed this by
713
+ * accident on plugin-bearing machines. It is the wrong lever regardless: it
714
+ * leaves `<apps_instructions>` in the prompt, it costs the operator their plugin
715
+ * skills and hooks, and it does nothing at all on a classic-only install —
716
+ * exactly the shape where 0.4.1 applies no override.
717
+ */
718
+ export const APPS_DISABLED_OVERRIDE = `features.${APPS_FEATURE}=false`;
719
+ /**
720
+ * Shut off every server the operator's install has, whatever supplies it.
721
+ *
722
+ * Two mechanisms, because Codex has two kinds of MCP server and one lever does
723
+ * not move both: `mcp_servers` entries are disabled individually
724
+ * ({@link disableEntriesThatLoad}), plugin-contributed ones only by
725
+ * {@link PLUGINS_DISABLED_OVERRIDE}. Nothing in `codex mcp list --json` says
726
+ * which is which — a plugin's server is reported with the same fields as any
727
+ * other, no source or plugin marker anywhere in the object — so provenance is
728
+ * *measured* rather than read: list again with plugins off, and the names that
729
+ * vanished were the plugin's.
730
+ *
731
+ * A **third** mechanism sits underneath both and is not a server at all: the
732
+ * operator's apps (connectors), which reach the run as the `codex_apps` MCP and
733
+ * appear in no listing this module can read. {@link APPS_DISABLED_OVERRIDE} is
734
+ * therefore applied to every run unconditionally — there is no inventory to
735
+ * decide from — and every probe below carries it, so each disable is proven
736
+ * against the config the run is actually handed.
737
+ *
738
+ * The classic path is still tried first and, when it succeeds, returned with no
739
+ * *plugins* override, so an install with no plugin-backed servers keeps its
740
+ * plugin skills and hooks and pays the same one probe it did before.
741
+ *
742
+ * Failing to shut a server off is reported, never worked around. A plugin
743
+ * server that survives even `features.plugins=false` is a mechanism we do not
744
+ * understand yet, and a run that cannot prove the boundary must not start. The
745
+ * apps switch is held to the same standard: it is *measured* off through
746
+ * {@link featureState} under the final override set, because an override that
747
+ * silently does nothing is the failure mode this whole module exists to catch.
748
+ */
749
+ export function isolateConfiguredServers(binary, runner, targets, cwd) {
750
+ // Present on every probe from here down, and on the run itself.
751
+ const base = [APPS_DISABLED_OVERRIDE];
752
+ const prove = (isolation) => {
753
+ if (featureState(binary, runner, APPS_FEATURE, isolation.configOverrides, cwd) === false) {
754
+ return isolation;
755
+ }
756
+ return {
757
+ ...isolation,
758
+ unproven: `Codex would not confirm its "${APPS_FEATURE}" feature is off, so the operator's apps ` +
759
+ '(connectors) and their tools cannot be kept out of this run',
760
+ };
761
+ };
762
+ const direct = disableEntriesThatLoad(binary, runner, targets, cwd, base);
763
+ if (direct.failed.length === 0)
764
+ return prove({ ...direct, configOverrides: base });
765
+ // Something resisted. Ask whether turning the plugin system off accounts for
766
+ // it, rather than assuming the survivors are plugin-backed because they look
767
+ // like it.
768
+ const withPluginsOff = listMcpServers(binary, runner, [...base, PLUGINS_DISABLED_OVERRIDE], cwd);
769
+ // The override does not load, so it disables nothing and we know nothing.
770
+ if (withPluginsOff === null)
771
+ return prove({ ...direct, configOverrides: base });
772
+ const remaining = new Set(withPluginsOff.map((server) => server.name));
773
+ const removedByPluginsOff = direct.failed.filter((name) => !remaining.has(name));
774
+ // Plugins were not the problem. Report what the classic path found, so the
775
+ // operator is told about the real survivor instead of a plugin red herring.
776
+ if (removedByPluginsOff.length === 0)
777
+ return prove({ ...direct, configOverrides: base });
778
+ // Re-verify the classic disables *under* the plugins-off override. They were
779
+ // proven against a different config, and a proof about a config the run does
780
+ // not get is not a proof.
781
+ const classic = targets.filter((name) => remaining.has(name));
782
+ const configOverrides = [...base, PLUGINS_DISABLED_OVERRIDE];
783
+ const verified = disableEntriesThatLoad(binary, runner, classic, cwd, configOverrides);
784
+ return prove({
785
+ entries: verified.entries,
786
+ configOverrides,
787
+ failed: verified.failed,
788
+ });
789
+ }
573
790
  /**
574
791
  * The `codex` binary out of the host's `--exec` command.
575
792
  *
@@ -705,6 +922,8 @@ export function codexCommand(base, flags = {}) {
705
922
  if (!gated && !has(words, ...BYPASS_FLAGS)) {
706
923
  parts.push('--yolo');
707
924
  }
925
+ for (const value of flags.configOverrides ?? [])
926
+ parts.push('-c', quote(value));
708
927
  const override = mcpServersOverride(flags.mcpEntries ?? []);
709
928
  if (override)
710
929
  parts.push('-c', quote(override));
@@ -146,6 +146,8 @@ interface PreparedMcp {
146
146
  mcpConfigPath?: string;
147
147
  /** Codex: entries in the single verified `mcp_servers` override. */
148
148
  codexMcpEntries?: string[];
149
+ /** Codex: verified non-`mcp_servers` overrides, e.g. plugins off. */
150
+ codexConfigOverrides?: string[];
149
151
  /** Credentials moved out of Codex's process argv. */
150
152
  execEnv?: NodeJS.ProcessEnv;
151
153
  /** ProhostAI tools are actually reachable from this invocation. */
package/dist/agent/run.js CHANGED
@@ -27,7 +27,7 @@ import { spawnSync } from 'node:child_process';
27
27
  import WebSocket from 'ws';
28
28
  import { completeRun, postReply } from './api.js';
29
29
  import { MAX_SALVAGE_CHARS, claudeCommand, isClaudeExec, looksLikeResumeFailure, readClaudeStream, writeMcpConfig, } from './claude.js';
30
- import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, disableEntriesThatLoad, extraMcpEntries, isCodexExec, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
30
+ import { APPS_FEATURE, MCP_API_KEY_ENV_VAR, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
31
31
  import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
32
32
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
33
33
  import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, RUN_ERROR_CANCELLED_BY_USER, eventData, heartbeatPathFor, parseRunRequest, } from './contract.js';
@@ -329,19 +329,41 @@ function codexMcpEntries(options, extraServers, log, workdir) {
329
329
  if (acceptedExtraEntries.length === 0)
330
330
  acceptedExtraNames.clear();
331
331
  const targets = [...configuredNames].filter((name) => !acceptedExtraNames.has(name));
332
- const { entries: disabled, failed } = disableEntriesThatLoad(binary, probe, targets, workdir);
332
+ const { entries: disabled, configOverrides, failed, unproven } = isolateConfiguredServers(binary, probe, targets, workdir);
333
+ if (unproven) {
334
+ // Not an `mcp_servers` problem, so it does not get the `mcp_servers`
335
+ // remedy. Upgrading Codex is the honest advice: the switch is read back
336
+ // from `codex features list`, so this fires when that command, the feature,
337
+ // or its name is not what this harness was measured against.
338
+ return fail(`${unproven} — upgrade Codex, or run the daemon with a Codex build that reports ` +
339
+ `"${APPS_FEATURE}" in \`codex features list\``);
340
+ }
333
341
  if (failed.length > 0) {
334
- return fail(`these Codex MCP servers could not be disabled: ${failed.join(', ')}`);
342
+ // The remedy belongs in the message. This string is echoed into the thread
343
+ // the teammate is waiting in, and "could not be disabled" on its own leaves
344
+ // them with a run that will not start and nothing to do about it.
345
+ return fail(`these Codex MCP servers could not be disabled: ${failed.join(', ')} — remove them from ` +
346
+ '~/.codex/config.toml, or run the daemon with a dedicated CODEX_HOME containing only auth.json');
335
347
  }
336
348
  entries.push(...disabled);
337
- if (disabled.length > 0) {
349
+ if (targets.length > 0) {
338
350
  log(`${timestamp()} ⚙ your own Codex MCP servers are disabled for agent runs: ${targets.join(', ')}`);
339
351
  }
352
+ // Unconditional, so it is stated every run rather than only when something
353
+ // was found: the operator's connectors are account-level and invisible to
354
+ // `codex mcp list`, and "nothing was listed" is exactly the wrong thing for
355
+ // them to conclude from silence here.
356
+ log(`${timestamp()} ⚙ Codex apps (connectors) are switched off for agent runs`);
357
+ if (configOverrides.includes(PLUGINS_DISABLED_OVERRIDE)) {
358
+ log(`${timestamp()} ⚙ Codex plugins are switched off for agent runs — a plugin supplies an MCP ` +
359
+ 'server that cannot be disabled on its own');
360
+ }
340
361
  // Verify the exact combined override, not its pieces. This catches a config
341
362
  // merge that re-enables a server and a server installed between the initial
342
363
  // survey and this final probe.
343
364
  const override = mcpServersOverride(entries);
344
- const finalListing = listMcpServers(binary, probe, override, workdir);
365
+ const runOverrides = [...configOverrides, ...(override ? [override] : [])];
366
+ const finalListing = listMcpServers(binary, probe, runOverrides, workdir);
345
367
  if (finalListing === null)
346
368
  return fail('Codex rejected the final isolated MCP configuration');
347
369
  const allowed = new Set([
@@ -359,6 +381,7 @@ function codexMcpEntries(options, extraServers, log, workdir) {
359
381
  }
360
382
  return {
361
383
  codexMcpEntries: entries,
384
+ codexConfigOverrides: configOverrides.length > 0 ? configOverrides : undefined,
362
385
  execEnv: Object.keys(execEnv).length > 0 ? execEnv : undefined,
363
386
  mcpWired: Boolean(options.credentials.mcp_url),
364
387
  };
@@ -573,6 +596,7 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log) {
573
596
  ? codexCommand(options.exec, {
574
597
  sessionId: resume,
575
598
  mcpEntries: mcp.codexMcpEntries,
599
+ configOverrides: mcp.codexConfigOverrides,
576
600
  skipPermissions: runtime.skipPermissions,
577
601
  })
578
602
  : claudeCommand(options.exec, {
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.4.0";
10
+ export declare const CLI_VERSION = "0.4.2";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.4.0";
12
+ export declare const USER_AGENT = "prohost-cli/0.4.2";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.4.0';
10
+ export const CLI_VERSION = '0.4.2';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",