fix: support Codex CLI QA auth

This commit is contained in:
Peter Steinberger
2026-04-08 15:52:01 +01:00
parent 47db29076e
commit aa3b1357cb
8 changed files with 233 additions and 4 deletions

View File

@@ -68,6 +68,38 @@ describe("buildQaRuntimeEnv", () => {
expect(env.OPENAI_API_KEY).toBe("openai-explicit");
});
it("preserves Codex CLI auth home for live frontier runs while sandboxing OpenClaw home", async () => {
const hostHome = await mkdtemp(path.join(os.tmpdir(), "qa-host-home-"));
cleanups.push(async () => {
await rm(hostHome, { recursive: true, force: true });
});
const codexHome = path.join(hostHome, ".codex");
await mkdir(codexHome);
const env = buildQaRuntimeEnv({
...createParams({
HOME: hostHome,
}),
providerMode: "live-frontier",
});
expect(env.HOME).toBe("/tmp/openclaw-qa/home");
expect(env.OPENCLAW_HOME).toBe("/tmp/openclaw-qa/home");
expect(env.CODEX_HOME).toBe(codexHome);
});
it("keeps explicit Codex CLI auth home for live frontier runs", () => {
const env = buildQaRuntimeEnv({
...createParams({
CODEX_HOME: "/custom/codex-home",
HOME: "/host/home",
}),
providerMode: "live-frontier",
});
expect(env.CODEX_HOME).toBe("/custom/codex-home");
});
it("scrubs direct and live provider keys in mock mode", () => {
const env = buildQaRuntimeEnv({
...createParams({
@@ -78,6 +110,7 @@ describe("buildQaRuntimeEnv", () => {
GOOGLE_API_KEY: "google-live",
OPENAI_API_KEY: "openai-live",
OPENAI_API_KEYS: "openai-a,openai-b",
CODEX_HOME: "/host/.codex",
OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live",
OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b",
OPENCLAW_LIVE_GEMINI_KEY: "gemini-live",
@@ -88,6 +121,7 @@ describe("buildQaRuntimeEnv", () => {
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.OPENAI_API_KEYS).toBeUndefined();
expect(env.CODEX_HOME).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined();
expect(env.GEMINI_API_KEY).toBeUndefined();
@@ -214,6 +248,30 @@ describe("qa bundled plugin dir", () => {
).resolves.toBeTruthy();
});
it("maps cli backend provider ids to their owning bundled plugin ids", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-plugin-owner-"));
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
});
await mkdir(path.join(repoRoot, "dist", "extensions", "openai"), { recursive: true });
await writeFile(
path.join(repoRoot, "dist", "extensions", "openai", "openclaw.plugin.json"),
JSON.stringify({
id: "openai",
providers: ["openai", "openai-codex"],
cliBackends: ["codex-cli"],
}),
"utf8",
);
await expect(
__testing.resolveQaOwnerPluginIdsForProviderIds({
repoRoot,
providerIds: ["codex-cli"],
}),
).resolves.toEqual(["openai"]);
});
it("raises the QA runtime host version to the highest allowed plugin floor", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-runtime-version-"));
cleanups.push(async () => {

View File

@@ -8,8 +8,9 @@ import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { startQaGatewayRpcClient } from "./gateway-rpc-client.js";
import { splitQaModelRef } from "./model-selection.js";
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
import { buildQaGatewayConfig } from "./qa-gateway-config.js";
import { buildQaGatewayConfig, type QaThinkingLevel } from "./qa-gateway-config.js";
const QA_LIVE_ENV_ALIASES = Object.freeze([
{
@@ -41,6 +42,7 @@ const QA_MOCK_BLOCKED_ENV_VARS = Object.freeze([
"OPENAI_API_KEY",
"OPENAI_API_KEYS",
"OPENAI_BASE_URL",
"CODEX_HOME",
"OPENCLAW_LIVE_ANTHROPIC_KEY",
"OPENCLAW_LIVE_ANTHROPIC_KEYS",
"OPENCLAW_LIVE_GEMINI_KEY",
@@ -106,6 +108,19 @@ export function normalizeQaProviderModeEnv(
return env;
}
function resolveQaLiveCliAuthEnv(baseEnv: NodeJS.ProcessEnv) {
const configuredCodexHome = baseEnv.CODEX_HOME?.trim();
if (configuredCodexHome) {
return { CODEX_HOME: configuredCodexHome };
}
const hostHome = baseEnv.HOME?.trim();
if (!hostHome) {
return {};
}
const codexHome = path.join(hostHome, ".codex");
return existsSync(codexHome) ? { CODEX_HOME: codexHome } : {};
}
export function buildQaRuntimeEnv(params: {
configPath: string;
gatewayToken: string;
@@ -119,9 +134,11 @@ export function buildQaRuntimeEnv(params: {
providerMode?: "mock-openai" | "live-frontier";
baseEnv?: NodeJS.ProcessEnv;
}) {
const baseEnv = params.baseEnv ?? process.env;
const env: NodeJS.ProcessEnv = {
...(params.baseEnv ?? process.env),
...baseEnv,
HOME: params.homeDir,
...(params.providerMode === "live-frontier" ? resolveQaLiveCliAuthEnv(baseEnv) : {}),
OPENCLAW_HOME: params.homeDir,
OPENCLAW_CONFIG_PATH: params.configPath,
OPENCLAW_STATE_DIR: params.stateDir,
@@ -161,6 +178,8 @@ function isRetryableGatewayCallError(details: string): boolean {
export const __testing = {
buildQaRuntimeEnv,
isRetryableGatewayCallError,
resolveQaLiveCliAuthEnv,
resolveQaOwnerPluginIdsForProviderIds,
resolveQaBundledPluginsSourceRoot,
resolveQaRuntimeHostVersion,
createQaBundledPluginsDir,
@@ -180,6 +199,57 @@ function resolveQaBundledPluginsSourceRoot(repoRoot: string) {
throw new Error("failed to resolve qa bundled plugins source root");
}
async function resolveQaOwnerPluginIdsForProviderIds(params: {
repoRoot: string;
providerIds: readonly string[];
}) {
const providerIds = [
...new Set(params.providerIds.map((providerId) => providerId.trim())),
].filter((providerId) => providerId.length > 0);
if (providerIds.length === 0) {
return [];
}
const remainingProviderIds = new Set(providerIds);
const ownerPluginIds = new Set<string>();
const sourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot);
for (const entry of await fs.readdir(sourceRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const manifestPath = path.join(sourceRoot, entry.name, "openclaw.plugin.json");
if (!existsSync(manifestPath)) {
continue;
}
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as {
id?: unknown;
providers?: unknown;
cliBackends?: unknown;
};
const pluginId = typeof manifest.id === "string" ? manifest.id.trim() : entry.name;
if (!pluginId) {
continue;
}
const ownedIds = new Set(
[
pluginId,
...(Array.isArray(manifest.providers) ? manifest.providers : []),
...(Array.isArray(manifest.cliBackends) ? manifest.cliBackends : []),
].filter((ownedId): ownedId is string => typeof ownedId === "string"),
);
for (const providerId of providerIds) {
if (!ownedIds.has(providerId)) {
continue;
}
ownerPluginIds.add(pluginId);
remainingProviderIds.delete(providerId);
}
}
for (const providerId of remainingProviderIds) {
ownerPluginIds.add(providerId);
}
return [...ownerPluginIds];
}
function parseStableSemverFloor(value: string | undefined) {
if (!value) {
return null;
@@ -371,6 +441,7 @@ export async function startQaGatewayChild(params: {
primaryModel?: string;
alternateModel?: string;
fastMode?: boolean;
thinkingDefault?: QaThinkingLevel;
controlUiEnabled?: boolean;
}) {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qa-suite-"));
@@ -396,6 +467,21 @@ export async function startQaGatewayChild(params: {
fs.mkdir(xdgDataHome, { recursive: true }),
fs.mkdir(xdgCacheHome, { recursive: true }),
]);
const liveProviderIds =
params.providerMode === "live-frontier"
? [params.primaryModel, params.alternateModel]
.map((modelRef) =>
typeof modelRef === "string" ? splitQaModelRef(modelRef)?.provider : undefined,
)
.filter((providerId): providerId is string => Boolean(providerId))
: [];
const enabledPluginIds =
liveProviderIds.length > 0
? await resolveQaOwnerPluginIdsForProviderIds({
repoRoot: params.repoRoot,
providerIds: liveProviderIds,
})
: undefined;
const cfg = buildQaGatewayConfig({
bind: "loopback",
gatewayPort,
@@ -411,7 +497,9 @@ export async function startQaGatewayChild(params: {
providerMode: params.providerMode,
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
enabledPluginIds,
fastMode: params.fastMode,
thinkingDefault: params.thinkingDefault,
controlUiEnabled: params.controlUiEnabled,
});
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");

View File

@@ -5,6 +5,7 @@ import { startQaGatewayChild } from "./gateway-child.js";
import { startQaLabServer } from "./lab-server.js";
import { resolveQaLiveTurnTimeoutMs } from "./live-timeout.js";
import { startQaMockOpenAiServer } from "./mock-openai-server.js";
import type { QaThinkingLevel } from "./qa-gateway-config.js";
type QaManualLaneParams = {
repoRoot: string;
@@ -12,6 +13,7 @@ type QaManualLaneParams = {
primaryModel: string;
alternateModel: string;
fastMode?: boolean;
thinkingDefault?: QaThinkingLevel;
message: string;
timeoutMs?: number;
};
@@ -61,6 +63,7 @@ export async function runQaManualLane(params: QaManualLaneParams) {
primaryModel: params.primaryModel,
alternateModel: params.alternateModel,
fastMode: params.fastMode,
thinkingDefault: params.thinkingDefault,
controlUiEnabled: false,
});

View File

@@ -78,6 +78,42 @@ describe("buildQaGatewayConfig", () => {
expect(cfg.agents?.defaults).not.toHaveProperty("imageGenerationModel");
});
it("uses owning plugin ids separately from live model provider ids", () => {
const cfg = buildQaGatewayConfig({
bind: "loopback",
gatewayPort: 18789,
gatewayToken: "token",
qaBusBaseUrl: "http://127.0.0.1:43124",
workspaceDir: "/tmp/qa-workspace",
providerMode: "live-frontier",
primaryModel: "codex-cli/test-model",
alternateModel: "codex-cli/test-model",
imageGenerationModel: null,
enabledPluginIds: ["openai"],
});
expect(getPrimaryModel(cfg.agents?.defaults?.model)).toBe("codex-cli/test-model");
expect(cfg.plugins?.allow).toEqual(["memory-core", "openai", "qa-channel"]);
expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true });
expect(cfg.plugins?.entries?.["codex-cli"]).toBeUndefined();
});
it("can set a QA default thinking level for judge turns", () => {
const cfg = buildQaGatewayConfig({
bind: "loopback",
gatewayPort: 18789,
gatewayToken: "token",
qaBusBaseUrl: "http://127.0.0.1:43124",
workspaceDir: "/tmp/qa-workspace",
providerMode: "live-frontier",
primaryModel: "openai/gpt-5.4",
alternateModel: "openai/gpt-5.4",
thinkingDefault: "xhigh",
});
expect(cfg.agents?.defaults?.thinkingDefault).toBe("xhigh");
});
it("can disable control ui for suite-only gateway children", () => {
const cfg = buildQaGatewayConfig({
bind: "loopback",

View File

@@ -15,6 +15,8 @@ export const DEFAULT_QA_CONTROL_UI_ALLOWED_ORIGINS = Object.freeze([
"http://localhost:43124",
]);
export type QaThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive";
export function mergeQaControlUiAllowedOrigins(extraOrigins?: string[]) {
const normalizedExtra = (extraOrigins ?? [])
.map((origin) => origin.trim())
@@ -37,7 +39,9 @@ export function buildQaGatewayConfig(params: {
alternateModel?: string;
imageGenerationModel?: string | null;
enabledProviderIds?: string[];
enabledPluginIds?: string[];
fastMode?: boolean;
thinkingDefault?: QaThinkingLevel;
}): OpenClawConfig {
const mockProviderBaseUrl = params.providerBaseUrl ?? "http://127.0.0.1:44080/v1";
const mockOpenAiProvider: ModelProviderConfig = {
@@ -119,13 +123,23 @@ export function buildQaGatewayConfig(params: {
),
]
: [];
const selectedPluginIds =
providerMode === "live-frontier"
? [
...new Set(
(params.enabledPluginIds?.length ?? 0) > 0
? params.enabledPluginIds
: selectedProviderIds,
),
]
: [];
const pluginEntries =
providerMode === "live-frontier"
? Object.fromEntries(selectedProviderIds.map((providerId) => [providerId, { enabled: true }]))
? Object.fromEntries(selectedPluginIds.map((pluginId) => [pluginId, { enabled: true }]))
: {};
const allowedPlugins =
providerMode === "live-frontier"
? ["memory-core", ...selectedProviderIds, "qa-channel"]
? ["memory-core", ...selectedPluginIds, "qa-channel"]
: ["memory-core", "qa-channel"];
const liveModelParams =
providerMode === "live-frontier"
@@ -166,6 +180,7 @@ export function buildQaGatewayConfig(params: {
},
}
: {}),
...(params.thinkingDefault ? { thinkingDefault: params.thinkingDefault } : {}),
memorySearch: {
sync: {
watch: true,

View File

@@ -1149,6 +1149,7 @@ export async function runQaSuite(params?: {
providerMode,
primaryModel,
alternateModel,
fastMode,
controlUiEnabled: true,
});
lab.setControlUi({