@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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The operator's opt-in extra MCP servers (`$PROHOST_HOME/extra-mcp.json`).
3
+ *
4
+ * Both brains this harness shapes get exactly one MCP server by default — ours
5
+ * — and that isolation is a security boundary rather than an implementation
6
+ * detail. A paired run executes with permission prompts off, on prompt text
7
+ * written by anyone who can mention the agent in the workspace, so every server
8
+ * reachable from that run is a server a workspace teammate can drive. Claude
9
+ * Code is held to it by `--strict-mcp-config`; Codex is held to it by
10
+ * explicitly disabling the servers its own config defines.
11
+ *
12
+ * That leaves no way at all to give the agent a tool the operator *does* want
13
+ * it to have, which is why this file exists. It is **never created by the
14
+ * CLI** — its presence is the operator's deliberate act, taken after reading
15
+ * what it costs. Nothing here weakens the boundary; it moves the decision to a
16
+ * file whose only purpose is to record it.
17
+ *
18
+ * Everything in this module fails soft, in the sense `claude.ts` states: a
19
+ * malformed file is a warning and a run without extras, never a run that
20
+ * doesn't happen. The operator's agent going quiet is a worse outcome than
21
+ * their extra server being missing, and the missing server is the one they can
22
+ * actually see in the log.
23
+ */
24
+ /** Filename of the operator-authored extras file, inside `$PROHOST_HOME`. */
25
+ export declare const EXTRA_MCP_FILENAME = "extra-mcp.json";
26
+ /** One server definition, passed through to the brain untouched. */
27
+ export type ExtraServer = Record<string, unknown>;
28
+ export interface ExtraMcpServers {
29
+ /** Accepted definitions, keyed by server name. Empty when there are none. */
30
+ servers: Record<string, ExtraServer>;
31
+ /**
32
+ * Lines for the operator to see when the current grant set is loaded.
33
+ *
34
+ * Both the good news and the bad travel this way: which extras loaded is as
35
+ * worth printing as which were rejected, because "I added a server and the
36
+ * agent still can't use it" is otherwise a silent, unfalsifiable complaint.
37
+ */
38
+ warnings: string[];
39
+ }
40
+ /** Absolute path of the extras file. */
41
+ export declare function extraMcpPath(env?: NodeJS.ProcessEnv): string;
42
+ export interface LoadExtraOptions {
43
+ env?: NodeJS.ProcessEnv;
44
+ /** Injectable read, so a test can exercise the parse without a filesystem. */
45
+ readImpl?: (file: string) => string;
46
+ }
47
+ /**
48
+ * Read and validate `extra-mcp.json`.
49
+ *
50
+ * The file's shape is deliberately the same object Claude Code's
51
+ * `--mcp-config` takes — `{"mcpServers": {"<name>": {...}}}` — so an operator
52
+ * can lift a block straight out of a `.mcp.json` they already have rather than
53
+ * learn a format that exists only here.
54
+ *
55
+ * Four rejections, each returning a warning rather than an exception:
56
+ *
57
+ * * **No file.** Not a warning at all — the overwhelmingly common case, and
58
+ * the CLI never creates one, so its absence is the default state rather
59
+ * than something the operator did wrong.
60
+ * * **Unparseable JSON.** A warning and zero extras. The alternative — refuse
61
+ * to start — would take a working agent offline over a trailing comma in an
62
+ * optional enhancement.
63
+ * * **A server named `prohost`.** Rejected loudly and never merged. The name
64
+ * is how the prompt, the `--allowedTools` pattern and both generated
65
+ * configs refer to the ProhostAI server; letting a file on disk take it
66
+ * over would silently redirect a run's workspace lookups — with the agent
67
+ * still reporting, in good faith, that it looked them up.
68
+ * * **A definition that is not an object.** Rejected per entry, because a
69
+ * `"github": "https://…"` typo would otherwise reach the brain as a config
70
+ * it cannot parse and take the whole config down with it — including our
71
+ * server.
72
+ */
73
+ export declare function loadExtraMcpServers(options?: LoadExtraOptions): ExtraMcpServers;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The operator's opt-in extra MCP servers (`$PROHOST_HOME/extra-mcp.json`).
3
+ *
4
+ * Both brains this harness shapes get exactly one MCP server by default — ours
5
+ * — and that isolation is a security boundary rather than an implementation
6
+ * detail. A paired run executes with permission prompts off, on prompt text
7
+ * written by anyone who can mention the agent in the workspace, so every server
8
+ * reachable from that run is a server a workspace teammate can drive. Claude
9
+ * Code is held to it by `--strict-mcp-config`; Codex is held to it by
10
+ * explicitly disabling the servers its own config defines.
11
+ *
12
+ * That leaves no way at all to give the agent a tool the operator *does* want
13
+ * it to have, which is why this file exists. It is **never created by the
14
+ * CLI** — its presence is the operator's deliberate act, taken after reading
15
+ * what it costs. Nothing here weakens the boundary; it moves the decision to a
16
+ * file whose only purpose is to record it.
17
+ *
18
+ * Everything in this module fails soft, in the sense `claude.ts` states: a
19
+ * malformed file is a warning and a run without extras, never a run that
20
+ * doesn't happen. The operator's agent going quiet is a worse outcome than
21
+ * their extra server being missing, and the missing server is the one they can
22
+ * actually see in the log.
23
+ */
24
+ import { readFileSync } from 'node:fs';
25
+ import path from 'node:path';
26
+ import { prohostHome } from './credentials.js';
27
+ import { MCP_SERVER_NAME } from './mcp.js';
28
+ /** Filename of the operator-authored extras file, inside `$PROHOST_HOME`. */
29
+ export const EXTRA_MCP_FILENAME = 'extra-mcp.json';
30
+ /** Absolute path of the extras file. */
31
+ export function extraMcpPath(env = process.env) {
32
+ return path.join(prohostHome(env), EXTRA_MCP_FILENAME);
33
+ }
34
+ function isPlainObject(value) {
35
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
36
+ }
37
+ /**
38
+ * Read and validate `extra-mcp.json`.
39
+ *
40
+ * The file's shape is deliberately the same object Claude Code's
41
+ * `--mcp-config` takes — `{"mcpServers": {"<name>": {...}}}` — so an operator
42
+ * can lift a block straight out of a `.mcp.json` they already have rather than
43
+ * learn a format that exists only here.
44
+ *
45
+ * Four rejections, each returning a warning rather than an exception:
46
+ *
47
+ * * **No file.** Not a warning at all — the overwhelmingly common case, and
48
+ * the CLI never creates one, so its absence is the default state rather
49
+ * than something the operator did wrong.
50
+ * * **Unparseable JSON.** A warning and zero extras. The alternative — refuse
51
+ * to start — would take a working agent offline over a trailing comma in an
52
+ * optional enhancement.
53
+ * * **A server named `prohost`.** Rejected loudly and never merged. The name
54
+ * is how the prompt, the `--allowedTools` pattern and both generated
55
+ * configs refer to the ProhostAI server; letting a file on disk take it
56
+ * over would silently redirect a run's workspace lookups — with the agent
57
+ * still reporting, in good faith, that it looked them up.
58
+ * * **A definition that is not an object.** Rejected per entry, because a
59
+ * `"github": "https://…"` typo would otherwise reach the brain as a config
60
+ * it cannot parse and take the whole config down with it — including our
61
+ * server.
62
+ */
63
+ export function loadExtraMcpServers(options = {}) {
64
+ const file = extraMcpPath(options.env ?? process.env);
65
+ const read = options.readImpl ?? ((target) => readFileSync(target, 'utf8'));
66
+ let raw;
67
+ try {
68
+ raw = read(file);
69
+ }
70
+ catch (err) {
71
+ // ENOENT is the normal state and must stay silent. Anything else — a
72
+ // permission problem, a directory where the file should be — the operator
73
+ // wants to hear about, because they put the file there on purpose.
74
+ const code = err.code;
75
+ if (code === 'ENOENT')
76
+ return { servers: {}, warnings: [] };
77
+ return {
78
+ servers: {},
79
+ warnings: [`could not read ${file} (${err instanceof Error ? err.message : String(err)}); no extra MCP servers`],
80
+ };
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ }
86
+ catch (err) {
87
+ return {
88
+ servers: {},
89
+ warnings: [`${file} is not valid JSON (${err instanceof Error ? err.message : String(err)}); no extra MCP servers`],
90
+ };
91
+ }
92
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.mcpServers)) {
93
+ return {
94
+ servers: {},
95
+ warnings: [`${file} has no "mcpServers" object; no extra MCP servers`],
96
+ };
97
+ }
98
+ const servers = {};
99
+ const warnings = [];
100
+ for (const [name, definition] of Object.entries(parsed.mcpServers)) {
101
+ if (name === MCP_SERVER_NAME) {
102
+ warnings.push(`${file} defines a server named "${MCP_SERVER_NAME}" — that name is reserved for the ` +
103
+ 'ProhostAI server and the entry was ignored. Rename it to use it.');
104
+ continue;
105
+ }
106
+ if (!isPlainObject(definition)) {
107
+ warnings.push(`${file}: server "${name}" is not an object; the entry was ignored`);
108
+ continue;
109
+ }
110
+ servers[name] = definition;
111
+ }
112
+ const names = Object.keys(servers);
113
+ if (names.length > 0) {
114
+ warnings.push(`extra MCP servers loaded from ${EXTRA_MCP_FILENAME}: ${names.join(', ')} — ` +
115
+ 'anyone who can trigger this agent can use them, with permission prompts off.');
116
+ }
117
+ return { servers, warnings };
118
+ }
@@ -12,12 +12,13 @@
12
12
  */
