@prohost/cli 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent/run.js CHANGED
@@ -18,10 +18,12 @@
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
24
  import { claudeCommand, isClaudeExec, looksLikeResumeFailure, parseClaudeOutput, writeMcpConfig, } from './claude.js';
24
- import { MCP_API_KEY_ENV_VAR, codexCommand, isCodexExec, looksLikeCodexResumeFailure, parseCodexOutput, } from './codex.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';
25
27
  import { MCP_SERVER_NAME, MCP_TOOL_PATTERN } from './mcp.js';
26
28
  import { EVENT_AGENT_RUN_REQUESTED, EVENT_MENTION_CREATED, EVENT_MESSAGE_TEAM_CHAT, eventData, parseRunRequest, } from './contract.js';
27
29
  import { ensureWorkspace, streamUrlFor, workspacePath } from './credentials.js';
@@ -116,13 +118,12 @@ function resolveWorkdir(options, log) {
116
118
  }
117
119
  }
118
120
  /**
119
- * Assemble the per-process runtime: workspace, session ledger, and — for Claude
120
- * 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.
121
123
  *
122
- * Every step here is allowed to fail without stopping the harness. An agent
123
- * that answers from the prompt alone is the product as it shipped; refusing to
124
- * start because a config file couldn't be written would be a regression
125
- * 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.
126
127
  */
127
128
  function buildRuntime(options, log) {
128
129
  const workdir = resolveWorkdir(options, log);
@@ -134,53 +135,51 @@ function buildRuntime(options, log) {
134
135
  : 'plain';
135
136
  const skipPermissions = options.safeTools !== true;
136
137
  if (brain === 'plain') {
137
- return { workdir, sessions, brain, skipPermissions, mcpWired: false };
138
+ return { workdir, sessions, brain, skipPermissions, prepareMcp: () => ({ mcpWired: false }) };
138
139
  }
139
140
  const mcpUrl = options.credentials.mcp_url;
140
- let mcpConfigPath;
141
- let codexMcpUrl;
142
- let execEnv;
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
+ };
143
150
  if (!mcpUrl) {
144
151
  log(`${timestamp()} ! this pairing carries no MCP endpoint; the agent will run without ProhostAI tools`);
145
152
  }
146
- else if (brain === 'claude') {
147
- try {
148
- mcpConfigPath = writeMcpConfig({
149
- mcpUrl,
150
- apiKey: options.credentials.api_key,
151
- env: options.env,
152
- });
153
- }
154
- catch (err) {
155
- // Said out loud rather than swallowed: the visible symptom would
156
- // otherwise be an agent that answers "I can't look that up", which reads
157
- // like a model problem and sends someone to the wrong file.
158
- log(`${timestamp()} ! could not write the MCP config ` +
159
- `(${err instanceof Error ? err.message : String(err)}); the agent will run without ` +
160
- 'ProhostAI tools');
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
+ }
161
176
  }
162
- }
163
- else {
164
- // Codex needs no file: the server is a `-c` override on each invocation and
165
- // the key is read from the child's environment. Nothing to fail, so nothing
166
- // to fall back from.
167
- codexMcpUrl = mcpUrl;
168
- execEnv = { [MCP_API_KEY_ENV_VAR]: options.credentials.api_key };
169
- }
170
- const mcpWired = Boolean(mcpConfigPath ?? codexMcpUrl);
171
- const tools = mcpWired ? `, ProhostAI tools wired in as ${MCP_SERVER_NAME}` : '';
177
+ : codexMcpEntryProvider(options, log, workdir);
178
+ const tools = mcpUrl ? `, ProhostAI tools prepared per run as ${MCP_SERVER_NAME}` : '';
172
179
  const where = workdir ? ` (workspace: ${workdir})` : '';
173
180
  log(brain === 'claude'
174
181
  ? `${timestamp()} ⚙ Claude Code detected — resuming a session per conversation${tools}${where}`
175
182
  : `${timestamp()} ⚙ Codex detected — resuming a thread per conversation${tools}${where}`);
176
- if (brain === 'codex' && mcpWired) {
177
- // The one asymmetry worth an operator knowing about, because it is a
178
- // security property they may be assuming they have. Claude Code is told to
179
- // load our config and nothing else; Codex has no equivalent switch, so its
180
- // own `~/.codex/config.toml` servers come along.
181
- log(`${timestamp()} ! Codex has no --strict-mcp-config equivalent: this run also holds ` +
182
- 'whatever MCP servers your ~/.codex/config.toml defines.');
183
- }
184
183
  // Said out loud every start-up, not buried in --help. This is the line that
185
184
  // tells an operator their machine will run tool calls, unprompted, on text
186
185
  // written by someone else in their workspace.
@@ -191,7 +190,153 @@ function buildRuntime(options, log) {
191
190
  'access, on instructions from your workspace. Use --safe-tools to gate them.'
192
191
  : `${timestamp()} ⚙ --safe-tools: tools are permission-gated; anything needing ` +
193
192
  'approval will not run (nobody is at a terminal to approve it).');
