Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
209 changes: 209 additions & 0 deletions scripts/checks/test-create-require-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// 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-resume-config.test.ts",
"src/lib/actions/sandbox/rebuild-resume-reasoning.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;

// Count identifiers in executable syntax, including property access, because
// either can introduce a loader seam. Literal text cannot invoke createRequire.
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