Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ repos:
name: Repository checks
entry: npm run checks
language: system
files: ^(bin/.*\.(cjs|js|mjs)$|src/.*\.tsx?$|scripts/.*\.(cjs|js|mjs|ts|tsx)$|test/.*\.(cjs|js|mjs|ts|tsx)$|nemoclaw/src/.*\.tsx?$)
files: ^(bin/.*\.(cjs|js|mjs)$|src/.*\.(cts|mts|ts|tsx)$|scripts/.*\.(cjs|cts|js|mjs|mts|ts|tsx)$|test/.*\.(cjs|cts|js|mjs|mts|ts|tsx)$|nemoclaw/src/.*\.(cts|mts|ts|tsx)$)
pass_filenames: false
priority: 10

Expand Down
5 changes: 5 additions & 0 deletions scripts/checks/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ const CHECKS: readonly CheckCommand[] = [
command: TSX,
args: ["scripts/checks/no-test-dist-imports.ts"],
},
{
name: "test-create-require-budget",
command: TSX,
args: ["scripts/checks/test-create-require-budget.ts"],
},
{
name: "vitest-project-overlap",
command: TSX,
Expand Down
210 changes: 210 additions & 0 deletions scripts/checks/test-create-require-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import ts from "typescript";

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const CLI_TEST_ROOT = path.join(REPO_ROOT, "src");
const TEST_SUPPORT_ROOT = path.join(REPO_ROOT, "test");
const TEST_FILE_PATTERN = /\.test\.(?:[cm]?ts|tsx)$/;
const TYPESCRIPT_PATTERN = /\.(?:[cm]?ts|tsx)$/;

// Keep the exact paths rather than treating a scalar count as spare capacity.
// When another CommonJS test seam is retired, removing its path is part of
// that change; a different file cannot silently consume the freed slot.
export const CLI_CREATE_REQUIRE_FILES = [
"src/lib/actions/sandbox/doctor-flow.test.ts",
"src/lib/actions/sandbox/doctor-system-checks.test.ts",
"src/lib/actions/sandbox/gateway-state-drift.test.ts",
"src/lib/actions/sandbox/gateway-state-hints.test.ts",
"src/lib/actions/sandbox/process-recovery-lock.test.ts",
"src/lib/actions/sandbox/rebuild-agent-base-image-preflight.test.ts",
"src/lib/actions/sandbox/rebuild-config-hash.test.ts",
"src/lib/actions/sandbox/rebuild-flow-helpers.test.ts",
"src/lib/actions/sandbox/rebuild-gateway-drift.test.ts",
"src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts",
"src/lib/actions/sandbox/rebuild-messaging-stage.test.ts",
"src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts",
"src/lib/actions/sandbox/rebuild-resume-config.test.ts",
"src/lib/actions/sandbox/rebuild-resume-reasoning.test.ts",
"src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts",
"src/lib/actions/sandbox/rebuild-shields-finally.test.ts",
"src/lib/actions/sandbox/sandbox-gateway-routing.test.ts",
"src/lib/actions/upgrade-sandboxes-recovery.test.ts",
"src/lib/adapters/openshell/gateway-drift.test.ts",
"src/lib/hermes-provider-auth.test.ts",
"src/lib/inference/nim-igpu-compute-constrained.test.ts",
"src/lib/inference/nim.test.ts",
"src/lib/inference/ollama/proxy.test.ts",
"src/lib/inference/ollama/windows.test.ts",
"src/lib/onboard/sandbox-registration.test.ts",
"src/lib/sandbox/privileged-exec.test.ts",
"src/lib/shields/flow.test.ts",
"src/lib/shields/legacy-hermes-compat.test.ts",
"src/lib/shields/mutable-config-repair.test.ts",
"src/lib/shields/openclaw-transition.test.ts",
"src/lib/shields/policy-transition.test.ts",
"src/lib/state/onboard-session-cross-process-lock.test.ts",
"src/lib/state/onboard-session-tool-disclosure.test.ts",
"src/lib/state/onboard-session.test.ts",
"src/lib/state/user-managed-files-probe.test.ts",
] as const;

export const TEST_SUPPORT_CREATE_REQUIRE_FILES = [
"test/fixtures/strict-tool-call-probe-driver.ts",
"test/fixtures/uninstall-prompt-pty-driver.ts",
"test/helpers/base-image-test-harness.ts",
"test/helpers/destroy-flow-test-harness.ts",
"test/helpers/rebuild-flow-harness.ts",
"test/helpers/rebuild-flow-test-harness.ts",
"test/support/connect-flow-test-harness.ts",
"test/support/status-flow-test-harness.ts",
] as const;

function* walkTypeScriptFiles(directory: string): Generator<string> {
if (!existsSync(directory)) return;

for (const entry of readdirSync(directory)) {
const absolutePath = path.join(directory, entry);
const stats = lstatSync(absolutePath);
if (stats.isSymbolicLink()) continue;
if (stats.isDirectory()) {
yield* walkTypeScriptFiles(absolutePath);
} else if (stats.isFile() && TYPESCRIPT_PATTERN.test(entry)) {
yield absolutePath;
}
}
}

export function containsCreateRequireIdentifier(
sourceText: string,
fileName = "example.test.ts",
): boolean {
const sourceFile = ts.createSourceFile(
fileName,
sourceText,
ts.ScriptTarget.Latest,
true,
fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
let found = false;

function visit(node: ts.Node): void {
if (found) return;
if (ts.isIdentifier(node) && node.text === "createRequire") {
found = true;
return;
}
ts.forEachChild(node, visit);
}

visit(sourceFile);
return found;
}

export function collectCliCreateRequireTests(root = CLI_TEST_ROOT): string[] {
return [...walkTypeScriptFiles(root)]
.filter((absolutePath) => TEST_FILE_PATTERN.test(absolutePath))
.filter((absolutePath) =>
containsCreateRequireIdentifier(readFileSync(absolutePath, "utf8"), absolutePath),
)
.map((absolutePath) => path.relative(REPO_ROOT, absolutePath).split(path.sep).join("/"))
.sort();
}

function collectNonTestCreateRequireSources(root: string): string[] {
return [...walkTypeScriptFiles(root)]
.filter((absolutePath) => !TEST_FILE_PATTERN.test(absolutePath))
.filter((absolutePath) =>
containsCreateRequireIdentifier(readFileSync(absolutePath, "utf8"), absolutePath),
)
.map((absolutePath) => path.relative(REPO_ROOT, absolutePath).split(path.sep).join("/"))
.sort();
}

export function collectProductionCreateRequireSources(root = CLI_TEST_ROOT): string[] {
return collectNonTestCreateRequireSources(root);
}

export function collectTestSupportCreateRequireSources(root = TEST_SUPPORT_ROOT): string[] {
return collectNonTestCreateRequireSources(root);
}

export function createRequireBudgetFailure(
files: readonly string[],
allowedFiles: readonly string[] = CLI_CREATE_REQUIRE_FILES,
): string | null {
const actual = new Set(files);
const allowed = new Set(allowedFiles);
const added = [...actual].filter((file) => !allowed.has(file)).sort();
const removed = [...allowed].filter((file) => !actual.has(file)).sort();
if (added.length === 0 && removed.length === 0) return null;

const lines = ["CLI createRequire path budget failed."];
if (added.length > 0) {
lines.push(
"",
"Replace new CommonJS test seams with native imports or explicit dependencies:",
...added.map((file) => `- ${file}`),
);
}
if (removed.length > 0) {
lines.push(
"",
"Remove retired paths from CLI_CREATE_REQUIRE_FILES so they cannot return:",
...removed.map((file) => `- ${file}`),
);
}
return lines.join("\n");
}

function main(): void {
const productionFiles = collectProductionCreateRequireSources();
if (productionFiles.length > 0) {
console.error(
[
"Production TypeScript must not introduce createRequire boundaries.",
"Use static imports, explicit dependencies, or retain a genuine CommonJS boundary outside src/.",
"",
...productionFiles.map((file) => `- ${file}`),
].join("\n"),
);
process.exitCode = 1;
return;
}

const files = collectCliCreateRequireTests();
const failure = createRequireBudgetFailure(files);
if (failure) {
console.error(failure);
process.exitCode = 1;
return;
}

const supportFiles = collectTestSupportCreateRequireSources();
const supportFailure = createRequireBudgetFailure(
supportFiles,
TEST_SUPPORT_CREATE_REQUIRE_FILES,
);
if (supportFailure) {
console.error(
supportFailure
.replace("CLI createRequire", "Test-support createRequire")
.replaceAll("CLI_CREATE_REQUIRE_FILES", "TEST_SUPPORT_CREATE_REQUIRE_FILES"),
);
process.exitCode = 1;
return;
}

console.log(
`CLI createRequire budget passed: ${files.length} CLI test file(s), ${supportFiles.length} support file(s).`,
);
}

if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? "")) {
main();
}
61 changes: 26 additions & 35 deletions src/lib/actions/sandbox/policy-channel-agent-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,30 @@
// Without this gate, a destructive sandbox rebuild can run and fail late at
// Dockerfile patching.
//
// policy-channel.ts loads several dependencies through CommonJS `require()`.
// Load the source module and its dependencies through the shared source hook
// so `vi.spyOn` observes one require cache without depending on a CLI build.

