@prohost/cli 0.2.2 → 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 +115 -0
- package/README.md +147 -20
- package/dist/agent/claude.d.ts +21 -3
- package/dist/agent/claude.js +7 -1
- package/dist/agent/codex.d.ts +215 -11
- package/dist/agent/codex.js +555 -23
- package/dist/agent/extras.d.ts +73 -0
- package/dist/agent/extras.js +118 -0
- package/dist/agent/mcp.d.ts +7 -6
- package/dist/agent/mcp.js +7 -6
- package/dist/agent/prompt.d.ts +6 -5
- package/dist/agent/prompt.js +16 -0
- package/dist/agent/run.d.ts +33 -0
- package/dist/agent/run.js +216 -61
- package/dist/index.js +10 -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
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Everything here was verified against a real `codex-cli 0.144.1` rather than
|
|
13
13
|
* taken from documentation; the surprises are called out where they are relied
|
|
14
|
-
* on.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
17
|
*/
|
|
18
18
|
import { MCP_SERVER_NAME } from './mcp.js';
|
|
19
|
-
import {
|
|
19
|
+
import { shellQuote } from './shell.js';
|
|
20
20
|
/**
|
|
21
21
|
* Environment variable the API key is handed to Codex in.
|
|
22
22
|
*
|
|
@@ -37,39 +37,79 @@ export const MCP_API_KEY_ENV_VAR = 'PROHOST_MCP_API_KEY';
|
|
|
37
37
|
* Both halves matter. Interactive `codex` has no `--json` and no `resume`
|
|
38
38
|
* subcommand, so shaping it would break a command that works today.
|
|
39
39
|
*
|
|
40
|
-
* Three things
|
|
41
|
-
* harm rather than nothing:
|
|
40
|
+
* Three things are deliberately not shaped, each because appending to it would
|
|
41
|
+
* do harm rather than nothing:
|
|
42
42
|
*
|
|
43
|
-
* * Commands with shell control operators. Flags are appended to the *end* of
|
|
44
|
-
* the string, so for `codex exec | jq .` they would land on `jq`.
|
|
45
43
|
* * `codex exec resume …` and `codex exec review …`. The host already chose a
|
|
46
44
|
* sub-subcommand; appending our own `resume` would produce two of them.
|
|
47
45
|
* * Anything after a `--` sentinel, which is the host saying "stop parsing".
|
|
48
|
-
* * A command carrying its own prompt, e.g. `codex exec
|
|
46
|
+
* * A command carrying its own unquoted prompt, e.g. `codex exec hello`. Codex
|
|
49
47
|
* takes exactly one prompt positional, so both the `-` this harness appends
|
|
50
48
|
* and a `resume <id>` would be a second one — `error: unexpected argument
|
|
51
49
|
* '-' found`, on a command that works today. Such a host keeps the plain
|
|
52
50
|
* contract; the place to put standing instructions is now the agent's
|
|
53
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.
|
|
54
56
|
*/
|
|
55
|
-
export function isCodexExec(command) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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() ?? '';
|
|
61
75
|
if (base !== 'codex' && base !== 'codex.cmd' && base !== 'codex.exe')
|
|
62
76
|
return false;
|
|
63
|
-
if (words.includes('--'))
|
|
64
|
-
return false;
|
|
65
77
|
const rest = words.slice(1);
|
|
66
78
|
if (!rest.includes('exec') && !rest.includes('e'))
|
|
67
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;
|
|
68
88
|
// A host who already picked `resume` or `review` gets left alone entirely.
|
|
69
89
|
if (rest.includes('resume') || rest.includes('review'))
|
|
70
90
|
return false;
|
|
71
91
|
return !hasExplicitPrompt(words);
|
|
72
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
|
+
}
|
|
73
113
|
/**
|
|
74
114
|
* Does the command already carry a prompt of the host's own?
|
|
75
115
|
*
|
|
@@ -165,10 +205,446 @@ function tomlString(value) {
|
|
|
165
205
|
* keeps a live API key off a command line that anyone on the machine can read
|
|
166
206
|
* out of `ps`.
|
|
167
207
|
*/
|
|
168
|
-
export function
|
|
169
|
-
return (
|
|
208
|
+
export function prohostMcpEntry(mcpUrl) {
|
|
209
|
+
return (`${tomlString(MCP_SERVER_NAME)}={url=${tomlString(mcpUrl)}, ` +
|
|
170
210
|
`env_http_headers={"X-API-Key"=${tomlString(MCP_API_KEY_ENV_VAR)}}}`);
|
|
171
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
|
+
}
|
|
172
648
|
/** Already present in the host's own command — never add a second one. */
|
|
173
649
|
function has(words, ...flags) {
|
|
174
650
|
return flags.some((flag) => words.includes(flag));
|
|
@@ -229,8 +705,9 @@ export function codexCommand(base, flags = {}) {
|
|
|
229
705
|
if (!gated && !has(words, ...BYPASS_FLAGS)) {
|
|
230
706
|
parts.push('--yolo');
|
|
231
707
|
}
|
|
232
|
-
|
|
233
|
-
|
|
708
|
+
const override = mcpServersOverride(flags.mcpEntries ?? []);
|
|
709
|
+
if (override)
|
|
710
|
+
parts.push('-c', quote(override));
|
|
234
711
|
// The prompt arrives on stdin, and `-` is how Codex is told to read it.
|
|
235
712
|
// Explicit rather than implied: `codex exec` with no positional does read
|
|
236
713
|
// stdin, but `codex exec resume <id>` treats a missing positional as "no
|
|
@@ -239,6 +716,17 @@ export function codexCommand(base, flags = {}) {
|
|
|
239
716
|
parts.push('-');
|
|
240
717
|
return parts.join(' ');
|
|
241
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;
|
|
242
730
|
function asObject(candidate) {
|
|
243
731
|
try {
|
|
244
732
|
const parsed = JSON.parse(candidate);
|
|
@@ -283,11 +771,21 @@ function failureMessage(event) {
|
|
|
283
771
|
* than a fallback to posting the raw text. We asked for JSON; anything else
|
|
284
772
|
* means something went wrong that we do not understand, and the one thing that
|
|
285
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).
|
|
286
783
|
*/
|
|
287
784
|
export function parseCodexOutput(stdout) {
|
|
288
785
|
let sessionId;
|
|
289
786
|
let reply;
|
|
290
787
|
let failure;
|
|
788
|
+
const denials = [];
|
|
291
789
|
for (const line of stdout.split('\n')) {
|
|
292
790
|
const trimmed = line.trim();
|
|
293
791
|
if (!trimmed || !trimmed.startsWith('{'))
|
|
@@ -312,6 +810,9 @@ export function parseCodexOutput(stdout) {
|
|
|
312
810
|
// answer the run ended on.
|
|
313
811
|
if (record.type === 'agent_message')
|
|
314
812
|
reply = text(record.text) ?? reply;
|
|
813
|
+
const denied = denialName(record);
|
|
814
|
+
if (denied && !denials.includes(denied))
|
|
815
|
+
denials.push(denied);
|
|
315
816
|
}
|
|
316
817
|
}
|
|
317
818
|
}
|
|
@@ -320,18 +821,49 @@ export function parseCodexOutput(stdout) {
|
|
|
320
821
|
// it would put the failure in front of a customer as if the agent had chosen
|
|
321
822
|
// to say it.
|
|
322
823
|
if (failure)
|
|
323
|
-
return { ok: false, sessionId, error: failure };
|
|
824
|
+
return { ok: false, sessionId, denials, error: failure };
|
|
324
825
|
if (reply)
|
|
325
|
-
return { ok: true, sessionId, text: reply };
|
|
826
|
+
return { ok: true, sessionId, denials, text: reply };
|
|
326
827
|
const sample = stdout.trim().slice(0, 200);
|
|
327
828
|
return {
|
|
328
829
|
ok: false,
|
|
329
830
|
sessionId,
|
|
831
|
+
denials,
|
|
330
832
|
error: sample
|
|
331
833
|
? `codex produced no agent message (asked for --json). First 200 chars: ${sample}`
|
|
332
834
|
: 'codex returned no output',
|
|
333
835
|
};
|
|
334
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
|
+
}
|
|
335
867
|
/**
|
|
336
868
|
* Does this look like the resume having been rejected, rather than the agent
|
|
337
869
|
* having failed at its actual work?
|