@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.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * The one place that knows anything about Codex CLI specifically.
3
+ *
4
+ * The sibling of `claude.ts`, and it exists for the same reason: `exec.ts` is
5
+ * deliberately agent-agnostic — prompt on stdin, reply on stdout — and that
6
+ * lowest common denominator costs a Codex run the two things it can actually
7
+ * have. A run has no memory of the previous one, and it has no tools for
8
+ * looking anything up in the workspace it is answering about. Until this
9
+ * module existed, a `codex` brain ran bare while a `claude` brain got both, so
10
+ * the same mention answered by two hosts produced visibly different work.
11
+ *
12
+ * Everything here was verified against a real `codex-cli 0.144.1` rather than
13
+ * taken from documentation; the surprises are called out where they are relied
14
+ * on. Output and configuration used to prove the MCP boundary are deliberately
15
+ * load-bearing: if that proof is incomplete, the unsandboxed run must not
16
+ * start. Session continuity and reply parsing still fail independently.
17
+ */
18
+ /**
19
+ * Environment variable the API key is handed to Codex in.
20
+ *
21
+ * Codex resolves MCP headers from the environment at request time
22
+ * (`env_http_headers`), which is why the key travels this way rather than in
23
+ * the config override. The override is part of the command line, and a command
24
+ * line is world-readable out of `ps`; a child's environment is not. This is the
25
+ * one place the Codex path is meaningfully *safer* than the Claude one, which
26
+ * has to keep the key in a `0600` file on disk.
27
+ */
28
+ export declare const MCP_API_KEY_ENV_VAR = "PROHOST_MCP_API_KEY";
29
+ /**
30
+ * Is this exec command Codex CLI in non-interactive mode?
31
+ *
32
+ * A **heuristic**, and deliberately as narrow as {@link isClaudeExec}: it
33
+ * matches when the first word of the command is `codex` (or a path ending in
34
+ * `/codex`) *and* the command uses the `exec` subcommand (or its `e` alias).
35
+ * Both halves matter. Interactive `codex` has no `--json` and no `resume`
36
+ * subcommand, so shaping it would break a command that works today.
37
+ *
38
+ * Three things are deliberately not shaped, each because appending to it would
39
+ * do harm rather than nothing:
40
+ *
41
+ * * `codex exec resume …` and `codex exec review …`. The host already chose a
42
+ * sub-subcommand; appending our own `resume` would produce two of them.
43
+ * * Anything after a `--` sentinel, which is the host saying "stop parsing".
44
+ * * A command carrying its own unquoted prompt, e.g. `codex exec hello`. Codex
45
+ * takes exactly one prompt positional, so both the `-` this harness appends
46
+ * and a `resume <id>` would be a second one — `error: unexpected argument
47
+ * '-' found`, on a command that works today. Such a host keeps the plain
48
+ * contract; the place to put standing instructions is now the agent's
49
+ * ProhostAI profile, which arrives in the prompt itself.
50
+ *
51
+ * Shell syntax is different: recognisable Codex exec commands containing it
52
+ * stay classified as Codex so the harness can fail closed before spawn. Letting
53
+ * one fall through as a plain command would also drop the verified MCP boundary.
54
+ */
55
+ export declare function isCodexExec(command: string, platform?: NodeJS.Platform): boolean;
56
+ /**
57
+ * Shell syntax a shaped Codex command cannot safely preserve.
58
+ *
59
+ * Codex commands are eventually executed with `shell: true`, while the harness
60
+ * currently operates on whitespace-delimited words. Quotes, escapes,
61
+ * expansions, globs, comments and control/redirection operators all make those
62
+ * two views differ. Rather than guess at POSIX plus cmd.exe tokenization, only
63
+ * the deliberately boring argv-shaped subset is accepted. A matching command
64
+ * is still classified as Codex and then rejected before spawn.
65
+ */
66
+ export declare function codexShellSyntax(command: string, platform?: NodeJS.Platform): string | undefined;
67
+ /**
68
+ * The `-c` override that registers the ProhostAI MCP server.
69
+ *
70
+ * Codex takes a streamable-HTTP MCP server with **arbitrary** headers, which is
71
+ * what makes this possible at all: `codex mcp add --url` offers only
72
+ * `--bearer-token-env-var`, and a bearer token is the *wrong credential* for
73
+ * this endpoint — the server validates `Authorization: Bearer` as an OAuth
74
+ * access token, checks it first, and does not fall through to `X-API-Key` when
75
+ * it fails. A pairing hands out an `X-API-Key`. The `-c` path reaches the same
76
+ * config layer with none of that restriction.
77
+ *
78
+ * `env_http_headers` rather than `http_headers`: both work and both were
79
+ * verified against the running binary, but the former names an environment
80
+ * variable to read the value from instead of carrying the value itself, which
81
+ * keeps a live API key off a command line that anyone on the machine can read
82
+ * out of `ps`.
83
+ */
84
+ export declare function prohostMcpEntry(mcpUrl: string): string;
85
+ /**
86
+ * The whole `mcp_servers` override for a run, ProhostAI server included.
87
+ *
88
+ * It is one override, and it has to be: `-c` overrides are applied in order,
89
+ * and a later whole-table assignment **replaces** an earlier dotted one rather
90
+ * than merging with it. `-c mcp_servers.prohost={…} -c mcp_servers={…}` — the
91
+ * shape this had until it was measured — therefore drops the ProhostAI server
92
+ * outright, on every run that disables anything, while the harness went on
93
+ * logging "ProhostAI tools wired in" and the prompt went on telling the agent
94
+ * it had them. (Deep-merging is what happens between the CLI override and the
95
+ * *config file*, which is what made the wrong shape look right.)
96
+ */
97
+ export declare function buildMcpOverride(mcpUrl: string): string;
98
+ /** Entries destined for the one `mcp_servers` table override. */
99
+ export interface ExtraEntries {
100
+ /** `"name"={…}` fragments, for {@link mcpServersOverride}. */
101
+ entries: string[];
102
+ /** Names that could not be encoded, for the operator to see. */
103
+ skipped: string[];
104
+ /** Literal extra-server credentials, kept off the process command line. */
105
+ env: NodeJS.ProcessEnv;
106
+ }
107
+ /**
108
+ * Turn `extra-mcp.json` entries into `mcp_servers` table fragments.
109
+ *
110
+ * The name is quoted rather than written as a dotted path, which is what lets
111
+ * *any* name work. `-c` splits on the first `=` and reads everything before it
112
+ * as a dotted path, so `-c mcp_servers.fly.io.enabled=false` addresses
113
+ * `mcp_servers.fly.io` — three segments, not two — and Codex rejects the whole
114
+ * config with `invalid transport in mcp_servers.fly`. Quoting the segment does
115
+ * not help either: `mcp_servers."fly.io"` fails the same way, because the
116
+ * splitting happens before any TOML is parsed. Putting the name inside the
117
+ * *value* sidesteps the path parser entirely.
118
+ *
119
+ * Claude's JSON shape carries literal `headers` and stdio `env` values. Those
120
+ * must not be copied literally into Codex's `-c` argument: command lines are
121
+ * readable by other local processes. Translate them to Codex's indirection
122
+ * fields and put the values in the child environment instead. `type` is also a
123
+ * Claude-only discriminator; Codex infers the transport from `url`/`command`.
124
+ */
125
+ export declare function extraMcpEntries(servers: Record<string, Record<string, unknown>>, platform?: NodeJS.Platform): ExtraEntries;
126
+ /**
127
+ * Table fragments that switch off the servers the operator's Codex install has.
128
+ *
129
+ * This is the Codex answer to `--strict-mcp-config`, and it took a real binary
130
+ * to find. Two mechanisms that look like they should work do not: replacing the
131
+ * table wholesale does **not** replace it — Codex deep-merges config overrides,
132
+ * so the operator's servers survive alongside whatever the override adds — and
133
+ * `--ignore-user-config` does work but drops the operator's *entire* config
134
+ * with it, taking their model, profile and sandbox settings along for a
135
+ * security fix they did not ask to pay for that way.
136
+ *
137
+ * Setting `enabled=false` per server is the surgical one, verified end to end
138
+ * against `codex-cli 0.144.1`: the server is then reported `disabled` by
139
+ * `codex mcp list`, its own definition is left intact, other servers are
140
+ * untouched, and its tools are genuinely absent from the model's tool list
141
+ * mid-run.
142
+ *
143
+ * Names the extras file defines are left alone. Codex would deep-merge
144
+ * `enabled=false` into the operator's definition of the same name and produce a
145
+ * server the operator asked for twice and got zero times, which is a worse
146
+ * outcome than the collision itself.
147
+ */
148
+ export declare function disableMcpEntries(configuredNames: string[], keep?: string[]): string[];
149
+ /**
150
+ * Fold every entry into the single `-c mcp_servers={…}` override.
151
+ *
152
+ * One override rather than one per server, because they all reach the same
153
+ * table and Codex merges rather than replaces — so the combined form is
154
+ * equivalent, shorter on the command line, and (unlike the dotted-path form)
155
+ * imposes no constraint on what a server may be called.
156
+ */
157
+ export declare function mcpServersOverride(entries: string[]): string | undefined;
158
+ /** One MCP server as Codex reports it, and whether this config leaves it on. */
159
+ export interface CodexMcpServer {
160
+ name: string;
161
+ enabled: boolean;
162
+ }
163
+ /** Runs one short command in the same directory as Codex itself. */
164
+ export type CommandRunner = (command: string, cwd?: string) => {
165
+ status: number | null;
166
+ stdout: string;
167
+ };
168
+ /**
169
+ * Ask the binary what MCP servers it has, optionally under a `-c` override.
170
+ *
171
+ * Asking rather than parsing `~/.codex/config.toml` is deliberate twice over.
172
+ * `codex mcp list --json` already honours `CODEX_HOME` and the binary's normal
173
+ * config layering — and, measured on an ordinary install, it
174
+ * reports servers that appear **nowhere** in that file (plugins contribute
175
+ * their own). A config file we half-understand is exactly how an isolation
176
+ * guarantee ends up with a hole in it.
177
+ *
178
+ * Passing an override makes this a *verifier*: the listing then reflects the
179
+ * config the run would actually get, so "did the disable take" is a question
180
+ * with an answer rather than an assumption.
181
+ *
182
+ * The working directory is equally load-bearing. Codex discovers trusted
183
+ * project `.codex/config.toml` layers from cwd, so a probe in the harness's
184
+ * launch directory cannot certify a run executed in `$PROHOST_HOME/workspace`
185
+ * or an explicit `--workdir`. Every probe uses the eventual run cwd.
186
+ *
187
+ * Returns `null` — distinct from `[]` — when the question could not be
188
+ * answered, which for an override also means "that override does not load".
189
+ * The caller must treat the two differently: an empty list means there is
190
+ * nothing to disable, while `null` means we do not know, and a run that cannot
191
+ * enumerate must say so rather than claim an isolation it did not apply.
192
+ */
193
+ export declare function listMcpServers(binary: string, runner: CommandRunner, override?: string, cwd?: string): CodexMcpServer[] | null;
194
+ /** Disable entries proven to work, and the servers that could not be disabled. */
195
+ export interface VerifiedDisable {
196
+ entries: string[];
197
+ /** Servers still reachable from the run. Empty is the outcome we want. */
198
+ failed: string[];
199
+ }
200
+ /**
201
+ * Disable entries, checked against the binary before they are used.
202
+ *
203
+ * Not paranoia — a measured failure. `{enabled=false}` merges onto a server's
204
+ * existing definition, and a server contributed by a **plugin** has no entry in
205
+ * `mcp_servers` to merge onto, so the override defines a table with no
206
+ * transport and Codex refuses the entire config: `invalid transport in
207
+ * mcp_servers.github`. Shipped unverified, that would not have leaked a
208
+ * server — it would have broken *every* run for anyone with a Codex plugin
209
+ * installed, which is a far worse outcome than the hole it was closing.
210
+ *
211
+ * So the entries are tried before they are trusted: one probe for the whole
212
+ * set, and only if that fails, one probe per server to find which ones can be
213
+ * disabled and which cannot. The happy path costs a single extra command per
214
+ * survey; the slow path is paid only by installs that need it.
215
+ *
216
+ * `failed` is the honest part. A server that cannot be disabled is still
217
+ * reachable from the run, and the caller has to say so out loud rather than
218
+ * report an isolation it only partly achieved.
219
+ */
220
+ export declare function disableEntriesThatLoad(binary: string, runner: CommandRunner, targets: string[], cwd?: string): VerifiedDisable;
221
+ /**
222
+ * The `codex` binary out of the host's `--exec` command.
223
+ *
224
+ * The host may have written a path (`/opt/homebrew/bin/codex`), and the
225
+ * enumeration above has to ask the *same* binary the run will use — a different
226
+ * one on `PATH` could be a different version reading a different `CODEX_HOME`.
227
+ */
228
+ export declare function codexBinary(command: string): string;
229
+ /**
230
+ * Is the host running Codex under a named profile?
231
+ *
232
+ * Worth asking because it is a hole in the isolation that cannot be closed from
233
+ * here. `codex exec -p work` additionally loads `$CODEX_HOME/work.config.toml`,
234
+ * and `codex mcp list` has no way to be asked about a profile: `-p` and
235
+ * `--profile` are rejected outright, and `-c profile="work"` is refused as a
236
+ * removed legacy form. So a server defined only inside a profile is invisible
237
+ * to the enumeration and stays reachable from the run.
238
+ *
239
+ * Nothing here can fix that — reading the profile file ourselves is the
240
+ * config-parsing this deliberately avoids, and it is exactly how an isolation
241
+ * guarantee ends up with a hole nobody knows about. What it can do is tell the
242
+ * operator, which is what the caller does with this.
243
+ */
244
+ export declare function codexProfileName(command: string): string | undefined;
245
+ /**
246
+ * A host flag that can make the actual run's MCP inventory differ from the
247
+ * plain `codex mcp list` probe.
248
+ *
249
+ * Replaying arbitrary config arguments into a separate shell command would
250
+ * require parsing the operator's shell syntax exactly. Guessing is not an
251
+ * isolation boundary, so reject the small set of flags that can alter cwd,
252
+ * config, or plugin loading. `-C`/`--cd` matters because Codex discovers
253
+ * project-local config from the directory it runs in. Dedicated model/sandbox
254
+ * flags remain supported.
255
+ */
256
+ export declare function codexMcpInventoryFlag(command: string): string | undefined;
257
+ export interface CodexFlags {
258
+ /** Session (Codex calls it a thread) to resume. Omitted for the first turn. */
259
+ sessionId?: string;
260
+ /**
261
+ * `mcp_servers` table entries for this run: the ProhostAI server, the
262
+ * operator's extras, and the `enabled=false` switches that keep their own
263
+ * config's servers out.
264
+ *
265
+ * Entries rather than complete overrides, and one list rather than several,
266
+ * because they must all land in a **single** `-c mcp_servers={…}` — see
267
+ * {@link buildMcpOverride} for what happens when they don't.
268
+ */
269
+ mcpEntries?: string[];
270
+ /**
271
+ * Run with approvals and the sandbox bypassed. Default ON — see
272
+ * {@link codexCommand}. Set false by `agent run --safe-tools`.
273
+ */
274
+ skipPermissions?: boolean;
275
+ /** Quoting target. Defaults to the running platform; tests set it. */
276
+ platform?: NodeJS.Platform;
277
+ }
278
+ /**
279
+ * Append the flags this harness needs to a Codex command.
280
+ *
281
+ * Four things get added, and each one is load-bearing:
282
+ *
283
+ * `--json` turns stdout into a JSONL event stream, which is the only way to
284
+ * read the thread id back out (and so the only way sessions can continue).
285
+ *
286
+ * `--skip-git-repo-check` is not optional. The harness runs the agent in
287
+ * `$PROHOST_HOME/workspace`, which is not a git repository, and Codex refuses
288
+ * to start there — `Not inside a trusted directory and --skip-git-repo-check
289
+ * was not specified.` Without this flag **every** Codex run fails before the
290
+ * model is ever reached.
291
+ *
292
+ * `--yolo` mirrors Claude Code's `--dangerously-skip-permissions`, for the same
293
+ * reason: in a non-interactive run nobody is there to approve a command, so a
294
+ * sandboxed brain silently declines the work it was hired for. It is a hidden
295
+ * alias of `--dangerously-bypass-approvals-and-sandbox`, and passing both spells
296
+ * is a hard clap error (`cannot be used multiple times`) — so a host who already
297
+ * wrote either one must not get ours on top, or every run of theirs fails.
298
+ *
299
+ * `resume` goes **after** the host's own flags, not before them. `codex exec
300
+ * resume` accepts a strict subset of `codex exec`'s flags (no `-s`, `-C`,
301
+ * `--add-dir`, `-p`), and a flag placed after `resume` is parsed by `resume`.
302
+ * `codex exec -s workspace-write resume <id>` parses; `codex exec resume <id>
303
+ * -s workspace-write` is rejected. Appending keeps the host's flags where Codex
304
+ * still understands them.
305
+ */
306
+ export declare function codexCommand(base: string, flags?: CodexFlags): string;
307
+ /** What the harness got back from one Codex invocation. */
308
+ export type CodexOutput = {
309
+ ok: true;
310
+ text: string;
311
+ sessionId?: string;
312
+ denials: string[];
313
+ } | {
314
+ ok: false;
315
+ error: string;
316
+ sessionId?: string;
317
+ denials: string[];
318
+ };
319
+ /**
320
+ * Read the reply and the thread id out of `--json` stdout.
321
+ *
322
+ * Codex emits **JSONL**, not one object: `thread.started` (carrying
323
+ * `thread_id`), `turn.started`, an `item.completed` per item the turn produced,
324
+ * then `turn.completed`. Only `agent_message` items are the answer — reasoning
325
+ * and command items share the stream — and the last one is what a person would
326
+ * have seen at the end of the run.
327
+ *
328
+ * Lines that are not JSON are skipped rather than treated as corruption,
329
+ * because they are *expected*: an unreachable MCP server puts `ERROR
330
+ * rmcp::transport::worker: …` straight into this stream, and the run around it
331
+ * succeeds normally. This was observed, not guessed at.
332
+ *
333
+ * As in `parseClaudeOutput`, output with no reply in it is a **failure** rather
334
+ * than a fallback to posting the raw text. We asked for JSON; anything else
335
+ * means something went wrong that we do not understand, and the one thing that
336
+ * must not happen is that reaching a customer's conversation.
337
+ *
338
+ * Refused tool calls come back too, as the Codex counterpart of Claude Code's
339
+ * `permission_denials`. Codex has no such field, but it does emit the fact: a
340
+ * refused call is an `item.completed` carrying a `mcp_tool_call` with
341
+ * `status: "failed"` and `error.message: "user cancelled MCP tool call"`, which
342
+ * is what a run with nobody at the terminal gets every time. Names are spelled
343
+ * `mcp__<server>__<tool>` so the harness's log line reads identically whichever
344
+ * brain produced it — and because that is genuinely how Codex names the tool to
345
+ * the model (verified against 0.144.1, same convention as Claude Code).
346
+ */
347
+ export declare function parseCodexOutput(stdout: string): CodexOutput;
348
+ /**
349
+ * Does this look like the resume having been rejected, rather than the agent
350
+ * having failed at its actual work?
351
+ *
352
+ * Two ways a Codex resume can fail where a fresh run would have worked: the
353
+ * thread is gone (rollout files get cleaned up and the ledger cannot know), or
354
+ * the host's own flags are ones `codex exec resume` does not accept, which clap
355
+ * reports as `unexpected argument`. Both must degrade to a fresh session
356
+ * instead of breaking that surface forever.
357
+ *
358
+ * The `thread.started` guard is what keeps this from firing on an ordinary bad
359
+ * turn. Codex echoes the thread id in that event on **success**, so the
360
+ * "output mentions the session id" heuristic — which is all `claude.ts` can
361
+ * do — would match a resumed run that started fine and then failed at its
362
+ * work, and retrying that runs the agent, and its tools, a second time for
363
+ * nothing.
364
+ */
365
+ export declare function looksLikeCodexResumeFailure(output: string, sessionId: string): boolean;