@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.
@@ -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 { MCP_SERVER_NAME, claudeCommand, isClaudeExec, looksLikeResumeFailure, parseClaudeOutput, writeMcpConfig, } from './claude.js';
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 — for Claude
118
- * Code only — the MCP config that gives the agent tools.
121
+ * Assemble the per-process runtime: workspace, session ledger, and the
122
+ * brain-specific MCP preparation used immediately before each run.
119
123
  *
120
- * Every step here is allowed to fail without stopping the harness. An agent
121
- * that answers from the prompt alone is the product as it shipped; refusing to
122
- * start because a config file couldn't be written would be a regression
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 claude = isClaudeExec(options.exec);
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 (!claude)
131
- return { workdir, sessions, claude, skipPermissions };
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
- else {
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
- log(`${timestamp()} ⚙ Claude Code detected — resuming a session per conversation` +
155
- (mcpConfigPath ? `, ProhostAI tools wired in as ${MCP_SERVER_NAME}` : '') +
156
- `${workdir ? ` (workspace: ${workdir})` : ''}`);
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
- '(--dangerously-skip-permissions). The agent acts as you, with your local ' +
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, claude, mcpConfigPath, skipPermissions };
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 (!runtime.claude) {
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: Claude Code records it in the
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 shape = { mcpConfigPath: runtime.mcpConfigPath, skipPermissions: runtime.skipPermissions };
218
- let result = await exec(claudeCommand(options.exec, { ...shape, sessionId }));
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
- looksLikeResumeFailure(`${result.stderr}\n${result.stdout}`, sessionId) &&
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(claudeCommand(options.exec, shape));
422
+ result = await exec(build());
232
423
  }
233
424
  if (!result.ok)
234
425
  return failure(result);
235
- const parsed = parseClaudeOutput(result.stdout);
236
- // A denied tool is not an error the agent reports — the turn succeeds and the
237
- // reply just quietly lacks whatever the tool would have found. Printing it is
238
- // the difference between "the agent seems dumb" and "the agent asked for
239
- // WebSearch and there was nobody to approve it".
240
- if (parsed.denials.length > 0) {
241
- log(` ! tools the agent asked for and was refused: ${parsed.denials.join(', ')}`);
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: runtime.mcpConfigPath ? MCP_SERVER_NAME : undefined,
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.1";
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.1";
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.1';
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",