13
13
  export declare const MCP_SERVER_NAME = "prohost";
14
14
  /**
15
- * How Claude Code spells the wired-in tools.
15
+ * How both shaped brains spell the wired-in tools.
16
16
  *
17
- * Deliberately **not** used for Codex: that binary namespaces MCP tools its own
18
- * way, and this harness has not verified the spelling against it. Telling an
19
- * agent a tool is called something it isn't is the same class of failure as
20
- * naming an endpoint it cannot call — so the prompt names the server instead
21
- * and leaves the pattern out. See {@link ../prompt.ts}.
17
+ * Claude Code's spelling was known from its documentation. Codex's was an open
18
+ * question until 0.2.3 — the prompt named the server and left the pattern out
19
+ * rather than guess — and the answer, measured by registering a stub MCP server
20
+ * against a real `codex-cli 0.144.1` and asking the model what it had just
21
+ * called, is that Codex uses the same `mcp__<server>__<tool>` convention. So
22
+ * both brains now get the pattern. See {@link ../prompt.ts}.
22
23
  */
23
24
  export declare const MCP_TOOL_PATTERN = "mcp__prohost__*";
package/dist/agent/mcp.js CHANGED
@@ -12,12 +12,13 @@
12
12
  */
13
13
  export const MCP_SERVER_NAME = 'prohost';
