diff --git a/Dockerfile b/Dockerfile index 00f40100671c..dcebaf1a1856 100644 --- a/Dockerfile +++ b/Dockerfile @@ -116,11 +116,19 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \ echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \ echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \ rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI) -RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker +RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \ + export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \ + fi && \ + NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 RUN pnpm_config_verify_deps_before_run=false pnpm ui:build -RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build +RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \ + pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \ + mkdir -p dist/extensions/qa-lab/web && \ + rm -rf dist/extensions/qa-lab/web/dist && \ + cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \ + fi # Prune dev dependencies, omitted plugin runtime packages, and build-only # metadata before copying runtime assets into the final image. diff --git a/extensions/qa-lab/src/docker-harness.test.ts b/extensions/qa-lab/src/docker-harness.test.ts index 6e897dd6dd17..4dc5d1956dc0 100644 --- a/extensions/qa-lab/src/docker-harness.test.ts +++ b/extensions/qa-lab/src/docker-harness.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import YAML from "yaml"; import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js"; const cleanups: Array<() => Promise> = []; @@ -13,6 +14,19 @@ afterEach(async () => { } }); +function parseComposeServices(compose: string) { + const parsed = YAML.parse(compose) as { + services?: Record< + string, + { + environment?: Record; + volumes?: string[]; + } + >; + }; + return parsed.services ?? {}; +} + describe("qa docker harness", () => { it("writes compose, env, config, and workspace scaffold files", async () => { const outputDir = await mkdtemp(path.join(os.tmpdir(), "qa-docker-test-")); @@ -45,8 +59,21 @@ describe("qa docker harness", () => { } const compose = await readFile(path.join(outputDir, "docker-compose.qa.yml"), "utf8"); + const services = parseComposeServices(compose); expect(compose).toContain("image: openclaw:qa-local-prebaked"); expect(compose).toContain("qa-mock-openai:"); + expect(services["qa-mock-openai"]?.environment).toMatchObject({ + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", + OPENCLAW_PROFILE: "", + }); + expect(services["qa-mock-openai"]?.environment).not.toHaveProperty("OPENCLAW_CONFIG_PATH"); + expect(services["qa-mock-openai"]?.volumes).toBeUndefined(); + expect(services["qa-lab"]?.environment).toMatchObject({ + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", + OPENCLAW_CONFIG_PATH: "/opt/openclaw-scaffold/openclaw.json", + OPENCLAW_STATE_DIR: "/tmp/openclaw/state", + }); + expect(services["qa-lab"]?.volumes).toContain("./state:/opt/openclaw-scaffold:ro"); expect(compose).toContain(' - "127.0.0.1:18889:18789"'); expect(compose).toContain(' - "127.0.0.1:43124:43123"'); expect(compose).toContain(":/opt/openclaw-qa-lab-ui:ro"); @@ -75,13 +102,21 @@ describe("qa docker harness", () => { expect(envExample).toContain("QA_PROVIDER_BASE_URL=http://host.docker.internal:45123/v1"); expect(envExample).toContain("QA_LAB_URL=http://127.0.0.1:43124"); - const config = await readFile(path.join(outputDir, "state", "openclaw.json"), "utf8"); - expect(config).toContain('"allowInsecureAuth": true'); - expect(config).toContain('"pluginToolsMcpBridge": true'); - expect(config).toContain('"openClawToolsMcpBridge": true'); - expect(config).toContain("/app/dist/control-ui"); - expect(config).toContain("C-3PO QA"); - expect(config).toContain('"/tmp/openclaw/workspace"'); + const configText = await readFile(path.join(outputDir, "state", "openclaw.json"), "utf8"); + const config = JSON.parse(configText) as { + plugins?: { + allow?: string[]; + entries?: Record; + }; + }; + expect(configText).toContain('"allowInsecureAuth": true'); + expect(configText).toContain('"pluginToolsMcpBridge": true'); + expect(configText).toContain('"openClawToolsMcpBridge": true'); + expect(configText).toContain("/app/dist/control-ui"); + expect(configText).toContain("C-3PO QA"); + expect(configText).toContain('"/tmp/openclaw/workspace"'); + expect(config.plugins?.allow).toContain("qa-lab"); + expect(config.plugins?.entries?.["qa-lab"]?.enabled).toBe(true); const kickoff = await readFile( path.join(outputDir, "state", "seed-workspace", "QA_KICKOFF_TASK.md"), diff --git a/extensions/qa-lab/src/docker-harness.ts b/extensions/qa-lab/src/docker-harness.ts index 2959747639a8..33177df0c882 100644 --- a/extensions/qa-lab/src/docker-harness.ts +++ b/extensions/qa-lab/src/docker-harness.ts @@ -60,6 +60,9 @@ ${imageBlock} pull_policy: never timeout: 5s retries: 6 start_period: 3s + environment: + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" + OPENCLAW_PROFILE: "" command: - node - dist/index.js @@ -88,6 +91,9 @@ ${params.bindUiDist ? ` - ${qaLabUiMount}:${QA_LAB_UI_OVERLAY_DIR}:ro\n` : retries: 6 start_period: 5s environment: + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" + OPENCLAW_CONFIG_PATH: /opt/openclaw-scaffold/openclaw.json + OPENCLAW_STATE_DIR: /tmp/openclaw/state OPENCLAW_SKIP_GMAIL_WATCHER: "1" OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1" OPENCLAW_SKIP_CANVAS_HOST: "1" diff --git a/extensions/qa-lab/src/qa-gateway-config.test.ts b/extensions/qa-lab/src/qa-gateway-config.test.ts index d495ad983379..def57e260673 100644 --- a/extensions/qa-lab/src/qa-gateway-config.test.ts +++ b/extensions/qa-lab/src/qa-gateway-config.test.ts @@ -52,6 +52,11 @@ function getModelFallbacks(value: unknown): string[] | undefined { return undefined; } +function expectQaLabPluginEnabled(cfg: ReturnType) { + expect(cfg.plugins?.allow).toContain("qa-lab"); + expect(cfg.plugins?.entries?.["qa-lab"]).toEqual({ enabled: true }); +} + describe("buildQaGatewayConfig", () => { it("keeps mock-openai as the default provider lane", () => { const cfg = buildQaGatewayConfig({ @@ -78,7 +83,8 @@ describe("buildQaGatewayConfig", () => { expect(cfg.models?.providers?.openai?.request).toEqual({ allowPrivateNetwork: true }); expect(cfg.models?.providers?.anthropic?.baseUrl).toBe("http://127.0.0.1:44080"); expect(cfg.models?.providers?.anthropic?.request).toEqual({ allowPrivateNetwork: true }); - expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "qa-channel"]); + expectQaLabPluginEnabled(cfg); expect(cfg.plugins?.slots?.memory).toBe("memory-core"); expect(cfg.plugins?.entries?.acpx).toEqual({ enabled: true, @@ -124,7 +130,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.models?.providers?.anthropic?.models.map((model) => model.id)).toContain( "claude-opus-4-8", ); - expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab"]); }); it("falls back to provider defaults for blank model refs", () => { @@ -175,7 +181,7 @@ describe("buildQaGatewayConfig", () => { transportConfig: {}, }); - expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab"]); expect(cfg.plugins?.entries?.["qa-channel"]).toBeUndefined(); expect(cfg.channels?.["qa-channel"]).toBeUndefined(); }); @@ -191,7 +197,13 @@ describe("buildQaGatewayConfig", () => { ...createQaChannelTransportParams(), }); - expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "active-memory", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual([ + "acpx", + "memory-core", + "qa-lab", + "active-memory", + "qa-channel", + ]); expect(cfg.plugins?.entries?.["active-memory"]).toEqual({ enabled: true }); }); @@ -213,7 +225,7 @@ describe("buildQaGatewayConfig", () => { expect(getModelFallbacks(cfg.agents?.defaults?.model)).toBeUndefined(); expect(getModelFallbacks(cfg.agents?.list?.[0]?.model)).toBeUndefined(); expect(cfg.models).toBeUndefined(); - expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "openai", "qa-channel"]); expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true }); expect(cfg.agents?.defaults?.models?.["openai/gpt-5.5"]).toEqual({ params: { transport: "sse", openaiWsWarmup: false, fastMode: true }, @@ -236,6 +248,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.plugins?.allow).toEqual([ "acpx", "memory-core", + "qa-lab", "anthropic", "google", "qa-channel", @@ -261,7 +274,7 @@ describe("buildQaGatewayConfig", () => { }); expect(getPrimaryModel(cfg.agents?.defaults?.model)).toBe("codex-cli/test-model"); - expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "openai", "qa-channel"]); expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true }); expect(cfg.plugins?.entries?.["codex-cli"]).toBeUndefined(); }); @@ -301,7 +314,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.models?.mode).toBe("merge"); expect(cfg.models?.providers?.["custom-openai"]?.api).toBe("openai-responses"); - expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "openai", "qa-channel"]); + expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "openai", "qa-channel"]); }); it("can set a QA default thinking level for judge turns", () => { diff --git a/extensions/qa-lab/src/qa-gateway-config.ts b/extensions/qa-lab/src/qa-gateway-config.ts index d149a40b6457..e4d77b1a8011 100644 --- a/extensions/qa-lab/src/qa-gateway-config.ts +++ b/extensions/qa-lab/src/qa-gateway-config.ts @@ -23,6 +23,7 @@ export const DEFAULT_QA_CONTROL_UI_ALLOWED_ORIGINS = Object.freeze([ ]); export const QA_BASE_RUNTIME_PLUGIN_IDS = Object.freeze(["acpx", "memory-core"]); +export const QA_LAB_PLUGIN_ID = "qa-lab"; export function mergeQaControlUiAllowedOrigins(extraOrigins?: string[]) { const normalizedExtra = (extraOrigins ?? []) @@ -112,7 +113,12 @@ export function buildQaGatewayConfig(params: { transportPluginIds.map((pluginId) => [pluginId, { enabled: true }]), ); const allowedPlugins = [ - ...new Set([...QA_BASE_RUNTIME_PLUGIN_IDS, ...selectedPluginIds, ...transportPluginIds]), + ...new Set([ + ...QA_BASE_RUNTIME_PLUGIN_IDS, + QA_LAB_PLUGIN_ID, + ...selectedPluginIds, + ...transportPluginIds, + ]), ]; const resolveModelParams = (modelRef: string) => provider.resolveModelParams({ @@ -143,6 +149,9 @@ export function buildQaGatewayConfig(params: { "memory-core": { enabled: true, }, + [QA_LAB_PLUGIN_ID]: { + enabled: true, + }, ...pluginEntries, ...transportPluginEntries, }, diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index 8dc1de38d6ff..e5711a9d984e 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -194,10 +194,37 @@ describe("Dockerfile", () => { it("does not let pnpm resync the full source workspace during Docker build scripts", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); - - expect(dockerfile).toContain( + const collapsed = collapseDockerContinuations(dockerfile); + const qaLabBuildBlock = + /RUN if printf '%s\\n' "\$OPENCLAW_EXTENSIONS" \| tr ',' ' ' \| tr ' ' '\\n' \| grep -qx 'qa-lab'; then\s+pnpm_config_verify_deps_before_run=false pnpm qa:lab:build &&\s+mkdir -p dist\/extensions\/qa-lab\/web &&\s+rm -rf dist\/extensions\/qa-lab\/web\/dist &&\s+cp -R extensions\/qa-lab\/web\/dist dist\/extensions\/qa-lab\/web\/dist;\s+fi/u; + const qaLabExtensionCheckIndex = collapsed.indexOf("grep -qx 'qa-lab'"); + const qaLabBuildBlockMatch = qaLabBuildBlock.exec(collapsed); + const privateQaExportIndex = collapsed.indexOf( + "export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1", + ); + const buildDockerIndex = collapsed.indexOf( "NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker", ); + const qaLabBuildIndex = collapsed.indexOf( + "pnpm_config_verify_deps_before_run=false pnpm qa:lab:build", + ); + const qaLabDistCopyIndex = collapsed.indexOf( + "cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist", + ); + const runtimeAssetsIndex = collapsed.indexOf("FROM build AS runtime-assets"); + + expect(qaLabExtensionCheckIndex).toBeGreaterThan(-1); + expect(qaLabBuildBlockMatch?.index).toBeGreaterThan(-1); + expect(buildDockerIndex).toBeGreaterThan(-1); + expect(qaLabBuildIndex).toBeGreaterThan(-1); + expect(qaLabDistCopyIndex).toBeGreaterThan(-1); + expect(runtimeAssetsIndex).toBeGreaterThan(-1); + expect(privateQaExportIndex).toBeGreaterThan(qaLabExtensionCheckIndex); + expect(privateQaExportIndex).toBeLessThan(buildDockerIndex); + expect(qaLabBuildIndex).toBeGreaterThan(buildDockerIndex); + expect(qaLabBuildBlockMatch?.index).toBeGreaterThan(buildDockerIndex); + expect(qaLabDistCopyIndex).toBeGreaterThan(qaLabBuildIndex); + expect(qaLabDistCopyIndex).toBeLessThan(runtimeAssetsIndex); expect(dockerfile).toContain( "pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle", );