@prohost/cli 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +116 -0
- package/README.md +46 -0
- package/dist/agent/api.d.ts +41 -1
- package/dist/agent/api.js +60 -6
- package/dist/agent/codex.d.ts +105 -13
- package/dist/agent/codex.js +139 -19
- package/dist/agent/command.js +4 -0
- package/dist/agent/heartbeat.js +4 -3
- package/dist/agent/run.d.ts +12 -0
- package/dist/agent/run.js +130 -13
- package/dist/listen.d.ts +7 -0
- package/dist/listen.js +25 -2
- package/dist/liveness.d.ts +107 -0
- package/dist/liveness.js +156 -0
- package/dist/upgrade.d.ts +49 -0
- package/dist/upgrade.js +99 -0
- 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,122 @@ 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.5.0
|
|
8
|
+
|
|
9
|
+
A paired agent can no longer sit on a dead socket believing it is connected.
|
|
10
|
+
|
|
11
|
+
- **The failure.** One daemon (`agent run` under launchd) was "connected" for
|
|
12
|
+
three weeks and received nothing. The server had closed the socket on
|
|
13
|
+
2026-08-11 — the laptop was almost certainly asleep, the FIN never arrived —
|
|
14
|
+
and the client kernel kept the TCP connection in `ESTABLISHED`. The CLI only
|
|
15
|
+
ever reacted to `close` and `error`, answered pings with pongs, and had no
|
|
16
|
+
deadline of its own, so from where it stood the connection was open, quiet,
|
|
17
|
+
and fine. The server pings every 30 s and reaps after 60 s without a pong;
|
|
18
|
+
that contract only helps when the close actually reaches the client, and on
|
|
19
|
+
a half-open socket it never does. Nothing on either side had a reason to
|
|
20
|
+
say anything.
|
|
21
|
+
- **Client-side liveness watchdog.** Every frame the server sends — pings
|
|
22
|
+
included — is proof of life. Ninety seconds without one (three missed pings,
|
|
23
|
+
comfortably past the server's own reaper) means the connection is dead
|
|
24
|
+
whatever the kernel thinks: the CLI logs
|
|
25
|
+
`… no server ping for 90s — reconnecting`, tears the socket down with
|
|
26
|
+
`terminate()` (not `close()`, whose handshake would wait on a peer that is
|
|
27
|
+
not there), and the ordinary backoff-and-reconnect path runs. `listen` has
|
|
28
|
+
the same deadline; it has no reconnect loop, so it ends the session with
|
|
29
|
+
`… no server ping for 90s — connection lost` rather than a counter that
|
|
30
|
+
quietly stopped moving. The timer dies with the socket, so a reconnect never
|
|
31
|
+
inherits the previous socket's silence.
|
|
32
|
+
- **TCP keepalive.** Switched on for the connection under the websocket, with
|
|
33
|
+
a 30 s idle delay, as a kernel-level probe underneath the application-level
|
|
34
|
+
one. Guarded: a `ws` build that hides its socket, or none at all, simply
|
|
35
|
+
gets no keepalive rather than a crash on `open`.
|
|
36
|
+
- **A heartbeat in the log.** Every five minutes while connected, `agent run`
|
|
37
|
+
writes `● connected — last server ping Ns ago`. A stalled daemon now looks
|
|
38
|
+
different in `agent.log` from a healthy quiet one.
|
|
39
|
+
- **The build reports itself.** The websocket auth frame carries
|
|
40
|
+
`client_version` (this build's version) alongside `api_key` / `machine` /
|
|
41
|
+
`subscription_id`, and the upgrade request sends `User-Agent:
|
|
42
|
+
prohost-cli/<version>` like every HTTP call already did. The failing daemon
|
|
43
|
+
was two minor versions behind npm the whole time and nobody could tell.
|
|
44
|
+
- **An upgrade notice.** At `agent run` startup the CLI asks the npm registry
|
|
45
|
+
for the latest version, with a 3 s budget, and prints
|
|
46
|
+
`⬆ @prohost/cli <latest> is available (running <current>) — npm i -g @prohost/cli`
|
|
47
|
+
when there is one. It is not awaited and cannot fail startup: a slow, down,
|
|
48
|
+
or nonsensical registry prints nothing.
|
|
49
|
+
- **A stalled handshake is bounded too.** The deadline only starts at `open`,
|
|
50
|
+
and `ws` has no handshake timeout by default, so `agent run` now gives the
|
|
51
|
+
handshake 30 s — the same asleep-laptop failure, one step earlier.
|
|
52
|
+
|
|
53
|
+
A slow reply is no longer reported as a failed run.
|
|
54
|
+
|
|
55
|
+
- **The second failure, seen the moment the daemon reconnected.** The reply
|
|
56
|
+
`POST /v1/conversations/{id}/messages` took the server **51,216 ms** and
|
|
57
|
+
returned 202 — the message was saved — but the CLI had abandoned the request
|
|
58
|
+
at its 30 s deadline, logged `✗ run … reply rejected (HTTP 0) This operation
|
|
59
|
+
was aborted`, and completed the run as failed. The server then posted a
|
|
60
|
+
"picked this up but its run failed — posting the reply failed with HTTP 0"
|
|
61
|
+
note directly under an answer that had in fact landed. Two consecutive runs.
|
|
62
|
+
The slow send path is being fixed server-side; this release stops the client
|
|
63
|
+
turning a slow success into a reported failure.
|
|
64
|
+
- **120 s for the reply and the completion.** Cheap calls (heartbeats) keep
|
|
65
|
+
30 s. Four times the observed latency is the margin.
|
|
66
|
+
- **No answer is not a failure — replay with the same key.** The reply POST
|
|
67
|
+
was already keyed on the run id (`Idempotency-Key: prohost-cli-run-<run_id>`),
|
|
68
|
+
which is exactly what makes asking again safe. On a timeout or a network
|
|
69
|
+
error the CLI now logs `… reply POST timed out after 120s — replaying with
|
|
70
|
+
the same idempotency key` and sends the byte-identical request once more.
|
|
71
|
+
The server answers with the cached response of the attempt that landed
|
|
72
|
+
(`✓ reply confirmed on replay`), or with `409 idempotency_key_in_flight` if
|
|
73
|
+
the first attempt is still being processed — in which case the CLI waits
|
|
74
|
+
5 s and replays once more. Only a definitive answer, or a second transport
|
|
75
|
+
failure, reports the run as failed, and it does so exactly once.
|
|
76
|
+
- **`HTTP 0` is gone from the log.** A request with no response now says why:
|
|
77
|
+
`timed out after 120s` or `network error: <cause>`, in the log line and in
|
|
78
|
+
the failure reason the server shows in the thread.
|
|
79
|
+
|
|
80
|
+
## 0.4.2
|
|
81
|
+
|
|
82
|
+
Isolated Codex runs no longer receive the operator's **apps (connectors)**.
|
|
83
|
+
|
|
84
|
+
- **`codex_apps` tools are kept out of every run.** A Codex "app" is a set of
|
|
85
|
+
MCP tools served under the name `codex_apps`, and it appears in **no**
|
|
86
|
+
`codex mcp list --json` output at any setting: apps are account-level, arrive
|
|
87
|
+
with the ChatGPT login, and are cached under `$CODEX_HOME/cache/codex_apps_tools`
|
|
88
|
+
rather than declared in `mcp_servers`. The isolation contract is that a run
|
|
89
|
+
gets the ProhostAI server and the operator's explicitly-allowed extras and
|
|
90
|
+
nothing else, and this broke it silently — the enumeration the guarantee is
|
|
91
|
+
built on cannot see these tools at all. Every isolated run now carries
|
|
92
|
+
`-c features.apps=false`.
|
|
93
|
+
- **It was not hypothetical, and not limited to plugin installs.** Measured
|
|
94
|
+
against codex-cli 0.146.0: a `CODEX_HOME` whose config declares no plugins
|
|
95
|
+
and one ordinary MCP server — where 0.4.1 disables that server cleanly, finds
|
|
96
|
+
nothing that resists, and therefore applies no whole-config override at all —
|
|
97
|
+
still handed the run 28 `mcp__codex_apps__*` tools. They are not read-only:
|
|
98
|
+
`sites_create_site`, `sites_deploy_site_version`, `sites_add_custom_domain`,
|
|
99
|
+
`sites_update_environment_variables`, `sites_generate_siwc_bypass_token`,
|
|
100
|
+
`plugin_management_update_app_permissions` and
|
|
101
|
+
`plugin_management_uninstall_app` publish sites and rewrite app permissions on
|
|
102
|
+
the operator's own account.
|
|
103
|
+
- **A dedicated `CODEX_HOME` was never a fix for this one.** A directory holding
|
|
104
|
+
only `auth.json` — 0.4.1's own suggested remedy — fetches the account's apps
|
|
105
|
+
on its first run, writes them to its own cache, and serves the full set from
|
|
106
|
+
the second run onwards. The login carries them, so a fresh directory delays
|
|
107
|
+
the exposure by exactly one run. Hence unconditional rather than opt-in.
|
|
108
|
+
- **`features.plugins=false` was the wrong lever, and only ever worked by
|
|
109
|
+
accident.** It closed this on plugin-bearing machines because apps happened to
|
|
110
|
+
arrive through that pipeline there, but it leaves the apps instructions in the
|
|
111
|
+
prompt, costs the operator their plugin skills and hooks, and is never applied
|
|
112
|
+
at all on a classic-only install. `features.apps=false` is the narrow switch:
|
|
113
|
+
it leaves `plugins` on, so skills and hooks survive.
|
|
114
|
+
- **Proven, not assumed.** There is no listing to verify against, so the switch
|
|
115
|
+
is read back out of `codex features list` — the `apps` row must actually read
|
|
116
|
+
`false` under the exact overrides the run will carry. A renamed feature, an
|
|
117
|
+
older Codex with no `features` subcommand, or an override that loads and
|
|
118
|
+
changes nothing all read as *unknown*, and a run that cannot prove the
|
|
119
|
+
boundary fails before Codex starts with a one-line remedy. 0.4.1's plugin
|
|
120
|
+
handling, three-tier probe path and classic `mcp_servers` disabling are
|
|
121
|
+
unchanged and regression-tested.
|
|
122
|
+
|
|
7
123
|
## 0.4.1
|
|
8
124
|
|
|
9
125
|
Codex runs work again on machines with a Codex **plugin** installed.
|
package/README.md
CHANGED
|
@@ -329,6 +329,35 @@ used: `plugins."github@openai-curated".enabled=false` and every variant of it
|
|
|
329
329
|
load without error and disable nothing at all, which is precisely the kind of
|
|
330
330
|
override the verification step exists to catch.
|
|
331
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
|
+
|
|
332
361
|
**If a server still cannot be disabled**, the run fails before Codex starts and
|
|
333
362
|
names it. Two ways out: remove that server or plugin from `~/.codex/config.toml`,
|
|
334
363
|
or give the daemon a `CODEX_HOME` of its own that holds only `auth.json` —
|
|
@@ -343,6 +372,13 @@ A dedicated `CODEX_HOME` is the cleanest answer in general: the agent inherits
|
|
|
343
372
|
your login and nothing else, your own Codex setup is untouched, and no future
|
|
344
373
|
plugin or server you install can change what a paired run can reach.
|
|
345
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.
|
|
381
|
+
|
|
346
382
|
If the enumeration fails outright — an older Codex without `mcp list --json`,
|
|
347
383
|
or output the CLI doesn't recognise — the run fails before Codex starts. A
|
|
348
384
|
paired run never proceeds under `--yolo` when its MCP boundary cannot be proven.
|
|
@@ -489,6 +525,16 @@ On macOS this writes a launchd agent to
|
|
|
489
525
|
`$PROHOST_HOME/agent.log` — the first place to look when the agent is quiet.
|
|
490
526
|
Re-running it with a different `--exec` replaces the old daemon.
|
|
491
527
|
|
|
528
|
+
**It notices when the connection dies, even when the network doesn't say so.**
|
|
529
|
+
The server pings every 30 seconds. If no frame of any kind arrives for 90
|
|
530
|
+
seconds — a laptop that slept through the server closing the socket looks
|
|
531
|
+
exactly like this — the CLI logs `… no server ping for 90s — reconnecting`,
|
|
532
|
+
drops the socket and reconnects with backoff. While connected it writes
|
|
533
|
+
`● connected — last server ping Ns ago` every five minutes, so a healthy quiet
|
|
534
|
+
daemon and a stalled one read differently in `agent.log`. At startup it also
|
|
535
|
+
checks npm and prints one line if a newer `@prohost/cli` exists; the check
|
|
536
|
+
never delays or fails the connection.
|
|
537
|
+
|
|
492
538
|
The daemon is scoped to `$PROHOST_HOME`, because that's where the one credential
|
|
493
539
|
lives: installing twice from the same home replaces the daemon rather than
|
|
494
540
|
adding a second one. To run two agents on one machine, give each its own
|
package/dist/agent/api.d.ts
CHANGED
|
@@ -13,13 +13,23 @@ export interface ApiOptions {
|
|
|
13
13
|
baseUrl: string;
|
|
14
14
|
apiKey: string;
|
|
15
15
|
fetchImpl?: typeof fetch;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Per-request timeout override, applied to every call. Unset, each call
|
|
18
|
+
* uses its own default — {@link DEFAULT_TIMEOUT_MS} for the cheap ones,
|
|
19
|
+
* {@link SEND_TIMEOUT_MS} for the reply and the completion.
|
|
20
|
+
*/
|
|
17
21
|
timeoutMs?: number;
|
|
18
22
|
}
|
|
19
23
|
export interface ApiResult {
|
|
20
24
|
ok: boolean;
|
|
25
|
+
/** HTTP status, or ``0`` when no response arrived at all — see ``failure``. */
|
|
21
26
|
status: number;
|
|
22
27
|
error?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Why ``status`` is ``0``: the request was abandoned at its deadline, or the
|
|
30
|
+
* network failed before any status came back. Absent on every real response.
|
|
31
|
+
*/
|
|
32
|
+
failure?: 'timeout' | 'network';
|
|
23
33
|
/**
|
|
24
34
|
* The response body, when the caller asked for it.
|
|
25
35
|
*
|
|
@@ -28,6 +38,35 @@ export interface ApiResult {
|
|
|
28
38
|
*/
|
|
29
39
|
body?: string;
|
|
30
40
|
}
|
|
41
|
+
/** Deadline for the cheap calls — a heartbeat answers in milliseconds. */
|
|
42
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
43
|
+
/**
|
|
44
|
+
* Deadline for the reply POST and the completion.
|
|
45
|
+
*
|
|
46
|
+
* Thirty seconds was not enough. On 2026-09-01 the server took 51,216 ms to
|
|
47
|
+
* accept a reply (``POST /v1/conversations/{id}/messages`` → 202): the
|
|
48
|
+
* message was saved, but the CLI had already abandoned the request at 30 s,
|
|
49
|
+
* logged the reply as rejected, and completed the run as failed — so the
|
|
50
|
+
* server posted a "run failed" note under an answer that had in fact landed.
|
|
51
|
+
* Twice in a row. The slow send path is being fixed server-side; the client's
|
|
52
|
+
* job is to not turn a slow success into a reported failure, and four times
|
|
53
|
+
* the observed latency is the margin for that.
|
|
54
|
+
*/
|
|
55
|
+
export declare const SEND_TIMEOUT_MS = 120000;
|
|
56
|
+
/**
|
|
57
|
+
* One line a person can act on for a failed result: ``HTTP 503``, ``timed
|
|
58
|
+
* out after 120s``, ``network error: ECONNRESET``. ``HTTP 0`` told nobody
|
|
59
|
+
* anything, and it was the only thing the log said for the whole of the
|
|
60
|
+
* 30-second-timeout incident.
|
|
61
|
+
*/
|
|
62
|
+
export declare function describeFailure(result: ApiResult): string;
|
|
63
|
+
/**
|
|
64
|
+
* The server's "same key, same body, the first attempt is still being
|
|
65
|
+
* processed" answer (``api_public/middleware/idempotency.py``). A replay that
|
|
66
|
+
* gets this should wait and replay again — the original will finish and be
|
|
67
|
+
* cached, and the next replay receives its response.
|
|
68
|
+
*/
|
|
69
|
+
export declare function isIdempotencyInFlight(result: ApiResult): boolean;
|
|
31
70
|
/**
|
|
32
71
|
* Post the agent's reply into the conversation that triggered the run.
|
|
33
72
|
*
|
|
@@ -69,6 +108,7 @@ export type HeartbeatOutcome =
|
|
|
69
108
|
kind: 'error';
|
|
70
109
|
status: number;
|
|
71
110
|
detail?: string;
|
|
111
|
+
failure?: ApiResult['failure'];
|
|
72
112
|
};
|
|
73
113
|
/**
|
|
74
114
|
* Tell the server a run is still being worked on, and ask whether it should be.
|
package/dist/agent/api.js
CHANGED
|
@@ -11,13 +11,52 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { USER_AGENT } from '../version.js';
|
|
13
13
|
import { MAX_ERROR_CHARS, MAX_PROGRESS_CHARS, RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from './contract.js';
|
|
14
|
-
|
|
14
|
+
/** Deadline for the cheap calls — a heartbeat answers in milliseconds. */
|
|
15
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
16
|
+
/**
|
|
17
|
+
* Deadline for the reply POST and the completion.
|
|
18
|
+
*
|
|
19
|
+
* Thirty seconds was not enough. On 2026-09-01 the server took 51,216 ms to
|
|
20
|
+
* accept a reply (``POST /v1/conversations/{id}/messages`` → 202): the
|
|
21
|
+
* message was saved, but the CLI had already abandoned the request at 30 s,
|
|
22
|
+
* logged the reply as rejected, and completed the run as failed — so the
|
|
23
|
+
* server posted a "run failed" note under an answer that had in fact landed.
|
|
24
|
+
* Twice in a row. The slow send path is being fixed server-side; the client's
|
|
25
|
+
* job is to not turn a slow success into a reported failure, and four times
|
|
26
|
+
* the observed latency is the margin for that.
|
|
27
|
+
*/
|
|
28
|
+
export const SEND_TIMEOUT_MS = 120_000;
|
|
15
29
|
/** Cap on a captured response body. Every one we parse is a small object. */
|
|
16
30
|
const MAX_BODY_CHARS = 2_000;
|
|
17
|
-
|
|
31
|
+
/**
|
|
32
|
+
* One line a person can act on for a failed result: ``HTTP 503``, ``timed
|
|
33
|
+
* out after 120s``, ``network error: ECONNRESET``. ``HTTP 0`` told nobody
|
|
34
|
+
* anything, and it was the only thing the log said for the whole of the
|
|
35
|
+
* 30-second-timeout incident.
|
|
36
|
+
*/
|
|
37
|
+
export function describeFailure(result) {
|
|
38
|
+
if (result.failure === 'timeout')
|
|
39
|
+
return result.error ?? 'timed out';
|
|
40
|
+
if (result.failure === 'network')
|
|
41
|
+
return `network error: ${result.error ?? 'unknown'}`;
|
|
42
|
+
if (result.status === 0)
|
|
43
|
+
return `network error: ${result.error ?? 'unknown'}`;
|
|
44
|
+
return `HTTP ${result.status}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The server's "same key, same body, the first attempt is still being
|
|
48
|
+
* processed" answer (``api_public/middleware/idempotency.py``). A replay that
|
|
49
|
+
* gets this should wait and replay again — the original will finish and be
|
|
50
|
+
* cached, and the next replay receives its response.
|
|
51
|
+
*/
|
|
52
|
+
export function isIdempotencyInFlight(result) {
|
|
53
|
+
return result.status === 409 && (result.error ?? '').includes('idempotency_key_in_flight');
|
|
54
|
+
}
|
|
55
|
+
async function post(options, path, body, idempotencyKey, captureBody = false, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
18
56
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
19
57
|
const controller = new AbortController();
|
|
20
|
-
const
|
|
58
|
+
const deadlineMs = options.timeoutMs ?? timeoutMs;
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
21
60
|
try {
|
|
22
61
|
const response = await fetchImpl(`${options.baseUrl.replace(/\/+$/, '')}${path}`, {
|
|
23
62
|
method: 'POST',
|
|
@@ -46,9 +85,22 @@ async function post(options, path, body, idempotencyKey, captureBody = false) {
|
|
|
46
85
|
return { ok, status: response.status, error, body: captureBody ? text : undefined };
|
|
47
86
|
}
|
|
48
87
|
catch (err) {
|
|
88
|
+
// Our own deadline fired: `fetch` rejects with an AbortError, whose
|
|
89
|
+
// message ("This operation was aborted") names neither the cause nor the
|
|
90
|
+
// duration. Say both.
|
|
91
|
+
const abortError = err instanceof Error && err.name === 'AbortError';
|
|
92
|
+
if (controller.signal.aborted || abortError) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
status: 0,
|
|
96
|
+
failure: 'timeout',
|
|
97
|
+
error: `timed out after ${Math.round(deadlineMs / 1000)}s`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
49
100
|
return {
|
|
50
101
|
ok: false,
|
|
51
102
|
status: 0,
|
|
103
|
+
failure: 'network',
|
|
52
104
|
error: err instanceof Error ? err.message : String(err),
|
|
53
105
|
};
|
|
54
106
|
}
|
|
@@ -72,7 +124,9 @@ export async function postReply(options, path, bodyKey, message, runId, extra) {
|
|
|
72
124
|
// `extra` first, so the message can never be clobbered by a `body_extra` key
|
|
73
125
|
// that happens to collide with the body field. The reply text is the one
|
|
74
126
|
// thing in this request that must survive.
|
|
75
|
-
|
|
127
|
+
// Byte-identical on every attempt: the server hashes the body against the
|
|
128
|
+
// key, so a replay must serialise exactly as the original did.
|
|
129
|
+
return post(options, path, { ...extra, [bodyKey]: message }, `prohost-cli-run-${runId}`, false, SEND_TIMEOUT_MS);
|
|
76
130
|
}
|
|
77
131
|
function booleanField(body, key) {
|
|
78
132
|
if (!body)
|
|
@@ -107,7 +161,7 @@ export async function heartbeatRun(options, path, progress) {
|
|
|
107
161
|
return { kind: 'unknown' };
|
|
108
162
|
if (result.status === 409)
|
|
109
163
|
return { kind: 'resolved' };
|
|
110
|
-
return { kind: 'error', status: result.status, detail: result.error };
|
|
164
|
+
return { kind: 'error', status: result.status, detail: result.error, failure: result.failure };
|
|
111
165
|
}
|
|
112
166
|
/**
|
|
113
167
|
* Report a run's terminal state. Called for every run — including one whose
|
|
@@ -121,7 +175,7 @@ export async function completeRun(options, path, outcome) {
|
|
|
121
175
|
const body = outcome.ok
|
|
122
176
|
? { status: RUN_STATUS_SUCCEEDED }
|
|
123
177
|
: { status: RUN_STATUS_FAILED, error: outcome.error.slice(0, MAX_ERROR_CHARS) };
|
|
124
|
-
const result = await post(options, path, body);
|
|
178
|
+
const result = await post(options, path, body, undefined, false, SEND_TIMEOUT_MS);
|
|
125
179
|
if (result.status === 409)
|
|
126
180
|
return { ...result, ok: true };
|
|
127
181
|
return result;
|
package/dist/agent/codex.d.ts
CHANGED
|
@@ -191,6 +191,43 @@ export type CommandRunner = (command: string, cwd?: string) => {
|
|
|
191
191
|
* enumerate must say so rather than claim an isolation it did not apply.
|
|
192
192
|
*/
|
|
193
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[];
|
|
@@ -217,11 +254,12 @@ export interface VerifiedDisable {
|
|
|
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.
|
|
219
256
|
*
|
|
220
|
-
* `baseOverrides` are config overrides the run will also carry —
|
|
221
|
-
* {@link
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* config the
|
|
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.
|
|
225
263
|
*/
|
|
226
264
|
export declare function disableEntriesThatLoad(binary: string, runner: CommandRunner, targets: string[], cwd?: string, baseOverrides?: string[]): VerifiedDisable;
|
|
227
265
|
/**
|
|
@@ -260,14 +298,58 @@ export declare function disableEntriesThatLoad(binary: string, runner: CommandRu
|
|
|
260
298
|
* an install whose plugins contribute no MCP servers keeps all of that.
|
|
261
299
|
*/
|
|
262
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";
|
|
263
337
|
/** A verified isolation: what to pass Codex, and what could not be shut off. */
|
|
264
338
|
export interface VerifiedIsolation {
|
|
265
339
|
/** `mcp_servers` fragments, for the single {@link mcpServersOverride}. */
|
|
266
340
|
entries: string[];
|
|
267
|
-
/** Whole-config `-c` overrides the run needs on top.
|
|
341
|
+
/** Whole-config `-c` overrides the run needs on top. Never empty. */
|
|
268
342
|
configOverrides: string[];
|
|
269
343
|
/** Servers still reachable from the run. Empty is the outcome we want. */
|
|
270
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;
|
|
271
353
|
}
|
|
272
354
|
/**
|
|
273
355
|
* Shut off every server the operator's install has, whatever supplies it.
|
|
@@ -281,14 +363,23 @@ export interface VerifiedIsolation {
|
|
|
281
363
|
* *measured* rather than read: list again with plugins off, and the names that
|
|
282
364
|
* vanished were the plugin's.
|
|
283
365
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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.
|
|
288
376
|
*
|
|
289
377
|
* Failing to shut a server off is reported, never worked around. A plugin
|
|
290
378
|
* 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.
|
|
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.
|
|
292
383
|
*/
|
|
293
384
|
export declare function isolateConfiguredServers(binary: string, runner: CommandRunner, targets: string[], cwd?: string): VerifiedIsolation;
|
|
294
385
|
/**
|
|
@@ -341,8 +432,9 @@ export interface CodexFlags {
|
|
|
341
432
|
*/
|
|
342
433
|
mcpEntries?: string[];
|
|
343
434
|
/**
|
|
344
|
-
* Whole-config `-c` overrides that are not `mcp_servers
|
|
345
|
-
* {@link
|
|
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
|
|
346
438
|
* contributes MCP servers. They precede the `mcp_servers` override for the
|
|
347
439
|
* same reason they do in the verifier: the run has to be handed the same
|
|
348
440
|
* overrides in the same order the probe certified.
|