From 5c665d4a8eb89ecf2f4d0eb6798bcf8b88ac9fcf Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Sat, 15 Aug 2026 23:29:37 -0700 Subject: [PATCH 1/4] refactor(security): share bundled npm package utilities Signed-off-by: Ho Lim --- scripts/lib/bundled-npm-package.mts | 115 ++++++++++++++++ scripts/lib/patch-bundled-npm-ip-address.mts | 123 +++-------------- scripts/patch-bundled-npm-brace-expansion.mts | 125 +++--------------- scripts/patch-bundled-npm-tar.mts | 52 ++------ scripts/upgrade-bundled-npm.mts | 38 +----- test/bundled-npm-package.test.ts | 63 +++++++++ 6 files changed, 227 insertions(+), 289 deletions(-) create mode 100644 scripts/lib/bundled-npm-package.mts create mode 100644 test/bundled-npm-package.test.ts diff --git a/scripts/lib/bundled-npm-package.mts b/scripts/lib/bundled-npm-package.mts new file mode 100644 index 00000000000..7b41446351a --- /dev/null +++ b/scripts/lib/bundled-npm-package.mts @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readdirSync, + readFileSync, + realpathSync, +} from "node:fs"; +import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; + +export type JsonObject = Record; + +export function jsonObject(value: unknown, label: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${label} must be a JSON object`); + } + return value as JsonObject; +} + +export function readJsonObject(file: string, label: string): JsonObject { + const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + if (!fstatSync(descriptor).isFile()) throw new Error(`${label} must be a real file: ${file}`); + return jsonObject(JSON.parse(readFileSync(descriptor, "utf8")), label); + } catch (error) { + throw new Error(`${label} is invalid: ${String(error)}`); + } finally { + closeSync(descriptor); + } +} + +export function requireRealDirectory(directory: string, label: string): string { + const resolved = resolve(directory); + const metadata = lstatSync(resolved); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`${label} must be a real directory: ${resolved}`); + } + return realpathSync(resolved); +} + +export function rejectUnsafePackageTree(root: string, label: string): void { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) { + throw new Error(`${label} contains an unsafe member: ${entry.name}`); + } + if (entry.isDirectory()) rejectUnsafePackageTree(join(root, entry.name), label); + } +} + +function isContainedBinSymlink( + nodeModulesRoot: string, + directory: string, + entryName: string, +): boolean { + if (basename(directory) !== ".bin") return false; + try { + const target = realpathSync(join(directory, entryName)); + const targetRelative = relative(nodeModulesRoot, target); + return ( + targetRelative !== "" && + targetRelative !== ".." && + !targetRelative.startsWith(`..${sep}`) && + !isAbsolute(targetRelative) && + lstatSync(target).isFile() + ); + } catch { + return false; + } +} + +export function collectBundledPackageVersions(options: { + ignoredDirectoryPrefixes: readonly string[]; + nodeModulesRoot: string; + packageName: string; +}): string[] { + const versions: string[] = []; + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + if (!isContainedBinSymlink(options.nodeModulesRoot, directory, entry.name)) { + throw new Error(`npm package contains an unsafe symlink: ${join(directory, entry.name)}`); + } + continue; + } + if ( + entry.isDirectory() && + options.ignoredDirectoryPrefixes.some((prefix) => entry.name.startsWith(prefix)) + ) { + continue; + } + const child = join(directory, entry.name); + if (!entry.isDirectory() && !entry.isFile()) { + throw new Error(`npm package contains an unsafe member: ${child}`); + } + if (entry.isDirectory()) { + visit(child); + continue; + } + if (entry.name !== "package.json") continue; + const manifest = readJsonObject(child, "npm bundled package manifest"); + if (manifest.name !== options.packageName) continue; + if (typeof manifest.version !== "string") { + throw new Error(`npm bundled ${options.packageName} version is invalid`); + } + versions.push(manifest.version); + } + }; + visit(options.nodeModulesRoot); + return versions; +} diff --git a/scripts/lib/patch-bundled-npm-ip-address.mts b/scripts/lib/patch-bundled-npm-ip-address.mts index 499cc72b5c0..5d7270b7199 100755 --- a/scripts/lib/patch-bundled-npm-ip-address.mts +++ b/scripts/lib/patch-bundled-npm-ip-address.mts @@ -10,20 +10,26 @@ import { constants, cpSync, fstatSync, - lstatSync, mkdirSync, mkdtempSync, openSync, - readdirSync, readFileSync, - realpathSync, renameSync, rmSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + type JsonObject, + collectBundledPackageVersions, + jsonObject as record, + readJsonObject as readJson, + rejectUnsafePackageTree, + requireRealDirectory as realDirectory, +} from "./bundled-npm-package.mts"; + export const AFFECTED_IP_ADDRESS_VERSION = "10.2.0"; export const FIXED_IP_ADDRESS_VERSION = "10.3.1"; export const FIXED_IP_ADDRESS_INTEGRITY = @@ -37,45 +43,6 @@ const REVIEWED_IP_ADDRESS_VERSIONS = new Set([ FIXED_IP_ADDRESS_VERSION, ]); -type JsonRecord = Record; - -function record(value: unknown, label: string): JsonRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new Error(`${label} must be a JSON object`); - } - return value as JsonRecord; -} - -function readJson(file: string, label: string): JsonRecord { - const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - if (!fstatSync(descriptor).isFile()) throw new Error(`${label} must be a real file: ${file}`); - return record(JSON.parse(readFileSync(descriptor, "utf8")), label); - } catch (error) { - throw new Error(`${label} is invalid: ${String(error)}`); - } finally { - closeSync(descriptor); - } -} - -function realDirectory(directory: string, label: string): string { - const resolved = resolve(directory); - const metadata = lstatSync(resolved); - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`${label} must be a real directory: ${resolved}`); - } - return realpathSync(resolved); -} - -function rejectUnsafeTree(root: string): void { - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) { - throw new Error(`replacement ip-address package contains an unsafe member: ${entry.name}`); - } - if (entry.isDirectory()) rejectUnsafeTree(join(root, entry.name)); - } -} - function removeBackup(backupPath: string): void { try { rmSync(backupPath, { force: true, recursive: true }); @@ -91,66 +58,7 @@ function removeBackup(backupPath: string): void { } } -function isContainedBinSymlink( - nodeModulesRoot: string, - directory: string, - entryName: string, -): boolean { - if (basename(directory) !== ".bin") return false; - try { - const target = realpathSync(join(directory, entryName)); - const targetRelative = relative(nodeModulesRoot, target); - return ( - targetRelative !== "" && - targetRelative !== ".." && - !targetRelative.startsWith(`..${sep}`) && - !isAbsolute(targetRelative) && - lstatSync(target).isFile() - ); - } catch { - return false; - } -} - -function collectIpAddressVersions( - directory: string, - nodeModulesRoot: string, - versions: string[], -): void { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.isSymbolicLink()) { - if (!isContainedBinSymlink(nodeModulesRoot, directory, entry.name)) { - throw new Error(`npm package contains an unsafe symlink: ${join(directory, entry.name)}`); - } - continue; - } - if ( - entry.isDirectory() && - (entry.name.startsWith(".ip-address.nemoclaw-stage-") || - entry.name.startsWith("ip-address.nemoclaw-backup-")) - ) { - continue; - } - if (!entry.isDirectory() && !entry.isFile()) { - throw new Error(`npm package contains an unsafe member: ${join(directory, entry.name)}`); - } - const child = join(directory, entry.name); - if (entry.isDirectory()) { - collectIpAddressVersions(child, nodeModulesRoot, versions); - continue; - } - if (entry.name !== "package.json") continue; - const manifest = readJson(child, "npm bundled package manifest"); - if (manifest.name === "ip-address") { - if (typeof manifest.version !== "string") { - throw new Error("npm bundled ip-address version is invalid"); - } - versions.push(manifest.version); - } - } -} - -function verifyIpAddressManifest(manifest: JsonRecord): string { +function verifyIpAddressManifest(manifest: JsonObject): string { const version = manifest.version; const engines = record(manifest.engines, "npm bundled ip-address engines"); if ( @@ -196,9 +104,12 @@ export function inspectBundledNpmIpAddress(npmRoot: string): BundledNpmIpAddress "npm bundled ip-address manifest", ), ); - const versions: string[] = []; const nodeModulesRoot = realDirectory(join(root, "node_modules"), "npm node_modules root"); - collectIpAddressVersions(nodeModulesRoot, nodeModulesRoot, versions); + const versions = collectBundledPackageVersions({ + ignoredDirectoryPrefixes: [".ip-address.nemoclaw-stage-", "ip-address.nemoclaw-backup-"], + nodeModulesRoot, + packageName: "ip-address", + }); if (versions.length !== 1 || versions[0] !== version) { throw new Error(`npm bundled ip-address layout has drifted: ${JSON.stringify(versions)}`); } @@ -226,7 +137,7 @@ export function patchBundledNpmIpAddress(options: { }): BundledNpmIpAddressState { const npmRoot = realDirectory(options.npmRoot, "npm package root"); const replacementRoot = realDirectory(options.replacementRoot, "replacement ip-address root"); - rejectUnsafeTree(replacementRoot); + rejectUnsafePackageTree(replacementRoot, "replacement ip-address package"); const replacementVersion = verifyIpAddressManifest( readJson(join(replacementRoot, "package.json"), "replacement ip-address manifest"), ); diff --git a/scripts/patch-bundled-npm-brace-expansion.mts b/scripts/patch-bundled-npm-brace-expansion.mts index b4361682d7d..b91b7c42234 100755 --- a/scripts/patch-bundled-npm-brace-expansion.mts +++ b/scripts/patch-bundled-npm-brace-expansion.mts @@ -10,20 +10,25 @@ import { constants, cpSync, fstatSync, - lstatSync, mkdirSync, mkdtempSync, openSync, - readdirSync, readFileSync, - realpathSync, renameSync, rmSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + collectBundledPackageVersions, + jsonObject as record, + readJsonObject as readJson, + rejectUnsafePackageTree, + requireRealDirectory as realDirectory, +} from "./lib/bundled-npm-package.mts"; + export const AFFECTED_BRACE_EXPANSION_VERSION = "5.0.7"; export const FIXED_BRACE_EXPANSION_VERSION = "5.0.9"; export const FIXED_BRACE_EXPANSION_INTEGRITY = @@ -38,106 +43,6 @@ const REVIEWED_BRACE_EXPANSION_VERSIONS = new Set([ FIXED_BRACE_EXPANSION_VERSION, ]); -type JsonRecord = Record; - -function record(value: unknown, label: string): JsonRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new Error(`${label} must be a JSON object`); - } - return value as JsonRecord; -} - -function readJson(file: string, label: string): JsonRecord { - const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - if (!fstatSync(descriptor).isFile()) throw new Error(`${label} must be a real file: ${file}`); - return record(JSON.parse(readFileSync(descriptor, "utf8")), label); - } catch (error) { - throw new Error(`${label} is invalid: ${String(error)}`); - } finally { - closeSync(descriptor); - } -} - -function realDirectory(directory: string, label: string): string { - const resolved = resolve(directory); - const metadata = lstatSync(resolved); - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`${label} must be a real directory: ${resolved}`); - } - return realpathSync(resolved); -} - -function rejectUnsafeTree(root: string): void { - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) { - throw new Error( - `replacement brace-expansion package contains an unsafe member: ${entry.name}`, - ); - } - if (entry.isDirectory()) rejectUnsafeTree(join(root, entry.name)); - } -} - -function isContainedBinSymlink( - nodeModulesRoot: string, - directory: string, - entryName: string, -): boolean { - if (basename(directory) !== ".bin") return false; - try { - const target = realpathSync(join(directory, entryName)); - const targetRelative = relative(nodeModulesRoot, target); - return ( - targetRelative !== "" && - targetRelative !== ".." && - !targetRelative.startsWith(`..${sep}`) && - !isAbsolute(targetRelative) && - lstatSync(target).isFile() - ); - } catch { - return false; - } -} - -function collectBraceExpansionVersions( - directory: string, - nodeModulesRoot: string, - versions: string[], -): void { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.isSymbolicLink()) { - if (!isContainedBinSymlink(nodeModulesRoot, directory, entry.name)) { - throw new Error(`npm package contains an unsafe symlink: ${join(directory, entry.name)}`); - } - continue; - } - if ( - entry.isDirectory() && - (entry.name.startsWith(".brace-expansion.nemoclaw-stage-") || - entry.name.startsWith("brace-expansion.nemoclaw-backup-")) - ) { - continue; - } - if (!entry.isDirectory() && !entry.isFile()) { - throw new Error(`npm package contains an unsafe member: ${join(directory, entry.name)}`); - } - const child = join(directory, entry.name); - if (entry.isDirectory()) { - collectBraceExpansionVersions(child, nodeModulesRoot, versions); - continue; - } - if (entry.name !== "package.json") continue; - const manifest = readJson(child, "npm bundled package manifest"); - if (manifest.name === "brace-expansion") { - if (typeof manifest.version !== "string") { - throw new Error("npm bundled brace-expansion version is invalid"); - } - versions.push(manifest.version); - } - } -} - export type BundledNpmBraceExpansionState = Readonly<{ braceExpansionVersion: string; npmVersion: string; @@ -174,9 +79,15 @@ export function inspectBundledNpmBraceExpansion(npmRoot: string): BundledNpmBrac ); } - const versions: string[] = []; const nodeModulesRoot = realDirectory(join(root, "node_modules"), "npm node_modules root"); - collectBraceExpansionVersions(nodeModulesRoot, nodeModulesRoot, versions); + const versions = collectBundledPackageVersions({ + ignoredDirectoryPrefixes: [ + ".brace-expansion.nemoclaw-stage-", + "brace-expansion.nemoclaw-backup-", + ], + nodeModulesRoot, + packageName: "brace-expansion", + }); if (versions.length !== 1 || versions[0] !== version) { throw new Error(`npm bundled brace-expansion layout has drifted: ${JSON.stringify(versions)}`); } @@ -207,7 +118,7 @@ export function patchBundledNpmBraceExpansion(options: { options.replacementRoot, "replacement brace-expansion root", ); - rejectUnsafeTree(replacementRoot); + rejectUnsafePackageTree(replacementRoot, "replacement brace-expansion package"); const replacement = readJson( join(replacementRoot, "package.json"), "replacement brace-expansion manifest", diff --git a/scripts/patch-bundled-npm-tar.mts b/scripts/patch-bundled-npm-tar.mts index 25c61f153bb..3dd69f6cb1c 100755 --- a/scripts/patch-bundled-npm-tar.mts +++ b/scripts/patch-bundled-npm-tar.mts @@ -10,13 +10,10 @@ import { constants, cpSync, fstatSync, - lstatSync, mkdirSync, mkdtempSync, openSync, - readdirSync, readFileSync, - realpathSync, renameSync, rmSync, } from "node:fs"; @@ -24,6 +21,13 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + jsonObject as record, + readJsonObject as readJson, + rejectUnsafePackageTree, + requireRealDirectory as realDirectory, +} from "./lib/bundled-npm-package.mts"; + export const FIXED_TAR_VERSION = "7.5.20"; export const FIXED_TAR_INTEGRITY = "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ=="; @@ -43,37 +47,6 @@ export const NODE_BASES_REQUIRING_BUNDLED_NPM_TAR_PATCH = [ "node:24-trixie-slim@sha256:05c08ce4291e9a58f59456a7985176defb12cdd42271f35ff81a3e167ea61d4c", ] as const; -type JsonRecord = Record; - -function record(value: unknown, label: string): JsonRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new Error(`${label} must be a JSON object`); - } - return value as JsonRecord; -} - -function readJson(file: string, label: string): JsonRecord { - const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - const metadata = fstatSync(descriptor); - if (!metadata.isFile()) throw new Error(`${label} must be a real file: ${file}`); - return record(JSON.parse(readFileSync(descriptor, "utf8")), label); - } catch (error) { - throw new Error(`${label} is invalid: ${String(error)}`); - } finally { - closeSync(descriptor); - } -} - -function realDirectory(directory: string, label: string): string { - const resolved = resolve(directory); - const metadata = lstatSync(resolved); - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`${label} must be a real directory: ${resolved}`); - } - return realpathSync(resolved); -} - function parseVersion(version: unknown, label: string): readonly [number, number, number] { if (typeof version !== "string") throw new Error(`${label} must be an exact semver version`); const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.exec(version); @@ -90,15 +63,6 @@ function versionAtLeast(version: unknown, minimum: string, label: string): boole return true; } -function rejectUnsafeTree(root: string): void { - for (const entry of readdirSync(root, { withFileTypes: true })) { - if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) { - throw new Error(`replacement tar package contains an unsafe member: ${entry.name}`); - } - if (entry.isDirectory()) rejectUnsafeTree(join(root, entry.name)); - } -} - export type BundledNpmTarState = Readonly<{ npmVersion: string; state: "affected" | "fixed"; @@ -158,7 +122,7 @@ export function patchBundledNpmTar(options: { }): BundledNpmTarState { const npmRoot = realDirectory(options.npmRoot, "npm package root"); const replacementRoot = realDirectory(options.replacementRoot, "replacement tar root"); - rejectUnsafeTree(replacementRoot); + rejectUnsafePackageTree(replacementRoot, "replacement tar package"); const replacement = readJson(join(replacementRoot, "package.json"), "replacement tar manifest"); if (replacement.name !== "tar" || replacement.version !== FIXED_TAR_VERSION) { throw new Error(`replacement package must be tar@${FIXED_TAR_VERSION}`); diff --git a/scripts/upgrade-bundled-npm.mts b/scripts/upgrade-bundled-npm.mts index 70b6822cd58..2976107f1a3 100755 --- a/scripts/upgrade-bundled-npm.mts +++ b/scripts/upgrade-bundled-npm.mts @@ -9,18 +9,22 @@ import { closeSync, constants, fstatSync, - lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, - realpathSync, rmSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + jsonObject as record, + readJsonObject as readJson, + requireRealDirectory as realDirectory, +} from "./lib/bundled-npm-package.mts"; + export const REVIEWED_NPM_VERSION = "11.18.0"; export const REVIEWED_NPM_INTEGRITY = "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w=="; @@ -35,36 +39,6 @@ export const REVIEWED_NPM_PACKAGES = { const REPLACEABLE_NPM_VERSIONS = new Set(["10.9.8", "11.13.0", "11.16.0"]); -type JsonRecord = Record; - -function record(value: unknown, label: string): JsonRecord { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new Error(`${label} must be a JSON object`); - } - return value as JsonRecord; -} - -function readJson(file: string, label: string): JsonRecord { - const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); - try { - if (!fstatSync(descriptor).isFile()) throw new Error(`${label} must be a real file: ${file}`); - return record(JSON.parse(readFileSync(descriptor, "utf8")), label); - } catch (error) { - throw new Error(`${label} is invalid: ${String(error)}`); - } finally { - closeSync(descriptor); - } -} - -function realDirectory(directory: string, label: string): string { - const resolved = resolve(directory); - const metadata = lstatSync(resolved); - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`${label} must be a real directory: ${resolved}`); - } - return realpathSync(resolved); -} - function npmVersion(npmRoot: string): string { const manifest = readJson(join(npmRoot, "package.json"), "npm package manifest"); if (manifest.name !== "npm" || typeof manifest.version !== "string") { diff --git a/test/bundled-npm-package.test.ts b/test/bundled-npm-package.test.ts new file mode 100644 index 00000000000..490904692c3 --- /dev/null +++ b/test/bundled-npm-package.test.ts @@ -0,0 +1,63 @@ +// 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 { + jsonObject, + readJsonObject, + rejectUnsafePackageTree, + requireRealDirectory, +} from "../scripts/lib/bundled-npm-package.mts"; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-npm-package-")); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("bundled npm package utilities", () => { + it("accepts a real JSON object without following file or directory symlinks", () => { + const root = temporaryDirectory(); + const manifest = path.join(root, "package.json"); + fs.writeFileSync(manifest, '{"name":"npm"}\n'); + expect(jsonObject({ name: "npm" }, "manifest")).toEqual({ name: "npm" }); + expect(() => jsonObject([], "manifest")).toThrow("manifest must be a JSON object"); + expect(readJsonObject(manifest, "npm manifest")).toEqual({ name: "npm" }); + expect(requireRealDirectory(root, "npm root")).toBe(fs.realpathSync(root)); + + const fileLink = path.join(root, "package-link.json"); + fs.symlinkSync("package.json", fileLink); + expect(() => readJsonObject(fileLink, "npm manifest")).toThrow(); + const directoryLink = path.join(temporaryDirectory(), "npm-link"); + fs.symlinkSync(root, directoryLink); + expect(() => requireRealDirectory(directoryLink, "npm root")).toThrow( + `npm root must be a real directory: ${directoryLink}`, + ); + }); + + it("accepts regular nested trees and rejects symlinked members", () => { + const root = temporaryDirectory(); + const nested = path.join(root, "lib"); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, "index.js"), "export {};\n"); + expect(() => rejectUnsafePackageTree(root, "replacement package")).not.toThrow(); + + fs.symlinkSync("lib/index.js", path.join(root, "unsafe-link")); + expect(() => rejectUnsafePackageTree(root, "replacement package")).toThrow( + "replacement package contains an unsafe member: unsafe-link", + ); + }); +}); From f9e3154d0453d2afc4601df6aeb164f3d485fe3a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 16 Aug 2026 10:17:50 -0700 Subject: [PATCH 2/4] fix(images): include bundled npm helper Signed-off-by: Carlos Villela --- Dockerfile | 2 ++ Dockerfile.base | 1 + agents/hermes/Dockerfile | 5 ++++- agents/hermes/Dockerfile.base | 1 + agents/langchain-deepagents-code/Dockerfile | 1 + agents/langchain-deepagents-code/Dockerfile.base | 1 + agents/pi/Dockerfile | 1 + agents/pi/Dockerfile.base | 1 + test/node-tar-dockerfile-contract.test.ts | 6 +++++- 9 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4da69d44984..7738f6cd90a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -531,6 +531,7 @@ COPY agents/openclaw/wechat-runtime/package.json /usr/local/lib/nemoclaw/wechat- COPY agents/openclaw/wechat-runtime/package-lock.json /usr/local/lib/nemoclaw/wechat-runtime/package-lock.json COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts @@ -2313,6 +2314,7 @@ RUN check_metadata() { \ exit 1; \ fi; \ } \ + && check_metadata /scripts/lib/bundled-npm-package.mts 'root:root:644' \ && check_metadata /scripts/patch-bundled-npm-brace-expansion.mts 'root:root:755' \ && check_metadata /scripts/lib/patch-bundled-npm-ip-address.mts 'root:root:755' \ && check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root:755' \ diff --git a/Dockerfile.base b/Dockerfile.base index 0a3515a57e9..4f561096459 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -409,6 +409,7 @@ COPY agents/openclaw/mcporter-runtime/package.json \ /usr/local/lib/nemoclaw/mcporter-runtime/ COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json COPY scripts/lib/reviewed-npm-archive.mts \ + scripts/lib/bundled-npm-package.mts \ scripts/lib/reviewed-npm-audit.mts \ scripts/lib/openclaw-npm-remediation.mts \ /scripts/lib/ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index f503314aeaa..d8787c71cf3 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -69,6 +69,7 @@ RUN set -eu; \ FROM scratch AS hermes-npm-patch-payload COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts @@ -370,7 +371,8 @@ RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + \ && chmod 444 /src/lib/hermes-managed-route.ts /src/lib/tool-disclosure.ts \ - && chmod 444 /scripts/lib/reviewed-npm-archive.mts /scripts/lib/openclaw-npm-remediation.mts \ + && chmod 444 /scripts/lib/reviewed-npm-archive.mts /scripts/lib/bundled-npm-package.mts \ + /scripts/lib/openclaw-npm-remediation.mts \ /scripts/patch-bundled-npm-brace-expansion.mts /scripts/lib/patch-bundled-npm-ip-address.mts \ /scripts/patch-bundled-npm-tar.mts \ && chmod -R a+rX /src/lib/messaging @@ -1386,6 +1388,7 @@ RUN check_metadata() { \ && check_absent /sandbox/.nemoclaw/hermes-cron-restore-drain.json \ && check_absent /sandbox/.nemoclaw/hermes-cron-restore-release-recovery.json \ && check_metadata /sandbox/.nemoclaw 'root:root 1755' \ + && check_metadata /scripts/lib/bundled-npm-package.mts 'root:root 444' \ && check_metadata /scripts/patch-bundled-npm-brace-expansion.mts 'root:root 444' \ && check_metadata /scripts/lib/patch-bundled-npm-ip-address.mts 'root:root 444' \ && check_metadata /scripts/patch-bundled-npm-tar.mts 'root:root 444' \ diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 61274edcd66..e53967edf3c 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -257,6 +257,7 @@ RUN arch="$(dpkg --print-architecture)" \ && test "$(npm --version)" = "11.16.0" COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index 590a4ce8b44..d776be250df 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -147,6 +147,7 @@ RUN managed_runtime_assertion_failed() { \ && install -d -o root -g root -m 0755 /run/nemoclaw COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts diff --git a/agents/langchain-deepagents-code/Dockerfile.base b/agents/langchain-deepagents-code/Dockerfile.base index 1f55d6b854d..33d4c3e8bf7 100644 --- a/agents/langchain-deepagents-code/Dockerfile.base +++ b/agents/langchain-deepagents-code/Dockerfile.base @@ -69,6 +69,7 @@ ARG NEMOCLAW_CORPORATE_CA_B64 COPY --from=perl-builder /out /tmp/nemoclaw-native-security COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts diff --git a/agents/pi/Dockerfile b/agents/pi/Dockerfile index af30a1a0a40..67d4b7f0f3f 100644 --- a/agents/pi/Dockerfile +++ b/agents/pi/Dockerfile @@ -115,6 +115,7 @@ RUN managed_runtime_assertion_failed() { \ && install -d -o root -g root -m 0755 /run/nemoclaw COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts diff --git a/agents/pi/Dockerfile.base b/agents/pi/Dockerfile.base index b62ed3034fe..efe2d1f846a 100644 --- a/agents/pi/Dockerfile.base +++ b/agents/pi/Dockerfile.base @@ -76,6 +76,7 @@ ARG PI_NPM_INTEGRITY COPY --from=perl-builder /out /tmp/nemoclaw-native-security COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts +COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 4e4685321a2..0dd306beb16 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -29,6 +29,8 @@ const dockerfiles = [ installsPatchDownloader: false, installsWithNpm: false, }, + { file: "agents/pi/Dockerfile.base", installsPatchDownloader: true, installsWithNpm: true }, + { file: "agents/pi/Dockerfile", installsPatchDownloader: false, installsWithNpm: false }, ] as const; const patchCommand = "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts"; const npmRootArguments = ["--npm-root", "/usr/local/lib/node_modules/npm"] as const; @@ -130,6 +132,7 @@ describe("node-tar image remediation contract", () => { patchPayloadStage === undefined ? source : namedStage(dockerfile, patchPayloadStage); const flattenedPatchInputStage = patchInputStage.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); const reviewedCopy = patchInputStage.indexOf("COPY scripts/lib/reviewed-npm-archive.mts"); + const helperCopy = patchInputStage.indexOf("scripts/lib/bundled-npm-package.mts"); const patchCopy = patchInputStage.indexOf( "COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts", ); @@ -150,7 +153,8 @@ describe("node-tar image remediation contract", () => { ), file, ).toBe(true); - expect(patchCopy, file).toBeGreaterThan(reviewedCopy); + expect(helperCopy, file).toBeGreaterThan(reviewedCopy); + expect(patchCopy, file).toBeGreaterThan(helperCopy); expect(patchRun, file).toBeGreaterThan(patchInputReady); const aptInstall = source.indexOf( "RUN apt-get update && apt-get install -y --no-install-recommends", From aec8757a957feb0ea70d1bba8b3a86d37d67772b Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 16 Aug 2026 10:33:29 -0700 Subject: [PATCH 3/4] fix(images): stage bundled npm helper Signed-off-by: Carlos Villela --- ci/full-e2e-cold-path-calibration.json | 1 + src/lib/onboard/build-context-stage.test.ts | 1 + src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts | 2 +- src/lib/sandbox-base-image/source-identity.test.ts | 1 + src/lib/sandbox-base-image/source-identity.ts | 1 + src/lib/sandbox/build-context.ts | 4 ++++ test/hermes-final-image-layout.test.ts | 2 ++ test/mcporter-supply-chain.test.ts | 2 +- test/node-tar-dockerfile-contract.test.ts | 4 ++-- test/openclaw-dependency-review.test.ts | 2 +- test/openclaw-final-image-layout.test.ts | 2 ++ test/sandbox-build-context.test.ts | 3 +++ 12 files changed, 20 insertions(+), 5 deletions(-) diff --git a/ci/full-e2e-cold-path-calibration.json b/ci/full-e2e-cold-path-calibration.json index dfa54a1c881..a459c4c6447 100644 --- a/ci/full-e2e-cold-path-calibration.json +++ b/ci/full-e2e-cold-path-calibration.json @@ -195,6 +195,7 @@ "scripts/patch-openclaw-device-self-approval.mts", "scripts/verify-wechat-runtime-lock.mts", "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/bundled-npm-package.mts", "src/lib/sandbox/build-context.ts" ], "adjustedMetrics": [ diff --git a/src/lib/onboard/build-context-stage.test.ts b/src/lib/onboard/build-context-stage.test.ts index badfafab95f..030a8cc88cc 100644 --- a/src/lib/onboard/build-context-stage.test.ts +++ b/src/lib/onboard/build-context-stage.test.ts @@ -115,6 +115,7 @@ describe("stageCreateSandboxBuildContext", () => { ["agents/hermes/plugin/entry.py", "required-plugin-bytes"], ["src/lib/tool-disclosure.ts", "required-tool-disclosure-bytes"], ["scripts/lib/reviewed-npm-archive.mts", "required-script-bytes"], + ["scripts/lib/bundled-npm-package.mts", "required-package-helper-bytes"], ["scripts/lib/seed-reviewed-npm-cache.mts", "required-cache-seed-bytes"], ["nemoclaw-blueprint/blueprint.yaml", "required-blueprint-bytes"], ] as const; diff --git a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts index 89f4b751909..7846a303f6f 100644 --- a/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts +++ b/src/lib/onboard/dockerfile-remote-dashboard-bind-contract.ts @@ -56,7 +56,7 @@ const CANONICAL_POST_GENERATOR_RUN_SHA256 = new Set([ "a0a554d474cb70087e50686d998915eae06201d6182a2410d3ccc4879e5058e6", "5af905889f94ffed2f6c371111d0589e38eed7b0de54ddb0dd68ad912a23149a", "1197b99bdb996b37a3e4e386a507dfabcdfb2c26a40b015d617f97208668187d", - "a619aead6cdf253dc7bf4504267e6b1d724fed672597072394b7400c08f81fd0", + "c65f4558aa283a73d4043aa7465fe8f4291af0be72ea721d76812095e7be6995", "c0b409e1bf4d33a9e44f407c6bd9b0445b2ffd0b796823fe3cfa5989314d6603", "9fcc674a44a152707380cdb09a67f8594f568288406c96f5354f1c87f5b939a6", "83567d1fa0e73bef6a3333383c13ace05e26704964ae6a7a76ee24a2f2be3d7e", diff --git a/src/lib/sandbox-base-image/source-identity.test.ts b/src/lib/sandbox-base-image/source-identity.test.ts index 3d4a9eea0a7..977376d74d8 100644 --- a/src/lib/sandbox-base-image/source-identity.test.ts +++ b/src/lib/sandbox-base-image/source-identity.test.ts @@ -180,6 +180,7 @@ describe("sandbox base-image source identity", () => { "scripts/security/patches/perl-5.44.0-net-ping-capability-tests.patch", "scripts/lib/openclaw-npm-remediation.mts", "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/bundled-npm-package.mts", "scripts/patch-bundled-npm-brace-expansion.mts", "scripts/lib/patch-bundled-npm-ip-address.mts", "scripts/patch-bundled-npm-tar.mts", diff --git a/src/lib/sandbox-base-image/source-identity.ts b/src/lib/sandbox-base-image/source-identity.ts index 5223fe01d8c..d4199909402 100644 --- a/src/lib/sandbox-base-image/source-identity.ts +++ b/src/lib/sandbox-base-image/source-identity.ts @@ -17,6 +17,7 @@ export const BASE_IMAGE_INPUT_PATHS = [ "scripts/security/patches/perl-5.44.0-net-ping-capability-tests.patch", "scripts/lib/openclaw-npm-remediation.mts", "scripts/lib/reviewed-npm-archive.mts", + "scripts/lib/bundled-npm-package.mts", "scripts/patch-bundled-npm-brace-expansion.mts", "scripts/lib/patch-bundled-npm-ip-address.mts", "scripts/patch-bundled-npm-tar.mts", diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 4a080151565..7e7b3616138 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -437,6 +437,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "reviewed-npm-archive.mts"), path.join(stagedScriptsDir, "lib", "reviewed-npm-archive.mts"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "bundled-npm-package.mts"), + path.join(stagedScriptsDir, "lib", "bundled-npm-package.mts"), + ); fs.copyFileSync( path.join(rootDir, "scripts", "lib", "seed-reviewed-npm-cache.mts"), path.join(stagedScriptsDir, "lib", "seed-reviewed-npm-cache.mts"), diff --git a/test/hermes-final-image-layout.test.ts b/test/hermes-final-image-layout.test.ts index 34b08e33cbb..4320bba538b 100644 --- a/test/hermes-final-image-layout.test.ts +++ b/test/hermes-final-image-layout.test.ts @@ -244,6 +244,7 @@ describe("Hermes final image layout", () => { stage: "hermes-npm-patch-payload", copies: [ "COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts", + "COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts", "COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts", "COPY scripts/lib/patch-bundled-npm-ip-address.mts /scripts/lib/patch-bundled-npm-ip-address.mts", "COPY scripts/patch-bundled-npm-tar.mts /scripts/patch-bundled-npm-tar.mts", @@ -440,6 +441,7 @@ describe("Hermes final image layout", () => { expect(modeNormalize).toBeGreaterThan(darwinCompatibility); expect(modeNormalize).toBeLessThan(metadataCheck); for (const metadataContract of [ + "/scripts/lib/bundled-npm-package.mts 'root:root 444'", "/scripts/patch-bundled-npm-brace-expansion.mts 'root:root 444'", "/scripts/lib/patch-bundled-npm-ip-address.mts 'root:root 444'", "/scripts/patch-bundled-npm-tar.mts 'root:root 444'", diff --git a/test/mcporter-supply-chain.test.ts b/test/mcporter-supply-chain.test.ts index e6562201f23..8d4a1516197 100644 --- a/test/mcporter-supply-chain.test.ts +++ b/test/mcporter-supply-chain.test.ts @@ -204,7 +204,7 @@ describe("mcporter image supply-chain controls", () => { ); expect( flattenedContents.includes( - "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", + "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", ) || contents.includes( "COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts", diff --git a/test/node-tar-dockerfile-contract.test.ts b/test/node-tar-dockerfile-contract.test.ts index 0dd306beb16..0f7becbaac2 100644 --- a/test/node-tar-dockerfile-contract.test.ts +++ b/test/node-tar-dockerfile-contract.test.ts @@ -29,7 +29,7 @@ const dockerfiles = [ installsPatchDownloader: false, installsWithNpm: false, }, - { file: "agents/pi/Dockerfile.base", installsPatchDownloader: true, installsWithNpm: true }, + { file: "agents/pi/Dockerfile.base", installsPatchDownloader: true, installsWithNpm: false }, { file: "agents/pi/Dockerfile", installsPatchDownloader: false, installsWithNpm: false }, ] as const; const patchCommand = "node --experimental-strip-types /scripts/patch-bundled-npm-tar.mts"; @@ -146,7 +146,7 @@ describe("node-tar image remediation contract", () => { expect(reviewedCopy, file).toBeGreaterThanOrEqual(0); expect( flattenedPatchInputStage.includes( - "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", + "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", ) || patchInputStage.includes( "COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts", diff --git a/test/openclaw-dependency-review.test.ts b/test/openclaw-dependency-review.test.ts index b252504ebea..7ea26023d8c 100644 --- a/test/openclaw-dependency-review.test.ts +++ b/test/openclaw-dependency-review.test.ts @@ -558,7 +558,7 @@ describe("OpenClaw 2026.6.10 dependency review contract", () => { const dockerfile = readFileSync(path.join(REPO_ROOT, "Dockerfile.base"), "utf-8"); const flattenedDockerfile = dockerfile.replace(/\\\s*\n/g, " ").replace(/\s+/g, " "); const groupedHelperCopy = flattenedDockerfile.indexOf( - "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", + "COPY scripts/lib/reviewed-npm-archive.mts scripts/lib/bundled-npm-package.mts scripts/lib/reviewed-npm-audit.mts scripts/lib/openclaw-npm-remediation.mts /scripts/lib/", ); const legacyHelperCopy = flattenedDockerfile.indexOf( "COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts", diff --git a/test/openclaw-final-image-layout.test.ts b/test/openclaw-final-image-layout.test.ts index 91a4e2141a5..7023c122052 100644 --- a/test/openclaw-final-image-layout.test.ts +++ b/test/openclaw-final-image-layout.test.ts @@ -53,6 +53,7 @@ describe("OpenClaw final image layout", () => { "COPY agents/openclaw/wechat-runtime/package-lock.json /usr/local/lib/nemoclaw/wechat-runtime/package-lock.json", "COPY ci/npm-audit-exceptions.json /scripts/npm-audit-exceptions.json", "COPY scripts/lib/reviewed-npm-archive.mts /scripts/lib/reviewed-npm-archive.mts", + "COPY scripts/lib/bundled-npm-package.mts /scripts/lib/bundled-npm-package.mts", "COPY scripts/lib/reviewed-npm-audit.mts /scripts/lib/reviewed-npm-audit.mts", "COPY scripts/lib/openclaw-npm-remediation.mts /scripts/lib/openclaw-npm-remediation.mts", "COPY scripts/patch-bundled-npm-brace-expansion.mts /scripts/patch-bundled-npm-brace-expansion.mts", @@ -159,6 +160,7 @@ describe("OpenClaw final image layout", () => { runtimeCopy, ]); for (const metadataContract of [ + "/scripts/lib/bundled-npm-package.mts 'root:root:644'", "/scripts/patch-bundled-npm-brace-expansion.mts 'root:root:755'", "/scripts/lib/patch-bundled-npm-ip-address.mts 'root:root:755'", "/scripts/patch-bundled-npm-tar.mts 'root:root:755'", diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index f75c936d424..6ad1178b916 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -269,6 +269,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "upgrade-bundled-npm.mts")); writeFixture(path.join("scripts", "verify-wechat-runtime-lock.mts")); writeFixture(path.join("scripts", "lib", "reviewed-npm-archive.mts"), "fixture\n", 0o700); + writeFixture(path.join("scripts", "lib", "bundled-npm-package.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "seed-reviewed-npm-cache.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "reviewed-npm-audit.mts"), "fixture\n", 0o700); writeFixture(path.join("scripts", "lib", "openclaw-npm-remediation.mts"), "fixture\n", 0o700); @@ -502,11 +503,13 @@ describe("sandbox build context staging", () => { const stagedScripts = path.join(buildCtx, "scripts"); const stagedLib = path.join(stagedScripts, "lib"); const stagedHelper = path.join(stagedLib, "reviewed-npm-archive.mts"); + const stagedPackageHelper = path.join(stagedLib, "bundled-npm-package.mts"); const stagedSeed = path.join(stagedLib, "seed-reviewed-npm-cache.mts"); expect((fs.statSync(stagedScripts).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(stagedLib).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(stagedHelper).mode & 0o777).toString(8)).toBe("755"); + expect((fs.statSync(stagedPackageHelper).mode & 0o777).toString(8)).toBe("755"); expect((fs.statSync(stagedSeed).mode & 0o777).toString(8)).toBe("755"); } From c6d4e2f235c9e7e85768b2336489179eb7ffe5db Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 16 Aug 2026 10:42:38 -0700 Subject: [PATCH 4/4] fix(npm): remove unused utility import Signed-off-by: Carlos Villela --- scripts/upgrade-bundled-npm.mts | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/upgrade-bundled-npm.mts b/scripts/upgrade-bundled-npm.mts index 2976107f1a3..813b84444ae 100755 --- a/scripts/upgrade-bundled-npm.mts +++ b/scripts/upgrade-bundled-npm.mts @@ -20,7 +20,6 @@ import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { - jsonObject as record, readJsonObject as readJson, requireRealDirectory as realDirectory, } from "./lib/bundled-npm-package.mts";