@prohost/cli 0.2.1 → 0.2.3
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 +211 -0
- package/README.md +198 -17
- package/dist/agent/claude.d.ts +23 -10
- package/dist/agent/claude.js +24 -30
- package/dist/agent/codex.d.ts +365 -0
- package/dist/agent/codex.js +901 -0
- package/dist/agent/contract.d.ts +62 -3
- package/dist/agent/contract.js +66 -3
- package/dist/agent/extras.d.ts +73 -0
- package/dist/agent/extras.js +118 -0
- package/dist/agent/mcp.d.ts +24 -0
- package/dist/agent/mcp.js +24 -0
- package/dist/agent/prompt.d.ts +12 -0
- package/dist/agent/prompt.js +107 -4
- package/dist/agent/run.d.ts +33 -0
- package/dist/agent/run.js +263 -50
- package/dist/agent/shell.d.ts +23 -0
- package/dist/agent/shell.js +27 -0
- package/dist/index.js +10 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that knows anything about Codex CLI specifically.
|
|
3
|
+
*
|
|
4
|
+
* The sibling of `claude.ts`, and it exists for the same reason: `exec.ts` is
|
|
5
|
+
* deliberately agent-agnostic — prompt on stdin, reply on stdout — and that
|
|
6
|
+
* lowest common denominator costs a Codex run the two things it can actually
|
|
7
|
+
* have. A run has no memory of the previous one, and it has no tools for
|
|
8
|
+
* looking anything up in the workspace it is answering about. Until this
|
|
9
|
+
* module existed, a `codex` brain ran bare while a `claude` brain got both, so
|
|
10
|
+
* the same mention answered by two hosts produced visibly different work.
|
|
11
|
+
*
|
|
12
|
+
* Everything here was verified against a real `codex-cli 0.144.1` rather than
|
|
13
|
+
* taken from documentation; the surprises are called out where they are relied
|
|
14
|
+
* on. Output and configuration used to prove the MCP boundary are deliberately
|
|
15
|
+
* load-bearing: if that proof is incomplete, the unsandboxed run must not
|
|
16
|
+
* start. Session continuity and reply parsing still fail independently.
|
|
17
|
+
*/
|
|
18
|
+
import { MCP_SERVER_NAME } from './mcp.js';
|
|
19
|
+
import { shellQuote } from './shell.js';
|
|
20
|
+
/**
|
|
21
|
+
* Environment variable the API key is handed to Codex in.
|
|
22
|
+
*
|
|
23
|
+
* Codex resolves MCP headers from the environment at request time
|
|
24
|
+
* (`env_http_headers`), which is why the key travels this way rather than in
|
|
25
|
+
* the config override. The override is part of the command line, and a command
|
|
26
|
+
* line is world-readable out of `ps`; a child's environment is not. This is the
|
|
27
|
+
* one place the Codex path is meaningfully *safer* than the Claude one, which
|
|
28
|
+
* has to keep the key in a `0600` file on disk.
|
|
29
|
+
*/
|
|
30
|
+
export const MCP_API_KEY_ENV_VAR = 'PROHOST_MCP_API_KEY';
|
|
31
|
+
/**
|
|
32
|
+
* Is this exec command Codex CLI in non-interactive mode?
|
|
33
|
+
*
|
|
34
|
+
* A **heuristic**, and deliberately as narrow as {@link isClaudeExec}: it
|
|
35
|
+
* matches when the first word of the command is `codex` (or a path ending in
|
|
36
|
+
* `/codex`) *and* the command uses the `exec` subcommand (or its `e` alias).
|
|
37
|
+
* Both halves matter. Interactive `codex` has no `--json` and no `resume`
|
|
38
|
+
* subcommand, so shaping it would break a command that works today.
|
|
39
|
+
*
|
|
40
|
+
* Three things are deliberately not shaped, each because appending to it would
|
|
41
|
+
* do harm rather than nothing:
|
|
42
|
+
*
|
|
43
|
+
* * `codex exec resume …` and `codex exec review …`. The host already chose a
|
|
44
|
+
* sub-subcommand; appending our own `resume` would produce two of them.
|
|
45
|
+
* * Anything after a `--` sentinel, which is the host saying "stop parsing".
|
|
46
|
+
* * A command carrying its own unquoted prompt, e.g. `codex exec hello`. Codex
|
|
47
|
+
* takes exactly one prompt positional, so both the `-` this harness appends
|
|
48
|
+
* and a `resume <id>` would be a second one — `error: unexpected argument
|
|
49
|
+
* '-' found`, on a command that works today. Such a host keeps the plain
|
|
50
|
+
* contract; the place to put standing instructions is now the agent's
|
|
51
|
+
* ProhostAI profile, which arrives in the prompt itself.
|
|
52
|
+
*
|
|
53
|
+
* Shell syntax is different: recognisable Codex exec commands containing it
|
|
54
|
+
* stay classified as Codex so the harness can fail closed before spawn. Letting
|
|
55
|
+
* one fall through as a plain command would also drop the verified MCP boundary.
|
|
56
|
+
*/
|
|
57
|
+
export function isCodexExec(command, platform = process.platform) {
|
|
58
|
+
// Strip one matching quote pair for classification only. The real command is
|
|
59
|
+
// never rebuilt from these words: any quote makes the shell-syntax check
|
|
60
|
+
// below fail closed before a run. This small normalization is what prevents
|
|
61
|
+
// `codex exec '--enable' plugins` from falling through to the unshaped/plain
|
|
62
|
+
// path merely because the shell removes quotes our whitespace scan cannot.
|
|
63
|
+
const words = command
|
|
64
|
+
.trim()
|
|
65
|
+
.split(/\s+/)
|
|
66
|
+
.map((word) => {
|
|
67
|
+
const match = /^(['"])(.*)\1$/.exec(word);
|
|
68
|
+
return match?.[2] ?? word;
|
|
69
|
+
});
|
|
70
|
+
const trimmed = command.trim();
|
|
71
|
+
const quote = trimmed[0];
|
|
72
|
+
const closingQuote = quote === "'" || quote === '"' ? trimmed.indexOf(quote, 1) : -1;
|
|
73
|
+
const first = closingQuote > 0 ? trimmed.slice(1, closingQuote) : (words[0] ?? '');
|
|
74
|
+
const base = first.split(/[\\/]/).pop() ?? '';
|
|
75
|
+
if (base !== 'codex' && base !== 'codex.cmd' && base !== 'codex.exe')
|
|
76
|
+
return false;
|
|
77
|
+
const rest = words.slice(1);
|
|
78
|
+
if (!rest.includes('exec') && !rest.includes('e'))
|
|
79
|
+
return false;
|
|
80
|
+
// Once this is recognisably a Codex exec command, unsupported shell syntax
|
|
81
|
+
// must stay on the Codex path so preparation can refuse it. Treating it as a
|
|
82
|
+
// plain command would run the same Codex binary without the verified MCP
|
|
83
|
+
// boundary, exactly the failure this classifier exists to prevent.
|
|
84
|
+
if (codexShellSyntax(command, platform))
|
|
85
|
+
return true;
|
|
86
|
+
if (words.includes('--'))
|
|
87
|
+
return false;
|
|
88
|
+
// A host who already picked `resume` or `review` gets left alone entirely.
|
|
89
|
+
if (rest.includes('resume') || rest.includes('review'))
|
|
90
|
+
return false;
|
|
91
|
+
return !hasExplicitPrompt(words);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Shell syntax a shaped Codex command cannot safely preserve.
|
|
95
|
+
*
|
|
96
|
+
* Codex commands are eventually executed with `shell: true`, while the harness
|
|
97
|
+
* currently operates on whitespace-delimited words. Quotes, escapes,
|
|
98
|
+
* expansions, globs, comments and control/redirection operators all make those
|
|
99
|
+
* two views differ. Rather than guess at POSIX plus cmd.exe tokenization, only
|
|
100
|
+
* the deliberately boring argv-shaped subset is accepted. A matching command
|
|
101
|
+
* is still classified as Codex and then rejected before spawn.
|
|
102
|
+
*/
|
|
103
|
+
export function codexShellSyntax(command, platform = process.platform) {
|
|
104
|
+
// Backslash is a POSIX escape, but an ordinary path separator to cmd.exe.
|
|
105
|
+
// Windows's own expansion/escape characters (`%`, `^`, `!`) remain rejected,
|
|
106
|
+
// along with the control and quoting syntax shared by both shells.
|
|
107
|
+
const unsupported = platform === 'win32'
|
|
108
|
+
? /['"$`|&;<>(){}*?\[\]~#\r\n%^!]/
|
|
109
|
+
: /['"\\$`|&;<>(){}*?\[\]~#\r\n%^!]/;
|
|
110
|
+
const match = unsupported.exec(command);
|
|
111
|
+
return match?.[0];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Does the command already carry a prompt of the host's own?
|
|
115
|
+
*
|
|
116
|
+
* Anything after the `exec` token that is not a flag, not a flag's value, and
|
|
117
|
+
* not the bare `-` stdin marker can only be Codex's one `[PROMPT]` positional.
|
|
118
|
+
*/
|
|
119
|
+
function hasExplicitPrompt(words) {
|
|
120
|
+
const execIndex = words.findIndex((word, index) => index > 0 && (word === 'exec' || word === 'e'));
|
|
121
|
+
if (execIndex === -1)
|
|
122
|
+
return false;
|
|
123
|
+
for (let i = execIndex + 1; i < words.length; i += 1) {
|
|
124
|
+
const word = words[i] ?? '';
|
|
125
|
+
if (word === '-' || word.startsWith('-'))
|
|
126
|
+
continue;
|
|
127
|
+
if (VALUE_TAKING_FLAGS.has(words[i - 1] ?? ''))
|
|
128
|
+
continue;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Flags that take a following value, for the bare-`-` scan below.
|
|
135
|
+
*
|
|
136
|
+
* Not a complete model of Codex's command line — just enough to tell the prompt
|
|
137
|
+
* positional `-` from a `-` that is somebody's argument.
|
|
138
|
+
*/
|
|
139
|
+
const VALUE_TAKING_FLAGS = new Set([
|
|
140
|
+
'-c',
|
|
141
|
+
'--config',
|
|
142
|
+
'-m',
|
|
143
|
+
'--model',
|
|
144
|
+
'-i',
|
|
145
|
+
'--image',
|
|
146
|
+
'-o',
|
|
147
|
+
'--output-last-message',
|
|
148
|
+
'-s',
|
|
149
|
+
'--sandbox',
|
|
150
|
+
'-C',
|
|
151
|
+
'--cd',
|
|
152
|
+
'-p',
|
|
153
|
+
'--profile',
|
|
154
|
+
'--add-dir',
|
|
155
|
+
'--output-schema',
|
|
156
|
+
'--color',
|
|
157
|
+
'--local-provider',
|
|
158
|
+
'--enable',
|
|
159
|
+
'--disable',
|
|
160
|
+
]);
|
|
161
|
+
/**
|
|
162
|
+
* Drop the prompt positional from the host's command.
|
|
163
|
+
*
|
|
164
|
+
* The README has recommended `codex exec -` since 0.2.0, and `codex exec -` is
|
|
165
|
+
* the form most people write anyway. We re-add the `-` ourselves at the very
|
|
166
|
+
* end, after any `resume <id>` — leaving the host's in place would make it the
|
|
167
|
+
* `SESSION_ID` positional and push `resume` into being a second one, which
|
|
168
|
+
* Codex rejects outright.
|
|
169
|
+
*
|
|
170
|
+
* A `-` that follows a value-taking flag is that flag's argument, not the
|
|
171
|
+
* prompt, so it stays.
|
|
172
|
+
*/
|
|
173
|
+
function withoutPromptPositional(words) {
|
|
174
|
+
return words.filter((word, index) => word !== '-' || VALUE_TAKING_FLAGS.has(words[index - 1] ?? ''));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Escape a value for a TOML basic string.
|
|
178
|
+
*
|
|
179
|
+
* The `-c` override is parsed as TOML, so the URL lands inside double quotes
|
|
180
|
+
* and has to survive that. Control characters are escaped rather than passed
|
|
181
|
+
* through because TOML rejects raw ones and the whole override would fail to
|
|
182
|
+
* parse — taking the agent's tools with it.
|
|
183
|
+
*/
|
|
184
|
+
function tomlString(value) {
|
|
185
|
+
const escaped = value
|
|
186
|
+
.replace(/\\/g, '\\\\')
|
|
187
|
+
.replace(/"/g, '\\"')
|
|
188
|
+
.replace(/[\u0000-\u001f\u007f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
189
|
+
return `"${escaped}"`;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The `-c` override that registers the ProhostAI MCP server.
|
|
193
|
+
*
|
|
194
|
+
* Codex takes a streamable-HTTP MCP server with **arbitrary** headers, which is
|
|
195
|
+
* what makes this possible at all: `codex mcp add --url` offers only
|
|
196
|
+
* `--bearer-token-env-var`, and a bearer token is the *wrong credential* for
|
|
197
|
+
* this endpoint — the server validates `Authorization: Bearer` as an OAuth
|
|
198
|
+
* access token, checks it first, and does not fall through to `X-API-Key` when
|
|
199
|
+
* it fails. A pairing hands out an `X-API-Key`. The `-c` path reaches the same
|
|
200
|
+
* config layer with none of that restriction.
|
|
201
|
+
*
|
|
202
|
+
* `env_http_headers` rather than `http_headers`: both work and both were
|
|
203
|
+
* verified against the running binary, but the former names an environment
|
|
204
|
+
* variable to read the value from instead of carrying the value itself, which
|
|
205
|
+
* keeps a live API key off a command line that anyone on the machine can read
|
|
206
|
+
* out of `ps`.
|
|
207
|
+
*/
|
|
208
|
+
export function prohostMcpEntry(mcpUrl) {
|
|
209
|
+
return (`${tomlString(MCP_SERVER_NAME)}={url=${tomlString(mcpUrl)}, ` +
|
|
210
|
+
`env_http_headers={"X-API-Key"=${tomlString(MCP_API_KEY_ENV_VAR)}}}`);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The whole `mcp_servers` override for a run, ProhostAI server included.
|
|
214
|
+
*
|
|
215
|
+
* It is one override, and it has to be: `-c` overrides are applied in order,
|
|
216
|
+
* and a later whole-table assignment **replaces** an earlier dotted one rather
|
|
217
|
+
* than merging with it. `-c mcp_servers.prohost={…} -c mcp_servers={…}` — the
|
|
218
|
+
* shape this had until it was measured — therefore drops the ProhostAI server
|
|
219
|
+
* outright, on every run that disables anything, while the harness went on
|
|
220
|
+
* logging "ProhostAI tools wired in" and the prompt went on telling the agent
|
|
221
|
+
* it had them. (Deep-merging is what happens between the CLI override and the
|
|
222
|
+
* *config file*, which is what made the wrong shape look right.)
|
|
223
|
+
*/
|
|
224
|
+
export function buildMcpOverride(mcpUrl) {
|
|
225
|
+
return mcpServersOverride([prohostMcpEntry(mcpUrl)]);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Encode a JSON value as a TOML inline value.
|
|
229
|
+
*
|
|
230
|
+
* Only needed for `extra-mcp.json`, whose entries are written by the operator
|
|
231
|
+
* in Claude Code's JSON shape and have to reach Codex through `-c`, which parses
|
|
232
|
+
* its value as TOML. Deliberately total over what JSON can hold and nothing
|
|
233
|
+
* more: a value it cannot represent returns `null` and the whole server is
|
|
234
|
+
* skipped with a warning, because a half-encoded table would not merely lose
|
|
235
|
+
* that server — a `-c` override that fails to parse takes the entire config
|
|
236
|
+
* down with it, ProhostAI server included.
|
|
237
|
+
*
|
|
238
|
+
* `null` is dropped from tables rather than encoded, TOML having no null.
|
|
239
|
+
*/
|
|
240
|
+
function tomlValue(value) {
|
|
241
|
+
if (typeof value === 'string')
|
|
242
|
+
return tomlString(value);
|
|
243
|
+
if (typeof value === 'boolean')
|
|
244
|
+
return value ? 'true' : 'false';
|
|
245
|
+
if (typeof value === 'number')
|
|
246
|
+
return Number.isFinite(value) ? String(value) : null;
|
|
247
|
+
if (Array.isArray(value)) {
|
|
248
|
+
const items = value.map(tomlValue);
|
|
249
|
+
return items.some((item) => item === null) ? null : `[${items.join(', ')}]`;
|
|
250
|
+
}
|
|
251
|
+
if (value && typeof value === 'object') {
|
|
252
|
+
const pairs = [];
|
|
253
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
254
|
+
if (entry === null || entry === undefined)
|
|
255
|
+
continue;
|
|
256
|
+
const encoded = tomlValue(entry);
|
|
257
|
+
if (encoded === null)
|
|
258
|
+
return null;
|
|
259
|
+
pairs.push(`${tomlString(key)}=${encoded}`);
|
|
260
|
+
}
|
|
261
|
+
return `{${pairs.join(', ')}}`;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Turn `extra-mcp.json` entries into `mcp_servers` table fragments.
|
|
267
|
+
*
|
|
268
|
+
* The name is quoted rather than written as a dotted path, which is what lets
|
|
269
|
+
* *any* name work. `-c` splits on the first `=` and reads everything before it
|
|
270
|
+
* as a dotted path, so `-c mcp_servers.fly.io.enabled=false` addresses
|
|
271
|
+
* `mcp_servers.fly.io` — three segments, not two — and Codex rejects the whole
|
|
272
|
+
* config with `invalid transport in mcp_servers.fly`. Quoting the segment does
|
|
273
|
+
* not help either: `mcp_servers."fly.io"` fails the same way, because the
|
|
274
|
+
* splitting happens before any TOML is parsed. Putting the name inside the
|
|
275
|
+
* *value* sidesteps the path parser entirely.
|
|
276
|
+
*
|
|
277
|
+
* Claude's JSON shape carries literal `headers` and stdio `env` values. Those
|
|
278
|
+
* must not be copied literally into Codex's `-c` argument: command lines are
|
|
279
|
+
* readable by other local processes. Translate them to Codex's indirection
|
|
280
|
+
* fields and put the values in the child environment instead. `type` is also a
|
|
281
|
+
* Claude-only discriminator; Codex infers the transport from `url`/`command`.
|
|
282
|
+
*/
|
|
283
|
+
export function extraMcpEntries(servers, platform = process.platform) {
|
|
284
|
+
const entries = [];
|
|
285
|
+
const skipped = [];
|
|
286
|
+
const env = {};
|
|
287
|
+
let secretIndex = 0;
|
|
288
|
+
const stringRecord = (value) => {
|
|
289
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
290
|
+
return null;
|
|
291
|
+
const pairs = Object.entries(value);
|
|
292
|
+
if (pairs.some(([, entry]) => typeof entry !== 'string'))
|
|
293
|
+
return null;
|
|
294
|
+
return Object.fromEntries(pairs);
|
|
295
|
+
};
|
|
296
|
+
// Literal headers need generated environment names. Reserve every name any
|
|
297
|
+
// extra already references *before* allocating one: otherwise an earlier
|
|
298
|
+
// header can claim PROHOST_EXTRA_MCP_SECRET_0 and a later extra's env_vars or
|
|
299
|
+
// env_http_headers can read that credential across the server boundary.
|
|
300
|
+
// Windows environment-variable lookup is case-insensitive. Keep the emitted
|
|
301
|
+
// spelling untouched, but compare identities the same way the child process
|
|
302
|
+
// will, so a lower-case alias cannot read the paired or generated credential.
|
|
303
|
+
const envNameKey = (name) => (platform === 'win32' ? name.toUpperCase() : name);
|
|
304
|
+
const prohibitedEnvName = envNameKey(MCP_API_KEY_ENV_VAR);
|
|
305
|
+
const isProhibitedEnvName = (name) => envNameKey(name) === prohibitedEnvName;
|
|
306
|
+
const reservedEnvNames = new Set([prohibitedEnvName]);
|
|
307
|
+
const assignedEnvValues = new Map();
|
|
308
|
+
for (const definition of Object.values(servers)) {
|
|
309
|
+
const declaredEnvVars = definition.env_vars;
|
|
310
|
+
if (Array.isArray(declaredEnvVars)) {
|
|
311
|
+
for (const name of declaredEnvVars) {
|
|
312
|
+
if (typeof name === 'string')
|
|
313
|
+
reservedEnvNames.add(envNameKey(name));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const declaredHeaderEnv = stringRecord(definition.env_http_headers);
|
|
317
|
+
if (declaredHeaderEnv) {
|
|
318
|
+
for (const name of Object.values(declaredHeaderEnv))
|
|
319
|
+
reservedEnvNames.add(envNameKey(name));
|
|
320
|
+
}
|
|
321
|
+
const literalEnv = definition.env;
|
|
322
|
+
if (literalEnv && typeof literalEnv === 'object' && !Array.isArray(literalEnv)) {
|
|
323
|
+
for (const name of Object.keys(literalEnv))
|
|
324
|
+
reservedEnvNames.add(envNameKey(name));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const secretVariable = () => {
|
|
328
|
+
let variable;
|
|
329
|
+
do
|
|
330
|
+
variable = `PROHOST_EXTRA_MCP_SECRET_${secretIndex++}`;
|
|
331
|
+
while (reservedEnvNames.has(envNameKey(variable)) || assignedEnvValues.has(envNameKey(variable)));
|
|
332
|
+
reservedEnvNames.add(envNameKey(variable));
|
|
333
|
+
return variable;
|
|
334
|
+
};
|
|
335
|
+
for (const [name, original] of Object.entries(servers)) {
|
|
336
|
+
const definition = { ...original };
|
|
337
|
+
const definitionEnv = {};
|
|
338
|
+
let invalid = false;
|
|
339
|
+
delete definition.type;
|
|
340
|
+
const declaredHeaderEnv = definition.env_http_headers;
|
|
341
|
+
if (declaredHeaderEnv !== undefined) {
|
|
342
|
+
const indirect = stringRecord(declaredHeaderEnv);
|
|
343
|
+
if (indirect === null || Object.values(indirect).some(isProhibitedEnvName)) {
|
|
344
|
+
skipped.push(name);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const declaredEnvVars = definition.env_vars;
|
|
349
|
+
if (declaredEnvVars !== undefined &&
|
|
350
|
+
(!Array.isArray(declaredEnvVars) ||
|
|
351
|
+
declaredEnvVars.some((entry) => typeof entry !== 'string') ||
|
|
352
|
+
declaredEnvVars.some((entry) => typeof entry === 'string' && isProhibitedEnvName(entry)))) {
|
|
353
|
+
skipped.push(name);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const literalHeaders = definition.headers ?? definition.http_headers;
|
|
357
|
+
if (literalHeaders !== undefined) {
|
|
358
|
+
const headers = stringRecord(literalHeaders);
|
|
359
|
+
const existing = definition.env_http_headers;
|
|
360
|
+
const indirect = existing === undefined ? {} : stringRecord(existing);
|
|
361
|
+
if (headers === null || indirect === null) {
|
|
362
|
+
skipped.push(name);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (Object.values(indirect).some(isProhibitedEnvName)) {
|
|
366
|
+
skipped.push(name);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
for (const [header, value] of Object.entries(headers)) {
|
|
370
|
+
const variable = secretVariable();
|
|
371
|
+
definitionEnv[variable] = value;
|
|
372
|
+
indirect[header] = variable;
|
|
373
|
+
}
|
|
374
|
+
definition.env_http_headers = indirect;
|
|
375
|
+
delete definition.headers;
|
|
376
|
+
delete definition.http_headers;
|
|
377
|
+
}
|
|
378
|
+
if (definition.env !== undefined) {
|
|
379
|
+
const variables = stringRecord(definition.env);
|
|
380
|
+
const existing = definition.env_vars;
|
|
381
|
+
if (variables === null ||
|
|
382
|
+
(existing !== undefined &&
|
|
383
|
+
(!Array.isArray(existing) || existing.some((entry) => typeof entry !== 'string')))) {
|
|
384
|
+
skipped.push(name);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const names = new Set(existing);
|
|
388
|
+
const definitionEnvNames = new Set(Object.keys(definitionEnv).map(envNameKey));
|
|
389
|
+
for (const [variable, value] of Object.entries(variables)) {
|
|
390
|
+
const envKey = envNameKey(variable);
|
|
391
|
+
const assignedValue = assignedEnvValues.get(envKey);
|
|
392
|
+
if (isProhibitedEnvName(variable) ||
|
|
393
|
+
(assignedValue !== undefined && assignedValue !== value) ||
|
|
394
|
+
definitionEnvNames.has(envKey)) {
|
|
395
|
+
invalid = true;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
definitionEnv[variable] = value;
|
|
399
|
+
definitionEnvNames.add(envKey);
|
|
400
|
+
names.add(variable);
|
|
401
|
+
}
|
|
402
|
+
if (invalid || [...names].some(isProhibitedEnvName)) {
|
|
403
|
+
skipped.push(name);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
definition.env_vars = [...names];
|
|
407
|
+
delete definition.env;
|
|
408
|
+
}
|
|
409
|
+
const encoded = tomlValue(definition);
|
|
410
|
+
if (encoded === null) {
|
|
411
|
+
skipped.push(name);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
entries.push(`${tomlString(name)}=${encoded}`);
|
|
415
|
+
for (const [variable, value] of Object.entries(definitionEnv)) {
|
|
416
|
+
if (value === undefined)
|
|
417
|
+
continue;
|
|
418
|
+
const envKey = envNameKey(variable);
|
|
419
|
+
if (!assignedEnvValues.has(envKey))
|
|
420
|
+
env[variable] = value;
|
|
421
|
+
assignedEnvValues.set(envKey, value);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { entries, skipped, env };
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Table fragments that switch off the servers the operator's Codex install has.
|
|
428
|
+
*
|
|
429
|
+
* This is the Codex answer to `--strict-mcp-config`, and it took a real binary
|
|
430
|
+
* to find. Two mechanisms that look like they should work do not: replacing the
|
|
431
|
+
* table wholesale does **not** replace it — Codex deep-merges config overrides,
|
|
432
|
+
* so the operator's servers survive alongside whatever the override adds — and
|
|
433
|
+
* `--ignore-user-config` does work but drops the operator's *entire* config
|
|
434
|
+
* with it, taking their model, profile and sandbox settings along for a
|
|
435
|
+
* security fix they did not ask to pay for that way.
|
|
436
|
+
*
|
|
437
|
+
* Setting `enabled=false` per server is the surgical one, verified end to end
|
|
438
|
+
* against `codex-cli 0.144.1`: the server is then reported `disabled` by
|
|
439
|
+
* `codex mcp list`, its own definition is left intact, other servers are
|
|
440
|
+
* untouched, and its tools are genuinely absent from the model's tool list
|
|
441
|
+
* mid-run.
|
|
442
|
+
*
|
|
443
|
+
* Names the extras file defines are left alone. Codex would deep-merge
|
|
444
|
+
* `enabled=false` into the operator's definition of the same name and produce a
|
|
445
|
+
* server the operator asked for twice and got zero times, which is a worse
|
|
446
|
+
* outcome than the collision itself.
|
|
447
|
+
*/
|
|
448
|
+
export function disableMcpEntries(configuredNames, keep = []) {
|
|
449
|
+
const kept = new Set([MCP_SERVER_NAME, ...keep]);
|
|
450
|
+
return configuredNames.filter((name) => !kept.has(name)).map((name) => `${tomlString(name)}={enabled=false}`);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Fold every entry into the single `-c mcp_servers={…}` override.
|
|
454
|
+
*
|
|
455
|
+
* One override rather than one per server, because they all reach the same
|
|
456
|
+
* table and Codex merges rather than replaces — so the combined form is
|
|
457
|
+
* equivalent, shorter on the command line, and (unlike the dotted-path form)
|
|
458
|
+
* imposes no constraint on what a server may be called.
|
|
459
|
+
*/
|
|
460
|
+
export function mcpServersOverride(entries) {
|
|
461
|
+
return entries.length > 0 ? `mcp_servers={${entries.join(', ')}}` : undefined;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Ask the binary what MCP servers it has, optionally under a `-c` override.
|
|
465
|
+
*
|
|
466
|
+
* Asking rather than parsing `~/.codex/config.toml` is deliberate twice over.
|
|
467
|
+
* `codex mcp list --json` already honours `CODEX_HOME` and the binary's normal
|
|
468
|
+
* config layering — and, measured on an ordinary install, it
|
|
469
|
+
* reports servers that appear **nowhere** in that file (plugins contribute
|
|
470
|
+
* their own). A config file we half-understand is exactly how an isolation
|
|
471
|
+
* guarantee ends up with a hole in it.
|
|
472
|
+
*
|
|
473
|
+
* Passing an override makes this a *verifier*: the listing then reflects the
|
|
474
|
+
* config the run would actually get, so "did the disable take" is a question
|
|
475
|
+
* with an answer rather than an assumption.
|
|
476
|
+
*
|
|
477
|
+
* The working directory is equally load-bearing. Codex discovers trusted
|
|
478
|
+
* project `.codex/config.toml` layers from cwd, so a probe in the harness's
|
|
479
|
+
* launch directory cannot certify a run executed in `$PROHOST_HOME/workspace`
|
|
480
|
+
* or an explicit `--workdir`. Every probe uses the eventual run cwd.
|
|
481
|
+
*
|
|
482
|
+
* Returns `null` — distinct from `[]` — when the question could not be
|
|
483
|
+
* answered, which for an override also means "that override does not load".
|
|
484
|
+
* The caller must treat the two differently: an empty list means there is
|
|
485
|
+
* nothing to disable, while `null` means we do not know, and a run that cannot
|
|
486
|
+
* enumerate must say so rather than claim an isolation it did not apply.
|
|
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`;
|
|
490
|
+
let result;
|
|
491
|
+
try {
|
|
492
|
+
result = runner(command, cwd);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
if (result.status !== 0)
|
|
498
|
+
return null;
|
|
499
|
+
try {
|
|
500
|
+
const parsed = JSON.parse(result.stdout);
|
|
501
|
+
if (!Array.isArray(parsed))
|
|
502
|
+
return null;
|
|
503
|
+
const servers = [];
|
|
504
|
+
for (const entry of parsed) {
|
|
505
|
+
// Skipping an entry would turn an output shape we do not understand into
|
|
506
|
+
// "there is no server here". This parser is an isolation verifier, so one
|
|
507
|
+
// malformed item makes the entire answer unknown and the run fails closed.
|
|
508
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
509
|
+
return null;
|
|
510
|
+
const record = entry;
|
|
511
|
+
if (typeof record.name !== 'string' || record.name.length === 0)
|
|
512
|
+
return null;
|
|
513
|
+
servers.push({ name: record.name, enabled: record.enabled !== false });
|
|
514
|
+
}
|
|
515
|
+
return servers;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Disable entries, checked against the binary before they are used.
|
|
523
|
+
*
|
|
524
|
+
* Not paranoia — a measured failure. `{enabled=false}` merges onto a server's
|
|
525
|
+
* existing definition, and a server contributed by a **plugin** has no entry in
|
|
526
|
+
* `mcp_servers` to merge onto, so the override defines a table with no
|
|
527
|
+
* transport and Codex refuses the entire config: `invalid transport in
|
|
528
|
+
* mcp_servers.github`. Shipped unverified, that would not have leaked a
|
|
529
|
+
* server — it would have broken *every* run for anyone with a Codex plugin
|
|
530
|
+
* installed, which is a far worse outcome than the hole it was closing.
|
|
531
|
+
*
|
|
532
|
+
* So the entries are tried before they are trusted: one probe for the whole
|
|
533
|
+
* set, and only if that fails, one probe per server to find which ones can be
|
|
534
|
+
* disabled and which cannot. The happy path costs a single extra command per
|
|
535
|
+
* survey; the slow path is paid only by installs that need it.
|
|
536
|
+
*
|
|
537
|
+
* `failed` is the honest part. A server that cannot be disabled is still
|
|
538
|
+
* reachable from the run, and the caller has to say so out loud rather than
|
|
539
|
+
* report an isolation it only partly achieved.
|
|
540
|
+
*/
|
|
541
|
+
export function disableEntriesThatLoad(binary, runner, targets, cwd) {
|
|
542
|
+
if (targets.length === 0)
|
|
543
|
+
return { entries: [], failed: [] };
|
|
544
|
+
const disabled = (names) => {
|
|
545
|
+
const override = mcpServersOverride(disableMcpEntries(names));
|
|
546
|
+
if (!override)
|
|
547
|
+
return [];
|
|
548
|
+
const listing = listMcpServers(binary, runner, override, cwd);
|
|
549
|
+
// A config that does not load disables nothing, and a server the listing
|
|
550
|
+
// still reports as enabled was not disabled whatever the override said.
|
|
551
|
+
if (listing === null)
|
|
552
|
+
return [];
|
|
553
|
+
const off = new Set(listing.filter((server) => !server.enabled).map((server) => server.name));
|
|
554
|
+
return names.filter((name) => off.has(name));
|
|
555
|
+
};
|
|
556
|
+
const all = disabled(targets);
|
|
557
|
+
if (all.length === targets.length)
|
|
558
|
+
return { entries: disableMcpEntries(targets), failed: [] };
|
|
559
|
+
// The combined override was rejected, or took only partially. Fall back to
|
|
560
|
+
// asking about each server on its own, so one plugin-provided server cannot
|
|
561
|
+
// cost the isolation of every other server the operator has.
|
|
562
|
+
const usable = targets.filter((name) => disabled([name]).length === 1);
|
|
563
|
+
// Then ask again about the survivors *together*. Individually-valid entries
|
|
564
|
+
// combining into a valid one is an inference, and an inference is what put
|
|
565
|
+
// the unverified override in here to begin with — a combination Codex
|
|
566
|
+
// rejected would break every run rather than leak a server.
|
|
567
|
+
const verified = disabled(usable);
|
|
568
|
+
return {
|
|
569
|
+
entries: disableMcpEntries(verified),
|
|
570
|
+
failed: targets.filter((name) => !verified.includes(name)),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* The `codex` binary out of the host's `--exec` command.
|
|
575
|
+
*
|
|
576
|
+
* The host may have written a path (`/opt/homebrew/bin/codex`), and the
|
|
577
|
+
* enumeration above has to ask the *same* binary the run will use — a different
|
|
578
|
+
* one on `PATH` could be a different version reading a different `CODEX_HOME`.
|
|
579
|
+
*/
|
|
580
|
+
export function codexBinary(command) {
|
|
581
|
+
return command.trim().split(/\s+/)[0] ?? 'codex';
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Is the host running Codex under a named profile?
|
|
585
|
+
*
|
|
586
|
+
* Worth asking because it is a hole in the isolation that cannot be closed from
|
|
587
|
+
* here. `codex exec -p work` additionally loads `$CODEX_HOME/work.config.toml`,
|
|
588
|
+
* and `codex mcp list` has no way to be asked about a profile: `-p` and
|
|
589
|
+
* `--profile` are rejected outright, and `-c profile="work"` is refused as a
|
|
590
|
+
* removed legacy form. So a server defined only inside a profile is invisible
|
|
591
|
+
* to the enumeration and stays reachable from the run.
|
|
592
|
+
*
|
|
593
|
+
* Nothing here can fix that — reading the profile file ourselves is the
|
|
594
|
+
* config-parsing this deliberately avoids, and it is exactly how an isolation
|
|
595
|
+
* guarantee ends up with a hole nobody knows about. What it can do is tell the
|
|
596
|
+
* operator, which is what the caller does with this.
|
|
597
|
+
*/
|
|
598
|
+
export function codexProfileName(command) {
|
|
599
|
+
const words = command.trim().split(/\s+/);
|
|
600
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
601
|
+
if (words[i] === '-p' || words[i] === '--profile')
|
|
602
|
+
return words[i + 1];
|
|
603
|
+
const long = /^--profile=(.+)$/.exec(words[i] ?? '');
|
|
604
|
+
if (long)
|
|
605
|
+
return long[1];
|
|
606
|
+
// clap accepts a short option's value joined to the flag (`-pwork`) and
|
|
607
|
+
// with an equals sign (`-p=work`). Missing these would let the real run load
|
|
608
|
+
// profile-only MCP servers while the profile-less probe certified the base
|
|
609
|
+
// config.
|
|
610
|
+
const short = /^-p=?(.+)$/.exec(words[i] ?? '');
|
|
611
|
+
if (short)
|
|
612
|
+
return short[1];
|
|
613
|
+
}
|
|
614
|
+
return undefined;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* A host flag that can make the actual run's MCP inventory differ from the
|
|
618
|
+
* plain `codex mcp list` probe.
|
|
619
|
+
*
|
|
620
|
+
* Replaying arbitrary config arguments into a separate shell command would
|
|
621
|
+
* require parsing the operator's shell syntax exactly. Guessing is not an
|
|
622
|
+
* isolation boundary, so reject the small set of flags that can alter cwd,
|
|
623
|
+
* config, or plugin loading. `-C`/`--cd` matters because Codex discovers
|
|
624
|
+
* project-local config from the directory it runs in. Dedicated model/sandbox
|
|
625
|
+
* flags remain supported.
|
|
626
|
+
*/
|
|
627
|
+
export function codexMcpInventoryFlag(command) {
|
|
628
|
+
const words = command.trim().split(/\s+/);
|
|
629
|
+
for (const word of words) {
|
|
630
|
+
if (word === '-c' ||
|
|
631
|
+
/^-c(?:=)?.+/.test(word) ||
|
|
632
|
+
word === '--config' ||
|
|
633
|
+
word.startsWith('--config=') ||
|
|
634
|
+
word === '--enable' ||
|
|
635
|
+
word.startsWith('--enable=') ||
|
|
636
|
+
word === '--disable' ||
|
|
637
|
+
word.startsWith('--disable=') ||
|
|
638
|
+
word === '--ignore-user-config' ||
|
|
639
|
+
word === '-C' ||
|
|
640
|
+
/^-C(?:=)?.+/.test(word) ||
|
|
641
|
+
word === '--cd' ||
|
|
642
|
+
word.startsWith('--cd=')) {
|
|
643
|
+
return word;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return undefined;
|
|
647
|
+
}
|
|
648
|
+
/** Already present in the host's own command — never add a second one. */
|
|
649
|
+
function has(words, ...flags) {
|
|
650
|
+
return flags.some((flag) => words.includes(flag));
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Both spellings of "bypass approvals and the sandbox".
|
|
654
|
+
*
|
|
655
|
+
* `--yolo` is a hidden alias of the long form; Codex rejects the pair outright.
|
|
656
|
+
*/
|
|
657
|
+
const BYPASS_FLAGS = ['--yolo', '--dangerously-bypass-approvals-and-sandbox'];
|
|
658
|
+
/**
|
|
659
|
+
* Append the flags this harness needs to a Codex command.
|
|
660
|
+
*
|
|
661
|
+
* Four things get added, and each one is load-bearing:
|
|
662
|
+
*
|
|
663
|
+
* `--json` turns stdout into a JSONL event stream, which is the only way to
|
|
664
|
+
* read the thread id back out (and so the only way sessions can continue).
|
|
665
|
+
*
|
|
666
|
+
* `--skip-git-repo-check` is not optional. The harness runs the agent in
|
|
667
|
+
* `$PROHOST_HOME/workspace`, which is not a git repository, and Codex refuses
|
|
668
|
+
* to start there — `Not inside a trusted directory and --skip-git-repo-check
|
|
669
|
+
* was not specified.` Without this flag **every** Codex run fails before the
|
|
670
|
+
* model is ever reached.
|
|
671
|
+
*
|
|
672
|
+
* `--yolo` mirrors Claude Code's `--dangerously-skip-permissions`, for the same
|
|
673
|
+
* reason: in a non-interactive run nobody is there to approve a command, so a
|
|
674
|
+
* sandboxed brain silently declines the work it was hired for. It is a hidden
|
|
675
|
+
* alias of `--dangerously-bypass-approvals-and-sandbox`, and passing both spells
|
|
676
|
+
* is a hard clap error (`cannot be used multiple times`) — so a host who already
|
|
677
|
+
* wrote either one must not get ours on top, or every run of theirs fails.
|
|
678
|
+
*
|
|
679
|
+
* `resume` goes **after** the host's own flags, not before them. `codex exec
|
|
680
|
+
* resume` accepts a strict subset of `codex exec`'s flags (no `-s`, `-C`,
|
|
681
|
+
* `--add-dir`, `-p`), and a flag placed after `resume` is parsed by `resume`.
|
|
682
|
+
* `codex exec -s workspace-write resume <id>` parses; `codex exec resume <id>
|
|
683
|
+
* -s workspace-write` is rejected. Appending keeps the host's flags where Codex
|
|
684
|
+
* still understands them.
|
|
685
|
+
*/
|
|
686
|
+
export function codexCommand(base, flags = {}) {
|
|
687
|
+
const platform = flags.platform ?? process.platform;
|
|
688
|
+
const quote = (value) => shellQuote(value, platform);
|
|
689
|
+
const gated = flags.skipPermissions === false;
|
|
690
|
+
// `--safe-tools` has to *remove* a bypass the host wrote, not merely decline
|
|
691
|
+
// to add one. Until 0.2.2 this README recommended `--exec 'codex exec -
|
|
692
|
+
// --yolo'`, so the flag is sitting in existing installs — and leaving it
|
|
693
|
+
// there would have the harness log "tools are permission-gated" and the
|
|
694
|
+
// prompt tell the agent that approvals will stop it, over a run that is in
|
|
695
|
+
// fact fully unsandboxed. A safety switch that quietly does nothing is worse
|
|
696
|
+
// than not having one.
|
|
697
|
+
const words = withoutPromptPositional(base.trim().split(/\s+/)).filter((word) => !gated || !BYPASS_FLAGS.includes(word));
|
|
698
|
+
const parts = [...words];
|
|
699
|
+
if (flags.sessionId)
|
|
700
|
+
parts.push('resume', quote(flags.sessionId));
|
|
701
|
+
if (!has(words, '--json'))
|
|
702
|
+
parts.push('--json');
|
|
703
|
+
if (!has(words, '--skip-git-repo-check'))
|
|
704
|
+
parts.push('--skip-git-repo-check');
|
|
705
|
+
if (!gated && !has(words, ...BYPASS_FLAGS)) {
|
|
706
|
+
parts.push('--yolo');
|
|
707
|
+
}
|
|
708
|
+
const override = mcpServersOverride(flags.mcpEntries ?? []);
|
|
709
|
+
if (override)
|
|
710
|
+
parts.push('-c', quote(override));
|
|
711
|
+
// The prompt arrives on stdin, and `-` is how Codex is told to read it.
|
|
712
|
+
// Explicit rather than implied: `codex exec` with no positional does read
|
|
713
|
+
// stdin, but `codex exec resume <id>` treats a missing positional as "no
|
|
714
|
+
// prompt", and it also prints a `Reading prompt from stdin...` banner that
|
|
715
|
+
// the `-` form suppresses.
|
|
716
|
+
parts.push('-');
|
|
717
|
+
return parts.join(' ');
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Wording Codex uses when a tool call was refused rather than attempted.
|
|
721
|
+
*
|
|
722
|
+
* The verified string is `user cancelled MCP tool call`, emitted on a
|
|
723
|
+
* `mcp_tool_call` item with `status: "failed"` when there is no approver — the
|
|
724
|
+
* exact situation every run of this harness is in. The rest of the alternation
|
|
725
|
+
* is defensive: the phrasing is not ours to depend on across Codex versions,
|
|
726
|
+
* and the cost of matching one word too many is a log line, while the cost of
|
|
727
|
+
* matching one too few is the failure mode this whole function exists to end.
|
|
728
|
+
*/
|
|
729
|
+
const DENIAL_WORDING = /cancell?ed|rejected|denied|not (approved|permitted|allowed)|requires approval/i;
|
|
730
|
+
function asObject(candidate) {
|
|
731
|
+
try {
|
|
732
|
+
const parsed = JSON.parse(candidate);
|
|
733
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
734
|
+
return parsed;
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function text(value) {
|
|
743
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
744
|
+
}
|
|
745
|
+
/** Pull a human-readable complaint out of whatever shape a failure event has. */
|
|
746
|
+
function failureMessage(event) {
|
|
747
|
+
const error = event.error;
|
|
748
|
+
if (typeof error === 'string')
|
|
749
|
+
return text(error);
|
|
750
|
+
if (error && typeof error === 'object') {
|
|
751
|
+
const record = error;
|
|
752
|
+
return text(record.message) ?? text(record.reason);
|
|
753
|
+
}
|
|
754
|
+
return text(event.message);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Read the reply and the thread id out of `--json` stdout.
|
|
758
|
+
*
|
|
759
|
+
* Codex emits **JSONL**, not one object: `thread.started` (carrying
|
|
760
|
+
* `thread_id`), `turn.started`, an `item.completed` per item the turn produced,
|
|
761
|
+
* then `turn.completed`. Only `agent_message` items are the answer — reasoning
|
|
762
|
+
* and command items share the stream — and the last one is what a person would
|
|
763
|
+
* have seen at the end of the run.
|
|
764
|
+
*
|
|
765
|
+
* Lines that are not JSON are skipped rather than treated as corruption,
|
|
766
|
+
* because they are *expected*: an unreachable MCP server puts `ERROR
|
|
767
|
+
* rmcp::transport::worker: …` straight into this stream, and the run around it
|
|
768
|
+
* succeeds normally. This was observed, not guessed at.
|
|
769
|
+
*
|
|
770
|
+
* As in `parseClaudeOutput`, output with no reply in it is a **failure** rather
|
|
771
|
+
* than a fallback to posting the raw text. We asked for JSON; anything else
|
|
772
|
+
* means something went wrong that we do not understand, and the one thing that
|
|
773
|
+
* must not happen is that reaching a customer's conversation.
|
|
774
|
+
*
|
|
775
|
+
* Refused tool calls come back too, as the Codex counterpart of Claude Code's
|
|
776
|
+
* `permission_denials`. Codex has no such field, but it does emit the fact: a
|
|
777
|
+
* refused call is an `item.completed` carrying a `mcp_tool_call` with
|
|
778
|
+
* `status: "failed"` and `error.message: "user cancelled MCP tool call"`, which
|
|
779
|
+
* is what a run with nobody at the terminal gets every time. Names are spelled
|
|
780
|
+
* `mcp__<server>__<tool>` so the harness's log line reads identically whichever
|
|
781
|
+
* brain produced it — and because that is genuinely how Codex names the tool to
|
|
782
|
+
* the model (verified against 0.144.1, same convention as Claude Code).
|
|
783
|
+
*/
|
|
784
|
+
export function parseCodexOutput(stdout) {
|
|
785
|
+
let sessionId;
|
|
786
|
+
let reply;
|
|
787
|
+
let failure;
|
|
788
|
+
const denials = [];
|
|
789
|
+
for (const line of stdout.split('\n')) {
|
|
790
|
+
const trimmed = line.trim();
|
|
791
|
+
if (!trimmed || !trimmed.startsWith('{'))
|
|
792
|
+
continue;
|
|
793
|
+
const event = asObject(trimmed);
|
|
794
|
+
if (!event)
|
|
795
|
+
continue;
|
|
796
|
+
const type = event.type;
|
|
797
|
+
if (type === 'thread.started') {
|
|
798
|
+
sessionId = text(event.thread_id) ?? sessionId;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (type === 'turn.failed' || type === 'error') {
|
|
802
|
+
failure = failureMessage(event) ?? failure ?? 'codex reported a failed turn';
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (type === 'item.completed') {
|
|
806
|
+
const item = event.item;
|
|
807
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
808
|
+
const record = item;
|
|
809
|
+
// Last one wins: a turn can emit several, and the final one is the
|
|
810
|
+
// answer the run ended on.
|
|
811
|
+
if (record.type === 'agent_message')
|
|
812
|
+
reply = text(record.text) ?? reply;
|
|
813
|
+
const denied = denialName(record);
|
|
814
|
+
if (denied && !denials.includes(denied))
|
|
815
|
+
denials.push(denied);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// A failed turn that still produced a message is a failure. The message in
|
|
820
|
+
// that case is the explanation ("I ran out of…"), not an answer, and posting
|
|
821
|
+
// it would put the failure in front of a customer as if the agent had chosen
|
|
822
|
+
// to say it.
|
|
823
|
+
if (failure)
|
|
824
|
+
return { ok: false, sessionId, denials, error: failure };
|
|
825
|
+
if (reply)
|
|
826
|
+
return { ok: true, sessionId, denials, text: reply };
|
|
827
|
+
const sample = stdout.trim().slice(0, 200);
|
|
828
|
+
return {
|
|
829
|
+
ok: false,
|
|
830
|
+
sessionId,
|
|
831
|
+
denials,
|
|
832
|
+
error: sample
|
|
833
|
+
? `codex produced no agent message (asked for --json). First 200 chars: ${sample}`
|
|
834
|
+
: 'codex returned no output',
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Name the tool this item was refused, or `undefined` if it wasn't refused.
|
|
839
|
+
*
|
|
840
|
+
* A *failed* call is not a *denied* one — an MCP server that 500s also lands
|
|
841
|
+
* here — so the error text has to say refusal before this reports one.
|
|
842
|
+
* Over-reporting would put "the agent was refused these tools" in the log for a
|
|
843
|
+
* server that was merely down, sending the operator to their permissions when
|
|
844
|
+
* the problem is their network.
|
|
845
|
+
*/
|
|
846
|
+
function denialName(item) {
|
|
847
|
+
if (item.status !== 'failed')
|
|
848
|
+
return undefined;
|
|
849
|
+
const error = item.error;
|
|
850
|
+
const message = typeof error === 'string' ? error : error && typeof error === 'object' ? text(error.message) : undefined;
|
|
851
|
+
if (!message || !DENIAL_WORDING.test(message))
|
|
852
|
+
return undefined;
|
|
853
|
+
if (item.type === 'mcp_tool_call') {
|
|
854
|
+
const server = text(item.server);
|
|
855
|
+
const tool = text(item.tool);
|
|
856
|
+
if (server && tool)
|
|
857
|
+
return `mcp__${server}__${tool}`;
|
|
858
|
+
if (tool)
|
|
859
|
+
return tool;
|
|
860
|
+
}
|
|
861
|
+
// Only the MCP shape above was verified against a real binary. Anything else
|
|
862
|
+
// that reports itself refused is still worth naming — by its item type, which
|
|
863
|
+
// is the only handle we can be sure of — rather than dropped for not matching
|
|
864
|
+
// a shape we have seen.
|
|
865
|
+
return text(item.type);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Does this look like the resume having been rejected, rather than the agent
|
|
869
|
+
* having failed at its actual work?
|
|
870
|
+
*
|
|
871
|
+
* Two ways a Codex resume can fail where a fresh run would have worked: the
|
|
872
|
+
* thread is gone (rollout files get cleaned up and the ledger cannot know), or
|
|
873
|
+
* the host's own flags are ones `codex exec resume` does not accept, which clap
|
|
874
|
+
* reports as `unexpected argument`. Both must degrade to a fresh session
|
|
875
|
+
* instead of breaking that surface forever.
|
|
876
|
+
*
|
|
877
|
+
* The `thread.started` guard is what keeps this from firing on an ordinary bad
|
|
878
|
+
* turn. Codex echoes the thread id in that event on **success**, so the
|
|
879
|
+
* "output mentions the session id" heuristic — which is all `claude.ts` can
|
|
880
|
+
* do — would match a resumed run that started fine and then failed at its
|
|
881
|
+
* work, and retrying that runs the agent, and its tools, a second time for
|
|
882
|
+
* nothing.
|
|
883
|
+
*/
|
|
884
|
+
export function looksLikeCodexResumeFailure(output, sessionId) {
|
|
885
|
+
if (!sessionId)
|
|
886
|
+
return false;
|
|
887
|
+
if (/"type"\s*:\s*"thread\.started"/.test(output))
|
|
888
|
+
return false;
|
|
889
|
+
if (/unexpected argument|unrecognized subcommand|invalid value|error: unexpected/i.test(output)) {
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
// The first branch is the message the binary actually prints for a thread it
|
|
893
|
+
// no longer has on disk, captured from a real run:
|
|
894
|
+
// Error: thread/resume: thread/resume failed: no rollout found for thread
|
|
895
|
+
// id <uuid> (code -32600)
|
|
896
|
+
// The rest are broader, because the wording is not ours to depend on.
|
|
897
|
+
if (/no rollout found|no (conversation|session|thread)[^\n]{0,40}found|(session|thread)[^\n]{0,40}not found|resume[^\n]{0,20}(failed|invalid|expired)/i.test(output)) {
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
return output.includes(sessionId);
|
|
901
|
+
}
|