14
14
  /**
15
- * How Claude Code spells the wired-in tools.
15
+ * How both shaped brains spell the wired-in tools.
16
16
  *
17
- * Deliberately **not** used for Codex: that binary namespaces MCP tools its own
18
- * way, and this harness has not verified the spelling against it. Telling an
19
- * agent a tool is called something it isn't is the same class of failure as
20
- * naming an endpoint it cannot call — so the prompt names the server instead
21
- * and leaves the pattern out. See {@link ../prompt.ts}.
17
+ * Claude Code's spelling was known from its documentation. Codex's was an open
18
+ * question until 0.2.3 — the prompt named the server and left the pattern out
19
+ * rather than guess — and the answer, measured by registering a stub MCP server
20
+ * against a real `codex-cli 0.144.1` and asking the model what it had just
21
+ * called, is that Codex uses the same `mcp__<server>__<tool>` convention. So
22
+ * both brains now get the pattern. See {@link ../prompt.ts}.
22
23
  */
23
24
  export const MCP_TOOL_PATTERN = `mcp__${MCP_SERVER_NAME}__*`;
@@ -22,11 +22,12 @@ export interface PromptContext {
22
22
  * How this particular brain spells the wired-in tools, e.g. `mcp__prohost__*`.
23
23
  *
24
24
  * Set only when the harness has actually verified the spelling for the brain
25
- * it is driving — Claude Code, today. Left unset for Codex, whose MCP tool
26
- * namespacing this harness has not confirmed: the prompt then names the
27
- * server without claiming a prefix, because a wrong tool name is the same
28
- * failure as a wrong endpoint. The agent can see its own tool list; it does
29
- * not need us to guess at it.
25
+ * it is driving — Claude Code and, since 0.2.3, Codex, which turned out to
26
+ * use the same convention. Left unset for every other `--exec` command,
27
+ * whose namespacing we have no way to know: the prompt then names the server
28
+ * without claiming a prefix, because a wrong tool name is the same failure as
29
+ * a wrong endpoint. The agent can see its own tool list; it does not need us
30
+ * to guess at it.
30
31
  */
31
32
  mcpToolPattern?: string;
32
33
  /**
@@ -145,6 +145,16 @@ export function buildPrompt(run, context) {
145
145
  // could. Field-observed on the first 0.2.0 deployment; phrased
146
146
  // conditionally because other `--exec` brains have no ToolSearch.
147
147
  '- If you have a ToolSearch tool, part of your toolkit is deferred and', ' invisible until loaded — web browsing (WebSearch, WebFetch) usually', ' is. Search ToolSearch for a capability before telling anyone you', ' lack it.',
148
+ // The correction to the bullet above, and it is here because the bullet
149
+ // above caused a real production failure. A paired brain searched
150
+ // ToolSearch for "bash", found nothing — ToolSearch indexes only the
151
+ // *deferred* tools, and Bash is a direct one — and concluded across a
152
+ // whole resumed session that it had no shell, while
153
+ // `--dangerously-skip-permissions` had been handing it the full toolset
154
+ // the entire time. "Search before you claim you lack it" quietly taught
155
+ // the opposite of what it meant: that a ToolSearch miss is evidence of
156
+ // absence. It is not evidence of anything.
157
+ '- A ToolSearch miss proves nothing. ToolSearch indexes only the deferred', ' tools; the tools listed directly to you are the authoritative set, and', ' a tool can be fully available while ToolSearch has never heard of it', ' (Bash is exactly this). Never reason from a ToolSearch miss to "I do', ' not have that tool".',
148
158
  // The counterpart to the ToolSearch bullet, and the same failure one level
149
159
  // up: a brain that assumes it is boxed in reports "I have no access to the
150
160
  // codebase" from a machine with the checkout, the credentials and the CLI
@@ -161,6 +171,12 @@ export function buildPrompt(run, context) {
161
171
  ' lives, `gh auth status`, whether the command is on PATH. A quick',
162
172
  ' look, not an audit — this run has a hard time limit and somebody',
163
173
  ' waiting at the end of it.',
174
+ '- "Looking" means running the command, not inspecting your own tool',
175
+ ' list. Say you have no shell, no file access, or no checkout only',
176
+ ' after an attempt failed and you can quote what it said. Introspection',
177
+ ' is not evidence here: you were started with your tools unrestricted,',
178
+ ' so a capability you cannot find in a list is far more likely to be',
179
+ ' one you have not tried.',
164
180
  ]),
165
181
  // Only when there is something to look at. Said unconditionally it is a
166
182
  // paragraph of advice about a situation that does not exist, which is how a
@@ -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 {};