-
Notifications
You must be signed in to change notification settings - Fork 58
Expand file tree
/
Copy pathspawn.ts
More file actions
216 lines (191 loc) · 7.42 KB
/
Copy pathspawn.ts
File metadata and controls
216 lines (191 loc) · 7.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
import { type ChildProcess, spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { delimiter, dirname } from "node:path";
import type { Readable, Writable } from "node:stream";
import type { ProcessSpawnedCallback } from "../../types";
import { Logger } from "../../utils/logger";
import { stripElectronNodeShimFromPath } from "../../utils/spawn-env";
import { CodexSettingsManager } from "./settings";
/**
* Host-facing codex options passed through `createAcpConnection`'s
* `codexOptions`. The connection layer maps these onto
* `CodexAppServerProcessOptions` plus the agent-level model settings.
*/
export interface CodexOptions {
cwd?: string;
apiBaseUrl?: string;
apiKey?: string;
model?: string;
reasoningEffort?: string;
/** Guidance appended on top of Codex's base prompt via `developer_instructions`. */
developerInstructions?: string;
/**
* Bundled-binary hint: the native codex binary itself, or any file in the
* directory that contains it (see `nativeCodexBinaryPath`).
*/
binaryPath?: string;
codexHome?: string;
/** Extra codex `-c key=value` config overrides. */
configOverrides?: Record<string, string | number>;
/**
* Additional writable roots. Currently only honored per-thread via prompt
* params; accepted here so hosts can pass it uniformly.
*/
additionalDirectories?: string[];
logger?: Logger;
processCallbacks?: ProcessSpawnedCallback;
}
export interface CodexAppServerProcessOptions {
/** Path to the native `codex` CLI binary (the one that exposes `app-server`). */
binaryPath: string;
cwd?: string;
apiBaseUrl?: string;
apiKey?: string;
/**
* Private CODEX_HOME for this run (skills + config). Without it codex falls
* back to the user's ~/.codex, whose ambient plugins/MCP servers can stall
* every turn (a broken plugin MCP blocks turn/start for its full timeout).
*/
codexHome?: string;
/** Guidance appended to Codex's base prompt via `developer_instructions`. */
developerInstructions?: string;
/** Extra codex `-c key=value` config overrides (e.g. auto_compact_token_limit). */
configOverrides?: Record<string, string | number>;
logger?: Logger;
processCallbacks?: ProcessSpawnedCallback;
}
export interface CodexAppServerProcess {
process: ChildProcess;
stdin: Writable;
stdout: Readable;
kill: () => void;
}
export function buildAppServerArgs(
options: CodexAppServerProcessOptions,
): string[] {
const args: string[] = ["app-server"];
args.push("-c", "features.remote_models=false");
// Ambient plugins from the user's config.toml inject MCP servers and
// session-start hooks into PostHog sessions (e.g. an unauthenticated plugin
// MCP failing every thread, hooks wedging turns). Threads only get the MCP
// servers PostHog injects, so disable the plugin system outright.
args.push("-c", "features.plugins=false");
// Codex defaults to the OS keychain for CLI auth, MCP OAuth tokens, and its
// secrets encryption key — on macOS that means permission prompts for our
// bundled binary (keychain ACLs are signature-bound, so grants to a user's
// standalone codex don't cover ours and don't stick across releases). Model
// auth is injected via POSTHOG_GATEWAY_API_KEY, so codex's own credential
// stores are unused: keep them on plain files inside the private CODEX_HOME
// and never touch the keychain.
args.push("-c", `cli_auth_credentials_store="file"`);
args.push("-c", `mcp_oauth_credentials_store="file"`);
// OS sandbox gated on platform (= availability): macOS Seatbelt → workspace-write
// (keeps the sandbox engaged so a per-turn readOnly can tighten it and block
// edits); linux/windows have no sandbox launcher and would panic, so
// danger-full-access (the enclosing docker/Modal sandbox isolates instead).
args.push(
"-c",
process.platform === "darwin"
? `sandbox_mode="workspace-write"`
: `sandbox_mode="danger-full-access"`,
);
// Disable the user's ambient ~/.codex MCP servers so the adapter only exposes
// MCP servers PostHog injects per-thread; otherwise codex fails connecting to them.
for (const name of new CodexSettingsManager(
options.cwd ?? process.cwd(),
).getSettings().mcpServerNames) {
// codex's `-c` parser rejects quoted/special key segments; skip such names.
if (!/^[A-Za-z0-9_-]+$/.test(name)) continue;
args.push("-c", `mcp_servers.${name}.enabled=false`);
}
if (options.apiBaseUrl) {
args.push("-c", `model_provider="posthog"`);
args.push("-c", `model_providers.posthog.name="PostHog Gateway"`);
args.push("-c", `model_providers.posthog.base_url="${options.apiBaseUrl}"`);
args.push("-c", `model_providers.posthog.wire_api="responses"`);
args.push(
"-c",
`model_providers.posthog.env_key="POSTHOG_GATEWAY_API_KEY"`,
);
}
// developer_instructions are set per-thread in thread/start (with the host's
// task system prompt), not as a spawn-level global default.
// Numbers/bools go bare; strings are quoted, matching codex's `-c` parser.
for (const [key, value] of Object.entries(options.configOverrides ?? {})) {
args.push(
"-c",
`${key}=${typeof value === "number" ? value : `"${value}"`}`,
);
}
return args;
}
export function spawnCodexAppServerProcess(
options: CodexAppServerProcessOptions,
): CodexAppServerProcess {
const logger =
options.logger ?? new Logger({ debug: true, prefix: "[CodexAppServer]" });
if (!existsSync(options.binaryPath)) {
throw new Error(
`codex binary not found at ${options.binaryPath}. Run "node apps/code/scripts/download-binaries.mjs" to download it.`,
);
}
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
delete env.ELECTRON_NO_ASAR;
if (options.apiKey) {
env.POSTHOG_GATEWAY_API_KEY = options.apiKey;
}
if (options.codexHome) {
env.CODEX_HOME = options.codexHome;
}
env.PATH = `${dirname(options.binaryPath)}${delimiter}${stripElectronNodeShimFromPath(env.PATH) ?? ""}`;
const args = buildAppServerArgs(options);
logger.info("Spawning codex app-server process", {
command: options.binaryPath,
args,
cwd: options.cwd,
});
const child = spawn(options.binaryPath, args, {
cwd: options.cwd,
env,
stdio: ["pipe", "pipe", "pipe"],
detached: process.platform !== "win32",
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
child.stderr?.on("data", (data: Buffer) => {
logger.warn("codex app-server stderr:", data.toString());
});
child.on("error", (err) => {
logger.error("codex app-server process error:", err);
});
child.on("exit", (code, signal) => {
logger.info("codex app-server process exited", { code, signal });
if (child.pid && options.processCallbacks?.onProcessExited) {
options.processCallbacks.onProcessExited(child.pid);
}
});
if (!child.stdin || !child.stdout) {
throw new Error(
"Failed to get stdio streams from codex app-server process",
);
}
if (child.pid && options.processCallbacks?.onProcessSpawned) {
options.processCallbacks.onProcessSpawned({
pid: child.pid,
command: options.binaryPath,
});
}
return {
process: child,
stdin: child.stdin,
stdout: child.stdout,
kill: () => {
logger.info("Killing codex app-server process", { pid: child.pid });
child.stdin?.destroy();
child.stdout?.destroy();
child.stderr?.destroy();
child.kill("SIGTERM");
},
};
}