| ... | @@ -29,6 +29,30 @@ import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; | ... | @@ -29,6 +29,30 @@ import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; |
| 29 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 29 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 30 | | 30 | |
| 31 | const CODEX_TIMEOUT_MS = 180_000; | 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 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | 56 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 33 | /** | 57 | /** |
| 34 | * Build a Codex text-generation closure bound to a specific `CodexSettings` | 58 | * Build a Codex text-generation closure bound to a specific `CodexSettings` |
| ... | @@ -143,6 +167,67 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -143,6 +167,67 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 143 | return imagePaths; | 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 | const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({ | 231 | const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({ |
| 147 | operation, | 232 | operation, |
| 148 | cwd, | 233 | cwd, |
| ... | @@ -189,11 +274,36 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -189,11 +274,36 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 189 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? | 274 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? |
| 190 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT; | 275 | DEFAULT_TEXT_GENERATION_REASONING_EFFORT; |
| 191 | const serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection); | 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 | const spawnCommand = yield* resolveSpawnCommand( | 297 | const spawnCommand = yield* resolveSpawnCommand( |
| 193 | effectiveConfig.binaryPath || "codex", | 298 | binary, |
| 194 | [ | 299 | [ |
| 195 | "exec", | 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 | "--ephemeral", | 307 | "--ephemeral", |
| 198 | "--skip-git-repo-check", | 308 | "--skip-git-repo-check", |
| 199 | "-s", | 309 | "-s", |
| ... | @@ -213,17 +323,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | ... | @@ -213,17 +323,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func |
| 213 | { env: effectiveEnvironment }, | 323 | { env: effectiveEnvironment }, |
| 214 | ); | 324 | ); |
| 215 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { | 325 | const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 216 | env: { | 326 | env, |
| 217 | ...effectiveEnvironment, | | |
| 218 | ...(effectiveConfig.homePath | | |
| 219 | ? { | | |
| 220 | CODEX_HOME: expandHomePath( | | |
| 221 | effectiveConfig.homePath, | | |
| 222 | yield* HostProcess.HomeDirectory, | | |
| 223 | ), | | |
| 224 | } | | |
| 225 | : {}), | | |
| 226 | }, | | |
| 227 | cwd, | 327 | cwd, |
| 228 | shell: spawnCommand.shell, | 328 | shell: spawnCommand.shell, |
| 229 | stdin: { | 329 | stdin: { |