From f32febcd2298ec958f09377f5357e7b3962c4398 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 9 Aug 2026 13:20:07 -0700 Subject: [PATCH 1/2] fix(e2e): prebuild trusted EXDEV fixture image Signed-off-by: Apurv Kumaria --- test/e2e/README.md | 5 + ...w-plugin-runtime-exdev-trusted-prebuild.ts | 251 ++++++++++++++++++ .../openclaw-plugin-runtime-exdev.test.ts | 82 +++++- ...gin-runtime-exdev-trusted-prebuild.test.ts | 86 ++++++ 4 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts create mode 100644 test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts diff --git a/test/e2e/README.md b/test/e2e/README.md index d140eafdd3..676a4f7816 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -300,6 +300,11 @@ stock policy-source bytes, and the distinct-device and source-side `EXDEV` checks. The duplicate v3 rebuild is removed from this job. The `rebuild-openclaw` job remains the canonical live rebuild coverage. +The current-checkout fixture locally prebuilds its repository-controlled v1 +and v2 Dockerfiles with BuildKit, then hands only those local image references +to OpenShell. User-supplied `--from` Dockerfiles retain the gateway-builder +trust boundary and are never host-prebuilt by this fixture. + The runtime target for `openclaw-plugin-runtime-exdev` is 16–17 minutes. Push-run timing for the reduced lifecycle has not yet been measured. diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts b/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts new file mode 100644 index 0000000000..dc6d5a313c --- /dev/null +++ b/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts @@ -0,0 +1,251 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { LOCAL_SANDBOX_IMAGE_REPO } from "../../../src/lib/domain/sandbox/image-tag.ts"; +import { createCustomBuildContextFilter } from "../../../src/lib/onboard/custom-build-context.ts"; +import { patchStagedDockerfile } from "../../../src/lib/onboard/dockerfile-patch.ts"; +import { REQUIRED_OPENSHELL_MCP_FEATURES } from "../../../src/lib/onboard/openshell-feature-gate.ts"; +import { + prebuildSandboxImageIfEligible, + type SandboxPrebuildResult, +} from "../../../src/lib/onboard/sandbox-prebuild.ts"; +import { SANDBOX_BUILD_CONTEXT_PREFIX } from "../../../src/lib/sandbox/build-context.ts"; +import type { ArtifactSink } from "../fixtures/artifacts.ts"; +import type { CleanupRegistry } from "../fixtures/cleanup.ts"; +import { resultText, shellQuote } from "../fixtures/clients/command.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import { + createOpenShellDriverConfigTestWrapper, + type OpenShellDriverConfigTestWrapper, +} from "./openshell-driver-config-test-wrapper.ts"; + +export const DELEGATED_CAPABILITY_COMMENT_PREFIX = + "# TEST-ONLY delegated-capability marker from validated canonical OpenShell: "; + +const TRUSTED_EXDEV_IMAGE_REF_PATTERN = new RegExp( + `^${LOCAL_SANDBOX_IMAGE_REPO}:[a-z0-9_][a-z0-9_.-]{0,127}$`, +); + +export type OpenShellTrustedImageWrapper = OpenShellDriverConfigTestWrapper & { + selectImage(imageRef: string): void; +}; + +export function trustedExdevImageRef(tag: string): string { + const imageRef = `${LOCAL_SANDBOX_IMAGE_REPO}:${tag}`; + assert.match(imageRef, TRUSTED_EXDEV_IMAGE_REF_PATTERN); + return imageRef; +} + +export function createOpenShellTrustedImageWrapper(options: { + driverConfigJson: string; + realOpenshellPath: string; +}): OpenShellTrustedImageWrapper { + const delegated = createOpenShellDriverConfigTestWrapper({ + delegatedCapabilityMarkers: REQUIRED_OPENSHELL_MCP_FEATURES, + driverConfigJson: options.driverConfigJson, + label: "exdev", + realOpenshellPath: options.realOpenshellPath, + }); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-exdev-image-wrapper-")); + const imageRefPath = path.join(directory, "selected-image-ref"); + const rewriterPath = path.join(directory, "rewrite-from.cjs"); + const executable = path.join(directory, "openshell"); + fs.writeFileSync(imageRefPath, "\n", { encoding: "utf8", mode: 0o600 }); + fs.writeFileSync( + rewriterPath, + `const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); + +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "create") { + const fromIndexes = args.flatMap((argument, index) => argument === "--from" ? [index] : []); + if (fromIndexes.length !== 1 || fromIndexes[0] + 1 >= args.length) { + process.stderr.write("trusted EXDEV image handoff requires exactly one --from value\\n"); + process.exit(64); + } + const imageRef = fs.readFileSync(${JSON.stringify(imageRefPath)}, "utf8").trim(); + if (!${TRUSTED_EXDEV_IMAGE_REF_PATTERN.toString()}.test(imageRef)) { + process.stderr.write("trusted EXDEV image handoff rejected the selected image ref\\n"); + process.exit(64); + } + args[fromIndexes[0] + 1] = imageRef; +} +const result = spawnSync(${JSON.stringify(delegated.executable)}, args, { stdio: "inherit" }); +if (result.error) throw result.error; +process.exit(result.status ?? 1); +`, + { encoding: "utf8", mode: 0o600 }, + ); + const capabilityComments = REQUIRED_OPENSHELL_MCP_FEATURES.map( + (marker) => `${DELEGATED_CAPABILITY_COMMENT_PREFIX}${marker}`, + ).join("\n"); + fs.writeFileSync( + executable, + `#!/bin/sh +${capabilityComments} +set -eu +exec ${shellQuote(process.execPath)} ${shellQuote(rewriterPath)} "$@" +`, + { encoding: "utf8", mode: 0o700 }, + ); + + return { + directory, + executable, + selectImage: (imageRef) => { + assert.match(imageRef, TRUSTED_EXDEV_IMAGE_REF_PATTERN); + fs.writeFileSync(imageRefPath, `${imageRef}\n`, { + encoding: "utf8", + mode: 0o600, + }); + }, + remove: () => { + fs.rmSync(directory, { recursive: true, force: true }); + delegated.remove(); + }, + }; +} + +type TrustedPluginFixtureBuildContext = { + dockerfilePath: string; + sourceRoot: string; +}; + +export type TrustedPluginFixtureImageCleanup = { + track(imageRef: string, version: "v1" | "v2"): void; +}; + +export function registerTrustedPluginFixtureImageCleanup(options: { + cleanup: CleanupRegistry; + environment: NodeJS.ProcessEnv; + host: Pick; +}): TrustedPluginFixtureImageCleanup { + const images: Array<{ imageRef: string; version: "v1" | "v2" }> = []; + options.cleanup.add("remove trusted EXDEV fixture images", async () => { + for (const image of [...images].reverse()) { + const result = await options.host.command( + "docker", + ["image", "rm", "--force", image.imageRef], + { + artifactName: `cleanup-trusted-exdev-image-${image.version}`, + env: options.environment, + timeoutMs: 60_000, + }, + ); + assert.equal(result.exitCode, 0, resultText(result)); + } + }); + return { + track: (imageRef, version) => { + assert.match(imageRef, TRUSTED_EXDEV_IMAGE_REF_PATTERN); + images.push({ imageRef, version }); + }, + }; +} + +export function acceptTrustedPluginFixturePrebuild(options: { + images: TrustedPluginFixtureImageCleanup; + prebuild: SandboxPrebuildResult; + sandboxName: string; + version: "v1" | "v2"; +}): { imageId: string; imageRef: string } { + assert(options.prebuild.imageRef, "trusted EXDEV fixture prebuild must return a local image ref"); + const imageRef = options.prebuild.imageRef; + assert.match(imageRef, TRUSTED_EXDEV_IMAGE_REF_PATTERN); + options.images.track(imageRef, options.version); + assert.deepEqual(options.prebuild.createArgs, [ + "--from", + imageRef, + "--name", + options.sandboxName, + ]); + const imageId = String(options.prebuild.imageId); + assert.match( + imageId, + /^sha256:[0-9a-f]{64}$/, + "trusted EXDEV fixture prebuild must retain its immutable local image identity", + ); + return { imageId, imageRef }; +} + +export async function buildTrustedPluginFixtureImage(options: { + artifacts: ArtifactSink; + baseImageRef: string; + cleanup: CleanupRegistry; + context: TrustedPluginFixtureBuildContext; + deploymentEnv: NodeJS.ProcessEnv; + environment: NodeJS.ProcessEnv; + images: TrustedPluginFixtureImageCleanup; + sandboxName: string; + version: "v1" | "v2"; +}): Promise { + const buildId = `exdev-${options.version}-${randomUUID()}`; + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), SANDBOX_BUILD_CONTEXT_PREFIX)); + const stagedDockerfile = path.join(buildCtx, "Dockerfile"); + options.cleanup.add(`remove trusted EXDEV fixture context ${options.version}`, () => + fs.rmSync(buildCtx, { recursive: true, force: true }), + ); + fs.cpSync(options.context.sourceRoot, buildCtx, { + recursive: true, + filter: createCustomBuildContextFilter(options.context.sourceRoot), + }); + fs.copyFileSync( + path.join(buildCtx, path.basename(options.context.dockerfilePath)), + stagedDockerfile, + ); + const endpointUrl = String(options.deploymentEnv.NEMOCLAW_ENDPOINT_URL); + assert.match(endpointUrl, /^http:\/\//); + patchStagedDockerfile( + stagedDockerfile, + "nemoclaw-exdev-probe", + "http://127.0.0.1:18789", + buildId, + "custom", + "openai-completions", + null, + options.baseImageRef, + false, + null, + [], + { + buildIdPolicy: "rewrite", + requireToolDisclosureContract: true, + upstreamEndpointUrl: endpointUrl, + }, + ); + const prebuild = await prebuildSandboxImageIfEligible({ + buildCtx, + buildId, + createArgs: ["--from", stagedDockerfile, "--name", options.sandboxName], + dockerDriverGateway: true, + env: { ...options.environment, NEMOCLAW_SANDBOX_PREBUILD: "1" }, + // The staged source is owned by this E2E fixture. User custom Dockerfiles + // remain origin=custom and never cross this local-build trust boundary. + origin: "generated", + requiresLocalBuildKit: true, + sandboxName: options.sandboxName, + }); + const { imageId, imageRef } = acceptTrustedPluginFixturePrebuild({ + images: options.images, + prebuild, + sandboxName: options.sandboxName, + version: options.version, + }); + await options.artifacts.writeJson(`trusted-exdev-image-${options.version}.json`, { + baseImageRef: options.baseImageRef, + buildId, + imageId, + imageRef, + sourceDockerfile: options.context.dockerfilePath, + stagedDockerfile, + version: options.version, + }); + return imageRef; +} diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts index 669a98f023..6704b2e2ad 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts @@ -9,6 +9,7 @@ import os from "node:os"; import path from "node:path"; import { resolveOpenshell } from "../../../src/lib/adapters/openshell/resolve.ts"; +import { pullAndResolveBaseImageDigest } from "../../../src/lib/onboard/base-image.ts"; import { hasRequiredOpenshellMessagingFeatures, REQUIRED_OPENSHELL_MCP_FEATURES, @@ -40,6 +41,13 @@ import { currentLifecycleCommands, type WeatherFixtureVersion, } from "./openclaw-plugin-runtime-exdev-lifecycle.ts"; +import { + buildTrustedPluginFixtureImage, + createOpenShellTrustedImageWrapper, + DELEGATED_CAPABILITY_COMMENT_PREFIX, + registerTrustedPluginFixtureImageCleanup, + trustedExdevImageRef, +} from "./openclaw-plugin-runtime-exdev-trusted-prebuild.ts"; import { createOpenShellDriverConfigTestWrapper, type OpenShellComponents, @@ -111,8 +119,6 @@ const EXDEV_TMPFS_DRIVER_CONFIG = JSON.stringify({ mounts: [EXDEV_TMPFS_MOUNT_CONFIG], }, }); -const DELEGATED_CAPABILITY_COMMENT_PREFIX = - "# TEST-ONLY delegated-capability marker from validated canonical OpenShell: "; const STOCK_OPENCLAW_POLICY_PATHS = [ path.join(REPO_ROOT, "agents", "openclaw", "policy-permissive.yaml"), path.join(REPO_ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"), @@ -282,7 +288,7 @@ function runWrapper(wrapper: string, args: readonly string[]): string[] { return result.stdout.trimEnd().split("\n"); } -test("OpenShell wrapper injects only the reviewed tmpfs config into sandbox create", { +test("OpenShell wrapper injects the reviewed tmpfs config and selected fixture image", { meta: { e2ePhases: [ "create fake OpenShell delegates", @@ -304,9 +310,22 @@ test("OpenShell wrapper injects only the reviewed tmpfs config into sandbox crea }); } const components = resolvePinnedOpenShellComponents(delegate); - const wrapper = createOpenShellTmpfsWrapper(components.cli); + const wrapper = createOpenShellTrustedImageWrapper({ + driverConfigJson: EXDEV_TMPFS_DRIVER_CONFIG, + realOpenshellPath: components.cli, + }); try { + const imageRef = trustedExdevImageRef("wrapper-contract-v1"); progress.phase("inspect wrapper capabilities and environment"); + const missingImage = spawnSync( + wrapper.executable, + ["sandbox", "create", "--from", "/tmp/staged/Dockerfile"], + { encoding: "utf8", killSignal: "SIGKILL", timeout: 30_000 }, + ); + expect(missingImage.status).toBe(64); + expect(missingImage.stderr).toContain("rejected the selected image ref"); + expect(() => wrapper.selectImage("docker.io/untrusted:latest")).toThrow(); + wrapper.selectImage(imageRef); const wrapperSource = fs.readFileSync(wrapper.executable, "utf8"); expect( wrapperSource @@ -345,6 +364,8 @@ test("OpenShell wrapper injects only the reviewed tmpfs config into sandbox crea runWrapper(wrapper.executable, [ "sandbox", "create", + "--from", + "/tmp/staged/Dockerfile", "--name", "demo", "--", @@ -357,6 +378,8 @@ test("OpenShell wrapper injects only the reviewed tmpfs config into sandbox crea "create", "--driver-config-json", EXDEV_TMPFS_DRIVER_CONFIG, + "--from", + imageRef, "--name", "demo", "--", @@ -373,7 +396,7 @@ test("OpenShell wrapper injects only the reviewed tmpfs config into sandbox crea progress.phase("reject duplicate driver config and remove the fixture"); const duplicateConfig = spawnSync( wrapper.executable, - ["sandbox", "create", "--driver-config-json", "{}"], + ["sandbox", "create", "--from", "/tmp/staged/Dockerfile", "--driver-config-json", "{}"], { encoding: "utf8", killSignal: "SIGKILL", timeout: 30_000 }, ); expect(duplicateConfig.status).toBe(64); @@ -1177,6 +1200,7 @@ test("the current-lifecycle custom plugin survives restart and recreation withou "the CLI and Dockerfile use the same checkout source and a compatible sandbox base image", "gateway log, runtime inspection, tools.catalog, and tools.invoke prove weather/get_weather", "custom-plugin v1 survives restart and recreation installs v2", + "the repository-controlled fixture is prebuilt with local BuildKit and handed to OpenShell as a local image", "workspace state survives onboarding recreation", `test-only driver config mounts tmpfs at ${EXDEV_TMPFS_MOUNT} without changing production policies`, "stock OpenClaw policy source bytes remain unchanged through onboard and recreation", @@ -1204,6 +1228,12 @@ test("the current-lifecycle custom plugin survives restart and recreation withou "bin/nemoclaw.js missing — run npm run build:cli before this live target", ).toBe(true); + // Cleanup is LIFO: delete the sandbox before reclaiming its exact image tags. + const trustedFixtureImages = registerTrustedPluginFixtureImageCleanup({ + cleanup, + environment: liveEnv(), + host, + }); cleanup.trackDisposable(`delete OpenShell sandbox ${SANDBOX_NAME}`, () => sandbox.cleanupSandbox(SANDBOX_NAME, { artifactName: "cleanup-openshell-delete-openclaw-plugin-exdev", @@ -1252,7 +1282,10 @@ test("the current-lifecycle custom plugin survives restart and recreation withou }), "current pinned OpenShell components must pass coherence preflight before delegation", ).toBe(true); - const openshellWrapper = createOpenShellTmpfsWrapper(pinnedOpenshell.cli); + const openshellWrapper = createOpenShellTrustedImageWrapper({ + driverConfigJson: EXDEV_TMPFS_DRIVER_CONFIG, + realOpenshellPath: pinnedOpenshell.cli, + }); cleanup.add("remove current EXDEV OpenShell PATH wrapper", openshellWrapper.remove); expect( hasRequiredOpenshellMessagingFeatures({ @@ -1272,6 +1305,31 @@ test("the current-lifecycle custom plugin survives restart and recreation withou }); progress.phase("build and onboard plugin v1"); + const baseImageResolution = pullAndResolveBaseImageDigest({ + forceRefresh: true, + requireOpenshellSandboxAbi: true, + }); + assert( + baseImageResolution, + "current CLI must resolve an OpenShell-compatible sandbox base image", + ); + await artifacts.writeJson("trusted-exdev-base-image.json", { + digest: baseImageResolution.digest, + ref: baseImageResolution.ref, + source: baseImageResolution.source, + }); + const pluginImageV1 = await buildTrustedPluginFixtureImage({ + artifacts, + baseImageRef: baseImageResolution.ref, + cleanup, + context: customPluginContext, + deploymentEnv, + environment: liveEnv(), + images: trustedFixtureImages, + sandboxName: SANDBOX_NAME, + version: "v1", + }); + openshellWrapper.selectImage(pluginImageV1); const onboard = await host.command( lifecycleCommands.onboard.command, lifecycleCommands.onboard.args, @@ -1324,6 +1382,18 @@ test("the current-lifecycle custom plugin survives restart and recreation withou // extension instead of replacing it with the backed-up v1 directory. progress.phase("recreate the sandbox with plugin v2"); writeCustomPluginVersion(customPluginContext.versionSourcePath, "v2"); + const pluginImageV2 = await buildTrustedPluginFixtureImage({ + artifacts, + baseImageRef: baseImageResolution.ref, + cleanup, + context: customPluginContext, + deploymentEnv, + environment: liveEnv(), + images: trustedFixtureImages, + sandboxName: SANDBOX_NAME, + version: "v2", + }); + openshellWrapper.selectImage(pluginImageV2); const recreate = await host.command( lifecycleCommands.recreate.command, lifecycleCommands.recreate.args, diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts new file mode 100644 index 0000000000..90cb19f24f --- /dev/null +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { CleanupRegistry } from "../fixtures/cleanup.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { + acceptTrustedPluginFixturePrebuild, + registerTrustedPluginFixtureImageCleanup, + trustedExdevImageRef, +} from "../live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts"; + +const IMAGE_ID = `sha256:${"a".repeat(64)}`; + +function commandResult(): ShellProbeResult { + return { + artifacts: { result: "result.json", stderr: "stderr.txt", stdout: "stdout.txt" }, + command: ["docker", "image", "rm"], + exitCode: 0, + signal: null, + stderr: "", + stdout: "", + timedOut: false, + }; +} + +describe("trusted EXDEV fixture image cleanup", () => { + it("reclaims an image whose immutable identity assertion fails in LIFO order", async () => { + const calls: string[] = []; + const cleanup = new CleanupRegistry(); + const host = { + command: vi.fn(async (_command: string, args: string[]) => { + calls.push(`image:${args.at(-1)}`); + return commandResult(); + }), + }; + const images = registerTrustedPluginFixtureImageCleanup({ + cleanup, + environment: { PATH: "/usr/bin" }, + host, + }); + cleanup.add("delete fixture sandbox", () => { + calls.push("sandbox"); + }); + + const imageV1 = trustedExdevImageRef("cleanup-v1"); + const imageV2 = trustedExdevImageRef("cleanup-v2"); + expect( + acceptTrustedPluginFixturePrebuild({ + images, + prebuild: { + createArgs: ["--from", imageV1, "--name", "fixture-sandbox"], + imageId: IMAGE_ID, + imageRef: imageV1, + }, + sandboxName: "fixture-sandbox", + version: "v1", + }), + ).toEqual({ imageId: IMAGE_ID, imageRef: imageV1 }); + expect(() => + acceptTrustedPluginFixturePrebuild({ + images, + prebuild: { + createArgs: ["--from", imageV2, "--name", "fixture-sandbox"], + imageId: null, + imageRef: imageV2, + }, + sandboxName: "fixture-sandbox", + version: "v2", + }), + ).toThrow("trusted EXDEV fixture prebuild must retain its immutable local image identity"); + + expect(await cleanup.runAll()).toEqual({ + failures: [], + passed: ["delete fixture sandbox", "remove trusted EXDEV fixture images"], + }); + expect(calls).toEqual(["sandbox", `image:${imageV2}`, `image:${imageV1}`]); + expect(host.command).toHaveBeenNthCalledWith( + 1, + "docker", + ["image", "rm", "--force", imageV2], + expect.objectContaining({ artifactName: "cleanup-trusted-exdev-image-v2" }), + ); + }); +}); From 615578c0e034055e5b9dbcd631b25522e18079e5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Sun, 9 Aug 2026 14:44:03 -0700 Subject: [PATCH 2/2] fix(e2e): continue fixture image cleanup after failure Signed-off-by: Apurv Kumaria --- ...w-plugin-runtime-exdev-trusted-prebuild.ts | 10 +++- ...gin-runtime-exdev-trusted-prebuild.test.ts | 48 +++++++++++++++++-- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts b/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts index dc6d5a313c..decec70a4b 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev-trusted-prebuild.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; @@ -129,6 +128,7 @@ export function registerTrustedPluginFixtureImageCleanup(options: { }): TrustedPluginFixtureImageCleanup { const images: Array<{ imageRef: string; version: "v1" | "v2" }> = []; options.cleanup.add("remove trusted EXDEV fixture images", async () => { + const failures: string[] = []; for (const image of [...images].reverse()) { const result = await options.host.command( "docker", @@ -139,8 +139,14 @@ export function registerTrustedPluginFixtureImageCleanup(options: { timeoutMs: 60_000, }, ); - assert.equal(result.exitCode, 0, resultText(result)); + if (result.exitCode !== 0) { + const detail = + resultText(result).trim() || + (result.signal ? `signal=${result.signal}` : `exit=${result.exitCode ?? "unknown"}`); + failures.push(`${image.imageRef}: ${detail}`); + } } + assert.deepEqual(failures, [], "failed to remove trusted EXDEV fixture images"); }); return { track: (imageRef, version) => { diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts index 90cb19f24f..58de5287c5 100644 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-trusted-prebuild.test.ts @@ -13,13 +13,13 @@ import { const IMAGE_ID = `sha256:${"a".repeat(64)}`; -function commandResult(): ShellProbeResult { +function commandResult(exitCode = 0, stderr = ""): ShellProbeResult { return { artifacts: { result: "result.json", stderr: "stderr.txt", stdout: "stdout.txt" }, command: ["docker", "image", "rm"], - exitCode: 0, + exitCode, signal: null, - stderr: "", + stderr, stdout: "", timedOut: false, }; @@ -83,4 +83,46 @@ describe("trusted EXDEV fixture image cleanup", () => { expect.objectContaining({ artifactName: "cleanup-trusted-exdev-image-v2" }), ); }); + + it("continues reclaiming images after a removal fails and reports the failure", async () => { + const cleanup = new CleanupRegistry(); + let removal = 0; + const host = { + command: vi.fn(async () => { + removal += 1; + return removal === 1 ? commandResult(1, "removal denied") : commandResult(); + }), + }; + const images = registerTrustedPluginFixtureImageCleanup({ + cleanup, + environment: { PATH: "/usr/bin" }, + host, + }); + const imageV1 = trustedExdevImageRef("cleanup-failure-v1"); + const imageV2 = trustedExdevImageRef("cleanup-failure-v2"); + images.track(imageV1, "v1"); + images.track(imageV2, "v2"); + + const result = await cleanup.runAll(); + + expect(result.passed).toEqual([]); + expect(result.failures).toEqual([ + { + message: expect.stringContaining(`${imageV2}: removal denied`), + name: "remove trusted EXDEV fixture images", + }, + ]); + expect(host.command).toHaveBeenNthCalledWith( + 1, + "docker", + ["image", "rm", "--force", imageV2], + expect.objectContaining({ artifactName: "cleanup-trusted-exdev-image-v2" }), + ); + expect(host.command).toHaveBeenNthCalledWith( + 2, + "docker", + ["image", "rm", "--force", imageV1], + expect.objectContaining({ artifactName: "cleanup-trusted-exdev-image-v1" }), + ); + }); });