fix(docker): bundle QA Lab runtime in the image (#92087)

* fix(docker): split qa lab runtime fixes

* fix(docker): remove store platform selector

* test(docker): assert qa lab ui copy is gated
This commit is contained in:
Jesse Merhi
2026-06-12 14:02:32 +10:00
committed by GitHub
parent 8be3beec74
commit 6223a538bc
6 changed files with 117 additions and 19 deletions
+10 -2
View File
@@ -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.
+42 -7
View File
@@ -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<void>> = [];
@@ -13,6 +14,19 @@ afterEach(async () => {
}
});
function parseComposeServices(compose: string) {
const parsed = YAML.parse(compose) as {
services?: Record<
string,
{
environment?: Record<string, string>;
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<string, { enabled?: boolean }>;
};
};
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"),
+6
View File
@@ -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"
@@ -52,6 +52,11 @@ function getModelFallbacks(value: unknown): string[] | undefined {
return undefined;
}
function expectQaLabPluginEnabled(cfg: ReturnType<typeof buildQaGatewayConfig>) {
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", () => {
+10 -1
View File
@@ -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,
},
+29 -2
View File
@@ -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",
);