mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
+10
-2
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user