@ravefox/space 0.0.0-stage → 0.2.0

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.
Files changed (152) hide show
  1. package/LICENSE +203 -0
  2. package/NOTICE +44 -0
  3. package/README.md +49 -2
  4. package/assets/webhook-sdk/python/bot.py +63 -0
  5. package/assets/webhook-sdk/python/commonly.py +232 -0
  6. package/node_modules/@commonlyai/seats/README.md +25 -0
  7. package/node_modules/@commonlyai/seats/package.json +22 -0
  8. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
  9. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  10. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  11. package/node_modules/@commonlyai/seats/src/failures.js +44 -0
  12. package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
  13. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  14. package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
  15. package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
  16. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  17. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  18. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  19. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  20. package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
  21. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  22. package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
  23. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
  24. package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
  25. package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
  26. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  27. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
  28. package/package.json +56 -4
  29. package/skills/commonly/SKILL.md +233 -0
  30. package/src/commands/agent.js +6334 -0
  31. package/src/commands/daemon.js +933 -0
  32. package/src/commands/dev.js +153 -0
  33. package/src/commands/doctor.js +141 -0
  34. package/src/commands/inbox.js +330 -0
  35. package/src/commands/login.js +194 -0
  36. package/src/commands/mcp.js +56 -0
  37. package/src/commands/pod.js +120 -0
  38. package/src/commands/task.js +279 -0
  39. package/src/commands/verify.js +48 -0
  40. package/src/index.js +99 -0
  41. package/src/lib/adapter-login.js +76 -0
  42. package/src/lib/adapters/broker-bridge-entry.js +76 -0
  43. package/src/lib/adapters/claude.js +2207 -0
  44. package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
  45. package/src/lib/adapters/codex.js +1898 -0
  46. package/src/lib/adapters/dsh.js +513 -0
  47. package/src/lib/adapters/goose.js +510 -0
  48. package/src/lib/adapters/hermes.js +18 -0
  49. package/src/lib/adapters/index.js +68 -0
  50. package/src/lib/adapters/opencode.js +1681 -0
  51. package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
  52. package/src/lib/adapters/pi-mcp-client.mjs +617 -0
  53. package/src/lib/adapters/pi-shell-env.mjs +41 -0
  54. package/src/lib/adapters/pi.js +1025 -0
  55. package/src/lib/adapters/qwen.js +523 -0
  56. package/src/lib/adapters/seat-model.js +8 -0
  57. package/src/lib/adapters/stub.js +1 -0
  58. package/src/lib/adapters/tested-version.js +106 -0
  59. package/src/lib/api.js +205 -0
  60. package/src/lib/claim-outcome.js +128 -0
  61. package/src/lib/claimed-work-gate.js +73 -0
  62. package/src/lib/cli-hint.js +33 -0
  63. package/src/lib/commit-attribution.js +391 -0
  64. package/src/lib/config.js +155 -0
  65. package/src/lib/content-refusal.js +94 -0
  66. package/src/lib/credential-file.js +208 -0
  67. package/src/lib/daemon-agent-states.js +95 -0
  68. package/src/lib/daemon-hint.js +18 -0
  69. package/src/lib/daemon-logs.js +43 -0
  70. package/src/lib/daemon-service-windows.js +263 -0
  71. package/src/lib/daemon-service.js +486 -0
  72. package/src/lib/daemon-state.js +84 -0
  73. package/src/lib/daemon-store.js +119 -0
  74. package/src/lib/daemon-supervisor.js +629 -0
  75. package/src/lib/daemon-watch.js +115 -0
  76. package/src/lib/declared-mcp-guard.js +284 -0
  77. package/src/lib/default-environment.js +207 -0
  78. package/src/lib/device-login.js +126 -0
  79. package/src/lib/digest-frame.js +151 -0
  80. package/src/lib/doctor.js +291 -0
  81. package/src/lib/egress-proxy.js +160 -0
  82. package/src/lib/enforcement.js +946 -0
  83. package/src/lib/environment.js +681 -0
  84. package/src/lib/first-output-watchdog.js +191 -0
  85. package/src/lib/git-readonly-server.js +138 -0
  86. package/src/lib/git-readonly.js +463 -0
  87. package/src/lib/git-remote-commonly.mjs +100 -0
  88. package/src/lib/grant-broker-guard.js +345 -0
  89. package/src/lib/hooks-config.js +238 -0
  90. package/src/lib/host-gh-broker.js +152 -0
  91. package/src/lib/host-gh-shim.mjs +98 -0
  92. package/src/lib/inbox.js +483 -0
  93. package/src/lib/instance-proxy.js +76 -0
  94. package/src/lib/machine-host.js +29 -0
  95. package/src/lib/mcp-credential-delivery.js +121 -0
  96. package/src/lib/mcp-first-run.js +50 -0
  97. package/src/lib/mcp-home.js +644 -0
  98. package/src/lib/mcp-ready.js +68 -0
  99. package/src/lib/mcp-server-version.js +123 -0
  100. package/src/lib/mcp-token.js +36 -0
  101. package/src/lib/mcp-warm-child.mjs +412 -0
  102. package/src/lib/memory-bridge.js +191 -0
  103. package/src/lib/memory-import.js +156 -0
  104. package/src/lib/missing-harness.js +31 -0
  105. package/src/lib/model-unavailable.js +154 -0
  106. package/src/lib/native-runtime-inventory.js +40 -0
  107. package/src/lib/outsider-grant-mcp.js +82 -0
  108. package/src/lib/own-key-launch.js +42 -0
  109. package/src/lib/pod-catch-up.js +55 -0
  110. package/src/lib/pod-focus.js +224 -0
  111. package/src/lib/pod-tail.js +68 -0
  112. package/src/lib/poll-liveness.js +103 -0
  113. package/src/lib/poll-retry.js +81 -0
  114. package/src/lib/poller.js +200 -0
  115. package/src/lib/provider-proxy.js +42 -0
  116. package/src/lib/public-seat-mcp-flag.js +36 -0
  117. package/src/lib/public-tool-error.js +60 -0
  118. package/src/lib/public-withheld-tools.js +48 -0
  119. package/src/lib/reply-receipt.js +55 -0
  120. package/src/lib/resolve-on-path.js +22 -0
  121. package/src/lib/runtime-inventory.js +56 -0
  122. package/src/lib/runtime-observation.js +37 -0
  123. package/src/lib/sandbox/bwrap.js +275 -0
  124. package/src/lib/sandbox/codex-perimeter.js +16 -0
  125. package/src/lib/sandbox/host-check.js +268 -0
  126. package/src/lib/sandbox/mode.js +49 -0
  127. package/src/lib/sandbox/opencode-loopback.mjs +68 -0
  128. package/src/lib/sandbox/seatbelt.js +684 -0
  129. package/src/lib/sandbox/turn-dir.js +56 -0
  130. package/src/lib/sandbox/windows-unsandboxed.js +183 -0
  131. package/src/lib/sandbox/workspace-git-config.js +243 -0
  132. package/src/lib/sandbox/workspace-pin.js +158 -0
  133. package/src/lib/seat-runtime-controls.js +61 -0
  134. package/src/lib/seat-stop.js +97 -0
  135. package/src/lib/server-skills.js +32 -0
  136. package/src/lib/session-recycle.js +219 -0
  137. package/src/lib/session-store.js +215 -0
  138. package/src/lib/skill-prompt.js +74 -0
  139. package/src/lib/skills-import.js +113 -0
  140. package/src/lib/spawn-credential.js +402 -0
  141. package/src/lib/spawn-retry.js +311 -0
  142. package/src/lib/stable-install.js +176 -0
  143. package/src/lib/supervised-turn.js +352 -0
  144. package/src/lib/task.js +235 -0
  145. package/src/lib/turn-failure-notice.js +177 -0
  146. package/src/lib/unconfined-seat-guard.js +94 -0
  147. package/src/lib/update-check.js +192 -0
  148. package/src/lib/upstream-refusal.js +1 -0
  149. package/src/lib/webhook-server.js +109 -0
  150. package/src/lib/windows-command.js +393 -0
  151. package/src/lib/workspace-git.js +87 -0
  152. package/src/lib/workspace-trust.js +162 -0