import { createRequire } from "node:module";

import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";

const requireSource = createRequire(import.meta.url);
const D = (p: string) => requireSource(`../../${p}`);
import * as runtime from "../../adapters/openshell/runtime";
import * as defs from "../../agent/defs";
import * as store from "../../credentials/store";
import * as policy from "../../policy";
import * as registry from "../../state/registry";
import { addSandboxChannel } from "./policy-channel";
import { policyChannelDependencies } from "./policy-channel-dependencies";

const registry = D("state/registry.js");
const providers = D("onboard/providers.js");
const runtime = D("adapters/openshell/runtime.js");
const defs = D("agent/defs.js");
const rebuild = D("actions/sandbox/rebuild.js");
const policy = D("policy/index.js");
const store = D("credentials/store.js");
function agentFixture(name: string): defs.AgentDefinition {
return { name } as defs.AgentDefinition;
}

const { addSandboxChannel } = D("actions/sandbox/policy-channel.js") as {
addSandboxChannel: (
name: string,
options?: { channel?: string; dryRun?: boolean; force?: boolean },
) => Promise<void>;
};
function successfulOpenshellResult(): ReturnType<typeof runtime.runOpenshell> {
return {
pid: 0,
output: [null, "", ""],
stdout: "",
stderr: "",
status: 0,
signal: null,
};
}

