fix(codex): isolate QA sandbox from production policy (#114830)

This commit is contained in:
Peter Steinberger
2026-07-27 21:05:41 -04:00
committed by GitHub
parent 83cc8887b8
commit b2f137797b
13 changed files with 402 additions and 57 deletions
@@ -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,
};
}
+98 -13
View File
@@ -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 } },
@@ -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", () => {
@@ -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 }
@@ -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",
);
});
});
@@ -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(" ");
}
+34 -1
View File
@@ -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 });
+16 -3
View File
@@ -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"),
@@ -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": {},
+13 -2
View File
@@ -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 }]),
@@ -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(),
+12 -2
View File
@@ -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;
+34 -1
View File
@@ -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 = {