@@ -0,0 +1,1898 @@
1
+ import { codexRuntimeInventory } from '../native-runtime-inventory.js';
2
+ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
3
+ import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
4
+ import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/turn-processes';
5
+ /**
6
+ * codex adapter — wraps the local `codex` CLI as a Commonly agent.
7
+ *
8
+ * Contract: ADR-005 §Adapter pattern.
9
+ *
10
+ * Tested against codex-cli 0.125.0. The argv shape diverges from the
11
+ * ADR-005 §Adapters-shipped-in-v1 table (which was written against an
12
+ * earlier codex-acp variant): modern codex uses `codex exec resume <id>`
13
+ * as a subcommand to continue a session, NOT a `--session <id>` flag.
14
+ * If a future codex bumps the surface again, this adapter is the single
15
+ * file to update.
16
+ *
17
+ * Output shape:
18
+ * - stdout = JSONL events: `{"type":"thread.started","thread_id":"<uuid>"}`,
19
+ * `{"type":"turn.started"}`, `{"type":"turn.failed","error":{...}}`, etc.
20
+ * - stderr = Rust tracing logs (timestamped), unrelated to model output.
21
+ *
22
+ * We capture session id from the `thread.started` event and read the agent's
23
+ * final reply from the file written via `-o <FILE>` (codex's
24
+ * `--output-last-message` short alias) — cleaner than parsing every
25
+ * event-type variant the model can emit.
26
+ *
27
+ * Memory preamble: the adapter prepends the kernel's long-term memory context
28
+ * on every turn. A fresh underlying session additionally receives the
29
+ * read-first and durable-state-at-boundary cues, matching the Claude adapter
30
+ * so the run loop's memory plumbing works identically across drivers.
31
+ *
32
+ * Purity (§Load-bearing invariants #1): input = argv + env + prompt;
33
+ * output = text + session id. No direct network, no direct CAP calls.
34
+ *
35
+ * Test seam: `ctx._spawnImpl` is the sanctioned way for any adapter in this
36
+ * codebase to swap `child_process.spawn` out for a mock. Same convention as
37
+ * claude.js so future adapters stay uniform.
38
+ *
39
+ * Timeout caveat: SIGTERM at `timeoutMs`, then wait for `close`. If codex
40
+ * ignores SIGTERM the promise never resolves — same caveat as claude.js;
41
+ * SIGKILL escalation is a post-v1 concern (ADR-005 §Spawning semantics).
42
+ */
43
+
44
+ import { requireSeatModel, seatModelRequired } from './seat-model.js';
45
+ import { meetsMinimumVersion, probeVersion, publicCodexVersionError } from './tested-version.js';
46
+ import { spawn as childSpawn, spawnSync } from 'child_process';
47
+ import { createHash } from 'crypto';
48
+ import {
49
+ accessSync, constants, existsSync, readFileSync, readdirSync, realpathSync, statSync,
50
+ } from 'fs';
51
+ import {
52
+ chmod,
53
+ lstat,
54
+ mkdir,
55
+ mkdtemp,
56
+ readFile,
57
+ readlink,
58
+ realpath,
59
+ rename,
60
+ rm,
61
+ symlink,
62
+ unlink,
63
+ writeFile,
64
+ } from 'fs/promises';
65
+ import { homedir, tmpdir } from 'os';
66
+ import { createRequire } from 'module';
67
+ import {
68
+ basename,
69
+ delimiter,
70
+ dirname,
71
+ isAbsolute,
72
+ join,
73
+ resolve as pathResolve,
74
+ } from 'path';
75
+ import { CREDENTIAL_KEY, armCredentialRelease, writeCredentialFile } from '../credential-file.js';
76
+ import { MCP_READY_FILE_ENV, bridgeReadyFile, commonlyMcpReadyFile } from '../mcp-ready.js';
77
+ import { isOurGrantBroker } from '../grant-broker-guard.js';
78
+ import { BRIDGE_PATH } from './codex-broker-bridge.mjs';
79
+ import { brokerBridgeEntry } from './broker-bridge-entry.js';
80
+ import { supportedGitHooks } from '../commit-attribution.js';
81
+ import { WORKSPACE_GIT_SERVER_NAME, confinedWorkspaceGitServer, workspaceGitEnabled } from '../workspace-git.js';
82
+ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
83
+ import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js';
84
+ import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
85
+ import { resolveSkillSource, skillsAsText } from '../skill-prompt.js';
86
+ import { isLegacySandboxTrust, normalizeSandboxTrust, refuseWorkspaceTrust } from '../environment.js';
87
+ import { contains, realpathOfNearest, refuseWorkspaceHoldingTurnDirs } from '../sandbox/turn-dir.js';
88
+ import { refuseCodeRunningGitSetup } from '../sandbox/workspace-git-config.js';
89
+ import { codexTrustedPerimeter } from '../sandbox/codex-perimeter.js';
90
+ import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
91
+ import { publicSeatWithheldTools } from '../public-withheld-tools.js';
92
+ import { startProviderProxy } from '../provider-proxy.js';
93
+ import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
94
+ import { codexContextTokens, codexHomeFor, codexRolloutPath } from '../session-recycle.js';
95
+
96
+ // Default timeout for a single codex spawn (exec mode).
97
+ //
98
+ // 2026-05-20 smoke surface: Cody's cycle-0 research task ran 15+ web
99
+ // searches across openai/codex release notes (a legitimate research
100
+ // workload) and was SIGTERM'd at exactly 300_000ms — the adapter killed
101
+ // her before she could synthesize the final reply. 5 minutes is too
102
+ // tight for codex `exec` mode under real tool-use; the bigger model
103
+ // (gpt-5.4) + web.run + reasoning is just slower than a chat turn.
104
+ // Bumping to 15 minutes gives multi-step research / repo investigation
105
+ // room to finish without disabling the cap (pathological hangs still
106
+ // SIGTERM).
107
+ //
108
+ // Operators can override via the COMMONLY_AGENT_RUN_TIMEOUT_MS env var
109
+ // without rebuilding (caller-supplied `ctx.timeoutMs` still wins).
110
+ // Invalid / non-positive values fall through to the default rather
111
+ // than disabling the timeout entirely.
112
+ const DEFAULT_TIMEOUT_MS = (() => {
113
+ const fallback = 15 * 60 * 1000;
114
+ const raw = process.env.COMMONLY_AGENT_RUN_TIMEOUT_MS;
115
+ if (!raw) return fallback;
116
+ const parsed = Number(raw);
117
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
118
+ })();
119
+
120
+ const buildPrompt = buildMemoryPreamble;
121
+
122
+ // ── MCP wiring — codex consumes MCP servers via `-c mcp_servers.*` overrides ─
123
+ //
124
+ // Same substitution contract as claude.js (see that file for the placeholder
125
+ // rationale): ${COMMONLY_AGENT_TOKEN} / ${COMMONLY_API_URL} in the declared
126
+ // env spec are replaced with the wrapper's per-(agent, pod) runtime values at
127
+ // spawn time. Before this block the codex adapter silently ignored
128
+ // `environment.mcp`, so a codex agent had NO sanctioned posting tool — the
129
+ // 2026-07-22 as-operator attribution incident was a codex agent falling back
130
+ // to the operator's CLI profile because commonly_* tools were never wired.
131
+ //
132
+ // Codex-specific constraints:
133
+ // - stdio/command servers only (no url transport here); url-only entries
134
+ // are skipped rather than half-wired. The one exception is our grant
135
+ // broker, which runs behind a stdio bridge (bridgeGrantBroker, F175b).
136
+ // - Only entries that DECLARE stdio (or name no transport) are emitted, even
137
+ // when they also carry a `command`. `auditDeclaredMcp` classifies by
138
+ // `transport` and never judges the command of an entry that declared an
139
+ // http one, so emitting it here executes a command the guard did not
140
+ // approve — a case measured on 2026-09-18.
141
+ // - Token-bearing values ride through `env_vars`, never a `-c ...env=...`
142
+ // argv override. Command lines are visible to other same-user processes
143
+ // unless the OS sandbox blocks process inspection; keeping bearer tokens
144
+ // out of argv is an independent defense.
145
+ const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_TOKEN_FILE', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL'];
146
+ const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g;
147
+
148
+ const substitutePlaceholders = (value, ctx) => {
149
+ if (typeof value !== 'string') return value;
150
+ if (!value.includes('${COMMONLY_')) return value;
151
+ const subs = {
152
+ COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
153
+ // The path, not the token: a declared server that can open a file reads the
154
+ // credential from there, and the PATH is not a secret, so it may ride in the
155
+ // argv override below (TASK-083).
156
+ COMMONLY_TOKEN_FILE: ctx.credentialFile || '',
157
+ COMMONLY_API_URL: ctx.instanceUrl || '',
158
+ COMMONLY_INSTANCE_URL: ctx.instanceUrl || '',
159
+ };
160
+ return value.replace(PLACEHOLDER_RE, (whole, key) => (
161
+ SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole
162
+ ));
163
+ };
164
+
165
+ // JSON string escaping is a valid subset of TOML basic-string escaping, so
166
+ // JSON.stringify doubles as the TOML quoter for command/args/env values.
167
+ const toml = (s) => JSON.stringify(String(s));
168
+
169
+ const HTTP_TRANSPORTS = new Set(['http', 'streamable-http']);
170
+
171
+ // F175b: the grant broker is a Streamable HTTP server and codex runs stdio
172
+ // servers here, so the broker is wired as OUR stdio bridge
173
+ // (broker-bridge-entry.js). Whether this seat may hold the broker at all was
174
+ // decided before the adapter (grant-broker-guard.js).
175
+ const bridgeGrantBroker = (server, ctx) => {
176
+ const entry = brokerBridgeEntry(server, ctx);
177
+ if (entry.skip) {
178
+ // eslint-disable-next-line no-console
179
+ console.warn(`[codex] grant broker ${server.name}: ${entry.skip}, so it is not wired for this spawn.`);
180
+ return null;
181
+ }
182
+ // F460: the bridge reads the credential once and says so in its ready file.
183
+ const readyPath = bridgeReadyFile(server, ctx);
184
+ if (readyPath) entry.env = { ...entry.env, [MCP_READY_FILE_ENV]: readyPath };
185
+ return [
186
+ '-c', `mcp_servers.${server.name}.command=${toml(entry.command)}`,
187
+ // The broker parks every write for the owner's approval itself, so codex
188
+ // approving the call is the same boundary the stdio servers get below.
189
+ '-c', `mcp_servers.${server.name}.default_tools_approval_mode="approve"`,
190
+ '-c', `mcp_servers.${server.name}.args=[${entry.args.map(toml).join(', ')}]`,
191
+ '-c', `mcp_servers.${server.name}.env={${Object.entries(entry.env).map(([key, value]) => `${key} = ${toml(value)}`).join(', ')}}`,
192
+ ];
193
+ };
194
+
195
+ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
196
+ const flags = [];
197
+ const forwardedEnv = {};
198
+ const unavailableMcp = [];
199
+ const mcpReadDirectories = [];
200
+ for (const server of mcpServers || []) {
201
+ const transport = typeof server?.transport === 'string' ? server.transport.trim().toLowerCase() : 'stdio';
202
+ if (server?.name && HTTP_TRANSPORTS.has(transport)
203
+ && isOurGrantBroker(server, { instanceUrl: ctx.instanceUrl })) {
204
+ const bridged = bridgeGrantBroker(server, ctx);
205
+ if (bridged) {
206
+ flags.push(...bridged);
207
+ // A public seat's MCP children run under its permission profile; the
208
+ // bridge imports node builtins only, so the file is all it reads.
209
+ if (ctx.publicSeat) mcpReadDirectories.push(BRIDGE_PATH);
210
+ }
211
+ continue;
212
+ }
213
+ if (!server?.name || transport !== 'stdio'
214
+ || !Array.isArray(server.command) || !server.command.length) continue;
215
+ // TASK-174: the command that will EXECUTE — the warmed build from the seat's
216
+ // MCP home when there is one — and the credential channel is decided on that
217
+ // same command, so the two can never disagree about which release runs. The
218
+ // rewrite is before `substitutePlaceholders` because it introduces no
219
+ // placeholder: it is a path this host resolved.
220
+ const spawnCommand = prepareMcpSpawn(server.command, {
221
+ apiUrl: ctx.instanceUrl, publicSeat: ctx.publicSeat,
222
+ });
223
+ if (!spawnCommand) {
224
+ unavailableMcp.push(server.name);
225
+ continue;
226
+ }
227
+ // A declared credential is rewritten to the file channel before anything is
228
+ // substituted, so for our server the token is not token-bearing at all: it
229
+ // lands in `env={...}` as a path and never reaches `env_vars`, which is the
230
+ // one path by which the value ends up in codex's own environment and from
231
+ // there in every MCP child it spawns (TASK-083, measured on a live codex
232
+ // seat before the change).
233
+ let declaredEnv = deliverSeatCredential({ ...server, command: spawnCommand }, {
234
+ credentialFile: ctx.credentialFile,
235
+ label: 'codex',
236
+ }).env;
237
+ // Win32 only: codex spawns the server without a shell, so an `npx.cmd` is
238
+ // resolved to its target here (#2141). Identity on every other platform.
239
+ const [command, ...rest] = resolveMcpCommand(
240
+ spawnCommand.map((a) => substitutePlaceholders(a, ctx)),
241
+ ctx.commandResolution,
242
+ { label: 'codex', name: server.name },
243
+ );
244
+ // The doc block above promises bearer tokens never ride in argv, and this is
245
+ // the place that had to hold it: an env value that carries the token is
246
+ // diverted to env_vars, but a COMMAND ARGUMENT has no such route — codex
247
+ // substitutes it literally, and a command line is readable by every
248
+ // same-user process. So an entry that needs the token in its argv is
249
+ // refused whole (skipped, with the reason) rather than half-wired: emitting
250
+ // its other flags would leave a server the guard approved and the seat
251
+ // cannot use, and emitting this one would publish the secret. Measured
252
+ // before writing this: substitution did put `cm_agent_*` into
253
+ // `mcp_servers.<name>.args` on the -c command line (TASK-083).
254
+ const carriesTokenInArgv = (value) => !!ctx.runtimeToken
255
+ && typeof value === 'string'
256
+ && value.includes(ctx.runtimeToken);
257
+ if (carriesTokenInArgv(command) || rest.some(carriesTokenInArgv)) {
258
+ // eslint-disable-next-line no-console
259
+ console.warn(
260
+ `[codex] MCP server ${server.name} wants the seat credential as a command `
261
+ + 'argument, where codex substitutes a literal and every same-user process '
262
+ + 'can read it. Skipping that entry: declare the credential on the entry\'s '
263
+ + 'env, where it rides as a file path instead of a value.',
264
+ );
265
+ continue;
266
+ }
267
+ // F460: on a public seat our server names the file it writes once it has
268
+ // read the credential (a path, not a secret). An older warm build refuses,
269
+ // and so does another server handed the credential path. Named only for an
270
+ // entry that is wired, so a skipped one is never waited for.
271
+ const readyPath = commonlyMcpReadyFile(server, spawnCommand, ctx, { env: declaredEnv });
272
+ if (readyPath) declaredEnv = { ...declaredEnv, [MCP_READY_FILE_ENV]: readyPath };
273
+ flags.push('-c', `mcp_servers.${server.name}.command=${toml(command)}`);
274
+ const versionDir = ctx.publicSeat && selectedMcpVersionDir(spawnCommand);
275
+ if (versionDir) mcpReadDirectories.push(versionDir);
276
+ // The user opted into every server present in the environment spec.
277
+ // Public permission profiles + approval_policy=never otherwise auto-deny
278
+ // side-effecting MCP calls, silently removing the agent's Commonly tools.
279
+ // Mirror Claude's --allowedTools behavior: declared MCP servers are the
280
+ // capability boundary, and all of their tools are non-interactively
281
+ // approved inside that boundary.
282
+ flags.push(
283
+ '-c',
284
+ `mcp_servers.${server.name}.default_tools_approval_mode="approve"`,
285
+ );
286
+ // ...except the tools no public seat is offered (public-withheld-tools.js),
287
+ // self-install and the memory writes included (F118). This list is argv on
288
+ // every spawn, `exec resume` too, so a resumed thread gets this turn's list.
289
+ if (ctx.publicSeat) {
290
+ flags.push('-c', `mcp_servers.${server.name}.disabled_tools=[${publicSeatWithheldTools(ctx).map(toml).join(',')}]`);
291
+ }
292
+ if (rest.length) {
293
+ flags.push('-c', `mcp_servers.${server.name}.args=[${rest.map(toml).join(',')}]`);
294
+ }
295
+ const envEntries = [];
296
+ const envVars = [];
297
+ for (const [key, rawValue] of Object.entries(declaredEnv)) {
298
+ const value = substitutePlaceholders(rawValue, ctx);
299
+ const carriesRuntimeToken = !!ctx.runtimeToken
300
+ && typeof value === 'string'
301
+ && value.includes(ctx.runtimeToken);
302
+ if (carriesRuntimeToken) {
303
+ // Codex reads these as its own credential or model route, so a value
304
+ // forwarded under one of these names would be sent to the model endpoint.
305
+ // Compared in upper case on every platform: Windows env names are
306
+ // case-insensitive, and refusing a lower-case spelling elsewhere costs nothing.
307
+ if (CODEX_OWN_AUTH_ENVS.includes(key.toUpperCase())) {
308
+ throw new Error(
309
+ `codex MCP server ${server.name} sets ${key} to the seat token; codex reads ${key} `
310
+ + 'as its own credential, so declare the token under another name',
311
+ );
312
+ }
313
+ if (forwardedEnv[key] !== undefined && forwardedEnv[key] !== value) {
314
+ throw new Error(
315
+ `codex MCP servers declare conflicting token-bearing values for env var ${key}`,
316
+ );
317
+ }
318
+ forwardedEnv[key] = value;
319
+ envVars.push(key);
320
+ } else {
321
+ envEntries.push(`${key} = ${toml(value)}`);
322
+ }
323
+ }
324
+ if (envEntries.length) {
325
+ flags.push('-c', `mcp_servers.${server.name}.env={${envEntries.join(', ')}}`);
326
+ }
327
+ if (envVars.length) {
328
+ flags.push('-c', `mcp_servers.${server.name}.env_vars=[${envVars.map(toml).join(',')}]`);
329
+ }
330
+ }
331
+ if (Object.keys(forwardedEnv).length > 0) {
332
+ // The value is forwarded only for a declaration that needs the token as a
333
+ // LITERAL somewhere a file path is not a value — command args, or a string
334
+ // that merely contains it. That is the measured carve-out (TASK-082), and it
335
+ // is not silent: this is the seat whose environment still carries a secret.
336
+ // eslint-disable-next-line no-console
337
+ console.warn(
338
+ `[codex] this declaration needs ${CREDENTIAL_KEY} as a literal (command args, or a `
339
+ + 'value that contains it), so the token is forwarded to codex for this spawn '
340
+ + "and every MCP child inherits it. Put the credential on the entry's env to "
341
+ + 'get the file channel.',
342
+ );
343
+ }
344
+ return { flags, forwardedEnv, unavailableMcp, mcpReadDirectories };
345
+ };
346
+
347
+ const PUBLIC_PERMISSION_PROFILE = 'commonly_public';
348
+ const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
349
+ // F387: a private seat outside a cloud-codex pod. Not a sandbox for untrusted
350
+ // input: public seats keep commonly_public.
351
+ const PRIVATE_PERMISSION_PROFILE = 'commonly_private';
352
+
353
+ const statOrNull = async (path) => {
354
+ try {
355
+ return await lstat(path);
356
+ } catch (err) {
357
+ if (err?.code === 'ENOENT') return null;
358
+ throw err;
359
+ }
360
+ };
361
+
362
+ // Codex reads $CODEX_HOME/AGENTS.md before model-generated commands enter the
363
+ // OS sandbox. Pointing a public run at the operator's normal ~/.codex would
364
+ // therefore expose global private instructions even though shell reads of
365
+ // ~/.codex are denied. Give every public wrapper identity its own persistent
366
+ // Codex home for sessions/state, with only an auth symlink back to the
367
+ // operator credential. On macOS the symlink lives under ~/.commonly, which the
368
+ // permission profile denies to model-generated commands; on Linux (below) its
369
+ // target, ~/.codex, is denied instead.
370
+ //
371
+ // On Linux the home cannot sit under ~/.commonly. Codex (0.164 and later)
372
+ // runs its file helper by exec'ing `$CODEX_HOME/tmp/arg0-linux/.../
373
+ // codex-linux-sandbox` inside bwrap, and grants read on that one file, but a
374
+ // denied ancestor always wins, so the profile's ~/.commonly deny made every
375
+ // public session fail at bootstrap with "bwrap: execvp ... Permission denied".
376
+ // Outside every granted root the home is still unreadable to model commands:
377
+ // Codex binds the helper file alone, not the home around it (measured on
378
+ // codex-cli 0.164.0, where a home there reached the model call). That holds
379
+ // only while the workspace does not contain the home, so such a workspace is
380
+ // refused. macOS has no arg0 helper and keeps its homes where they were.
381
+ const LEGACY_CODEX_HOMES = (home) => join(home, '.commonly', 'codex-homes');
382
+
383
+ const stateHomeUnderCommonly = (ctx, home, seat = 'public') => Object.assign(new Error(
384
+ `XDG_STATE_HOME (${ctx.env?.XDG_STATE_HOME}) puts the ${seat} Codex home under ${join(home, '.commonly')}, `
385
+ + `which the ${seat} sandbox denies on Linux. Point it elsewhere or unset it.`,
386
+ ), { code: 'state_home_under_commonly' });
387
+
388
+ const publicCodexHomesRoot = (ctx) => {
389
+ const home = ctx._homeDir || homedir();
390
+ if ((ctx._platform || process.platform) !== 'linux') return LEGACY_CODEX_HOMES(home);
391
+ const stateHome = ctx.env?.XDG_STATE_HOME;
392
+ const root = join(stateHome && isAbsolute(stateHome) ? stateHome : join(home, '.local', 'state'),
393
+ 'commonly', 'codex-homes');
394
+ // An override that lands back under the denied tree would bring the
395
+ // bootstrap failure above back with it. Checked here by name, and again
396
+ // through symlinks before anything is created (preparePublicCodexHome).
397
+ if (contains(pathResolve(home, '.commonly'), pathResolve(root))) throw stateHomeUnderCommonly(ctx, home);
398
+ return root;
399
+ };
400
+
401
+ // An XDG_STATE_HOME under ~/.commonly throws above, and that tree is denied anyway.
402
+ const privatePublicHomesDeny = (ctx) => {
403
+ if ((ctx._platform || process.platform) !== 'linux') return [];
404
+ try {
405
+ return [publicCodexHomesRoot(ctx)];
406
+ } catch {
407
+ return [];
408
+ }
409
+ };
410
+
411
+ const operatorCodexHome = (ctx) => ctx.env?.CODEX_HOME
412
+ || process.env.CODEX_HOME
413
+ || join(homedir(), '.codex');
414
+
415
+ // F445: on Linux a private seat runs with a Codex home of its own, beside the
416
+ // public ones, so the folder its shell can see holds no other session's logs,
417
+ // databases or history: F444 denied a shared home entry by entry, from a
418
+ // listing taken when the turn's keys are built, and anything another session
419
+ // wrote there under a new name, or during a command, stayed readable. The home
420
+ // starts with no history: nothing is copied from the shared one, which holds
421
+ // other sessions' history, the thing being fenced off. What the agent is meant
422
+ // to remember lives in Commonly. Only the person's own setup is carried over,
423
+ // as symlinks Codex follows outside the sandbox: their login, config.toml (a
424
+ // private seat keeps its own config) and global AGENTS.md. macOS denies the
425
+ // shared home whole under Seatbelt and keeps running from it.
426
+ const PRIVATE_CODEX_HOMES = 'codex-private-homes';
427
+ const PRIVATE_HOME_LINKS = Object.freeze(['auth.json', 'config.toml', 'AGENTS.md']);
428
+
429
+ /** The shared Codex home a private seat's setup comes from. */
430
+ const privateSourceCodexHome = (ctx, env) => pathResolve(
431
+ ctx.cwd || process.cwd(),
432
+ env?.CODEX_HOME || join(env?.HOME || homedir(), '.codex'),
433
+ );
434
+
435
+ /**
436
+ * Who a seat's own Codex home belongs to (F450): the seat's local key, as the
437
+ * run loop names its sessions and state (F306). A second instance's is
438
+ * `name@instance`, so it gets a home of its own; every other seat's is its
439
+ * bare name, so every home made before this keeps its seat, including a seat
440
+ * saved under its name that runs a non-default instance.
441
+ */
442
+ export const codexHomeIdentity = (ctx) => ctx.seatKey || ctx.agentName || ctx.cwd || 'anonymous';
443
+
444
+ /** Where this private seat's own Codex home is, or null where it has none. */
445
+ export const privateCodexHomePath = (ctx) => {
446
+ if (ctx._privateCodexHome) return ctx._privateCodexHome;
447
+ const identity = codexHomeIdentity(ctx);
448
+ const hash = createHash('sha256').update(`private:${identity}`).digest('hex').slice(0, 20);
449
+ let publicRoot;
450
+ try {
451
+ publicRoot = publicCodexHomesRoot(ctx);
452
+ } catch (error) {
453
+ if (error?.code !== 'state_home_under_commonly') throw error;
454
+ throw stateHomeUnderCommonly(ctx, ctx._homeDir || homedir(), 'private');
455
+ }
456
+ return join(dirname(publicRoot), PRIVATE_CODEX_HOMES, hash);
457
+ };
458
+
459
+ // F450: a login Codex keeps in the keyring is filed under the Codex home's own
460
+ // path and leaves no auth.json, so a seat in a home of its own can't reach it.
461
+ // Said once per process per home, at the first turn.
462
+ const keyringLoginNoted = new Set();
463
+ const KEYRING_STORE = /^\s*cli_auth_credentials_store\s*=\s*["'](keyring|auto)["']/m;
464
+ // Only the keys above the first table header are the top level.
465
+ const topLevelToml = (text) => text.split(/^\s*\[/m)[0];
466
+ const noteKeyringLogin = async (source, home) => {
467
+ if (keyringLoginNoted.has(home)) return;
468
+ keyringLoginNoted.add(home);
469
+ if (await statOrNull(join(source, 'auth.json'))) return;
470
+ const config = await readFile(join(source, 'config.toml'), 'utf8').catch(() => '');
471
+ const store = topLevelToml(config).match(KEYRING_STORE)?.[1];
472
+ if (!store) return;
473
+ process.stderr.write(`[codex] this agent runs from a Codex home of its own (${home}). Your config.toml `
474
+ + `sets cli_auth_credentials_store = "${store}" and there is no auth.json to link, so a login kept in `
475
+ + 'the keyring can\'t reach it. '
476
+ + 'To fix it, set cli_auth_credentials_store = "file" in your config.toml and run `codex login`.\n');
477
+ };
478
+
479
+ const preparePrivateCodexHome = async (ctx, childEnv) => {
480
+ const source = privateSourceCodexHome(ctx, childEnv);
481
+ const home = privateCodexHomePath(ctx);
482
+ if (pathResolve(home) === source) throw new Error('a private Codex home must be apart from the shared CODEX_HOME');
483
+ const workspacePath = pathResolve(ctx.cwd || process.cwd());
484
+ const userHome = ctx._homeDir || homedir();
485
+ const [workspace, realHome, realSource, realCommonly] = await Promise.all([
486
+ // As in realpathOfNearest: only a missing workspace falls back to its own path.
487
+ realpath(workspacePath).catch((error) => {
488
+ if (error?.code === 'ENOENT') return workspacePath;
489
+ throw error;
490
+ }),
491
+ realpathOfNearest(home),
492
+ realpathOfNearest(source),
493
+ realpathOfNearest(join(userHome, '.commonly')),
494
+ ]);
495
+ if (contains(workspace, realHome)) {
496
+ throw new Error(`private codex agents cannot run in ${ctx.cwd}: it contains the agent's own Codex home `
497
+ + `(${home}), which the sandbox would then expose. Run it in a project folder.`);
498
+ }
499
+ // Both are denied whole, and a home inside either would hide Codex's own
500
+ // sandbox helper, so every turn would stop at start.
501
+ if (contains(realSource, realHome) || contains(realCommonly, realHome)) {
502
+ throw new Error(`a private Codex agent's own home (${home}) resolves inside `
503
+ + `${contains(realSource, realHome) ? `the shared Codex home (${source})` : join(userHome, '.commonly')}, `
504
+ + 'which its sandbox denies on Linux. Point XDG_STATE_HOME elsewhere or unset it.');
505
+ }
506
+ await mkdir(home, { recursive: true, mode: 0o700 });
507
+ await chmod(home, 0o700);
508
+ for (const name of PRIVATE_HOME_LINKS) {
509
+ const from = join(source, name);
510
+ const to = join(home, name);
511
+ const [fromStat, toStat] = await Promise.all([statOrNull(from), statOrNull(to)]);
512
+ // A file the seat's Codex wrote in its own home is its own: kept.
513
+ if (toStat && !toStat.isSymbolicLink()) continue;
514
+ if (!fromStat) {
515
+ // A link to a login the person removed would sign in through it anyway.
516
+ if (toStat) await unlink(to);
517
+ continue;
518
+ }
519
+ const current = toStat ? pathResolve(dirname(to), await readlink(to)) : null;
520
+ if (current !== from) {
521
+ if (toStat) await unlink(to);
522
+ // Another turn of the same seat may have just made it.
523
+ await symlink(from, to).catch((error) => { if (error?.code !== 'EEXIST') throw error; });
524
+ }
525
+ }
526
+ await noteKeyringLogin(source, home);
527
+ await mkdir(join(home, 'tmp', 'arg0-linux'), { recursive: true, mode: 0o700 });
528
+ // Every other private seat's home, as it stands now. One created during this
529
+ // turn is denied from the next turn on: until then, what that seat's first
530
+ // turn writes there stays readable to this turn's commands.
531
+ const others = entriesOf(dirname(home)).filter((name) => name !== basename(home)).sort()
532
+ .map((name) => join(dirname(home), name));
533
+ // The links into the shared home, which is denied whole with their targets.
534
+ const links = [];
535
+ for (const name of PRIVATE_HOME_LINKS) {
536
+ const stat = await statOrNull(join(home, name));
537
+ if (stat?.isSymbolicLink() && pathResolve(home, await readlink(join(home, name))) === join(source, name)) links.push(name);
538
+ }
539
+ return { home, source, realSource, others, links };
540
+ };
541
+
542
+ // `login: false` is the proxy route (D250): codex authenticates with the
543
+ // per-turn token, so the public home gets no link to the person's Codex login,
544
+ // and a link an earlier turn left is removed.
545
+ const preparePublicCodexHome = async (ctx, childEnv, { login = true } = {}) => {
546
+ const operatorHome = operatorCodexHome(ctx);
547
+ const identity = codexHomeIdentity(ctx);
548
+ const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
549
+ const homesRoot = publicCodexHomesRoot(ctx);
550
+ const publicHome = ctx._publicCodexHome || join(homesRoot, identityHash);
551
+ if (pathResolve(publicHome) === pathResolve(operatorHome)) {
552
+ throw new Error('public codex home must be isolated from the operator CODEX_HOME');
553
+ }
554
+
555
+ // Checked before anything is created or moved, so a refused workspace
556
+ // never receives the home or a migrated seat's sessions.
557
+ if (!ctx._publicCodexHome && homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) {
558
+ const home = ctx._homeDir || homedir();
559
+ const [realRoot, realCommonly] = await Promise.all([realpathOfNearest(homesRoot), realpathOfNearest(join(home, '.commonly'))]);
560
+ if (contains(realCommonly, realRoot)) throw stateHomeUnderCommonly(ctx, home);
561
+ const workspacePath = pathResolve(ctx.cwd || process.cwd());
562
+ const [workspace, realHome] = await Promise.all([
563
+ // As in realpathOfNearest: only a missing workspace falls back to its own path.
564
+ realpath(workspacePath).catch((err) => {
565
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return workspacePath;
566
+ throw err;
567
+ }),
568
+ realpathOfNearest(publicHome),
569
+ ]);
570
+ if (contains(workspace, realHome)) {
571
+ throw new Error(`public codex agents cannot run in ${ctx.cwd}: it contains the agent's own `
572
+ + `Codex home (${publicHome}), which the sandbox would then expose. Run it in a project folder.`);
573
+ }
574
+ }
575
+
576
+ // A Linux seat that ran before the move keeps its sessions, so a resumed
577
+ // thread still resumes. Across filesystems it starts fresh instead, and
578
+ // says so: the old sessions stay where they were, untouched, and a copy
579
+ // that failed halfway would leave a home later runs never migrate again.
580
+ const legacyHome = join(LEGACY_CODEX_HOMES(ctx._homeDir || homedir()), identityHash);
581
+ if (!ctx._publicCodexHome && legacyHome !== publicHome && !(await statOrNull(publicHome))) {
582
+ const legacy = await statOrNull(legacyHome);
583
+ if (legacy?.isDirectory()) {
584
+ await mkdir(homesRoot, { recursive: true, mode: 0o700 });
585
+ try {
586
+ await rename(legacyHome, publicHome);
587
+ } catch (err) {
588
+ if (err?.code !== 'EXDEV') throw err;
589
+ console.warn(
590
+ `[codex] ${legacyHome} is on another filesystem from ${publicHome}; starting a fresh Codex home. `
591
+ + 'Earlier sessions stay in the old home and will not resume; move it there by hand to keep them.',
592
+ );
593
+ }
594
+ }
595
+ }
596
+
597
+ await mkdir(publicHome, { recursive: true, mode: 0o700 });
598
+ await chmod(publicHome, 0o700);
599
+
600
+ const sourceAuth = join(operatorHome, 'auth.json');
601
+ const targetAuth = join(publicHome, 'auth.json');
602
+ const sourceStat = await statOrNull(sourceAuth);
603
+ const targetStat = await statOrNull(targetAuth);
604
+ if (targetStat && !targetStat.isSymbolicLink()) {
605
+ throw new Error(`refusing to replace non-symlink public Codex credential: ${targetAuth}`);
606
+ }
607
+ if (!login) {
608
+ if (targetStat) await unlink(targetAuth);
609
+ } else if (sourceStat) {
610
+ const currentTarget = targetStat
611
+ ? pathResolve(dirname(targetAuth), await readlink(targetAuth))
612
+ : null;
613
+ if (currentTarget !== pathResolve(sourceAuth)) {
614
+ if (targetStat) await unlink(targetAuth);
615
+ await symlink(sourceAuth, targetAuth);
616
+ }
617
+ } else if (targetStat) {
618
+ // A stale symlink could unexpectedly authenticate through an old home
619
+ // after the operator intentionally logged out. Remove it and let Codex
620
+ // fail closed with its normal "not logged in" error.
621
+ await unlink(targetAuth);
622
+ }
623
+ // Model commands are sandboxed through the arg0 helper in this folder, a
624
+ // symlink to Codex's own binary, and unlike the file helper that path grants
625
+ // itself neither the folder nor the binary. Without both, every command
626
+ // fails with "bwrap: execvp ... No such file or directory" (measured on
627
+ // codex-cli 0.164.0 with a user-prefix npm install). Only where the home
628
+ // sits outside every deny can the folder's grant take effect.
629
+ const helperReadPaths = [];
630
+ if (homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) {
631
+ const helperDirectory = join(publicHome, 'tmp', 'arg0-linux');
632
+ await mkdir(helperDirectory, { recursive: true, mode: 0o700 });
633
+ helperReadPaths.push(helperDirectory);
634
+ // Looked up on the PATH the child gets, so the grant names the binary it runs.
635
+ const binary = (ctx._codexNativeBinary || codexNativeBinary)(childEnv);
636
+ if (binary) helperReadPaths.push(binary);
637
+ }
638
+ return { home: publicHome, helperReadPaths };
639
+ };
640
+
641
+ // The native binary `codex` on PATH runs: itself when it is one, or the one
642
+ // the npm launcher (`bin/codex.js`) picks, resolved the way that launcher does.
643
+ // Only the Linux targets are named because only the Linux helper needs this.
644
+ const LINUX_CODEX_TARGETS = { x64: 'x86_64-unknown-linux-musl', arm64: 'aarch64-unknown-linux-musl' };
645
+
646
+ export const codexNativeBinary = (env) => {
647
+ const onPath = String(env?.PATH || '').split(delimiter).filter(Boolean)
648
+ .map((dir) => join(dir, 'codex'))
649
+ .find((candidate) => {
650
+ try {
651
+ accessSync(candidate, constants.X_OK);
652
+ return true;
653
+ } catch {
654
+ return false;
655
+ }
656
+ });
657
+ if (!onPath) return null;
658
+ const real = realpathSync(onPath);
659
+ if (basename(real) !== 'codex.js') return real;
660
+ const target = LINUX_CODEX_TARGETS[process.arch];
661
+ if (!target) return null;
662
+ const packageRoot = dirname(dirname(real));
663
+ let vendorRoot = join(packageRoot, 'vendor');
664
+ try {
665
+ const platformPackage = createRequire(real).resolve(`@openai/codex-linux-${process.arch}/package.json`);
666
+ vendorRoot = join(dirname(platformPackage), 'vendor');
667
+ } catch { /* the launcher falls back to its own vendor folder too */ }
668
+ const binary = join(vendorRoot, target, 'bin', 'codex');
669
+ return existsSync(binary) ? realpathSync(binary) : null;
670
+ };
671
+
672
+ // The macOS standalone installer puts Codex below ~/.codex, which a public
673
+ // seat denies. Codex re-executes itself for its filesystem helper, before the
674
+ // first turn: a read exception cannot override Seatbelt's deny. Copy only the
675
+ // installed executable outside the secret tree; keep the same sandbox policy.
676
+ export const stagePublicMacCodex = async (ctx, childEnv) => {
677
+ if ((ctx._platform || process.platform) !== 'darwin') return null;
678
+ const source = (ctx._codexNativeBinary || codexNativeBinary)(childEnv);
679
+ if (!source) return null;
680
+ const home = ctx._homeDir || homedir();
681
+ const namedSecretRoot = join(home, '.codex');
682
+ if (!contains(namedSecretRoot, pathResolve(source))) return null;
683
+ const secretRoot = await realpathOfNearest(namedSecretRoot);
684
+ const binary = await realpath(source);
685
+ if (!contains(secretRoot, binary)) return null;
686
+
687
+ const root = join(home, '.local', 'share', 'space-runtime', 'codex');
688
+ const [realRoot, realCommonly, workspace] = await Promise.all([
689
+ realpathOfNearest(root),
690
+ realpathOfNearest(join(home, '.commonly')),
691
+ realpathOfNearest(ctx.cwd || process.cwd()),
692
+ ]);
693
+ if (contains(secretRoot, realRoot) || contains(realCommonly, realRoot)
694
+ || contains(workspace, realRoot)) {
695
+ throw new Error('The Space Codex executable cache must be outside the agent workspace and secret directories. Run this public agent in a project folder.');
696
+ }
697
+ const bytes = await readFile(binary);
698
+ const digest = createHash('sha256').update(bytes).digest('hex');
699
+ const directory = join(root, digest);
700
+ await mkdir(directory, { recursive: true, mode: 0o700 });
701
+ const target = join(directory, 'codex');
702
+ const present = await statOrNull(target);
703
+ if (present && !present.isFile()) throw new Error(`Refusing non-file Codex executable cache: ${target}`);
704
+ if (!present) {
705
+ const temp = await mkdtemp(join(directory, '.stage-'));
706
+ try {
707
+ const staged = join(temp, 'codex');
708
+ await writeFile(staged, bytes, { mode: 0o500 });
709
+ await rename(staged, target);
710
+ } finally {
711
+ await rm(temp, { recursive: true, force: true });
712
+ }
713
+ }
714
+ return target;
715
+ };
716
+
717
+ // A public seat ran with none of its declared skills: this adapter never read
718
+ // `environment.skills`, so every outsider's Codex worked without the house
719
+ // rules buildDefaultEnvironment declares for it (found 2026-10-20 beside the
720
+ // Claude half of the same gap, #2354). `codex exec` has no skill loader a
721
+ // public seat can use, but it reads $CODEX_HOME/AGENTS.md as the user's own
722
+ // instructions, and the public home is one this CLI owns. Measured on
723
+ // codex-cli 0.162.1 with this adapter's public flags (`--ignore-user-config`
724
+ // skips config.toml only): a marker in that file came back verbatim on a new
725
+ // thread, and a changed marker came back on `exec resume` of the same thread,
726
+ // so a seat picks the skills up on its next turn either way.
727
+ //
728
+ // `skills.claude` is ADR-008's SKILL.md list ("drivers copy or symlink as
729
+ // appropriate"); Codex reads the same format, so no new key. Rewritten each
730
+ // spawn (staged, then renamed, so a concurrent read never sees half a file),
731
+ // and removed when nothing is declared so a dropped skill stops applying.
732
+ // The cap is our own, set at Codex's `project_doc_max_bytes` (32768 in
733
+ // codex-cli 0.162.1's shipped default config, read from the binary 2026-10-21).
734
+ // It is not a limit Codex applies to this file. At rust-v0.162.1 the global
735
+ // `$CODEX_HOME/AGENTS.md` is read whole and trimmed
736
+ // (codex-home/src/instructions/mod.rs) and passed in as user instructions,
737
+ // outside the project-doc budget, which counts only the project AGENTS.md files
738
+ // discovered from the working directory (core/src/agents_md.rs,
739
+ // load_project_instructions). So the skills never share a budget with a
740
+ // workspace's own AGENTS.md, and nothing past the cap would be cut. The cap is
741
+ // what keeps the injected text to the size Codex allows a project doc. It
742
+ // bounds the file as written, final newline included, and a skill that would
743
+ // cross it is left out whole.
744
+ const PUBLIC_CODEX_INSTRUCTIONS_MAX_BYTES = 32 * 1024;
745
+
746
+ const writePublicCodexInstructions = async (publicHome, ctx) => {
747
+ // Codex loads this file before AGENTS.md when it exists. Clear any leftover
748
+ // override on every spawn so it cannot shadow the seat's declared skills.
749
+ await rm(join(publicHome, 'AGENTS.override.md'), { force: true });
750
+ const declared = Array.isArray(ctx.environment?.skills?.claude) ? ctx.environment.skills.claude : [];
751
+ const baseDir = ctx.cwd || process.cwd();
752
+ const sources = declared.map((source) => resolveSkillSource(source, baseDir));
753
+ const included = [];
754
+ const text = await skillsAsText(
755
+ sources,
756
+ {
757
+ maxBytes: PUBLIC_CODEX_INSTRUCTIONS_MAX_BYTES,
758
+ label: 'codex',
759
+ trailer: '\n',
760
+ onIncluded: (source) => included.push(source),
761
+ },
762
+ );
763
+ const target = join(publicHome, 'AGENTS.md');
764
+ if (!text) {
765
+ await rm(target, { force: true });
766
+ ctx.onSkillsLinked?.({ mounted: [], conflicted: sources.map((path) => ({ path, reason: 'not-inlined' })) });
767
+ return;
768
+ }
769
+ const staged = `${target}.${process.pid}.tmp`;
770
+ await writeFile(staged, text, { encoding: 'utf8', mode: 0o600 });
771
+ await rename(staged, target);
772
+ ctx.onSkillsLinked?.({
773
+ mounted: included,
774
+ conflicted: sources.filter((source) => !included.includes(source)).map((path) => ({ path, reason: 'not-inlined' })),
775
+ });
776
+ };
777
+
778
+ // A public Codex seat runs with Commonly's tools, the shell, file edits and
779
+ // view_image. Those three read and write through the permission profile, as
780
+ // measured on each version named below; these keys turn off Codex's other
781
+ // built-ins, which claude.js does with its deny list.
782
+ //
783
+ // - `web_search` is the top-level mode, and "disabled" also drops `web__run`
784
+ // from the `exec` tool of code-mode models.
785
+ // - `features.multi_agent` removes the agent tools from a model on the v1 agent
786
+ // tools, but a thread started without it keeps them when resumed, so
787
+ // `agents.max_depth=0` covers that thread. A model whose catalog entry says
788
+ // `multi_agent_version: "v2"` keeps `collaboration.*` in its tool list whatever
789
+ // these say, so the v2 cap is what stops it: at 1 the root thread fills it, and
790
+ // spawn_agent returns "agent thread limit reached". `agents.max_threads` doesn't
791
+ // do this, and only `model_catalog_json` takes the names out, which replaces
792
+ // the whole catalog.
793
+ // - `features.goals` removes the goal tools. A goal is kept on the thread and
794
+ // comes back in the next turn's context.
795
+ // - `features.plugins`, `features.apps` and `analytics.enabled` turn off plugins,
796
+ // account apps and usage analytics, which a public seat has no use for.
797
+ //
798
+ // Measured against the tool list sent to the model, on codex-cli 0.162.1 with
799
+ // gpt-5.5, gpt-5.6 and gpt-6 models and on 0.159.3 with gpt-5.5 and gpt-6 models
800
+ // (adapters.codex.public-tools.test.mjs).
801
+ const PUBLIC_BUILTIN_TOOL_FLAGS = Object.freeze([
802
+ '-c', 'web_search="disabled"',
803
+ '-c', 'features.multi_agent=false',
804
+ '-c', 'features.multi_agent_v2.max_concurrent_threads_per_session=1',
805
+ '-c', 'features.goals=false',
806
+ '-c', 'features.plugins=false',
807
+ '-c', 'features.apps=false',
808
+ '-c', 'analytics.enabled=false',
809
+ '-c', 'agents.max_depth=0',
810
+ ]);
811
+
812
+ // A public seat needs a tested Codex version: below this one it runs no turn.
813
+ // Tested on macOS with the real binary, through this adapter's public flags: the
814
+ // permission profile (shell and view_image, read and write, network off) and the
815
+ // tool list, on new and resumed threads, on 0.159.3 and 0.162.1. An older version
816
+ // isn't tested to this bar. A later one runs as it is, so the BYO workflows'
817
+ // 0.164.0 is allowed, though these checks have not been run on it, nor on Linux
818
+ // on any version.
819
+ export const PUBLIC_CODEX_MIN_VERSION = '0.159.3';
820
+
821
+ // The exact line `codex --version` printed on every version tested. Anything else
822
+ // counts as unread: a looser match could take a number from another line.
823
+ const parseCodexVersion = (stdout) => (
824
+ /^codex-cli (\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s*$/m.exec(String(stdout))?.[1] || null
825
+ );
826
+
827
+ // The commit-attribution hooks a profile has to let the shell read: the hooks
828
+ // folder Commonly installs, and each executable hook the repository already had.
829
+ const commitHookReadPaths = (commitAttributionEnv) => {
830
+ const commitHooksPath = commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
831
+ const originalHooksPath = commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
832
+ const originalHookPaths = originalHooksPath
833
+ ? supportedGitHooks()
834
+ .map((hookName) => join(originalHooksPath, hookName))
835
+ .filter((hookPath) => {
836
+ try {
837
+ accessSync(hookPath, constants.X_OK);
838
+ return true;
839
+ } catch {
840
+ return false;
841
+ }
842
+ })
843
+ : [];
844
+ return { commitHooksPath, originalHookPaths };
845
+ };
846
+
847
+ // Codex's default name filter treats GIT_CONFIG_KEY_n as sensitive, so the
848
+ // attribution keys are set again after filtering. Their values stay in the
849
+ // environment instead of riding argv.
850
+ const gitConfigKeyShellSet = (commitAttributionEnv) => Object.entries(commitAttributionEnv || {})
851
+ .filter(([name]) => /^GIT_CONFIG_KEY_\d+$/.test(name))
852
+ .map(([name, value]) => `${toml(name)}=${toml(value)}`);
853
+
854
+ const publicPermissionProfileFlags = (
855
+ mode,
856
+ commitAttributionEnv = null,
857
+ mcpReadDirectories = [],
858
+ codexHelperReadPaths = [],
859
+ platform = process.platform,
860
+ workspacePath = null,
861
+ ) => {
862
+ if (!PUBLIC_SANDBOX_MODES.has(mode)) {
863
+ throw new Error(
864
+ `public codex agents require sandbox.mode=workspace or read-only, got ${mode || 'unset'}`,
865
+ );
866
+ }
867
+ const workspaceAccess = mode === 'read-only' ? 'read' : 'write';
868
+ const { commitHooksPath, originalHookPaths } = commitHookReadPaths(commitAttributionEnv);
869
+ // Preserve caller-supplied git config entries alongside the appended
870
+ // hooksPath entry. The explicit allowlist below admits only these concrete
871
+ // variable names, not arbitrary environment variables.
872
+ const gitConfigEnv = Object.keys(commitAttributionEnv || {})
873
+ .filter((name) => /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(name));
874
+ const gitConfigKeySet = gitConfigKeyShellSet(commitAttributionEnv);
875
+ // The profile denies $TMPDIR and /tmp, so a shell heredoc fails. Set for the
876
+ // model's shell alone: codex, its MCP child and the per-turn folder keep the
877
+ // host's temp root, which spawn refuses to find in the workspace. The
878
+ // workspace is the one folder this shell already writes. zsh, the macOS
879
+ // login shell codex runs, takes its heredoc file from TMPPREFIX and ignores
880
+ // TMPDIR. A read-only seat has no such folder, so its heredocs keep failing.
881
+ const shellTmpDir = workspaceAccess === 'write' && workspacePath
882
+ ? [`${toml('TMPDIR')}=${toml(workspacePath)}`, `${toml('TMPPREFIX')}=${toml(join(workspacePath, 'zsh'))}`]
883
+ : [];
884
+ const shellSet = [...gitConfigKeySet, ...shellTmpDir];
885
+ const filesystem = [
886
+ '":minimal"="read"',
887
+ '"~/.commonly"="deny"',
888
+ '"~/.claude"="deny"',
889
+ '"~/.codex"="deny"',
890
+ '"~/.ssh"="deny"',
891
+ '"~/.aws"="deny"',
892
+ '"~/.config"="deny"',
893
+ // macOS's temp root, where other sessions' temp dirs live. Codex reads
894
+ // every key as a path for its own platform, and on Windows this one has
895
+ // no drive, so Codex refused the whole profile and no turn ever ran
896
+ // (byo-windows run 41996139905). Linux accepts it as a plain path.
897
+ ...(platform === 'win32' ? [] : ['"/private/tmp"="deny"']),
898
+ `":workspace_roots"={"."="${workspaceAccess}",".commonly/**"="deny",".codex/**"="deny","*.env"="deny","*/*.env"="deny","*/*/*.env"="deny","**/.npmrc"="deny","**/.yarnrc*"="deny"}`,
899
+ ...(commitHooksPath ? [`${toml(commitHooksPath)}="read"`] : []),
900
+ ...originalHookPaths.map((hookPath) => `${toml(hookPath)}="read"`),
901
+ ...mcpReadDirectories.map((path) => `${toml(path)}="read"`),
902
+ ...codexHelperReadPaths.map((path) => `${toml(path)}="read"`),
903
+ ].join(',');
904
+ const shellEnvironment = [
905
+ 'PATH', 'HOME', 'TMPDIR', 'TMPPREFIX', 'LANG', 'LC_*',
906
+ ...(commitHooksPath ? [
907
+ ...gitConfigEnv,
908
+ 'COMMONLY_AGENT_GIT_CONFIG_INDEX',
909
+ 'COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT',
910
+ 'COMMONLY_AGENT_HOOKS_PATH',
911
+ 'COMMONLY_AGENT_ORIGINAL_HOOKS_PATH',
912
+ 'COMMONLY_AGENT_SEAT_NAME',
913
+ 'COMMONLY_AGENT_SEAT_ID',
914
+ 'COMMONLY_AGENT_ADAPTER',
915
+ 'COMMONLY_AGENT_MODEL',
916
+ 'COMMONLY_AGENT_EFFORT',
917
+ ] : []),
918
+ ];
919
+ return [
920
+ '-c', `default_permissions=${toml(PUBLIC_PERMISSION_PROFILE)}`,
921
+ '-c', `permissions.${PUBLIC_PERMISSION_PROFILE}.filesystem={${filesystem}}`,
922
+ '-c', `permissions.${PUBLIC_PERMISSION_PROFILE}.network.enabled=false`,
923
+ // The MCP launcher receives explicitly forwarded env_vars separately.
924
+ // `core` drops explicit GIT_CONFIG_* and hook metadata before include_only
925
+ // can admit them. When attribution is active, inherit all then constrain
926
+ // model shells to the exact allowlist below; keep Codex's default secret
927
+ // filters enabled.
928
+ '-c', `shell_environment_policy.inherit="${commitHooksPath ? 'all' : 'core'}"`,
929
+ '-c', 'shell_environment_policy.ignore_default_excludes=false',
930
+ '-c', `shell_environment_policy.include_only=${JSON.stringify(shellEnvironment)}`,
931
+ // Codex's default name filter treats GIT_CONFIG_KEY_n as sensitive. Set
932
+ // only those key names after filtering; their corresponding values stay
933
+ // in the allowlisted environment instead of riding argv.
934
+ ...(shellSet.length ? ['-c', `shell_environment_policy.set={${shellSet.join(',')}}`] : []),
935
+ ];
936
+ };
937
+
938
+ // Host secrets a private seat's shell never reads. ~/.commonly holds every
939
+ // seat's tokens and state; the seat's own workspace sits under it and is
940
+ // admitted by the more specific workspace rule (on Linux too: measured on
941
+ // codex-cli 0.164.0, a write key inside a denied folder works there). The
942
+ // Codex home is privateCodexHomeDenies'. gh keeps working: on macOS its
943
+ // login is in the keychain, which a credential the seat itself uses has to be.
944
+ const PRIVATE_SECRET_DENIES = Object.freeze([
945
+ '~/.commonly', '~/.claude', '~/.ssh', '~/.aws', '~/.gnupg',
946
+ '~/.kube', '~/.docker', '~/.netrc', '~/.config/gcloud',
947
+ ]);
948
+
949
+ const realOrSelf = (path) => {
950
+ try {
951
+ return realpathSync(path);
952
+ } catch {
953
+ return path;
954
+ }
955
+ };
956
+
957
+ // F444: what the private profile denies of the Codex home the seat runs with.
958
+ // On macOS the home is denied whole. On Linux Codex execs its sandbox helper
959
+ // from `<home>/tmp/arg0-linux` inside bwrap, and a denied home stops every turn
960
+ // at start ("bwrap: execvp codex-linux-sandbox: Permission denied"; a read key
961
+ // for `tmp` under the denied home does not lift it). Measured with `codex
962
+ // sandbox` and a real turn on codex-cli 0.164.0, ubuntu-22.04. So there the
963
+ // home itself stays readable and everything in it is denied except the helper
964
+ // folder: each entry present now, and the names Codex writes later even while
965
+ // they are absent (a key for a missing path is accepted there). Codex's SQLite
966
+ // databases write a `-wal` file during the turn that holds their newest rows,
967
+ // other sessions' included, and SQLite removes it when the last connection
968
+ // closes; so every database name, listed or known, takes its `-wal`, `-shm`
969
+ // and `-journal` too. A database under a new name is listed, with those, from
970
+ // the next turn. No globs: Codex's `*` crosses `/`, and a glob that matched a
971
+ // file inside a folder already denied stopped every turn at start ("bwrap:
972
+ // Can't create file at …: Read-only file system", run 43103991434).
973
+ // `~/.codex` is denied whole when it isn't that home.
974
+ const CODEX_HOME_SECRET_NAMES = Object.freeze([
975
+ 'auth.json', 'config.toml', 'sessions', 'archived_sessions', 'history.jsonl', 'log',
976
+ 'session_index.jsonl', 'shell_snapshots', 'memories',
977
+ 'logs_2.sqlite', 'state_5.sqlite', 'goals_1.sqlite', 'queue_1.sqlite',
978
+ ]);
979
+ const SQLITE_SIDECARS = Object.freeze(['-wal', '-shm', '-journal']);
980
+ const CODEX_HELPER_PATH = Object.freeze(['tmp', 'arg0-linux']);
981
+
982
+ const entriesOf = (dir) => {
983
+ try {
984
+ return readdirSync(dir);
985
+ } catch {
986
+ return [];
987
+ }
988
+ };
989
+
990
+ // `links`: entries of the run home that are links into a home denied whole
991
+ // (F445). A key on one makes bwrap follow it into the hidden folder and stop
992
+ // every turn at start ("Can't create file at …/auth.json: No such file or
993
+ // directory", run 43114789288); its target is out of reach already.
994
+ export const privateCodexHomeDenies = ({
995
+ codexHome = null, platform = process.platform, home = homedir(), links = [],
996
+ } = {}) => {
997
+ const defaultHome = join(home, '.codex');
998
+ const runHome = codexHome ? realOrSelf(codexHome) : null;
999
+ const runsFromDefault = !runHome || runHome === realOrSelf(defaultHome);
1000
+ if (platform !== 'linux') return runsFromDefault ? ['~/.codex'] : ['~/.codex', runHome];
1001
+ const helperHome = runHome || realOrSelf(defaultHome);
1002
+ const denies = runsFromDefault ? [] : ['~/.codex'];
1003
+ // Deny every sibling of the helper path at each level, so only the path
1004
+ // itself stays reachable.
1005
+ let dir = helperHome;
1006
+ for (const [depth, keep] of CODEX_HELPER_PATH.entries()) {
1007
+ const names = new Set(entriesOf(dir));
1008
+ if (depth === 0) {
1009
+ CODEX_HOME_SECRET_NAMES.forEach((name) => names.add(name));
1010
+ [...names].filter((name) => /\.(sqlite|db)$/.test(name))
1011
+ .forEach((name) => SQLITE_SIDECARS.forEach((suffix) => names.add(`${name}${suffix}`)));
1012
+ }
1013
+ names.delete(keep);
1014
+ if (depth === 0) links.forEach((name) => names.delete(name));
1015
+ denies.push(...[...names].sort().map((name) => join(dir, name)));
1016
+ dir = join(dir, keep);
1017
+ }
1018
+ return denies;
1019
+ };
1020
+
1021
+ // What a commit writes, and nothing that runs code. Codex keeps a workspace's
1022
+ // `.git` read-only even inside a writable root, and only an explicit absolute
1023
+ // key opens it (measured with `codex sandbox` on 0.162.1: the same key inside
1024
+ // :workspace_roots did not). `config` and `hooks` stay read-only, because a
1025
+ // hook or a config-defined program (core.fsmonitor, a filter) would run as the
1026
+ // person in every later git command there, outside any sandbox; renaming over
1027
+ // them is refused too (measured). A git worktree keeps its admin folder
1028
+ // outside the workspace (`.git` is a file naming it), and a commit there also
1029
+ // writes the shared repository's objects, refs and logs, and packed-refs when
1030
+ // git repacks them. Read from the files git leaves, without running git.
1031
+ // Measured: a worktree whose repository lies under the denied ~/.commonly
1032
+ // can't commit (git can't resolve the path), whatever these keys are.
1033
+ const gitAdminKeys = (workspace) => {
1034
+ if (!workspace) return [];
1035
+ const dotGit = join(workspace, '.git');
1036
+ const ownRepository = (dir) => {
1037
+ const real = realOrSelf(dir);
1038
+ return [
1039
+ [real, 'write'],
1040
+ [join(real, 'config'), 'read'],
1041
+ [join(real, 'config.worktree'), 'read'],
1042
+ [join(real, 'hooks'), 'read'],
1043
+ ];
1044
+ };
1045
+ try {
1046
+ if (statSync(dotGit).isDirectory()) return ownRepository(dotGit);
1047
+ } catch {
1048
+ return [];
1049
+ }
1050
+ try {
1051
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8'))?.[1]?.trim();
1052
+ if (!pointer) return [];
1053
+ const gitDir = pathResolve(workspace, pointer);
1054
+ let commonDir = null;
1055
+ try {
1056
+ commonDir = pathResolve(gitDir, readFileSync(join(gitDir, 'commondir'), 'utf8').trim());
1057
+ } catch {
1058
+ // A linked gitdir that is a whole repository of its own.
1059
+ return ownRepository(gitDir);
1060
+ }
1061
+ const realGitDir = realOrSelf(gitDir);
1062
+ const realCommon = realOrSelf(commonDir);
1063
+ return [
1064
+ [realGitDir, 'write'],
1065
+ [join(realGitDir, 'config.worktree'), 'read'],
1066
+ ...['objects', 'refs', 'logs', 'packed-refs', 'packed-refs.lock']
1067
+ .map((name) => [join(realCommon, name), 'write']),
1068
+ ];
1069
+ } catch {
1070
+ // An unreadable `.git` file.
1071
+ return [];
1072
+ }
1073
+ };
1074
+
1075
+ // F387: the profile for a private Codex seat outside a cloud-codex pod. It is
1076
+ // the seat owner's own agent doing dev work, so the disk stays readable, the
1077
+ // network is on and the environment is inherited (with Codex's secret-name
1078
+ // filter on); what it removes is every host secret the seat doesn't use
1079
+ // itself. It is not a sandbox for untrusted input: public seats keep
1080
+ // commonly_public.
1081
+ const privatePermissionProfileFlags = ({
1082
+ commitAttributionEnv = null,
1083
+ mcpReadDirectories = [],
1084
+ codexHelperReadPaths = [],
1085
+ platform = process.platform,
1086
+ workspace = null,
1087
+ tempRoots = [],
1088
+ shellTempDir = null,
1089
+ codexHome = null,
1090
+ home = homedir(),
1091
+ otherCodexHomes = [],
1092
+ codexHomeLinks = [],
1093
+ } = {}) => {
1094
+ const { commitHooksPath, originalHookPaths } = commitHookReadPaths(commitAttributionEnv);
1095
+ const posix = platform !== 'win32';
1096
+ // The temp roots hold every seat's per-turn folder: its runtime token and the
1097
+ // reply file Codex posts from. So they are denied whole, and the shell gets
1098
+ // one folder of its own inside this turn's, as TMPDIR (and TMPPREFIX, which
1099
+ // zsh takes its heredoc files from). Codex, and the MCP child that reads the
1100
+ // token, run outside the profile. On Windows these keys have no drive.
1101
+ const deniedTemp = posix
1102
+ ? [...new Set(['/private/tmp', ...tempRoots.filter(Boolean).map(realOrSelf)])]
1103
+ : [];
1104
+ const shellTemp = posix && shellTempDir ? realOrSelf(shellTempDir) : null;
1105
+ const filesystem = [
1106
+ // Codex reads every key as a path for its own platform; on Windows "/" has
1107
+ // no drive and Codex would refuse the whole profile (as with /private/tmp
1108
+ // in publicPermissionProfileFlags).
1109
+ posix ? '"/"="read"' : '":minimal"="read"',
1110
+ ...[...PRIVATE_SECRET_DENIES, ...privateCodexHomeDenies({ codexHome, platform, home, links: codexHomeLinks }), ...otherCodexHomes]
1111
+ .map((path) => `${toml(path)}="deny"`),
1112
+ ...deniedTemp.map((path) => `${toml(path)}="deny"`),
1113
+ ...(shellTemp ? [`${toml(shellTemp)}="write"`] : []),
1114
+ '":workspace_roots"={"."="write",".commonly/**"="deny",".codex/**"="deny"}',
1115
+ ...gitAdminKeys(workspace).map(([path, mode]) => `${toml(path)}=${toml(mode)}`),
1116
+ ...(commitHooksPath ? [`${toml(commitHooksPath)}="read"`] : []),
1117
+ ...originalHookPaths.map((hookPath) => `${toml(hookPath)}="read"`),
1118
+ ...mcpReadDirectories.map((path) => `${toml(path)}="read"`),
1119
+ ...codexHelperReadPaths.map((path) => `${toml(path)}="read"`),
1120
+ ].join(',');
1121
+ const shellSet = [
1122
+ ...gitConfigKeyShellSet(commitAttributionEnv),
1123
+ ...(shellTemp
1124
+ ? [
1125
+ `${toml('TMPDIR')}=${toml(shellTemp)}`,
1126
+ `${toml('TMPPREFIX')}=${toml(join(shellTemp, 'zsh'))}`,
1127
+ // macOS's /usr/bin tools are xcrun shims, which keep a cache in the
1128
+ // user temp root (denied above) and print an error on every call
1129
+ // without this.
1130
+ `${toml('xcrun_db')}=${toml(join(shellTemp, 'xcrun_db'))}`,
1131
+ ]
1132
+ : []),
1133
+ ];
1134
+ return [
1135
+ '-c', `default_permissions=${toml(PRIVATE_PERMISSION_PROFILE)}`,
1136
+ '-c', `permissions.${PRIVATE_PERMISSION_PROFILE}.filesystem={${filesystem}}`,
1137
+ '-c', `permissions.${PRIVATE_PERMISSION_PROFILE}.network.enabled=true`,
1138
+ '-c', 'shell_environment_policy.inherit="all"',
1139
+ '-c', 'shell_environment_policy.ignore_default_excludes=false',
1140
+ ...(shellSet.length ? ['-c', `shell_environment_policy.set={${shellSet.join(',')}}`] : []),
1141
+ ];
1142
+ };
1143
+
1144
+ // F188: the host's OpenAI-compatible model route, for a public seat. That seat
1145
+ // runs with `--ignore-user-config`, so config.toml's `openai_base_url` never
1146
+ // loads, and codex-cli 0.162.1 does not read the OPENAI_BASE_URL env either
1147
+ // (Growth's D252 walk, 11-05): a host routed at LiteLLM or another
1148
+ // OpenAI-compatible endpoint sent every turn to api.openai.com and got a 401.
1149
+ // The env is the declaration, as ANTHROPIC_BASE_URL is for the claude adapter;
1150
+ // buildArgs passes it on as `-c openai_base_url=...`, the top-level key the
1151
+ // 0.162.1 binary carries. Only a plain http(s) URL: one carrying credentials,
1152
+ // a query or a fragment could put a key into argv, so it is refused rather
1153
+ // than trimmed, and the refusal does not echo the value. The key itself stays
1154
+ // where codex already reads it (auth.json in the public home, or the env).
1155
+ export const publicModelRouteBaseUrl = (source = {}) => {
1156
+ const raw = typeof source.OPENAI_BASE_URL === 'string' ? source.OPENAI_BASE_URL.trim() : '';
1157
+ if (!raw) return null;
1158
+ let url = null;
1159
+ try { url = new URL(raw); } catch { /* refused below */ }
1160
+ if (!url || !['http:', 'https:'].includes(url.protocol) || !url.hostname
1161
+ || url.username || url.password || /[?#\s]/.test(raw)) {
1162
+ throw new Error('codex adapter: OPENAI_BASE_URL must be a plain http(s) URL with no credentials, '
1163
+ + 'query or fragment (e.g. https://litellm.example.com/v1); fix or unset it before a public seat runs.');
1164
+ }
1165
+ return raw;
1166
+ };
1167
+
1168
+ // F188 guard: `openai_base_url` moves wherever the seat's credential goes, a
1169
+ // ChatGPT session included (the built-in provider swaps only its default base
1170
+ // for a ChatGPT login). So the route is forwarded only to a seat that signs in
1171
+ // with an API key, and a ChatGPT session token is never pointed at a URL from
1172
+ // the env. The public home links the operator's auth.json, so that file decides:
1173
+ // its `auth_mode` alone is read (codex-cli 0.162.1 writes "apikey", "chatgpt",
1174
+ // "chatgptAuthTokens", ...), the parsed object is dropped, and nothing else from
1175
+ // it is kept or logged. Only "apikey" counts as a key; a file with any other
1176
+ // mode, no mode, or that does not parse is treated as a login and fails closed,
1177
+ // whatever OPENAI_API_KEY says, since codex prefers its stored login. With no
1178
+ // auth.json, an OPENAI_API_KEY in the env is the key (a CODEX_API_KEY never gets
1179
+ // here: it takes the proxy route below). A CODEX_ACCESS_TOKEN in the env is a
1180
+ // token codex sends in place of a key, so it counts as a login whatever
1181
+ // auth.json says.
1182
+ const CODEX_OWN_AUTH_ENVS = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_ACCESS_TOKEN', 'OPENAI_BASE_URL'];
1183
+
1184
+ const CHATGPT_ROUTE_IGNORED = 'OPENAI_BASE_URL is ignored while Codex is signed in with ChatGPT; '
1185
+ + 'use an API key for a custom route';
1186
+
1187
+ const envSet = (source, name) => typeof source[name] === 'string' && source[name].trim() !== '';
1188
+
1189
+ export const codexAuthKind = async (operatorHome, source = {}) => {
1190
+ if (envSet(source, 'CODEX_ACCESS_TOKEN')) return 'login';
1191
+ let text = null;
1192
+ try {
1193
+ text = await readFile(join(operatorHome, 'auth.json'), 'utf8');
1194
+ } catch (err) {
1195
+ if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') return 'login';
1196
+ }
1197
+ if (text === null) {
1198
+ return envSet(source, 'OPENAI_API_KEY') ? 'apikey' : 'none';
1199
+ }
1200
+ let mode = null;
1201
+ try { mode = JSON.parse(text)?.auth_mode ?? null; } catch { /* unreadable: a login */ }
1202
+ return mode === 'apikey' ? 'apikey' : 'login';
1203
+ };
1204
+
1205
+ // D250: the F188 route leaves the key in codex's own environment. With
1206
+ // CODEX_API_KEY in the seat's environment (codex's own name for an exec key),
1207
+ // the key stays on the host behind the per-turn loopback proxy, as a Claude
1208
+ // seat's ANTHROPIC_API_KEY does, and codex gets a provider entry naming the
1209
+ // proxy plus a per-turn token in an env var of our own. Measured on 0.162.1
1210
+ // with `--ignore-user-config`: these two `-c` overrides sent
1211
+ // `POST <base_url>/responses` with `Authorization: Bearer $<env_key>`. The URL
1212
+ // passes the same check as F188's. Without this key the seat takes F188's
1213
+ // route or the person's Codex login, as before.
1214
+ const CODEX_PROXY_PROVIDER = 'commonly_proxy';
1215
+ const CODEX_PROVIDER_TOKEN_ENV = 'COMMONLY_CODEX_PROVIDER_TOKEN';
1216
+ const OPENAI_DEFAULT_BASE_URL = 'https://api.openai.com/v1';
1217
+
1218
+ const publicProviderRoute = (source = {}) => {
1219
+ if (!envSet(source, 'CODEX_API_KEY')) return null;
1220
+ return {
1221
+ baseURL: publicModelRouteBaseUrl(source) || OPENAI_DEFAULT_BASE_URL,
1222
+ key: source.CODEX_API_KEY.trim(),
1223
+ };
1224
+ };
1225
+
1226
+ const providerRouteFlags = (proxy) => {
1227
+ if (!proxy) return [];
1228
+ // codex appends `/responses` itself, so a trailing slash would double it.
1229
+ const baseUrl = proxy.baseURL.replace(/\/+$/, '');
1230
+ return [
1231
+ '-c', `model_provider=${toml(CODEX_PROXY_PROVIDER)}`,
1232
+ '-c', `model_providers.${CODEX_PROXY_PROVIDER}={name=${toml('Space host proxy')},`
1233
+ + `base_url=${toml(baseUrl)},env_key=${toml(CODEX_PROVIDER_TOKEN_ENV)},wire_api="responses"}`,
1234
+ ];
1235
+ };
1236
+
1237
+ // Build the argv after the `codex` binary. Resume vs new turn is a
1238
+ // subcommand-level distinction in modern codex, not an option flag — keep
1239
+ // that detail isolated here so the spawn path stays linear.
1240
+ const buildArgs = ({
1241
+ sessionId,
1242
+ prompt,
1243
+ outputFile,
1244
+ mcpFlags = [],
1245
+ publicSandboxMode = null,
1246
+ model = null,
1247
+ effort = null,
1248
+ commitAttributionEnv = null,
1249
+ mcpReadDirectories = [],
1250
+ codexHelperReadPaths = [],
1251
+ modelBaseUrl = null,
1252
+ providerProxy = null,
1253
+ platform = process.platform,
1254
+ workspacePath = null,
1255
+ trustedContainerPerimeter = false,
1256
+ operatorUnconfined = false,
1257
+ privateWorkspace = null,
1258
+ tempRoots = [],
1259
+ shellTempDir = null,
1260
+ privateCodexHome = null,
1261
+ privateHome = homedir(),
1262
+ privateOtherCodexHomes = [],
1263
+ privateCodexHomeLinks = [],
1264
+ loginFileStore = false,
1265
+ }) => {
1266
+ const publicSandbox = publicSandboxMode !== null;
1267
+ // F387: a private seat keeps the flag below only in a cloud-codex pod, or
1268
+ // when its operator opted this run out of the OS sandbox (D245, native
1269
+ // Windows only: Codex can't enforce a profile's read denies there).
1270
+ const keepsOwnPolicy = trustedContainerPerimeter || operatorUnconfined;
1271
+ const privateProfile = !publicSandbox && !keepsOwnPolicy;
1272
+ // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
1273
+ // bubblewrap (bwrap) sandbox + approval prompts. bwrap needs CAP_SYS_ADMIN
1274
+ // or unprivileged user-namespaces — neither available to standard k8s
1275
+ // containers without elevated securityContext. Without this flag, every
1276
+ // shell tool call (git, ls, pwd, ...) fails with "bwrap: Failed to make /
1277
+ // slave: Permission denied" inside cloud-codex pods (verified 2026-05-15).
1278
+ // The pod is the security perimeter — agent identity is isolated, workspace
1279
+ // is PVC-scoped, no host mounts. bwrap inside the pod is redundant.
1280
+ // Public laptop wrappers are different: untrusted pod content must never
1281
+ // inherit that bypass. Codex permission profiles provide a native
1282
+ // deny-by-default read/write/network boundary on macOS and Linux, on the
1283
+ // versions spawn admits (PUBLIC_CODEX_MIN_VERSION). Do not
1284
+ // pass legacy `--sandbox` alongside them: Codex documents that the legacy
1285
+ // mode disables permission-profile composition.
1286
+ //
1287
+ // F387 (TASK-280): the flag above belongs to a cloud-codex k8s pod, where
1288
+ // bwrap cannot run and the pod is the boundary, or to a run its operator
1289
+ // opted out (keepsOwnPolicy). Every other private seat gets
1290
+ // privatePermissionProfileFlags: readable disk, network on, host secret dirs
1291
+ // denied. It keeps the seat's own config and route (no --ignore-user-config),
1292
+ // since a private seat relies on its config.toml; the -c profile still wins
1293
+ // over config defaults (checked against sandbox_mode, default_permissions and
1294
+ // a user [permissions.*] table on codex-cli 0.162.1).
1295
+ const executionPolicy = publicSandbox
1296
+ ? [
1297
+ '--ignore-user-config',
1298
+ '--ignore-rules',
1299
+ ...publicPermissionProfileFlags(
1300
+ publicSandboxMode,
1301
+ commitAttributionEnv,
1302
+ mcpReadDirectories,
1303
+ codexHelperReadPaths,
1304
+ platform,
1305
+ workspacePath,
1306
+ ),
1307
+ ...providerRouteFlags(providerProxy),
1308
+ ...PUBLIC_BUILTIN_TOOL_FLAGS,
1309
+ ]
1310
+ : keepsOwnPolicy
1311
+ ? ['--dangerously-bypass-approvals-and-sandbox']
1312
+ : privatePermissionProfileFlags({
1313
+ commitAttributionEnv,
1314
+ mcpReadDirectories,
1315
+ codexHelperReadPaths,
1316
+ platform,
1317
+ workspace: privateWorkspace,
1318
+ tempRoots,
1319
+ shellTempDir,
1320
+ codexHome: privateCodexHome,
1321
+ home: privateHome,
1322
+ otherCodexHomes: privateOtherCodexHomes,
1323
+ codexHomeLinks: privateCodexHomeLinks,
1324
+ });
1325
+ const common = [
1326
+ '--json',
1327
+ '--skip-git-repo-check',
1328
+ ...executionPolicy,
1329
+ // F188: only a public seat drops config.toml; a private one keeps its own
1330
+ // route there. In `common`, so it lands after `exec` / `exec resume <id>`,
1331
+ // the only position codex parses it from.
1332
+ ...(publicSandbox && modelBaseUrl ? ['-c', `openai_base_url=${toml(modelBaseUrl)}`] : []),
1333
+ ...(model ? ['--model', String(model)] : []),
1334
+ ...(effort ? ['-c', 'model_reasoning_effort=' + toml(effort)] : []),
1335
+ ...(loginFileStore ? ['-c', 'cli_auth_credentials_store="file"'] : []),
1336
+ ...mcpFlags,
1337
+ '-o',
1338
+ outputFile,
1339
+ ];
1340
+ // Approval policy is a global option in Codex 0.144, so it must precede
1341
+ // the `exec` subcommand. A denied operation is returned to the model;
1342
+ // non-interactive agents never hang waiting for an operator. A private seat
1343
+ // under its profile keeps its own config.toml, which may ask for approvals.
1344
+ const prefix = publicSandbox || privateProfile ? ['--ask-for-approval', 'never'] : [];
1345
+ if (sessionId) {
1346
+ // Place <sessionId> immediately after the `exec resume` subcommand so a
1347
+ // future codex parser change can't accidentally consume it as the value
1348
+ // of a preceding flag (e.g. -o). Codex's CLI signature is documented as
1349
+ // `codex exec resume [OPTIONS] [SESSION_ID] [PROMPT]`, and this ordering
1350
+ // matches that intent unambiguously regardless of clap version.
1351
+ return [...prefix, 'exec', 'resume', sessionId, ...common, prompt];
1352
+ }
1353
+ return [...prefix, 'exec', ...common, prompt];
1354
+ };
1355
+
1356
+ // Stream-parse JSONL stdout. Codex emits one event per line; partial lines
1357
+ // across chunk boundaries are buffered and flushed on the next newline.
1358
+ const makeEventParser = () => {
1359
+ let buffer = '';
1360
+ let threadId = null;
1361
+ let turnFailedMessage = null;
1362
+ let turnCompleted = false;
1363
+
1364
+ const consumeLine = (raw) => {
1365
+ const line = raw.trim();
1366
+ if (!line) return;
1367
+ try {
1368
+ const evt = JSON.parse(line);
1369
+ if (evt.type === 'thread.started' && evt.thread_id && !threadId) {
1370
+ threadId = evt.thread_id;
1371
+ } else if (evt.type === 'turn.completed') {
1372
+ turnCompleted = true;
1373
+ } else if (evt.type === 'turn.failed') {
1374
+ // Capture the most recent — codex may emit multiple before exit.
1375
+ turnFailedMessage = evt.error?.message || 'codex turn failed';
1376
+ }
1377
+ } catch {
1378
+ // Non-JSON lines on stdout are unexpected but not fatal — skip.
1379
+ }
1380
+ };
1381
+
1382
+ const consume = (chunk) => {
1383
+ buffer += chunk.toString();
1384
+ let nl;
1385
+ // eslint-disable-next-line no-cond-assign
1386
+ while ((nl = buffer.indexOf('\n')) !== -1) {
1387
+ consumeLine(buffer.slice(0, nl));
1388
+ buffer = buffer.slice(nl + 1);
1389
+ }
1390
+ };
1391
+
1392
+ const flush = () => {
1393
+ consumeLine(buffer);
1394
+ buffer = '';
1395
+ };
1396
+
1397
+ return {
1398
+ consume,
1399
+ flush,
1400
+ get threadId() { return threadId; },
1401
+ get turnFailedMessage() { return turnFailedMessage; },
1402
+ get turnCompleted() { return turnCompleted; },
1403
+ };
1404
+ };
1405
+
1406
+ /**
1407
+ * Provider keys this adapter's route can authenticate with. `env_key` in
1408
+ * `~/.codex/config.toml` names one of them (ADR-014 — the HTTPS layer is
1409
+ * redirected, the CLI's semantics are not), and the adapter cannot know which
1410
+ * one this seat uses, so it snapshots whichever are present. A failure tail that
1411
+ * echoes a key must not reach a pod (TASK-103).
1412
+ */
1413
+ const CODEX_PROVIDER_KEY_ENVS = [
1414
+ 'LITELLM_API_KEY',
1415
+ 'LITELLM_MASTER_KEY',
1416
+ 'OPENAI_API_KEY',
1417
+ 'CODEX_API_KEY',
1418
+ ];
1419
+
1420
+ // What the turn will launch, asked for its version. 30 s, as in goose: a first
1421
+ // launch after an upgrade can be slow. A failed probe isn't cached, so the next
1422
+ // turn reads it again. `_codexVersion` and `_versionProbe` are test seams only.
1423
+ const readCodexVersion = async (ctx, env) => {
1424
+ if (ctx._codexVersion !== undefined) return ctx._codexVersion;
1425
+ try {
1426
+ const { command, args } = resolveLaunch('codex', [], { env, ...ctx._commandResolution });
1427
+ return await probeVersion(command, {
1428
+ args, env, timeoutMs: 30000, parse: parseCodexVersion, ...ctx._versionProbe,
1429
+ });
1430
+ } catch {
1431
+ return null;
1432
+ }
1433
+ };
1434
+
1435
+ // `ownGroup`: see runClaude in claude.js.
1436
+ const runCodex = ({
1437
+ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
1438
+ turnReap = turnReapEnabled(), commandResolution = {}, confined = false, command = null,
1439
+ }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
1440
+ // defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
1441
+ // `Reading additional input from stdin...` because it sees an open pipe
1442
+ // and waits for input that never arrives. Interactive runs are fine because
1443
+ // codex detects a TTY and uses the argv prompt directly. Setting stdin to
1444
+ // `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
1445
+ // argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
1446
+ // What the turn leaves running is stopped once it has closed (see claude.js).
1447
+ // A confined turn's leftovers are stopped whatever the keep setting says.
1448
+ const turn = markTurn(env ?? process.env, { enabled: turnReap, confined });
1449
+ // On Windows `npm i -g @openai/codex` leaves a `codex.cmd`; it is read and
1450
+ // its target spawned directly, never through cmd.exe, because the prompt is
1451
+ // the last argv element and is pod content (#2141). Identity elsewhere.
1452
+ const launch = command ? { command, args } : resolveLaunch('codex', args, { env: turn.env, ...commandResolution });
1453
+ const proc = spawnInOwnGroup(spawnImpl, launch.command, launch.args, { cwd, env: turn.env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
1454
+ observeSpawn(proc, onSpawn, controls);
1455
+ let stderr = '';
1456
+ let timedOut = false;
1457
+ const events = makeEventParser();
1458
+
1459
+ const timer = setTimeout(() => {
1460
+ timedOut = true;
1461
+ proc.kill('SIGTERM');
1462
+ }, timeoutMs);
1463
+
1464
+ proc.stdout?.on('data', (chunk) => events.consume(chunk));
1465
+ proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
1466
+ proc.on('error', (err) => {
1467
+ clearTimeout(timer);
1468
+ reject(err);
1469
+ });
1470
+ proc.on('close', (code) => {
1471
+ clearTimeout(timer);
1472
+ const reaped = describeTurnReap(turn.reap(proc));
1473
+ // eslint-disable-next-line no-console
1474
+ if (reaped) console.warn(`[codex] ${reaped}`);
1475
+ if (timedOut) return reject(new Error(`codex timed out after ${timeoutMs}ms`));
1476
+ events.flush();
1477
+ if (events.turnFailedMessage) {
1478
+ // Surface the model-side failure message verbatim — the run loop posts
1479
+ // it as the agent's reply so the user sees what went wrong rather than
1480
+ // a generic "non-zero exit" error. Verbatim is about the WORDING: the
1481
+ // tail still goes through the shared reader (TASK-103), so a message that
1482
+ // echoed a credential this spawn was handed is redacted before it can be
1483
+ // posted into a pod, and a status it names is attached for the circuit
1484
+ // breaker rather than left to a regex.
1485
+ return reject(adapterFailure('codex turn failed', events.turnFailedMessage, {
1486
+ credentials,
1487
+ limit: 2000,
1488
+ }));
1489
+ }
1490
+ if (code !== 0) {
1491
+ return reject(adapterFailure('codex', stderr.trim(), { credentials, exitCode: code, limit: 500 }));
1492
+ }
1493
+ resolve({ threadId: events.threadId, turnCompleted: events.turnCompleted });
1494
+ });
1495
+ });
1496
+
1497
+ export default {
1498
+ get requiresModel() { return seatModelRequired(); },
1499
+ name: 'codex',
1500
+ // See claude.js for the rationale — identity-bearing runtime tag persisted
1501
+ // to AgentInstallation.config.runtime (paired with host:'byo') so a CLI-
1502
+ // attached Codex agent and a cloud-hosted Codex agent share the same
1503
+ // `runtimeType` and differ only on `host`.
1504
+ runtimeType: 'codex',
1505
+
1506
+ inventory: codexRuntimeInventory,
1507
+
1508
+ // `opts` is a test seam only: `_commandResolution` overrides the Windows
1509
+ // resolver's platform/env/fs. Production calls detect() with no argument.
1510
+ async detect(opts = {}) {
1511
+ try {
1512
+ const located = resolveWindowsCommandWithReason('codex', opts._commandResolution);
1513
+ if (!located.command) {
1514
+ // Windows only: a `codex.cmd` this CLI cannot read safely, or none.
1515
+ process.stderr.write(`codex adapter: ${located.reason}\n`);
1516
+ return null;
1517
+ }
1518
+ const res = spawnSync(located.command, [...located.args, '--version'], { encoding: 'utf8', timeout: 5000 });
1519
+ if (res.error || res.status !== 0) return null;
1520
+ // `codex --version` prints e.g. "codex-cli 0.125.0" — last token is
1521
+ // the version. Defensive against future format tweaks: if no
1522
+ // dotted-numeric token is found, fall back to the raw stdout.
1523
+ const stdout = (res.stdout || '').trim();
1524
+ const versionMatch = stdout.match(/(\d+\.\d+(?:\.\d+)?)/);
1525
+ const version = versionMatch ? versionMatch[1] : (stdout || 'unknown');
1526
+ if (located.source) return { path: located.source, version };
1527
+ const where = spawnSync('which', ['codex'], { encoding: 'utf8' });
1528
+ const path = where.status === 0 ? (where.stdout || '').trim() || 'codex' : 'codex';
1529
+ return { path, version };
1530
+ } catch {
1531
+ return null;
1532
+ }
1533
+ },
1534
+
1535
+ async spawn(prompt, ctx = {}) {
1536
+ const seatModel = requireSeatModel(ctx.environment, 'codex');
1537
+ const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
1538
+ refuseWorkspaceTrust(sandbox, 'codex');
1539
+ // Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
1540
+ // `null || ''` is `''` — which routed the unreadable case straight into
1541
+ // the empty-memory branch and made the whole distinction unreachable from
1542
+ // the only two paths that build a real prompt. buildPrompt handles
1543
+ // undefined and '' as absence itself; it does not need a guard, it needs
1544
+ // the value.
1545
+ //
1546
+ // Of the two habits (TASK-545) a private Codex seat is taught row slicing
1547
+ // alone. claude.js adds parallel reads, whose cue names Claude's Agent tool;
1548
+ // Codex has agent tools of its own, under other names. A public seat answers
1549
+ // strangers rather than working board rows, so it is taught neither.
1550
+ // F445: a private seat on Linux resumes only a thread in its own Codex
1551
+ // home; one in the shared home (a run before F445) starts fresh, which the
1552
+ // prompt has to know.
1553
+ const ownPrivateHome = (ctx._platform || process.platform) === 'linux' && sandbox?.trust !== 'public'
1554
+ && !codexTrustedPerimeter(ctx.env || process.env) && ctx.hostUnconfined !== true
1555
+ ? privateCodexHomePath(ctx) : null;
1556
+ let sessionId = ctx.sessionId || null;
1557
+ if (ownPrivateHome && sessionId && !codexRolloutPath({ codexHome: ownPrivateHome, sessionId })) {
1558
+ process.stderr.write('[codex] this agent now keeps its own Codex home, so it starts a new thread; '
1559
+ + 'what it is meant to remember is in its Space memory.\n');
1560
+ sessionId = null;
1561
+ }
1562
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
1563
+ freshSession: !sessionId,
1564
+ workCues: sandbox?.trust === 'public' ? [] : [ROW_SLICING_CUE, BACKGROUND_PROCESS_CUE],
1565
+ // F118: a public seat reads its memory but is not offered a write.
1566
+ memoryReadOnly: sandbox?.trust === 'public' && ctx.ownerWoken !== true,
1567
+ });
1568
+
1569
+ const workspacePath = sandbox?.trust === 'public'
1570
+ ? await refuseWorkspaceHoldingTurnDirs({ workspace: ctx.cwd, seat: 'public Codex agents' })
1571
+ : null;
1572
+ // Codex runs git in the workspace outside the seat's profile, so a
1573
+ // repository set up to run programs is refused before anything starts
1574
+ // (sandbox/workspace-git-config.js).
1575
+ if (workspacePath) {
1576
+ (ctx._refuseCodeRunningGitSetup || refuseCodeRunningGitSetup)(workspacePath, {
1577
+ seat: 'This public Codex agent', env: ctx.env || process.env,
1578
+ });
1579
+ }
1580
+
1581
+ // Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
1582
+ // so a crash in the middle of the spawn doesn't leak files in $TMPDIR.
1583
+ const dir = await mkdtemp(join(tmpdir(), 'commonly-codex-'));
1584
+ const outputFile = join(dir, 'last-message.txt');
1585
+ // Written into the per-spawn directory this adapter already creates and
1586
+ // removes: codex reads and writes inside it for --output-last-message, so a
1587
+ // child it spawns can reach a file there, which is the one property this
1588
+ // file has to have. Outside it the path is an unreadable promise.
1589
+ const credential = writeCredentialFile(ctx.runtimeToken, {
1590
+ agentName: ctx.agentName || 'agent',
1591
+ root: dir,
1592
+ });
1593
+
1594
+ let providerProxy = null;
1595
+ const readyFiles = [];
1596
+ let credentialRelease = null;
1597
+ try {
1598
+ // A public seat can't run git from its shell on macOS: Homebrew's git is
1599
+ // outside the profile and /usr/bin/git is an xcrun shim that fails there.
1600
+ // So it reads its workspace's history through the read-only git tool a
1601
+ // Claude workspace seat has. Codex starts MCP servers outside the seat's
1602
+ // profile, so this one runs under a Seatbelt of its own
1603
+ // (confinedWorkspaceGitServer). An entry the environment declares under
1604
+ // the same name would run unconfined, so on a public seat it is replaced.
1605
+ const declaredMcp = ctx.environment?.mcp || [];
1606
+ let seatMcp = declaredMcp;
1607
+ if (workspacePath) {
1608
+ // The confined tool is opt-in (workspaceGitEnabled). A declared one is
1609
+ // dropped either way: Codex would run it outside the sandbox.
1610
+ const gitEnabled = workspaceGitEnabled();
1611
+ const declaresGit = declaredMcp.some((server) => server?.name === WORKSPACE_GIT_SERVER_NAME);
1612
+ if (declaresGit) {
1613
+ // eslint-disable-next-line no-console
1614
+ console.warn(`[codex] this public seat's environment declares ${WORKSPACE_GIT_SERVER_NAME}; `
1615
+ + (gitEnabled
1616
+ ? 'Codex would run it outside the sandbox, so the confined one replaces it'
1617
+ : 'Codex would run it outside the sandbox, so it is dropped'));
1618
+ }
1619
+ let workspaceGit = null;
1620
+ if (gitEnabled) {
1621
+ const stateDir = join(dir, 'git-tool');
1622
+ await mkdir(stateDir, { mode: 0o700 });
1623
+ workspaceGit = (ctx._workspaceGitServer || confinedWorkspaceGitServer)(workspacePath, {
1624
+ stateDir,
1625
+ platform: ctx._platform || process.platform,
1626
+ });
1627
+ }
1628
+ if (gitEnabled && !workspaceGit) {
1629
+ // eslint-disable-next-line no-console
1630
+ console.warn('[codex] the read-only git tool needs macOS and git on this machine, so this public seat has none');
1631
+ }
1632
+ seatMcp = [
1633
+ ...declaredMcp.filter((server) => server?.name !== WORKSPACE_GIT_SERVER_NAME),
1634
+ ...(workspaceGit ? [workspaceGit] : []),
1635
+ ];
1636
+ }
1637
+ // F460: a public seat's MCP servers name their ready files here; codex
1638
+ // starts them outside the seat's permission profile, beside the credential.
1639
+ const readyFileDir = sandbox?.trust === 'public' && credential ? join(dir, 'mcp-ready') : null;
1640
+ if (readyFileDir) await mkdir(readyFileDir, { recursive: true, mode: 0o700 });
1641
+ const mcp = buildMcpOverrideArgs(seatMcp, {
1642
+ runtimeToken: ctx.runtimeToken,
1643
+ instanceUrl: ctx.instanceUrl,
1644
+ credentialFile: credential?.path || null,
1645
+ readyFileDir,
1646
+ readyFiles,
1647
+ publicSeat: sandbox?.trust === 'public',
1648
+ ownerWoken: ctx.ownerWoken,
1649
+ commandResolution: { env: ctx.env || process.env, ...ctx._commandResolution },
1650
+ });
1651
+ if (mcp.unavailableMcp.length) throw publicMcpUnavailableError(mcp.unavailableMcp);
1652
+ // A derived record stores `trust: 'public'` and no mode (the block is
1653
+ // platform-independent; see cli/src/lib/default-environment.js). Codex's
1654
+ // mode only selects read vs write access — its permission profiles run on
1655
+ // both macOS and Linux — so the derived default is `workspace`, and an
1656
+ // explicit mode in the record still wins. Before this, a mode-less public
1657
+ // record threw `got unset` and no codex seat spawned at all.
1658
+ if (isLegacySandboxTrust(ctx.environment?.sandbox)) {
1659
+ // eslint-disable-next-line no-console
1660
+ console.warn(
1661
+ '[codex] sandbox.trust=internal is no longer accepted: it reads as '
1662
+ + 'confinement and engaged none. Resolving this seat as trust=public '
1663
+ + '(Wren 69585).',
1664
+ );
1665
+ }
1666
+ const publicSandboxMode = sandbox?.trust === 'public'
1667
+ ? sandbox?.mode ?? 'workspace'
1668
+ : null;
1669
+ // Checked before the public home is prepared, so a refused route changes nothing.
1670
+ // Only a seat that will forward the URL validates it: a ChatGPT host with an
1671
+ // OPENAI_BASE_URL set for some other tool keeps running, as it did before.
1672
+ const routeEnv = ctx.env || process.env;
1673
+ const route = publicSandboxMode !== null ? publicProviderRoute(routeEnv) : null;
1674
+ let modelBaseUrl = null;
1675
+ if (!route && publicSandboxMode !== null && String(routeEnv.OPENAI_BASE_URL || '').trim()) {
1676
+ const authKind = await codexAuthKind(operatorCodexHome(ctx), routeEnv);
1677
+ if (authKind === 'apikey') modelBaseUrl = publicModelRouteBaseUrl(routeEnv);
1678
+ else if (authKind === 'login') process.stderr.write(`[codex] ${CHATGPT_ROUTE_IGNORED}\n`);
1679
+ }
1680
+ const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
1681
+ // codex talks to the proxy alone, so none of the names it or its gateway
1682
+ // read stays in its environment, on the turn or on the version probe; the
1683
+ // turn gets the per-turn token instead. In upper case, as above: Windows
1684
+ // env names are case-insensitive, and this is a plain copy of them.
1685
+ if (route) {
1686
+ const routeNames = new Set([...CODEX_PROVIDER_KEY_ENVS, ...CODEX_OWN_AUTH_ENVS]);
1687
+ for (const name of Object.keys(childEnv)) {
1688
+ if (routeNames.has(name.toUpperCase())) delete childEnv[name];
1689
+ }
1690
+ }
1691
+ // F387: only a cloud-codex pod sets the perimeter env
1692
+ // (sandbox/codex-perimeter.js), and only the operator's own D245 opt-in
1693
+ // sets hostUnconfined. Every other private seat runs under the private
1694
+ // permission profile.
1695
+ const trustedContainerPerimeter = codexTrustedPerimeter(ctx.env || process.env);
1696
+ const operatorUnconfined = ctx.hostUnconfined === true;
1697
+ const privateProfile = publicSandboxMode === null && !trustedContainerPerimeter && !operatorUnconfined;
1698
+ // Before the public home is prepared, so a refused seat changes nothing.
1699
+ // A private profile uses the same permission keys, tested to the same bar.
1700
+ if (publicSandboxMode !== null || privateProfile) {
1701
+ const codexVersion = await readCodexVersion(ctx, childEnv);
1702
+ if (!meetsMinimumVersion(codexVersion, PUBLIC_CODEX_MIN_VERSION)) {
1703
+ throw publicCodexVersionError({
1704
+ found: codexVersion,
1705
+ required: PUBLIC_CODEX_MIN_VERSION,
1706
+ seat: publicSandboxMode !== null ? 'public seat' : 'private seat',
1707
+ });
1708
+ }
1709
+ }
1710
+ const publicCodexHome = publicSandboxMode !== null
1711
+ ? await preparePublicCodexHome(ctx, childEnv, { login: !route })
1712
+ : null;
1713
+ const publicMacCommand = publicSandboxMode !== null
1714
+ ? await stagePublicMacCodex(ctx, childEnv)
1715
+ : null;
1716
+ const privateCodexHome = privateProfile && ownPrivateHome
1717
+ ? await preparePrivateCodexHome(ctx, childEnv)
1718
+ : null;
1719
+ // The private shell's TMPDIR: inside this turn's folder, which `finally`
1720
+ // removes, and beside (not over) the token's folder.
1721
+ const privateShellTemp = privateProfile ? join(dir, 'shell-tmp') : null;
1722
+ if (privateShellTemp) await mkdir(privateShellTemp, { mode: 0o700 });
1723
+ if (route) {
1724
+ providerProxy = await (ctx._startProviderProxy || startProviderProxy)({
1725
+ provider: { baseURL: route.baseURL },
1726
+ keyMaterial: route.key,
1727
+ wire: 'openai-responses',
1728
+ adapterLabel: 'Codex',
1729
+ });
1730
+ childEnv[CODEX_PROVIDER_TOKEN_ENV] = providerProxy.token;
1731
+ }
1732
+ const args = buildArgs({
1733
+ sessionId,
1734
+ prompt: fullPrompt,
1735
+ outputFile,
1736
+ mcpFlags: mcp.flags,
1737
+ // Nothing is added for the git tool, which has a Seatbelt of its own:
1738
+ // admitting the git binary here would let the shell run git directly,
1739
+ // around the read-only server.
1740
+ mcpReadDirectories: mcp.mcpReadDirectories,
1741
+ publicSandboxMode,
1742
+ model: seatModel,
1743
+ effort: ctx.environment?.effort,
1744
+ commitAttributionEnv: ctx.commitAttributionEnv,
1745
+ codexHelperReadPaths: publicCodexHome?.helperReadPaths || [],
1746
+ modelBaseUrl,
1747
+ providerProxy,
1748
+ platform: ctx._platform || process.platform,
1749
+ workspacePath,
1750
+ trustedContainerPerimeter,
1751
+ operatorUnconfined,
1752
+ privateWorkspace: privateProfile && ctx.cwd ? pathResolve(ctx.cwd) : null,
1753
+ // Both roots: this process's, where every seat's per-turn folder
1754
+ // lives, and the one the shell would inherit.
1755
+ tempRoots: privateProfile ? [tmpdir(), childEnv.TMPDIR] : [],
1756
+ shellTempDir: privateShellTemp,
1757
+ // The home Codex runs with, and the HOME its `~` keys expand against.
1758
+ // Codex reads a relative CODEX_HOME from its own folder, the turn's.
1759
+ privateCodexHome: privateProfile
1760
+ ? privateCodexHome?.home || privateSourceCodexHome(ctx, childEnv)
1761
+ : null,
1762
+ privateHome: childEnv.HOME || homedir(),
1763
+ // Public seats' Codex homes (their sessions and logs): under the denied
1764
+ // ~/.commonly on macOS, outside it on Linux. With a home of its own, the
1765
+ // shared one it came from and every other private seat's are denied whole.
1766
+ privateOtherCodexHomes: privateProfile
1767
+ ? [...new Set([
1768
+ ...privatePublicHomesDeny(ctx),
1769
+ // By its real path too, when it is a link to another folder.
1770
+ ...(privateCodexHome
1771
+ ? [privateCodexHome.source, privateCodexHome.realSource, ...privateCodexHome.others]
1772
+ : []),
1773
+ ])]
1774
+ : [],
1775
+ privateCodexHomeLinks: privateCodexHome?.links || [],
1776
+ // F450: a seat with a home of its own keeps the person's login in
1777
+ // auth.json, linked to theirs. Codex's file store writes a refreshed
1778
+ // login through that link (open + truncate, codex-rs login
1779
+ // auth/storage.rs at rust-v0.164.0), so the person's file gets the new
1780
+ // tokens. Its keyring and auto stores save to the keyring under the
1781
+ // seat home's own path and delete the seat's auth.json, which would
1782
+ // leave the person's file holding the refresh token the refresh just
1783
+ // rotated. config.toml is linked in, so the seat would inherit those
1784
+ // modes; this keeps it on the file store.
1785
+ loginFileStore: Boolean(privateCodexHome),
1786
+ });
1787
+ // childEnv is derived from the process environment, so the bootstrap export has to be
1788
+ // taken back out: the declaration above moved our server onto the file,
1789
+ // and a value left here is a value codex's own children inherit. Kept only
1790
+ // when the substitution genuinely needed it (`forwardedEnv` names that
1791
+ // carve-out, and the warning above it is emitted for the same spawn).
1792
+ withholdRuntimeCredential(childEnv, {
1793
+ credentialFile: credential?.path || null,
1794
+ keepsValue: mcp.forwardedEnv[CREDENTIAL_KEY] !== undefined,
1795
+ });
1796
+ if (publicSandboxMode !== null && ctx.commitAttributionEnv) {
1797
+ Object.assign(childEnv, ctx.commitAttributionEnv);
1798
+ }
1799
+ if (publicSandboxMode !== null) {
1800
+ childEnv.CODEX_HOME = publicCodexHome.home;
1801
+ await writePublicCodexInstructions(childEnv.CODEX_HOME, ctx);
1802
+ }
1803
+ if (privateCodexHome) childEnv.CODEX_HOME = privateCodexHome.home;
1804
+
1805
+ // The awaits above can outlast a supervisor stall window. Once it has
1806
+ // aborted, the attempt has already settled and a retry may be running,
1807
+ // so no process is created for this one. Checked synchronously before
1808
+ // runCodex, so the spawn itself cannot race the abort.
1809
+ if (ctx.signal?.aborted) {
1810
+ throw ctx.signal.reason || new Error('codex spawn cancelled before the process started');
1811
+ }
1812
+ // F460: on a public seat the credential goes once every MCP reader has it
1813
+ // (their ready files), or at the deadline. A delete that fails ends the turn.
1814
+ let releaseFailure = null;
1815
+ let child = null;
1816
+ const spawnImpl = (...spawnArgs) => {
1817
+ child = (ctx._spawnImpl || childSpawn)(...spawnArgs);
1818
+ return child;
1819
+ };
1820
+ if (readyFileDir) {
1821
+ credentialRelease = armCredentialRelease(credential, {
1822
+ readyFiles: readyFiles.map(({ path }) => path),
1823
+ label: 'public codex seat',
1824
+ warn: (text) => process.stderr.write(`[codex] ${text}\n`),
1825
+ onFailure: (err) => { releaseFailure = err; child?.kill?.('SIGKILL'); },
1826
+ ...ctx._credentialRelease,
1827
+ });
1828
+ // A delete that failed at arm time (every reader already ready) ends
1829
+ // the turn before Codex starts.
1830
+ if (credentialRelease.error) throw credentialRelease.error;
1831
+ }
1832
+ const ran = await runCodex({
1833
+ command: publicMacCommand,
1834
+ args,
1835
+ cwd: ctx.cwd,
1836
+ env: childEnv,
1837
+ timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
1838
+ // The values this spawn was handed, for the failure tail's exact-match
1839
+ // check: the seat credential and the provider key the route
1840
+ // authenticates with. Snapshot HERE — `withholdRuntimeCredential` above
1841
+ // already removed the token from the child copy, and after that there is
1842
+ // nothing left to compare a tail against.
1843
+ // The per-turn proxy token is one too: codex was handed it in place of the key.
1844
+ credentials: [...spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS), route?.key, providerProxy?.token].filter(Boolean),
1845
+ spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
1846
+ ownGroup: wantsOwnProcessGroup(ctx),
1847
+ turnReap: ctx._turnReap,
1848
+ // A public seat keeps nothing a turn leaves running, whatever the turn
1849
+ // set (F201, as claude.js does for F197). `publicSandboxMode` is set
1850
+ // exactly when trust is `public`; workspace trust is refused above.
1851
+ confined: publicSandboxMode !== null,
1852
+ commandResolution: ctx._commandResolution, // test seam only
1853
+ }).catch((err) => { throw releaseFailure || err; });
1854
+ if (releaseFailure) throw releaseFailure;
1855
+ const { threadId, turnCompleted } = ran;
1856
+
1857
+ let text = '';
1858
+ try {
1859
+ text = (await readFile(outputFile, 'utf8')).trim();
1860
+ } catch {
1861
+ // No file: a finished turn with no final message.
1862
+ // The run loop treats empty text as a silent turn and acknowledges it.
1863
+ text = '';
1864
+ }
1865
+
1866
+ // A signal doesn't reach the exit code when codex runs through the npm
1867
+ // launcher: it re-raises SIGTERM or SIGHUP into its own handler and exits
1868
+ // 0. Resolving would read as a turn with nothing to say, and the messages
1869
+ // it held would never be answered. A final message counts as finished,
1870
+ // so a version that doesn't print turn.completed still posts its reply.
1871
+ if (!turnCompleted && !text) throw adapterFailure('codex', 'the turn stopped before it finished');
1872
+
1873
+ // newSessionId precedence: thread_id from this turn (always emitted on
1874
+ // a new session, often re-emitted on resume) > the persisted id we
1875
+ // came in with > null. The wrapper persists whichever non-null value
1876
+ // we return so subsequent turns hit `codex exec resume <id>`.
1877
+ const newSessionId = threadId || sessionId || null;
1878
+ // F404: what the turn's last request was sent, from the rollout the child
1879
+ // wrote under its own CODEX_HOME. Null keeps resuming.
1880
+ const contextTokens = newSessionId
1881
+ ? (ctx._codexContextTokens || codexContextTokens)({ codexHome: codexHomeFor(childEnv), sessionId: newSessionId })
1882
+ : null;
1883
+ // D247: the reply receipt names the model and effort this spawn passed
1884
+ // (`--model`, `model_reasoning_effort`); codex's own stream does not
1885
+ // restate them.
1886
+ const effort = ctx.environment?.effort;
1887
+ const stats = { ...(seatModel ? { model: String(seatModel) } : {}),
1888
+ ...(typeof effort === 'string' && effort ? { effort } : {}) };
1889
+ return {
1890
+ text, newSessionId, contextTokens, ...(Object.keys(stats).length ? { stats } : {}),
1891
+ };
1892
+ } finally {
1893
+ credentialRelease?.stop();
1894
+ try { await providerProxy?.close(); } catch { /* ignore */ }
1895
+ try { await rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
1896
+ }
1897
+ },
1898
+ };