Skip to content
140 changes: 140 additions & 0 deletions test/e2e-scenario/live/rebuild-openclaw-old-base-context.ts
Original file line number Diff line number Diff line change
@@ -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;
}
49 changes: 25 additions & 24 deletions test/e2e-scenario/live/rebuild-openclaw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ??
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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<ShellProbeResult>;
}): Promise<void> {
Expand All @@ -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"),
],
{
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
Loading