From 507914829b65ea627ae462b06587ba4ffa731026 Mon Sep 17 00:00:00 2001 From: bft-codebot Date: Fri, 15 May 2026 16:10:42 +0000 Subject: [PATCH] sync(bfmono): fix(workloop): harden chief runtime streaming path (+19 more) (bfmono@7fe71db82) This PR is an automated gambitmono sync of bfmono Gambit packages. - Source: `packages/gambit/` - Core: `packages/gambit/packages/gambit-core/` - bfmono rev: 7fe71db82 Changes: - 7fe71db82 fix(workloop): harden chief runtime streaming path - e3b8496f4 fix(workloop): upgrade chief runtime codex to 0.128 - 426e8dbbf fix(gambit): run nested codex decks from bot root - 5e9f37c1d fix(gambit): pass Codex app-server thread sandbox authority - 8952a1f9b docs(gambit): point policy references at memos vault - 412e36c9f feat(gambit): expose workloop task host service methods - bd65fd7bc fix(gambit): remove task delegation host service methods - 5776f3e98 feat(workloop): route runtime work through host services - af2da504c chore(gambit): cut 1.0.0-rc.2 - 84f610a1d docs(workloop): align Gambit brand hierarchy - ca4aff086 chore(gambit): remove legacy desktop fallbacks - e281e27f1 fix(gambit): hydrate chat transcript from persisted state - 35438f961 chore(gambit): record full precommit verification - e88e4957e docs(gambit): reposition around scenarios and graders - 2d2691076 fix(gambit): reject sandboxed chat runs - 98ab911eb docs(gambit): use canonical graders frontmatter - bb582eeb6 feat(gambit): improve chat event observability - 43e0588ba feat(gambit): stream and control chat turns - 538d0ad1b feat(gambit): add local deck chat repro server - 60078d9f6 fix(workloop): disable Codex websockets in chief runtime Do not edit this repo directly; make changes in bfmono and re-run the sync. --- src/codex_app_server_debug.test.ts | 43 +++++ src/codex_app_server_debug.ts | 20 ++ src/codex_preflight.test.ts | 213 ++++++++++++++++++++- src/codex_preflight.ts | 124 +++++++++++- src/providers/codex.ts | 131 +++++++++++-- src/providers/codex_app_server.test.ts | 25 ++- src/providers/provider_conformance.test.ts | 73 ++++++- 7 files changed, 602 insertions(+), 27 deletions(-) create mode 100644 src/codex_app_server_debug.test.ts diff --git a/src/codex_app_server_debug.test.ts b/src/codex_app_server_debug.test.ts new file mode 100644 index 00000000..779bffc9 --- /dev/null +++ b/src/codex_app_server_debug.test.ts @@ -0,0 +1,43 @@ +import { assertEquals } from "@std/assert"; +import { + codexAppServerStderrDebugDetails, + summarizeCodexAppServerDebugValue, +} from "./codex_app_server_debug.ts"; + +Deno.test("codex app-server stderr debug parses JSON lines before summarizing", () => { + const details = codexAppServerStderrDebugDetails( + JSON.stringify({ + timestamp: "2026-05-15T02:41:48.000Z", + level: "DEBUG", + target: "codex_core::client", + fields: { + message: "starting turn", + authorization: "Bearer secret-token", + prompt: "summarize this private prompt", + }, + }), + ); + + assertEquals(summarizeCodexAppServerDebugValue(details), { + json: { + timestamp: "2026-05-15T02:41:48.000Z", + level: "DEBUG", + target: "codex_core::client", + fields: { + message: "starting turn", + authorization: "", + prompt: "", + }, + }, + }); +}); + +Deno.test("codex app-server stderr debug keeps non-JSON lines opaque", () => { + const details = codexAppServerStderrDebugDetails( + "plain stderr with a token-like value", + ); + + assertEquals(summarizeCodexAppServerDebugValue(details), { + line: "", + }); +}); diff --git a/src/codex_app_server_debug.ts b/src/codex_app_server_debug.ts index 04d23c6b..d0cd91d9 100644 --- a/src/codex_app_server_debug.ts +++ b/src/codex_app_server_debug.ts @@ -2,13 +2,21 @@ const CODEX_APP_SERVER_DEBUG_ENV = "GAMBIT_CODEX_APP_SERVER_DEBUG"; const STRUCTURAL_STRING_KEYS = new Set([ "error", + "file", + "filename", + "level", "method", + "message", + "module_path", "name", "phase", "reason", "role", "server", "status", + "target", + "timestamp", + "time", "tool", "type", ]); @@ -99,4 +107,16 @@ export function summarizeCodexAppServerDebugValue(value: unknown): DebugValue { return summarizeDebugValue(value); } +export function codexAppServerStderrDebugDetails( + line: string, +): Record { + const trimmed = line.trim(); + if (!trimmed) return { line: "" }; + try { + return { json: JSON.parse(trimmed) }; + } catch { + return { line: trimmed }; + } +} + export { CODEX_APP_SERVER_DEBUG_ENV }; diff --git a/src/codex_preflight.test.ts b/src/codex_preflight.test.ts index 72a684d7..58946c42 100644 --- a/src/codex_preflight.test.ts +++ b/src/codex_preflight.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertStringIncludes } from "@std/assert"; +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; import { join } from "@std/path"; import { CODEX_HOST_AUTH_BUNDLE_ENV } from "./codex_auth.ts"; import { @@ -111,6 +111,94 @@ done } }); +Deno.test("codex preflight does not hang when app-server ignores SIGTERM after account read", async () => { + const priorBin = Deno.env.get("GAMBIT_CODEX_BIN"); + const priorBundle = Deno.env.get(CODEX_HOST_AUTH_BUNDLE_ENV); + const root = await Deno.makeTempDir({ + prefix: "codex-preflight-shutdown-", + }); + const fakeCodexPath = join(root, "fake-codex"); + + await Deno.writeTextFile( + fakeCodexPath, + `#!/bin/sh +set -eu +${fakeCodexVersionBlock()} + +extract_id() { + printf '%s\\n' "$1" | sed -n 's/.*"id":"\\([^"]*\\)".*/\\1/p' +} + +mode="" +for arg in "$@"; do + if [ "$arg" = "app-server" ]; then + mode="app-server" + fi +done + +[ "$mode" = "app-server" ] || exit 64 + +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) + id="$(extract_id "$line")" + printf '{"id":"%s","result":{"capabilities":{"experimentalApi":true}}}\\n' "$id" + ;; + *'"method":"initialized"'*) + ;; + *'"method":"account/login/start"'*) + id="$(extract_id "$line")" + printf '{"id":"%s","result":{"account":{"id":"acct-preflight"}}}\\n' "$id" + ;; + *'"method":"account/read"'*) + id="$(extract_id "$line")" + printf '{"id":"%s","result":{"account":{"id":"acct-preflight","planType":"pro"},"requiresOpenaiAuth":false}}\\n' "$id" + ;; + esac +done + +trap '' TERM +while :; do :; done +`, + ); + await Deno.chmod(fakeCodexPath, 0o755); + + Deno.env.set("GAMBIT_CODEX_BIN", fakeCodexPath); + Deno.env.set( + CODEX_HOST_AUTH_BUNDLE_ENV, + JSON.stringify({ + accessToken: "preflight-access-token", + refreshToken: "refresh-token", + idToken: "id-token", + chatgptAccountId: "acct-preflight", + chatgptPlanType: "pro", + lastRefresh: "2026-04-17T00:00:00Z", + }), + ); + + try { + const startedAt = performance.now(); + const status = await readCodexLoginStatus(); + assertEquals(status.codexLoggedIn, true); + assert( + performance.now() - startedAt < 2_000, + "preflight should not wait indefinitely for app-server shutdown", + ); + } finally { + if (priorBin == null) { + Deno.env.delete("GAMBIT_CODEX_BIN"); + } else { + Deno.env.set("GAMBIT_CODEX_BIN", priorBin); + } + if (priorBundle == null) { + Deno.env.delete(CODEX_HOST_AUTH_BUNDLE_ENV); + } else { + Deno.env.set(CODEX_HOST_AUTH_BUNDLE_ENV, priorBundle); + } + await Deno.remove(root, { recursive: true }).catch(() => undefined); + } +}); + Deno.test("codex preflight responds to app-server refresh RPCs before account/read completes", async () => { const priorBin = Deno.env.get("GAMBIT_CODEX_BIN"); const priorBundle = Deno.env.get(CODEX_HOST_AUTH_BUNDLE_ENV); @@ -328,6 +416,129 @@ done } }); +Deno.test("codex preflight does not hang when stale-account host refresh is unresponsive", async () => { + const priorBin = Deno.env.get("GAMBIT_CODEX_BIN"); + const priorBundle = Deno.env.get(CODEX_HOST_AUTH_BUNDLE_ENV); + const priorHostServiceSocket = Deno.env.get(RUNTIME_HOST_SERVICE_SOCKET_ENV); + const priorHostServiceToken = Deno.env.get(RUNTIME_HOST_SERVICE_TOKEN_ENV); + const root = await Deno.makeTempDir({ + prefix: "codex-preflight-host-refresh-timeout-", + }); + const fakeCodexPath = join(root, "fake-codex"); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const listenerAddress = listener.addr as Deno.NetAddr; + const heldConnections: Array = []; + const acceptLoop = (async () => { + for await (const conn of listener) { + heldConnections.push(conn); + } + })().catch(() => undefined); + + await Deno.writeTextFile( + fakeCodexPath, + `#!/bin/sh +set -eu +${fakeCodexVersionBlock()} + +extract_id() { + printf '%s\\n' "$1" | sed -n 's/.*"id":"\\([^"]*\\)".*/\\1/p' +} + +mode="" +for arg in "$@"; do + if [ "$arg" = "app-server" ]; then + mode="app-server" + fi +done + +[ "$mode" = "app-server" ] || exit 64 + +while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) + id="$(extract_id "$line")" + printf '{"id":"%s","result":{"capabilities":{"experimentalApi":true}}}\\n' "$id" + ;; + *'"method":"initialized"'*) + ;; + *'"method":"account/login/start"'*) + id="$(extract_id "$line")" + printf '{"id":"%s","result":{"account":{"id":"acct-preflight"}}}\\n' "$id" + ;; + *'"method":"account/read"'*) + id="$(extract_id "$line")" + printf '{"id":"%s","result":{"account":{"id":"acct-preflight","planType":"pro"},"requiresOpenaiAuth":true}}\\n' "$id" + ;; + esac +done +`, + ); + await Deno.chmod(fakeCodexPath, 0o755); + + Deno.env.set("GAMBIT_CODEX_BIN", fakeCodexPath); + Deno.env.set( + RUNTIME_HOST_SERVICE_SOCKET_ENV, + `tcp://127.0.0.1:${listenerAddress.port}`, + ); + Deno.env.set(RUNTIME_HOST_SERVICE_TOKEN_ENV, "host-service-token"); + Deno.env.set( + CODEX_HOST_AUTH_BUNDLE_ENV, + JSON.stringify({ + accessToken: "preflight-access-token", + refreshToken: "refresh-token", + idToken: "id-token", + chatgptAccountId: "acct-preflight", + chatgptPlanType: "pro", + lastRefresh: "2026-04-17T00:00:00Z", + }), + ); + + try { + const startedAt = performance.now(); + const status = await readCodexLoginStatus(); + assertEquals(status.codexLoggedIn, true); + assertStringIncludes( + status.codexLoginStatus, + "account/read still reports requiresOpenaiAuth", + ); + assert( + performance.now() - startedAt < 2_500, + "preflight should not wait indefinitely for stale-account host refresh", + ); + } finally { + listener.close(); + for (const conn of heldConnections) { + try { + conn.close(); + } catch { + // ignore + } + } + await acceptLoop; + if (priorBin == null) { + Deno.env.delete("GAMBIT_CODEX_BIN"); + } else { + Deno.env.set("GAMBIT_CODEX_BIN", priorBin); + } + if (priorBundle == null) { + Deno.env.delete(CODEX_HOST_AUTH_BUNDLE_ENV); + } else { + Deno.env.set(CODEX_HOST_AUTH_BUNDLE_ENV, priorBundle); + } + if (priorHostServiceSocket == null) { + Deno.env.delete(RUNTIME_HOST_SERVICE_SOCKET_ENV); + } else { + Deno.env.set(RUNTIME_HOST_SERVICE_SOCKET_ENV, priorHostServiceSocket); + } + if (priorHostServiceToken == null) { + Deno.env.delete(RUNTIME_HOST_SERVICE_TOKEN_ENV); + } else { + Deno.env.set(RUNTIME_HOST_SERVICE_TOKEN_ENV, priorHostServiceToken); + } + await Deno.remove(root, { recursive: true }).catch(() => undefined); + } +}); + Deno.test("codex preflight does not mark login ready when account/read requires auth and returns no account id", async () => { const priorBin = Deno.env.get("GAMBIT_CODEX_BIN"); const priorBundle = Deno.env.get(CODEX_HOST_AUTH_BUNDLE_ENV); diff --git a/src/codex_preflight.ts b/src/codex_preflight.ts index badaa649..3b742f94 100644 --- a/src/codex_preflight.ts +++ b/src/codex_preflight.ts @@ -3,7 +3,10 @@ import { refreshCodexChatgptAuthTokens, summarizeCodexAuthBundle, } from "./codex_auth.ts"; -import { logCodexAppServerDebug } from "./codex_app_server_debug.ts"; +import { + codexAppServerStderrDebugDetails, + logCodexAppServerDebug, +} from "./codex_app_server_debug.ts"; import { callRuntimeHostService, CODEX_REFRESH_HOST_SERVICE_METHOD, @@ -14,6 +17,8 @@ import { const CODEX_BIN_ENV = "GAMBIT_CODEX_BIN"; export const MINIMUM_SUPPORTED_CODEX_CLI_VERSION = "0.121.0"; +const APP_SERVER_PREFLIGHT_HOST_REFRESH_TIMEOUT_MS = 1_000; +const APP_SERVER_PREFLIGHT_SHUTDOWN_TIMEOUT_MS = 250; export type CodexLoginStatus = { codexLoggedIn: boolean; @@ -145,9 +150,101 @@ async function refreshCodexPreflightViaHost(input: { reason: string; }): Promise { if (!hasRuntimeHostServiceRefreshConfig()) return null; - return await callRuntimeHostService({ + const refreshPromise = callRuntimeHostService({ method: CODEX_REFRESH_HOST_SERVICE_METHOD, params: input, + }).catch((error) => { + logCodexAppServerDebug("preflight:host_refresh_failed", { + error: error instanceof Error ? error.message : String(error), + reason: input.reason, + }); + return null; + }); + const timeoutPromise = new Promise<"timeout">((resolve) => { + setTimeout( + () => resolve("timeout"), + APP_SERVER_PREFLIGHT_HOST_REFRESH_TIMEOUT_MS, + ); + }); + const result = await Promise.race([refreshPromise, timeoutPromise]); + if (result === "timeout") { + logCodexAppServerDebug("preflight:host_refresh_timeout", { + reason: input.reason, + timeoutMs: APP_SERVER_PREFLIGHT_HOST_REFRESH_TIMEOUT_MS, + }); + return null; + } + return result; +} + +async function settlesWithin( + promise: Promise, + timeoutMs: number, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then(() => true, () => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +async function waitForAppServerPreflightShutdown(input: { + child: Deno.ChildProcess; + stdoutLoop: Promise; + stderrLoop: Promise; + childStatus: Promise; +}): Promise { + const shutdown = () => + Promise.allSettled([ + input.stdoutLoop, + input.stderrLoop, + input.childStatus, + ]); + if ( + await settlesWithin(shutdown(), APP_SERVER_PREFLIGHT_SHUTDOWN_TIMEOUT_MS) + ) { + return; + } + logCodexAppServerDebug("preflight:child_shutdown_timeout", { + signal: "SIGTERM", + timeoutMs: APP_SERVER_PREFLIGHT_SHUTDOWN_TIMEOUT_MS, + }); + try { + input.child.kill("SIGKILL"); + } catch { + // ignore + } + if ( + await settlesWithin(shutdown(), APP_SERVER_PREFLIGHT_SHUTDOWN_TIMEOUT_MS) + ) { + return; + } + logCodexAppServerDebug("preflight:child_shutdown_timeout", { + signal: "SIGKILL", + timeoutMs: APP_SERVER_PREFLIGHT_SHUTDOWN_TIMEOUT_MS, + }); +} + +async function closeAppServerPreflightStdin( + stdinWriter: WritableStreamDefaultWriter, +): Promise { + const closePromise = stdinWriter.close().catch(() => undefined); + if ( + await settlesWithin( + closePromise, + APP_SERVER_PREFLIGHT_SHUTDOWN_TIMEOUT_MS, + ) + ) { + return; + } + logCodexAppServerDebug("preflight:stdin_close_timeout", { + timeoutMs: APP_SERVER_PREFLIGHT_SHUTDOWN_TIMEOUT_MS, }); } @@ -355,15 +452,17 @@ export async function readCodexLoginStatus(): Promise { for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - logCodexAppServerDebug("preflight:stderr", { - line: trimmed, - }); + logCodexAppServerDebug( + "preflight:stderr", + codexAppServerStderrDebugDetails(trimmed), + ); } } if (buffered.trim()) { - logCodexAppServerDebug("preflight:stderr", { - line: buffered.trim(), - }); + logCodexAppServerDebug( + "preflight:stderr", + codexAppServerStderrDebugDetails(buffered), + ); } })(); @@ -443,13 +542,18 @@ export async function readCodexLoginStatus(): Promise { request.reject(new Error("Codex app-server session closed.")); } pending.clear(); - await stdinWriter.close().catch(() => undefined); + await closeAppServerPreflightStdin(stdinWriter); try { child.kill("SIGTERM"); } catch { // ignore } - await Promise.allSettled([stdoutLoop, stderrLoop, childStatus]); + await waitForAppServerPreflightShutdown({ + child, + stdoutLoop, + stderrLoop, + childStatus, + }); if (childClosedError) { childClosedError = null; } diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 77ccaa3a..2c81da4c 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -15,7 +15,10 @@ import type { } from "@bolt-foundry/gambit-core"; import { joinTextParts, loadDeck } from "@bolt-foundry/gambit-core"; import type { CodexChatgptAuthTokens } from "../codex_auth.ts"; -import { logCodexAppServerDebug } from "../codex_app_server_debug.ts"; +import { + codexAppServerStderrDebugDetails, + logCodexAppServerDebug, +} from "../codex_app_server_debug.ts"; import { ensureTempMcpDenoConfigSync } from "../mcp_deno_config.ts"; export const CODEX_PREFIX = "codex-cli/"; @@ -29,7 +32,22 @@ const CODEX_VERBOSITY_ENV = "GAMBIT_CODEX_VERBOSITY"; const CODEX_BIN_ENV = "GAMBIT_CODEX_BIN"; const CODEX_SKIP_SANDBOX_CONFIG_ENV = "GAMBIT_CODEX_SKIP_SANDBOX_CONFIG"; const CODEX_DISABLE_WEBSOCKETS_ENV = "GAMBIT_CODEX_DISABLE_WEBSOCKETS"; +const CODEX_NO_WEBSOCKET_PROVIDER_ID = "openai-no-ws"; const CODEX_APP_SERVER_TIMING_ENV = "GAMBIT_CODEX_APP_SERVER_TIMING"; +const CODEX_APP_SERVER_RUST_LOG_ENV = "RUST_LOG"; +const CODEX_APP_SERVER_LOG_FORMAT_ENV = "LOG_FORMAT"; +const GAMBIT_CODEX_APP_SERVER_RUST_LOG_ENV = "GAMBIT_CODEX_APP_SERVER_RUST_LOG"; +const GAMBIT_CODEX_APP_SERVER_LOG_FORMAT_ENV = + "GAMBIT_CODEX_APP_SERVER_LOG_FORMAT"; +const CODEX_CA_CERTIFICATE_ENV = "CODEX_CA_CERTIFICATE"; +const SSL_CERT_FILE_ENV = "SSL_CERT_FILE"; +const CODEX_APP_SERVER_TRUST_ENV_NAMES = [ + CODEX_CA_CERTIFICATE_ENV, + SSL_CERT_FILE_ENV, + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", +] as const; const CODEX_DANGEROUS_BYPASS_ENV = "GAMBIT_CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX"; const MCP_DENO_BIN_ENV = "GAMBIT_MCP_DENO_BIN"; @@ -275,6 +293,62 @@ function logCodexAppServerTiming( ); } +export function codexAppServerEnvOverridesForTests(): Record { + const env: Record = {}; + const rustLog = Deno.env.get(GAMBIT_CODEX_APP_SERVER_RUST_LOG_ENV)?.trim(); + if (rustLog) { + env[CODEX_APP_SERVER_RUST_LOG_ENV] = rustLog; + } + const logFormat = Deno.env.get(GAMBIT_CODEX_APP_SERVER_LOG_FORMAT_ENV) + ?.trim(); + if (logFormat) { + env[CODEX_APP_SERVER_LOG_FORMAT_ENV] = logFormat; + } + for (const envName of CODEX_APP_SERVER_TRUST_ENV_NAMES) { + const value = Deno.env.get(envName)?.trim(); + if (value) { + env[envName] = value; + } + } + if (!env[CODEX_CA_CERTIFICATE_ENV] && env[SSL_CERT_FILE_ENV]) { + env[CODEX_CA_CERTIFICATE_ENV] = env[SSL_CERT_FILE_ENV]; + } + return env; +} + +async function codexAppServerTrustEnvDetails( + env: Record, +): Promise> { + const selectedCaPath = env[CODEX_CA_CERTIFICATE_ENV] ?? + env[SSL_CERT_FILE_ENV] ?? + null; + const details: Record = { + envNames: CODEX_APP_SERVER_TRUST_ENV_NAMES.filter((envName) => + Boolean(env[envName]) + ), + selectedCaEnv: env[CODEX_CA_CERTIFICATE_ENV] + ? CODEX_CA_CERTIFICATE_ENV + : env[SSL_CERT_FILE_ENV] + ? SSL_CERT_FILE_ENV + : null, + selectedCaPath, + }; + if (!selectedCaPath) { + return details; + } + try { + const stat = await Deno.stat(selectedCaPath); + details.selectedCaReadable = stat.isFile; + details.selectedCaSize = stat.size; + } catch (error) { + details.selectedCaReadable = false; + details.selectedCaError = error instanceof Error + ? error.message + : String(error); + } + return details; +} + function shouldDangerouslyBypassCodexApprovalsAndSandbox( params?: Record, ): boolean { @@ -307,12 +381,30 @@ function shouldSkipCodexSandboxConfig( } function shouldDisableCodexWebsockets(): boolean { - // Newer Codex releases reserve built-in provider IDs, so overriding - // `model_providers.openai.supports_websockets` now prevents app-server - // startup. Keep the env var harmless while the old transport workaround ages - // out. - Deno.env.get(CODEX_DISABLE_WEBSOCKETS_ENV); - return false; + const raw = Deno.env.get(CODEX_DISABLE_WEBSOCKETS_ENV); + return Boolean(raw && parseTruthy(raw)); +} + +function codexNoWebsocketProviderConfigArgs(): Array { + const providerKey = `model_providers.${ + tomlKeySegment(CODEX_NO_WEBSOCKET_PROVIDER_ID) + }`; + return [ + "-c", + `model_provider=${tomlString(CODEX_NO_WEBSOCKET_PROVIDER_ID)}`, + "-c", + `${providerKey}.name=${tomlString("OpenAI")}`, + "-c", + `${providerKey}.wire_api=${tomlString("responses")}`, + "-c", + `${providerKey}.requires_openai_auth=true`, + "-c", + `${providerKey}.supports_websockets=false`, + ]; +} + +function codexThreadModelProviderOverride(): string | null { + return shouldDisableCodexWebsockets() ? CODEX_NO_WEBSOCKET_PROVIDER_ID : null; } function tomlString(value: string): string { @@ -423,7 +515,7 @@ function codexConfigArgs(input: { const args: Array = []; args.push(...codexAdditionalConfigArgs(input.params)); if (shouldDisableCodexWebsockets()) { - args.push("-c", "model_providers.openai.supports_websockets=false"); + args.push(...codexNoWebsocketProviderConfigArgs()); } args.push("-c", `approval_policy=${tomlString("never")}`); const pathEnv = Deno.env.get("PATH")?.trim(); @@ -1184,9 +1276,15 @@ async function defaultAppServerTurnRunner( skipSandboxConfig, }); const spawnStartedAt = performance.now(); + const appServerEnv = codexAppServerEnvOverridesForTests(); + logCodexAppServerTiming( + "spawn:trust_env", + await codexAppServerTrustEnvDetails(appServerEnv), + ); const child = new Deno.Command(codexBin, { args: spawnArgs, cwd: input.cwd, + env: appServerEnv, stdin: "piped", stdout: "piped", stderr: "piped", @@ -1549,15 +1647,17 @@ async function defaultAppServerTurnRunner( for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - logCodexAppServerDebug("stderr", { - line: trimmed, - }); + logCodexAppServerDebug( + "stderr", + codexAppServerStderrDebugDetails(trimmed), + ); } } if (buffered.trim()) { - logCodexAppServerDebug("stderr", { - line: buffered.trim(), - }); + logCodexAppServerDebug( + "stderr", + codexAppServerStderrDebugDetails(buffered), + ); } })(); @@ -1591,6 +1691,7 @@ async function defaultAppServerTurnRunner( }); const model = normalizeCodexModel(input.model); + const modelProvider = codexThreadModelProviderOverride(); const threadOperation = input.priorThreadId ? "thread/resume" : "thread/start"; @@ -1600,6 +1701,7 @@ async function defaultAppServerTurnRunner( threadId: input.priorThreadId, model: model && model !== "default" ? model : null, cwd: input.cwd, + ...(modelProvider ? { modelProvider } : {}), approvalPolicy: "never", sandbox: appServerSandboxMode({ cwd: input.cwd, @@ -1610,6 +1712,7 @@ async function defaultAppServerTurnRunner( : await request("thread/start", { model: model && model !== "default" ? model : null, cwd: input.cwd, + ...(modelProvider ? { modelProvider } : {}), approvalPolicy: "never", sandbox: appServerSandboxMode({ cwd: input.cwd, diff --git a/src/providers/codex_app_server.test.ts b/src/providers/codex_app_server.test.ts index f300bef5..626fa5c0 100644 --- a/src/providers/codex_app_server.test.ts +++ b/src/providers/codex_app_server.test.ts @@ -35,7 +35,7 @@ Deno.test("codex app-server refresh host failures are returned as RPC errors", a } }); -Deno.test("codex websocket disable env does not override built-in OpenAI provider", () => { +Deno.test("codex websocket disable env uses a custom OpenAI provider", () => { const previous = Deno.env.get("GAMBIT_CODEX_DISABLE_WEBSOCKETS"); Deno.env.set("GAMBIT_CODEX_DISABLE_WEBSOCKETS", "1"); @@ -46,6 +46,29 @@ Deno.test("codex websocket disable env does not override built-in OpenAI provide args.includes("model_providers.openai.supports_websockets=false"), false, ); + assertEquals(args.includes('model_provider="openai-no-ws"'), true); + assertEquals( + args.includes('model_providers.openai-no-ws.name="OpenAI"'), + true, + ); + assertEquals( + args.includes( + 'model_providers.openai-no-ws.wire_api="responses"', + ), + true, + ); + assertEquals( + args.includes( + "model_providers.openai-no-ws.requires_openai_auth=true", + ), + true, + ); + assertEquals( + args.includes( + "model_providers.openai-no-ws.supports_websockets=false", + ), + true, + ); } finally { if (previous === undefined) { Deno.env.delete("GAMBIT_CODEX_DISABLE_WEBSOCKETS"); diff --git a/src/providers/provider_conformance.test.ts b/src/providers/provider_conformance.test.ts index 769900f2..bd341ad0 100644 --- a/src/providers/provider_conformance.test.ts +++ b/src/providers/provider_conformance.test.ts @@ -1,10 +1,52 @@ import { assertEquals } from "@std/assert"; import type OpenAI from "@openai/openai"; -import { createCodexProvider } from "./codex.ts"; +import { + codexAppServerEnvOverridesForTests, + createCodexProvider, +} from "./codex.ts"; import { createGoogleProvider } from "./google.ts"; import { createOllamaProvider } from "./ollama.ts"; import { createOpenRouterProvider } from "./openrouter.ts"; +const CODEX_APP_SERVER_ENV_TEST_NAMES = [ + "GAMBIT_CODEX_APP_SERVER_RUST_LOG", + "GAMBIT_CODEX_APP_SERVER_LOG_FORMAT", + "CODEX_CA_CERTIFICATE", + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", +] as const; + +function withEnv( + updates: Partial< + Record + >, + run: () => void, +): void { + const previous = new Map(); + for (const name of CODEX_APP_SERVER_ENV_TEST_NAMES) { + previous.set(name, Deno.env.get(name)); + Deno.env.delete(name); + } + try { + for (const [name, value] of Object.entries(updates)) { + if (value !== undefined) { + Deno.env.set(name, value); + } + } + run(); + } finally { + for (const [name, value] of previous) { + if (value === undefined) { + Deno.env.delete(name); + } else { + Deno.env.set(name, value); + } + } + } +} + function buildResponseFixture(model: string): OpenAI.Responses.Response { return { id: "resp_1", @@ -184,3 +226,32 @@ Deno.test("provider conformance: codex responses forwards abort signal", async ( assertEquals(seenSignal, controller.signal); }); + +Deno.test("provider conformance: codex app-server env maps targeted tracing env to upstream names", () => { + withEnv({ + GAMBIT_CODEX_APP_SERVER_RUST_LOG: "codex_app_server=debug,codex_core=debug", + GAMBIT_CODEX_APP_SERVER_LOG_FORMAT: "json", + }, () => { + assertEquals(codexAppServerEnvOverridesForTests(), { + RUST_LOG: "codex_app_server=debug,codex_core=debug", + LOG_FORMAT: "json", + }); + }); +}); + +Deno.test("provider conformance: codex app-server env forwards runtime CA trust env", () => { + withEnv({ + SSL_CERT_FILE: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + CURL_CA_BUNDLE: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + REQUESTS_CA_BUNDLE: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + NODE_EXTRA_CA_CERTS: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + }, () => { + assertEquals(codexAppServerEnvOverridesForTests(), { + CODEX_CA_CERTIFICATE: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + SSL_CERT_FILE: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + CURL_CA_BUNDLE: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + REQUESTS_CA_BUNDLE: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + NODE_EXTRA_CA_CERTS: "/runtime/trust/workloop-cleanroom-egress-ca.pem", + }); + }); +});