@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
package/dist/agent/run.d.ts
CHANGED
|
@@ -59,6 +59,18 @@ export interface AgentRunOptions {
|
|
|
59
59
|
maxConnections?: number;
|
|
60
60
|
/** Injectable sleep so tests don't wait out the reconnect backoff. */
|
|
61
61
|
sleepImpl?: (ms: number) => Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Injectable short-command runner, used before Codex runs to ask which MCP
|
|
64
|
+
* servers the operator's current config defines. Separate from
|
|
65
|
+
* {@link spawnImpl} because it is synchronous and captures stdout, and
|
|
66
|
+
* because a test wants to answer this question without answering the other.
|
|
67
|
+
* The second argument is the exact cwd the eventual Codex process receives;
|
|
68
|
+
* Codex discovers project-local config from it.
|
|
69
|
+
*/
|
|
70
|
+
probeImpl?: (command: string, cwd?: string) => {
|
|
71
|
+
status: number | null;
|
|
72
|
+
stdout: string;
|
|
73
|
+
};
|
|
62
74
|
/** Environment used to locate the run ledger (tests point this at a temp dir). */
|
|
63
75
|
env?: NodeJS.ProcessEnv;
|
|
64
76
|
}
|
|
@@ -81,6 +93,26 @@ export interface AgentRunMetrics {
|
|
|
81
93
|
*/
|
|
82
94
|
fatal?: string;
|
|
83
95
|
}
|
|
96
|
+
interface PreparedMcp {
|
|
97
|
+
/** Claude Code: path of the strict generated MCP config. */
|
|
98
|
+
mcpConfigPath?: string;
|
|
99
|
+
/** Codex: entries in the single verified `mcp_servers` override. */
|
|
100
|
+
codexMcpEntries?: string[];
|
|
101
|
+
/** Credentials moved out of Codex's process argv. */
|
|
102
|
+
execEnv?: NodeJS.ProcessEnv;
|
|
103
|
+
/** ProhostAI tools are actually reachable from this invocation. */
|
|
104
|
+
mcpWired: boolean;
|
|
105
|
+
/** A fail-closed preparation error. When set, the agent is never spawned. */
|
|
106
|
+
error?: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Prepare a fresh Codex MCP override immediately before each invocation.
|
|
110
|
+
*
|
|
111
|
+
* No cache is safe here: Codex rereads its config per invocation and the
|
|
112
|
+
* extras file is the operator's live grant list. Fresh reads make both a new
|
|
113
|
+
* configured server and a revoked extra visible to the very next run.
|
|
114
|
+
*/
|
|
115
|
+
export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (line: string) => void, workdir?: string): () => PreparedMcp;
|
|
84
116
|
/**
|
|
85
117
|
* Run the harness until the abort signal fires (or ``maxConnections`` is hit).
|
|
86
118
|
*
|
|
@@ -88,3 +120,4 @@ export interface AgentRunMetrics {
|
|
|
88
120
|
* is fatal, because retrying a rejected credential just hammers the server.
|
|
89
121
|
*/
|
|
90
122
|
export declare function runAgentHarness(options: AgentRunOptions): Promise<AgentRunMetrics>;
|
|
123
|
+
export {};
|
package/dist/agent/run.js
CHANGED
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
* the command a second time and post a second reply.
|
|
19
19
|
*/
|
|
20
20
|
import os from 'node:os';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
21
22
|
import WebSocket from 'ws';
|
|
22
23
|
import { completeRun, postReply } from './api.js';
|
|
23
|
-
import {
|
|
24
|
+
import { claudeCommand, isClaudeExec, looksLikeResumeFailure, parseClaudeOutput, writeMcpConfig, } from './claude.js';
|
|
25
|
+
import { MCP_API_KEY_ENV_VAR, codexBinary, codexCommand, codexMcpInventoryFlag, codexProfileName, codexShellSyntax, disableEntriesThatLoad, extraMcpEntries, isCodexExec, listMcpServers, mcpServersOverride, prohostMcpEntry, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.js';
|
|
26
|
+
import { EXTRA_MCP_FILENAME, loadExtraMcpServers } from './extras.js';
|
|
27
|
+
import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
|
|
24
28
|
import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, eventData, parseRunRequest, } from './contract.js';
|
|
25
29
|
import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
|
|
26
30
|
import { execAgent } from './exec.js';
|
|
@@ -114,56 +118,225 @@ function resolveWorkdir(options, log) {
|
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
/**
|
|
117
|
-
* Assemble the per-process runtime: workspace, session ledger, and
|
|
118
|
-
*
|
|
121
|
+
* Assemble the per-process runtime: workspace, session ledger, and the
|
|
122
|
+
* brain-specific MCP preparation used immediately before each run.
|
|
119
123
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* dressed up as a safety check.
|
|
124
|
+
* Brain-specific MCP preparation happens per run. A daemon is long-lived, so
|
|
125
|
+
* an extras-file deletion or newly installed Codex server must take effect on
|
|
126
|
+
* the next invocation rather than after a restart or cache timeout.
|
|
124
127
|
*/
|
|
125
128
|
function buildRuntime(options, log) {
|
|
126
129
|
const workdir = resolveWorkdir(options, log);
|
|
127
130
|
const sessions = SessionStore.load(options.env, log);
|
|
128
|
-
const
|
|
131
|
+
const brain = isClaudeExec(options.exec)
|
|
132
|
+
? 'claude'
|
|
133
|
+
: isCodexExec(options.exec)
|
|
134
|
+
? 'codex'
|
|
135
|
+
: 'plain';
|
|
129
136
|
const skipPermissions = options.safeTools !== true;
|
|
130
|
-
if (
|
|
131
|
-
return { workdir, sessions,
|
|
132
|
-
let mcpConfigPath;
|
|
133
|
-
const mcpUrl = options.credentials.mcp_url;
|
|
134
|
-
if (mcpUrl) {
|
|
135
|
-
try {
|
|
136
|
-
mcpConfigPath = writeMcpConfig({
|
|
137
|
-
mcpUrl,
|
|
138
|
-
apiKey: options.credentials.api_key,
|
|
139
|
-
env: options.env,
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
catch (err) {
|
|
143
|
-
// Said out loud rather than swallowed: the visible symptom would
|
|
144
|
-
// otherwise be an agent that answers "I can't look that up", which reads
|
|
145
|
-
// like a model problem and sends someone to the wrong file.
|
|
146
|
-
log(`${timestamp()} ! could not write the MCP config ` +
|
|
147
|
-
`(${err instanceof Error ? err.message : String(err)}); the agent will run without ` +
|
|
148
|
-
'ProhostAI tools');
|
|
149
|
-
}
|
|
137
|
+
if (brain === 'plain') {
|
|
138
|
+
return { workdir, sessions, brain, skipPermissions, prepareMcp: () => ({ mcpWired: false }) };
|
|
150
139
|
}
|
|
151
|
-
|
|
140
|
+
const mcpUrl = options.credentials.mcp_url;
|
|
141
|
+
let announced = '';
|
|
142
|
+
const announce = (lines) => {
|
|
143
|
+
const message = lines.join('\n');
|
|
144
|
+
if (message === announced)
|
|
145
|
+
return;
|
|
146
|
+
for (const line of lines)
|
|
147
|
+
log(line);
|
|
148
|
+
announced = message;
|
|
149
|
+
};
|
|
150
|
+
if (!mcpUrl) {
|
|
152
151
|
log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
|
|
153
152
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
const prepareMcp = brain === 'claude'
|
|
154
|
+
? () => {
|
|
155
|
+
const extras = loadExtraMcpServers({ env: options.env });
|
|
156
|
+
announce(extras.warnings.map((warning) => `${timestamp()} ! ${warning}`));
|
|
157
|
+
// Always pass an explicit strict config, including when it is empty.
|
|
158
|
+
// Omitting the path would make `--strict-mcp-config` ineffective and
|
|
159
|
+
// expose Claude's user/project MCP servers under skipped permissions.
|
|
160
|
+
try {
|
|
161
|
+
const mcpConfigPath = writeMcpConfig({
|
|
162
|
+
mcpUrl,
|
|
163
|
+
apiKey: options.credentials.api_key,
|
|
164
|
+
extraServers: extras.servers,
|
|
165
|
+
env: options.env,
|
|
166
|
+
});
|
|
167
|
+
return { mcpConfigPath, mcpWired: Boolean(mcpUrl) };
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
171
|
+
return {
|
|
172
|
+
mcpWired: false,
|
|
173
|
+
error: `could not write the strict Claude MCP config (${detail})`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
: codexMcpEntryProvider(options, log, workdir);
|
|
178
|
+
const tools = mcpUrl ? `, ProhostAI tools prepared per run as ${MCP_SERVER_NAME}` : '';
|
|
179
|
+
const where = workdir ? ` (workspace: ${workdir})` : '';
|
|
180
|
+
log(brain === 'claude'
|
|
181
|
+
? `${timestamp()} ⚙ Claude Code detected — resuming a session per conversation${tools}${where}`
|
|
182
|
+
: `${timestamp()} ⚙ Codex detected — resuming a thread per conversation${tools}${where}`);
|
|
157
183
|
// Said out loud every start-up, not buried in --help. This is the line that
|
|
158
184
|
// tells an operator their machine will run tool calls, unprompted, on text
|
|
159
185
|
// written by someone else in their workspace.
|
|
186
|
+
const bypassFlag = brain === 'claude' ? '--dangerously-skip-permissions' : '--yolo';
|
|
160
187
|
log(skipPermissions
|
|
161
188
|
? `${timestamp()} ⚠ tools run WITHOUT permission prompts ` +
|
|
162
|
-
|
|
189
|
+
`(${bypassFlag}). The agent acts as you, with your local ` +
|
|
163
190
|
'access, on instructions from your workspace. Use --safe-tools to gate them.'
|
|
164
191
|
: `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
|
|
165
192
|
'approval will not run (nobody is at a terminal to approve it).');
|
|
166
|
-
return { workdir, sessions,
|
|
193
|
+
return { workdir, sessions, brain, prepareMcp, skipPermissions };
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Prepare a fresh Codex MCP override immediately before each invocation.
|
|
197
|
+
*
|
|
198
|
+
* No cache is safe here: Codex rereads its config per invocation and the
|
|
199
|
+
* extras file is the operator's live grant list. Fresh reads make both a new
|
|
200
|
+
* configured server and a revoked extra visible to the very next run.
|
|
201
|
+
*/
|
|
202
|
+
export function codexMcpEntryProvider(options, log, workdir) {
|
|
203
|
+
let announced = '';
|
|
204
|
+
return () => {
|
|
205
|
+
const extras = loadExtraMcpServers({ env: options.env });
|
|
206
|
+
const lines = extras.warnings.map((warning) => `${timestamp()} ! ${warning}`);
|
|
207
|
+
const prepared = codexMcpEntries(options, extras.servers, (line) => lines.push(line), workdir);
|
|
208
|
+
const said = lines.join('\n');
|
|
209
|
+
if (said !== announced) {
|
|
210
|
+
for (const line of lines)
|
|
211
|
+
log(line);
|
|
212
|
+
announced = said;
|
|
213
|
+
}
|
|
214
|
+
return prepared;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/*
|
|
218
|
+
* Every `mcp_servers` entry a Codex run gets: ours, the operator's extras, and
|
|
219
|
+
* verified switches that keep the operator's own servers out. Preparation is
|
|
220
|
+
* fail-closed: an unprovable boundary never reaches an unsandboxed agent.
|
|
221
|
+
*/
|
|
222
|
+
function codexMcpEntries(options, extraServers, log, workdir) {
|
|
223
|
+
const fail = (detail) => {
|
|
224
|
+
log(`${timestamp()} ! ${detail}`);
|
|
225
|
+
return { mcpWired: false, error: detail };
|
|
226
|
+
};
|
|
227
|
+
const probe = options.probeImpl ?? defaultProbe;
|
|
228
|
+
if (codexShellSyntax(options.exec)) {
|
|
229
|
+
return fail('--exec uses shell quoting, expansion, glob, comment, escape, or control syntax that the Codex ' +
|
|
230
|
+
'isolation verifier cannot tokenize safely; use a plain argv-shaped Codex command');
|
|
231
|
+
}
|
|
232
|
+
const binary = codexBinary(options.exec);
|
|
233
|
+
const entries = [];
|
|
234
|
+
const execEnv = {};
|
|
235
|
+
if (options.credentials.mcp_url) {
|
|
236
|
+
entries.push(prohostMcpEntry(options.credentials.mcp_url));
|
|
237
|
+
execEnv[MCP_API_KEY_ENV_VAR] = options.credentials.api_key;
|
|
238
|
+
}
|
|
239
|
+
const profile = codexProfileName(options.exec);
|
|
240
|
+
if (profile) {
|
|
241
|
+
return fail(`--exec runs Codex under profile "${profile}", whose MCP servers cannot be enumerated; ` +
|
|
242
|
+
'remove the profile flag before running this harness');
|
|
243
|
+
}
|
|
244
|
+
const inventoryFlag = codexMcpInventoryFlag(options.exec);
|
|
245
|
+
if (inventoryFlag) {
|
|
246
|
+
return fail(`--exec includes ${inventoryFlag}, which can change Codex's MCP or plugin inventory, or its ` +
|
|
247
|
+
`working directory, outside the configuration the harness can verify; remove cwd, config, feature, ` +
|
|
248
|
+
'and --ignore-user-config flags');
|
|
249
|
+
}
|
|
250
|
+
const configured = listMcpServers(binary, probe, undefined, workdir);
|
|
251
|
+
if (configured === null) {
|
|
252
|
+
return fail('could not ask Codex which MCP servers it has configured');
|
|
253
|
+
}
|
|
254
|
+
const configuredNames = new Set(configured.map((server) => server.name));
|
|
255
|
+
if (configuredNames.has(MCP_SERVER_NAME)) {
|
|
256
|
+
return fail(`your Codex config already defines the reserved MCP server name "${MCP_SERVER_NAME}"; ` +
|
|
257
|
+
'rename or remove it so credentials cannot be merged into the paired server');
|
|
258
|
+
}
|
|
259
|
+
const collisions = Object.keys(extraServers).filter((name) => configuredNames.has(name));
|
|
260
|
+
if (collisions.length > 0) {
|
|
261
|
+
log(`${timestamp()} ! ${EXTRA_MCP_FILENAME} reuses names your Codex config already defines, ` +
|
|
262
|
+
`so those entries were ignored (Codex would merge their credentials): ${collisions.join(', ')}. Rename them to use them.`);
|
|
263
|
+
}
|
|
264
|
+
const usableExtras = Object.fromEntries(Object.entries(extraServers).filter(([name]) => !collisions.includes(name)));
|
|
265
|
+
const extra = extraMcpEntries(usableExtras);
|
|
266
|
+
if (extra.skipped.length > 0) {
|
|
267
|
+
log(`${timestamp()} ! could not pass these extra MCP servers to Codex ` +
|
|
268
|
+
`(value not expressible as a safe config override): ${extra.skipped.join(', ')}`);
|
|
269
|
+
}
|
|
270
|
+
let acceptedExtraEntries = [];
|
|
271
|
+
if (extra.entries.length > 0) {
|
|
272
|
+
if (listMcpServers(binary, probe, mcpServersOverride(extra.entries), workdir) === null) {
|
|
273
|
+
log(`${timestamp()} ! Codex rejected the server definitions in ${EXTRA_MCP_FILENAME}; ` +
|
|
274
|
+
'the run will continue without those optional extras');
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
acceptedExtraEntries = extra.entries;
|
|
278
|
+
entries.push(...acceptedExtraEntries);
|
|
279
|
+
Object.assign(execEnv, extra.env);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// A configured collision was dropped above, so it is deliberately disabled.
|
|
283
|
+
const acceptedExtraNames = new Set(Object.keys(usableExtras).filter((name) => !extra.skipped.includes(name)));
|
|
284
|
+
if (acceptedExtraEntries.length === 0)
|
|
285
|
+
acceptedExtraNames.clear();
|
|
286
|
+
const targets = [...configuredNames].filter((name) => !acceptedExtraNames.has(name));
|
|
287
|
+
const { entries: disabled, failed } = disableEntriesThatLoad(binary, probe, targets, workdir);
|
|
288
|
+
if (failed.length > 0) {
|
|
289
|
+
return fail(`these Codex MCP servers could not be disabled: ${failed.join(', ')}`);
|
|
290
|
+
}
|
|
291
|
+
entries.push(...disabled);
|
|
292
|
+
if (disabled.length > 0) {
|
|
293
|
+
log(`${timestamp()} ⚙ your own Codex MCP servers are disabled for agent runs: ${targets.join(', ')}`);
|
|
294
|
+
}
|
|
295
|
+
// Verify the exact combined override, not its pieces. This catches a config
|
|
296
|
+
// merge that re-enables a server and a server installed between the initial
|
|
297
|
+
// survey and this final probe.
|
|
298
|
+
const override = mcpServersOverride(entries);
|
|
299
|
+
const finalListing = listMcpServers(binary, probe, override, workdir);
|
|
300
|
+
if (finalListing === null)
|
|
301
|
+
return fail('Codex rejected the final isolated MCP configuration');
|
|
302
|
+
const allowed = new Set([
|
|
303
|
+
...(options.credentials.mcp_url ? [MCP_SERVER_NAME] : []),
|
|
304
|
+
...acceptedExtraNames,
|
|
305
|
+
]);
|
|
306
|
+
const enabled = new Set(finalListing.filter((server) => server.enabled).map((server) => server.name));
|
|
307
|
+
const missing = [...allowed].filter((name) => !enabled.has(name));
|
|
308
|
+
if (missing.length > 0) {
|
|
309
|
+
return fail(`expected MCP servers are missing or disabled after isolation: ${missing.join(', ')}`);
|
|
310
|
+
}
|
|
311
|
+
const unexpected = finalListing.filter((server) => server.enabled && !allowed.has(server.name));
|
|
312
|
+
if (unexpected.length > 0) {
|
|
313
|
+
return fail(`unexpected MCP servers remain enabled after isolation: ${unexpected.map((server) => server.name).join(', ')}`);
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
codexMcpEntries: entries,
|
|
317
|
+
execEnv: Object.keys(execEnv).length > 0 ? execEnv : undefined,
|
|
318
|
+
mcpWired: Boolean(options.credentials.mcp_url),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Run one short command and capture stdout.
|
|
323
|
+
*
|
|
324
|
+
* `shell: true` for the same reason `exec.ts` uses it — the binary name comes
|
|
325
|
+
* out of the operator's own `--exec` string and may be a quoted path — and for
|
|
326
|
+
* the same reason it is safe: nothing event-derived reaches this command line.
|
|
327
|
+
* `cwd` must match the eventual run exactly, because Codex loads trusted
|
|
328
|
+
* project `.codex/config.toml` layers from there.
|
|
329
|
+
*/
|
|
330
|
+
function defaultProbe(command, cwd) {
|
|
331
|
+
const result = spawnSync(command, {
|
|
332
|
+
shell: true,
|
|
333
|
+
cwd,
|
|
334
|
+
encoding: 'utf8',
|
|
335
|
+
// Generous for a config read, short enough that a wedged binary delays
|
|
336
|
+
// a run rather than wedging the daemon.
|
|
337
|
+
timeout: 15_000,
|
|
338
|
+
});
|
|
339
|
+
return { status: result.status, stdout: result.stdout ?? '' };
|
|
167
340
|
}
|
|
168
341
|
/**
|
|
169
342
|
* Run the agent command once and turn its output into a reply.
|
|
@@ -174,7 +347,7 @@ function buildRuntime(options, log) {
|
|
|
174
347
|
* tell a failed turn from a successful one. A plain command's behaviour here is
|
|
175
348
|
* byte-for-byte what it was before this function existed.
|
|
176
349
|
*/
|
|
177
|
-
async function runTurn(run, prompt, options, runtime, log) {
|
|
350
|
+
async function runTurn(run, prompt, options, runtime, mcp, log) {
|
|
178
351
|
// `--timeout` is a ceiling on the run, not on each attempt: the retry below
|
|
179
352
|
// must not let one run take twice what the operator allowed. Both attempts
|
|
180
353
|
// share one deadline, which costs the retry nothing in practice — a rejected
|
|
@@ -187,11 +360,15 @@ async function runTurn(run, prompt, options, runtime, log) {
|
|
|
187
360
|
prompt,
|
|
188
361
|
timeoutMs: remainingMs(),
|
|
189
362
|
cwd: runtime.workdir,
|
|
363
|
+
env: mcp.execEnv,
|
|
190
364
|
spawnImpl: options.spawnImpl,
|
|
191
365
|
});
|
|
192
366
|
// Wall clock for the whole turn, retry included — what the operator waited,
|
|
193
367
|
// rather than what the last attempt took.
|
|
194
368
|
const elapsed = () => Date.now() - startedAt;
|
|
369
|
+
if (mcp.error) {
|
|
370
|
+
return { ok: false, reason: 'MCP isolation could not be established', detail: mcp.error, durationMs: 0 };
|
|
371
|
+
}
|
|
195
372
|
const failure = (result) => ({
|
|
196
373
|
ok: false,
|
|
197
374
|
reason: result.error ??
|
|
@@ -199,7 +376,7 @@ async function runTurn(run, prompt, options, runtime, log) {
|
|
|
199
376
|
detail: result.stderr.trim().slice(-500),
|
|
200
377
|
durationMs: elapsed(),
|
|
201
378
|
});
|
|
202
|
-
if (
|
|
379
|
+
if (runtime.brain === 'plain') {
|
|
203
380
|
const result = await exec(options.exec);
|
|
204
381
|
if (!result.ok)
|
|
205
382
|
return failure(result);
|
|
@@ -209,37 +386,67 @@ async function runTurn(run, prompt, options, runtime, log) {
|
|
|
209
386
|
: { ok: false, reason: 'agent produced no output', durationMs: elapsed() };
|
|
210
387
|
}
|
|
211
388
|
// No session bookkeeping on a dry run. `--dry-run` promises to make no
|
|
212
|
-
// writes, and a resumed turn is a write:
|
|
389
|
+
// writes, and a resumed turn is a write: both brains record it in their
|
|
213
390
|
// transcript whether or not we post the reply, so remembering the session
|
|
214
391
|
// would leave the next *real* run continuing from an answer nobody saw.
|
|
215
392
|
const key = options.dryRun ? undefined : sessionKeyFor(run);
|
|
216
393
|
const sessionId = runtime.sessions.get(key);
|
|
217
|
-
const
|
|
218
|
-
|
|
394
|
+
const codex = runtime.brain === 'codex';
|
|
395
|
+
// Same three questions for both brains — resume this thread, wire these
|
|
396
|
+
// tools, gate or don't — asked with each one's own flags.
|
|
397
|
+
const build = (resume) => codex
|
|
398
|
+
? codexCommand(options.exec, {
|
|
399
|
+
sessionId: resume,
|
|
400
|
+
mcpEntries: mcp.codexMcpEntries,
|
|
401
|
+
skipPermissions: runtime.skipPermissions,
|
|
402
|
+
})
|
|
403
|
+
: claudeCommand(options.exec, {
|
|
404
|
+
sessionId: resume,
|
|
405
|
+
mcpConfigPath: mcp.mcpConfigPath,
|
|
406
|
+
skipPermissions: runtime.skipPermissions,
|
|
407
|
+
});
|
|
408
|
+
let result = await exec(build(sessionId));
|
|
219
409
|
// A remembered session may simply be gone — transcripts are cleaned up and
|
|
220
410
|
// the ledger can't know. One retry without it, because the alternative is a
|
|
221
411
|
// surface that fails every run forever over an optimisation.
|
|
412
|
+
const resumeFailed = codex ? looksLikeCodexResumeFailure : looksLikeResumeFailure;
|
|
222
413
|
if (!result.ok &&
|
|
223
414
|
sessionId &&
|
|
224
|
-
|
|
415
|
+
resumeFailed(`${result.stderr}\n${result.stdout}`, sessionId) &&
|
|
225
416
|
// Skipped rather than squeezed into a token budget: a retry with no time
|
|
226
417
|
// left would be killed on the spot and reported as a timeout, which is a
|
|
227
418
|
// worse error message than the resume failure it replaced.
|
|
228
419
|
remainingMs() > 0) {
|
|
229
420
|
log(` … session ${sessionId.slice(0, 12)} could not be resumed; starting a fresh one`);
|
|
230
421
|
runtime.sessions.forget(key);
|
|
231
|
-
result = await exec(
|
|
422
|
+
result = await exec(build());
|
|
232
423
|
}
|
|
233
424
|
if (!result.ok)
|
|
234
425
|
return failure(result);
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
426
|
+
// A denied tool is not an error either brain reports — the turn succeeds and
|
|
427
|
+
// the reply just quietly lacks whatever the tool would have found. Printing
|
|
428
|
+
// it is the difference between "the agent seems dumb" and "the agent asked
|
|
429
|
+
// for WebSearch and there was nobody to approve it".
|
|
430
|
+
const reportDenials = (denials) => {
|
|
431
|
+
if (denials.length > 0) {
|
|
432
|
+
log(` ! tools the agent asked for and was refused: ${denials.join(', ')}`);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
if (codex) {
|
|
436
|
+
const output = parseCodexOutput(result.stdout);
|
|
437
|
+
reportDenials(output.denials);
|
|
438
|
+
if (!output.ok) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
reason: 'the agent did not produce a usable reply',
|
|
442
|
+
detail: output.error,
|
|
443
|
+
durationMs: elapsed(),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return { ok: true, reply: output.text, durationMs: elapsed(), sessionId: output.sessionId };
|
|
242
447
|
}
|
|
448
|
+
const parsed = parseClaudeOutput(result.stdout);
|
|
449
|
+
reportDenials(parsed.denials);
|
|
243
450
|
if (!parsed.ok) {
|
|
244
451
|
return {
|
|
245
452
|
ok: false,
|
|
@@ -260,15 +467,21 @@ async function handleRun(run, options, runtime, api, log, sleep) {
|
|
|
260
467
|
const label = `run ${run.run_id.slice(0, 12)}`;
|
|
261
468
|
const surface = run.conversation_id ?? run.kind ?? 'no conversation';
|
|
262
469
|
log(`${timestamp()} ▶ ${label} started (${surface})`);
|
|
470
|
+
const mcp = runtime.prepareMcp();
|
|
263
471
|
const prompt = buildPrompt(run, {
|
|
264
472
|
agentName: options.credentials.agent?.name ?? 'your agent',
|
|
265
473
|
// Named only when the tools are actually reachable from this invocation.
|
|
266
474
|
// Telling an agent about an endpoint it has no client for is what produced
|
|
267
475
|
// replies describing lookups that never happened.
|
|
268
|
-
mcpServerName:
|
|
476
|
+
mcpServerName: mcp.mcpWired ? MCP_SERVER_NAME : undefined,
|
|
477
|
+
// Both shaped brains spell MCP tools `mcp__<server>__<tool>` — Codex's was
|
|
478
|
+
// an open question until it was measured against the binary in 0.2.3. A
|
|
479
|
+
// `plain` command is still told the server name only: we have no idea what
|
|
480
|
+
// it is. See PromptContext.mcpToolPattern.
|
|
481
|
+
mcpToolPattern: runtime.brain !== 'plain' && mcp.mcpWired ? MCP_TOOL_PATTERN : undefined,
|
|
269
482
|
toolsGated: !runtime.skipPermissions,
|
|
270
483
|
});
|
|
271
|
-
const result = await runTurn(run, prompt, options, runtime, log);
|
|
484
|
+
const result = await runTurn(run, prompt, options, runtime, mcp, log);
|
|
272
485
|
const seconds = (result.durationMs / 1000).toFixed(1);
|
|
273
486
|
if (!result.ok) {
|
|
274
487
|
const { reason } = result;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quoting for the `shell: true` command lines the brain harnesses build.
|
|
3
|
+
*
|
|
4
|
+
* Shared by `claude.ts` and `codex.ts` because both append values — a session
|
|
5
|
+
* id we stored, a path under `$PROHOST_HOME`, a TOML config override — to a
|
|
6
|
+
* string that a shell will parse.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Quote a value for a `shell: true` command line on this platform.
|
|
10
|
+
*
|
|
11
|
+
* Every value that goes through here is ours or the operator's — never
|
|
12
|
+
* event-derived text. It is quoted anyway: a home directory with a space in it
|
|
13
|
+
* is ordinary, and "the inputs are trusted" is the assumption that stops being
|
|
14
|
+
* true the first time someone extends this.
|
|
15
|
+
*
|
|
16
|
+
* Windows needs its own branch. `shell: true` there runs `cmd.exe`, where a
|
|
17
|
+
* single quote is an ordinary character rather than a quoting one — POSIX
|
|
18
|
+
* quoting would hand the child a config path with literal apostrophes around
|
|
19
|
+
* it, and the tools would simply never load.
|
|
20
|
+
*/
|
|
21
|
+
export declare function shellQuote(value: string, platform: NodeJS.Platform): string;
|
|
22
|
+
/** Pipes, lists, redirections, subshells — anything that makes "append to the end" wrong. */
|
|
23
|
+
export declare const SHELL_CONTROL_OPERATORS: RegExp;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quoting for the `shell: true` command lines the brain harnesses build.
|
|
3
|
+
*
|
|
4
|
+
* Shared by `claude.ts` and `codex.ts` because both append values — a session
|
|
5
|
+
* id we stored, a path under `$PROHOST_HOME`, a TOML config override — to a
|
|
6
|
+
* string that a shell will parse.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Quote a value for a `shell: true` command line on this platform.
|
|
10
|
+
*
|
|
11
|
+
* Every value that goes through here is ours or the operator's — never
|
|
12
|
+
* event-derived text. It is quoted anyway: a home directory with a space in it
|
|
13
|
+
* is ordinary, and "the inputs are trusted" is the assumption that stops being
|
|
14
|
+
* true the first time someone extends this.
|
|
15
|
+
*
|
|
16
|
+
* Windows needs its own branch. `shell: true` there runs `cmd.exe`, where a
|
|
17
|
+
* single quote is an ordinary character rather than a quoting one — POSIX
|
|
18
|
+
* quoting would hand the child a config path with literal apostrophes around
|
|
19
|
+
* it, and the tools would simply never load.
|
|
20
|
+
*/
|
|
21
|
+
export function shellQuote(value, platform) {
|
|
22
|
+
if (platform === 'win32')
|
|
23
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
24
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
25
|
+
}
|
|
26
|
+
/** Pipes, lists, redirections, subshells — anything that makes "append to the end" wrong. */
|
|
27
|
+
export const SHELL_CONTROL_OPERATORS = /[|&;<>(){}`]|\$\(/;
|
package/dist/index.js
CHANGED
|
@@ -57,6 +57,16 @@ function printHelp() {
|
|
|
57
57
|
' act; with --safe-tools expect it to answer rather than do. Alias:',
|
|
58
58
|
' --no-skip-permissions.',
|
|
59
59
|
'',
|
|
60
|
+
'Extra MCP servers (opt-in):',
|
|
61
|
+
' A run gets the ProhostAI MCP server and nothing else. To add more, create',
|
|
62
|
+
' $PROHOST_HOME/extra-mcp.json yourself — the CLI never writes it — holding the same',
|
|
63
|
+
' {"mcpServers": {...}} object Claude Code\'s --mcp-config takes. Works on Claude Code',
|
|
64
|
+
' and Codex.',
|
|
65
|
+
' EVERY server you add is exercisable by anyone who can trigger this agent, with',
|
|
66
|
+
' permission prompts off and no human in the loop. Scope each one as narrowly as it',
|
|
67
|
+
' will go. The name "prohost" is reserved; a malformed file is a warning and a run',
|
|
68
|
+
' without extras. Which servers loaded is printed to agent.log at start-up.',
|
|
69
|
+
'',
|
|
60
70
|
'agent install-daemon flags: (macOS: launchd; Linux: prints a systemd-user unit)',
|
|
61
71
|
' --exec The same command you would pass to `agent run` (required).',
|
|
62
72
|
' --workdir, --timeout, --machine, --url, --allow-unverified, --safe-tools — passed to `agent run`.',
|
package/dist/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.2.
|
|
10
|
+
export declare const CLI_VERSION = "0.2.3";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.2.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.2.3";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.2.
|
|
10
|
+
export const CLI_VERSION = '0.2.3';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|