diff --git a/test/e2e-scenario/live/rebuild-openclaw-old-base-context.ts b/test/e2e-scenario/live/rebuild-openclaw-old-base-context.ts new file mode 100644 index 00000000000..2c6800150ac --- /dev/null +++ b/test/e2e-scenario/live/rebuild-openclaw-old-base-context.ts @@ -0,0 +1,140 @@ +// 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"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +const DOCKERFILE_BASE = path.join(REPO_ROOT, "Dockerfile.base"); +const DOCKERIGNORE = path.join(REPO_ROOT, ".dockerignore"); +const OLD_OPENCLAW_VERSION = "2026.3.11"; +const BLUEPRINT_RELPATH = "nemoclaw-blueprint/blueprint.yaml"; + +export function oldBaseContextSources(): string[] { + return [BLUEPRINT_RELPATH, ...directDockerfileBaseCopySources()]; +} + +export function directDockerfileBaseCopySources(dockerfilePath = DOCKERFILE_BASE): string[] { + const text = fs.readFileSync(dockerfilePath, "utf8"); + const sources: string[] = []; + + for (const [lineIndex, rawLine] of text.split(/\r?\n/).entries()) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const instructionMatch = /^(\S+)\b([\s\S]*)$/.exec(line); + if (!instructionMatch || instructionMatch[1].toUpperCase() !== "COPY") continue; + + const tokens = instructionMatch[2].trim().split(/\s+/).filter(Boolean); + const normalizedTokens = tokens.map((token) => token.toLowerCase()); + const nonFlagTokens = tokens.filter((token) => !token.startsWith("--")); + const hasStageSource = normalizedTokens.some( + (token) => token === "--from" || token.startsWith("--from="), + ); + if (hasStageSource) continue; + + if (nonFlagTokens.length !== 2 || nonFlagTokens[0]?.startsWith("[")) { + throw new Error( + `Unsupported direct Dockerfile.base COPY form at line ${lineIndex + 1}: ${rawLine}`, + ); + } + + validateOldBaseContextSource(nonFlagTokens[0]); + sources.push(nonFlagTokens[0]); + } + + return sources; +} + +export function dockerignoreSecretPatterns(dockerignorePath = DOCKERIGNORE): string[] { + const patterns: string[] = []; + let inSecuritySection = false; + + for (const rawLine of fs.readFileSync(dockerignorePath, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (/^#\s*Security:/i.test(line)) { + inSecuritySection = true; + continue; + } + if (!inSecuritySection || !line || line.startsWith("#")) continue; + if (line.startsWith("!")) { + throw new Error(`Unsupported negated .dockerignore security pattern: ${line}`); + } + patterns.push(line.replace(/^\.\//, "")); + } + + if (patterns.length === 0) { + throw new Error("No .dockerignore security patterns found"); + } + return patterns; +} + +function dockerignorePatternMatchesPath(pattern: string, relativePath: string): boolean { + const normalizedPattern = pattern.replace(/^\/+/, ""); + const parts = relativePath.split("/"); + const fileName = parts.at(-1) ?? ""; + + if (normalizedPattern.endsWith("/")) { + const dirName = normalizedPattern.replace(/\/+$/, ""); + return parts.includes(dirName); + } + + const target = normalizedPattern.includes("/") ? relativePath : fileName; + const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`).test(target); +} + +function matchesDockerignoreSecretPattern(relativePath: string): boolean { + return dockerignoreSecretPatterns().some((pattern) => + dockerignorePatternMatchesPath(pattern, relativePath), + ); +} + +function validateOldBaseContextSource(relativePath: string): string { + const parts = relativePath.split("/"); + const resolved = path.resolve(REPO_ROOT, relativePath); + const repoPrefix = `${REPO_ROOT}${path.sep}`; + const invalidSource = + path.isAbsolute(relativePath) || + relativePath.includes("\\") || + parts.some((part) => !part || part === "." || part === "..") || + (resolved !== REPO_ROOT && !resolved.startsWith(repoPrefix)); + if (invalidSource) { + throw new Error(`Unsupported direct Dockerfile.base COPY source: ${relativePath}`); + } + if (matchesDockerignoreSecretPattern(relativePath)) { + throw new Error( + `Unsupported .dockerignore-secret Dockerfile.base COPY source: ${relativePath}`, + ); + } + return resolved; +} + +function copyOldBaseContextFile(buildContext: string, relativePath: string): void { + const source = validateOldBaseContextSource(relativePath); + const target = path.join(buildContext, ...relativePath.split("/")); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); +} + +export function createOldBaseBuildContext(): string { + const buildContext = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-openclaw-base-")); + // The legacy bash test builds Dockerfile.base with the full repository as + // context after temporarily lowering blueprint.yaml in-place. Keep the + // trusted checkout read-only while staging every current Dockerfile.base + // direct COPY dependency needed by that old-base build. + for (const relativePath of oldBaseContextSources()) { + copyOldBaseContextFile(buildContext, relativePath); + } + + const stagedBlueprint = path.join(buildContext, ...BLUEPRINT_RELPATH.split("/")); + const original = fs.readFileSync(stagedBlueprint, "utf8"); + const minOpenClawVersion = /^(\s*min_openclaw_version:\s*).*/m; + if (!minOpenClawVersion.test(original)) { + throw new Error("blueprint min_openclaw_version line was not found"); + } + const lowered = original.replace(minOpenClawVersion, `$1"${OLD_OPENCLAW_VERSION}"`); + fs.writeFileSync(stagedBlueprint, lowered, "utf8"); + return buildContext; +} diff --git a/test/e2e-scenario/live/rebuild-openclaw.test.ts b/test/e2e-scenario/live/rebuild-openclaw.test.ts index 4cc68c6afd3..d74021af76d 100644 --- a/test/e2e-scenario/live/rebuild-openclaw.test.ts +++ b/test/e2e-scenario/live/rebuild-openclaw.test.ts @@ -13,6 +13,7 @@ import { expect, test } from "../fixtures/e2e-test.ts"; import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; import { shellQuote } from "../../../src/lib/core/shell-quote"; +import { createOldBaseBuildContext } from "./rebuild-openclaw-old-base-context.ts"; // Direct Vitest replacement coverage for test/e2e/test-rebuild-openclaw.sh. // The contract stays intentionally local to this live test: build an older @@ -26,14 +27,17 @@ import { shellQuote } from "../../../src/lib/core/shell-quote"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js"); -const BLUEPRINT_RELPATH = path.join("nemoclaw-blueprint", "blueprint.yaml"); -const BLUEPRINT = path.join(REPO_ROOT, BLUEPRINT_RELPATH); const OLD_OPENCLAW_VERSION = "2026.3.11"; const MARKER_FILE = "/sandbox/.openclaw/workspace/rebuild-marker.txt"; const REGISTRY_FILE = path.join(os.homedir(), ".nemoclaw", "sandboxes.json"); const SESSION_FILE = path.join(os.homedir(), ".nemoclaw", "onboard-session.json"); const BACKUP_ROOT = path.join(os.homedir(), ".nemoclaw", "rebuild-backups"); -const DEFAULT_MODEL = "nvidia/nemotron-3-super-120b-a12b"; +const HOSTED_ENDPOINT_URL = + process.env.NEMOCLAW_ENDPOINT_URL ?? "https://inference-api.nvidia.com/v1"; +const DEFAULT_MODEL = + process.env.NEMOCLAW_MODEL ?? + process.env.NEMOCLAW_COMPAT_MODEL ?? + "nvidia/nvidia/nemotron-3-ultra"; const TEST_SANDBOX_PREFIX = "e2e-rebuild-openclaw"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? @@ -136,7 +140,17 @@ function dockerContextEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { function cliEnv(apiKey: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { return dockerContextEnv({ + COMPATIBLE_API_KEY: apiKey, NVIDIA_INFERENCE_API_KEY: apiKey, + // Keep the recreate resume request aligned with the registry/session this + // test seeds below. The rebuild workflow supplies a hosted-compatible key + // through NVIDIA_INFERENCE_API_KEY, so record and request the matching + // compatible-endpoint route instead of NVIDIA Endpoints. + NEMOCLAW_COMPAT_MODEL: DEFAULT_MODEL, + NEMOCLAW_ENDPOINT_URL: HOSTED_ENDPOINT_URL, + NEMOCLAW_MODEL: DEFAULT_MODEL, + NEMOCLAW_PREFERRED_API: "openai-completions", + NEMOCLAW_PROVIDER: "custom", NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, ...extra, }); @@ -164,20 +178,6 @@ function pythonExecArgs(script: string): string[] { return ["python3", "-c", `import base64; exec(base64.b64decode('${encoded}'))`]; } -function createOldBaseBuildContext(): string { - const buildContext = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-openclaw-base-")); - fs.mkdirSync(path.join(buildContext, path.dirname(BLUEPRINT_RELPATH)), { recursive: true }); - const original = fs.readFileSync(BLUEPRINT, "utf8"); - const minOpenClawVersion = /^(\s*min_openclaw_version:\s*).*/m; - expect( - minOpenClawVersion.test(original), - "blueprint min_openclaw_version line was not found", - ).toBe(true); - const lowered = original.replace(minOpenClawVersion, `$1"${OLD_OPENCLAW_VERSION}"`); - fs.writeFileSync(path.join(buildContext, BLUEPRINT_RELPATH), lowered, "utf8"); - return buildContext; -} - async function waitForSandboxReady(sandbox: { list(options?: object): Promise; }): Promise { @@ -204,12 +204,12 @@ async function configureGatewayInferenceRoute( "-lc", [ "set -euo pipefail", - "if openshell provider get nvidia-prod >/dev/null 2>&1; then", - " openshell provider update nvidia-prod --credential NVIDIA_INFERENCE_API_KEY", + "if openshell provider get compatible-endpoint >/dev/null 2>&1; then", + " openshell provider update compatible-endpoint --credential COMPATIBLE_API_KEY --config OPENAI_BASE_URL=$NEMOCLAW_ENDPOINT_URL", "else", - " openshell provider create --name nvidia-prod --type nvidia --credential NVIDIA_INFERENCE_API_KEY", + " openshell provider create --name compatible-endpoint --type openai --credential COMPATIBLE_API_KEY --config OPENAI_BASE_URL=$NEMOCLAW_ENDPOINT_URL", "fi", - `openshell inference set --no-verify --provider nvidia-prod --model ${model}`, + `openshell inference set --no-verify --provider compatible-endpoint --model ${model}`, ].join("\n"), ], { @@ -238,7 +238,7 @@ function seedRegistryAndSession(): void { name: SANDBOX_NAME, createdAt: new Date().toISOString(), model: DEFAULT_MODEL, - provider: "nvidia-prod", + provider: "compatible-endpoint", gpuEnabled: false, policies: [], policyTier: null, @@ -258,9 +258,10 @@ function seedRegistryAndSession(): void { resumable: true, lastCompletedStep: "inference", failure: null, - provider: "nvidia-prod", + provider: "compatible-endpoint", model: DEFAULT_MODEL, - credentialEnv: "NVIDIA_INFERENCE_API_KEY", + credentialEnv: "COMPATIBLE_API_KEY", + endpointUrl: HOSTED_ENDPOINT_URL, agent: null, steps: { preflight: complete, diff --git a/test/e2e-scenario/support-tests/rebuild-openclaw-old-base-context.test.ts b/test/e2e-scenario/support-tests/rebuild-openclaw-old-base-context.test.ts new file mode 100644 index 00000000000..f0c026217ab --- /dev/null +++ b/test/e2e-scenario/support-tests/rebuild-openclaw-old-base-context.test.ts @@ -0,0 +1,140 @@ +// 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 } from "vitest"; + +import { + createOldBaseBuildContext, + directDockerfileBaseCopySources, + dockerignoreSecretPatterns, +} from "../live/rebuild-openclaw-old-base-context.ts"; + +const copiedContexts: string[] = []; +const testFiles: string[] = []; + +describe("rebuild-openclaw old-base build context", () => { + afterEach(() => { + for (const contextPath of copiedContexts.splice(0)) { + fs.rmSync(contextPath, { recursive: true, force: true }); + } + for (const filePath of testFiles.splice(0)) { + fs.rmSync(filePath, { recursive: true, force: true }); + } + }); + + it("stages every direct Dockerfile.base COPY dependency", () => { + const buildContext = createOldBaseBuildContext(); + copiedContexts.push(buildContext); + + const stagedSources = directDockerfileBaseCopySources().map((source) => + path.join(buildContext, ...source.split("/")), + ); + + expect(stagedSources).not.toHaveLength(0); + expect(stagedSources.every((source) => fs.existsSync(source))).toBe(true); + }); + + it("parses direct Dockerfile.base COPY syntax without silently ignoring variants", () => { + const dockerfilePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-openclaw-dockerfile-")), + "Dockerfile.base", + ); + testFiles.push(path.dirname(dockerfilePath)); + fs.writeFileSync( + dockerfilePath, + [ + "FROM base AS build", + "copy scripts/lib/sandbox-rlimits.sh /tmp/lowercase", + "COPY\tnemoclaw-blueprint/blueprint.yaml /tmp/tabbed", + "COPY --from=build /tmp/ignored /tmp/ignored", + ].join("\n"), + "utf8", + ); + + expect(directDockerfileBaseCopySources(dockerfilePath)).toEqual([ + "scripts/lib/sandbox-rlimits.sh", + "nemoclaw-blueprint/blueprint.yaml", + ]); + }); + + it("rejects out-of-context direct Dockerfile.base COPY sources before staging", () => { + const parentRelativeDockerfilePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-openclaw-dockerfile-")), + "Dockerfile.base", + ); + const absoluteDockerfilePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-openclaw-dockerfile-")), + "Dockerfile.base", + ); + testFiles.push( + path.dirname(parentRelativeDockerfilePath), + path.dirname(absoluteDockerfilePath), + ); + fs.writeFileSync(parentRelativeDockerfilePath, "COPY ../outside /tmp/outside\n", "utf8"); + fs.writeFileSync(absoluteDockerfilePath, "COPY /etc/passwd /tmp/passwd\n", "utf8"); + + expect(() => directDockerfileBaseCopySources(parentRelativeDockerfilePath)).toThrow( + "Unsupported direct Dockerfile.base COPY source", + ); + expect(() => directDockerfileBaseCopySources(absoluteDockerfilePath)).toThrow( + "Unsupported direct Dockerfile.base COPY source", + ); + }); + + it("rejects every current .dockerignore secret COPY pattern before staging", () => { + const representativeSourceByPattern = new Map([ + [".env", ".env"], + [".env.*", ".env.prod"], + [".envrc", ".envrc"], + [".npmrc", ".npmrc"], + [".netrc", ".netrc"], + [".pypirc", ".pypirc"], + [".direnv/", ".direnv/config"], + [".ssh/", ".ssh/id_rsa.pub"], + ["secrets/", "secrets/token.json"], + [".credentials", ".credentials"], + ["*.key", "private.key"], + ["*.pem", "private.pem"], + ["*.pfx", "private.pfx"], + ["*.p12", "private.p12"], + ["*.jks", "private.jks"], + ["*.keystore", "private.keystore"], + ["*.tfvars", "terraform.tfvars"], + ["*_ecdsa", "id_ecdsa"], + ["*_ed25519", "id_ed25519"], + ["*_rsa", "id_rsa"], + ["credentials.json", "credentials.json"], + ["key.json", "key.json"], + ["secrets.json", "secrets.json"], + ["secrets.yaml", "secrets.yaml"], + ["service-account*.json", "service-account-prod.json"], + ["token.json", "token.json"], + ]); + + const securityPatterns = dockerignoreSecretPatterns(); + expect(securityPatterns).not.toHaveLength(0); + expect(securityPatterns).toEqual([...representativeSourceByPattern.keys()]); + + for (const pattern of securityPatterns) { + const dockerfilePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "e2e-rebuild-openclaw-dockerfile-")), + "Dockerfile.base", + ); + testFiles.push(path.dirname(dockerfilePath)); + const source = representativeSourceByPattern.get(pattern); + expect( + source, + `missing representative source for .dockerignore pattern ${pattern}`, + ).toBeDefined(); + fs.writeFileSync(dockerfilePath, `COPY ${source} /tmp/secret\n`, "utf8"); + + expect( + () => directDockerfileBaseCopySources(dockerfilePath), + `.dockerignore pattern ${pattern} should reject representative source ${source}`, + ).toThrow("Unsupported .dockerignore-secret Dockerfile.base COPY source"); + } + }); +});