let exitMock: MockInstance;
let errSpy: MockInstance;
Expand Down Expand Up @@ -64,10 +63,8 @@ beforeEach(() => {

getSandboxMock = vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "da-test" });
updateSandboxMock = vi.spyOn(registry, "updateSandbox").mockReturnValue(true);
upsertMock = vi.spyOn(providers, "upsertMessagingProviders").mockImplementation(() => undefined);
runOpenshellMock = vi
.spyOn(runtime, "runOpenshell")
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
upsertMock = vi.spyOn(policyChannelDependencies, "upsertMessagingProviders").mockReturnValue([]);
runOpenshellMock = vi.spyOn(runtime, "runOpenshell").mockReturnValue(successfulOpenshellResult());
loadPresetForSandboxMock = vi
.spyOn(policy, "loadPresetForSandbox")
.mockReturnValue("network_policies:\n stub: {}\n");
Expand All @@ -78,7 +75,7 @@ beforeEach(() => {
getCredentialMock = vi.spyOn(store, "getCredential").mockReturnValue(null);
saveCredentialMock = vi.spyOn(store, "saveCredential").mockImplementation(() => undefined);
promptMock = vi.spyOn(store, "prompt").mockResolvedValue("");
rebuildMock = vi.spyOn(rebuild, "rebuildSandbox").mockResolvedValue(undefined);
rebuildMock = vi.spyOn(policyChannelDependencies, "rebuildSandbox").mockResolvedValue(undefined);
});

afterEach(() => {
Expand All @@ -87,9 +84,7 @@ afterEach(() => {

describe("addSandboxChannel agent gate", () => {
it("rejects an unknown agent before any preset, mutation, provider, credential, or rebuild call", async () => {
vi.spyOn(defs, "loadAgent").mockReturnValue({
name: "custom-agent",
});
vi.spyOn(defs, "loadAgent").mockReturnValue(agentFixture("custom-agent"));

let caught: unknown;
try {
Expand Down Expand Up @@ -118,9 +113,7 @@ describe("addSandboxChannel agent gate", () => {
});

it("rejects an agent that is not listed by any channel manifest before any mutation", async () => {
vi.spyOn(defs, "loadAgent").mockReturnValue({
name: "future-agent",
});
vi.spyOn(defs, "loadAgent").mockReturnValue(agentFixture("future-agent"));

let caught: unknown;
try {
Expand All @@ -138,9 +131,7 @@ describe("addSandboxChannel agent gate", () => {
});

it("does not gate messaging-capable agents (openclaw flows past the agent check)", async () => {
vi.spyOn(defs, "loadAgent").mockReturnValue({
name: "openclaw",
});
vi.spyOn(defs, "loadAgent").mockReturnValue(agentFixture("openclaw"));

let caught: unknown;
try {
Expand Down
Loading
Loading