@prohost/cli 0.2.2 → 0.3.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 +169 -0
- package/README.md +170 -21
- package/dist/agent/api.d.ts +53 -5
- package/dist/agent/api.js +51 -11
- package/dist/agent/claude.d.ts +74 -4
- package/dist/agent/claude.js +172 -21
- package/dist/agent/codex.d.ts +215 -11
- package/dist/agent/codex.js +555 -23
- package/dist/agent/command.js +11 -2
- package/dist/agent/contract.d.ts +39 -0
- package/dist/agent/contract.js +47 -0
- package/dist/agent/exec.d.ts +42 -4
- package/dist/agent/exec.js +104 -18
- package/dist/agent/extras.d.ts +73 -0
- package/dist/agent/extras.js +118 -0
- package/dist/agent/heartbeat.d.ts +114 -0
- package/dist/agent/heartbeat.js +165 -0
- package/dist/agent/mcp.d.ts +7 -6
- package/dist/agent/mcp.js +7 -6
- package/dist/agent/prompt.d.ts +17 -5
- package/dist/agent/prompt.js +66 -2
- package/dist/agent/run.d.ts +58 -3
- package/dist/agent/run.js +525 -97
- package/dist/index.js +13 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/agent/claude.d.ts
CHANGED
|
@@ -87,7 +87,10 @@ export interface ClaudeFlags {
|
|
|
87
87
|
* servers in the operator's own configuration with ours, so a run answering a
|
|
88
88
|
* customer's question would hold whatever MCP servers they use for their day
|
|
89
89
|
* job — with permission checks off. Ours is the only one this run is entitled
|
|
90
|
-
* to.
|
|
90
|
+
* to, plus whatever the operator listed in `extra-mcp.json` — a file they had
|
|
91
|
+
* to write by hand, which is the point: the boundary still holds, and crossing
|
|
92
|
+
* it is a decision somebody made on purpose rather than one their day-job
|
|
93
|
+
* config made for them.
|
|
91
94
|
*/
|
|
92
95
|
export declare function claudeCommand(base: string, flags?: ClaudeFlags): string;
|
|
93
96
|
/** What the harness got back from one Claude Code invocation. */
|
|
@@ -103,7 +106,7 @@ export type ClaudeOutput = {
|
|
|
103
106
|
denials: string[];
|
|
104
107
|
};
|
|
105
108
|
/**
|
|
106
|
-
* Read the reply and the session id out of
|
|
109
|
+
* Read the reply and the session id out of a finished run's stdout.
|
|
107
110
|
*
|
|
108
111
|
* Unparseable output is a **failure**, not a fallback to posting the raw text.
|
|
109
112
|
* We asked for JSON; stdout that isn't JSON means something went wrong that we
|
|
@@ -111,8 +114,60 @@ export type ClaudeOutput = {
|
|
|
111
114
|
* one thing that must not happen is that being posted verbatim into a customer's
|
|
112
115
|
* conversation. Reporting the run failed is recoverable; sending a stack trace
|
|
113
116
|
* to a guest is not.
|
|
117
|
+
*
|
|
118
|
+
* Still whole-stdout rather than streaming, and still used: it is what
|
|
119
|
+
* {@link readClaudeStream} falls back to when a run produced no `result` event
|
|
120
|
+
* at all, and it reads a plain `--output-format json` envelope exactly as it
|
|
121
|
+
* always did.
|
|
114
122
|
*/
|
|
115
123
|
export declare function parseClaudeOutput(stdout: string): ClaudeOutput;
|
|
124
|
+
/** Cap on the activity line handed to the heartbeat. */
|
|
125
|
+
export declare const MAX_ACTIVITY_CHARS = 120;
|
|
126
|
+
/** Cap on the partial answer salvaged from a run that was killed. */
|
|
127
|
+
export declare const MAX_SALVAGE_CHARS = 500;
|
|
128
|
+
/**
|
|
129
|
+
* A run's stdout, read as it is produced.
|
|
130
|
+
*
|
|
131
|
+
* Everything here is an enhancement over reading stdout at exit, and every one
|
|
132
|
+
* of them fails soft: a stream we cannot parse costs progress reporting and
|
|
133
|
+
* salvage, never the run.
|
|
134
|
+
*/
|
|
135
|
+
export interface ClaudeStream {
|
|
136
|
+
/** Feed one stdout chunk. Never throws. */
|
|
137
|
+
push(chunk: string): void;
|
|
138
|
+
/**
|
|
139
|
+
* The session id, as soon as the run announced it.
|
|
140
|
+
*
|
|
141
|
+
* This is the whole reason the session id is read here rather than out of the
|
|
142
|
+
* final envelope: a run killed at hour two never produces a final envelope,
|
|
143
|
+
* and without the id its two hours of context are unreachable — the next turn
|
|
144
|
+
* on that surface starts from nothing.
|
|
145
|
+
*/
|
|
146
|
+
sessionId(): string | undefined;
|
|
147
|
+
/**
|
|
148
|
+
* What the agent is doing right now, in one short line — the last tool it
|
|
149
|
+
* called, or the opening of the text it is writing.
|
|
150
|
+
*/
|
|
151
|
+
activity(): string | undefined;
|
|
152
|
+
/** The tail of the last thing the agent wrote, for a run that was killed. */
|
|
153
|
+
salvage(): string | undefined;
|
|
154
|
+
/**
|
|
155
|
+
* The turn's outcome. ``stdout`` is the complete captured output, used only
|
|
156
|
+
* when the stream held no `result` event — a crashed binary, a login prompt,
|
|
157
|
+
* or a plain `--output-format json` envelope from a command we did not shape.
|
|
158
|
+
*/
|
|
159
|
+
finish(stdout: string): ClaudeOutput;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Parse `--output-format stream-json` incrementally.
|
|
163
|
+
*
|
|
164
|
+
* Line-delimited JSON, one event per line, so the parser is a buffer and a
|
|
165
|
+
* split. Anything it doesn't recognise is skipped in silence: the event
|
|
166
|
+
* vocabulary belongs to Claude Code and grows without asking us, and a reader
|
|
167
|
+
* that failed on an unknown `type` would turn a Claude Code release into a
|
|
168
|
+
* fleet of broken laptops.
|
|
169
|
+
*/
|
|
170
|
+
export declare function readClaudeStream(): ClaudeStream;
|
|
116
171
|
/**
|
|
117
172
|
* Does this look like the resume having been rejected, rather than the agent
|
|
118
173
|
* having failed at its actual work?
|
|
@@ -131,10 +186,25 @@ export declare function looksLikeResumeFailure(output: string, sessionId: string
|
|
|
131
186
|
/** Absolute path of the generated MCP config. */
|
|
132
187
|
export declare function mcpConfigPath(env?: NodeJS.ProcessEnv): string;
|
|
133
188
|
export interface McpConfigOptions {
|
|
134
|
-
/**
|
|
135
|
-
|
|
189
|
+
/**
|
|
190
|
+
* The MCP endpoint the pairing reported, e.g. `https://connect.prohost.ai/mcp`.
|
|
191
|
+
*
|
|
192
|
+
* Optional, because a pairing can carry none — and a run with no ProhostAI
|
|
193
|
+
* server is still a run whose operator may have opted into extras. Omitted,
|
|
194
|
+
* the generated config holds only those, and `--strict-mcp-config` still
|
|
195
|
+
* keeps everything else out.
|
|
196
|
+
*/
|
|
197
|
+
mcpUrl?: string;
|
|
136
198
|
/** The paired API key. Written to a `0600` file and nowhere else. */
|
|
137
199
|
apiKey: string;
|
|
200
|
+
/**
|
|
201
|
+
* Servers the operator opted into via `$PROHOST_HOME/extra-mcp.json`.
|
|
202
|
+
*
|
|
203
|
+
* Merged in *under* ours, never over it: {@link loadExtraMcpServers} already
|
|
204
|
+
* refuses an entry named `prohost`, and writing ours last means even a future
|
|
205
|
+
* bug there cannot end with a run's workspace tools pointed somewhere else.
|
|
206
|
+
*/
|
|
207
|
+
extraServers?: Record<string, Record<string, unknown>>;
|
|
138
208
|
env?: NodeJS.ProcessEnv;
|
|
139
209
|
}
|
|
140
210
|
/**
|
package/dist/agent/claude.js
CHANGED
|
@@ -87,7 +87,10 @@ export function isClaudeExec(command) {
|
|
|
87
87
|
* servers in the operator's own configuration with ours, so a run answering a
|
|
88
88
|
* customer's question would hold whatever MCP servers they use for their day
|
|
89
89
|
* job — with permission checks off. Ours is the only one this run is entitled
|
|
90
|
-
* to.
|
|
90
|
+
* to, plus whatever the operator listed in `extra-mcp.json` — a file they had
|
|
91
|
+
* to write by hand, which is the point: the boundary still holds, and crossing
|
|
92
|
+
* it is a decision somebody made on purpose rather than one their day-job
|
|
93
|
+
* config made for them.
|
|
91
94
|
*/
|
|
92
95
|
export function claudeCommand(base, flags = {}) {
|
|
93
96
|
const platform = flags.platform ?? process.platform;
|
|
@@ -106,7 +109,17 @@ export function claudeCommand(base, flags = {}) {
|
|
|
106
109
|
.split(/\s+/)
|
|
107
110
|
.filter((word) => word !== '--dangerously-skip-permissions')
|
|
108
111
|
.join(' ');
|
|
109
|
-
|
|
112
|
+
// `stream-json` rather than `json`, and the two flags travel together:
|
|
113
|
+
// Claude Code refuses `--output-format stream-json` in print mode without
|
|
114
|
+
// `--verbose`. What it buys is everything that cannot wait for the process to
|
|
115
|
+
// exit — the session id lands in the ledger on the first event rather than
|
|
116
|
+
// after the reply is posted, the heartbeat can say what the agent is doing,
|
|
117
|
+
// and a run that gets killed at hour two still has a partial answer to
|
|
118
|
+
// report. The final `result` event carries the same fields the single JSON
|
|
119
|
+
// envelope did, so the reply itself is unchanged.
|
|
120
|
+
const parts = [command, '--output-format', 'stream-json'];
|
|
121
|
+
if (!/(^|\s)--verbose(\s|$)/.test(command))
|
|
122
|
+
parts.push('--verbose');
|
|
110
123
|
if (skipPermissions)
|
|
111
124
|
parts.push('--dangerously-skip-permissions');
|
|
112
125
|
if (flags.sessionId)
|
|
@@ -195,27 +208,13 @@ function text(value) {
|
|
|
195
208
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
196
209
|
}
|
|
197
210
|
/**
|
|
198
|
-
*
|
|
211
|
+
* Turn a `result` envelope into the harness's view of the turn.
|
|
199
212
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* one thing that must not happen is that being posted verbatim into a customer's
|
|
204
|
-
* conversation. Reporting the run failed is recoverable; sending a stack trace
|
|
205
|
-
* to a guest is not.
|
|
213
|
+
* Shared by the whole-stdout parser and the streaming reader, because the
|
|
214
|
+
* envelope is identical either way: `--output-format stream-json` ends with the
|
|
215
|
+
* same object `--output-format json` used to print on its own.
|
|
206
216
|
*/
|
|
207
|
-
|
|
208
|
-
const parsed = resultEnvelope(stdout);
|
|
209
|
-
if (!parsed) {
|
|
210
|
-
const sample = stdout.trim().slice(0, 200);
|
|
211
|
-
return {
|
|
212
|
-
denials: [],
|
|
213
|
-
ok: false,
|
|
214
|
-
error: sample
|
|
215
|
-
? `claude did not return JSON (asked for --output-format json). First 200 chars: ${sample}`
|
|
216
|
-
: 'claude returned no output',
|
|
217
|
-
};
|
|
218
|
-
}
|
|
217
|
+
function outputFromEnvelope(parsed) {
|
|
219
218
|
const sessionId = text(parsed.session_id);
|
|
220
219
|
const result = text(parsed.result);
|
|
221
220
|
const denials = denialNames(parsed.permission_denials);
|
|
@@ -236,6 +235,155 @@ export function parseClaudeOutput(stdout) {
|
|
|
236
235
|
}
|
|
237
236
|
return { ok: true, text: result, sessionId, denials };
|
|
238
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Read the reply and the session id out of a finished run's stdout.
|
|
240
|
+
*
|
|
241
|
+
* Unparseable output is a **failure**, not a fallback to posting the raw text.
|
|
242
|
+
* We asked for JSON; stdout that isn't JSON means something went wrong that we
|
|
243
|
+
* do not understand — a crash banner, a login prompt, an npm warning — and the
|
|
244
|
+
* one thing that must not happen is that being posted verbatim into a customer's
|
|
245
|
+
* conversation. Reporting the run failed is recoverable; sending a stack trace
|
|
246
|
+
* to a guest is not.
|
|
247
|
+
*
|
|
248
|
+
* Still whole-stdout rather than streaming, and still used: it is what
|
|
249
|
+
* {@link readClaudeStream} falls back to when a run produced no `result` event
|
|
250
|
+
* at all, and it reads a plain `--output-format json` envelope exactly as it
|
|
251
|
+
* always did.
|
|
252
|
+
*/
|
|
253
|
+
export function parseClaudeOutput(stdout) {
|
|
254
|
+
const parsed = resultEnvelope(stdout);
|
|
255
|
+
if (!parsed) {
|
|
256
|
+
const sample = stdout.trim().slice(0, 200);
|
|
257
|
+
return {
|
|
258
|
+
denials: [],
|
|
259
|
+
ok: false,
|
|
260
|
+
error: sample
|
|
261
|
+
? `claude did not return JSON (asked for --output-format stream-json). First 200 chars: ${sample}`
|
|
262
|
+
: 'claude returned no output',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return outputFromEnvelope(parsed);
|
|
266
|
+
}
|
|
267
|
+
/** Cap on the activity line handed to the heartbeat. */
|
|
268
|
+
export const MAX_ACTIVITY_CHARS = 120;
|
|
269
|
+
/** Cap on the partial answer salvaged from a run that was killed. */
|
|
270
|
+
export const MAX_SALVAGE_CHARS = 500;
|
|
271
|
+
/**
|
|
272
|
+
* Cap on a single buffered stdout line.
|
|
273
|
+
*
|
|
274
|
+
* A stream line is one JSON event, and a pathological one that never ends
|
|
275
|
+
* (binary output from a wrapper script, say) would otherwise accumulate
|
|
276
|
+
* unbounded between newlines. Dropped rather than truncated: half a JSON object
|
|
277
|
+
* parses as nothing useful anyway.
|
|
278
|
+
*/
|
|
279
|
+
const MAX_LINE_CHARS = 4 * 1024 * 1024;
|
|
280
|
+
/** Text of an `assistant` event's message, and the last tool it invoked. */
|
|
281
|
+
function assistantContent(event) {
|
|
282
|
+
const message = event.message;
|
|
283
|
+
if (!message || typeof message !== 'object')
|
|
284
|
+
return {};
|
|
285
|
+
const content = message.content;
|
|
286
|
+
if (!Array.isArray(content))
|
|
287
|
+
return {};
|
|
288
|
+
let latestText;
|
|
289
|
+
let latestTool;
|
|
290
|
+
for (const block of content) {
|
|
291
|
+
if (!block || typeof block !== 'object')
|
|
292
|
+
continue;
|
|
293
|
+
const entry = block;
|
|
294
|
+
if (entry.type === 'text' && typeof entry.text === 'string' && entry.text.trim()) {
|
|
295
|
+
latestText = entry.text;
|
|
296
|
+
}
|
|
297
|
+
else if (entry.type === 'tool_use' && typeof entry.name === 'string' && entry.name) {
|
|
298
|
+
latestTool = entry.name;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { text: latestText, tool: latestTool };
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Parse `--output-format stream-json` incrementally.
|
|
305
|
+
*
|
|
306
|
+
* Line-delimited JSON, one event per line, so the parser is a buffer and a
|
|
307
|
+
* split. Anything it doesn't recognise is skipped in silence: the event
|
|
308
|
+
* vocabulary belongs to Claude Code and grows without asking us, and a reader
|
|
309
|
+
* that failed on an unknown `type` would turn a Claude Code release into a
|
|
310
|
+
* fleet of broken laptops.
|
|
311
|
+
*/
|
|
312
|
+
export function readClaudeStream() {
|
|
313
|
+
let buffer = '';
|
|
314
|
+
let session;
|
|
315
|
+
let lastActivity;
|
|
316
|
+
let lastText;
|
|
317
|
+
let result;
|
|
318
|
+
const handle = (line) => {
|
|
319
|
+
const event = asObject(line);
|
|
320
|
+
if (!event)
|
|
321
|
+
return;
|
|
322
|
+
// Present on the `system`/`init` event, and repeated on most others. Taken
|
|
323
|
+
// from whichever arrives first — they all name the same session.
|
|
324
|
+
if (!session)
|
|
325
|
+
session = text(event.session_id);
|
|
326
|
+
if (event.type === 'result') {
|
|
327
|
+
result = event;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (event.type !== 'assistant')
|
|
331
|
+
return;
|
|
332
|
+
const { text: written, tool } = assistantContent(event);
|
|
333
|
+
if (written)
|
|
334
|
+
lastText = written;
|
|
335
|
+
// Whichever happened last within this message: a tool call after some text
|
|
336
|
+
// means the agent has moved on from writing to doing.
|
|
337
|
+
if (tool)
|
|
338
|
+
lastActivity = `using ${tool}`;
|
|
339
|
+
else if (written)
|
|
340
|
+
lastActivity = written.replace(/\s+/g, ' ').trim().slice(0, MAX_ACTIVITY_CHARS);
|
|
341
|
+
};
|
|
342
|
+
return {
|
|
343
|
+
push(chunk) {
|
|
344
|
+
buffer += chunk;
|
|
345
|
+
if (!buffer.includes('\n')) {
|
|
346
|
+
if (buffer.length > MAX_LINE_CHARS)
|
|
347
|
+
buffer = '';
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const lines = buffer.split('\n');
|
|
351
|
+
buffer = lines.pop() ?? '';
|
|
352
|
+
if (buffer.length > MAX_LINE_CHARS)
|
|
353
|
+
buffer = '';
|
|
354
|
+
for (const line of lines) {
|
|
355
|
+
const trimmed = line.trim();
|
|
356
|
+
if (trimmed)
|
|
357
|
+
handle(trimmed);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
sessionId: () => session,
|
|
361
|
+
activity: () => lastActivity,
|
|
362
|
+
salvage: () => {
|
|
363
|
+
const trimmed = lastText?.trim();
|
|
364
|
+
if (!trimmed)
|
|
365
|
+
return undefined;
|
|
366
|
+
return trimmed.length > MAX_SALVAGE_CHARS ? trimmed.slice(-MAX_SALVAGE_CHARS) : trimmed;
|
|
367
|
+
},
|
|
368
|
+
finish(stdout) {
|
|
369
|
+
// A trailing event with no newline after it is still an event — a binary
|
|
370
|
+
// that exits without flushing a final `\n` must not lose its result.
|
|
371
|
+
const tail = buffer.trim();
|
|
372
|
+
if (tail) {
|
|
373
|
+
handle(tail);
|
|
374
|
+
buffer = '';
|
|
375
|
+
}
|
|
376
|
+
if (result)
|
|
377
|
+
return outputFromEnvelope(result);
|
|
378
|
+
// No `result` event: either the command isn't emitting the stream we
|
|
379
|
+
// asked for, or it died before finishing. Either way the whole-output
|
|
380
|
+
// parser gives the better error message, and it still understands a
|
|
381
|
+
// single `json` envelope.
|
|
382
|
+
const parsed = parseClaudeOutput(stdout);
|
|
383
|
+
return session && !parsed.sessionId ? { ...parsed, sessionId: session } : parsed;
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
239
387
|
/**
|
|
240
388
|
* Does this look like the resume having been rejected, rather than the agent
|
|
241
389
|
* having failed at its actual work?
|
|
@@ -268,8 +416,11 @@ export function mcpConfigPath(env = process.env) {
|
|
|
268
416
|
* the filesystem, and so the header scheme is stated in exactly one place.
|
|
269
417
|
*/
|
|
270
418
|
export function buildMcpConfig(options) {
|
|
419
|
+
if (!options.mcpUrl)
|
|
420
|
+
return { mcpServers: { ...(options.extraServers ?? {}) } };
|
|
271
421
|
return {
|
|
272
422
|
mcpServers: {
|
|
423
|
+
...(options.extraServers ?? {}),
|
|
273
424
|
[MCP_SERVER_NAME]: {
|
|
274
425
|
type: 'http',
|
|
275
426
|
// Verbatim, exactly as pairing reported it. An earlier revision appended
|
package/dist/agent/codex.d.ts
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
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
|
/**
|
|
19
19
|
* Environment variable the API key is handed to Codex in.
|
|
@@ -35,22 +35,35 @@ export declare const MCP_API_KEY_ENV_VAR = "PROHOST_MCP_API_KEY";
|
|
|
35
35
|
* Both halves matter. Interactive `codex` has no `--json` and no `resume`
|
|
36
36
|
* subcommand, so shaping it would break a command that works today.
|
|
37
37
|
*
|
|
38
|
-
* Three things
|
|
39
|
-
* harm rather than nothing:
|
|
38
|
+
* Three things are deliberately not shaped, each because appending to it would
|
|
39
|
+
* do harm rather than nothing:
|
|
40
40
|
*
|
|
41
|
-
* * Commands with shell control operators. Flags are appended to the *end* of
|
|
42
|
-
* the string, so for `codex exec | jq .` they would land on `jq`.
|
|
43
41
|
* * `codex exec resume …` and `codex exec review …`. The host already chose a
|
|
44
42
|
* sub-subcommand; appending our own `resume` would produce two of them.
|
|
45
43
|
* * Anything after a `--` sentinel, which is the host saying "stop parsing".
|
|
46
|
-
* * A command carrying its own prompt, e.g. `codex exec
|
|
44
|
+
* * A command carrying its own unquoted prompt, e.g. `codex exec hello`. Codex
|
|
47
45
|
* takes exactly one prompt positional, so both the `-` this harness appends
|
|
48
46
|
* and a `resume <id>` would be a second one — `error: unexpected argument
|
|
49
47
|
* '-' found`, on a command that works today. Such a host keeps the plain
|
|
50
48
|
* contract; the place to put standing instructions is now the agent's
|
|
51
49
|
* ProhostAI profile, which arrives in the prompt itself.
|
|
50
|
+
*
|
|
51
|
+
* Shell syntax is different: recognisable Codex exec commands containing it
|
|
52
|
+
* stay classified as Codex so the harness can fail closed before spawn. Letting
|
|
53
|
+
* one fall through as a plain command would also drop the verified MCP boundary.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isCodexExec(command: string, platform?: NodeJS.Platform): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Shell syntax a shaped Codex command cannot safely preserve.
|
|
58
|
+
*
|
|
59
|
+
* Codex commands are eventually executed with `shell: true`, while the harness
|
|
60
|
+
* currently operates on whitespace-delimited words. Quotes, escapes,
|
|
61
|
+
* expansions, globs, comments and control/redirection operators all make those
|
|
62
|
+
* two views differ. Rather than guess at POSIX plus cmd.exe tokenization, only
|
|
63
|
+
* the deliberately boring argv-shaped subset is accepted. A matching command
|
|
64
|
+
* is still classified as Codex and then rejected before spawn.
|
|
52
65
|
*/
|
|
53
|
-
export declare function
|
|
66
|
+
export declare function codexShellSyntax(command: string, platform?: NodeJS.Platform): string | undefined;
|
|
54
67
|
/**
|
|
55
68
|
* The `-c` override that registers the ProhostAI MCP server.
|
|
56
69
|
*
|
|
@@ -68,12 +81,192 @@ export declare function isCodexExec(command: string): boolean;
|
|
|
68
81
|
* keeps a live API key off a command line that anyone on the machine can read
|
|
69
82
|
* out of `ps`.
|
|
70
83
|
*/
|
|
84
|
+
export declare function prohostMcpEntry(mcpUrl: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* The whole `mcp_servers` override for a run, ProhostAI server included.
|
|
87
|
+
*
|
|
88
|
+
* It is one override, and it has to be: `-c` overrides are applied in order,
|
|
89
|
+
* and a later whole-table assignment **replaces** an earlier dotted one rather
|
|
90
|
+
* than merging with it. `-c mcp_servers.prohost={…} -c mcp_servers={…}` — the
|
|
91
|
+
* shape this had until it was measured — therefore drops the ProhostAI server
|
|
92
|
+
* outright, on every run that disables anything, while the harness went on
|
|
93
|
+
* logging "ProhostAI tools wired in" and the prompt went on telling the agent
|
|
94
|
+
* it had them. (Deep-merging is what happens between the CLI override and the
|
|
95
|
+
* *config file*, which is what made the wrong shape look right.)
|
|
96
|
+
*/
|
|
71
97
|
export declare function buildMcpOverride(mcpUrl: string): string;
|
|
98
|
+
/** Entries destined for the one `mcp_servers` table override. */
|
|
99
|
+
export interface ExtraEntries {
|
|
100
|
+
/** `"name"={…}` fragments, for {@link mcpServersOverride}. */
|
|
101
|
+
entries: string[];
|
|
102
|
+
/** Names that could not be encoded, for the operator to see. */
|
|
103
|
+
skipped: string[];
|
|
104
|
+
/** Literal extra-server credentials, kept off the process command line. */
|
|
105
|
+
env: NodeJS.ProcessEnv;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Turn `extra-mcp.json` entries into `mcp_servers` table fragments.
|
|
109
|
+
*
|
|
110
|
+
* The name is quoted rather than written as a dotted path, which is what lets
|
|
111
|
+
* *any* name work. `-c` splits on the first `=` and reads everything before it
|
|
112
|
+
* as a dotted path, so `-c mcp_servers.fly.io.enabled=false` addresses
|
|
113
|
+
* `mcp_servers.fly.io` — three segments, not two — and Codex rejects the whole
|
|
114
|
+
* config with `invalid transport in mcp_servers.fly`. Quoting the segment does
|
|
115
|
+
* not help either: `mcp_servers."fly.io"` fails the same way, because the
|
|
116
|
+
* splitting happens before any TOML is parsed. Putting the name inside the
|
|
117
|
+
* *value* sidesteps the path parser entirely.
|
|
118
|
+
*
|
|
119
|
+
* Claude's JSON shape carries literal `headers` and stdio `env` values. Those
|
|
120
|
+
* must not be copied literally into Codex's `-c` argument: command lines are
|
|
121
|
+
* readable by other local processes. Translate them to Codex's indirection
|
|
122
|
+
* fields and put the values in the child environment instead. `type` is also a
|
|
123
|
+
* Claude-only discriminator; Codex infers the transport from `url`/`command`.
|
|
124
|
+
*/
|
|
125
|
+
export declare function extraMcpEntries(servers: Record<string, Record<string, unknown>>, platform?: NodeJS.Platform): ExtraEntries;
|
|
126
|
+
/**
|
|
127
|
+
* Table fragments that switch off the servers the operator's Codex install has.
|
|
128
|
+
*
|
|
129
|
+
* This is the Codex answer to `--strict-mcp-config`, and it took a real binary
|
|
130
|
+
* to find. Two mechanisms that look like they should work do not: replacing the
|
|
131
|
+
* table wholesale does **not** replace it — Codex deep-merges config overrides,
|
|
132
|
+
* so the operator's servers survive alongside whatever the override adds — and
|
|
133
|
+
* `--ignore-user-config` does work but drops the operator's *entire* config
|
|
134
|
+
* with it, taking their model, profile and sandbox settings along for a
|
|
135
|
+
* security fix they did not ask to pay for that way.
|
|
136
|
+
*
|
|
137
|
+
* Setting `enabled=false` per server is the surgical one, verified end to end
|
|
138
|
+
* against `codex-cli 0.144.1`: the server is then reported `disabled` by
|
|
139
|
+
* `codex mcp list`, its own definition is left intact, other servers are
|
|
140
|
+
* untouched, and its tools are genuinely absent from the model's tool list
|
|
141
|
+
* mid-run.
|
|
142
|
+
*
|
|
143
|
+
* Names the extras file defines are left alone. Codex would deep-merge
|
|
144
|
+
* `enabled=false` into the operator's definition of the same name and produce a
|
|
145
|
+
* server the operator asked for twice and got zero times, which is a worse
|
|
146
|
+
* outcome than the collision itself.
|
|
147
|
+
*/
|
|
148
|
+
export declare function disableMcpEntries(configuredNames: string[], keep?: string[]): string[];
|
|
149
|
+
/**
|
|
150
|
+
* Fold every entry into the single `-c mcp_servers={…}` override.
|
|
151
|
+
*
|
|
152
|
+
* One override rather than one per server, because they all reach the same
|
|
153
|
+
* table and Codex merges rather than replaces — so the combined form is
|
|
154
|
+
* equivalent, shorter on the command line, and (unlike the dotted-path form)
|
|
155
|
+
* imposes no constraint on what a server may be called.
|
|
156
|
+
*/
|
|
157
|
+
export declare function mcpServersOverride(entries: string[]): string | undefined;
|
|
158
|
+
/** One MCP server as Codex reports it, and whether this config leaves it on. */
|
|
159
|
+
export interface CodexMcpServer {
|
|
160
|
+
name: string;
|
|
161
|
+
enabled: boolean;
|
|
162
|
+
}
|
|
163
|
+
/** Runs one short command in the same directory as Codex itself. */
|
|
164
|
+
export type CommandRunner = (command: string, cwd?: string) => {
|
|
165
|
+
status: number | null;
|
|
166
|
+
stdout: string;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Ask the binary what MCP servers it has, optionally under a `-c` override.
|
|
170
|
+
*
|
|
171
|
+
* Asking rather than parsing `~/.codex/config.toml` is deliberate twice over.
|
|
172
|
+
* `codex mcp list --json` already honours `CODEX_HOME` and the binary's normal
|
|
173
|
+
* config layering — and, measured on an ordinary install, it
|
|
174
|
+
* reports servers that appear **nowhere** in that file (plugins contribute
|
|
175
|
+
* their own). A config file we half-understand is exactly how an isolation
|
|
176
|
+
* guarantee ends up with a hole in it.
|
|
177
|
+
*
|
|
178
|
+
* Passing an override makes this a *verifier*: the listing then reflects the
|
|
179
|
+
* config the run would actually get, so "did the disable take" is a question
|
|
180
|
+
* with an answer rather than an assumption.
|
|
181
|
+
*
|
|
182
|
+
* The working directory is equally load-bearing. Codex discovers trusted
|
|
183
|
+
* project `.codex/config.toml` layers from cwd, so a probe in the harness's
|
|
184
|
+
* launch directory cannot certify a run executed in `$PROHOST_HOME/workspace`
|
|
185
|
+
* or an explicit `--workdir`. Every probe uses the eventual run cwd.
|
|
186
|
+
*
|
|
187
|
+
* Returns `null` — distinct from `[]` — when the question could not be
|
|
188
|
+
* answered, which for an override also means "that override does not load".
|
|
189
|
+
* The caller must treat the two differently: an empty list means there is
|
|
190
|
+
* nothing to disable, while `null` means we do not know, and a run that cannot
|
|
191
|
+
* enumerate must say so rather than claim an isolation it did not apply.
|
|
192
|
+
*/
|
|
193
|
+
export declare function listMcpServers(binary: string, runner: CommandRunner, override?: string, cwd?: string): CodexMcpServer[] | null;
|
|
194
|
+
/** Disable entries proven to work, and the servers that could not be disabled. */
|
|
195
|
+
export interface VerifiedDisable {
|
|
196
|
+
entries: string[];
|
|
197
|
+
/** Servers still reachable from the run. Empty is the outcome we want. */
|
|
198
|
+
failed: string[];
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Disable entries, checked against the binary before they are used.
|
|
202
|
+
*
|
|
203
|
+
* Not paranoia — a measured failure. `{enabled=false}` merges onto a server's
|
|
204
|
+
* existing definition, and a server contributed by a **plugin** has no entry in
|
|
205
|
+
* `mcp_servers` to merge onto, so the override defines a table with no
|
|
206
|
+
* transport and Codex refuses the entire config: `invalid transport in
|
|
207
|
+
* mcp_servers.github`. Shipped unverified, that would not have leaked a
|
|
208
|
+
* server — it would have broken *every* run for anyone with a Codex plugin
|
|
209
|
+
* installed, which is a far worse outcome than the hole it was closing.
|
|
210
|
+
*
|
|
211
|
+
* So the entries are tried before they are trusted: one probe for the whole
|
|
212
|
+
* set, and only if that fails, one probe per server to find which ones can be
|
|
213
|
+
* disabled and which cannot. The happy path costs a single extra command per
|
|
214
|
+
* survey; the slow path is paid only by installs that need it.
|
|
215
|
+
*
|
|
216
|
+
* `failed` is the honest part. A server that cannot be disabled is still
|
|
217
|
+
* reachable from the run, and the caller has to say so out loud rather than
|
|
218
|
+
* report an isolation it only partly achieved.
|
|
219
|
+
*/
|
|
220
|
+
export declare function disableEntriesThatLoad(binary: string, runner: CommandRunner, targets: string[], cwd?: string): VerifiedDisable;
|
|
221
|
+
/**
|
|
222
|
+
* The `codex` binary out of the host's `--exec` command.
|
|
223
|
+
*
|
|
224
|
+
* The host may have written a path (`/opt/homebrew/bin/codex`), and the
|
|
225
|
+
* enumeration above has to ask the *same* binary the run will use — a different
|
|
226
|
+
* one on `PATH` could be a different version reading a different `CODEX_HOME`.
|
|
227
|
+
*/
|
|
228
|
+
export declare function codexBinary(command: string): string;
|
|
229
|
+
/**
|
|
230
|
+
* Is the host running Codex under a named profile?
|
|
231
|
+
*
|
|
232
|
+
* Worth asking because it is a hole in the isolation that cannot be closed from
|
|
233
|
+
* here. `codex exec -p work` additionally loads `$CODEX_HOME/work.config.toml`,
|
|
234
|
+
* and `codex mcp list` has no way to be asked about a profile: `-p` and
|
|
235
|
+
* `--profile` are rejected outright, and `-c profile="work"` is refused as a
|
|
236
|
+
* removed legacy form. So a server defined only inside a profile is invisible
|
|
237
|
+
* to the enumeration and stays reachable from the run.
|
|
238
|
+
*
|
|
239
|
+
* Nothing here can fix that — reading the profile file ourselves is the
|
|
240
|
+
* config-parsing this deliberately avoids, and it is exactly how an isolation
|
|
241
|
+
* guarantee ends up with a hole nobody knows about. What it can do is tell the
|
|
242
|
+
* operator, which is what the caller does with this.
|
|
243
|
+
*/
|
|
244
|
+
export declare function codexProfileName(command: string): string | undefined;
|
|
245
|
+
/**
|
|
246
|
+
* A host flag that can make the actual run's MCP inventory differ from the
|
|
247
|
+
* plain `codex mcp list` probe.
|
|
248
|
+
*
|
|
249
|
+
* Replaying arbitrary config arguments into a separate shell command would
|
|
250
|
+
* require parsing the operator's shell syntax exactly. Guessing is not an
|
|
251
|
+
* isolation boundary, so reject the small set of flags that can alter cwd,
|
|
252
|
+
* config, or plugin loading. `-C`/`--cd` matters because Codex discovers
|
|
253
|
+
* project-local config from the directory it runs in. Dedicated model/sandbox
|
|
254
|
+
* flags remain supported.
|
|
255
|
+
*/
|
|
256
|
+
export declare function codexMcpInventoryFlag(command: string): string | undefined;
|
|
72
257
|
export interface CodexFlags {
|
|
73
258
|
/** Session (Codex calls it a thread) to resume. Omitted for the first turn. */
|
|
74
259
|
sessionId?: string;
|
|
75
|
-
/**
|
|
76
|
-
|
|
260
|
+
/**
|
|
261
|
+
* `mcp_servers` table entries for this run: the ProhostAI server, the
|
|
262
|
+
* operator's extras, and the `enabled=false` switches that keep their own
|
|
263
|
+
* config's servers out.
|
|
264
|
+
*
|
|
265
|
+
* Entries rather than complete overrides, and one list rather than several,
|
|
266
|
+
* because they must all land in a **single** `-c mcp_servers={…}` — see
|
|
267
|
+
* {@link buildMcpOverride} for what happens when they don't.
|
|
268
|
+
*/
|
|
269
|
+
mcpEntries?: string[];
|
|
77
270
|
/**
|
|
78
271
|
* Run with approvals and the sandbox bypassed. Default ON — see
|
|
79
272
|
* {@link codexCommand}. Set false by `agent run --safe-tools`.
|
|
@@ -116,10 +309,12 @@ export type CodexOutput = {
|
|
|
116
309
|
ok: true;
|
|
117
310
|
text: string;
|
|
118
311
|
sessionId?: string;
|
|
312
|
+
denials: string[];
|
|
119
313
|
} | {
|
|
120
314
|
ok: false;
|
|
121
315
|
error: string;
|
|
122
316
|
sessionId?: string;
|
|
317
|
+
denials: string[];
|
|
123
318
|
};
|
|
124
319
|
/**
|
|
125
320
|
* Read the reply and the thread id out of `--json` stdout.
|
|
@@ -139,6 +334,15 @@ export type CodexOutput = {
|
|
|
139
334
|
* than a fallback to posting the raw text. We asked for JSON; anything else
|
|
140
335
|
* means something went wrong that we do not understand, and the one thing that
|
|
141
336
|
* must not happen is that reaching a customer's conversation.
|
|
337
|
+
*
|
|
338
|
+
* Refused tool calls come back too, as the Codex counterpart of Claude Code's
|
|
339
|
+
* `permission_denials`. Codex has no such field, but it does emit the fact: a
|
|
340
|
+
* refused call is an `item.completed` carrying a `mcp_tool_call` with
|
|
341
|
+
* `status: "failed"` and `error.message: "user cancelled MCP tool call"`, which
|
|
342
|
+
* is what a run with nobody at the terminal gets every time. Names are spelled
|
|
343
|
+
* `mcp__<server>__<tool>` so the harness's log line reads identically whichever
|
|
344
|
+
* brain produced it — and because that is genuinely how Codex names the tool to
|
|
345
|
+
* the model (verified against 0.144.1, same convention as Claude Code).
|
|
142
346
|
*/
|
|
143
347
|
export declare function parseCodexOutput(stdout: string): CodexOutput;
|
|
144
348
|
/**
|