| ... | ... | @@ -29,6 +29,30 @@ import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; |
| 29 | 29 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 30 | 30 | |
| 31 | 31 | const CODEX_TIMEOUT_MS = 180_000; |
| 32 | const CODEX_MCP_LIST_TIMEOUT_MS = 10_000; |
| 33 | // Plugins and apps bring MCP servers of their own; the user's are listed and |
| 34 | // turned off by name below. These follow the user's launch args, so they win |
| 35 | // over a `-c features.plugins=true` there, which `--disable plugins` does not. |
| 36 | const CODEX_NO_PLUGIN_ARGS = [ |
| 37 | "--config", |
| 38 | "features.plugins=false", |
| 39 | "--config", |
| 40 | "features.apps=false", |
| 41 | ] as const; |
| 42 | const CodexMcpServers = Schema.fromJsonString( |
| 43 | Schema.Array(Schema.Struct({ name: Schema.String, enabled: Schema.Boolean })), |
| 44 | ); |
| 45 | const decodeCodexMcpServers = Schema.decodeEffect(CodexMcpServers); |
| 46 | |
| 47 | /** |
| 48 | * The `--config` value that turns off each MCP server Codex would start. |
| 49 | * `mcp_servers={}` merges into the user's servers rather than replacing them, |
| 50 | * so each is named; quoting the name keeps one with a dot a single key. |
| 51 | */ |
| 52 | const codexMcpServersOff = (names: ReadonlyArray<string>): string | null => |
| 53 | names.length === 0 |
| 54 | ? null |
| 55 | : `mcp_servers={${names.map((name) => `${JSON.stringify(name)}={enabled=false}`).join(",")}}`; |
| 32 | 56 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 33 | 57 | /** |
| 34 | 58 | * Build a Codex text-generation closure bound to a specific `CodexSettings` |
| ... | ... | @@ -143,6 +167,67 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 143 | 167 | return imagePaths; |
| 144 | 168 | }); |
| 145 | 169 | |
| 170 | /** |
| 171 | * The MCP servers Codex would start for this configuration, by name. Text |
| 172 | * generation goes ahead with them listed as none if Codex cannot list them. |
| 173 | */ |
| 174 | const listEnabledMcpServers = Effect.fn("listEnabledMcpServers")( |
| 175 | function* (input: { |
| 176 | readonly binary: string; |
| 177 | readonly cwd: string; |
| 178 | readonly env: NodeJS.ProcessEnv; |
| 179 | readonly launchArgs: ReadonlyArray<string>; |
| 180 | }) { |
| 181 | const spawnCommand = yield* resolveSpawnCommand( |
| 182 | input.binary, |
| 183 | [...input.launchArgs, ...CODEX_NO_PLUGIN_ARGS, "mcp", "list", "--json"], |
| 184 | { env: input.env }, |
| 185 | ); |
| 186 | const child = yield* commandSpawner.spawn( |
| 187 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 188 | env: input.env, |
| 189 | cwd: input.cwd, |
| 190 | shell: spawnCommand.shell, |
| 191 | }), |
| 192 | ); |
| 193 | const [stdout, , exitCode] = yield* Effect.all( |
| 194 | [ |
| 195 | child.stdout.pipe(Stream.decodeText(), Stream.mkString), |
| 196 | child.stderr.pipe(Stream.runDrain), |
| 197 | child.exitCode, |
| 198 | ], |
| 199 | { concurrency: "unbounded" }, |
| 200 | ); |
| 201 | if (exitCode !== 0) { |
| 202 | yield* Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", { |
| 203 | reason: "exit", |
| 204 | exitCode, |
| 205 | }); |
| 206 | return []; |
| 207 | } |
| 208 | const servers = yield* decodeCodexMcpServers(stdout).pipe( |
| 209 | Effect.tapError(() => |
| 210 | Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", { |
| 211 | reason: "decode", |
| 212 | }), |
| 213 | ), |
| 214 | ); |
| 215 | return servers.filter((server) => server.enabled).map((server) => server.name); |
| 216 | }, |
| 217 | Effect.scoped, |
| 218 | Effect.timeoutOption(CODEX_MCP_LIST_TIMEOUT_MS), |
| 219 | Effect.flatMap( |
| 220 | Option.match({ |
| 221 | onNone: () => |
| 222 | Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", { |
| 223 | reason: "timeout", |
| 224 | }).pipe(Effect.as<ReadonlyArray<string>>([])), |
| 225 | onSome: (names) => Effect.succeed(names), |
| 226 | }), |
| 227 | ), |
| 228 | Effect.orElseSucceed((): ReadonlyArray<string> => []), |
| 229 | ); |
| 230 | |
| 146 | 231 | const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({ |
| 147 | 232 | operation, |
| 148 | 233 | cwd, |
| ... | ... | @@ -189,11 +274,36 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 189 | 274 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? |
| 190 | 275 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT; |
| 191 | 276 | const serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection); |
| 277 | const binary = effectiveConfig.binaryPath || "codex"; |
| 278 | const env = { |
| 279 | ...effectiveEnvironment, |
| 280 | ...(effectiveConfig.homePath |
| 281 | ? { |
| 282 | CODEX_HOME: expandHomePath( |
| 283 | effectiveConfig.homePath, |
| 284 | yield* HostProcess.HomeDirectory, |
| 285 | ), |
| 286 | } |
| 287 | : {}), |
| 288 | }; |
| 289 | const execLaunchArgs = codexExecLaunchArgs(launchArgs); |
| 290 | const mcpServersOff = yield* listEnabledMcpServers({ |
| 291 | binary, |
| 292 | cwd, |
| 293 | env, |
| 294 | // `codex mcp` rejects --strict-config; the overrides still apply. |
| 295 | launchArgs: execLaunchArgs.filter((arg) => arg !== "--strict-config"), |
| 296 | }).pipe(Effect.map(codexMcpServersOff)); |
| 192 | 297 | const spawnCommand = yield* resolveSpawnCommand( |
| 193 | | effectiveConfig.binaryPath || "codex", |
| 298 | binary, |
| 194 | 299 | [ |
| 195 | 300 | "exec", |
| 196 | | ...codexExecLaunchArgs(launchArgs), |
| 301 | ...execLaunchArgs, |
| 302 | // Text generation needs only the prompt. A user's MCP server would |
| 303 | // otherwise start and could act on it, as Claude's --strict-mcp-config |
| 304 | // prevents for Claude. |
| 305 | ...CODEX_NO_PLUGIN_ARGS, |
| 306 | ...(mcpServersOff ? ["--config", mcpServersOff] : []), |
| 197 | 307 | "--ephemeral", |
| 198 | 308 | "--skip-git-repo-check", |
| 199 | 309 | "-s", |
| ... | ... | @@ -213,17 +323,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 213 | 323 | { env: effectiveEnvironment }, |
| 214 | 324 | ); |
| 215 | 325 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 216 | | env: { |
| 217 | | ...effectiveEnvironment, |
| 218 | | ...(effectiveConfig.homePath |
| 219 | | ? { |
| 220 | | CODEX_HOME: expandHomePath( |
| 221 | | effectiveConfig.homePath, |
| 222 | | yield* HostProcess.HomeDirectory, |
| 223 | | ), |
| 224 | | } |
| 225 | | : {}), |
| 226 | | }, |
| 326 | env, |
| 227 | 327 | cwd, |
| 228 | 328 | shell: spawnCommand.shell, |
| 229 | 329 | stdin: { |