194
- return { workdir, sessions, brain, mcpConfigPath, codexMcpUrl, execEnv, mcpWired, 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 ?? '' };
195
340
  }
196
341
  /**
197
342
  * Run the agent command once and turn its output into a reply.
@@ -202,7 +347,7 @@ function buildRuntime(options, log) {
202
347
  * tell a failed turn from a successful one. A plain command's behaviour here is
203
348
  * byte-for-byte what it was before this function existed.
204
349
  */
205
- async function runTurn(run, prompt, options, runtime, log) {
350
+ async function runTurn(run, prompt, options, runtime, mcp, log) {
206
351
  // `--timeout` is a ceiling on the run, not on each attempt: the retry below
207
352
  // must not let one run take twice what the operator allowed. Both attempts
208
353
  // share one deadline, which costs the retry nothing in practice — a rejected
@@ -215,12 +360,15 @@ async function runTurn(run, prompt, options, runtime, log) {
215
360
  prompt,
216
361
  timeoutMs: remainingMs(),
217
362
  cwd: runtime.workdir,
218
- env: runtime.execEnv,
363
+ env: mcp.execEnv,
219
364
  spawnImpl: options.spawnImpl,
220
365
  });
221
366
  // Wall clock for the whole turn, retry included — what the operator waited,
222
367
  // rather than what the last attempt took.
223
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
+ }
224
372
  const failure = (result) => ({
225
373
  ok: false,
226
374
  reason: result.error ??
@@ -249,12 +397,12 @@ async function runTurn(run, prompt, options, runtime, log) {
249
397
  const build = (resume) => codex
250
398
  ? codexCommand(options.exec, {
251
399
  sessionId: resume,
252
- mcpUrl: runtime.codexMcpUrl,
400
+ mcpEntries: mcp.codexMcpEntries,
253
401
  skipPermissions: runtime.skipPermissions,
254
402
  })
255
403
  : claudeCommand(options.exec, {
256
404
  sessionId: resume,
257
- mcpConfigPath: runtime.mcpConfigPath,
405
+ mcpConfigPath: mcp.mcpConfigPath,
258
406
  skipPermissions: runtime.skipPermissions,
259
407
  });
260
408
  let result = await exec(build(sessionId));
@@ -275,8 +423,18 @@ async function runTurn(run, prompt, options, runtime, log) {
275
423
  }
276
424
  if (!result.ok)
277
425
  return failure(result);
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
+ };
278
435
  if (codex) {
279
436
  const output = parseCodexOutput(result.stdout);
437
+ reportDenials(output.denials);
280
438
  if (!output.ok) {
281
439
  return {
282
440
  ok: false,
@@ -288,13 +446,7 @@ async function runTurn(run, prompt, options, runtime, log) {
288
446
  return { ok: true, reply: output.text, durationMs: elapsed(), sessionId: output.sessionId };
289
447
  }
290
448
  const parsed = parseClaudeOutput(result.stdout);
291
- // A denied tool is not an error the agent reports — the turn succeeds and the
292
- // reply just quietly lacks whatever the tool would have found. Printing it is
293
- // the difference between "the agent seems dumb" and "the agent asked for
294
- // WebSearch and there was nobody to approve it".
295
- if (parsed.denials.length > 0) {
296
- log(` ! tools the agent asked for and was refused: ${parsed.denials.join(', ')}`);
297
- }
449
+ reportDenials(parsed.denials);
298
450
  if (!parsed.ok) {
299
451
  return {
300
452
  ok: false,
@@ -315,18 +467,21 @@ async function handleRun(run, options, runtime, api, log, sleep) {
315
467
  const label = `run ${run.run_id.slice(0, 12)}`;
316
468
  const surface = run.conversation_id ?? run.kind ?? 'no conversation';
317
469
  log(`${timestamp()} ▶ ${label} started (${surface})`);
470
+ const mcp = runtime.prepareMcp();
318
471
  const prompt = buildPrompt(run, {
319
472
  agentName: options.credentials.agent?.name ?? 'your agent',
320
473
  // Named only when the tools are actually reachable from this invocation.
321
474
  // Telling an agent about an endpoint it has no client for is what produced
322
475
  // replies describing lookups that never happened.
323
- mcpServerName: runtime.mcpWired ? MCP_SERVER_NAME : undefined,
324
- // Only Claude Code's spelling has been verified against the binary, so only
325
- // Claude Code gets told a pattern. See PromptContext.mcpToolPattern.
326
- mcpToolPattern: runtime.brain === 'claude' && runtime.mcpWired ? MCP_TOOL_PATTERN : 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,
327
482
  toolsGated: !runtime.skipPermissions,
328
483
  });
329
- const result = await runTurn(run, prompt, options, runtime, log);
484
+ const result = await runTurn(run, prompt, options, runtime, mcp, log);
330
485
  const seconds = (result.durationMs / 1000).toFixed(1);
331
486
  if (!result.ok) {
332
487
  const { reason } = result;
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.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.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.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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.2.2",
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",