@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/dist/agent/codex.js
CHANGED
|
@@ -524,6 +524,67 @@ export function listMcpServers(binary, runner, overrides, cwd) {
|
|
|
524
524
|
return null;
|
|
525
525
|
}
|
|
526
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
|
+
}
|
|
527
588
|
/**
|
|
528
589
|
* Disable entries, checked against the binary before they are used.
|
|
529
590
|
*
|
|
@@ -544,11 +605,12 @@ export function listMcpServers(binary, runner, overrides, cwd) {
|
|
|
544
605
|
* reachable from the run, and the caller has to say so out loud rather than
|
|
545
606
|
* report an isolation it only partly achieved.
|
|
546
607
|
*
|
|
547
|
-
* `baseOverrides` are config overrides the run will also carry —
|
|
548
|
-
* {@link
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
* config the
|
|
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.
|
|
552
614
|
*/
|
|
553
615
|
export function disableEntriesThatLoad(binary, runner, targets, cwd, baseOverrides = []) {
|
|
554
616
|
if (targets.length === 0)
|
|
@@ -618,6 +680,42 @@ export function disableEntriesThatLoad(binary, runner, targets, cwd, baseOverrid
|
|
|
618
680
|
* an install whose plugins contribute no MCP servers keeps all of that.
|
|
619
681
|
*/
|
|
620
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`;
|
|
621
719
|
/**
|
|
622
720
|
* Shut off every server the operator's install has, whatever supplies it.
|
|
623
721
|
*
|
|
@@ -630,42 +728,64 @@ export const PLUGINS_DISABLED_OVERRIDE = 'features.plugins=false';
|
|
|
630
728
|
* *measured* rather than read: list again with plugins off, and the names that
|
|
631
729
|
* vanished were the plugin's.
|
|
632
730
|
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
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.
|
|
637
741
|
*
|
|
638
742
|
* Failing to shut a server off is reported, never worked around. A plugin
|
|
639
743
|
* 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.
|
|
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.
|
|
641
748
|
*/
|
|
642
749
|
export function isolateConfiguredServers(binary, runner, targets, cwd) {
|
|
643
|
-
|
|
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);
|
|
644
763
|
if (direct.failed.length === 0)
|
|
645
|
-
return { ...direct, configOverrides:
|
|
764
|
+
return prove({ ...direct, configOverrides: base });
|
|
646
765
|
// Something resisted. Ask whether turning the plugin system off accounts for
|
|
647
766
|
// it, rather than assuming the survivors are plugin-backed because they look
|
|
648
767
|
// like it.
|
|
649
|
-
const withPluginsOff = listMcpServers(binary, runner, PLUGINS_DISABLED_OVERRIDE, cwd);
|
|
768
|
+
const withPluginsOff = listMcpServers(binary, runner, [...base, PLUGINS_DISABLED_OVERRIDE], cwd);
|
|
650
769
|
// The override does not load, so it disables nothing and we know nothing.
|
|
651
770
|
if (withPluginsOff === null)
|
|
652
|
-
return { ...direct, configOverrides:
|
|
771
|
+
return prove({ ...direct, configOverrides: base });
|
|
653
772
|
const remaining = new Set(withPluginsOff.map((server) => server.name));
|
|
654
773
|
const removedByPluginsOff = direct.failed.filter((name) => !remaining.has(name));
|
|
655
774
|
// Plugins were not the problem. Report what the classic path found, so the
|
|
656
775
|
// operator is told about the real survivor instead of a plugin red herring.
|
|
657
776
|
if (removedByPluginsOff.length === 0)
|
|
658
|
-
return { ...direct, configOverrides:
|
|
777
|
+
return prove({ ...direct, configOverrides: base });
|
|
659
778
|
// Re-verify the classic disables *under* the plugins-off override. They were
|
|
660
779
|
// proven against a different config, and a proof about a config the run does
|
|
661
780
|
// not get is not a proof.
|
|
662
781
|
const classic = targets.filter((name) => remaining.has(name));
|
|
663
|
-
const
|
|
664
|
-
|
|
782
|
+
const configOverrides = [...base, PLUGINS_DISABLED_OVERRIDE];
|
|
783
|
+
const verified = disableEntriesThatLoad(binary, runner, classic, cwd, configOverrides);
|
|
784
|
+
return prove({
|
|
665
785
|
entries: verified.entries,
|
|
666
|
-
configOverrides
|
|
786
|
+
configOverrides,
|
|
667
787
|
failed: verified.failed,
|
|
668
|
-
};
|
|
788
|
+
});
|
|
669
789
|
}
|
|
670
790
|
/**
|
|
671
791
|
* The `codex` binary out of the host's `--exec` command.
|
package/dist/agent/command.js
CHANGED
|
@@ -13,6 +13,7 @@ import { agentLogPath, buildSystemdUnit, harnessArguments, installLaunchdDaemon,
|
|
|
13
13
|
import { PairError, runPair } from './pair.js';
|
|
14
14
|
import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome } from './credentials.js';
|
|
15
15
|
import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
|
|
16
|
+
import { announceUpgradeIfAvailable } from '../upgrade.js';
|
|
16
17
|
function flagString(flags, name) {
|
|
17
18
|
const value = flags[name];
|
|
18
19
|
return typeof value === 'string' ? value : undefined;
|
|
@@ -306,6 +307,9 @@ async function runCommand(flags) {
|
|
|
306
307
|
};
|
|
307
308
|
process.on('SIGINT', onSig);
|
|
308
309
|
process.on('SIGTERM', onSig);
|
|
310
|
+
// Deliberately not awaited: the registry lookup must never delay or fail the
|
|
311
|
+
// connection. It prints one line if a newer build exists and nothing else.
|
|
312
|
+
void announceUpgradeIfAvailable((line) => process.stdout.write(`${line}\n`));
|
|
309
313
|
try {
|
|
310
314
|
const metrics = await runAgentHarness({
|
|
311
315
|
exec,
|
package/dist/agent/heartbeat.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* failure mode — an old server that 404s, a dropped network, a handler that
|
|
22
22
|
* throws — costs at most the beat it happened on.
|
|
23
23
|
*/
|
|
24
|
-
import { heartbeatRun } from './api.js';
|
|
24
|
+
import { describeFailure, heartbeatRun } from './api.js';
|
|
25
25
|
/**
|
|
26
26
|
* How often each open run is reported.
|
|
27
27
|
*
|
|
@@ -154,8 +154,9 @@ export class HeartbeatLoop {
|
|
|
154
154
|
// Transient by assumption: keep the cadence rather than give up on the
|
|
155
155
|
// run. A run whose beats all fail is reaped server-side eventually, which
|
|
156
156
|
// is the correct end state for a machine that cannot reach the API.
|
|
157
|
-
this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} failed
|
|
158
|
-
|
|
157
|
+
this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} failed ` +
|
|
158
|
+
`(${describeFailure({ ok: false, status: outcome.status, error: outcome.detail, failure: outcome.failure })})` +
|
|
159
|
+
`${outcome.status !== 0 && outcome.detail ? ` ${outcome.detail}` : ''}`);
|
|
159
160
|
}
|
|
160
161
|
catch (err) {
|
|
161
162
|
this.options.log(` ! heartbeat for run ${target.runId.slice(0, 12)} threw: ` +
|
package/dist/agent/run.d.ts
CHANGED
|
@@ -121,6 +121,16 @@ export interface AgentRunOptions {
|
|
|
121
121
|
* inside a test's lifetime.
|
|
122
122
|
*/
|
|
123
123
|
heartbeatIntervalMs?: number;
|
|
124
|
+
/**
|
|
125
|
+
* How long the server may go silent before the socket is torn down and
|
|
126
|
+
* reconnected. Defaults to {@link PING_LIVENESS_MS}; tests shorten it.
|
|
127
|
+
*/
|
|
128
|
+
livenessTimeoutMs?: number;
|
|
129
|
+
/**
|
|
130
|
+
* How often a connected harness logs that it is still connected. Defaults
|
|
131
|
+
* to {@link HEARTBEAT_LOG_INTERVAL_MS}; tests shorten it.
|
|
132
|
+
*/
|
|
133
|
+
heartbeatLogIntervalMs?: number;
|
|
124
134
|
}
|
|
125
135
|
export interface AgentRunMetrics {
|
|
126
136
|
connections: number;
|
|
@@ -134,6 +144,8 @@ export interface AgentRunMetrics {
|
|
|
134
144
|
ignored: number;
|
|
135
145
|
/** Repeat deliveries of a run already handled this session. */
|
|
136
146
|
duplicates: number;
|
|
147
|
+
/** Sockets torn down locally because the server went silent past the liveness window. */
|
|
148
|
+
livenessTimeouts: number;
|
|
137
149
|
/**
|
|
138
150
|
* Set when the harness stopped for a reason retrying can't fix (a rejected
|
|
139
151
|
* credential). The CLI exits nonzero on it, so a service manager doesn't
|
package/dist/agent/run.js
CHANGED
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import WebSocket from 'ws';
|
|
28
|
-
import { completeRun, postReply } from './api.js';
|
|
28
|
+
import { completeRun, describeFailure, isIdempotencyInFlight, 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, PLUGINS_DISABLED_OVERRIDE, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, extraMcpEntries, isCodexExec, isolateConfiguredServers, 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';
|
|
@@ -40,6 +40,8 @@ import { SessionStore, sessionKeyFor } from './sessionstore.js';
|
|
|
40
40
|
import { verifySignature } from './signature.js';
|
|
41
41
|
import { isWebhookEvent } from '../forward.js';
|
|
42
42
|
import { DEFAULT_WS_URL } from '../listen.js';
|
|
43
|
+
import { ServerLiveness, enableTcpKeepAlive, seconds, terminateSocket } from '../liveness.js';
|
|
44
|
+
import { CLI_VERSION, USER_AGENT } from '../version.js';
|
|
43
45
|
/**
|
|
44
46
|
* Default ceiling on one agent invocation: none.
|
|
45
47
|
*
|
|
@@ -74,6 +76,14 @@ export const DEFAULT_IDLE_TIMEOUT_MS = 900_000;
|
|
|
74
76
|
/** Reconnect backoff — full jitter between ``BASE`` and the current cap. */
|
|
75
77
|
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
76
78
|
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
79
|
+
/**
|
|
80
|
+
* Longest the websocket handshake may take before `ws` gives up on it.
|
|
81
|
+
*
|
|
82
|
+
* The liveness deadline only starts at `open`; without this, a connect that
|
|
83
|
+
* stalls mid-handshake — the same asleep-laptop shape, one step earlier —
|
|
84
|
+
* would hang with neither `open` nor `close` to move the loop along.
|
|
85
|
+
*/
|
|
86
|
+
const HANDSHAKE_TIMEOUT_MS = 30_000;
|
|
77
87
|
/** Ack status codes reported back over the socket for each frame. */
|
|
78
88
|
const ACK_ACCEPTED = 200;
|
|
79
89
|
const ACK_IGNORED = 204;
|
|
@@ -88,6 +98,7 @@ function newMetrics() {
|
|
|
88
98
|
signatureRejections: 0,
|
|
89
99
|
ignored: 0,
|
|
90
100
|
duplicates: 0,
|
|
101
|
+
livenessTimeouts: 0,
|
|
91
102
|
};
|
|
92
103
|
}
|
|
93
104
|
function timestamp() {
|
|
@@ -130,12 +141,62 @@ async function completeWithRetry(api, path, outcome, log, sleep) {
|
|
|
130
141
|
const retryable = result.status === 0 || result.status === 429 || result.status >= 500;
|
|
131
142
|
if (!retryable)
|
|
132
143
|
return result;
|
|
133
|
-
log(` … completion failed (
|
|
144
|
+
log(` … completion failed (${describeFailure(result)}); retrying in ${delay / 1000}s`);
|
|
134
145
|
await sleep(delay);
|
|
135
146
|
result = await completeRun(api, path, outcome);
|
|
136
147
|
}
|
|
137
148
|
return result;
|
|
138
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* How long to wait before replaying a reply whose first attempt the server
|
|
152
|
+
* reports as still in flight. The original request is being processed at
|
|
153
|
+
* that moment; a few seconds is enough for it to finish and be cached.
|
|
154
|
+
*/
|
|
155
|
+
const REPLAY_IN_FLIGHT_DELAY_MS = 5_000;
|
|
156
|
+
/**
|
|
157
|
+
* Post the reply, and if the request dies in transit, replay it once with
|
|
158
|
+
* the same idempotency key before believing it failed.
|
|
159
|
+
*
|
|
160
|
+
* A request abandoned at its deadline is not a request that failed — it is
|
|
161
|
+
* a request whose answer we did not wait for. On 2026-09-01 the server took
|
|
162
|
+
* 51 s to accept a reply that the CLI had given up on at 30 s; the message
|
|
163
|
+
* was in the conversation, and the CLI reported the run as failed anyway.
|
|
164
|
+
* The key (``prohost-cli-run-<run_id>``) already made the send idempotent
|
|
165
|
+
* server-side, so the correct reaction to "no answer" is to ask again with
|
|
166
|
+
* the same key and let the server say what happened:
|
|
167
|
+
*
|
|
168
|
+
* - a 2xx (fresh, or the cached response of the attempt that did land) —
|
|
169
|
+
* the reply is durable, carry on as a success;
|
|
170
|
+
* - ``409 idempotency_key_in_flight`` — the first attempt is still being
|
|
171
|
+
* processed; wait, then replay once more;
|
|
172
|
+
* - anything else — a real decision or a second transport failure — is the
|
|
173
|
+
* answer, and the run is reported failed exactly once.
|
|
174
|
+
*/
|
|
175
|
+
async function postReplyWithReplay(api, send, label, log, sleep) {
|
|
176
|
+
let posted = await send();
|
|
177
|
+
// A real answer — success or a decision — is final. Only "no answer at all"
|
|
178
|
+
// earns a replay.
|
|
179
|
+
if (posted.ok || posted.status !== 0)
|
|
180
|
+
return posted;
|
|
181
|
+
const why = posted.failure === 'timeout'
|
|
182
|
+
? `reply POST ${posted.error}`
|
|
183
|
+
: `reply POST failed (${describeFailure(posted)})`;
|
|
184
|
+
log(`${timestamp()} … ${label} ${why} — replaying with the same idempotency key`);
|
|
185
|
+
posted = await send();
|
|
186
|
+
if (posted.ok) {
|
|
187
|
+
log(`${timestamp()} ✓ ${label} reply confirmed on replay`);
|
|
188
|
+
return posted;
|
|
189
|
+
}
|
|
190
|
+
if (isIdempotencyInFlight(posted)) {
|
|
191
|
+
log(`${timestamp()} … ${label} first reply attempt is still in flight server-side — ` +
|
|
192
|
+
`replaying again in ${REPLAY_IN_FLIGHT_DELAY_MS / 1000}s`);
|
|
193
|
+
await sleep(REPLAY_IN_FLIGHT_DELAY_MS);
|
|
194
|
+
posted = await send();
|
|
195
|
+
if (posted.ok)
|
|
196
|
+
log(`${timestamp()} ✓ ${label} reply confirmed on replay`);
|
|
197
|
+
}
|
|
198
|
+
return posted;
|
|
199
|
+
}
|
|
139
200
|
/**
|
|
140
201
|
* Decide where the agent command runs.
|
|
141
202
|
*
|
|
@@ -329,7 +390,15 @@ function codexMcpEntries(options, extraServers, log, workdir) {
|
|
|
329
390
|
if (acceptedExtraEntries.length === 0)
|
|
330
391
|
acceptedExtraNames.clear();
|
|
331
392
|
const targets = [...configuredNames].filter((name) => !acceptedExtraNames.has(name));
|
|
332
|
-
const { entries: disabled, configOverrides, failed } = isolateConfiguredServers(binary, probe, targets, workdir);
|
|
393
|
+
const { entries: disabled, configOverrides, failed, unproven } = isolateConfiguredServers(binary, probe, targets, workdir);
|
|
394
|
+
if (unproven) {
|
|
395
|
+
// Not an `mcp_servers` problem, so it does not get the `mcp_servers`
|
|
396
|
+
// remedy. Upgrading Codex is the honest advice: the switch is read back
|
|
397
|
+
// from `codex features list`, so this fires when that command, the feature,
|
|
398
|
+
// or its name is not what this harness was measured against.
|
|
399
|
+
return fail(`${unproven} — upgrade Codex, or run the daemon with a Codex build that reports ` +
|
|
400
|
+
`"${APPS_FEATURE}" in \`codex features list\``);
|
|
401
|
+
}
|
|
333
402
|
if (failed.length > 0) {
|
|
334
403
|
// The remedy belongs in the message. This string is echoed into the thread
|
|
335
404
|
// the teammate is waiting in, and "could not be disabled" on its own leaves
|
|
@@ -341,6 +410,11 @@ function codexMcpEntries(options, extraServers, log, workdir) {
|
|
|
341
410
|
if (targets.length > 0) {
|
|
342
411
|
log(`${timestamp()} ⚙ your own Codex MCP servers are disabled for agent runs: ${targets.join(', ')}`);
|
|
343
412
|
}
|
|
413
|
+
// Unconditional, so it is stated every run rather than only when something
|
|
414
|
+
// was found: the operator's connectors are account-level and invisible to
|
|
415
|
+
// `codex mcp list`, and "nothing was listed" is exactly the wrong thing for
|
|
416
|
+
// them to conclude from silence here.
|
|
417
|
+
log(`${timestamp()} ⚙ Codex apps (connectors) are switched off for agent runs`);
|
|
344
418
|
if (configOverrides.includes(PLUGINS_DISABLED_OVERRIDE)) {
|
|
345
419
|
log(`${timestamp()} ⚙ Codex plugins are switched off for agent runs — a plugin supplies an MCP ` +
|
|
346
420
|
'server that cannot be disabled on its own');
|
|
@@ -715,7 +789,8 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
715
789
|
// it, and anything wrapped around it reads as a crash instead of a stop.
|
|
716
790
|
const closed = await completeWithRetry(api, run.completion_path, { ok: false, error: RUN_ERROR_CANCELLED_BY_USER }, log, sleep);
|
|
717
791
|
if (!closed.ok) {
|
|
718
|
-
log(` ! could not report the cancellation (
|
|
792
|
+
log(` ! could not report the cancellation (${describeFailure(closed)})` +
|
|
793
|
+
`${closed.status !== 0 && closed.error ? ` ${closed.error}` : ''}`);
|
|
719
794
|
return 'unreported';
|
|
720
795
|
}
|
|
721
796
|
return 'cancelled';
|
|
@@ -732,7 +807,8 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
732
807
|
}
|
|
733
808
|
const completed = await completeWithRetry(api, run.completion_path, { ok: false, error: detail ? `${reason}: ${detail}` : reason }, log, sleep);
|
|
734
809
|
if (!completed.ok) {
|
|
735
|
-
log(` ! could not report failure (
|
|
810
|
+
log(` ! could not report failure (${describeFailure(completed)})` +
|
|
811
|
+
`${completed.status !== 0 && completed.error ? ` ${completed.error}` : ''}`);
|
|
736
812
|
return 'unreported';
|
|
737
813
|
}
|
|
738
814
|
return 'failed';
|
|
@@ -759,7 +835,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
759
835
|
log(` │ ${line}`);
|
|
760
836
|
const completed = await completeWithRetry(api, run.completion_path, { ok: true }, log, sleep);
|
|
761
837
|
if (!completed.ok) {
|
|
762
|
-
log(` ! could not mark the run complete (
|
|
838
|
+
log(` ! could not mark the run complete (${describeFailure(completed)})`);
|
|
763
839
|
return 'unreported';
|
|
764
840
|
}
|
|
765
841
|
if (result.sessionId) {
|
|
@@ -767,16 +843,20 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
767
843
|
}
|
|
768
844
|
return 'succeeded';
|
|
769
845
|
}
|
|
770
|
-
const
|
|
846
|
+
const replyPath = run.reply_path;
|
|
847
|
+
const replyBodyKey = run.reply_body_key;
|
|
848
|
+
const posted = await postReplyWithReplay(api, () => postReply(api, replyPath, replyBodyKey, reply, run.run_id, run.reply_body_extra), label, log, sleep);
|
|
771
849
|
if (!posted.ok) {
|
|
772
|
-
|
|
850
|
+
const failure = describeFailure(posted);
|
|
851
|
+
const detail = posted.status === 0 ? '' : ` ${posted.error ?? ''}`;
|
|
852
|
+
log(`${timestamp()} ✗ ${label} reply rejected (${failure})${detail}`);
|
|
773
853
|
// Undo the mid-run recording. The session exists and the agent's transcript
|
|
774
854
|
// holds this turn, but the conversation never received its answer, so
|
|
775
855
|
// continuing it would skip reasoning nobody can see — the same rule that
|
|
776
856
|
// used to be expressed by simply not recording until the reply landed.
|
|
777
857
|
if (result.sessionRecorded)
|
|
778
858
|
runtime.sessions.forget(sessionKeyFor(run));
|
|
779
|
-
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed
|
|
859
|
+
const reported = await completeWithRetry(api, run.completion_path, { ok: false, error: `posting the reply failed: ${failure}` }, log, sleep);
|
|
780
860
|
return reported.ok ? 'failed' : 'unreported';
|
|
781
861
|
}
|
|
782
862
|
// Advance continuity only after the answer is durable on its reply surface.
|
|
@@ -791,7 +871,7 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
|
|
|
791
871
|
// failure, not a lost answer — say so precisely. Returning false lets the
|
|
792
872
|
// caller forget the run so a redelivery can retry the completion; the
|
|
793
873
|
// reply's idempotency key stops that from posting twice.
|
|
794
|
-
log(`${timestamp()} ! ${label} replied, but marking it complete failed (
|
|
874
|
+
log(`${timestamp()} ! ${label} replied, but marking it complete failed (${describeFailure(completed)})`);
|
|
795
875
|
return 'unreported';
|
|
796
876
|
}
|
|
797
877
|
log(`${timestamp()} ✓ ${label} replied in ${seconds}s (${reply.length} chars)`);
|
|
@@ -932,7 +1012,11 @@ export async function runAgentHarness(options) {
|
|
|
932
1012
|
return metrics;
|
|
933
1013
|
/** One socket lifetime. Resolves ``true`` when the session ever authed. */
|
|
934
1014
|
function connectOnce() {
|
|
935
|
-
|
|
1015
|
+
// The `User-Agent` names the build server-side, the same as every HTTP
|
|
1016
|
+
// call this CLI makes; `client_version` in the auth frame is the copy the
|
|
1017
|
+
// server reads, because a header on an upgrade request is easy to lose in
|
|
1018
|
+
// proxies and logs and a frame field is not.
|
|
1019
|
+
const ws = new Ctor(url, { headers: { 'User-Agent': USER_AGENT }, handshakeTimeout: HANDSHAKE_TIMEOUT_MS });
|
|
936
1020
|
let authed = false;
|
|
937
1021
|
return new Promise((resolve) => {
|
|
938
1022
|
const close = () => {
|
|
@@ -949,7 +1033,33 @@ export async function runAgentHarness(options) {
|
|
|
949
1033
|
else
|
|
950
1034
|
options.signal.addEventListener('abort', close, { once: true });
|
|
951
1035
|
}
|
|
1036
|
+
// The server pings every 30 s and reaps after 60 s without a pong — but
|
|
1037
|
+
// that close never reaches a client whose kernel still believes the
|
|
1038
|
+
// connection is up (a laptop that slept through the FIN). Keep our own
|
|
1039
|
+
// deadline: no frame of any kind for the window means the socket is
|
|
1040
|
+
// dead, and tearing it down here is what makes the reconnect loop run.
|
|
1041
|
+
const liveness = new ServerLiveness({
|
|
1042
|
+
timeoutMs: options.livenessTimeoutMs,
|
|
1043
|
+
heartbeatIntervalMs: options.heartbeatLogIntervalMs,
|
|
1044
|
+
onSilence: (silentMs) => {
|
|
1045
|
+
metrics.livenessTimeouts += 1;
|
|
1046
|
+
log(`${timestamp()} … no server ping for ${seconds(silentMs)}s — reconnecting`);
|
|
1047
|
+
terminateSocket(ws);
|
|
1048
|
+
},
|
|
1049
|
+
onHeartbeat: (state) => {
|
|
1050
|
+
if (!authed)
|
|
1051
|
+
return;
|
|
1052
|
+
const since = state.sinceLastPingMs === undefined
|
|
1053
|
+
? `no server ping yet (connected ${seconds(state.sinceLastFrameMs)}s ago)`
|
|
1054
|
+
: `last server ping ${seconds(state.sinceLastPingMs)}s ago`;
|
|
1055
|
+
log(`${timestamp()} ● connected — ${since}`);
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
952
1058
|
ws.on('open', () => {
|
|
1059
|
+
// Kernel-level probe underneath the application-level one: a dead
|
|
1060
|
+
// peer is detected by the TCP stack even while the socket is idle.
|
|
1061
|
+
enableTcpKeepAlive(ws);
|
|
1062
|
+
liveness.start();
|
|
953
1063
|
// `subscription_id` is a presence signal, not a scoping one: the server
|
|
954
1064
|
// derives which subscription's events this socket receives from the
|
|
955
1065
|
// connection itself. Sending it stamps `connected_from` with this
|
|
@@ -959,6 +1069,7 @@ export async function runAgentHarness(options) {
|
|
|
959
1069
|
type: 'auth',
|
|
960
1070
|
api_key: options.credentials.api_key,
|
|
961
1071
|
machine,
|
|
1072
|
+
client_version: CLI_VERSION,
|
|
962
1073
|
...(options.credentials.subscription_id
|
|
963
1074
|
? { subscription_id: options.credentials.subscription_id }
|
|
964
1075
|
: {}),
|
|
@@ -970,11 +1081,16 @@ export async function runAgentHarness(options) {
|
|
|
970
1081
|
frame = JSON.parse(data.toString());
|
|
971
1082
|
}
|
|
972
1083
|
catch {
|
|
1084
|
+
// Unparseable, but the server sent it: still proof it is there.
|
|
1085
|
+
liveness.observe(undefined);
|
|
973
1086
|
return;
|
|
974
1087
|
}
|
|
975
|
-
if (!frame || typeof frame !== 'object')
|
|
1088
|
+
if (!frame || typeof frame !== 'object') {
|
|
1089
|
+
liveness.observe(undefined);
|
|
976
1090
|
return;
|
|
1091
|
+
}
|
|
977
1092
|
const f = frame;
|
|
1093
|
+
liveness.observe(f.type);
|
|
978
1094
|
if (f.type === 'auth_ok') {
|
|
979
1095
|
authed = true;
|
|
980
1096
|
const name = options.credentials.agent?.name ?? 'agent';
|
|
@@ -1148,6 +1264,7 @@ export async function runAgentHarness(options) {
|
|
|
1148
1264
|
});
|
|
1149
1265
|
});
|
|
1150
1266
|
ws.on('close', () => {
|
|
1267
|
+
liveness.stop();
|
|
1151
1268
|
if (options.signal)
|
|
1152
1269
|
options.signal.removeEventListener('abort', close);
|
|
1153
1270
|
resolve(authed);
|
package/dist/listen.d.ts
CHANGED
|
@@ -31,12 +31,19 @@ export interface ListenOptions {
|
|
|
31
31
|
* and the final status has been emitted.
|
|
32
32
|
*/
|
|
33
33
|
signal?: AbortSignal;
|
|
34
|
+
/**
|
|
35
|
+
* How long the server may go silent before the socket is torn down.
|
|
36
|
+
* Defaults to {@link PING_LIVENESS_MS}; tests shorten it.
|
|
37
|
+
*/
|
|
38
|
+
livenessTimeoutMs?: number;
|
|
34
39
|
}
|
|
35
40
|
export interface ListenMetrics {
|
|
36
41
|
connected: boolean;
|
|
37
42
|
eventsReceived: number;
|
|
38
43
|
eventsForwarded: number;
|
|
39
44
|
forwardErrors: number;
|
|
45
|
+
/** Set when the listener ended because the server went silent, not because it closed. */
|
|
46
|
+
livenessTimeout?: boolean;
|
|
40
47
|
}
|
|
41
48
|
/**
|
|
42
49
|
* `/ws/webhooks` is served by **api_public**, which is `connect.prohost.ai` —
|
package/dist/listen.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import WebSocket from 'ws';
|
|
7
7
|
import { forwardEvent, isWebhookEvent } from './forward.js';
|
|
8
|
+
import { ServerLiveness, enableTcpKeepAlive, seconds, terminateSocket } from './liveness.js';
|
|
9
|
+
import { CLI_VERSION, USER_AGENT } from './version.js';
|
|
8
10
|
/**
|
|
9
11
|
* `/ws/webhooks` is served by **api_public**, which is `connect.prohost.ai` —
|
|
10
12
|
* the same host as {@link DEFAULT_API_BASE_URL}. It is NOT on `api.prohost.ai`
|
|
@@ -35,7 +37,7 @@ export async function runListen(options) {
|
|
|
35
37
|
eventsForwarded: 0,
|
|
36
38
|
forwardErrors: 0,
|
|
37
39
|
};
|
|
38
|
-
const ws = new Ctor(url);
|
|
40
|
+
const ws = new Ctor(url, { headers: { 'User-Agent': USER_AGENT } });
|
|
39
41
|
return new Promise((resolve) => {
|
|
40
42
|
const status = () => {
|
|
41
43
|
const dot = metrics.connected ? '\u25CF' : '\u25CB';
|
|
@@ -55,11 +57,27 @@ export async function runListen(options) {
|
|
|
55
57
|
else
|
|
56
58
|
options.signal.addEventListener('abort', closeWith, { once: true });
|
|
57
59
|
}
|
|
60
|
+
// Same contract as the agent harness (see `liveness.ts`): the server's
|
|
61
|
+
// 60 s reaper cannot close a socket whose FIN never reached us, so keep a
|
|
62
|
+
// client-side deadline. `listen` has no reconnect loop \u2014 it resolves on
|
|
63
|
+
// close and the operator restarts it \u2014 so this ends the session with a
|
|
64
|
+
// reason on screen instead of a counter that silently stopped moving.
|
|
65
|
+
const liveness = new ServerLiveness({
|
|
66
|
+
timeoutMs: options.livenessTimeoutMs,
|
|
67
|
+
onSilence: (silentMs) => {
|
|
68
|
+
metrics.livenessTimeout = true;
|
|
69
|
+
onStatus(`\n\u2026 no server ping for ${seconds(silentMs)}s \u2014 connection lost\n`);
|
|
70
|
+
terminateSocket(ws);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
58
73
|
ws.on('open', () => {
|
|
74
|
+
enableTcpKeepAlive(ws);
|
|
75
|
+
liveness.start();
|
|
59
76
|
const authFrame = {
|
|
60
77
|
type: 'auth',
|
|
61
78
|
api_key: options.apiKey,
|
|
62
79
|
machine,
|
|
80
|
+
client_version: CLI_VERSION,
|
|
63
81
|
...(options.subscriptionId ? { subscription_id: options.subscriptionId } : {}),
|
|
64
82
|
};
|
|
65
83
|
ws.send(JSON.stringify(authFrame));
|
|
@@ -70,11 +88,15 @@ export async function runListen(options) {
|
|
|
70
88
|
frame = JSON.parse(data.toString());
|
|
71
89
|
}
|
|
72
90
|
catch {
|
|
91
|
+
liveness.observe(undefined);
|
|
73
92
|
return;
|
|
74
93
|
}
|
|
75
|
-
if (!frame || typeof frame !== 'object')
|
|
94
|
+
if (!frame || typeof frame !== 'object') {
|
|
95
|
+
liveness.observe(undefined);
|
|
76
96
|
return;
|
|
97
|
+
}
|
|
77
98
|
const f = frame;
|
|
99
|
+
liveness.observe(f.type);
|
|
78
100
|
if (f.type === 'auth_ok') {
|
|
79
101
|
metrics.connected = true;
|
|
80
102
|
status();
|
|
@@ -114,6 +136,7 @@ export async function runListen(options) {
|
|
|
114
136
|
}
|
|
115
137
|
});
|
|
116
138
|
ws.on('close', () => {
|
|
139
|
+
liveness.stop();
|
|
117
140
|
metrics.connected = false;
|
|
118
141
|
status();
|
|
119
142
|
resolve(metrics);
|