diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f92080f471d..7f3940c3cc8 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -2781,9 +2781,6 @@ jobs: npx vitest run --project e2e-live \ test/e2e/live/full-e2e.test.ts \ --silent=false --reporter=default - npx vitest run --project e2e-live \ - test/e2e/live/onboard-progress-budget.test.ts \ - --silent=false --reporter=default - name: Upload full-e2e artifacts if: always() diff --git a/docs/deployment/install-openclaw-plugins.mdx b/docs/deployment/install-openclaw-plugins.mdx index 1f4396b107e..472220b1b5c 100644 --- a/docs/deployment/install-openclaw-plugins.mdx +++ b/docs/deployment/install-openclaw-plugins.mdx @@ -74,6 +74,8 @@ To run a second sandbox alongside an existing one, use a dedicated build directo ## Build Performance Custom plugin images are normal Docker builds, so build time depends on the build context size and the Docker layer cache rather than on NemoClaw. +NemoClaw sends user-supplied `--from` contexts to the OpenShell gateway builder and reserves its host-side local BuildKit prebuild for contexts that NemoClaw generates itself. +On a local Docker-driver gateway, a `Local BuildKit build skipped` notice is expected and the custom image build continues through the gateway. Keep the build context small and dedicated. The Dockerfile's parent directory is staged as the build context before the Docker build starts, so a broad directory can make onboarding look stuck while Docker is only preparing context. diff --git a/docs/manage-sandboxes/install-plugins-hermes.mdx b/docs/manage-sandboxes/install-plugins-hermes.mdx index 09dbc276a56..e2ad83e2a9d 100644 --- a/docs/manage-sandboxes/install-plugins-hermes.mdx +++ b/docs/manage-sandboxes/install-plugins-hermes.mdx @@ -54,6 +54,8 @@ Put the custom Dockerfile and every file it needs to `COPY` in one directory. `nemohermes onboard --from ` sends the Dockerfile's parent directory as the Docker build context. Add a `.dockerignore` next to the Dockerfile to keep local caches, generated artifacts, model files, or other unneeded paths out of the staged context. NemoClaw still excludes credential-like paths such as `.env*`, `.ssh/`, `.aws/`, `.npmrc`, `secrets/`, `*.pem`, and `*.key`, even if `.dockerignore` tries to include them. +NemoClaw sends user-supplied `--from` contexts to the OpenShell gateway builder and reserves its host-side local BuildKit prebuild for contexts that NemoClaw generates itself. +On a local Docker-driver gateway, a `Local BuildKit build skipped` notice is expected and the custom image build continues through the gateway. ```text my-hermes-plugin-sandbox/ diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index 7ece3873aaa..49dfd69f413 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -342,6 +342,12 @@ If the staged context is larger than 100 MB, onboarding prints a warning before Move the Dockerfile into a smaller dedicated directory or add `.dockerignore` entries for generated artifacts to shrink the context. If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory. + +NemoClaw builds user-supplied `--from` contexts with the OpenShell gateway builder. +The host-side local BuildKit prebuild is limited to build contexts generated entirely by NemoClaw. +On a local Docker-driver gateway, a `Local BuildKit build skipped` notice is expected and onboarding continues with the custom image. + + ```bash nemohermes onboard --from path/to/Dockerfile ``` @@ -400,6 +406,7 @@ Combining `--from ` with non-interactive onboarding requires one of Use a custom Dockerfile for the sandbox image. This variant of `nemohermes onboard` accepts a `--from ` argument to build the sandbox from a user-supplied Dockerfile instead of the default NemoClaw image. +The user-supplied context uses the OpenShell gateway builder instead of NemoClaw's host-side local BuildKit prebuild. ```bash nemohermes onboard --from ./Dockerfile.custom diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 1f34583dffc..2338adc1869 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -446,6 +446,12 @@ If the staged context is larger than 100 MB, onboarding prints a warning before Move the Dockerfile into a smaller dedicated directory or add `.dockerignore` entries for generated artifacts to shrink the context. If the directory contains unreadable files (for example, Windows system files visible in WSL), onboarding exits with an error suggesting you move the Dockerfile to a dedicated directory. + +NemoClaw builds user-supplied `--from` contexts with the OpenShell gateway builder. +The host-side local BuildKit prebuild is limited to build contexts generated entirely by NemoClaw. +On a local Docker-driver gateway, a `Local BuildKit build skipped` notice is expected and onboarding continues with the custom image. + + ```bash $$nemoclaw onboard --from path/to/Dockerfile ``` @@ -504,6 +510,7 @@ Combining `--from ` with non-interactive onboarding requires one of Use a custom Dockerfile for the sandbox image. This variant of `$$nemoclaw onboard` accepts a `--from ` argument to build the sandbox from a user-supplied Dockerfile instead of the default NemoClaw image. +The user-supplied context uses the OpenShell gateway builder instead of NemoClaw's host-side local BuildKit prebuild. ```bash $$nemoclaw onboard --from ./Dockerfile.custom diff --git a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts index d94a1a0c345..8875d4b75e8 100644 --- a/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-custom-image-preflight.test.ts @@ -40,6 +40,7 @@ describe("preflightRebuildImage", () => { buildCtx: "/tmp/rebuild-managed-context", stagedDockerfile: "/tmp/rebuild-managed-context/Dockerfile", cleanupBuildCtx, + origin: "generated" as const, })); const result = await preflightRebuildImage(input(null), { stageBuildContext, diff --git a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts index e267f449b09..374103bd7bb 100644 --- a/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts +++ b/src/lib/actions/sandbox/rebuild-gpu-opt-out.test.ts @@ -244,6 +244,7 @@ describe("buildRebuildRecreateOnboardOpts", () => { stagedDockerfile: "/tmp/dcode-rebuild/Dockerfile", buildId: "dcode-build", cleanupBuildCtx: () => true, + origin: "generated" as const, }, gatewayName: "nemoclaw", }; diff --git a/src/lib/actions/sandbox/rebuild-managed-image-preflight.test.ts b/src/lib/actions/sandbox/rebuild-managed-image-preflight.test.ts index 7810b62c81b..e1b5335a33c 100644 --- a/src/lib/actions/sandbox/rebuild-managed-image-preflight.test.ts +++ b/src/lib/actions/sandbox/rebuild-managed-image-preflight.test.ts @@ -61,6 +61,7 @@ describe("managed DCode rebuild image preflight", () => { buildCtx, stagedDockerfile, cleanupBuildCtx, + origin: "generated" as const, })); const prepareDockerfilePatch = vi.fn(async () => ({ buildId: "dcode-build-1", @@ -260,7 +261,12 @@ describe("managed DCode rebuild image preflight", () => { return true; }); const result = await prepareManagedDcodeRebuildImage(dcodeInput(), { - stageBuildContext: vi.fn(() => ({ buildCtx, stagedDockerfile, cleanupBuildCtx })), + stageBuildContext: vi.fn(() => ({ + buildCtx, + stagedDockerfile, + cleanupBuildCtx, + origin: "generated" as const, + })), prepareDockerfilePatch: vi.fn(async () => ({ buildId: "dcode-build-cleanup", resolvedBaseImage: null, @@ -292,6 +298,7 @@ describe("managed DCode rebuild image preflight", () => { buildCtx, stagedDockerfile, cleanupBuildCtx, + origin: "generated" as const, })), prepareDockerfilePatch: vi.fn(async () => ({ buildId: "dcode-build-failure", diff --git a/src/lib/agent/base-image.ts b/src/lib/agent/base-image.ts index b04523674bb..82bd4fc2541 100644 --- a/src/lib/agent/base-image.ts +++ b/src/lib/agent/base-image.ts @@ -15,6 +15,7 @@ import { dockerTag, } from "../adapters/docker"; import { ROOT } from "../runner"; +import { SANDBOX_BUILD_CONTEXT_PREFIX } from "../sandbox/build-context"; import { buildLocalBaseTag, createSandboxBaseImageResolutionKey, @@ -324,7 +325,7 @@ export function createAgentSandbox( } const { imageTag: baseImageRef, resolutionMetadata } = ensureAgentBaseImage(agent, options); - const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), SANDBOX_BUILD_CONTEXT_PREFIX)); fs.cpSync(ROOT, buildCtx, { recursive: true, filter: (src) => { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 884fb80c86d..c317e01bbd3 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2771,7 +2771,7 @@ async function createSandboxWithBaseImageResolution( // in env args, so it must not persist in /tmp after a failed sandbox create. // run() calls process.exit() on failure (bypassing normal control flow), so // we register a process 'exit' handler to guarantee cleanup in all cases. - const { buildCtx, stagedDockerfile, cleanupBuildCtx } = + const { buildCtx, stagedDockerfile, origin, cleanupBuildCtx } = preparedDcodeRebuild.resolveSandboxBuildContext( { preparedBuildContext, @@ -2798,6 +2798,7 @@ async function createSandboxWithBaseImageResolution( "openclaw-sandbox.yaml", ); const basePolicyPath = (agent && agentOnboard.getAgentPolicyPath(agent)) || defaultPolicyPath; + const dockerDriverGateway = isLinuxDockerDriverGatewayEnabled(); const { activeMessagingChannels, initialSandboxPolicy, @@ -2818,7 +2819,7 @@ async function createSandboxWithBaseImageResolution( extraProviders: registry.listExtraProviders(), hermesToolGateways, sandboxGpuConfig: effectiveSandboxGpuConfig, - dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(), + dockerDriverGateway, appendResourceFlags: (args) => appendResourceFlagsForProfile(args, resourceProfile, getOpenshellBinary(), { isNonInteractive, @@ -2884,8 +2885,7 @@ async function createSandboxWithBaseImageResolution( hermesDashboardState, manageDashboard, openshellShellCommand, - // Transitional BuildKit handoff removal is tracked by #6258. - prebuild: { buildCtx, buildId, dockerDriverGateway: isLinuxDockerDriverGatewayEnabled() }, + prebuild: { buildCtx, buildId, dockerDriverGateway, origin }, }); const dockerGpuCreatePatch = dockerGpuSandboxCreate.createDockerGpuSandboxCreatePatch({ enabled: useDockerGpuPatch, @@ -3007,7 +3007,7 @@ async function createSandboxWithBaseImageResolution( // when applicable, then gates host-network local inference reachability (#4509). dockerGpuLocalInference.verifyGpuSandboxAfterReady(effectiveSandboxGpuConfig, provider, { sandboxName, - dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(), + dockerDriverGateway, useDockerGpuPatch, verifyDirectSandboxGpu, verifyGpuOrExit: dockerGpuCreatePatch.verifyGpuOrExit, diff --git a/src/lib/onboard/build-context-stage.test.ts b/src/lib/onboard/build-context-stage.test.ts index 75a0cd2959a..09c532a1bdf 100644 --- a/src/lib/onboard/build-context-stage.test.ts +++ b/src/lib/onboard/build-context-stage.test.ts @@ -53,6 +53,7 @@ describe("stageCreateSandboxBuildContext", () => { ` Docker build context: ${buildContextDir}`, ]); expect(fs.readFileSync(result.stagedDockerfile, "utf-8")).toBe("FROM scratch\n"); + expect(result.origin).toBe("custom"); expect(fs.existsSync(path.join(result.buildCtx, "extra.txt"))).toBe(true); expect(fs.existsSync(path.join(result.buildCtx, ".ssh"))).toBe(false); expect(result.cleanupBuildCtx()).toBe(true); @@ -198,6 +199,7 @@ describe("stageCreateSandboxBuildContext", () => { }); expect(agentResult.buildCtx).toBe(agentBuild.buildCtx); + expect(agentResult.origin).toBe("generated"); expect(createAgentSandbox).toHaveBeenCalledWith({ name: "hermes" }); expect(stageDefaultSandboxBuildContext).not.toHaveBeenCalled(); @@ -210,6 +212,7 @@ describe("stageCreateSandboxBuildContext", () => { }); expect(defaultResult.buildCtx).toBe(defaultBuild.buildCtx); + expect(defaultResult.origin).toBe("generated"); expect(stageDefaultSandboxBuildContext).toHaveBeenCalledWith("/repo"); }); }); diff --git a/src/lib/onboard/build-context-stage.ts b/src/lib/onboard/build-context-stage.ts index 38c3b7618d1..7ef8ddafbcb 100644 --- a/src/lib/onboard/build-context-stage.ts +++ b/src/lib/onboard/build-context-stage.ts @@ -9,6 +9,8 @@ import type { AgentDefinition } from "../agent/defs"; import { isErrnoException } from "../core/errno"; import { collectBuildContextStats, + SANDBOX_BUILD_CONTEXT_PREFIX, + type SandboxBuildContextOrigin, type StagedBuildContext, stageOptimizedSandboxBuildContext, } from "../sandbox/build-context"; @@ -31,6 +33,7 @@ export interface CreateSandboxBuildContextInput { } export interface CreateSandboxBuildContextResult extends StagedBuildContext { + origin: SandboxBuildContextOrigin; cleanupBuildCtx(): boolean; } @@ -57,6 +60,7 @@ export function stageCreateSandboxBuildContext( const warn = input.warn ?? console.warn; const error = input.error ?? console.error; const exit = input.exit ?? ((code?: number): never => process.exit(code)); + const origin = input.fromDockerfile ? "custom" : "generated"; let build: StagedBuildContext; @@ -92,7 +96,7 @@ export function stageCreateSandboxBuildContext( " The --from flag sends the Dockerfile's parent directory to Docker; use a dedicated directory if this is not intentional.", ); } - const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-")); + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), SANDBOX_BUILD_CONTEXT_PREFIX)); const stagedDockerfile = path.join(buildCtx, "Dockerfile"); const cleanupCustomBuildCtx = (): void => { try { @@ -133,6 +137,7 @@ export function stageCreateSandboxBuildContext( return { ...build, + origin, cleanupBuildCtx: createCleanupBuildContext(build.buildCtx), }; } diff --git a/src/lib/onboard/machine/live-flow-slice.test.ts b/src/lib/onboard/machine/live-flow-slice.test.ts index 0716c90f934..ba94602f743 100644 --- a/src/lib/onboard/machine/live-flow-slice.test.ts +++ b/src/lib/onboard/machine/live-flow-slice.test.ts @@ -1,7 +1,7 @@ // 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 { afterEach, describe, expect, it, vi } from "vitest"; import { createSession, type Session } from "../../state/onboard-session"; import { @@ -69,6 +69,11 @@ function phase( } describe("runLiveOnboardFlowSlice", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it("uses the strict slice runner for fresh matching entry states", async () => { const runSlice = vi.fn(async ({ context }) => ({ context: { value: context.value + 1 }, @@ -178,6 +183,48 @@ describe("runLiveOnboardFlowSlice", () => { expect(applyCompatibleResult).toHaveBeenCalledOnce(); }); + it("keeps compatibility phases visible through the default heartbeat reporter", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + let markPhaseStarted!: () => void; + let releasePhase!: () => void; + const phaseStarted = new Promise((resolve) => { + markPhaseStarted = resolve; + }); + const phaseReleased = new Promise((resolve) => { + releasePhase = resolve; + }); + const liveRuntime = runtime("provider_selection"); + const pendingGateway: OnboardSequencePhase = { + state: "gateway", + async run(context) { + markPhaseStarted(); + await phaseReleased; + return { context, result: advanceTo("inference") }; + }, + }; + + const running = runLiveOnboardFlowSlice({ + context: { value: 1 }, + runtime: liveRuntime.runtime, + phases: [pendingGateway], + runWhenState: ["gateway"], + compatibilityWhenState: ["provider_selection"], + runSlice: vi.fn(), + applyCompatibleResult: (result) => liveRuntime.applyResult(result), + }); + await phaseStarted; + try { + await vi.advanceTimersByTimeAsync(30_000); + expect(log).toHaveBeenCalledWith(" ⏳ Still working on Gateway startup… (30s elapsed)"); + } finally { + releasePhase(); + await running; + } + expect(vi.getTimerCount()).toBe(0); + }); + it("rejects non-resume states before the slice entry before running side effects", async () => { const liveRuntime = runtime("init"); const blocked = phase("provider_selection", 2); diff --git a/src/lib/onboard/prepared-dcode-rebuild.test.ts b/src/lib/onboard/prepared-dcode-rebuild.test.ts index 0c38c079d96..571a0d1c540 100644 --- a/src/lib/onboard/prepared-dcode-rebuild.test.ts +++ b/src/lib/onboard/prepared-dcode-rebuild.test.ts @@ -19,6 +19,7 @@ const preparedBuildContext: PreparedSandboxBuildContext = { stagedDockerfile: "/tmp/prepared-dcode/Dockerfile", buildId: "6195-prepared", cleanupBuildCtx: () => true, + origin: "generated", }; const preparedOptions: PreparedDcodeRebuildOptions = { resume: true, @@ -119,6 +120,7 @@ describe("prepared DCode rebuild adapter", () => { buildCtx: "/tmp/ordinary", stagedDockerfile: "/tmp/ordinary/Dockerfile", cleanupBuildCtx: () => true, + origin: "generated" as const, })); const onExit = vi.fn(); diff --git a/src/lib/onboard/sandbox-create-launch.test.ts b/src/lib/onboard/sandbox-create-launch.test.ts index 1a890c7a169..64dcfa71134 100644 --- a/src/lib/onboard/sandbox-create-launch.test.ts +++ b/src/lib/onboard/sandbox-create-launch.test.ts @@ -6,8 +6,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SANDBOX_BUILD_CONTEXT_PREFIX } from "../sandbox/build-context"; import { createOpenshellCliHelpers } from "./openshell-cli"; import { prepareSandboxCreateLaunch, @@ -15,6 +16,20 @@ import { } from "./sandbox-create-launch"; const disabledHermesDashboardState = { config: null, enabled: false }; +const temporaryBuildContexts: string[] = []; + +function createTrustedBuildContext(): string { + const buildCtx = fs.mkdtempSync(path.join(os.tmpdir(), SANDBOX_BUILD_CONTEXT_PREFIX)); + temporaryBuildContexts.push(buildCtx); + fs.writeFileSync(path.join(buildCtx, "Dockerfile"), "FROM scratch\n"); + return buildCtx; +} + +afterEach(() => { + for (const buildCtx of temporaryBuildContexts.splice(0)) { + fs.rmSync(buildCtx, { recursive: true, force: true }); + } +}); describe("prepareSandboxCreateLaunch", () => { it("builds the sandbox create command and runtime env envelope", () => { @@ -265,11 +280,13 @@ describe("prepareSandboxCreateLaunch", () => { describe("prepareSandboxCreateLaunchWithPrebuild", () => { it("hands the build-qualified image to the canonical launch renderer", async () => { + const buildCtx = createTrustedBuildContext(); + const dockerfile = path.join(buildCtx, "Dockerfile"); const buildImage = vi.fn(async () => 0); const result = await prepareSandboxCreateLaunchWithPrebuild({ agent: null, chatUiUrl: "", - createArgs: ["--from", "/tmp/build/Dockerfile", "--name", "demo"], + createArgs: ["--from", dockerfile, "--name", "demo"], env: {}, extraPlaceholderKeys: [], getDashboardForwardPort: () => "0", @@ -279,12 +296,13 @@ describe("prepareSandboxCreateLaunchWithPrebuild", () => { sandboxName: "demo", buildEnv: () => ({}), prebuild: { - buildCtx: "/tmp/build", + buildCtx, buildId: "build-123", dockerDriverGateway: true, env: { NEMOCLAW_SANDBOX_PREBUILD: "1" }, buildImage, log: vi.fn(), + origin: "generated", }, }); @@ -299,10 +317,12 @@ describe("prepareSandboxCreateLaunchWithPrebuild", () => { }); it("renders the original Dockerfile after a local build failure", async () => { + const buildCtx = createTrustedBuildContext(); + const dockerfile = path.join(buildCtx, "Dockerfile"); const result = await prepareSandboxCreateLaunchWithPrebuild({ agent: null, chatUiUrl: "", - createArgs: ["--from", "/tmp/build/Dockerfile", "--name", "demo"], + createArgs: ["--from", dockerfile, "--name", "demo"], env: {}, extraPlaceholderKeys: [], getDashboardForwardPort: () => "0", @@ -312,22 +332,21 @@ describe("prepareSandboxCreateLaunchWithPrebuild", () => { sandboxName: "demo", buildEnv: () => ({}), prebuild: { - buildCtx: "/tmp/build", + buildCtx, buildId: "build-123", dockerDriverGateway: true, env: { NEMOCLAW_SANDBOX_PREBUILD: "1" }, buildImage: async () => 1, log: vi.fn(), + origin: "generated", }, }); expect(result.prebuild).toEqual({ - createArgs: ["--from", "/tmp/build/Dockerfile", "--name", "demo"], + createArgs: ["--from", dockerfile, "--name", "demo"], imageRef: null, }); - expect(result.createCommand).toContain( - "sandbox create --from /tmp/build/Dockerfile --name demo", - ); + expect(result.createCommand).toContain(`sandbox create --from ${dockerfile} --name demo`); expect(result.createCommand).not.toContain("nemoclaw-sandbox-local"); }); }); diff --git a/src/lib/onboard/sandbox-prebuild.test.ts b/src/lib/onboard/sandbox-prebuild.test.ts index dee44f0f262..74aaa63f404 100644 --- a/src/lib/onboard/sandbox-prebuild.test.ts +++ b/src/lib/onboard/sandbox-prebuild.test.ts @@ -1,8 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; +import { SANDBOX_BUILD_CONTEXT_PREFIX } from "../sandbox/build-context"; import { dockerBuildSubprocessEnv, prebuildSandboxImageIfEligible, @@ -10,14 +15,31 @@ import { sandboxLocalImageRef, } from "./sandbox-prebuild"; -const BUILD_CONTEXT = "/tmp/nemoclaw-build-abc"; const BUILD_ID = "1234567890"; -const DOCKERFILE = `${BUILD_CONTEXT}/Dockerfile`; -const CREATE_ARGS = ["--from", DOCKERFILE, "--name", "alpha"]; +const temporaryDirectories: string[] = []; + +function createBuildContext( + parent = os.tmpdir(), + prefix = SANDBOX_BUILD_CONTEXT_PREFIX, +): { + buildCtx: string; + createArgs: string[]; + dockerfile: string; +} { + const buildCtx = fs.mkdtempSync(path.join(parent, prefix)); + temporaryDirectories.push(buildCtx); + const dockerfile = path.join(buildCtx, "Dockerfile"); + fs.writeFileSync(dockerfile, "FROM scratch\n"); + return { buildCtx, createArgs: ["--from", dockerfile, "--name", "alpha"], dockerfile }; +} describe("sandbox BuildKit prebuild", () => { afterEach(() => { vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } }); it("keeps Docker runtime settings while dropping secrets and control-plane state", () => { @@ -32,6 +54,8 @@ describe("sandbox BuildKit prebuild", () => { vi.stubEnv("GITHUB_TOKEN", "secret"); vi.stubEnv("KUBECONFIG", "/home/user/.kube/config"); vi.stubEnv("SSH_AUTH_SOCK", "/tmp/agent.sock"); + vi.stubEnv("RUST_LOG", "debug"); + vi.stubEnv("RUST_BACKTRACE", "1"); vi.stubEnv("OPENSHELL_GATEWAY", "nemoclaw"); vi.stubEnv("GRPC_VERBOSITY", "debug"); @@ -51,6 +75,8 @@ describe("sandbox BuildKit prebuild", () => { "GITHUB_TOKEN", "KUBECONFIG", "SSH_AUTH_SOCK", + "RUST_LOG", + "RUST_BACKTRACE", "OPENSHELL_GATEWAY", "GRPC_VERBOSITY", ]) { @@ -82,11 +108,13 @@ describe("sandbox BuildKit prebuild", () => { }); it("skips the build when create arguments do not use the staged Dockerfile", async () => { + const { buildCtx } = createBuildContext(); const buildImage = vi.fn(async () => 0); await expect( prebuildSandboxImageIfEligible({ - buildCtx: BUILD_CONTEXT, + buildCtx, buildId: BUILD_ID, + origin: "generated", createArgs: ["--from", "/other/Dockerfile"], sandboxName: "alpha", dockerDriverGateway: true, @@ -97,12 +125,199 @@ describe("sandbox BuildKit prebuild", () => { expect(buildImage).not.toHaveBeenCalled(); }); + it("keeps user-supplied Dockerfiles on the gateway builder", async () => { + const { buildCtx, createArgs } = createBuildContext(); + const buildImage = vi.fn(async () => 0); + const log = vi.fn(); + + await expect( + prebuildSandboxImageIfEligible({ + buildCtx, + buildId: BUILD_ID, + origin: "custom", + createArgs, + sandboxName: "alpha", + dockerDriverGateway: true, + env: {}, + buildImage, + log, + }), + ).resolves.toEqual({ createArgs, imageRef: null }); + expect(buildImage).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("custom Dockerfile")); + }); + + it("skips host Docker for a staged-looking context outside the OS temp directory", async () => { + const { buildCtx, createArgs } = createBuildContext(); + const reportedTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-other-temp-")); + temporaryDirectories.push(reportedTempRoot); + vi.spyOn(os, "tmpdir").mockReturnValue(reportedTempRoot); + const buildImage = vi.fn(async () => 0); + + await expect( + prebuildSandboxImageIfEligible({ + buildCtx, + buildId: BUILD_ID, + origin: "generated", + createArgs, + sandboxName: "alpha", + dockerDriverGateway: true, + env: {}, + buildImage, + log: () => {}, + }), + ).resolves.toEqual({ createArgs, imageRef: null }); + expect(buildImage).not.toHaveBeenCalled(); + }); + + it("skips host Docker for a temporary context without the staging prefix", async () => { + const { buildCtx, createArgs } = createBuildContext(os.tmpdir(), "untrusted-build-"); + const buildImage = vi.fn(async () => 0); + + await expect( + prebuildSandboxImageIfEligible({ + buildCtx, + buildId: BUILD_ID, + origin: "generated", + createArgs, + sandboxName: "alpha", + dockerDriverGateway: true, + env: {}, + buildImage, + log: () => {}, + }), + ).resolves.toEqual({ createArgs, imageRef: null }); + expect(buildImage).not.toHaveBeenCalled(); + }); + + it("skips host Docker for a group-writable staged context", async () => { + const { buildCtx, createArgs } = createBuildContext(); + fs.chmodSync(buildCtx, 0o770); + const buildImage = vi.fn(async () => 0); + const log = vi.fn(); + + await expect( + prebuildSandboxImageIfEligible({ + buildCtx, + buildId: BUILD_ID, + origin: "generated", + createArgs, + sandboxName: "alpha", + dockerDriverGateway: true, + env: {}, + buildImage, + log, + }), + ).resolves.toEqual({ createArgs, imageRef: null }); + expect(buildImage).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("failed trust validation")); + }); + + it("skips host Docker for a symlinked staged Dockerfile", async () => { + const { buildCtx, createArgs, dockerfile } = createBuildContext(); + const target = path.join(buildCtx, "Dockerfile.regular"); + fs.renameSync(dockerfile, target); + fs.symlinkSync(target, dockerfile); + const buildImage = vi.fn(async () => 0); + + await expect( + prebuildSandboxImageIfEligible({ + buildCtx, + buildId: BUILD_ID, + origin: "generated", + createArgs, + sandboxName: "alpha", + dockerDriverGateway: true, + env: {}, + buildImage, + log: () => {}, + }), + ).resolves.toEqual({ createArgs, imageRef: null }); + expect(buildImage).not.toHaveBeenCalled(); + }); + + it("skips host Docker for a non-regular staged Dockerfile", async () => { + const { buildCtx, createArgs, dockerfile } = createBuildContext(); + fs.rmSync(dockerfile); + fs.mkdirSync(dockerfile); + const buildImage = vi.fn(async () => 0); + + await expect( + prebuildSandboxImageIfEligible({ + buildCtx, + buildId: BUILD_ID, + origin: "generated", + createArgs, + sandboxName: "alpha", + dockerDriverGateway: true, + env: {}, + buildImage, + log: () => {}, + }), + ).resolves.toEqual({ createArgs, imageRef: null }); + expect(buildImage).not.toHaveBeenCalled(); + }); + + it("skips host Docker when the staged Dockerfile resolves outside its context", async () => { + const { buildCtx, createArgs, dockerfile } = createBuildContext(); + const outsideDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-prebuild-outside-")); + temporaryDirectories.push(outsideDirectory); + const outside = path.join(outsideDirectory, "Dockerfile"); + fs.rmSync(dockerfile); + fs.writeFileSync(outside, "FROM scratch\n"); + fs.symlinkSync(outside, dockerfile); + const buildImage = vi.fn(async () => 0); + + await expect( + prebuildSandboxImageIfEligible({ + buildCtx, + buildId: BUILD_ID, + origin: "generated", + createArgs, + sandboxName: "alpha", + dockerDriverGateway: true, + env: {}, + buildImage, + log: () => {}, + }), + ).resolves.toEqual({ createArgs, imageRef: null }); + expect(buildImage).not.toHaveBeenCalled(); + }); + + it("logs filesystem inspection errors distinctly before falling back", async () => { + const { buildCtx, createArgs } = createBuildContext(); + const buildImage = vi.fn(async () => 0); + const log = vi.fn(); + vi.spyOn(fs, "openSync").mockImplementation(() => { + throw Object.assign(new Error("too many open files"), { code: "EMFILE" }); + }); + + await expect( + prebuildSandboxImageIfEligible({ + buildCtx, + buildId: BUILD_ID, + origin: "generated", + createArgs, + sandboxName: "alpha", + dockerDriverGateway: true, + env: {}, + buildImage, + log, + }), + ).resolves.toEqual({ createArgs, imageRef: null }); + expect(buildImage).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("too many open files")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("could not be inspected")); + }); + it("uses the argv-based Docker helper and returns the local image on success", async () => { + const { buildCtx, createArgs, dockerfile } = createBuildContext(); const buildImage = vi.fn(async () => 0); const result = await prebuildSandboxImageIfEligible({ - buildCtx: BUILD_CONTEXT, + buildCtx, buildId: BUILD_ID, - createArgs: CREATE_ARGS, + origin: "generated", + createArgs, sandboxName: "alpha", dockerDriverGateway: true, env: {}, @@ -117,8 +332,8 @@ describe("sandbox BuildKit prebuild", () => { "-t", "nemoclaw-sandbox-local:alpha-1234567890", "-f", - DOCKERFILE, - BUILD_CONTEXT, + dockerfile, + buildCtx, ], expect.objectContaining({ env: expect.objectContaining({ DOCKER_BUILDKIT: "1" }), @@ -135,24 +350,28 @@ describe("sandbox BuildKit prebuild", () => { ["nonzero result", async () => 1], ["missing exit status", async () => null], ])("falls back to OpenShell after a %s", async (_label, buildImage) => { + const { buildCtx, createArgs } = createBuildContext(); const result = await prebuildSandboxImageIfEligible({ - buildCtx: BUILD_CONTEXT, + buildCtx, buildId: BUILD_ID, - createArgs: CREATE_ARGS, + origin: "generated", + createArgs, sandboxName: "alpha", dockerDriverGateway: true, env: {}, buildImage, log: () => {}, }); - expect(result).toEqual({ createArgs: CREATE_ARGS, imageRef: null }); + expect(result).toEqual({ createArgs, imageRef: null }); }); it("falls back to OpenShell when the Docker helper throws", async () => { + const { buildCtx, createArgs } = createBuildContext(); const result = await prebuildSandboxImageIfEligible({ - buildCtx: BUILD_CONTEXT, + buildCtx, buildId: BUILD_ID, - createArgs: CREATE_ARGS, + origin: "generated", + createArgs, sandboxName: "alpha", dockerDriverGateway: true, env: {}, @@ -161,6 +380,6 @@ describe("sandbox BuildKit prebuild", () => { }, log: () => {}, }); - expect(result).toEqual({ createArgs: CREATE_ARGS, imageRef: null }); + expect(result).toEqual({ createArgs, imageRef: null }); }); }); diff --git a/src/lib/onboard/sandbox-prebuild.ts b/src/lib/onboard/sandbox-prebuild.ts index 9123502b8a5..f4feb12d9c2 100644 --- a/src/lib/onboard/sandbox-prebuild.ts +++ b/src/lib/onboard/sandbox-prebuild.ts @@ -1,7 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { dockerSpawn } from "../adapters/docker/exec"; +import { + SANDBOX_BUILD_CONTEXT_PREFIX, + type SandboxBuildContextOrigin, +} from "../sandbox/build-context"; import { buildSubprocessEnv } from "../subprocess-env"; const TRUTHY_FLAG_VALUES = new Set(["1", "true", "yes", "on"]); @@ -21,6 +29,7 @@ export interface SandboxPrebuildInput { createArgs: readonly string[]; sandboxName: string; dockerDriverGateway: boolean; + origin: SandboxBuildContextOrigin; env?: NodeJS.ProcessEnv; buildImage?: ( args: readonly string[], @@ -34,6 +43,49 @@ export interface SandboxPrebuildResult { imageRef: string | null; } +interface TrustedStagedBuildContext { + buildCtx: string; + dockerfile: string; +} + +/** + * Resolve the private staged context before handing it to the host Docker daemon. + * The context stagers create direct children of the OS temp directory with this + * prefix; fail closed if a future caller supplies anything else. + */ +function resolveTrustedStagedBuildContext(buildCtx: string): TrustedStagedBuildContext | null { + let descriptor: number | undefined; + try { + const temporaryRoot = fs.realpathSync(os.tmpdir()); + const resolvedBuildCtx = fs.realpathSync(buildCtx); + const context = fs.statSync(resolvedBuildCtx); + if ( + path.dirname(resolvedBuildCtx) !== temporaryRoot || + !path.basename(resolvedBuildCtx).startsWith(SANDBOX_BUILD_CONTEXT_PREFIX) || + !context.isDirectory() || + (context.mode & 0o022) !== 0 + ) { + return null; + } + + const dockerfile = path.join(resolvedBuildCtx, "Dockerfile"); + const resolvedDockerfile = fs.realpathSync(dockerfile); + if (path.dirname(resolvedDockerfile) !== resolvedBuildCtx) return null; + + const noFollow = fs.constants.O_NOFOLLOW; + if (typeof noFollow !== "number") return null; + const nonBlocking = typeof fs.constants.O_NONBLOCK === "number" ? fs.constants.O_NONBLOCK : 0; + + descriptor = fs.openSync(dockerfile, fs.constants.O_RDONLY | noFollow | nonBlocking); + const opened = fs.fstatSync(descriptor); + if (!opened.isFile()) return null; + + return { buildCtx: resolvedBuildCtx, dockerfile: resolvedDockerfile }; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + /** Restrict the host Docker build to environment values used by Docker itself. */ export function dockerBuildSubprocessEnv(): Record { const env = buildSubprocessEnv(); @@ -84,8 +136,9 @@ export function sandboxLocalImageRef(sandboxName: string, buildId: string): stri } /** - * Build the already-staged sandbox context with BuildKit on the shared local - * Docker daemon. Any failure preserves the original OpenShell build path. + * Build a NemoClaw-generated staged context with BuildKit on the shared local + * Docker daemon. User-supplied Dockerfiles stay on the OpenShell gateway + * builder trust boundary, and any failure preserves that original build path. * Remove this bridge once OpenShell uses BuildKit for this local-driver path; * extraction and observable retirement criteria are tracked by #6258. */ @@ -94,15 +147,42 @@ export async function prebuildSandboxImageIfEligible( ): Promise { const createArgs = [...input.createArgs]; const env = input.env ?? process.env; + const log = input.log ?? console.log; if (!resolveSandboxPrebuildEnabled(env, input.dockerDriverGateway)) { return { createArgs, imageRef: null }; } + if (input.origin !== "generated") { + log( + " Local BuildKit build skipped for a custom Dockerfile; using the gateway builder instead.", + ); + return { createArgs, imageRef: null }; + } const fromIndex = createArgs.indexOf("--from"); - if (fromIndex < 0 || createArgs[fromIndex + 1] !== `${input.buildCtx}/Dockerfile`) { + const fromDockerfile = createArgs[fromIndex + 1]; + if ( + fromIndex < 0 || + !fromDockerfile || + path.resolve(fromDockerfile) !== path.resolve(input.buildCtx, "Dockerfile") + ) { + return { createArgs, imageRef: null }; + } + let trustedContext: TrustedStagedBuildContext | null; + try { + trustedContext = resolveTrustedStagedBuildContext(input.buildCtx); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + log( + ` Local BuildKit build skipped: staged build context could not be inspected (${detail}); using the gateway builder instead.`, + ); + return { createArgs, imageRef: null }; + } + if (!trustedContext) { + log( + " Local BuildKit build skipped: staged build context failed trust validation; using the gateway builder instead.", + ); return { createArgs, imageRef: null }; } - const log = input.log ?? console.log; const imageRef = sandboxLocalImageRef(input.sandboxName, input.buildId); const buildImage = input.buildImage ?? @@ -123,8 +203,8 @@ export async function prebuildSandboxImageIfEligible( "-t", imageRef, "-f", - `${input.buildCtx}/Dockerfile`, - input.buildCtx, + trustedContext.dockerfile, + trustedContext.buildCtx, ], { env: { ...dockerBuildSubprocessEnv(), DOCKER_BUILDKIT: "1" }, diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 4103c91ba65..d3c50cd3fdd 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -5,6 +5,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +export const SANDBOX_BUILD_CONTEXT_PREFIX = "nemoclaw-build-"; +export type SandboxBuildContextOrigin = "custom" | "generated"; + export interface StagedBuildContext { buildCtx: string; stagedDockerfile: string; @@ -18,7 +21,7 @@ export interface BuildContextStats { type BuildContextStatsFilter = (entryPath: string) => boolean; function createBuildContextDir(tmpDir: string = os.tmpdir()): string { - return fs.mkdtempSync(path.join(tmpDir, "nemoclaw-build-")); + return fs.mkdtempSync(path.join(tmpDir, SANDBOX_BUILD_CONTEXT_PREFIX)); } function normalizeReadModesForDockerCopy(rootDir: string): void { diff --git a/test/e2e-release-gate-workflow.test.ts b/test/e2e-release-gate-workflow.test.ts index 2ae62c76848..fd77b887060 100644 --- a/test/e2e-release-gate-workflow.test.ts +++ b/test/e2e-release-gate-workflow.test.ts @@ -17,11 +17,12 @@ describe("release gate workflow resource contracts", () => { expect(fullJob.needs).toBe("generate-matrix"); expect(fullJob.if).not.toContain("always()"); expect(fullJob.if).toContain(",full-e2e,"); - expect( - fullJob.steps?.find((step) => step.name === "Run full-e2e live Vitest test")?.run, - ).toMatch( - /full-e2e\.test\.ts[\s\S]*npx vitest run --project e2e-live[\s\S]*onboard-progress-budget\.test\.ts/, - ); + const fullE2ERun = fullJob.steps?.find( + (step) => step.name === "Run full-e2e live Vitest test", + )?.run; + expect(fullE2ERun).toMatch(/npx vitest run --project e2e-live[\s\S]*full-e2e\.test\.ts/u); + expect(fullE2ERun).not.toContain("onboard-progress-budget.test.ts"); + expect(fullE2ERun?.match(/npx vitest run --project e2e-live/gu)).toHaveLength(1); expect(tuiJob.needs).toBe("generate-matrix"); expect(tuiJob.if).not.toContain("always()"); expect(tuiJob.if).toContain(",openclaw-tui-chat-correlation,"); diff --git a/test/e2e/README.md b/test/e2e/README.md index 206a0bdc786..3f5642eced8 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -71,4 +71,17 @@ should inspect the timing table before acting on a warning. For PRs, E2E Advisor deterministically recommends the `cloud-onboard` target when changes affect onboard behavior, trace timing, scorecard analysis, budget configuration, or the unified E2E workflow. The scorecard remains the source -of truth for threshold evaluation. +of truth for advisory warm-system trend evaluation. + +The `full-e2e` target enforces a separate hard acceptance contract for the +first fresh onboarding path in that job. It measures from the onboard root span +(a conservative anchor before wizard step `[1/8]`) through the first non-empty +agent response, requires the local BuildKit prebuild for the NemoClaw-generated +context without a gateway-builder fallback, limits the total to 180 seconds, +and limits the longest onboard output gap to 60 seconds. A violation fails +`full-e2e`, and the target writes its evidence to `onboard-progress-budget.json`. + +These assertions run inside the existing `full-e2e` lifecycle instead of a +second standalone onboarding run. This keeps the measurement on the job's first +sandbox build, avoids warming Docker layers before a duplicate performance +test, and makes `full-e2e` the source of truth for the hard cold-path contract. diff --git a/test/e2e/fixtures/onboard-performance.ts b/test/e2e/fixtures/onboard-performance.ts new file mode 100644 index 00000000000..f8550398c78 --- /dev/null +++ b/test/e2e/fixtures/onboard-performance.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ShellProbeOutputEvent } from "./shell-probe.ts"; + +const ONBOARD_SCOPE = "nemoclaw.onboard"; +const ONBOARD_ROOT_SPAN = "nemoclaw.onboard"; +const NANOSECONDS_PER_MILLISECOND = 1_000_000n; + +export interface OnboardTraceWindow { + durationMs: number; + finishedAtMs: number; + startedAtMs: number; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" ? (value as Record) : null; +} + +function unixNanoseconds(value: unknown, field: string): bigint { + if (typeof value !== "string" || !/^\d+$/u.test(value)) { + throw new Error(`onboard root span has an invalid ${field}`); + } + return BigInt(value); +} + +export function readOnboardTraceWindow(artifact: unknown): OnboardTraceWindow { + const resourceSpans = asRecord(artifact)?.resource_spans; + if (!Array.isArray(resourceSpans)) { + throw new Error("trace artifact is missing resource_spans"); + } + + const roots: Record[] = []; + for (const resourceSpan of resourceSpans) { + const scopeSpans = asRecord(resourceSpan)?.scope_spans; + if (!Array.isArray(scopeSpans)) continue; + for (const scopeSpan of scopeSpans) { + const scopeSpanRecord = asRecord(scopeSpan); + if (asRecord(scopeSpanRecord?.scope)?.name !== ONBOARD_SCOPE) continue; + const spans = scopeSpanRecord?.spans; + if (!Array.isArray(spans)) continue; + for (const span of spans) { + const record = asRecord(span); + if (record?.name === ONBOARD_ROOT_SPAN) roots.push(record); + } + } + } + + if (roots.length !== 1) { + throw new Error("trace artifact must contain exactly one onboard root span"); + } + const root = roots[0]; + if (asRecord(root.status)?.code !== "OK") { + throw new Error("onboard root span status is missing or not OK"); + } + + const startedAtNs = unixNanoseconds(root.start_time_unix_nano, "start time"); + const finishedAtNs = unixNanoseconds(root.end_time_unix_nano, "end time"); + if (finishedAtNs < startedAtNs) { + throw new Error("onboard root span ends before it starts"); + } + + return { + durationMs: Number((finishedAtNs - startedAtNs) / NANOSECONDS_PER_MILLISECOND), + finishedAtMs: Number(finishedAtNs / NANOSECONDS_PER_MILLISECOND), + startedAtMs: Number(startedAtNs / NANOSECONDS_PER_MILLISECOND), + }; +} + +export function maximumOutputSilenceMs( + window: Pick, + events: readonly Pick[], +): number { + const { finishedAtMs, startedAtMs } = window; + if ( + !Number.isFinite(startedAtMs) || + !Number.isFinite(finishedAtMs) || + finishedAtMs < startedAtMs + ) { + throw new Error("onboard output window is invalid"); + } + + const outputTimes = events + .map((event) => event.atMs) + .filter((atMs) => atMs >= startedAtMs && atMs <= finishedAtMs) + .sort((left, right) => left - right); + const boundaries = [startedAtMs, ...outputTimes, finishedAtMs]; + return boundaries + .slice(1) + .reduce((maximum, atMs, index) => Math.max(maximum, atMs - boundaries[index]), 0); +} diff --git a/test/e2e/live/agent-turn-latency-helpers.ts b/test/e2e/live/agent-turn-latency-helpers.ts index 92af70c9d6c..a43dc3e3c44 100644 --- a/test/e2e/live/agent-turn-latency-helpers.ts +++ b/test/e2e/live/agent-turn-latency-helpers.ts @@ -152,6 +152,34 @@ export function extractOpenClawAgentText(output: string): string { return ""; } +function collectOpenClawPayloadText(value: unknown): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const record = value as Record; + const result = + record.result && typeof record.result === "object" && !Array.isArray(record.result) + ? (record.result as Record) + : null; + const payloads = Array.isArray(record.payloads) + ? record.payloads + : Array.isArray(result?.payloads) + ? result.payloads + : []; + return payloads.flatMap((payload) => { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return []; + const text = (payload as Record).text; + return typeof text === "string" && text.trim() ? [text.trim()] : []; + }); +} + +/** Read only OpenClaw's agent-output payloads, excluding echoed request messages. */ +export function extractOpenClawAgentPayloadText(output: string): string { + for (let start = output.indexOf("{"); start >= 0; start = output.indexOf("{", start + 1)) { + const text = collectOpenClawPayloadText(parseJsonObjectAt(output, start)); + if (text.length > 0) return text.join("\n"); + } + return ""; +} + export function responseBodyAndStatus(raw: string): { body: string; status: string } { const match = raw.match(/\n__NEMOCLAW_HTTP_STATUS__=(\d{3})\s*$/u); return { body: match ? raw.slice(0, match.index).trim() : raw, status: match?.[1] ?? "000" }; diff --git a/test/e2e/live/full-e2e.test.ts b/test/e2e/live/full-e2e.test.ts index 3d8cc53a20a..1663b4ccb6d 100644 --- a/test/e2e/live/full-e2e.test.ts +++ b/test/e2e/live/full-e2e.test.ts @@ -6,25 +6,47 @@ import os from "node:os"; import path from "node:path"; import { containsInteger42Answer } from "../../helpers/e2e-answer-assertions.ts"; +import type { ArtifactSink } from "../fixtures/artifacts.ts"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { type HostCliClient } from "../fixtures/clients/host.ts"; -import { type SandboxClient, validateSandboxName } from "../fixtures/clients/sandbox.ts"; +import { + type SandboxClient, + trustedSandboxShellScript, + validateSandboxName, +} from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts"; import { shouldRunLiveE2E } from "../fixtures/live-project-gate.ts"; +import { + maximumOutputSilenceMs, + type OnboardTraceWindow, + readOnboardTraceWindow, +} from "../fixtures/onboard-performance.ts"; import { assertSecurityPosture, securityPostureEnabled, securityPostureModeEnv, } from "../fixtures/security-posture.ts"; -import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import type { ShellProbeOutputEvent, ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { extractOpenClawAgentPayloadText } from "./agent-turn-latency-helpers.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js"); const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-full"; const LIVE_TIMEOUT_MS = 50 * 60_000; +const FIRST_TURN_TIMEOUT_MS = 240_000; +const ONBOARD_BUDGET_SECS = 180; +const MAX_SILENCE_SECS = 60; +const EXPECTED_FIRST_REPLY = "NEMOCLAW_E2E_READY_6002"; +const MEASURE_COLD_ONBOARD = process.env.E2E_TARGET_ID === "full-e2e"; const liveTest = shouldRunLiveE2E() ? test : test.skip; +interface ColdOnboardCapture { + outputEvents: ShellProbeOutputEvent[]; + traceDirectory: string; + traceFile: string; +} + process.env.NEMOCLAW_CLI_BIN ??= CLI_ENTRYPOINT; validateSandboxName(SANDBOX_NAME); @@ -97,6 +119,110 @@ function parseReplyCommand(): string { return String.raw`python3 -c 'import json,sys; d=json.load(sys.stdin); m=d["choices"][0]["message"]; print((m.get("content") or m.get("reasoning_content") or "").strip())'`; } +function readAndDeleteTraceWindow(traceFile: string, traceDirectory: string): OnboardTraceWindow { + try { + return readOnboardTraceWindow(JSON.parse(fs.readFileSync(traceFile, "utf8")) as unknown); + } catch (error) { + throw new Error( + `Cold onboard evidence requires a valid trace file with one successful nemoclaw.onboard root span: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } finally { + fs.rmSync(traceDirectory, { recursive: true, force: true }); + } +} + +function createColdOnboardCapture(): ColdOnboardCapture | null { + const traceDirectory = MEASURE_COLD_ONBOARD + ? fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-full-e2e-trace-")) + : null; + return traceDirectory + ? { + outputEvents: [], + traceDirectory, + traceFile: path.join(traceDirectory, "onboard.json"), + } + : null; +} + +async function assertColdOnboardPerformance(input: { + apiKey: string; + artifacts: ArtifactSink; + install: ShellProbeResult; + outputEvents: readonly ShellProbeOutputEvent[]; + sandbox: SandboxClient; + traceDirectory: string; + traceFile: string; +}): Promise { + const traceWindow = readAndDeleteTraceWindow(input.traceFile, input.traceDirectory); + const ansiSgr = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); + const plain = resultText(input.install).replace(ansiSgr, ""); + const heartbeatCount = (plain.match(/Still working on /g) ?? []).length; + const buildKitFallback = /Local BuildKit build [^\n]*using the gateway builder instead\./u.test( + plain, + ); + const usedBuildKitPrebuild = + /Building sandbox image with BuildKit/u.test(plain) && !buildKitFallback; + const classicBuildSteps = (plain.match(/Step \d+\/\d+ :/gu) ?? []).length; + const maxSilenceMs = maximumOutputSilenceMs(traceWindow, input.outputEvents); + const maxSilenceSecs = Math.ceil(maxSilenceMs / 1_000); + + const turn = await input.sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript( + "openclaw agent --agent main --json --thinking off --session-id e2e-6002 " + + `-m 'Reply with exactly: ${EXPECTED_FIRST_REPLY}'`, + ), + { + artifactName: "phase-1-first-agent-turn", + env: env(), + redactionValues: [input.apiKey], + timeoutMs: FIRST_TURN_TIMEOUT_MS, + }, + ); + const totalMs = Date.now() - traceWindow.startedAtMs; + const totalSecs = Math.ceil(totalMs / 1_000); + const turnText = resultText(turn); + const assistantReply = extractOpenClawAgentPayloadText(turnText).trim(); + const compactAssistantReply = assistantReply.replace(/\s+/gu, ""); + const responseChars = assistantReply.length; + + await input.artifacts.writeJson("onboard-progress-budget.json", { + sandbox: SANDBOX_NAME, + installExitCode: input.install.exitCode, + firstTurnExitCode: turn.exitCode, + onboardSecs: Math.ceil(traceWindow.durationMs / 1_000), + totalMs, + totalSecs, + budgetSecs: ONBOARD_BUDGET_SECS, + heartbeatCount, + maxSilenceSecs, + maxSilenceBudgetSecs: MAX_SILENCE_SECS, + buildKitFallback, + usedBuildKitPrebuild, + classicBuildSteps, + responseChars, + }); + + expect(plain, "expected literal wizard step [1/8] in installer output").toContain("[1/8]"); + expect(buildKitFallback, "expected no fallback from BuildKit to the gateway builder").toBe(false); + expect(usedBuildKitPrebuild, "expected the cold install to use BuildKit").toBe(true); + expect(classicBuildSteps, "expected no classic per-instruction build steps").toBe(0); + expect( + maxSilenceSecs, + `longest silent gap ${maxSilenceSecs}s exceeds the ${MAX_SILENCE_SECS}s guarantee`, + ).toBeLessThanOrEqual(MAX_SILENCE_SECS); + expect(turn.exitCode, turnText).toBe(0); + expect( + compactAssistantReply, + `expected the sentinel first agent reply, got: ${turnText}`, + ).toContain(EXPECTED_FIRST_REPLY); + expect( + totalMs, + `[1/8]-to-first-response took ${totalSecs}s, over the ${ONBOARD_BUDGET_SECS}s budget`, + ).toBeLessThanOrEqual(ONBOARD_BUDGET_SECS * 1_000); +} + liveTest( "full e2e: install, onboard, inference, cli operations, and cleanup", { timeout: LIVE_TIMEOUT_MS }, @@ -133,14 +259,38 @@ liveTest( cleanupRegistry.add("remove full-e2e sandbox", () => cleanup(host, sandbox)); await cleanup(host, sandbox); + const coldOnboard = createColdOnboardCapture(); + coldOnboard && + cleanupRegistry.add("remove raw full-e2e trace", async () => { + fs.rmSync(coldOnboard.traceDirectory, { recursive: true, force: true }); + }); + const install = await host.command("bash", ["install.sh", "--non-interactive", "--fresh"], { artifactName: "phase-1-install-sh", cwd: REPO_ROOT, - env: env({ ...hosted.env, NVIDIA_INFERENCE_API_KEY: hosted.apiKey }), + env: env({ + ...hosted.env, + NVIDIA_INFERENCE_API_KEY: hosted.apiKey, + ...(coldOnboard ? { NEMOCLAW_TRACE_FILE: coldOnboard.traceFile } : {}), + }), + ...(coldOnboard + ? { onOutput: (event: ShellProbeOutputEvent) => coldOnboard.outputEvents.push(event) } + : {}), redactionValues, timeoutMs: 25 * 60_000, }); expect(install.exitCode, resultText(install)).toBe(0); + await (coldOnboard + ? assertColdOnboardPerformance({ + apiKey: hosted.apiKey, + artifacts, + install, + outputEvents: coldOnboard.outputEvents, + sandbox, + traceDirectory: coldOnboard.traceDirectory, + traceFile: coldOnboard.traceFile, + }) + : Promise.resolve()); const pathProbe = await host.command( "bash", diff --git a/test/e2e/live/onboard-progress-budget.test.ts b/test/e2e/live/onboard-progress-budget.test.ts deleted file mode 100644 index 99aa1b857b7..00000000000 --- a/test/e2e/live/onboard-progress-budget.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -// -// Live acceptance test for issue #6002. It measures the issue's actual -// acceptance path — onboard step [1/8] through the first agent response — and -// asserts a real worktree-CLI onboard: -// 1. never leaves a wait-heavy phase silent longer than the 60s guarantee -// (proved from timestamped stdout/stderr chunks), and -// 2. builds the sandbox image with BuildKit (the prebuild speed path), and -// 3. reaches the first agent response (a headless `openclaw agent` turn that -// returns a real hosted-inference reply), and -// 4. does all of that within the ≤3-minute budget (NEMOCLAW_E2E_ONBOARD_BUDGET_SECS). -// -// Uses real hosted inference (NVIDIA_INFERENCE_API_KEY) because a genuine first -// response requires a real LLM turn — a stub endpoint completes onboarding's -// inference smoke but cannot drive a full agent turn. Opt-in via -// NEMOCLAW_RUN_LIVE_E2E=1; requires the hosted-inference key. - -import path from "node:path"; - -import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import type { HostCliClient } from "../fixtures/clients/host.ts"; -import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; -import { trustedSandboxShellScript, validateSandboxName } from "../fixtures/clients/sandbox.ts"; -import { expect, test } from "../fixtures/e2e-test.ts"; -import { shouldRunLiveE2E } from "../fixtures/live-project-gate.ts"; -import type { ShellProbeOutputEvent, ShellProbeResult } from "../fixtures/shell-probe.ts"; -import { extractOpenClawAgentText } from "./agent-turn-latency-helpers.ts"; - -const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); -const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js"); -const HOSTED_INFERENCE_SECRET = "NVIDIA_INFERENCE_API_KEY"; -const SANDBOX_NAME = process.env.NEMOCLAW_E2E_PROGRESS_SANDBOX ?? "e2e-progress-budget"; -// Timeout env vars are named *_SECS because their values are seconds (×1000 -// below), matching their unit. -const ONBOARD_TIMEOUT_MS = Number(process.env.NEMOCLAW_E2E_ONBOARD_TIMEOUT_SECS ?? 1_200) * 1_000; -const FIRST_TURN_TIMEOUT_MS = - Number(process.env.NEMOCLAW_E2E_FIRST_TURN_TIMEOUT_SECS ?? 240) * 1_000; -// Budget for the whole [1/8]-to-first-response path. Defaults to the issue's -// ≤3-minute goal (180s); constrained / cold-cache runners can raise -// NEMOCLAW_E2E_ONBOARD_BUDGET_SECS. -const BUDGET_SECS = Number(process.env.NEMOCLAW_E2E_ONBOARD_BUDGET_SECS ?? 180); -// The issue's guarantee: no onboarding phase stays silent longer than this. -const MAX_SILENCE_SECS = Number(process.env.NEMOCLAW_E2E_MAX_SILENCE_SECS ?? 60); -const TEST_TIMEOUT_MS = 45 * 60_000; -// Gated at declaration (no in-body `if`): live E2E is explicitly opt-in. -const liveTest = shouldRunLiveE2E() ? test : test.skip; - -validateSandboxName(SANDBOX_NAME); - -function resultText(result: { stdout: string; stderr: string }): string { - return [result.stdout, result.stderr].filter(Boolean).join("\n"); -} - -function commandEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { - return { - ...buildAvailabilityProbeEnv(), - ...extra, - OPENSHELL_GATEWAY: "nemoclaw", - NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", - }; -} - -function onboardEnv(apiKey: string): NodeJS.ProcessEnv { - return commandEnv({ - // NVIDIA Endpoints hosted inference (default non-interactive provider). - NVIDIA_INFERENCE_API_KEY: apiKey, - NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, - NEMOCLAW_POLICY_MODE: "skip", - NEMOCLAW_DASHBOARD_PORT: "", - CHAT_UI_URL: "", - NEMOCLAW_RECREATE_SANDBOX: "1", - // Force the BuildKit prebuild path on under the Vitest-hosted live test. - NEMOCLAW_SANDBOX_PREBUILD: "1", - }); -} - -async function ignoreCleanupError(run: () => Promise): Promise { - try { - await run(); - } catch { - // Best-effort cleanup; never mask the lifecycle assertions. - } -} - -async function cleanupProgressState(host: HostCliClient, sandbox: SandboxClient): Promise { - await ignoreCleanupError(() => - host.command(process.execPath, [CLI_ENTRYPOINT, SANDBOX_NAME, "destroy", "--yes"], { - artifactName: "cleanup-nemoclaw-destroy", - env: commandEnv(), - timeoutMs: 180_000, - }), - ); - await ignoreCleanupError(() => - sandbox.openshell(["sandbox", "delete", SANDBOX_NAME], { - artifactName: "cleanup-openshell-sandbox-delete", - env: commandEnv(), - timeoutMs: 60_000, - }), - ); - await ignoreCleanupError(() => - sandbox.openshell(["gateway", "destroy", "-g", "nemoclaw"], { - artifactName: "cleanup-openshell-gateway-destroy", - env: commandEnv(), - timeoutMs: 60_000, - }), - ); -} - -liveTest( - "onboard [1/8] reaches a first response within 3 minutes without a 60-second output gap (#6002)", - { timeout: TEST_TIMEOUT_MS }, - async ({ artifacts, cleanup, host, sandbox, secrets }) => { - const apiKey = secrets.required(HOSTED_INFERENCE_SECRET); - - const docker = await host.command("docker", ["info"], { - artifactName: "prereq-docker-info", - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }); - expect(docker.exitCode, resultText(docker)).toBe(0); - - cleanup.add("remove progress-budget sandbox and gateway", async () => { - await cleanupProgressState(host, sandbox); - }); - await cleanupProgressState(host, sandbox); - - // Starting before process spawn is a conservative upper bound for the - // issue's literal [1/8]-to-response budget; the output assertion below - // proves that the expected wizard anchor was actually reached. - const startedAt = Date.now(); - const outputEvents: ShellProbeOutputEvent[] = []; - const onboard: ShellProbeResult = await host.command( - process.execPath, - [CLI_ENTRYPOINT, "onboard", "--non-interactive", "--no-gpu"], - { - artifactName: "onboard-progress-budget", - env: onboardEnv(apiKey), - onOutput: (event) => outputEvents.push(event), - redactionValues: [apiKey], - timeoutMs: ONBOARD_TIMEOUT_MS, - }, - ); - const onboardFinishedAt = Date.now(); - const onboardSecs = Math.round((onboardFinishedAt - startedAt) / 1000); - - // Strip ANSI so text assertions are colour-independent (ESC built from a - // char code so there is no control literal in source). - const ansiSgr = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); - const plain = resultText(onboard).replace(ansiSgr, ""); - const heartbeatCount = (plain.match(/Still working on /g) ?? []).length; - const usedBuildKitPrebuild = /Building sandbox image with BuildKit/.test(plain); - const classicBuildSteps = (plain.match(/Step \d+\/\d+ :/g) ?? []).length; - - const outputTimes = [startedAt, ...outputEvents.map((event) => event.atMs), onboardFinishedAt]; - const maxSilenceSecs = Math.ceil( - Math.max(...outputTimes.slice(1).map((atMs, index) => atMs - outputTimes[index])) / 1000, - ); - - expect(onboard.exitCode, plain).toBe(0); - expect(plain, "expected literal wizard step [1/8] in onboard output").toContain("[1/8]"); - // (2) BuildKit prebuild ran (the speed fix), not the classic in-gateway builder. - expect(usedBuildKitPrebuild, "expected the BuildKit prebuild to run").toBe(true); - expect(classicBuildSteps, "expected no classic per-instruction build steps").toBe(0); - // (1) Adjacent terminal output chunks never exceeded the 60-second - // guarantee. Heartbeats account for otherwise quiet phases. - expect( - maxSilenceSecs, - `longest silent gap ${maxSilenceSecs}s exceeds the ${MAX_SILENCE_SECS}s guarantee`, - ).toBeLessThanOrEqual(MAX_SILENCE_SECS); - // (3) First agent response: a real headless `openclaw agent` turn. This is - // the scriptable equivalent of the issue's first TUI message. - const turn = await sandbox.execShell( - SANDBOX_NAME, - trustedSandboxShellScript( - "openclaw agent --agent main --json --thinking off --session-id e2e-6002 " + - "-m 'Reply with a short acknowledgement.'", - ), - { - artifactName: "onboard-first-agent-turn", - env: commandEnv(), - redactionValues: [apiKey], - timeoutMs: FIRST_TURN_TIMEOUT_MS, - }, - ); - const totalMs = Date.now() - startedAt; - const totalSecs = Math.ceil(totalMs / 1000); - const turnText = resultText(turn); - // Parse the `--json` payload and measure the assistant reply text — a raw - // non-empty output could just be a JSON envelope / log noise, so it would - // not prove the agent actually returned content (CodeRabbit). - const assistantReply = extractOpenClawAgentText(turnText); - const responseChars = assistantReply.trim().length; - - await artifacts.writeJson("onboard-progress-budget.json", { - sandbox: SANDBOX_NAME, - onboardExitCode: onboard.exitCode, - firstTurnExitCode: turn.exitCode, - onboardSecs, - totalMs, - totalSecs, - budgetSecs: BUDGET_SECS, - heartbeatCount, - maxSilenceSecs, - maxSilenceBudgetSecs: MAX_SILENCE_SECS, - usedBuildKitPrebuild, - classicBuildSteps, - responseChars, - }); - - expect(turn.exitCode, turnText).toBe(0); - // A real, non-empty first response came back (not just a completed onboard). - expect( - responseChars, - `expected a non-empty first agent reply, got: ${turnText}`, - ).toBeGreaterThan(0); - - // (4) Process start is earlier than [1/8], so this is a stricter upper - // bound than the issue's [1/8]-to-first-response budget. - expect( - totalMs, - `[1/8]-to-first-response took ${totalSecs}s, over the ${BUDGET_SECS}s budget`, - ).toBeLessThanOrEqual(BUDGET_SECS * 1_000); - }, -); diff --git a/test/e2e/support/onboard-performance.test.ts b/test/e2e/support/onboard-performance.test.ts new file mode 100644 index 00000000000..661fa74b01f --- /dev/null +++ b/test/e2e/support/onboard-performance.test.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { maximumOutputSilenceMs, readOnboardTraceWindow } from "../fixtures/onboard-performance.ts"; +import { extractOpenClawAgentPayloadText } from "../live/agent-turn-latency-helpers.ts"; + +function traceArtifact(overrides: Partial> = {}): Record { + return { + resource_spans: [ + { + scope_spans: [ + { + scope: { name: "nemoclaw.onboard" }, + spans: [ + { + name: "nemoclaw.onboard", + start_time_unix_nano: "1000000000", + end_time_unix_nano: "4750000000", + status: { code: "OK" }, + ...overrides, + }, + ], + }, + ], + }, + ], + }; +} + +describe("onboard performance evidence", () => { + it("reads the successful onboard root span using integer nanosecond timestamps", () => { + expect(readOnboardTraceWindow(traceArtifact())).toEqual({ + durationMs: 3_750, + finishedAtMs: 4_750, + startedAtMs: 1_000, + }); + }); + + it.each([ + ["missing root", { name: "nemoclaw.onboard.phase.gateway" }], + ["failed root", { status: { code: "ERROR" } }], + ["malformed timestamp", { start_time_unix_nano: "yesterday" }], + ["reversed timestamps", { end_time_unix_nano: "999999999" }], + ])("rejects a %s trace", (_label, overrides) => { + expect(() => readOnboardTraceWindow(traceArtifact(overrides))).toThrow(); + }); + + it("measures the largest in-window gap after ordering and filtering output events", () => { + expect( + maximumOutputSilenceMs({ startedAtMs: 1_000, finishedAtMs: 5_000 }, [ + { atMs: 4_900 }, + { atMs: 1_100 }, + { atMs: 3_000 }, + { atMs: 999 }, + { atMs: 6_000 }, + ]), + ).toBe(1_900); + }); + + it("treats the entire onboard window as silent when no output arrives", () => { + expect(maximumOutputSilenceMs({ startedAtMs: 1_000, finishedAtMs: 5_000 }, [])).toBe(4_000); + }); + + it("rejects an output window that ends before it starts", () => { + expect(() => maximumOutputSilenceMs({ startedAtMs: 5_000, finishedAtMs: 1_000 }, [])).toThrow( + "onboard output window is invalid", + ); + }); + + it("rejects echoed user messages as first-agent-response evidence", () => { + expect( + extractOpenClawAgentPayloadText( + JSON.stringify({ + messages: [{ role: "user", content: "Reply with exactly: NEMOCLAW_E2E_READY_6002" }], + }), + ), + ).toBe(""); + }); + + it("accepts a framed OpenClaw agent-output payload", () => { + expect( + extractOpenClawAgentPayloadText( + `progress\n${JSON.stringify({ result: { payloads: [{ text: "NEMOCLAW_E2E_READY_6002" }] } })}`, + ), + ).toBe("NEMOCLAW_E2E_READY_6002"); + }); + + it("joins top-level agent-output payload fragments", () => { + expect( + extractOpenClawAgentPayloadText( + JSON.stringify({ + payloads: [{ text: "NEMOCLAW_" }, { text: "E2E_READY_6002" }], + }), + ), + ).toBe("NEMOCLAW_\nE2E_READY_6002"); + }); +}); diff --git a/test/onboard-prepared-build-context.test.ts b/test/onboard-prepared-build-context.test.ts index a810272d954..faf77ed4114 100644 --- a/test/onboard-prepared-build-context.test.ts +++ b/test/onboard-prepared-build-context.test.ts @@ -158,6 +158,7 @@ const preparedBuildContext = { buildCtx, stagedDockerfile: buildCtx + "/Dockerfile", buildId, + origin: "generated", cleanupBuildCtx: () => { cleanupCalls += 1; fs.rmSync(buildCtx, { recursive: true, force: true }); diff --git a/test/onboard-prepared-gateway-handoff.test.ts b/test/onboard-prepared-gateway-handoff.test.ts index 4969bcca2d9..3d08ed1c0f1 100644 --- a/test/onboard-prepared-gateway-handoff.test.ts +++ b/test/onboard-prepared-gateway-handoff.test.ts @@ -64,6 +64,7 @@ const preparedBuildContext = { buildCtx: ${JSON.stringify(path.join(home, "prepared-context"))}, stagedDockerfile: ${JSON.stringify(path.join(home, "prepared-context", "Dockerfile"))}, buildId: "6195-prepared", + origin: "generated", cleanupBuildCtx: () => true, }; const common = {