diff --git a/extensions/codex/src/app-server/config-runtime.ts b/extensions/codex/src/app-server/config-runtime.ts index f48d411a758b..7877d90cdead 100644 --- a/extensions/codex/src/app-server/config-runtime.ts +++ b/extensions/codex/src/app-server/config-runtime.ts @@ -58,7 +58,6 @@ import { readNumberEnv, resolveArgs, } from "./config-utils.js"; -import { isForcedPrivateQaCodexRuntime } from "./dynamic-tool-profile.js"; import type { CodexSandboxPolicy } from "./protocol.js"; export function resolveCodexAppServerRuntimeOptions( @@ -191,17 +190,11 @@ export function resolveCodexAppServerRuntimeOptions( ? normalizedPolicyMode : (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo"); const serviceTier = normalizeCodexServiceTier(config.serviceTier); - const configuredRuntimeSandbox = + const resolvedSandbox = forcedPolicy?.sandbox ?? configuredSandbox ?? defaultPolicy?.sandbox ?? (policyMode === "guardian" ? "workspace-write" : "danger-full-access"); - // Private QA may bound production yolo, but must never widen a configured - // read-only sandbox or override the ordinary policy precedence. - const resolvedSandbox = - isForcedPrivateQaCodexRuntime(env) && configuredRuntimeSandbox === "danger-full-access" - ? "workspace-write" - : configuredRuntimeSandbox; if (transport === "websocket" && !url) { throw new Error( "plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket", @@ -497,7 +490,7 @@ export function codexAppServerStartOptionsKey( export function codexSandboxPolicyForTurn( mode: CodexAppServerSandboxMode, cwd: string, - env: NodeJS.ProcessEnv = process.env, + nativeArgs: readonly string[] = [], ): CodexSandboxPolicy { if (mode === "danger-full-access") { return { type: "dangerFullAccess" }; @@ -505,15 +498,42 @@ export function codexSandboxPolicyForTurn( if (mode === "read-only") { return { type: "readOnly", networkAccess: false }; } - // Codex includes /tmp and TMPDIR in workspace-write by default. Private QA - // workspaces live there, so retaining either root defeats sibling containment. - const excludePrivateQaTempRoots = isForcedPrivateQaCodexRuntime(env); + let excludeTmpdirEnvVar = false; + let excludeSlashTmp = false; + for (let index = 0; index < nativeArgs.length; index += 1) { + const arg = nativeArgs[index]; + const override = + arg === "-c" || arg === "--config" + ? nativeArgs[++index] + : arg?.startsWith("--config=") + ? arg.slice("--config=".length) + : undefined; + if (!override) { + continue; + } + const separator = override.indexOf("="); + if (separator < 0) { + continue; + } + const key = override.slice(0, separator).trim(); + const value = override.slice(separator + 1).trim(); + if (value !== "true" && value !== "false") { + continue; + } + if (key === "sandbox_workspace_write.exclude_tmpdir_env_var") { + excludeTmpdirEnvVar = value === "true"; + } else if (key === "sandbox_workspace_write.exclude_slash_tmp") { + excludeSlashTmp = value === "true"; + } + } + // Native turn/start overrides replace the thread's sandbox. Carry explicit + // Codex CLI root exclusions forward or /tmp silently becomes writable again. return { type: "workspaceWrite", writableRoots: [cwd], networkAccess: false, - excludeTmpdirEnvVar: excludePrivateQaTempRoots, - excludeSlashTmp: excludePrivateQaTempRoots, + excludeTmpdirEnvVar, + excludeSlashTmp, }; } diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 7bc0a82d5f1d..8945c1246744 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -550,7 +550,7 @@ describe("Codex app-server config", () => { }); }); - it("confines only explicitly forced private-QA Codex runtime to workspace writes", () => { + it("does not let private-QA environment flags override native sandbox policy", () => { const privateQaCodexEnv = { OPENCLAW_BUILD_PRIVATE_QA: "1", OPENCLAW_QA_FORCE_RUNTIME: "codex", @@ -566,18 +566,48 @@ describe("Codex app-server config", () => { env: privateQaCodexEnv, }); + expectRuntimePolicy(runtime, { + approvalPolicy: "never", + sandbox: "danger-full-access", + approvalsReviewer: "user", + }); + expect(codexSandboxPolicyForTurn(runtime.sandbox, "/qa/workspace", runtime.start.args)).toEqual( + { + type: "dangerFullAccess", + }, + ); + }); + + it("honors explicitly configured native workspace temporary-root exclusions", () => { + const runtime = resolveRuntimeForTest({ + pluginConfig: { + appServer: { + sandbox: "workspace-write", + args: [ + "app-server", + "-c", + "sandbox_workspace_write.exclude_tmpdir_env_var=true", + "-c", + "sandbox_workspace_write.exclude_slash_tmp=true", + ], + }, + }, + }); + expectRuntimePolicy(runtime, { approvalPolicy: "never", sandbox: "workspace-write", approvalsReviewer: "user", }); - expect(codexSandboxPolicyForTurn(runtime.sandbox, "/qa/workspace", privateQaCodexEnv)).toEqual({ - type: "workspaceWrite", - writableRoots: ["/qa/workspace"], - networkAccess: false, - excludeTmpdirEnvVar: true, - excludeSlashTmp: true, - }); + expect(codexSandboxPolicyForTurn(runtime.sandbox, "/qa/workspace", runtime.start.args)).toEqual( + { + type: "workspaceWrite", + writableRoots: ["/qa/workspace"], + networkAccess: false, + excludeTmpdirEnvVar: true, + excludeSlashTmp: true, + }, + ); }); it("preserves an explicitly read-only sandbox for forced private-QA Codex runtime", () => { @@ -601,10 +631,12 @@ describe("Codex app-server config", () => { sandbox: "read-only", approvalsReviewer: "user", }); - expect(codexSandboxPolicyForTurn(runtime.sandbox, "/qa/workspace", privateQaCodexEnv)).toEqual({ - type: "readOnly", - networkAccess: false, - }); + expect(codexSandboxPolicyForTurn(runtime.sandbox, "/qa/workspace", runtime.start.args)).toEqual( + { + type: "readOnly", + networkAccess: false, + }, + ); }); it.each([ @@ -614,6 +646,10 @@ describe("Codex app-server config", () => { label: "forced runtime without a private build", env: { OPENCLAW_QA_FORCE_RUNTIME: "codex" }, }, + { + label: "forced private-QA Codex runtime without explicit sandbox configuration", + env: { OPENCLAW_BUILD_PRIVATE_QA: "1", OPENCLAW_QA_FORCE_RUNTIME: "codex" }, + }, { label: "forced private-QA OpenClaw runtime", env: { OPENCLAW_BUILD_PRIVATE_QA: "1", OPENCLAW_QA_FORCE_RUNTIME: "openclaw" }, @@ -626,7 +662,9 @@ describe("Codex app-server config", () => { sandbox: "danger-full-access", approvalsReviewer: "user", }); - expect(codexSandboxPolicyForTurn("workspace-write", "/qa/workspace", env)).toEqual({ + expect( + codexSandboxPolicyForTurn("workspace-write", "/qa/workspace", runtime.start.args), + ).toEqual({ type: "workspaceWrite", writableRoots: ["/qa/workspace"], networkAccess: false, @@ -635,6 +673,53 @@ describe("Codex app-server config", () => { }); }); + it.each([ + { + label: "long config arguments", + args: [ + "--config", + "sandbox_workspace_write.exclude_tmpdir_env_var=true", + "--config", + "sandbox_workspace_write.exclude_slash_tmp=true", + ], + excludeTmpdirEnvVar: true, + excludeSlashTmp: true, + }, + { + label: "inline config arguments", + args: [ + "--config=sandbox_workspace_write.exclude_tmpdir_env_var=true", + "--config=sandbox_workspace_write.exclude_slash_tmp=true", + ], + excludeTmpdirEnvVar: true, + excludeSlashTmp: true, + }, + { + label: "the last native config override", + args: [ + "-c", + "sandbox_workspace_write.exclude_tmpdir_env_var=true", + "-c", + "sandbox_workspace_write.exclude_tmpdir_env_var=false", + "-c", + "sandbox_workspace_write.exclude_slash_tmp=true", + ], + excludeTmpdirEnvVar: false, + excludeSlashTmp: true, + }, + ])( + "preserves native workspace root policy from $label", + ({ args, excludeTmpdirEnvVar, excludeSlashTmp }) => { + expect(codexSandboxPolicyForTurn("workspace-write", "/qa/workspace", args)).toEqual({ + type: "workspaceWrite", + writableRoots: ["/qa/workspace"], + networkAccess: false, + excludeTmpdirEnvVar, + excludeSlashTmp, + }); + }, + ); + it("does not change ordinary harness connection defaults when supervision is enabled", () => { const runtime = resolveRuntimeForTest({ pluginConfig: { supervision: { enabled: true } }, diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index f5a29a74e242..55e8280db74c 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -1105,26 +1105,31 @@ describe("Codex app-server native code mode config", () => { }); describe("Codex app-server turn input image sanitizing", () => { - it("excludes implicit temporary writable roots from forced private-QA Codex turns", () => { - vi.stubEnv("OPENCLAW_BUILD_PRIVATE_QA", "1"); - vi.stubEnv("OPENCLAW_QA_FORCE_RUNTIME", "codex"); - try { - const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), { - threadId: "thread-1", - cwd: "/tmp/qa/workspace", - appServer: createAppServerOptions() as never, - }); + it("carries native workspace temporary-root overrides into turn policy", () => { + const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), { + threadId: "thread-1", + cwd: "/tmp/qa/workspace", + appServer: { + ...createAppServerOptions(), + start: { + args: [ + "app-server", + "-c", + "sandbox_workspace_write.exclude_tmpdir_env_var=true", + "-c", + "sandbox_workspace_write.exclude_slash_tmp=true", + ], + }, + } as never, + }); - expect(request.sandboxPolicy).toEqual({ - type: "workspaceWrite", - writableRoots: ["/tmp/qa/workspace"], - networkAccess: false, - excludeTmpdirEnvVar: true, - excludeSlashTmp: true, - }); - } finally { - vi.unstubAllEnvs(); - } + expect(request.sandboxPolicy).toEqual({ + type: "workspaceWrite", + writableRoots: ["/tmp/qa/workspace"], + networkAccess: false, + excludeTmpdirEnvVar: true, + excludeSlashTmp: true, + }); }); it("preserves implicit temporary writable roots for ordinary Codex turns", () => { diff --git a/extensions/codex/src/app-server/turn-params.ts b/extensions/codex/src/app-server/turn-params.ts index b0504350e710..a9da28bbfef7 100644 --- a/extensions/codex/src/app-server/turn-params.ts +++ b/extensions/codex/src/app-server/turn-params.ts @@ -53,7 +53,11 @@ export function buildTurnStartParams( : { sandboxPolicy: options.sandboxPolicy ?? - codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd), + codexSandboxPolicyForTurn( + options.appServer.sandbox, + options.cwd, + options.appServer.start?.args, + ), }), ...(modelSelection ? { model: modelSelection.model, personality: CODEX_NATIVE_PERSONALITY_NONE } diff --git a/extensions/qa-lab/src/codex-app-server-args.test.ts b/extensions/qa-lab/src/codex-app-server-args.test.ts new file mode 100644 index 000000000000..d066f7f2b5fd --- /dev/null +++ b/extensions/qa-lab/src/codex-app-server-args.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { buildQaCodexAppServerArgs } from "./codex-app-server-args.js"; + +describe("native Codex QA app-server arguments", () => { + it("keeps live Codex QA workspaces confined without overriding provider routing", () => { + expect(buildQaCodexAppServerArgs()).toBe( + "app-server -c sandbox_workspace_write.exclude_tmpdir_env_var=true " + + "-c sandbox_workspace_write.exclude_slash_tmp=true --listen stdio://", + ); + }); + + it("preserves existing live-provider, transport, and model-catalog arguments", () => { + expect( + buildQaCodexAppServerArgs({ + existingArgs: + 'app-server -c openai_base_url="https://live.example/v1" ' + + '-c "model_catalog_json=/tmp/live models.json" --listen stdio://', + }), + ).toBe( + 'app-server -c openai_base_url="https://live.example/v1" ' + + '-c "model_catalog_json=/tmp/live models.json" --listen stdio:// ' + + "-c sandbox_workspace_write.exclude_tmpdir_env_var=true " + + "-c sandbox_workspace_write.exclude_slash_tmp=true", + ); + }); + + it("makes containment override a weaker preconfigured native sandbox", () => { + expect( + buildQaCodexAppServerArgs({ + existingArgs: + "app-server -c sandbox_workspace_write.exclude_slash_tmp=false --listen stdio://", + }), + ).toBe( + "app-server -c sandbox_workspace_write.exclude_slash_tmp=false --listen stdio:// " + + "-c sandbox_workspace_write.exclude_tmpdir_env_var=true " + + "-c sandbox_workspace_write.exclude_slash_tmp=true", + ); + }); + + it("owns native provider routing and workspace containment in the QA launcher", () => { + expect(buildQaCodexAppServerArgs({ providerBaseUrl: "http://127.0.0.1:44080/v1/" })).toBe( + "app-server -c openai_base_url=http://127.0.0.1:44080/v1 " + + "-c sandbox_workspace_write.exclude_tmpdir_env_var=true " + + "-c sandbox_workspace_write.exclude_slash_tmp=true --listen stdio://", + ); + }); + + it("preserves quoted native model-catalog paths", () => { + expect( + buildQaCodexAppServerArgs({ + providerBaseUrl: "http://127.0.0.1:44080/v1", + modelCatalogPath: "/tmp/qa catalog/models.json", + }), + ).toContain('-c "model_catalog_json=/tmp/qa catalog/models.json"'); + }); + + it("rejects a missing managed provider URL", () => { + expect(() => buildQaCodexAppServerArgs({ providerBaseUrl: " " })).toThrow( + "forced Codex mock QA requires the managed mock provider URL", + ); + }); +}); diff --git a/extensions/qa-lab/src/codex-app-server-args.ts b/extensions/qa-lab/src/codex-app-server-args.ts new file mode 100644 index 000000000000..c83f00080a18 --- /dev/null +++ b/extensions/qa-lab/src/codex-app-server-args.ts @@ -0,0 +1,38 @@ +/** Keeps native Codex workspace containment in every QA-owned runtime. */ +export function buildQaCodexAppServerArgs( + params: { + providerBaseUrl?: string; + modelCatalogPath?: string; + existingArgs?: string; + } = {}, +): string { + const providerBaseUrl = params.providerBaseUrl?.trim().replace(/\/+$/u, ""); + if (params.providerBaseUrl !== undefined && !providerBaseUrl) { + throw new Error("forced Codex mock QA requires the managed mock provider URL"); + } + const existingArgs = params.existingArgs?.trim(); + if (existingArgs && !providerBaseUrl) { + // Preserve live provider, feature, and transport flags. Native Codex + // applies repeated overrides in order, so QA containment remains final. + return [ + existingArgs, + "-c", + "sandbox_workspace_write.exclude_tmpdir_env_var=true", + "-c", + "sandbox_workspace_write.exclude_slash_tmp=true", + ].join(" "); + } + return [ + "app-server", + ...(providerBaseUrl ? ["-c", `openai_base_url=${providerBaseUrl}`] : []), + ...(params.modelCatalogPath + ? ["-c", JSON.stringify(`model_catalog_json=${params.modelCatalogPath}`)] + : []), + "-c", + "sandbox_workspace_write.exclude_tmpdir_env_var=true", + "-c", + "sandbox_workspace_write.exclude_slash_tmp=true", + "--listen", + "stdio://", + ].join(" "); +} diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 55ba640ab222..b2bac1d7e87b 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -264,7 +264,7 @@ describe("Gateway child fixture helpers", () => { }), ).toEqual( expect.objectContaining({ - OPENCLAW_CODEX_APP_SERVER_ARGS: `app-server -c openai_base_url=http://127.0.0.1:44080/v1 -c ${JSON.stringify(`model_catalog_json=${modelCatalogPath}`)} --listen stdio://`, + OPENCLAW_CODEX_APP_SERVER_ARGS: `app-server -c openai_base_url=http://127.0.0.1:44080/v1 -c ${JSON.stringify(`model_catalog_json=${modelCatalogPath}`)} -c sandbox_workspace_write.exclude_tmpdir_env_var=true -c sandbox_workspace_write.exclude_slash_tmp=true --listen stdio://`, }), ); }); @@ -290,6 +290,39 @@ describe("Gateway child fixture helpers", () => { ).rejects.toThrow(); }); + it("confines live Codex QA without replacing its native provider configuration", () => { + expect( + testing.buildQaForcedRuntimeEnvPatch({ + forcedRuntime: "codex", + providerMode: "live-frontier", + }), + ).toEqual({ + OPENCLAW_BUILD_PRIVATE_QA: "1", + OPENCLAW_QA_FORCE_RUNTIME: "codex", + OPENCLAW_CODEX_APP_SERVER_ARGS: + "app-server -c sandbox_workspace_write.exclude_tmpdir_env_var=true " + + "-c sandbox_workspace_write.exclude_slash_tmp=true --listen stdio://", + }); + }); + + it("preserves preconfigured live Codex arguments while enforcing QA containment", () => { + expect( + testing.buildQaForcedRuntimeEnvPatch({ + forcedRuntime: "codex", + providerMode: "live-frontier", + nativeAppServerArgs: + 'app-server -c openai_base_url="https://live.example/v1" --listen stdio://', + }), + ).toEqual({ + OPENCLAW_BUILD_PRIVATE_QA: "1", + OPENCLAW_QA_FORCE_RUNTIME: "codex", + OPENCLAW_CODEX_APP_SERVER_ARGS: + 'app-server -c openai_base_url="https://live.example/v1" --listen stdio:// ' + + "-c sandbox_workspace_write.exclude_tmpdir_env_var=true " + + "-c sandbox_workspace_write.exclude_slash_tmp=true", + }); + }); + it("resolves source and built Gateway CLI commands", async () => { const repoRoot = await tempDirs.makeTempDir("qa-gateway-command-"); await mkdir(path.join(repoRoot, "src"), { recursive: true }); diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 57e8bce01cb0..476d3ae82564 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -41,6 +41,7 @@ import { readQaChildOutput, } from "./child-output.js"; import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js"; +import { buildQaCodexAppServerArgs } from "./codex-app-server-args.js"; import { QaSuiteInfraError, toQaErrorObject } from "./errors.js"; import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js"; import { @@ -460,6 +461,7 @@ function buildQaForcedRuntimeEnvPatch(params: { providerMode: QaProviderMode; providerBaseUrl?: string; codexModelCatalogPath?: string; + nativeAppServerArgs?: string; }): NodeJS.ProcessEnv | undefined { if (!params.forcedRuntime) { return undefined; @@ -468,7 +470,13 @@ function buildQaForcedRuntimeEnvPatch(params: { OPENCLAW_BUILD_PRIVATE_QA: "1", OPENCLAW_QA_FORCE_RUNTIME: params.forcedRuntime, }; - if (params.forcedRuntime !== "codex" || params.providerMode !== "mock-openai") { + if (params.forcedRuntime !== "codex") { + return patch; + } + if (params.providerMode !== "mock-openai") { + patch.OPENCLAW_CODEX_APP_SERVER_ARGS = buildQaCodexAppServerArgs({ + existingArgs: params.nativeAppServerArgs, + }); return patch; } const providerBaseUrl = params.providerBaseUrl?.trim().replace(/\/+$/u, ""); @@ -478,8 +486,10 @@ function buildQaForcedRuntimeEnvPatch(params: { if (!params.codexModelCatalogPath) { throw new Error("forced Codex mock QA requires the staged native model catalog"); } - const modelCatalogOverride = JSON.stringify(`model_catalog_json=${params.codexModelCatalogPath}`); - patch.OPENCLAW_CODEX_APP_SERVER_ARGS = `app-server -c openai_base_url=${providerBaseUrl} -c ${modelCatalogOverride} --listen stdio://`; + patch.OPENCLAW_CODEX_APP_SERVER_ARGS = buildQaCodexAppServerArgs({ + providerBaseUrl, + modelCatalogPath: params.codexModelCatalogPath, + }); patch.OPENAI_API_KEY = QA_MOCK_OPENAI_API_KEY; patch.CODEX_API_KEY = QA_MOCK_OPENAI_API_KEY; return patch; @@ -1347,6 +1357,9 @@ export async function startQaGatewayChild(params: { providerMode, providerBaseUrl: params.providerBaseUrl, codexModelCatalogPath, + nativeAppServerArgs: + params.runtimeEnvPatch?.OPENCLAW_CODEX_APP_SERVER_ARGS ?? + process.env.OPENCLAW_CODEX_APP_SERVER_ARGS, }), }, forwardHostHomeForClaudeCli: liveProviderIds.includes("claude-cli"), diff --git a/extensions/qa-lab/src/qa-gateway-config.test.ts b/extensions/qa-lab/src/qa-gateway-config.test.ts index 093c299be383..a72e0972bf59 100644 --- a/extensions/qa-lab/src/qa-gateway-config.test.ts +++ b/extensions/qa-lab/src/qa-gateway-config.test.ts @@ -288,8 +288,35 @@ describe("buildQaGatewayConfig", () => { expect(cfg.agents?.defaults?.models?.["openai/gpt-5.6-luna"]).toEqual({}); expect(cfg.agents?.defaults?.models?.["openai/gpt-5.4"]).toEqual({}); expect(cfg.agents?.entries?.qa?.fastModeDefault).toBe(true); + expect(cfg.plugins?.allow).toContain("codex"); + expect(cfg.plugins?.entries?.codex).toEqual({ + enabled: true, + config: { appServer: { sandbox: "workspace-write" } }, + }); }); + it.each(["mock-openai", "live-frontier"] as const)( + "automatically stages a confined Codex harness for %s parity", + (providerMode) => { + const cfg = buildQaGatewayConfig({ + bind: "loopback", + gatewayPort: 18789, + gatewayToken: "token", + workspaceDir: "/tmp/qa-workspace", + providerMode, + forcedRuntime: "codex", + primaryModel: "openai/gpt-5.6-luna", + alternateModel: "openai/gpt-5.6-luna", + }); + + expect(cfg.plugins?.allow).toContain("codex"); + expect(cfg.plugins?.entries?.codex).toEqual({ + enabled: true, + config: { appServer: { sandbox: "workspace-write" } }, + }); + }, + ); + it("routes forced Codex mock cells through the app-server OpenAI provider", () => { const cfg = buildQaGatewayConfig({ bind: "loopback", @@ -325,7 +352,10 @@ describe("buildQaGatewayConfig", () => { "openai", "qa-channel", ]); - expect(cfg.plugins?.entries?.codex).toEqual({ enabled: true }); + expect(cfg.plugins?.entries?.codex).toEqual({ + enabled: true, + config: { appServer: { sandbox: "workspace-write" } }, + }); expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true }); expect(cfg.agents?.defaults?.models).toEqual({ "openai/gpt-5.6-luna": {}, diff --git a/extensions/qa-lab/src/qa-gateway-config.ts b/extensions/qa-lab/src/qa-gateway-config.ts index 81fc9a0ad2db..ac4bddd895c5 100644 --- a/extensions/qa-lab/src/qa-gateway-config.ts +++ b/extensions/qa-lab/src/qa-gateway-config.ts @@ -113,18 +113,29 @@ export function buildQaGatewayConfig(params: { .map((pluginId) => pluginId.trim()) .filter((pluginId) => pluginId.length > 0), ); - const selectedPluginIds = usesCodexMockAppServer + const providerSelectedPluginIds = usesCodexMockAppServer ? uniqueStrings([...configuredPluginIds, ...selectedProviderIds]) : provider.usesModelProviderPlugins ? uniqueStrings( (params.enabledPluginIds?.length ?? 0) > 0 ? configuredPluginIds : selectedProviderIds, ) : configuredPluginIds; + // A forced Codex cell must stage its harness even when the provider owner is + // selected independently; otherwise its QA-only sandbox never takes effect. + const selectedPluginIds = + params.forcedRuntime === "codex" + ? uniqueStrings([...providerSelectedPluginIds, "codex"]) + : providerSelectedPluginIds; const transportPluginIds = uniqueStrings(params.transportPluginIds ?? []) .map((pluginId) => pluginId.trim()) .filter((pluginId) => pluginId.length > 0); const pluginEntries = Object.fromEntries( - selectedPluginIds.map((pluginId) => [pluginId, { enabled: true }]), + selectedPluginIds.map((pluginId) => [ + pluginId, + params.forcedRuntime === "codex" && pluginId === "codex" + ? { enabled: true, config: { appServer: { sandbox: "workspace-write" } } } + : { enabled: true }, + ]), ); const transportPluginEntries = Object.fromEntries( transportPluginIds.map((pluginId) => [pluginId, { enabled: true }]), diff --git a/extensions/qa-lab/src/suite-run-standard.ts b/extensions/qa-lab/src/suite-run-standard.ts index 609498b2db01..22352408edad 100644 --- a/extensions/qa-lab/src/suite-run-standard.ts +++ b/extensions/qa-lab/src/suite-run-standard.ts @@ -138,6 +138,7 @@ export async function runQaFlowSuiteStandard( providerMode, forcedRuntime: params?.forcedRuntime, mockBaseUrl: activeMock?.baseUrl, + nativeAppServerArgs: process.env.OPENCLAW_CODEX_APP_SERVER_ARGS, }), transport.createRuntimeEnvPatch?.(), buildQaGatewayHeapCheckpointRuntimeEnvPatch(), diff --git a/extensions/qa-lab/src/suite-support.ts b/extensions/qa-lab/src/suite-support.ts index 5156074f9d5d..4c2c996739b0 100644 --- a/extensions/qa-lab/src/suite-support.ts +++ b/extensions/qa-lab/src/suite-support.ts @@ -1,4 +1,5 @@ import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline"; +import { buildQaCodexAppServerArgs } from "./codex-app-server-args.js"; import type { QaSuiteChannelDriverSelection } from "./crabline-artifacts.js"; import type { QaProviderMode } from "./model-selection.js"; import { parseQaProgressBooleanEnv as parseQaSuiteBooleanEnv } from "./progress-format.js"; @@ -124,15 +125,22 @@ export function buildQaRuntimeEnvPatch(params: { providerMode: QaProviderMode; forcedRuntime?: RuntimeId; mockBaseUrl?: string; + nativeAppServerArgs?: string; }): NodeJS.ProcessEnv | undefined { const patch: NodeJS.ProcessEnv = {}; if (params.forcedRuntime) { patch.OPENCLAW_BUILD_PRIVATE_QA = "1"; patch.OPENCLAW_QA_FORCE_RUNTIME = params.forcedRuntime; } - if (params.forcedRuntime !== "codex" || params.providerMode !== "mock-openai") { + if (params.forcedRuntime !== "codex") { return Object.keys(patch).length > 0 ? patch : undefined; } + if (params.providerMode !== "mock-openai") { + patch.OPENCLAW_CODEX_APP_SERVER_ARGS = buildQaCodexAppServerArgs({ + existingArgs: params.nativeAppServerArgs, + }); + return patch; + } let mockBaseUrl = params.mockBaseUrl?.trim(); while (mockBaseUrl?.endsWith("/")) { mockBaseUrl = mockBaseUrl.slice(0, -1); @@ -143,7 +151,9 @@ export function buildQaRuntimeEnvPatch(params: { // The forced codex lane uses the Codex app-server's native OpenAI provider // path, so pin the managed app-server to the QA mock endpoint instead of // leaking to the maintainer's real OpenAI config. - patch.OPENCLAW_CODEX_APP_SERVER_ARGS = `app-server -c openai_base_url=${mockBaseUrl}/v1 --listen stdio://`; + patch.OPENCLAW_CODEX_APP_SERVER_ARGS = buildQaCodexAppServerArgs({ + providerBaseUrl: `${mockBaseUrl}/v1`, + }); patch.OPENAI_API_KEY = "qa-mock-openai-key"; patch.CODEX_API_KEY = "qa-mock-openai-key"; return patch; diff --git a/extensions/qa-lab/src/suite.test.ts b/extensions/qa-lab/src/suite.test.ts index 1276021887d9..a700baa70262 100644 --- a/extensions/qa-lab/src/suite.test.ts +++ b/extensions/qa-lab/src/suite.test.ts @@ -814,7 +814,7 @@ describe("qa suite", () => { OPENCLAW_BUILD_PRIVATE_QA: "1", OPENCLAW_QA_FORCE_RUNTIME: "codex", OPENCLAW_CODEX_APP_SERVER_ARGS: - "app-server -c openai_base_url=http://127.0.0.1:44080/v1 --listen stdio://", + "app-server -c openai_base_url=http://127.0.0.1:44080/v1 -c sandbox_workspace_write.exclude_tmpdir_env_var=true -c sandbox_workspace_write.exclude_slash_tmp=true --listen stdio://", OPENAI_API_KEY: "qa-mock-openai-key", CODEX_API_KEY: "qa-mock-openai-key", }); @@ -833,6 +833,39 @@ describe("qa suite", () => { }); }); + it("confines live Codex QA without rewiring the native provider", () => { + expect( + qaSuiteProgressTesting.buildQaRuntimeEnvPatch({ + providerMode: "live-frontier", + forcedRuntime: "codex", + }), + ).toEqual({ + OPENCLAW_BUILD_PRIVATE_QA: "1", + OPENCLAW_QA_FORCE_RUNTIME: "codex", + OPENCLAW_CODEX_APP_SERVER_ARGS: + "app-server -c sandbox_workspace_write.exclude_tmpdir_env_var=true " + + "-c sandbox_workspace_write.exclude_slash_tmp=true --listen stdio://", + }); + }); + + it("preserves custom live Codex arguments when confining a QA suite", () => { + expect( + qaSuiteProgressTesting.buildQaRuntimeEnvPatch({ + providerMode: "live-frontier", + forcedRuntime: "codex", + nativeAppServerArgs: + 'app-server -c openai_base_url="https://live.example/v1" --listen stdio://', + }), + ).toEqual({ + OPENCLAW_BUILD_PRIVATE_QA: "1", + OPENCLAW_QA_FORCE_RUNTIME: "codex", + OPENCLAW_CODEX_APP_SERVER_ARGS: + 'app-server -c openai_base_url="https://live.example/v1" --listen stdio:// ' + + "-c sandbox_workspace_write.exclude_tmpdir_env_var=true " + + "-c sandbox_workspace_write.exclude_slash_tmp=true", + }); + }); + it("forwards run options into isolated scenario worker params", () => { const startLab = vi.fn(); const adapterFactory = {