diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index b9840a951c71..731769a8b19e 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -1027,6 +1027,9 @@ function laneEnv(poolLane, baseEnv, logDir, cacheKey) { }; const name = poolLane.name; env.OPENCLAW_DOCKER_ALL_LANE_NAME = name; + if (poolLane.bundledPluginSweepIds !== undefined) { + env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS = poolLane.bundledPluginSweepIds; + } const image = e2eImageForLane(poolLane, baseEnv); if (image) { env.OPENCLAW_DOCKER_E2E_IMAGE = image; @@ -1636,6 +1639,48 @@ async function main() { ); } + const bundledPluginSweepLanes = scheduledLanes.filter(({ name }) => + name.startsWith("bundled-plugin-install-uninstall-"), + ); + const configuredSweepIds = baseEnv.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS; + delete baseEnv.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS; + if (bundledPluginSweepLanes.length > 0) { + const configuredIds = (configuredSweepIds ?? "").split(/[,\s]+/u).filter(Boolean); + let sweepIds = + configuredIds.length > 0 && + configuredSweepIds?.trim() !== "undefined" && + configuredSweepIds?.trim() !== "null" + ? configuredSweepIds + : undefined; + + if (sweepIds === undefined) { + const { listBundledPluginPackArtifacts } = + await import("./lib/bundled-plugin-build-entries.mjs"); + const artifacts = listBundledPluginPackArtifacts({ + cwd: ROOT_DIR, + env: { + ...baseEnv, + OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS: undefined, + OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS: undefined, + }, + }); + const expectedIds = artifacts.flatMap((artifact) => { + const match = /^dist\/extensions\/([^/]+)\/openclaw\.plugin\.json$/u.exec(artifact); + return match?.[1] ? [match[1]] : []; + }); + if (expectedIds.length === 0) { + throw new Error("frozen target has no packaged bundled plugin manifests to verify"); + } + sweepIds = expectedIds.join(","); + } + + // Old target wrappers inspect only installed package contents, so the trusted + // scheduler must carry frozen-source expectations into every sweep shard. + for (const poolLane of bundledPluginSweepLanes) { + poolLane.bundledPluginSweepIds = sweepIds; + } + } + await runPhase( phases, "docker-preflight", diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index 4bbdac3f7d7d..a830b9b95250 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -58,6 +58,128 @@ function writeFrozenScenarioContract(root: string, scenarios: string[]): string return assertionsFile; } +function writeIsolatedDockerAllHarness(root: string): string { + const scriptsDir = path.join(root, "scripts"); + const libDir = path.join(scriptsDir, "lib"); + mkdirSync(libDir, { recursive: true }); + copyFileSync("package.json", path.join(root, "package.json")); + copyFileSync("scripts/test-docker-all.mjs", path.join(scriptsDir, "test-docker-all.mjs")); + for (const fileName of ["docker-e2e-plan.mjs", "docker-e2e-scenarios.mjs", "sleep.mjs"]) { + copyFileSync(path.join("scripts/lib", fileName), path.join(libDir, fileName)); + } + return path.join(scriptsDir, "test-docker-all.mjs"); +} + +function createFrozenBundledPluginSweepFixture(packagedPluginIds: string[]) { + const root = tempDirs.make("openclaw-docker-bundled-inventory-"); + const targetRoot = path.join(root, "frozen release target"); + const observationsPath = path.join(root, "lane-observations.jsonl"); + const fakePnpmPath = path.join(root, "fake pnpm"); + const packageTgz = path.join(root, "frozen package.tgz"); + + mkdirSync(targetRoot, { recursive: true }); + writeFileSync( + path.join(targetRoot, "package.json"), + JSON.stringify({ + files: ["dist/", "!dist/extensions/external-plugin/**"], + name: "openclaw", + scripts: { + "test:docker:bundled-plugin-install-uninstall": "old frozen wrapper", + "test:docker:plugin-binding-command-escape": "unrelated frozen wrapper", + }, + version: "2026.7.1", + }), + ); + for (const id of ["present-plugin", "missing-plugin", "external-plugin", "qa-lab", "acpx"]) { + const pluginRoot = path.join(targetRoot, "extensions", id); + mkdirSync(pluginRoot, { recursive: true }); + writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), JSON.stringify({ id })); + } + const supportRoot = path.join(targetRoot, "extensions", "runtime-support"); + mkdirSync(supportRoot, { recursive: true }); + writeFileSync( + path.join(supportRoot, "package.json"), + JSON.stringify({ name: "@openclaw/runtime-support" }), + ); + writeFileSync(path.join(supportRoot, "index.ts"), "export {};\n"); + writeFileSync(packageTgz, "frozen package fixture"); + writeFileSync( + fakePnpmPath, + [ + "#!/usr/bin/env node", + 'const fs = require("node:fs");', + 'const lane = process.env.OPENCLAW_DOCKER_ALL_LANE_NAME ?? "";', + "const sweepIds = process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS ?? null;", + `fs.appendFileSync(${JSON.stringify(observationsPath)}, JSON.stringify({ lane, sweepIds }) + "\\n");`, + 'if (!lane.startsWith("bundled-plugin-install-uninstall-")) process.exit(0);', + `const packagedIds = new Set(${JSON.stringify(packagedPluginIds)});`, + 'const expected = (sweepIds ?? "").split(/[,\\s]+/u).filter(Boolean);', + "for (const id of expected) {", + " if (!packagedIds.has(id)) {", + " console.error(`missing expected bundled plugin: ${id}`);", + " process.exit(23);", + " }", + "}", + ].join("\n"), + ); + chmodSync(fakePnpmPath, 0o755); + + return { + observationsPath, + packageTgz, + fakePnpmPath, + root, + targetRoot, + }; +} + +function runFrozenBundledPluginSweepFixture( + fixture: ReturnType, + options: { + args?: string[]; + env?: NodeJS.ProcessEnv; + lanes?: string; + schedulerPath?: string; + } = {}, +) { + return spawnSync( + process.execPath, + [options.schedulerPath ?? path.resolve("scripts/test-docker-all.mjs"), ...(options.args ?? [])], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS: "present-plugin", + OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: undefined, + OPENCLAW_CURRENT_PACKAGE_TGZ: fixture.packageTgz, + OPENCLAW_DOCKER_ALL_BUILD: "0", + OPENCLAW_DOCKER_ALL_LANES: + options.lanes ?? "bundled-plugin-install-uninstall-0,plugin-binding-command-escape", + OPENCLAW_DOCKER_ALL_LOG_DIR: path.join(fixture.root, "logs"), + OPENCLAW_DOCKER_ALL_PARALLELISM: "1", + OPENCLAW_DOCKER_ALL_PNPM_COMMAND: fixture.fakePnpmPath, + OPENCLAW_DOCKER_ALL_PREFLIGHT: "0", + OPENCLAW_DOCKER_ALL_START_STAGGER_MS: "0", + OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS: "0", + OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM: "1", + OPENCLAW_DOCKER_ALL_TIMINGS: "0", + OPENCLAW_DOCKER_E2E_REPO_ROOT: fixture.targetRoot, + OPENCLAW_INCLUDE_OPTIONAL_BUNDLED: "0", + OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS: "present-plugin", + ...options.env, + }, + }, + ); +} + +function readFrozenBundledPluginSweepObservations(observationsPath: string) { + return readFileSync(observationsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { lane: string; sweepIds: string | null }); +} + function expectDeclaredDispatchInputs(command: string): void { const workflow = parse(readFileSync(LIVE_E2E_WORKFLOW, "utf8")) as { on?: { workflow_dispatch?: { inputs?: Record } }; @@ -165,34 +287,146 @@ describe("scripts/test-docker-all scheduler", () => { it("plans from an isolated release harness without installed dependencies", () => { const root = tempDirs.make("openclaw-docker-plan-isolated-harness-"); - const scriptsDir = path.join(root, "scripts"); - const libDir = path.join(scriptsDir, "lib"); - mkdirSync(libDir, { recursive: true }); - copyFileSync("package.json", path.join(root, "package.json")); - copyFileSync("scripts/test-docker-all.mjs", path.join(scriptsDir, "test-docker-all.mjs")); - for (const fileName of ["docker-e2e-plan.mjs", "docker-e2e-scenarios.mjs", "sleep.mjs"]) { - copyFileSync(path.join("scripts/lib", fileName), path.join(libDir, fileName)); - } + const schedulerPath = writeIsolatedDockerAllHarness(root); - const result = spawnSync( - process.execPath, - [path.join(scriptsDir, "test-docker-all.mjs"), "--plan-json"], - { - cwd: root, - encoding: "utf8", - env: { - ...process.env, - OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL: "1", - OPENCLAW_DOCKER_ALL_PROFILE: "release-path", - OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT: process.cwd(), - }, + const result = spawnSync(process.execPath, [schedulerPath, "--plan-json"], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL: "1", + OPENCLAW_DOCKER_ALL_PROFILE: "release-path", + OPENCLAW_UPGRADE_SURVIVOR_TARGET_ROOT: process.cwd(), }, - ); + }); expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toMatchObject({ profile: "release-path" }); }); + it("rejects a frozen package that silently omits an expected bundled plugin", () => { + const fixture = createFrozenBundledPluginSweepFixture(["present-plugin"]); + + const result = runFrozenBundledPluginSweepFixture(fixture); + + expect(result.status, result.stdout).toBe(1); + expect( + readFileSync( + path.join(fixture.root, "logs", "bundled-plugin-install-uninstall-0.log"), + "utf8", + ), + ).toContain("missing expected bundled plugin: missing-plugin"); + expect(readFrozenBundledPluginSweepObservations(fixture.observationsPath)).toContainEqual({ + lane: "bundled-plugin-install-uninstall-0", + sweepIds: "missing-plugin,present-plugin", + }); + }); + + it.each([ + { label: "an unset", value: undefined }, + { label: "an undefined sentinel", value: "undefined" }, + { label: "a null sentinel", value: "null" }, + { label: "an empty token list", value: " , \t " }, + ])("derives the complete frozen-target plugin inventory for $label override", ({ value }) => { + const fixture = createFrozenBundledPluginSweepFixture(["missing-plugin", "present-plugin"]); + + const result = runFrozenBundledPluginSweepFixture(fixture, { + env: { OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: value }, + }); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(readFrozenBundledPluginSweepObservations(fixture.observationsPath)).toEqual([ + { + lane: "bundled-plugin-install-uninstall-0", + sweepIds: "missing-plugin,present-plugin", + }, + { + lane: "plugin-binding-command-escape", + sweepIds: null, + }, + ]); + }); + + it("preserves an explicitly selected frozen bundled-plugin sweep", () => { + const fixture = createFrozenBundledPluginSweepFixture(["present-plugin"]); + + const result = runFrozenBundledPluginSweepFixture(fixture, { + env: { OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: "present-plugin" }, + }); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(readFrozenBundledPluginSweepObservations(fixture.observationsPath)).toEqual([ + { + lane: "bundled-plugin-install-uninstall-0", + sweepIds: "present-plugin", + }, + { + lane: "plugin-binding-command-escape", + sweepIds: null, + }, + ]); + }); + + it.each([ + { label: "an unset", value: undefined }, + { label: "an explicit", value: "present-plugin" }, + { label: "an undefined sentinel", value: "undefined" }, + { label: "a null sentinel", value: "null" }, + { label: "an empty token list", value: " , \t " }, + ])("does not leak $label sweep override into unrelated lanes", ({ value }) => { + const fixture = createFrozenBundledPluginSweepFixture([]); + rmSync(path.join(fixture.targetRoot, "extensions"), { recursive: true }); + + const result = runFrozenBundledPluginSweepFixture(fixture, { + env: { OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS: value }, + lanes: "plugin-binding-command-escape", + }); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(readFrozenBundledPluginSweepObservations(fixture.observationsPath)).toEqual([ + { lane: "plugin-binding-command-escape", sweepIds: null }, + ]); + }); + + it("does not inspect frozen bundled-plugin manifests during dry runs", () => { + const fixture = createFrozenBundledPluginSweepFixture([]); + rmSync(path.join(fixture.targetRoot, "extensions"), { recursive: true }); + + const result = runFrozenBundledPluginSweepFixture(fixture, { + env: { OPENCLAW_DOCKER_ALL_DRY_RUN: "1" }, + }); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain("Dry run complete"); + expect(existsSync(fixture.observationsPath)).toBe(false); + }); + + it("fails closed when the frozen target has no packaged bundled-plugin manifests", () => { + const fixture = createFrozenBundledPluginSweepFixture([]); + for (const id of ["missing-plugin", "present-plugin"]) { + rmSync(path.join(fixture.targetRoot, "extensions", id), { recursive: true }); + } + + const result = runFrozenBundledPluginSweepFixture(fixture); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("frozen target has no packaged bundled plugin manifests"); + expect(existsSync(fixture.observationsPath)).toBe(false); + }); + + it("fails closed when a trusted harness cannot load its bundled-plugin inventory owner", () => { + const fixture = createFrozenBundledPluginSweepFixture(["missing-plugin", "present-plugin"]); + const harnessRoot = tempDirs.make("openclaw-docker-sweep-harness-without-inventory-"); + + const result = runFrozenBundledPluginSweepFixture(fixture, { + schedulerPath: writeIsolatedDockerAllHarness(harnessRoot), + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("bundled-plugin-build-entries.mjs"); + expect(existsSync(fixture.observationsPath)).toBe(false); + }); + it("rejects loose numeric runner env vars without a stack trace", () => { const result = spawnSync(process.execPath, ["scripts/test-docker-all.mjs", "--plan-json"], { cwd: process.cwd(),