@ravefox/space 0.2.1 → 0.4.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.
- package/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
|
@@ -1,1898 +0,0 @@
|
|
|
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
|
-
};
|