mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(codex): isolate QA sandbox from production policy (#114830)
This commit is contained in:
committed by
GitHub
parent
83cc8887b8
commit
b2f137797b
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user