From 759fb93a612bb8587237705e685e7b263270acb7 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 7 Jun 2026 01:10:14 -0700 Subject: [PATCH 1/3] test(cli): split sandbox connect inference tests --- ci/test-file-size-budget.json | 3 +- src/lib/core/version.ts | 11 + src/lib/onboard/config-sync.test.ts | 2 + src/lib/sandbox-base-image.test.ts | 34 +- src/lib/state/config-io.test.ts | 20 +- test/policies.test.ts | 34 +- test/release-latest-tag.test.ts | 20 +- .../auto-pair-approval.test.ts | 216 ++++++ test/sandbox-connect-inference/helpers.ts | 475 ++++++++++++ .../route-swap-repair.test.ts} | 677 +----------------- 10 files changed, 772 insertions(+), 720 deletions(-) create mode 100644 test/sandbox-connect-inference/auto-pair-approval.test.ts create mode 100644 test/sandbox-connect-inference/helpers.ts rename test/{sandbox-connect-inference.test.ts => sandbox-connect-inference/route-swap-repair.test.ts} (59%) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 236a84beff..f06a1834be 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -12,7 +12,6 @@ "test/onboard-messaging.test.ts": 2122, "test/onboard-selection.test.ts": 7757, "test/onboard.test.ts": 4887, - "test/policies.test.ts": 3147, - "test/sandbox-connect-inference.test.ts": 1577 + "test/policies.test.ts": 3143 } } diff --git a/src/lib/core/version.ts b/src/lib/core/version.ts index e7f8bfadfa..90d9ac7b5c 100644 --- a/src/lib/core/version.ts +++ b/src/lib/core/version.ts @@ -15,6 +15,16 @@ function isPackageInfo(value: PackageInfo | null): value is { version: string } return typeof value?.version === "string"; } +function gitEnvForRoot(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GIT_") && value !== undefined) { + env[key] = value; + } + } + return env; +} + export interface VersionOptions { /** Override the repo root directory. */ rootDir?: string; @@ -35,6 +45,7 @@ export function getVersion(opts: VersionOptions = {}): string { const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { cwd: root, encoding: "utf-8", + env: gitEnvForRoot(), stdio: ["ignore", "pipe", "ignore"], }).trim(); if (raw) return raw.replace(/^v/, ""); diff --git a/src/lib/onboard/config-sync.test.ts b/src/lib/onboard/config-sync.test.ts index 02775908dc..5294f8bc77 100644 --- a/src/lib/onboard/config-sync.test.ts +++ b/src/lib/onboard/config-sync.test.ts @@ -102,6 +102,7 @@ describe("sandbox config sync helpers", () => { try { const nemoclawDir = path.join(homeDir, ".nemoclaw"); fs.mkdirSync(nemoclawDir, { mode: 0o755 }); + fs.chmodSync(nemoclawDir, 0o755); const script = buildSandboxConfigSyncScript({ endpointType: "custom", endpointUrl: "https://inference.local/v1", @@ -128,6 +129,7 @@ describe("sandbox config sync helpers", () => { try { const nemoclawDir = path.join(homeDir, ".nemoclaw"); fs.mkdirSync(nemoclawDir, { mode: 0o755 }); + fs.chmodSync(nemoclawDir, 0o755); writeFakeCommand(fakeBin, "id", "1234"); writeFakeCommand(fakeBin, "stat", "0"); const script = buildSandboxConfigSyncScript({ diff --git a/src/lib/sandbox-base-image.test.ts b/src/lib/sandbox-base-image.test.ts index f297acaa6b..bdb88b4e8a 100644 --- a/src/lib/sandbox-base-image.test.ts +++ b/src/lib/sandbox-base-image.test.ts @@ -19,22 +19,34 @@ import { const tmpRoots: string[] = []; const emptyGitConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-empty-gitconfig-")); const emptyGitConfig = path.join(emptyGitConfigDir, "gitconfig"); +const emptyGitHooksDir = path.join(emptyGitConfigDir, "hooks"); const emptyGitConfigFd = fs.openSync(emptyGitConfig, "wx", 0o600); fs.closeSync(emptyGitConfigFd); +fs.mkdirSync(emptyGitHooksDir, { mode: 0o700 }); + +function buildGitEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GIT_") && value !== undefined) { + env[key] = value; + } + } + return { + ...env, + GIT_CONFIG_GLOBAL: emptyGitConfig, + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_AUTHOR_NAME: "Test User", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test User", + GIT_COMMITTER_EMAIL: "test@example.com", + }; +} -const gitEnv = { - ...process.env, - GIT_CONFIG_GLOBAL: emptyGitConfig, - GIT_CONFIG_NOSYSTEM: "1", - GIT_TERMINAL_PROMPT: "0", - GIT_AUTHOR_NAME: "Test User", - GIT_AUTHOR_EMAIL: "test@example.com", - GIT_COMMITTER_NAME: "Test User", - GIT_COMMITTER_EMAIL: "test@example.com", -}; +const gitEnv = buildGitEnv(); function git(root: string, args: string[]) { - const result = spawnSync("git", ["-C", root, ...args], { + const result = spawnSync("git", ["-c", `core.hooksPath=${emptyGitHooksDir}`, "-C", root, ...args], { encoding: "utf-8", env: gitEnv, }); diff --git a/src/lib/state/config-io.test.ts b/src/lib/state/config-io.test.ts index f1c8c03dca..7e5515bb1a 100644 --- a/src/lib/state/config-io.test.ts +++ b/src/lib/state/config-io.test.ts @@ -21,6 +21,11 @@ function makeTempDir(): string { return dir; } +function writeFileWithMode(filePath: string, contents: string, mode: number) { + fs.writeFileSync(filePath, contents, { mode }); + fs.chmodSync(filePath, mode); +} + afterEach(() => { for (const dir of tmpDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -75,6 +80,7 @@ describe("config-io", () => { it("tightens pre-existing weak directory permissions to 0o700", () => { const dir = path.join(makeTempDir(), "config"); fs.mkdirSync(dir, { mode: 0o755 }); + fs.chmodSync(dir, 0o755); ensureConfigDir(dir); @@ -154,7 +160,7 @@ describe("config-io", () => { const dir = makeTempDir(); fs.chmodSync(dir, 0o700); const file = path.join(dir, "config.json"); - fs.writeFileSync(file, JSON.stringify({ tight: true }), { mode: 0o644 }); + writeFileWithMode(file, JSON.stringify({ tight: true }), 0o644); const result = readConfigFile(file, null); @@ -186,7 +192,7 @@ describe("config-io", () => { "usage-notice.json", ]; for (const name of siblings) { - fs.writeFileSync(path.join(dir, name), "stale", { mode: 0o644 }); + writeFileWithMode(path.join(dir, name), "stale", 0o644); } readConfigFile(target, null); @@ -214,11 +220,11 @@ describe("config-io", () => { fs.writeFileSync(target, JSON.stringify({ ok: true }), { mode: 0o600 }); const sibling = path.join(dir, "should-be-healed.json"); - fs.writeFileSync(sibling, "stale", { mode: 0o644 }); + writeFileWithMode(sibling, "stale", 0o644); const outsideDir = makeTempDir(); const outside = path.join(outsideDir, "target"); - fs.writeFileSync(outside, "outside", { mode: 0o644 }); + writeFileWithMode(outside, "outside", 0o644); const linkPath = path.join(dir, "rogue-link"); fs.symlinkSync(outside, linkPath); @@ -242,7 +248,7 @@ describe("config-io", () => { const outsideDir = makeTempDir(); const outside = path.join(outsideDir, "target.json"); - fs.writeFileSync(outside, JSON.stringify({ outside: true }), { mode: 0o644 }); + writeFileWithMode(outside, JSON.stringify({ outside: true }), 0o644); const symlinkPath = path.join(dir, "config.json"); fs.symlinkSync(outside, symlinkPath); @@ -280,7 +286,7 @@ describe("config-io", () => { const target = path.join(unrelatedDir, "config.json"); fs.writeFileSync(target, JSON.stringify({ ok: true }), { mode: 0o600 }); const sibling = path.join(unrelatedDir, "other.json"); - fs.writeFileSync(sibling, "stale", { mode: 0o644 }); + writeFileWithMode(sibling, "stale", 0o644); readConfigFile(target, null); @@ -301,7 +307,7 @@ describe("config-io", () => { const target = path.join(hostDir, "sandboxes.json"); fs.writeFileSync(target, JSON.stringify({ ok: true }), { mode: 0o600 }); const sibling = path.join(hostDir, "onboard-session.json"); - fs.writeFileSync(sibling, "stale", { mode: 0o644 }); + writeFileWithMode(sibling, "stale", 0o644); readConfigFile(target, null); diff --git a/test/policies.test.ts b/test/policies.test.ts index e6940886da..0e1b89fc42 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1041,41 +1041,37 @@ exit 1 } }); - // The assertion must fire BEFORE any temp dir/file creation. With a real - // `process.exit(1)` the matching `finally` does not run, so a temp dir - // created before the exit gets orphaned in $TMPDIR. A mocked exit (which - // throws) doesn't reproduce that — `finally` still runs and cleans up. To - // catch the real-world bug, snapshot $TMPDIR at the *moment* of exit: - // if the assertion fires before mkdtempSync, no nemoclaw-policy-* dir - // should exist yet. + // A mocked process.exit still runs finally cleanup, so record same-process + // mkdtempSync calls at exit instead of counting shared $TMPDIR state. it("applyPreset does not create temp dirs before the openshell resolvability check", () => { - const beforeCount = fs - .readdirSync(os.tmpdir()) - .filter((entry) => entry.startsWith("nemoclaw-policy-")).length; - let countAtExit = -1; + const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); + const originalMkdtempSync = fs.mkdtempSync.bind(fs); + let policyTempDirsCreated = 0; + let policyTempDirsCreatedAtExit = -1; const resolveSpy = vi .spyOn(resolveOpenshellModule, "resolveOpenshell") .mockReturnValue(null); + const mkdtempSpy = vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix, options) => { + if (prefix === policyTempPrefix) { + policyTempDirsCreated += 1; + } + return originalMkdtempSync(prefix, options); + }); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => { - countAtExit = fs - .readdirSync(os.tmpdir()) - .filter((entry) => entry.startsWith("nemoclaw-policy-")).length; + policyTempDirsCreatedAtExit = policyTempDirsCreated; throw new Error("__test_exit__"); }) as never); try { - // Apply a real built-in preset so applyPresetContent runs end-to-end - // up to the resolvability check. expect(() => policies.applyPreset("my-assistant", "npm")).toThrow(/__test_exit__/); expect(exitSpy).toHaveBeenCalledWith(1); - // No `nemoclaw-policy-*` temp dir should have been created before - // the resolvability check exited. - expect(countAtExit).toBe(beforeCount); + expect(policyTempDirsCreatedAtExit).toBe(0); } finally { resolveSpy.mockRestore(); + mkdtempSpy.mockRestore(); errSpy.mockRestore(); logSpy.mockRestore(); exitSpy.mockRestore(); diff --git a/test/release-latest-tag.test.ts b/test/release-latest-tag.test.ts index ad3ad90a29..7dee7f58cc 100644 --- a/test/release-latest-tag.test.ts +++ b/test/release-latest-tag.test.ts @@ -23,9 +23,18 @@ const planScriptPath = path.join(repoRoot, "scripts", "release-plan.ts"); const tsxPath = path.join(repoRoot, "node_modules", ".bin", "tsx"); const tempRoots: string[] = []; +function baseEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GIT_") && value !== undefined) { + env[key] = value; + } + } + return { ...env, ...extra }; +} + function testEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { - return { - ...process.env, + return baseEnv({ GIT_AUTHOR_NAME: "Release Test", GIT_AUTHOR_EMAIL: "release-test@example.com", GIT_COMMITTER_NAME: "Release Test", @@ -34,7 +43,7 @@ function testEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { GIT_CONFIG_KEY_0: "tag.gpgSign", GIT_CONFIG_VALUE_0: "false", ...extra, - }; + }); } function run( @@ -134,8 +143,7 @@ function runReleaseLatestWithoutIdentity( const xdgConfigHome = path.join(fixture.root, "empty-xdg-config"); fs.mkdirSync(home); fs.mkdirSync(xdgConfigHome); - const env: NodeJS.ProcessEnv = { - ...process.env, + const env = baseEnv({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_0: "user.useConfigOnly", GIT_CONFIG_VALUE_0: "true", @@ -146,7 +154,7 @@ function runReleaseLatestWithoutIdentity( RELEASE_TAG: releaseTag, REMOTE_NAME: "origin", XDG_CONFIG_HOME: xdgConfigHome, - }; + }); delete env.GIT_AUTHOR_NAME; delete env.GIT_AUTHOR_EMAIL; delete env.GIT_COMMITTER_NAME; diff --git a/test/sandbox-connect-inference/auto-pair-approval.test.ts b/test/sandbox-connect-inference/auto-pair-approval.test.ts new file mode 100644 index 0000000000..3e54cd04cd --- /dev/null +++ b/test/sandbox-connect-inference/auto-pair-approval.test.ts @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { testTimeoutOptions } from "../helpers/timeouts"; +import { extractApprovalPassScript, runApprovalPassScript, runConnect, setupFixture } from "./helpers"; + +describe("sandbox connect auto-pair approval pass (#4263)", () => { + it( + "runs a bounded openclaw devices approval pass before opening SSH", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "approval-pass-sandbox", + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + gpuEnabled: false, + policies: [], + }, + "anthropic-prod", + "claude-sonnet-4-20250514", + ); + + const result = runConnect(tmpDir, sandboxName); + expect(result.status).toBe(0); + + const script = extractApprovalPassScript(stateFile, sandboxName); + // Hardened script content: source the proxy env, require local tools, + // and execute the trusted helper payload in memory instead of importing + // authorization code from predictable shared temp storage. + expect(script).toContain("/tmp/nemoclaw-proxy-env.sh"); + expect(script).toContain("command -v openclaw"); + expect(script).toContain("command -v python3"); + expect(script).toContain("devices"); + expect(script).toContain("list"); + expect(script).toContain("approve"); + expect(script).toContain("NEMOCLAW_APPROVAL_POLICY_B64="); + expect(script).toContain("base64.b64decode"); + expect(script).toContain("exec(compile(policy_source"); + expect(script).toContain("decision = approval_request_decision(device)"); + expect(script).toContain("if not decision['allowed']:"); + expect(script).toContain("approve_env = gateway_approval_env(os.environ)"); + expect(script).toContain("env=approve_env"); + expect(script).toContain("if approve_proc.returncode == 0"); + expect(script).not.toContain("/tmp/openclaw_device_approval_policy.py"); + expect(script).not.toContain("sys.path.insert(0, '/tmp')"); + expect(script.indexOf("[OPENCLAW, 'devices', 'list', '--json']")).toBeLessThan( + script.indexOf("approve_env = gateway_approval_env(os.environ)"), + ); + }, + ); + + it( + "rejects malformed and disallowed scope requests when the approval pass runs", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "approval-pass-policy", + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + gpuEnabled: false, + policies: [], + }, + "anthropic-prod", + "claude-sonnet-4-20250514", + ); + + const result = runConnect(tmpDir, sandboxName); + expect(result.status).toBe(0); + const script = extractApprovalPassScript(stateFile, sandboxName); + const run = runApprovalPassScript(script, [ + { + requestId: "ok-cli", + clientId: "openclaw-cli", + clientMode: "cli", + scopes: ["operator.read", "operator.write"], + }, + { + requestId: "admin-cli", + clientId: "openclaw-cli", + clientMode: "cli", + scopes: ["operator.admin"], + }, + { + requestId: "malformed-cli", + clientId: "openclaw-cli", + clientMode: "cli", + requestedScopes: "operator.write", + }, + { + requestId: "unknown-client", + clientId: "evil-client", + clientMode: "unknown", + scopes: ["operator.read"], + }, + { + requestId: "dedupe-cli", + clientId: "openclaw-cli", + clientMode: "cli", + requestedScopes: ["operator.read"], + }, + { + requestId: "dedupe-cli", + clientId: "openclaw-cli", + clientMode: "cli", + requestedScopes: ["operator.read"], + }, + ]); + + expect(run.result.status).toBe(0); + expect(run.approvals).toEqual(["ok-cli", "dedupe-cli"]); + expect(run.approvalEnv).toEqual(["unset:unset:unset", "unset:unset:unset"]); + }, + ); + + it( + "does not import approval policy from PYTHONPATH", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "approval-pass-tmp-tamper", + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + gpuEnabled: false, + policies: [], + }, + "anthropic-prod", + "claude-sonnet-4-20250514", + ); + const maliciousPolicy = [ + "def approval_request_decision(_device):", + " return {'allowed': True, 'reason': 'allowlisted', 'client_id': 'evil', 'client_mode': 'cli', 'scopes': set()}", + "", + "def gateway_approval_env(source_env=None):", + " return dict(source_env or {})", + "", + ].join("\n"); + const maliciousPythonPath = path.join(tmpDir, "malicious-pythonpath"); + + fs.mkdirSync(maliciousPythonPath); + fs.writeFileSync( + path.join(maliciousPythonPath, "openclaw_device_approval_policy.py"), + maliciousPolicy, + ); + + const result = runConnect(tmpDir, sandboxName); + expect(result.status).toBe(0); + const script = extractApprovalPassScript(stateFile, sandboxName); + const run = runApprovalPassScript( + script, + [ + { + requestId: "admin-cli", + clientId: "openclaw-cli", + clientMode: "cli", + scopes: ["operator.admin"], + }, + ], + { PYTHONPATH: maliciousPythonPath }, + ); + + expect(run.result.status).toBe(0); + expect(run.approvals).toEqual([]); + }, + ); + + it( + "does not block connect when the in-sandbox approval pass cannot run", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "approval-pass-tolerant", + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + gpuEnabled: false, + policies: [], + }, + "anthropic-prod", + "claude-sonnet-4-20250514", + ); + + // Force the approval-pass sandbox-exec to fail with exit status 7 + // (simulated via the NEMOCLAW_TEST_FAIL_APPROVAL_PASS hook in the + // fake openshell). The connect flow must still reach SSH handoff — + // the approval pass is best-effort and must not surface failures. + const result = runConnect(tmpDir, sandboxName, { + NEMOCLAW_TEST_FAIL_APPROVAL_PASS: "1", + }); + expect(result.status).toBe(0); + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + // Approval-pass exec was attempted (and the fake openshell exited + // non-zero for it, per the hook above). + const approvalExec = (state.sandboxExecCalls as string[][]).find( + (call) => + call.includes("--") && + call.some((segment) => segment.includes("openclaw")) && + call.some((segment) => segment.includes("devices")) && + call.some((segment) => segment.includes("approve")), + ); + expect(approvalExec).toBeDefined(); + // Despite the approval-pass failure, SSH handoff still happens. + expect(state.sandboxConnectCalls).toContainEqual([ + "sandbox", + "connect", + sandboxName, + ]); + }, + ); +}); diff --git a/test/sandbox-connect-inference/helpers.ts b/test/sandbox-connect-inference/helpers.ts new file mode 100644 index 0000000000..b0626f6a50 --- /dev/null +++ b/test/sandbox-connect-inference/helpers.ts @@ -0,0 +1,475 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { expect } from "vitest"; +import { execTimeout } from "../helpers/timeouts"; + +/** + * Tests for #1248 — inference route swap on sandbox connect. + * + * Each test creates a fake openshell binary that records calls to a state + * file, sets up a sandbox registry, and spawns the real CLI entrypoint. + */ + +export type SandboxEntryFixture = { + name: string; + model?: string | null; + provider?: string | null; + nimContainer?: string | null; + gpuEnabled?: boolean; + openshellDriver?: string | null; + policies?: string[]; +}; + +export type SetupFixtureOptions = { + curlExitCode?: number; + curlHttpStatus?: string; + curlStderr?: string; + inferenceProbeExitStatuses?: number[]; + inferenceProbeResponses?: string[]; + inferenceSetStatus?: number; + writeOllamaProxyState?: boolean; +}; + +export function isHostWsl() { + return ( + process.platform === "linux" && + (Boolean(process.env.WSL_DISTRO_NAME) || + Boolean(process.env.WSL_INTEROP) || + /microsoft/i.test(os.release())) + ); +} + +export function setupFixture( + sandboxEntry: SandboxEntryFixture, + liveInferenceProvider: string | null, + liveInferenceModel: string | null, + options: SetupFixtureOptions = {}, +) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-inf-swap-")); + const homeLocalBin = path.join(tmpDir, ".local", "bin"); + const registryDir = path.join(tmpDir, ".nemoclaw"); + const stateFile = path.join(tmpDir, "state.json"); + const openshellPath = path.join(homeLocalBin, "openshell"); + const dockerPath = path.join(homeLocalBin, "docker"); + const curlPath = path.join(homeLocalBin, "curl"); + const psPath = path.join(homeLocalBin, "ps"); + const sandboxName = String(sandboxEntry.name); + + fs.mkdirSync(homeLocalBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { [sandboxName]: sandboxEntry }, + }), + { mode: 0o600 }, + ); + + if ( + sandboxEntry.provider === "ollama-local" && + options.writeOllamaProxyState !== false + ) { + fs.writeFileSync( + path.join(registryDir, "ollama-proxy-token"), + "test-token\n", + { + mode: 0o600, + }, + ); + fs.writeFileSync( + path.join(registryDir, "ollama-auth-proxy.pid"), + "12345\n", + { + mode: 0o600, + }, + ); + } + + // Build the Gateway inference section for `openshell inference get` + let inferenceBlock; + if (liveInferenceProvider && liveInferenceModel) { + inferenceBlock = `Gateway inference:\\n Provider: ${liveInferenceProvider}\\n Model: ${liveInferenceModel}\\n`; + } else { + inferenceBlock = `Gateway inference:\\n Not configured\\n`; + } + + fs.writeFileSync( + stateFile, + JSON.stringify({ + dockerCalls: [], + curlExitCode: options.curlExitCode ?? 0, + curlHttpStatus: options.curlHttpStatus ?? "200", + curlStderr: options.curlStderr ?? "", + curlCalls: [], + curlEnvs: [], + inferenceProbeExitStatuses: options.inferenceProbeExitStatuses ?? [], + inferenceProbeResponses: options.inferenceProbeResponses ?? ["OK 200"], + inferenceSetCalls: [], + sandboxConnectCalls: [], + sandboxExecCalls: [], + }), + ); + + // Fake openshell binary — records inference set calls, stubs everything else + fs.writeFileSync( + openshellPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +const stateFile = ${JSON.stringify(stateFile)}; +const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + +if (args[0] === "status") { + process.stdout.write("Gateway: nemoclaw\\nStatus: Connected\\n"); + process.exit(0); +} + +if (args[0] === "gateway" && args[1] === "info") { + process.stdout.write("Gateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080\\n"); + process.exit(0); +} + +if (args[0] === "sandbox" && args[1] === "get" && args[2] === ${JSON.stringify(sandboxName)}) { + process.stdout.write("Sandbox:\\n\\n \\x1b[2mId:\\x1b[0m abc\\n Name: ${sandboxName}\\n Phase: Ready\\n"); + process.exit(0); +} + +if (args[0] === "sandbox" && args[1] === "list") { + process.stdout.write("${sandboxName} Ready 2m ago\\n"); + process.exit(0); +} + +if (args[0] === "sandbox" && args[1] === "exec") { + state.sandboxExecCalls.push(args); + const command = args.join(" "); + if (!command.includes("inference.local/v1/models")) { + fs.writeFileSync(stateFile, JSON.stringify(state)); + // Test hook (#4263 / CodeRabbit): when the connect-time auto-pair + // approval pass is specifically targeted, simulate the failure + // path the production code must tolerate. The approval-pass script + // is identifiable by its embedded \`openclaw devices approve\` call. + if ( + process.env.NEMOCLAW_TEST_FAIL_APPROVAL_PASS === "1" && + command.includes("openclaw") && + command.includes("devices") && + command.includes("approve") + ) { + process.stderr.write("simulated sandbox exec failure\\n"); + process.exit(7); + } + process.stdout.write("__NEMOCLAW_SANDBOX_EXEC_STARTED__\\nRUNNING\\n"); + process.exit(0); + } + const response = state.inferenceProbeResponses.length + ? state.inferenceProbeResponses.shift() + : 'BROKEN 503 {"error":"missing mocked inference probe response"}'; + const exitStatus = Number(state.inferenceProbeExitStatuses.shift() || 0); + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.stdout.write(response); + process.exit(exitStatus); +} + +if (args[0] === "sandbox" && args[1] === "connect") { + // Don't actually drop into a shell — just exit successfully + state.sandboxConnectCalls.push(args); + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.exit(0); +} + +if (args[0] === "inference" && args[1] === "get") { + process.stdout.write(${JSON.stringify(inferenceBlock.replace(/\\n/g, "\n"))}); + process.exit(0); +} + +if (args[0] === "inference" && args[1] === "set") { + state.inferenceSetCalls.push(args.slice(2)); + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.exit(${JSON.stringify(options.inferenceSetStatus ?? 0)}); +} + +if (args[0] === "logs") { + process.exit(0); +} + +if (args[0] === "forward") { + process.exit(0); +} + +// Default — succeed silently +process.exit(0); +`, + { mode: 0o755 }, + ); + + fs.writeFileSync( + dockerPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +const stateFile = ${JSON.stringify(stateFile)}; +const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); +state.dockerCalls.push(args); +fs.writeFileSync(stateFile, JSON.stringify(state)); +const cmd = args.join(" "); + +if (args[0] === "ps") { + process.stdout.write("openshell-cluster-nemoclaw\\n"); + process.exit(0); +} + +if (cmd.includes("get service kube-dns")) { + process.stdout.write("10.43.0.10"); + process.exit(0); +} +if (cmd.includes("get endpoints kube-dns")) { + process.stdout.write("10.42.0.15"); + process.exit(0); +} +if (cmd.includes("get pods -n openshell -o name")) { + process.stdout.write("pod/${sandboxName}-abc\\n"); + process.exit(0); +} +if (cmd.includes("ip addr show")) { + process.stdout.write("10.200.0.1\\n"); + process.exit(0); +} +if (cmd.includes("cat /tmp/dns-proxy.pid")) { + process.stdout.write("12345\\n"); + process.exit(0); +} +if (cmd.includes("cat /tmp/dns-proxy.log")) { + process.stdout.write("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\\n"); + process.exit(0); +} +if (cmd.includes("python3 -c")) { + process.stdout.write("ok"); + process.exit(0); +} +if (cmd.includes("ls /run/netns/")) { + process.stdout.write("sandbox-ns\\n"); + process.exit(0); +} +if (cmd.includes("test -x")) { + process.exit(cmd.includes("/usr/sbin/iptables") ? 0 : 1); +} +if (cmd.includes("cat /etc/resolv.conf")) { + process.stdout.write("nameserver 10.200.0.1\\n"); + process.exit(0); +} +if (cmd.includes("getent hosts github.com")) { + process.stdout.write("140.82.112.4 github.com\\n"); + process.exit(0); +} + +process.exit(0); +`, + { mode: 0o755 }, + ); + + fs.writeFileSync( + curlPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +const stateFile = ${JSON.stringify(stateFile)}; +const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); +state.curlCalls.push(args); +state.curlEnvs.push({ + ALL_PROXY: process.env.ALL_PROXY || "", + HTTP_PROXY: process.env.HTTP_PROXY || "", + NO_PROXY: process.env.NO_PROXY || "", + all_proxy: process.env.all_proxy || "", + http_proxy: process.env.http_proxy || "", + no_proxy: process.env.no_proxy || "", +}); +fs.writeFileSync(stateFile, JSON.stringify(state)); +const endpoint = args[args.length - 1] || ""; +if ( + process.env.OPENSHELL_TEST_FAIL_LOCALHOST_OLLAMA === "1" && + endpoint.includes("127.0.0.1:11434/api/tags") +) { + process.exit(7); +} +const outIndex = args.indexOf("-o"); +const exitCode = Number(state.curlExitCode || 0); +const status = String(state.curlHttpStatus || "200"); +if (outIndex >= 0 && args[outIndex + 1] && args[outIndex + 1] !== "/dev/null" && exitCode === 0) { + fs.writeFileSync(args[outIndex + 1], '{"models":[]}'); +} +if (state.curlStderr) { + process.stderr.write(String(state.curlStderr)); +} +if (args.includes("-w")) { + process.stdout.write(status); +} else { + process.stdout.write('{"models":[]}'); +} +process.exit(exitCode); +`, + { mode: 0o755 }, + ); + + fs.writeFileSync( + psPath, + `#!${process.execPath} +process.stdout.write("node /tmp/ollama-auth-proxy.js\\n"); +process.exit(0); +`, + { mode: 0o755 }, + ); + + return { tmpDir, stateFile, sandboxName }; +} + +export function createVmRootfs(tmpDir: string, sandboxId = "abc") { + const rootfs = path.join( + tmpDir, + ".local", + "state", + "nemoclaw", + "openshell-docker-gateway", + "vm-driver", + "sandboxes", + sandboxId, + "rootfs", + ); + fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); + fs.mkdirSync(path.join(rootfs, "srv"), { recursive: true }); + fs.writeFileSync( + path.join(rootfs, "etc", "resolv.conf"), + "nameserver 8.8.8.8\nnameserver 8.8.4.4\n", + ); + fs.writeFileSync( + path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), + [ + "elif ip link show eth0 >/dev/null 2>&1; then", + " if [ ! -s /etc/resolv.conf ]; then", + ' echo "nameserver 8.8.8.8" > /etc/resolv.conf', + ' echo "nameserver 8.8.4.4" >> /etc/resolv.conf', + " fi", + "fi", + "", + ].join("\n"), + ); + return rootfs; +} + +export function runConnect( + tmpDir: string, + sandboxName: string, + extraEnv: NodeJS.ProcessEnv = {}, + connectArgs: string[] = [], +) { + const repoRoot = path.join(import.meta.dirname, "..", ".."); + return spawnSync( + process.execPath, + [ + path.join(repoRoot, "bin", "nemoclaw.js"), + sandboxName, + "connect", + ...connectArgs, + ], + { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${path.join(tmpDir, ".local", "bin")}:/usr/bin:/bin`, + NEMOCLAW_NO_CONNECT_HINT: "1", + NEMOCLAW_OLLAMA_PORT: "11434", + NEMOCLAW_OLLAMA_PROXY_PORT: "11435", + ...extraEnv, + }, + timeout: execTimeout(15_000), + }, + ); +} + +export function extractApprovalPassScript(stateFile: string, sandboxName: string): string { + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + const approvalExec = (state.sandboxExecCalls as string[][]).find( + (call) => + call.includes("--") && + call.some((segment) => segment.includes("openclaw")) && + call.some((segment) => segment.includes("devices")) && + call.some((segment) => segment.includes("approve")), + ); + expect(approvalExec).toBeDefined(); + expect(approvalExec).toContain("sandbox"); + expect(approvalExec).toContain("exec"); + expect(approvalExec).toContain("--name"); + expect(approvalExec).toContain(sandboxName); + return approvalExec?.[approvalExec.length - 1] || ""; +} + +export function runApprovalPassScript( + script: string, + pending: unknown[], + extraEnv: NodeJS.ProcessEnv = {}, +) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-approval-pass-")); + const openclawPath = path.join(tmpDir, "openclaw"); + const approvalsFile = path.join(tmpDir, "approvals.log"); + const approvalEnvFile = path.join(tmpDir, "approval-env.log"); + const pendingResponse = JSON.stringify({ pending, paired: [] }); + + try { + fs.writeFileSync( + openclawPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +if (args[0] === "devices" && args[1] === "list") { + process.stdout.write(${JSON.stringify(`${pendingResponse}\n`)}); + process.exit(0); +} +if (args[0] === "devices" && args[1] === "approve") { + fs.appendFileSync(${JSON.stringify(approvalsFile)}, args[2] + "\\n"); + fs.appendFileSync( + ${JSON.stringify(approvalEnvFile)}, + [ + process.env.OPENCLAW_GATEWAY_URL || "unset", + process.env.OPENCLAW_GATEWAY_PORT || "unset", + process.env.OPENCLAW_GATEWAY_TOKEN || "unset", + ].join(":") + "\\n", + ); + process.stdout.write("{}\\n"); + process.exit(0); +} +process.stderr.write("unexpected openclaw args: " + args.join(" ") + "\\n"); +process.exit(2); +`, + { mode: 0o755 }, + ); + + const result = spawnSync("sh", ["-c", script], { + encoding: "utf-8", + env: { + ...process.env, + PATH: `${tmpDir}:/usr/bin:/bin`, + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_GATEWAY_TOKEN: "test-gateway-token", + ...extraEnv, + }, + timeout: 10_000, + }); + const approvals = fs.existsSync(approvalsFile) + ? fs.readFileSync(approvalsFile, "utf-8").trim().split("\n").filter(Boolean) + : []; + const approvalEnv = fs.existsSync(approvalEnvFile) + ? fs.readFileSync(approvalEnvFile, "utf-8").trim().split("\n").filter(Boolean) + : []; + return { result, approvals, approvalEnv }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} diff --git a/test/sandbox-connect-inference.test.ts b/test/sandbox-connect-inference/route-swap-repair.test.ts similarity index 59% rename from test/sandbox-connect-inference.test.ts rename to test/sandbox-connect-inference/route-swap-repair.test.ts index 12bb9447b1..e7ee671f49 100644 --- a/test/sandbox-connect-inference.test.ts +++ b/test/sandbox-connect-inference/route-swap-repair.test.ts @@ -1,478 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { execTimeout, testTimeoutOptions } from "./helpers/timeouts"; - -/** - * Tests for #1248 — inference route swap on sandbox connect. - * - * Each test creates a fake openshell binary that records calls to a state - * file, sets up a sandbox registry, and spawns the real CLI entrypoint. - */ - -type SandboxEntryFixture = { - name: string; - model?: string | null; - provider?: string | null; - nimContainer?: string | null; - gpuEnabled?: boolean; - openshellDriver?: string | null; - policies?: string[]; -}; - -type SetupFixtureOptions = { - curlExitCode?: number; - curlHttpStatus?: string; - curlStderr?: string; - inferenceProbeExitStatuses?: number[]; - inferenceProbeResponses?: string[]; - inferenceSetStatus?: number; - writeOllamaProxyState?: boolean; -}; - -function isHostWsl() { - return ( - process.platform === "linux" && - (Boolean(process.env.WSL_DISTRO_NAME) || - Boolean(process.env.WSL_INTEROP) || - /microsoft/i.test(os.release())) - ); -} - -function setupFixture( - sandboxEntry: SandboxEntryFixture, - liveInferenceProvider: string | null, - liveInferenceModel: string | null, - options: SetupFixtureOptions = {}, -) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-inf-swap-")); - const homeLocalBin = path.join(tmpDir, ".local", "bin"); - const registryDir = path.join(tmpDir, ".nemoclaw"); - const stateFile = path.join(tmpDir, "state.json"); - const openshellPath = path.join(homeLocalBin, "openshell"); - const dockerPath = path.join(homeLocalBin, "docker"); - const curlPath = path.join(homeLocalBin, "curl"); - const psPath = path.join(homeLocalBin, "ps"); - const sandboxName = String(sandboxEntry.name); - - fs.mkdirSync(homeLocalBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - defaultSandbox: sandboxName, - sandboxes: { [sandboxName]: sandboxEntry }, - }), - { mode: 0o600 }, - ); - - if ( - sandboxEntry.provider === "ollama-local" && - options.writeOllamaProxyState !== false - ) { - fs.writeFileSync( - path.join(registryDir, "ollama-proxy-token"), - "test-token\n", - { - mode: 0o600, - }, - ); - fs.writeFileSync( - path.join(registryDir, "ollama-auth-proxy.pid"), - "12345\n", - { - mode: 0o600, - }, - ); - } - - // Build the Gateway inference section for `openshell inference get` - let inferenceBlock; - if (liveInferenceProvider && liveInferenceModel) { - inferenceBlock = `Gateway inference:\\n Provider: ${liveInferenceProvider}\\n Model: ${liveInferenceModel}\\n`; - } else { - inferenceBlock = `Gateway inference:\\n Not configured\\n`; - } - - fs.writeFileSync( - stateFile, - JSON.stringify({ - dockerCalls: [], - curlExitCode: options.curlExitCode ?? 0, - curlHttpStatus: options.curlHttpStatus ?? "200", - curlStderr: options.curlStderr ?? "", - curlCalls: [], - curlEnvs: [], - inferenceProbeExitStatuses: options.inferenceProbeExitStatuses ?? [], - inferenceProbeResponses: options.inferenceProbeResponses ?? ["OK 200"], - inferenceSetCalls: [], - sandboxConnectCalls: [], - sandboxExecCalls: [], - }), - ); - - // Fake openshell binary — records inference set calls, stubs everything else - fs.writeFileSync( - openshellPath, - `#!${process.execPath} -const fs = require("fs"); -const args = process.argv.slice(2); -const stateFile = ${JSON.stringify(stateFile)}; -const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); - -if (args[0] === "status") { - process.stdout.write("Gateway: nemoclaw\\nStatus: Connected\\n"); - process.exit(0); -} - -if (args[0] === "gateway" && args[1] === "info") { - process.stdout.write("Gateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080\\n"); - process.exit(0); -} - -if (args[0] === "sandbox" && args[1] === "get" && args[2] === ${JSON.stringify(sandboxName)}) { - process.stdout.write("Sandbox:\\n\\n \\x1b[2mId:\\x1b[0m abc\\n Name: ${sandboxName}\\n Phase: Ready\\n"); - process.exit(0); -} - -if (args[0] === "sandbox" && args[1] === "list") { - process.stdout.write("${sandboxName} Ready 2m ago\\n"); - process.exit(0); -} - -if (args[0] === "sandbox" && args[1] === "exec") { - state.sandboxExecCalls.push(args); - const command = args.join(" "); - if (!command.includes("inference.local/v1/models")) { - fs.writeFileSync(stateFile, JSON.stringify(state)); - // Test hook (#4263 / CodeRabbit): when the connect-time auto-pair - // approval pass is specifically targeted, simulate the failure - // path the production code must tolerate. The approval-pass script - // is identifiable by its embedded \`openclaw devices approve\` call. - if ( - process.env.NEMOCLAW_TEST_FAIL_APPROVAL_PASS === "1" && - command.includes("openclaw") && - command.includes("devices") && - command.includes("approve") - ) { - process.stderr.write("simulated sandbox exec failure\\n"); - process.exit(7); - } - process.stdout.write("__NEMOCLAW_SANDBOX_EXEC_STARTED__\\nRUNNING\\n"); - process.exit(0); - } - const response = state.inferenceProbeResponses.length - ? state.inferenceProbeResponses.shift() - : 'BROKEN 503 {"error":"missing mocked inference probe response"}'; - const exitStatus = Number(state.inferenceProbeExitStatuses.shift() || 0); - fs.writeFileSync(stateFile, JSON.stringify(state)); - process.stdout.write(response); - process.exit(exitStatus); -} - -if (args[0] === "sandbox" && args[1] === "connect") { - // Don't actually drop into a shell — just exit successfully - state.sandboxConnectCalls.push(args); - fs.writeFileSync(stateFile, JSON.stringify(state)); - process.exit(0); -} - -if (args[0] === "inference" && args[1] === "get") { - process.stdout.write(${JSON.stringify(inferenceBlock.replace(/\\n/g, "\n"))}); - process.exit(0); -} - -if (args[0] === "inference" && args[1] === "set") { - state.inferenceSetCalls.push(args.slice(2)); - fs.writeFileSync(stateFile, JSON.stringify(state)); - process.exit(${JSON.stringify(options.inferenceSetStatus ?? 0)}); -} - -if (args[0] === "logs") { - process.exit(0); -} - -if (args[0] === "forward") { - process.exit(0); -} - -// Default — succeed silently -process.exit(0); -`, - { mode: 0o755 }, - ); - - fs.writeFileSync( - dockerPath, - `#!${process.execPath} -const fs = require("fs"); -const args = process.argv.slice(2); -const stateFile = ${JSON.stringify(stateFile)}; -const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); -state.dockerCalls.push(args); -fs.writeFileSync(stateFile, JSON.stringify(state)); -const cmd = args.join(" "); - -if (args[0] === "ps") { - process.stdout.write("openshell-cluster-nemoclaw\\n"); - process.exit(0); -} - -if (cmd.includes("get service kube-dns")) { - process.stdout.write("10.43.0.10"); - process.exit(0); -} -if (cmd.includes("get endpoints kube-dns")) { - process.stdout.write("10.42.0.15"); - process.exit(0); -} -if (cmd.includes("get pods -n openshell -o name")) { - process.stdout.write("pod/${sandboxName}-abc\\n"); - process.exit(0); -} -if (cmd.includes("ip addr show")) { - process.stdout.write("10.200.0.1\\n"); - process.exit(0); -} -if (cmd.includes("cat /tmp/dns-proxy.pid")) { - process.stdout.write("12345\\n"); - process.exit(0); -} -if (cmd.includes("cat /tmp/dns-proxy.log")) { - process.stdout.write("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\\n"); - process.exit(0); -} -if (cmd.includes("python3 -c")) { - process.stdout.write("ok"); - process.exit(0); -} -if (cmd.includes("ls /run/netns/")) { - process.stdout.write("sandbox-ns\\n"); - process.exit(0); -} -if (cmd.includes("test -x")) { - process.exit(cmd.includes("/usr/sbin/iptables") ? 0 : 1); -} -if (cmd.includes("cat /etc/resolv.conf")) { - process.stdout.write("nameserver 10.200.0.1\\n"); - process.exit(0); -} -if (cmd.includes("getent hosts github.com")) { - process.stdout.write("140.82.112.4 github.com\\n"); - process.exit(0); -} - -process.exit(0); -`, - { mode: 0o755 }, - ); - fs.writeFileSync( - curlPath, - `#!${process.execPath} -const fs = require("fs"); -const args = process.argv.slice(2); -const stateFile = ${JSON.stringify(stateFile)}; -const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); -state.curlCalls.push(args); -state.curlEnvs.push({ - ALL_PROXY: process.env.ALL_PROXY || "", - HTTP_PROXY: process.env.HTTP_PROXY || "", - NO_PROXY: process.env.NO_PROXY || "", - all_proxy: process.env.all_proxy || "", - http_proxy: process.env.http_proxy || "", - no_proxy: process.env.no_proxy || "", -}); -fs.writeFileSync(stateFile, JSON.stringify(state)); -const endpoint = args[args.length - 1] || ""; -if ( - process.env.OPENSHELL_TEST_FAIL_LOCALHOST_OLLAMA === "1" && - endpoint.includes("127.0.0.1:11434/api/tags") -) { - process.exit(7); -} -const outIndex = args.indexOf("-o"); -const exitCode = Number(state.curlExitCode || 0); -const status = String(state.curlHttpStatus || "200"); -if (outIndex >= 0 && args[outIndex + 1] && args[outIndex + 1] !== "/dev/null" && exitCode === 0) { - fs.writeFileSync(args[outIndex + 1], '{"models":[]}'); -} -if (state.curlStderr) { - process.stderr.write(String(state.curlStderr)); -} -if (args.includes("-w")) { - process.stdout.write(status); -} else { - process.stdout.write('{"models":[]}'); -} -process.exit(exitCode); -`, - { mode: 0o755 }, - ); - - fs.writeFileSync( - psPath, - `#!${process.execPath} -process.stdout.write("node /tmp/ollama-auth-proxy.js\\n"); -process.exit(0); -`, - { mode: 0o755 }, - ); - - return { tmpDir, stateFile, sandboxName }; -} - -function createVmRootfs(tmpDir: string, sandboxId = "abc") { - const rootfs = path.join( - tmpDir, - ".local", - "state", - "nemoclaw", - "openshell-docker-gateway", - "vm-driver", - "sandboxes", - sandboxId, - "rootfs", - ); - fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); - fs.mkdirSync(path.join(rootfs, "srv"), { recursive: true }); - fs.writeFileSync( - path.join(rootfs, "etc", "resolv.conf"), - "nameserver 8.8.8.8\nnameserver 8.8.4.4\n", - ); - fs.writeFileSync( - path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), - [ - "elif ip link show eth0 >/dev/null 2>&1; then", - " if [ ! -s /etc/resolv.conf ]; then", - ' echo "nameserver 8.8.8.8" > /etc/resolv.conf', - ' echo "nameserver 8.8.4.4" >> /etc/resolv.conf', - " fi", - "fi", - "", - ].join("\n"), - ); - return rootfs; -} - -function runConnect( - tmpDir: string, - sandboxName: string, - extraEnv: NodeJS.ProcessEnv = {}, - connectArgs: string[] = [], -) { - const repoRoot = path.join(import.meta.dirname, ".."); - return spawnSync( - process.execPath, - [ - path.join(repoRoot, "bin", "nemoclaw.js"), - sandboxName, - "connect", - ...connectArgs, - ], - { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${path.join(tmpDir, ".local", "bin")}:/usr/bin:/bin`, - NEMOCLAW_NO_CONNECT_HINT: "1", - NEMOCLAW_OLLAMA_PORT: "11434", - NEMOCLAW_OLLAMA_PROXY_PORT: "11435", - ...extraEnv, - }, - timeout: execTimeout(15_000), - }, - ); -} - -function extractApprovalPassScript(stateFile: string, sandboxName: string): string { - const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); - const approvalExec = (state.sandboxExecCalls as string[][]).find( - (call) => - call.includes("--") && - call.some((segment) => segment.includes("openclaw")) && - call.some((segment) => segment.includes("devices")) && - call.some((segment) => segment.includes("approve")), - ); - expect(approvalExec).toBeDefined(); - expect(approvalExec).toContain("sandbox"); - expect(approvalExec).toContain("exec"); - expect(approvalExec).toContain("--name"); - expect(approvalExec).toContain(sandboxName); - return approvalExec?.[approvalExec.length - 1] || ""; -} - -function runApprovalPassScript( - script: string, - pending: unknown[], - extraEnv: NodeJS.ProcessEnv = {}, -) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-approval-pass-")); - const openclawPath = path.join(tmpDir, "openclaw"); - const approvalsFile = path.join(tmpDir, "approvals.log"); - const approvalEnvFile = path.join(tmpDir, "approval-env.log"); - const pendingResponse = JSON.stringify({ pending, paired: [] }); - - try { - fs.writeFileSync( - openclawPath, - `#!${process.execPath} -const fs = require("fs"); -const args = process.argv.slice(2); -if (args[0] === "devices" && args[1] === "list") { - process.stdout.write(${JSON.stringify(`${pendingResponse}\n`)}); - process.exit(0); -} -if (args[0] === "devices" && args[1] === "approve") { - fs.appendFileSync(${JSON.stringify(approvalsFile)}, args[2] + "\\n"); - fs.appendFileSync( - ${JSON.stringify(approvalEnvFile)}, - [ - process.env.OPENCLAW_GATEWAY_URL || "unset", - process.env.OPENCLAW_GATEWAY_PORT || "unset", - process.env.OPENCLAW_GATEWAY_TOKEN || "unset", - ].join(":") + "\\n", - ); - process.stdout.write("{}\\n"); - process.exit(0); -} -process.stderr.write("unexpected openclaw args: " + args.join(" ") + "\\n"); -process.exit(2); -`, - { mode: 0o755 }, - ); - - const result = spawnSync("sh", ["-c", script], { - encoding: "utf-8", - env: { - ...process.env, - PATH: `${tmpDir}:/usr/bin:/bin`, - OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", - OPENCLAW_GATEWAY_PORT: "18789", - OPENCLAW_GATEWAY_TOKEN: "test-gateway-token", - ...extraEnv, - }, - timeout: 10_000, - }); - const approvals = fs.existsSync(approvalsFile) - ? fs.readFileSync(approvalsFile, "utf-8").trim().split("\n").filter(Boolean) - : []; - const approvalEnv = fs.existsSync(approvalEnvFile) - ? fs.readFileSync(approvalEnvFile, "utf-8").trim().split("\n").filter(Boolean) - : []; - return { result, approvals, approvalEnv }; - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } -} +import { testTimeoutOptions } from "../helpers/timeouts"; +import { createVmRootfs, isHostWsl, runConnect, setupFixture } from "./helpers"; describe("sandbox connect inference route swap (#1248)", () => { it( @@ -1368,210 +902,3 @@ describe("sandbox connect inference route swap (#1248)", () => { }, ); }); - -describe("sandbox connect auto-pair approval pass (#4263)", () => { - it( - "runs a bounded openclaw devices approval pass before opening SSH", - testTimeoutOptions(20_000), - () => { - const { tmpDir, stateFile, sandboxName } = setupFixture( - { - name: "approval-pass-sandbox", - model: "claude-sonnet-4-20250514", - provider: "anthropic-prod", - gpuEnabled: false, - policies: [], - }, - "anthropic-prod", - "claude-sonnet-4-20250514", - ); - - const result = runConnect(tmpDir, sandboxName); - expect(result.status).toBe(0); - - const script = extractApprovalPassScript(stateFile, sandboxName); - // Hardened script content: source the proxy env, require local tools, - // and execute the trusted helper payload in memory instead of importing - // authorization code from predictable shared temp storage. - expect(script).toContain("/tmp/nemoclaw-proxy-env.sh"); - expect(script).toContain("command -v openclaw"); - expect(script).toContain("command -v python3"); - expect(script).toContain("devices"); - expect(script).toContain("list"); - expect(script).toContain("approve"); - expect(script).toContain("NEMOCLAW_APPROVAL_POLICY_B64="); - expect(script).toContain("base64.b64decode"); - expect(script).toContain("exec(compile(policy_source"); - expect(script).toContain("decision = approval_request_decision(device)"); - expect(script).toContain("if not decision['allowed']:"); - expect(script).toContain("approve_env = gateway_approval_env(os.environ)"); - expect(script).toContain("env=approve_env"); - expect(script).toContain("if approve_proc.returncode == 0"); - expect(script).not.toContain("/tmp/openclaw_device_approval_policy.py"); - expect(script).not.toContain("sys.path.insert(0, '/tmp')"); - expect(script.indexOf("[OPENCLAW, 'devices', 'list', '--json']")).toBeLessThan( - script.indexOf("approve_env = gateway_approval_env(os.environ)"), - ); - }, - ); - - it( - "rejects malformed and disallowed scope requests when the approval pass runs", - testTimeoutOptions(20_000), - () => { - const { tmpDir, stateFile, sandboxName } = setupFixture( - { - name: "approval-pass-policy", - model: "claude-sonnet-4-20250514", - provider: "anthropic-prod", - gpuEnabled: false, - policies: [], - }, - "anthropic-prod", - "claude-sonnet-4-20250514", - ); - - const result = runConnect(tmpDir, sandboxName); - expect(result.status).toBe(0); - const script = extractApprovalPassScript(stateFile, sandboxName); - const run = runApprovalPassScript(script, [ - { - requestId: "ok-cli", - clientId: "openclaw-cli", - clientMode: "cli", - scopes: ["operator.read", "operator.write"], - }, - { - requestId: "admin-cli", - clientId: "openclaw-cli", - clientMode: "cli", - scopes: ["operator.admin"], - }, - { - requestId: "malformed-cli", - clientId: "openclaw-cli", - clientMode: "cli", - requestedScopes: "operator.write", - }, - { - requestId: "unknown-client", - clientId: "evil-client", - clientMode: "unknown", - scopes: ["operator.read"], - }, - { - requestId: "dedupe-cli", - clientId: "openclaw-cli", - clientMode: "cli", - requestedScopes: ["operator.read"], - }, - { - requestId: "dedupe-cli", - clientId: "openclaw-cli", - clientMode: "cli", - requestedScopes: ["operator.read"], - }, - ]); - - expect(run.result.status).toBe(0); - expect(run.approvals).toEqual(["ok-cli", "dedupe-cli"]); - expect(run.approvalEnv).toEqual(["unset:unset:unset", "unset:unset:unset"]); - }, - ); - - it( - "does not import approval policy from PYTHONPATH", - testTimeoutOptions(20_000), - () => { - const { tmpDir, stateFile, sandboxName } = setupFixture( - { - name: "approval-pass-tmp-tamper", - model: "claude-sonnet-4-20250514", - provider: "anthropic-prod", - gpuEnabled: false, - policies: [], - }, - "anthropic-prod", - "claude-sonnet-4-20250514", - ); - const maliciousPolicy = [ - "def approval_request_decision(_device):", - " return {'allowed': True, 'reason': 'allowlisted', 'client_id': 'evil', 'client_mode': 'cli', 'scopes': set()}", - "", - "def gateway_approval_env(source_env=None):", - " return dict(source_env or {})", - "", - ].join("\n"); - const maliciousPythonPath = path.join(tmpDir, "malicious-pythonpath"); - - fs.mkdirSync(maliciousPythonPath); - fs.writeFileSync( - path.join(maliciousPythonPath, "openclaw_device_approval_policy.py"), - maliciousPolicy, - ); - - const result = runConnect(tmpDir, sandboxName); - expect(result.status).toBe(0); - const script = extractApprovalPassScript(stateFile, sandboxName); - const run = runApprovalPassScript( - script, - [ - { - requestId: "admin-cli", - clientId: "openclaw-cli", - clientMode: "cli", - scopes: ["operator.admin"], - }, - ], - { PYTHONPATH: maliciousPythonPath }, - ); - - expect(run.result.status).toBe(0); - expect(run.approvals).toEqual([]); - }, - ); - - it( - "does not block connect when the in-sandbox approval pass cannot run", - testTimeoutOptions(20_000), - () => { - const { tmpDir, stateFile, sandboxName } = setupFixture( - { - name: "approval-pass-tolerant", - model: "claude-sonnet-4-20250514", - provider: "anthropic-prod", - gpuEnabled: false, - policies: [], - }, - "anthropic-prod", - "claude-sonnet-4-20250514", - ); - - // Force the approval-pass sandbox-exec to fail with exit status 7 - // (simulated via the NEMOCLAW_TEST_FAIL_APPROVAL_PASS hook in the - // fake openshell). The connect flow must still reach SSH handoff — - // the approval pass is best-effort and must not surface failures. - const result = runConnect(tmpDir, sandboxName, { - NEMOCLAW_TEST_FAIL_APPROVAL_PASS: "1", - }); - expect(result.status).toBe(0); - const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); - // Approval-pass exec was attempted (and the fake openshell exited - // non-zero for it, per the hook above). - const approvalExec = (state.sandboxExecCalls as string[][]).find( - (call) => - call.includes("--") && - call.some((segment) => segment.includes("openclaw")) && - call.some((segment) => segment.includes("devices")) && - call.some((segment) => segment.includes("approve")), - ); - expect(approvalExec).toBeDefined(); - // Despite the approval-pass failure, SSH handoff still happens. - expect(state.sandboxConnectCalls).toContainEqual([ - "sandbox", - "connect", - sandboxName, - ]); - }, - ); -}); From b70816a8bc1654390204d52a1686ba6929711b4b Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 7 Jun 2026 05:26:43 -0700 Subject: [PATCH 2/3] chore(cli): clean up unused CodeQL declarations (#4906) ## Summary Removes unused declarations reported by CodeQL's `js/unused-local-variable` rule across onboarding, inference helpers, scripts, and tests. Also stabilizes two test fixtures that blocked local full-hook verification under a restrictive umask and parallel temp-dir usage. ## Changes - Removed unused imports, constants, functions, and destructured bindings from `src/lib/onboard.ts`, inference helpers, the source-shape scanner, and affected tests. - Ratcheted legacy test-file size budgets after shrinking oversized test files. - Made permission and temp-dir assertions deterministic without changing production behavior. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Verification - [x] `npx prek run --all-files` passes - [x] `npm test` passes - [ ] Tests added or updated for new or changed behavior - [x] No secrets, API keys, or credentials committed - [x] Docs updated for user-facing behavior changes - [ ] `npm run docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Carlos Villela ## Summary by CodeRabbit * **Tests** * Updated test file imports and removed unused dependencies across multiple test suites. * Enhanced permission-handling tests for sandbox configuration validation scenarios. * Refactored test fixtures and improved test assertion clarity. * **Chores** * Reduced test file size budgets to reflect code optimization. * Removed unused internal helper functions and imports throughout the codebase. --- ci/test-file-size-budget.json | 4 +-- scripts/find-source-shape-tests.ts | 12 ------- src/lib/cli/command-display-metadata.test.ts | 2 -- src/lib/dashboard-url-command.test.ts | 6 +--- src/lib/inference/ollama/proxy.ts | 6 ---- src/lib/inference/onboard-probes.ts | 1 - src/lib/onboard.ts | 36 +++---------------- src/lib/onboard/config-sync.test.ts | 1 + src/lib/state/config-io.test.ts | 4 +++ test/cli-oclif-compatibility.test.ts | 1 - .../e2e-scenario-registry.test.ts | 2 +- test/e2e/brev-e2e.test.ts | 1 - test/gateway-state-reconcile-2276.test.ts | 2 -- test/nemoclaw-start.test.ts | 9 ----- test/policies.test.ts | 18 +++++----- test/sandbox-provisioning.test.ts | 3 -- test/skills-frontmatter.test.ts | 1 - 17 files changed, 22 insertions(+), 87 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 236a84beff..d9e09a4ffb 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,11 +8,11 @@ "test/channels-add-preset.test.ts": 1915, "test/generate-openclaw-config.test.ts": 2106, "test/install-preflight.test.ts": 4397, - "test/nemoclaw-start.test.ts": 5319, + "test/nemoclaw-start.test.ts": 5310, "test/onboard-messaging.test.ts": 2122, "test/onboard-selection.test.ts": 7757, "test/onboard.test.ts": 4887, - "test/policies.test.ts": 3147, + "test/policies.test.ts": 3145, "test/sandbox-connect-inference.test.ts": 1577 } } diff --git a/scripts/find-source-shape-tests.ts b/scripts/find-source-shape-tests.ts index bf2b59c74a..ad3396fae9 100755 --- a/scripts/find-source-shape-tests.ts +++ b/scripts/find-source-shape-tests.ts @@ -210,18 +210,6 @@ function isReadFileCall(node: ts.CallExpression): boolean { return false; } -function isSourceTextLikeName(name: string): boolean { - return /(src|source|text|content|body|block|snippet|heredoc|docker|script|shell|fn|lines?|matches|calls|usages)/i.test( - name, - ); -} - -function isTextDerivation(initText: string): boolean { - return /(\.indexOf\b|\.search\b|\.includes\b|\.match(All)?\b|\.slice\b|\.split\b|\.replace(All)?\b|\.trim(End)?\b|\.join\b|String\(|(?:YAML|yaml|JSON)\.parse\b|yaml\.load\b|Heredoc\b|Snippet\b|Block\b|extract[A-Z]|load[A-Z]|parse[A-Z])/.test( - initText, - ); -} - function isExecutionResultDerivation(initText: string): boolean { return /\b(?:spawnSync|execFileSync|execSync|run(?:Logged|Docker|Bash|WithLib|Embedded|Patch|Hermes|Openclaw|Daemon|Fetch|Command)\w*)\b/.test( initText, diff --git a/src/lib/cli/command-display-metadata.test.ts b/src/lib/cli/command-display-metadata.test.ts index 823abe99b0..42d737a6db 100644 --- a/src/lib/cli/command-display-metadata.test.ts +++ b/src/lib/cli/command-display-metadata.test.ts @@ -4,7 +4,6 @@ import { Config as OclifConfig } from "@oclif/core"; import { describe, expect, it } from "vitest"; -import { getRegisteredOclifCommandsMetadata } from "./oclif-metadata"; import { COMMANDS, visibleCommands } from "./command-registry"; describe("public command display metadata", () => { @@ -36,5 +35,4 @@ describe("public command display metadata", () => { expect(invalid).toEqual([]); }); - }); diff --git a/src/lib/dashboard-url-command.test.ts b/src/lib/dashboard-url-command.test.ts index 9b4701a7e4..4fab599ca2 100644 --- a/src/lib/dashboard-url-command.test.ts +++ b/src/lib/dashboard-url-command.test.ts @@ -3,11 +3,7 @@ import { describe, expect, it, vi } from "vitest"; -import { - DashboardUrlCommandError, - buildDashboardUrl, - runDashboardUrlCommand, -} from "./dashboard-url-command"; +import { buildDashboardUrl, runDashboardUrlCommand } from "./dashboard-url-command"; function makeSinks() { const out: string[] = []; diff --git a/src/lib/inference/ollama/proxy.ts b/src/lib/inference/ollama/proxy.ts index e693be542c..a31f2c58e2 100644 --- a/src/lib/inference/ollama/proxy.ts +++ b/src/lib/inference/ollama/proxy.ts @@ -9,7 +9,6 @@ import type { GpuInfo } from "../local"; const path = require("path"); const { spawn, spawnSync } = require("child_process"); -const http = require("http"); const { ROOT, SCRIPTS, run, runCapture, shellQuote } = require("../../runner"); const { OLLAMA_PORT, OLLAMA_PROXY_PORT } = require("../../core/ports"); const { waitForPort } = require("../../core/wait"); @@ -38,7 +37,6 @@ const { loadLocalAdapterPid, persistLocalAdapterPid, readLocalAdapterTextFile, - removeLocalAdapterFile, spawnDetachedNodeAdapter, writeLocalAdapterSecretFile, } = require("../local-adapter-lifecycle"); @@ -122,10 +120,6 @@ function loadPersistedProxyPid(): number | null { return loadLocalAdapterPid(PROXY_PID_PATH); } -function clearPersistedProxyPid(): void { - removeLocalAdapterFile(PROXY_PID_PATH); -} - // ── Process management ─────────────────────────────────────────── function isOllamaProxyProcess(pid: number | null | undefined): boolean { diff --git a/src/lib/inference/onboard-probes.ts b/src/lib/inference/onboard-probes.ts index 3272d8fd07..cef3f79514 100644 --- a/src/lib/inference/onboard-probes.ts +++ b/src/lib/inference/onboard-probes.ts @@ -15,7 +15,6 @@ const { const { isNvcfFunctionNotFoundForAccount, nvcfFunctionNotFoundMessage, - shouldForceCompletionsApi, } = require("../validation"); const { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index d770c058d3..cbee766b17 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -62,9 +62,6 @@ const { createGatewayBootstrapRepairHelpers, getGatewayBootstrapRepairPlan, }: typeof import("./onboard/gateway-bootstrap") = require("./onboard/gateway-bootstrap"); -const { - verifyWebSearchInsideSandbox: verifyWebSearchInsideSandboxWithDeps, -}: typeof import("./onboard/web-search-verify") = require("./onboard/web-search-verify"); const { buildDirectGpuPolicyYaml, buildDirectSandboxGpuProofCommands, @@ -123,11 +120,10 @@ const pRetry = require("p-retry"); * Covers CSI (color, erase, cursor), OSC, and C1 two-byte escapes per ECMA-48. */ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const runner: typeof import("./runner") = require("./runner"); -const { ROOT, SCRIPTS, redact, run, runShell, runCapture, runFile, shellQuote, validateName } = - runner; +const { ROOT, SCRIPTS, redact, run, runCapture, runFile, validateName } = runner; const braveProviderProfile: typeof import("./onboard/brave-provider-profile") = require("./onboard/brave-provider-profile"); const nameValidation: typeof import("./name-validation") = require("./name-validation"); -const { NAME_ALLOWED_FORMAT, getNameValidationGuidance } = nameValidation; +const { getNameValidationGuidance } = nameValidation; const docker: typeof import("./adapters/docker") = require("./adapters/docker"); const { dockerContainerInspectFormat, @@ -178,14 +174,12 @@ const localInference: typeof import("./inference/local") = require("./inference/ const { findReachableOllamaHost, resetOllamaHostCache, - getDefaultOllamaModel, getLocalProviderBaseUrl, getLocalProviderHealthCheck, getLocalProviderValidationBaseUrl, getOllamaModelOptions, getOllamaWarmupCommand, OLLAMA_HOST_DOCKER_INTERNAL, - validateOllamaModel, validateLocalProvider, } = localInference; const { @@ -227,8 +221,6 @@ const { HERMES_AUTH_METHOD_API_KEY, HERMES_AUTH_METHOD_OAUTH, HERMES_NOUS_API_KEY_CREDENTIAL_ENV, - HERMES_NOUS_API_KEY_HELP_URL, - getRequestedHermesAuthMethod, hermesAuthMethodLabel, normalizeHermesAuthMethod, } = hermesAuth; @@ -258,7 +250,6 @@ const { OLLAMA_PROXY_CREDENTIAL_ENV, VLLM_LOCAL_CREDENTIAL_ENV, getProviderLabel, - getEffectiveProviderName, getNonInteractiveProvider, getNonInteractiveModel, getSandboxInferenceConfig, @@ -270,7 +261,6 @@ const { OLLAMA_PROXY_CREDENTIAL_ENV: string; VLLM_LOCAL_CREDENTIAL_ENV: string; getProviderLabel: (key: string) => string; - getEffectiveProviderName: (key: string | null | undefined) => string | null; getNonInteractiveProvider: () => string | null; getNonInteractiveModel: (providerKey: string) => string | null; getSandboxInferenceConfig: ( @@ -285,7 +275,7 @@ const { inferenceCompat: LooseObject | null; }; }; -const { sleepSeconds, waitForHttp, waitUntil } = require("./core/wait"); +const { sleepSeconds, waitUntil } = require("./core/wait"); const platformUtils: typeof import("./platform") = require("./platform"); const { isWsl, shouldPatchCoredns } = platformUtils; const { @@ -353,7 +343,6 @@ const { exitOnSandboxGpuConfigErrors, formatSandboxGpuPassthroughNote, resolveSandboxGpuFlagFromOptions, - sandboxGpuRemediationLines, validateSandboxGpuPreflight, } = sandboxGpuPreflight; const openshellVersion: typeof import("./onboard/openshell-version") = require("./onboard/openshell-version"); @@ -473,8 +462,6 @@ const { getDockerDriverGatewayEndpoint } = dockerDriverGatewayEnv; const dockerDriverGatewayRuntimeMarker: typeof import("./onboard/docker-driver-gateway-runtime-marker") = require("./onboard/docker-driver-gateway-runtime-marker"); const gatewayBinding: typeof import("./onboard/gateway-binding") = require("./onboard/gateway-binding"); -const hostGatewayProcess: typeof import("./onboard/host-gateway-process") = - require("./onboard/host-gateway-process"); const vmDriverProcess: typeof import("./onboard/vm-driver-process") = require("./onboard/vm-driver-process"); const preflightUtils: typeof import("./onboard/preflight") = require("./onboard/preflight"); const clusterImagePatch: typeof import("./cluster-image-patch") = require("./cluster-image-patch"); @@ -572,9 +559,6 @@ const DIM = USE_COLOR ? "\x1b[2m" : ""; const RESET = USE_COLOR ? "\x1b[0m" : ""; let OPENSHELL_BIN: string | null = null; const GATEWAY_NAME = gatewayBinding.resolveGatewayName(GATEWAY_PORT); -const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway.plist"; - -const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; import type { JsonObject as LooseObject, @@ -647,7 +631,6 @@ const { openshellArgv, runOpenshell, runCaptureOpenshell, - safeOpenShellArgument, getGatewayPortArg, getDockerDriverGatewayEndpointArg, } = createOpenshellCliHelpers({ @@ -708,7 +691,7 @@ const selectOnboardAgent = createSelectOnboardAgent({ }); -const { getTransportRecoveryMessage, getProbeRecovery } = validationRecovery; +const { getTransportRecoveryMessage } = validationRecovery; // Validation functions — delegated to src/lib/validation.ts const { @@ -718,7 +701,6 @@ const { validateNvidiaApiKeyValue, isSafeModelId, shouldSkipResponsesProbe, - shouldForceCompletionsApi, } = validation; // validateNvidiaApiKeyValue — see validation import above @@ -731,7 +713,6 @@ const { resolveHermesNousApiKey, stageNousApiKeyProviderEnv, ensureHermesNousApiKeyEnv, - openshellResultMessage, checkHermesProviderStoreReachable, } = hermesAuth.createHermesAuthHelpers({ isNonInteractive, @@ -959,7 +940,6 @@ function isInferenceRouteReady(provider: string, model: string): boolean { } const { - sandboxExistsInGateway, pruneStaleSandboxEntry, shouldRestoreLatestBackupOnRecreate, confirmRecreateForSelectionDrift, @@ -974,9 +954,6 @@ const { const { - validateBraveSearchApiKey, - promptBraveSearchRecovery, - promptBraveSearchApiKey, ensureValidatedBraveSearchCredential, configureWebSearch, verifyWebSearchInsideSandbox, @@ -1000,8 +977,6 @@ const { verifyOnboardInferenceSmoke, getProbeAuthMode, getValidationProbeCurlArgs, - probeOpenAiLikeEndpoint, - probeAnthropicEndpoint, } = require("./inference/onboard-probes"); const { @@ -3840,7 +3815,7 @@ async function createSandbox( type ProviderChoice = { key: string; label: string }; -const { readLiveInference, readRecordedProvider, readRecordedNimContainer, readRecordedModel } = +const { readRecordedProvider, readRecordedNimContainer, readRecordedModel } = providerRecovery.createProviderRecoveryHelpers({ parseGatewayInference, runCaptureOpenshell, @@ -6238,7 +6213,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { const recordedSandboxName = session?.steps?.sandbox?.status === "complete" ? session?.sandboxName || null : null; - const resumeSandboxNameForGpu = recordedSandboxName || requestedSandboxName || null; console.log(""); console.log(` ${cliDisplayName()} Onboarding`); diff --git a/src/lib/onboard/config-sync.test.ts b/src/lib/onboard/config-sync.test.ts index 02775908dc..5c3d0c9c7d 100644 --- a/src/lib/onboard/config-sync.test.ts +++ b/src/lib/onboard/config-sync.test.ts @@ -128,6 +128,7 @@ describe("sandbox config sync helpers", () => { try { const nemoclawDir = path.join(homeDir, ".nemoclaw"); fs.mkdirSync(nemoclawDir, { mode: 0o755 }); + fs.chmodSync(nemoclawDir, 0o755); writeFakeCommand(fakeBin, "id", "1234"); writeFakeCommand(fakeBin, "stat", "0"); const script = buildSandboxConfigSyncScript({ diff --git a/src/lib/state/config-io.test.ts b/src/lib/state/config-io.test.ts index f1c8c03dca..22bcb7e14e 100644 --- a/src/lib/state/config-io.test.ts +++ b/src/lib/state/config-io.test.ts @@ -215,10 +215,12 @@ describe("config-io", () => { const sibling = path.join(dir, "should-be-healed.json"); fs.writeFileSync(sibling, "stale", { mode: 0o644 }); + fs.chmodSync(sibling, 0o644); const outsideDir = makeTempDir(); const outside = path.join(outsideDir, "target"); fs.writeFileSync(outside, "outside", { mode: 0o644 }); + fs.chmodSync(outside, 0o644); const linkPath = path.join(dir, "rogue-link"); fs.symlinkSync(outside, linkPath); @@ -243,6 +245,7 @@ describe("config-io", () => { const outsideDir = makeTempDir(); const outside = path.join(outsideDir, "target.json"); fs.writeFileSync(outside, JSON.stringify({ outside: true }), { mode: 0o644 }); + fs.chmodSync(outside, 0o644); const symlinkPath = path.join(dir, "config.json"); fs.symlinkSync(outside, symlinkPath); @@ -281,6 +284,7 @@ describe("config-io", () => { fs.writeFileSync(target, JSON.stringify({ ok: true }), { mode: 0o600 }); const sibling = path.join(unrelatedDir, "other.json"); fs.writeFileSync(sibling, "stale", { mode: 0o644 }); + fs.chmodSync(sibling, 0o644); readConfigFile(target, null); diff --git a/test/cli-oclif-compatibility.test.ts b/test/cli-oclif-compatibility.test.ts index 93ba59e5dd..b24b1e2a98 100644 --- a/test/cli-oclif-compatibility.test.ts +++ b/test/cli-oclif-compatibility.test.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import fs from "node:fs"; import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/test/e2e-scenario/framework-tests/e2e-scenario-registry.test.ts b/test/e2e-scenario/framework-tests/e2e-scenario-registry.test.ts index 32b9fb2abb..075f917cfc 100644 --- a/test/e2e-scenario/framework-tests/e2e-scenario-registry.test.ts +++ b/test/e2e-scenario/framework-tests/e2e-scenario-registry.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { scenario } from "../scenarios/builder.ts"; import { compileRunPlans } from "../scenarios/compiler.ts"; -import { buildScenarioRegistry, listScenarios } from "../scenarios/registry.ts"; +import { buildScenarioRegistry } from "../scenarios/registry.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const RUN_SCENARIOS = path.join(REPO_ROOT, "test/e2e-scenario/scenarios/run.ts"); diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index bec8462db3..840f9a7efe 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -52,7 +52,6 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { execSync, execFileSync, spawnSync, type StdioOptions } from "node:child_process"; -import fs from "node:fs"; import path from "node:path"; // Instance configuration diff --git a/test/gateway-state-reconcile-2276.test.ts b/test/gateway-state-reconcile-2276.test.ts index 4a83b6b1da..64e642542f 100644 --- a/test/gateway-state-reconcile-2276.test.ts +++ b/test/gateway-state-reconcile-2276.test.ts @@ -57,8 +57,6 @@ const STATUS_MALFORMED = "??? garbage output ???"; const SANDBOX_GET_READY = "Sandbox:\n\n Id: abc\n Name: my-assistant\n Namespace: openshell\n Phase: Ready\n"; const SANDBOX_GET_NOT_FOUND = "Error: × Not Found: sandbox not found"; -const SANDBOX_GET_TRANSPORT_ERROR = - "Error: × transport error\n ╰─▶ Connection reset by peer (os error 104)"; interface ScenarioScript { // sandbox get responses, one per call (cycled / stops at last) diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 9c383b5e02..53129834d7 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -13,15 +13,6 @@ const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "scripts", "lib const PRELOAD_SCRIPTS = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "scripts"); const JSON5_MODULE = path.join(import.meta.dirname, "..", "nemoclaw", "node_modules", "json5"); -function configureGuardBlock(src: string): string { - const start = src.indexOf("# nemoclaw-configure-guard begin"); - const end = src.indexOf("# nemoclaw-configure-guard end", start); - const endMarker = "# nemoclaw-configure-guard end"; - expect(start).toBeGreaterThan(-1); - expect(end).toBeGreaterThan(start); - return src.slice(start, end + endMarker.length); -} - function runtimeShellEnvBlock(src: string): string { const start = src.indexOf("write_runtime_shell_env() {"); const end = src.indexOf("# cleanup_on_signal", start); diff --git a/test/policies.test.ts b/test/policies.test.ts index e6940886da..e7a1d845ba 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1045,24 +1045,19 @@ exit 1 // `process.exit(1)` the matching `finally` does not run, so a temp dir // created before the exit gets orphaned in $TMPDIR. A mocked exit (which // throws) doesn't reproduce that — `finally` still runs and cleans up. To - // catch the real-world bug, snapshot $TMPDIR at the *moment* of exit: + // catch the real-world bug, spy on this process's mkdtempSync calls: // if the assertion fires before mkdtempSync, no nemoclaw-policy-* dir - // should exist yet. + // should be requested. it("applyPreset does not create temp dirs before the openshell resolvability check", () => { - const beforeCount = fs - .readdirSync(os.tmpdir()) - .filter((entry) => entry.startsWith("nemoclaw-policy-")).length; - let countAtExit = -1; + const policyTempPrefix = path.join(os.tmpdir(), "nemoclaw-policy-"); const resolveSpy = vi .spyOn(resolveOpenshellModule, "resolveOpenshell") .mockReturnValue(null); + const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => { - countAtExit = fs - .readdirSync(os.tmpdir()) - .filter((entry) => entry.startsWith("nemoclaw-policy-")).length; throw new Error("__test_exit__"); }) as never); @@ -1073,9 +1068,12 @@ exit 1 expect(exitSpy).toHaveBeenCalledWith(1); // No `nemoclaw-policy-*` temp dir should have been created before // the resolvability check exited. - expect(countAtExit).toBe(beforeCount); + expect( + mkdtempSpy.mock.calls.filter(([prefix]) => String(prefix).startsWith(policyTempPrefix)), + ).toEqual([]); } finally { resolveSpy.mockRestore(); + mkdtempSpy.mockRestore(); errSpy.mockRestore(); logSpy.mockRestore(); exitSpy.mockRestore(); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 7398918c54..0c171563ed 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -24,9 +24,6 @@ const DOCKERFILE_BASE = path.join(ROOT, "Dockerfile.base"); const DOCKERFILE_SANDBOX = path.join(ROOT, "test", "Dockerfile.sandbox"); const HERMES_DOCKERFILE = path.join(ROOT, "agents", "hermes", "Dockerfile"); const HERMES_DOCKERFILE_BASE = path.join(ROOT, "agents", "hermes", "Dockerfile.base"); -const HERMES_POLICY = path.join(ROOT, "agents", "hermes", "policy-additions.yaml"); -const HERMES_POLICY_PERMISSIVE = path.join(ROOT, "agents", "hermes", "policy-permissive.yaml"); -const HERMES_START = path.join(ROOT, "agents", "hermes", "start.sh"); function dockerRunCommandBetween( dockerfile: string, diff --git a/test/skills-frontmatter.test.ts b/test/skills-frontmatter.test.ts index d7943bd517..1bcf9bd6ab 100644 --- a/test/skills-frontmatter.test.ts +++ b/test/skills-frontmatter.test.ts @@ -44,7 +44,6 @@ describe("repo skill markdown files", () => { for (const markdownFile of generatedUserSkillFiles) { const relPath = path.relative(repoRoot, markdownFile); - const isSkill = path.basename(markdownFile) === "SKILL.md"; it(`does not include generated SPDX comments for ${relPath}`, () => { const raw = fs.readFileSync(markdownFile, "utf8"); From 90bc1260f1058695ad97b9109af2980e7f099537 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 7 Jun 2026 01:10:14 -0700 Subject: [PATCH 3/3] test(cli): split sandbox connect inference tests --- ci/test-file-size-budget.json | 3 +- src/lib/core/version.test.ts | 62 ++ src/lib/core/version.ts | 11 + src/lib/onboard/config-sync.test.ts | 1 + src/lib/sandbox-base-image.test.ts | 34 +- src/lib/state/config-io.test.ts | 24 +- test/policies.test.ts | 2 - test/release-latest-tag.test.ts | 20 +- .../auto-pair-approval.test.ts | 216 ++++++ test/sandbox-connect-inference/helpers.ts | 502 +++++++++++++ .../route-swap-repair.test.ts} | 677 +----------------- 11 files changed, 845 insertions(+), 707 deletions(-) create mode 100644 test/sandbox-connect-inference/auto-pair-approval.test.ts create mode 100644 test/sandbox-connect-inference/helpers.ts rename test/{sandbox-connect-inference.test.ts => sandbox-connect-inference/route-swap-repair.test.ts} (59%) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index d9e09a4ffb..eb17db48e7 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -12,7 +12,6 @@ "test/onboard-messaging.test.ts": 2122, "test/onboard-selection.test.ts": 7757, "test/onboard.test.ts": 4887, - "test/policies.test.ts": 3145, - "test/sandbox-connect-inference.test.ts": 1577 + "test/policies.test.ts": 3143 } } diff --git a/src/lib/core/version.test.ts b/src/lib/core/version.test.ts index 495ce37081..5f41caa07d 100644 --- a/src/lib/core/version.test.ts +++ b/src/lib/core/version.test.ts @@ -2,11 +2,47 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { getVersion } from "../../../dist/lib/core/version"; +const repoRoot = join(import.meta.dirname, "..", "..", ".."); + +function withoutGitEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GIT_") && value !== undefined) { + env[key] = value; + } + } + return env; +} + +function withEnv(overrides: NodeJS.ProcessEnv, fn: () => T): T { + const previous = new Map(); + for (const [key, value] of Object.entries(overrides)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + return fn(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + describe("lib/version", () => { let testDir: string; @@ -42,6 +78,32 @@ describe("lib/version", () => { writeFileSync(join(testDir, "package.json"), JSON.stringify({ version: "1.2.3" })); }); + it("ignores inherited Git hook environment for explicit roots", () => { + const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { + cwd: repoRoot, + encoding: "utf-8", + env: withoutGitEnv(), + }).trim(); + + writeFileSync(join(testDir, ".version"), "2.3.4\n"); + try { + const result = withEnv( + { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.hooksPath", + GIT_CONFIG_VALUE_0: "/tmp/hostile-hooks", + GIT_DIR: gitDir, + GIT_INDEX_FILE: join(testDir, "hostile-index"), + GIT_WORK_TREE: repoRoot, + }, + () => getVersion({ rootDir: testDir }), + ); + expect(result).toBe("2.3.4"); + } finally { + rmSync(join(testDir, ".version")); + } + }); + it("returns a string", () => { expect(typeof getVersion({ rootDir: testDir })).toBe("string"); }); diff --git a/src/lib/core/version.ts b/src/lib/core/version.ts index e7f8bfadfa..90d9ac7b5c 100644 --- a/src/lib/core/version.ts +++ b/src/lib/core/version.ts @@ -15,6 +15,16 @@ function isPackageInfo(value: PackageInfo | null): value is { version: string } return typeof value?.version === "string"; } +function gitEnvForRoot(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GIT_") && value !== undefined) { + env[key] = value; + } + } + return env; +} + export interface VersionOptions { /** Override the repo root directory. */ rootDir?: string; @@ -35,6 +45,7 @@ export function getVersion(opts: VersionOptions = {}): string { const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { cwd: root, encoding: "utf-8", + env: gitEnvForRoot(), stdio: ["ignore", "pipe", "ignore"], }).trim(); if (raw) return raw.replace(/^v/, ""); diff --git a/src/lib/onboard/config-sync.test.ts b/src/lib/onboard/config-sync.test.ts index 5c3d0c9c7d..5294f8bc77 100644 --- a/src/lib/onboard/config-sync.test.ts +++ b/src/lib/onboard/config-sync.test.ts @@ -102,6 +102,7 @@ describe("sandbox config sync helpers", () => { try { const nemoclawDir = path.join(homeDir, ".nemoclaw"); fs.mkdirSync(nemoclawDir, { mode: 0o755 }); + fs.chmodSync(nemoclawDir, 0o755); const script = buildSandboxConfigSyncScript({ endpointType: "custom", endpointUrl: "https://inference.local/v1", diff --git a/src/lib/sandbox-base-image.test.ts b/src/lib/sandbox-base-image.test.ts index f297acaa6b..bdb88b4e8a 100644 --- a/src/lib/sandbox-base-image.test.ts +++ b/src/lib/sandbox-base-image.test.ts @@ -19,22 +19,34 @@ import { const tmpRoots: string[] = []; const emptyGitConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-empty-gitconfig-")); const emptyGitConfig = path.join(emptyGitConfigDir, "gitconfig"); +const emptyGitHooksDir = path.join(emptyGitConfigDir, "hooks"); const emptyGitConfigFd = fs.openSync(emptyGitConfig, "wx", 0o600); fs.closeSync(emptyGitConfigFd); +fs.mkdirSync(emptyGitHooksDir, { mode: 0o700 }); + +function buildGitEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GIT_") && value !== undefined) { + env[key] = value; + } + } + return { + ...env, + GIT_CONFIG_GLOBAL: emptyGitConfig, + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + GIT_AUTHOR_NAME: "Test User", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test User", + GIT_COMMITTER_EMAIL: "test@example.com", + }; +} -const gitEnv = { - ...process.env, - GIT_CONFIG_GLOBAL: emptyGitConfig, - GIT_CONFIG_NOSYSTEM: "1", - GIT_TERMINAL_PROMPT: "0", - GIT_AUTHOR_NAME: "Test User", - GIT_AUTHOR_EMAIL: "test@example.com", - GIT_COMMITTER_NAME: "Test User", - GIT_COMMITTER_EMAIL: "test@example.com", -}; +const gitEnv = buildGitEnv(); function git(root: string, args: string[]) { - const result = spawnSync("git", ["-C", root, ...args], { + const result = spawnSync("git", ["-c", `core.hooksPath=${emptyGitHooksDir}`, "-C", root, ...args], { encoding: "utf-8", env: gitEnv, }); diff --git a/src/lib/state/config-io.test.ts b/src/lib/state/config-io.test.ts index 22bcb7e14e..7e5515bb1a 100644 --- a/src/lib/state/config-io.test.ts +++ b/src/lib/state/config-io.test.ts @@ -21,6 +21,11 @@ function makeTempDir(): string { return dir; } +function writeFileWithMode(filePath: string, contents: string, mode: number) { + fs.writeFileSync(filePath, contents, { mode }); + fs.chmodSync(filePath, mode); +} + afterEach(() => { for (const dir of tmpDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -75,6 +80,7 @@ describe("config-io", () => { it("tightens pre-existing weak directory permissions to 0o700", () => { const dir = path.join(makeTempDir(), "config"); fs.mkdirSync(dir, { mode: 0o755 }); + fs.chmodSync(dir, 0o755); ensureConfigDir(dir); @@ -154,7 +160,7 @@ describe("config-io", () => { const dir = makeTempDir(); fs.chmodSync(dir, 0o700); const file = path.join(dir, "config.json"); - fs.writeFileSync(file, JSON.stringify({ tight: true }), { mode: 0o644 }); + writeFileWithMode(file, JSON.stringify({ tight: true }), 0o644); const result = readConfigFile(file, null); @@ -186,7 +192,7 @@ describe("config-io", () => { "usage-notice.json", ]; for (const name of siblings) { - fs.writeFileSync(path.join(dir, name), "stale", { mode: 0o644 }); + writeFileWithMode(path.join(dir, name), "stale", 0o644); } readConfigFile(target, null); @@ -214,13 +220,11 @@ describe("config-io", () => { fs.writeFileSync(target, JSON.stringify({ ok: true }), { mode: 0o600 }); const sibling = path.join(dir, "should-be-healed.json"); - fs.writeFileSync(sibling, "stale", { mode: 0o644 }); - fs.chmodSync(sibling, 0o644); + writeFileWithMode(sibling, "stale", 0o644); const outsideDir = makeTempDir(); const outside = path.join(outsideDir, "target"); - fs.writeFileSync(outside, "outside", { mode: 0o644 }); - fs.chmodSync(outside, 0o644); + writeFileWithMode(outside, "outside", 0o644); const linkPath = path.join(dir, "rogue-link"); fs.symlinkSync(outside, linkPath); @@ -244,8 +248,7 @@ describe("config-io", () => { const outsideDir = makeTempDir(); const outside = path.join(outsideDir, "target.json"); - fs.writeFileSync(outside, JSON.stringify({ outside: true }), { mode: 0o644 }); - fs.chmodSync(outside, 0o644); + writeFileWithMode(outside, JSON.stringify({ outside: true }), 0o644); const symlinkPath = path.join(dir, "config.json"); fs.symlinkSync(outside, symlinkPath); @@ -283,8 +286,7 @@ describe("config-io", () => { const target = path.join(unrelatedDir, "config.json"); fs.writeFileSync(target, JSON.stringify({ ok: true }), { mode: 0o600 }); const sibling = path.join(unrelatedDir, "other.json"); - fs.writeFileSync(sibling, "stale", { mode: 0o644 }); - fs.chmodSync(sibling, 0o644); + writeFileWithMode(sibling, "stale", 0o644); readConfigFile(target, null); @@ -305,7 +307,7 @@ describe("config-io", () => { const target = path.join(hostDir, "sandboxes.json"); fs.writeFileSync(target, JSON.stringify({ ok: true }), { mode: 0o600 }); const sibling = path.join(hostDir, "onboard-session.json"); - fs.writeFileSync(sibling, "stale", { mode: 0o644 }); + writeFileWithMode(sibling, "stale", 0o644); readConfigFile(target, null); diff --git a/test/policies.test.ts b/test/policies.test.ts index e7a1d845ba..9ece74dc2c 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1062,8 +1062,6 @@ exit 1 }) as never); try { - // Apply a real built-in preset so applyPresetContent runs end-to-end - // up to the resolvability check. expect(() => policies.applyPreset("my-assistant", "npm")).toThrow(/__test_exit__/); expect(exitSpy).toHaveBeenCalledWith(1); // No `nemoclaw-policy-*` temp dir should have been created before diff --git a/test/release-latest-tag.test.ts b/test/release-latest-tag.test.ts index ad3ad90a29..7dee7f58cc 100644 --- a/test/release-latest-tag.test.ts +++ b/test/release-latest-tag.test.ts @@ -23,9 +23,18 @@ const planScriptPath = path.join(repoRoot, "scripts", "release-plan.ts"); const tsxPath = path.join(repoRoot, "node_modules", ".bin", "tsx"); const tempRoots: string[] = []; +function baseEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("GIT_") && value !== undefined) { + env[key] = value; + } + } + return { ...env, ...extra }; +} + function testEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { - return { - ...process.env, + return baseEnv({ GIT_AUTHOR_NAME: "Release Test", GIT_AUTHOR_EMAIL: "release-test@example.com", GIT_COMMITTER_NAME: "Release Test", @@ -34,7 +43,7 @@ function testEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { GIT_CONFIG_KEY_0: "tag.gpgSign", GIT_CONFIG_VALUE_0: "false", ...extra, - }; + }); } function run( @@ -134,8 +143,7 @@ function runReleaseLatestWithoutIdentity( const xdgConfigHome = path.join(fixture.root, "empty-xdg-config"); fs.mkdirSync(home); fs.mkdirSync(xdgConfigHome); - const env: NodeJS.ProcessEnv = { - ...process.env, + const env = baseEnv({ GIT_CONFIG_COUNT: "2", GIT_CONFIG_KEY_0: "user.useConfigOnly", GIT_CONFIG_VALUE_0: "true", @@ -146,7 +154,7 @@ function runReleaseLatestWithoutIdentity( RELEASE_TAG: releaseTag, REMOTE_NAME: "origin", XDG_CONFIG_HOME: xdgConfigHome, - }; + }); delete env.GIT_AUTHOR_NAME; delete env.GIT_AUTHOR_EMAIL; delete env.GIT_COMMITTER_NAME; diff --git a/test/sandbox-connect-inference/auto-pair-approval.test.ts b/test/sandbox-connect-inference/auto-pair-approval.test.ts new file mode 100644 index 0000000000..3e54cd04cd --- /dev/null +++ b/test/sandbox-connect-inference/auto-pair-approval.test.ts @@ -0,0 +1,216 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { testTimeoutOptions } from "../helpers/timeouts"; +import { extractApprovalPassScript, runApprovalPassScript, runConnect, setupFixture } from "./helpers"; + +describe("sandbox connect auto-pair approval pass (#4263)", () => { + it( + "runs a bounded openclaw devices approval pass before opening SSH", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "approval-pass-sandbox", + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + gpuEnabled: false, + policies: [], + }, + "anthropic-prod", + "claude-sonnet-4-20250514", + ); + + const result = runConnect(tmpDir, sandboxName); + expect(result.status).toBe(0); + + const script = extractApprovalPassScript(stateFile, sandboxName); + // Hardened script content: source the proxy env, require local tools, + // and execute the trusted helper payload in memory instead of importing + // authorization code from predictable shared temp storage. + expect(script).toContain("/tmp/nemoclaw-proxy-env.sh"); + expect(script).toContain("command -v openclaw"); + expect(script).toContain("command -v python3"); + expect(script).toContain("devices"); + expect(script).toContain("list"); + expect(script).toContain("approve"); + expect(script).toContain("NEMOCLAW_APPROVAL_POLICY_B64="); + expect(script).toContain("base64.b64decode"); + expect(script).toContain("exec(compile(policy_source"); + expect(script).toContain("decision = approval_request_decision(device)"); + expect(script).toContain("if not decision['allowed']:"); + expect(script).toContain("approve_env = gateway_approval_env(os.environ)"); + expect(script).toContain("env=approve_env"); + expect(script).toContain("if approve_proc.returncode == 0"); + expect(script).not.toContain("/tmp/openclaw_device_approval_policy.py"); + expect(script).not.toContain("sys.path.insert(0, '/tmp')"); + expect(script.indexOf("[OPENCLAW, 'devices', 'list', '--json']")).toBeLessThan( + script.indexOf("approve_env = gateway_approval_env(os.environ)"), + ); + }, + ); + + it( + "rejects malformed and disallowed scope requests when the approval pass runs", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "approval-pass-policy", + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + gpuEnabled: false, + policies: [], + }, + "anthropic-prod", + "claude-sonnet-4-20250514", + ); + + const result = runConnect(tmpDir, sandboxName); + expect(result.status).toBe(0); + const script = extractApprovalPassScript(stateFile, sandboxName); + const run = runApprovalPassScript(script, [ + { + requestId: "ok-cli", + clientId: "openclaw-cli", + clientMode: "cli", + scopes: ["operator.read", "operator.write"], + }, + { + requestId: "admin-cli", + clientId: "openclaw-cli", + clientMode: "cli", + scopes: ["operator.admin"], + }, + { + requestId: "malformed-cli", + clientId: "openclaw-cli", + clientMode: "cli", + requestedScopes: "operator.write", + }, + { + requestId: "unknown-client", + clientId: "evil-client", + clientMode: "unknown", + scopes: ["operator.read"], + }, + { + requestId: "dedupe-cli", + clientId: "openclaw-cli", + clientMode: "cli", + requestedScopes: ["operator.read"], + }, + { + requestId: "dedupe-cli", + clientId: "openclaw-cli", + clientMode: "cli", + requestedScopes: ["operator.read"], + }, + ]); + + expect(run.result.status).toBe(0); + expect(run.approvals).toEqual(["ok-cli", "dedupe-cli"]); + expect(run.approvalEnv).toEqual(["unset:unset:unset", "unset:unset:unset"]); + }, + ); + + it( + "does not import approval policy from PYTHONPATH", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "approval-pass-tmp-tamper", + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + gpuEnabled: false, + policies: [], + }, + "anthropic-prod", + "claude-sonnet-4-20250514", + ); + const maliciousPolicy = [ + "def approval_request_decision(_device):", + " return {'allowed': True, 'reason': 'allowlisted', 'client_id': 'evil', 'client_mode': 'cli', 'scopes': set()}", + "", + "def gateway_approval_env(source_env=None):", + " return dict(source_env or {})", + "", + ].join("\n"); + const maliciousPythonPath = path.join(tmpDir, "malicious-pythonpath"); + + fs.mkdirSync(maliciousPythonPath); + fs.writeFileSync( + path.join(maliciousPythonPath, "openclaw_device_approval_policy.py"), + maliciousPolicy, + ); + + const result = runConnect(tmpDir, sandboxName); + expect(result.status).toBe(0); + const script = extractApprovalPassScript(stateFile, sandboxName); + const run = runApprovalPassScript( + script, + [ + { + requestId: "admin-cli", + clientId: "openclaw-cli", + clientMode: "cli", + scopes: ["operator.admin"], + }, + ], + { PYTHONPATH: maliciousPythonPath }, + ); + + expect(run.result.status).toBe(0); + expect(run.approvals).toEqual([]); + }, + ); + + it( + "does not block connect when the in-sandbox approval pass cannot run", + testTimeoutOptions(20_000), + () => { + const { tmpDir, stateFile, sandboxName } = setupFixture( + { + name: "approval-pass-tolerant", + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + gpuEnabled: false, + policies: [], + }, + "anthropic-prod", + "claude-sonnet-4-20250514", + ); + + // Force the approval-pass sandbox-exec to fail with exit status 7 + // (simulated via the NEMOCLAW_TEST_FAIL_APPROVAL_PASS hook in the + // fake openshell). The connect flow must still reach SSH handoff — + // the approval pass is best-effort and must not surface failures. + const result = runConnect(tmpDir, sandboxName, { + NEMOCLAW_TEST_FAIL_APPROVAL_PASS: "1", + }); + expect(result.status).toBe(0); + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + // Approval-pass exec was attempted (and the fake openshell exited + // non-zero for it, per the hook above). + const approvalExec = (state.sandboxExecCalls as string[][]).find( + (call) => + call.includes("--") && + call.some((segment) => segment.includes("openclaw")) && + call.some((segment) => segment.includes("devices")) && + call.some((segment) => segment.includes("approve")), + ); + expect(approvalExec).toBeDefined(); + // Despite the approval-pass failure, SSH handoff still happens. + expect(state.sandboxConnectCalls).toContainEqual([ + "sandbox", + "connect", + sandboxName, + ]); + }, + ); +}); diff --git a/test/sandbox-connect-inference/helpers.ts b/test/sandbox-connect-inference/helpers.ts new file mode 100644 index 0000000000..2e4c4a5dff --- /dev/null +++ b/test/sandbox-connect-inference/helpers.ts @@ -0,0 +1,502 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { expect } from "vitest"; +import { execTimeout } from "../helpers/timeouts"; + +/** + * Tests for #1248 — inference route swap on sandbox connect. + * + * Each test creates a fake openshell binary that records calls to a state + * file, sets up a sandbox registry, and spawns the real CLI entrypoint. + */ + +export type SandboxEntryFixture = { + name: string; + model?: string | null; + provider?: string | null; + nimContainer?: string | null; + gpuEnabled?: boolean; + openshellDriver?: string | null; + policies?: string[]; +}; + +export type SetupFixtureOptions = { + curlExitCode?: number; + curlHttpStatus?: string; + curlStderr?: string; + inferenceProbeExitStatuses?: number[]; + inferenceProbeResponses?: string[]; + inferenceSetStatus?: number; + writeOllamaProxyState?: boolean; +}; + +export function isHostWsl() { + return ( + process.platform === "linux" && + (Boolean(process.env.WSL_DISTRO_NAME) || + Boolean(process.env.WSL_INTEROP) || + /microsoft/i.test(os.release())) + ); +} + +function writeRegistryState( + registryDir: string, + sandboxName: string, + sandboxEntry: SandboxEntryFixture, + options: SetupFixtureOptions, +) { + fs.writeFileSync( + path.join(registryDir, "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { [sandboxName]: sandboxEntry }, + }), + { mode: 0o600 }, + ); + + if ( + sandboxEntry.provider !== "ollama-local" || + options.writeOllamaProxyState === false + ) { + return; + } + + fs.writeFileSync(path.join(registryDir, "ollama-proxy-token"), "test-token\n", { + mode: 0o600, + }); + fs.writeFileSync(path.join(registryDir, "ollama-auth-proxy.pid"), "12345\n", { + mode: 0o600, + }); +} + +function buildInferenceBlock( + liveInferenceProvider: string | null, + liveInferenceModel: string | null, +) { + if (liveInferenceProvider && liveInferenceModel) { + return `Gateway inference:\\n Provider: ${liveInferenceProvider}\\n Model: ${liveInferenceModel}\\n`; + } + return `Gateway inference:\\n Not configured\\n`; +} + +function initStateFile(stateFile: string, options: SetupFixtureOptions) { + fs.writeFileSync( + stateFile, + JSON.stringify({ + dockerCalls: [], + curlExitCode: options.curlExitCode ?? 0, + curlHttpStatus: options.curlHttpStatus ?? "200", + curlStderr: options.curlStderr ?? "", + curlCalls: [], + curlEnvs: [], + inferenceProbeExitStatuses: options.inferenceProbeExitStatuses ?? [], + inferenceProbeResponses: options.inferenceProbeResponses ?? ["OK 200"], + inferenceSetCalls: [], + sandboxConnectCalls: [], + sandboxExecCalls: [], + }), + ); +} + +function writeExecutable(filePath: string, contents: string) { + fs.writeFileSync(filePath, contents, { mode: 0o755 }); +} + +function writeOpenshellStub( + openshellPath: string, + stateFile: string, + sandboxName: string, + inferenceBlock: string, + options: SetupFixtureOptions, +) { + writeExecutable( + openshellPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +const stateFile = ${JSON.stringify(stateFile)}; +const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); + +if (args[0] === "status") { + process.stdout.write("Gateway: nemoclaw\\nStatus: Connected\\n"); + process.exit(0); +} + +if (args[0] === "gateway" && args[1] === "info") { + process.stdout.write("Gateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080\\n"); + process.exit(0); +} + +if (args[0] === "sandbox" && args[1] === "get" && args[2] === ${JSON.stringify(sandboxName)}) { + process.stdout.write("Sandbox:\\n\\n \\x1b[2mId:\\x1b[0m abc\\n Name: ${sandboxName}\\n Phase: Ready\\n"); + process.exit(0); +} + +if (args[0] === "sandbox" && args[1] === "list") { + process.stdout.write("${sandboxName} Ready 2m ago\\n"); + process.exit(0); +} + +if (args[0] === "sandbox" && args[1] === "exec") { + state.sandboxExecCalls.push(args); + const command = args.join(" "); + if (!command.includes("inference.local/v1/models")) { + fs.writeFileSync(stateFile, JSON.stringify(state)); + // Test hook (#4263 / CodeRabbit): when the connect-time auto-pair + // approval pass is specifically targeted, simulate the failure + // path the production code must tolerate. The approval-pass script + // is identifiable by its embedded \`openclaw devices approve\` call. + if ( + process.env.NEMOCLAW_TEST_FAIL_APPROVAL_PASS === "1" && + command.includes("openclaw") && + command.includes("devices") && + command.includes("approve") + ) { + process.stderr.write("simulated sandbox exec failure\\n"); + process.exit(7); + } + process.stdout.write("__NEMOCLAW_SANDBOX_EXEC_STARTED__\\nRUNNING\\n"); + process.exit(0); + } + const response = state.inferenceProbeResponses.length + ? state.inferenceProbeResponses.shift() + : 'BROKEN 503 {"error":"missing mocked inference probe response"}'; + const exitStatus = Number(state.inferenceProbeExitStatuses.shift() || 0); + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.stdout.write(response); + process.exit(exitStatus); +} + +if (args[0] === "sandbox" && args[1] === "connect") { + // Don't actually drop into a shell — just exit successfully + state.sandboxConnectCalls.push(args); + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.exit(0); +} + +if (args[0] === "inference" && args[1] === "get") { + process.stdout.write(${JSON.stringify(inferenceBlock.replace(/\\n/g, "\n"))}); + process.exit(0); +} + +if (args[0] === "inference" && args[1] === "set") { + state.inferenceSetCalls.push(args.slice(2)); + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.exit(${JSON.stringify(options.inferenceSetStatus ?? 0)}); +} + +if (args[0] === "logs") { + process.exit(0); +} + +if (args[0] === "forward") { + process.exit(0); +} + +// Default — succeed silently +process.exit(0); +`, + ); +} + +function writeDockerStub(dockerPath: string, stateFile: string, sandboxName: string) { + writeExecutable( + dockerPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +const stateFile = ${JSON.stringify(stateFile)}; +const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); +state.dockerCalls.push(args); +fs.writeFileSync(stateFile, JSON.stringify(state)); +const cmd = args.join(" "); + +if (args[0] === "ps") { + process.stdout.write("openshell-cluster-nemoclaw\\n"); + process.exit(0); +} + +if (cmd.includes("get service kube-dns")) { + process.stdout.write("10.43.0.10"); + process.exit(0); +} +if (cmd.includes("get endpoints kube-dns")) { + process.stdout.write("10.42.0.15"); + process.exit(0); +} +if (cmd.includes("get pods -n openshell -o name")) { + process.stdout.write("pod/${sandboxName}-abc\\n"); + process.exit(0); +} +if (cmd.includes("ip addr show")) { + process.stdout.write("10.200.0.1\\n"); + process.exit(0); +} +if (cmd.includes("cat /tmp/dns-proxy.pid")) { + process.stdout.write("12345\\n"); + process.exit(0); +} +if (cmd.includes("cat /tmp/dns-proxy.log")) { + process.stdout.write("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\\n"); + process.exit(0); +} +if (cmd.includes("python3 -c")) { + process.stdout.write("ok"); + process.exit(0); +} +if (cmd.includes("ls /run/netns/")) { + process.stdout.write("sandbox-ns\\n"); + process.exit(0); +} +if (cmd.includes("test -x")) { + process.exit(cmd.includes("/usr/sbin/iptables") ? 0 : 1); +} +if (cmd.includes("cat /etc/resolv.conf")) { + process.stdout.write("nameserver 10.200.0.1\\n"); + process.exit(0); +} +if (cmd.includes("getent hosts github.com")) { + process.stdout.write("140.82.112.4 github.com\\n"); + process.exit(0); +} + +process.exit(0); +`, + ); +} + +function writeCurlStub(curlPath: string, stateFile: string) { + writeExecutable( + curlPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +const stateFile = ${JSON.stringify(stateFile)}; +const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); +state.curlCalls.push(args); +state.curlEnvs.push({ + ALL_PROXY: process.env.ALL_PROXY || "", + HTTP_PROXY: process.env.HTTP_PROXY || "", + NO_PROXY: process.env.NO_PROXY || "", + all_proxy: process.env.all_proxy || "", + http_proxy: process.env.http_proxy || "", + no_proxy: process.env.no_proxy || "", +}); +fs.writeFileSync(stateFile, JSON.stringify(state)); +const endpoint = args[args.length - 1] || ""; +if ( + process.env.OPENSHELL_TEST_FAIL_LOCALHOST_OLLAMA === "1" && + endpoint.includes("127.0.0.1:11434/api/tags") +) { + process.exit(7); +} +const outIndex = args.indexOf("-o"); +const exitCode = Number(state.curlExitCode || 0); +const status = String(state.curlHttpStatus || "200"); +if (outIndex >= 0 && args[outIndex + 1] && args[outIndex + 1] !== "/dev/null" && exitCode === 0) { + fs.writeFileSync(args[outIndex + 1], '{"models":[]}'); +} +if (state.curlStderr) { + process.stderr.write(String(state.curlStderr)); +} +if (args.includes("-w")) { + process.stdout.write(status); +} else { + process.stdout.write('{"models":[]}'); +} +process.exit(exitCode); +`, + ); +} + +function writePsStub(psPath: string) { + writeExecutable( + psPath, + `#!${process.execPath} +process.stdout.write("node /tmp/ollama-auth-proxy.js\\n"); +process.exit(0); +`, + ); +} + +export function setupFixture( + sandboxEntry: SandboxEntryFixture, + liveInferenceProvider: string | null, + liveInferenceModel: string | null, + options: SetupFixtureOptions = {}, +) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-inf-swap-")); + const homeLocalBin = path.join(tmpDir, ".local", "bin"); + const registryDir = path.join(tmpDir, ".nemoclaw"); + const stateFile = path.join(tmpDir, "state.json"); + const openshellPath = path.join(homeLocalBin, "openshell"); + const dockerPath = path.join(homeLocalBin, "docker"); + const curlPath = path.join(homeLocalBin, "curl"); + const psPath = path.join(homeLocalBin, "ps"); + const sandboxName = String(sandboxEntry.name); + + fs.mkdirSync(homeLocalBin, { recursive: true }); + fs.mkdirSync(registryDir, { recursive: true }); + + const inferenceBlock = buildInferenceBlock(liveInferenceProvider, liveInferenceModel); + writeRegistryState(registryDir, sandboxName, sandboxEntry, options); + initStateFile(stateFile, options); + writeOpenshellStub(openshellPath, stateFile, sandboxName, inferenceBlock, options); + writeDockerStub(dockerPath, stateFile, sandboxName); + writeCurlStub(curlPath, stateFile); + writePsStub(psPath); + + return { tmpDir, stateFile, sandboxName }; +} + +export function createVmRootfs(tmpDir: string, sandboxId = "abc") { + const rootfs = path.join( + tmpDir, + ".local", + "state", + "nemoclaw", + "openshell-docker-gateway", + "vm-driver", + "sandboxes", + sandboxId, + "rootfs", + ); + fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); + fs.mkdirSync(path.join(rootfs, "srv"), { recursive: true }); + fs.writeFileSync( + path.join(rootfs, "etc", "resolv.conf"), + "nameserver 8.8.8.8\nnameserver 8.8.4.4\n", + ); + fs.writeFileSync( + path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), + [ + "elif ip link show eth0 >/dev/null 2>&1; then", + " if [ ! -s /etc/resolv.conf ]; then", + ' echo "nameserver 8.8.8.8" > /etc/resolv.conf', + ' echo "nameserver 8.8.4.4" >> /etc/resolv.conf', + " fi", + "fi", + "", + ].join("\n"), + ); + return rootfs; +} + +export function runConnect( + tmpDir: string, + sandboxName: string, + extraEnv: NodeJS.ProcessEnv = {}, + connectArgs: string[] = [], +) { + const repoRoot = path.join(import.meta.dirname, "..", ".."); + return spawnSync( + process.execPath, + [ + path.join(repoRoot, "bin", "nemoclaw.js"), + sandboxName, + "connect", + ...connectArgs, + ], + { + cwd: repoRoot, + encoding: "utf-8", + env: { + HOME: tmpDir, + PATH: `${path.join(tmpDir, ".local", "bin")}:/usr/bin:/bin`, + NEMOCLAW_DISABLE_GATEWAY_DRIFT_PREFLIGHT: "1", + NEMOCLAW_NO_CONNECT_HINT: "1", + NEMOCLAW_OLLAMA_PORT: "11434", + NEMOCLAW_OLLAMA_PROXY_PORT: "11435", + VITEST: "true", + ...extraEnv, + }, + timeout: execTimeout(15_000), + }, + ); +} + +export function extractApprovalPassScript(stateFile: string, sandboxName: string): string { + const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); + const approvalExec = (state.sandboxExecCalls as string[][]).find( + (call) => + call.includes("--") && + call.some((segment) => segment.includes("openclaw")) && + call.some((segment) => segment.includes("devices")) && + call.some((segment) => segment.includes("approve")), + ); + expect(approvalExec).toBeDefined(); + expect(approvalExec).toContain("sandbox"); + expect(approvalExec).toContain("exec"); + expect(approvalExec).toContain("--name"); + expect(approvalExec).toContain(sandboxName); + return approvalExec?.[approvalExec.length - 1] || ""; +} + +export function runApprovalPassScript( + script: string, + pending: unknown[], + extraEnv: NodeJS.ProcessEnv = {}, +) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-approval-pass-")); + const openclawPath = path.join(tmpDir, "openclaw"); + const approvalsFile = path.join(tmpDir, "approvals.log"); + const approvalEnvFile = path.join(tmpDir, "approval-env.log"); + const pendingResponse = JSON.stringify({ pending, paired: [] }); + + try { + fs.writeFileSync( + openclawPath, + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +if (args[0] === "devices" && args[1] === "list") { + process.stdout.write(${JSON.stringify(`${pendingResponse}\n`)}); + process.exit(0); +} +if (args[0] === "devices" && args[1] === "approve") { + fs.appendFileSync(${JSON.stringify(approvalsFile)}, args[2] + "\\n"); + fs.appendFileSync( + ${JSON.stringify(approvalEnvFile)}, + [ + process.env.OPENCLAW_GATEWAY_URL || "unset", + process.env.OPENCLAW_GATEWAY_PORT || "unset", + process.env.OPENCLAW_GATEWAY_TOKEN || "unset", + ].join(":") + "\\n", + ); + process.stdout.write("{}\\n"); + process.exit(0); +} +process.stderr.write("unexpected openclaw args: " + args.join(" ") + "\\n"); +process.exit(2); +`, + { mode: 0o755 }, + ); + + const result = spawnSync("sh", ["-c", script], { + encoding: "utf-8", + env: { + ...process.env, + PATH: `${tmpDir}:/usr/bin:/bin`, + OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_GATEWAY_TOKEN: "test-gateway-token", + ...extraEnv, + }, + timeout: 10_000, + }); + const approvals = fs.existsSync(approvalsFile) + ? fs.readFileSync(approvalsFile, "utf-8").trim().split("\n").filter(Boolean) + : []; + const approvalEnv = fs.existsSync(approvalEnvFile) + ? fs.readFileSync(approvalEnvFile, "utf-8").trim().split("\n").filter(Boolean) + : []; + return { result, approvals, approvalEnv }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} diff --git a/test/sandbox-connect-inference.test.ts b/test/sandbox-connect-inference/route-swap-repair.test.ts similarity index 59% rename from test/sandbox-connect-inference.test.ts rename to test/sandbox-connect-inference/route-swap-repair.test.ts index 12bb9447b1..e7ee671f49 100644 --- a/test/sandbox-connect-inference.test.ts +++ b/test/sandbox-connect-inference/route-swap-repair.test.ts @@ -1,478 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { execTimeout, testTimeoutOptions } from "./helpers/timeouts"; - -/** - * Tests for #1248 — inference route swap on sandbox connect. - * - * Each test creates a fake openshell binary that records calls to a state - * file, sets up a sandbox registry, and spawns the real CLI entrypoint. - */ - -type SandboxEntryFixture = { - name: string; - model?: string | null; - provider?: string | null; - nimContainer?: string | null; - gpuEnabled?: boolean; - openshellDriver?: string | null; - policies?: string[]; -}; - -type SetupFixtureOptions = { - curlExitCode?: number; - curlHttpStatus?: string; - curlStderr?: string; - inferenceProbeExitStatuses?: number[]; - inferenceProbeResponses?: string[]; - inferenceSetStatus?: number; - writeOllamaProxyState?: boolean; -}; - -function isHostWsl() { - return ( - process.platform === "linux" && - (Boolean(process.env.WSL_DISTRO_NAME) || - Boolean(process.env.WSL_INTEROP) || - /microsoft/i.test(os.release())) - ); -} - -function setupFixture( - sandboxEntry: SandboxEntryFixture, - liveInferenceProvider: string | null, - liveInferenceModel: string | null, - options: SetupFixtureOptions = {}, -) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-inf-swap-")); - const homeLocalBin = path.join(tmpDir, ".local", "bin"); - const registryDir = path.join(tmpDir, ".nemoclaw"); - const stateFile = path.join(tmpDir, "state.json"); - const openshellPath = path.join(homeLocalBin, "openshell"); - const dockerPath = path.join(homeLocalBin, "docker"); - const curlPath = path.join(homeLocalBin, "curl"); - const psPath = path.join(homeLocalBin, "ps"); - const sandboxName = String(sandboxEntry.name); - - fs.mkdirSync(homeLocalBin, { recursive: true }); - fs.mkdirSync(registryDir, { recursive: true }); - - fs.writeFileSync( - path.join(registryDir, "sandboxes.json"), - JSON.stringify({ - defaultSandbox: sandboxName, - sandboxes: { [sandboxName]: sandboxEntry }, - }), - { mode: 0o600 }, - ); - - if ( - sandboxEntry.provider === "ollama-local" && - options.writeOllamaProxyState !== false - ) { - fs.writeFileSync( - path.join(registryDir, "ollama-proxy-token"), - "test-token\n", - { - mode: 0o600, - }, - ); - fs.writeFileSync( - path.join(registryDir, "ollama-auth-proxy.pid"), - "12345\n", - { - mode: 0o600, - }, - ); - } - - // Build the Gateway inference section for `openshell inference get` - let inferenceBlock; - if (liveInferenceProvider && liveInferenceModel) { - inferenceBlock = `Gateway inference:\\n Provider: ${liveInferenceProvider}\\n Model: ${liveInferenceModel}\\n`; - } else { - inferenceBlock = `Gateway inference:\\n Not configured\\n`; - } - - fs.writeFileSync( - stateFile, - JSON.stringify({ - dockerCalls: [], - curlExitCode: options.curlExitCode ?? 0, - curlHttpStatus: options.curlHttpStatus ?? "200", - curlStderr: options.curlStderr ?? "", - curlCalls: [], - curlEnvs: [], - inferenceProbeExitStatuses: options.inferenceProbeExitStatuses ?? [], - inferenceProbeResponses: options.inferenceProbeResponses ?? ["OK 200"], - inferenceSetCalls: [], - sandboxConnectCalls: [], - sandboxExecCalls: [], - }), - ); - - // Fake openshell binary — records inference set calls, stubs everything else - fs.writeFileSync( - openshellPath, - `#!${process.execPath} -const fs = require("fs"); -const args = process.argv.slice(2); -const stateFile = ${JSON.stringify(stateFile)}; -const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); - -if (args[0] === "status") { - process.stdout.write("Gateway: nemoclaw\\nStatus: Connected\\n"); - process.exit(0); -} - -if (args[0] === "gateway" && args[1] === "info") { - process.stdout.write("Gateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080\\n"); - process.exit(0); -} - -if (args[0] === "sandbox" && args[1] === "get" && args[2] === ${JSON.stringify(sandboxName)}) { - process.stdout.write("Sandbox:\\n\\n \\x1b[2mId:\\x1b[0m abc\\n Name: ${sandboxName}\\n Phase: Ready\\n"); - process.exit(0); -} - -if (args[0] === "sandbox" && args[1] === "list") { - process.stdout.write("${sandboxName} Ready 2m ago\\n"); - process.exit(0); -} - -if (args[0] === "sandbox" && args[1] === "exec") { - state.sandboxExecCalls.push(args); - const command = args.join(" "); - if (!command.includes("inference.local/v1/models")) { - fs.writeFileSync(stateFile, JSON.stringify(state)); - // Test hook (#4263 / CodeRabbit): when the connect-time auto-pair - // approval pass is specifically targeted, simulate the failure - // path the production code must tolerate. The approval-pass script - // is identifiable by its embedded \`openclaw devices approve\` call. - if ( - process.env.NEMOCLAW_TEST_FAIL_APPROVAL_PASS === "1" && - command.includes("openclaw") && - command.includes("devices") && - command.includes("approve") - ) { - process.stderr.write("simulated sandbox exec failure\\n"); - process.exit(7); - } - process.stdout.write("__NEMOCLAW_SANDBOX_EXEC_STARTED__\\nRUNNING\\n"); - process.exit(0); - } - const response = state.inferenceProbeResponses.length - ? state.inferenceProbeResponses.shift() - : 'BROKEN 503 {"error":"missing mocked inference probe response"}'; - const exitStatus = Number(state.inferenceProbeExitStatuses.shift() || 0); - fs.writeFileSync(stateFile, JSON.stringify(state)); - process.stdout.write(response); - process.exit(exitStatus); -} - -if (args[0] === "sandbox" && args[1] === "connect") { - // Don't actually drop into a shell — just exit successfully - state.sandboxConnectCalls.push(args); - fs.writeFileSync(stateFile, JSON.stringify(state)); - process.exit(0); -} - -if (args[0] === "inference" && args[1] === "get") { - process.stdout.write(${JSON.stringify(inferenceBlock.replace(/\\n/g, "\n"))}); - process.exit(0); -} - -if (args[0] === "inference" && args[1] === "set") { - state.inferenceSetCalls.push(args.slice(2)); - fs.writeFileSync(stateFile, JSON.stringify(state)); - process.exit(${JSON.stringify(options.inferenceSetStatus ?? 0)}); -} - -if (args[0] === "logs") { - process.exit(0); -} - -if (args[0] === "forward") { - process.exit(0); -} - -// Default — succeed silently -process.exit(0); -`, - { mode: 0o755 }, - ); - - fs.writeFileSync( - dockerPath, - `#!${process.execPath} -const fs = require("fs"); -const args = process.argv.slice(2); -const stateFile = ${JSON.stringify(stateFile)}; -const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); -state.dockerCalls.push(args); -fs.writeFileSync(stateFile, JSON.stringify(state)); -const cmd = args.join(" "); - -if (args[0] === "ps") { - process.stdout.write("openshell-cluster-nemoclaw\\n"); - process.exit(0); -} - -if (cmd.includes("get service kube-dns")) { - process.stdout.write("10.43.0.10"); - process.exit(0); -} -if (cmd.includes("get endpoints kube-dns")) { - process.stdout.write("10.42.0.15"); - process.exit(0); -} -if (cmd.includes("get pods -n openshell -o name")) { - process.stdout.write("pod/${sandboxName}-abc\\n"); - process.exit(0); -} -if (cmd.includes("ip addr show")) { - process.stdout.write("10.200.0.1\\n"); - process.exit(0); -} -if (cmd.includes("cat /tmp/dns-proxy.pid")) { - process.stdout.write("12345\\n"); - process.exit(0); -} -if (cmd.includes("cat /tmp/dns-proxy.log")) { - process.stdout.write("dns-proxy: 10.200.0.1:53 -> 10.43.0.10:53 pid=12345\\n"); - process.exit(0); -} -if (cmd.includes("python3 -c")) { - process.stdout.write("ok"); - process.exit(0); -} -if (cmd.includes("ls /run/netns/")) { - process.stdout.write("sandbox-ns\\n"); - process.exit(0); -} -if (cmd.includes("test -x")) { - process.exit(cmd.includes("/usr/sbin/iptables") ? 0 : 1); -} -if (cmd.includes("cat /etc/resolv.conf")) { - process.stdout.write("nameserver 10.200.0.1\\n"); - process.exit(0); -} -if (cmd.includes("getent hosts github.com")) { - process.stdout.write("140.82.112.4 github.com\\n"); - process.exit(0); -} - -process.exit(0); -`, - { mode: 0o755 }, - ); - fs.writeFileSync( - curlPath, - `#!${process.execPath} -const fs = require("fs"); -const args = process.argv.slice(2); -const stateFile = ${JSON.stringify(stateFile)}; -const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); -state.curlCalls.push(args); -state.curlEnvs.push({ - ALL_PROXY: process.env.ALL_PROXY || "", - HTTP_PROXY: process.env.HTTP_PROXY || "", - NO_PROXY: process.env.NO_PROXY || "", - all_proxy: process.env.all_proxy || "", - http_proxy: process.env.http_proxy || "", - no_proxy: process.env.no_proxy || "", -}); -fs.writeFileSync(stateFile, JSON.stringify(state)); -const endpoint = args[args.length - 1] || ""; -if ( - process.env.OPENSHELL_TEST_FAIL_LOCALHOST_OLLAMA === "1" && - endpoint.includes("127.0.0.1:11434/api/tags") -) { - process.exit(7); -} -const outIndex = args.indexOf("-o"); -const exitCode = Number(state.curlExitCode || 0); -const status = String(state.curlHttpStatus || "200"); -if (outIndex >= 0 && args[outIndex + 1] && args[outIndex + 1] !== "/dev/null" && exitCode === 0) { - fs.writeFileSync(args[outIndex + 1], '{"models":[]}'); -} -if (state.curlStderr) { - process.stderr.write(String(state.curlStderr)); -} -if (args.includes("-w")) { - process.stdout.write(status); -} else { - process.stdout.write('{"models":[]}'); -} -process.exit(exitCode); -`, - { mode: 0o755 }, - ); - - fs.writeFileSync( - psPath, - `#!${process.execPath} -process.stdout.write("node /tmp/ollama-auth-proxy.js\\n"); -process.exit(0); -`, - { mode: 0o755 }, - ); - - return { tmpDir, stateFile, sandboxName }; -} - -function createVmRootfs(tmpDir: string, sandboxId = "abc") { - const rootfs = path.join( - tmpDir, - ".local", - "state", - "nemoclaw", - "openshell-docker-gateway", - "vm-driver", - "sandboxes", - sandboxId, - "rootfs", - ); - fs.mkdirSync(path.join(rootfs, "etc"), { recursive: true }); - fs.mkdirSync(path.join(rootfs, "srv"), { recursive: true }); - fs.writeFileSync( - path.join(rootfs, "etc", "resolv.conf"), - "nameserver 8.8.8.8\nnameserver 8.8.4.4\n", - ); - fs.writeFileSync( - path.join(rootfs, "srv", "openshell-vm-sandbox-init.sh"), - [ - "elif ip link show eth0 >/dev/null 2>&1; then", - " if [ ! -s /etc/resolv.conf ]; then", - ' echo "nameserver 8.8.8.8" > /etc/resolv.conf', - ' echo "nameserver 8.8.4.4" >> /etc/resolv.conf', - " fi", - "fi", - "", - ].join("\n"), - ); - return rootfs; -} - -function runConnect( - tmpDir: string, - sandboxName: string, - extraEnv: NodeJS.ProcessEnv = {}, - connectArgs: string[] = [], -) { - const repoRoot = path.join(import.meta.dirname, ".."); - return spawnSync( - process.execPath, - [ - path.join(repoRoot, "bin", "nemoclaw.js"), - sandboxName, - "connect", - ...connectArgs, - ], - { - cwd: repoRoot, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - PATH: `${path.join(tmpDir, ".local", "bin")}:/usr/bin:/bin`, - NEMOCLAW_NO_CONNECT_HINT: "1", - NEMOCLAW_OLLAMA_PORT: "11434", - NEMOCLAW_OLLAMA_PROXY_PORT: "11435", - ...extraEnv, - }, - timeout: execTimeout(15_000), - }, - ); -} - -function extractApprovalPassScript(stateFile: string, sandboxName: string): string { - const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); - const approvalExec = (state.sandboxExecCalls as string[][]).find( - (call) => - call.includes("--") && - call.some((segment) => segment.includes("openclaw")) && - call.some((segment) => segment.includes("devices")) && - call.some((segment) => segment.includes("approve")), - ); - expect(approvalExec).toBeDefined(); - expect(approvalExec).toContain("sandbox"); - expect(approvalExec).toContain("exec"); - expect(approvalExec).toContain("--name"); - expect(approvalExec).toContain(sandboxName); - return approvalExec?.[approvalExec.length - 1] || ""; -} - -function runApprovalPassScript( - script: string, - pending: unknown[], - extraEnv: NodeJS.ProcessEnv = {}, -) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-approval-pass-")); - const openclawPath = path.join(tmpDir, "openclaw"); - const approvalsFile = path.join(tmpDir, "approvals.log"); - const approvalEnvFile = path.join(tmpDir, "approval-env.log"); - const pendingResponse = JSON.stringify({ pending, paired: [] }); - - try { - fs.writeFileSync( - openclawPath, - `#!${process.execPath} -const fs = require("fs"); -const args = process.argv.slice(2); -if (args[0] === "devices" && args[1] === "list") { - process.stdout.write(${JSON.stringify(`${pendingResponse}\n`)}); - process.exit(0); -} -if (args[0] === "devices" && args[1] === "approve") { - fs.appendFileSync(${JSON.stringify(approvalsFile)}, args[2] + "\\n"); - fs.appendFileSync( - ${JSON.stringify(approvalEnvFile)}, - [ - process.env.OPENCLAW_GATEWAY_URL || "unset", - process.env.OPENCLAW_GATEWAY_PORT || "unset", - process.env.OPENCLAW_GATEWAY_TOKEN || "unset", - ].join(":") + "\\n", - ); - process.stdout.write("{}\\n"); - process.exit(0); -} -process.stderr.write("unexpected openclaw args: " + args.join(" ") + "\\n"); -process.exit(2); -`, - { mode: 0o755 }, - ); - - const result = spawnSync("sh", ["-c", script], { - encoding: "utf-8", - env: { - ...process.env, - PATH: `${tmpDir}:/usr/bin:/bin`, - OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", - OPENCLAW_GATEWAY_PORT: "18789", - OPENCLAW_GATEWAY_TOKEN: "test-gateway-token", - ...extraEnv, - }, - timeout: 10_000, - }); - const approvals = fs.existsSync(approvalsFile) - ? fs.readFileSync(approvalsFile, "utf-8").trim().split("\n").filter(Boolean) - : []; - const approvalEnv = fs.existsSync(approvalEnvFile) - ? fs.readFileSync(approvalEnvFile, "utf-8").trim().split("\n").filter(Boolean) - : []; - return { result, approvals, approvalEnv }; - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } -} +import { testTimeoutOptions } from "../helpers/timeouts"; +import { createVmRootfs, isHostWsl, runConnect, setupFixture } from "./helpers"; describe("sandbox connect inference route swap (#1248)", () => { it( @@ -1368,210 +902,3 @@ describe("sandbox connect inference route swap (#1248)", () => { }, ); }); - -describe("sandbox connect auto-pair approval pass (#4263)", () => { - it( - "runs a bounded openclaw devices approval pass before opening SSH", - testTimeoutOptions(20_000), - () => { - const { tmpDir, stateFile, sandboxName } = setupFixture( - { - name: "approval-pass-sandbox", - model: "claude-sonnet-4-20250514", - provider: "anthropic-prod", - gpuEnabled: false, - policies: [], - }, - "anthropic-prod", - "claude-sonnet-4-20250514", - ); - - const result = runConnect(tmpDir, sandboxName); - expect(result.status).toBe(0); - - const script = extractApprovalPassScript(stateFile, sandboxName); - // Hardened script content: source the proxy env, require local tools, - // and execute the trusted helper payload in memory instead of importing - // authorization code from predictable shared temp storage. - expect(script).toContain("/tmp/nemoclaw-proxy-env.sh"); - expect(script).toContain("command -v openclaw"); - expect(script).toContain("command -v python3"); - expect(script).toContain("devices"); - expect(script).toContain("list"); - expect(script).toContain("approve"); - expect(script).toContain("NEMOCLAW_APPROVAL_POLICY_B64="); - expect(script).toContain("base64.b64decode"); - expect(script).toContain("exec(compile(policy_source"); - expect(script).toContain("decision = approval_request_decision(device)"); - expect(script).toContain("if not decision['allowed']:"); - expect(script).toContain("approve_env = gateway_approval_env(os.environ)"); - expect(script).toContain("env=approve_env"); - expect(script).toContain("if approve_proc.returncode == 0"); - expect(script).not.toContain("/tmp/openclaw_device_approval_policy.py"); - expect(script).not.toContain("sys.path.insert(0, '/tmp')"); - expect(script.indexOf("[OPENCLAW, 'devices', 'list', '--json']")).toBeLessThan( - script.indexOf("approve_env = gateway_approval_env(os.environ)"), - ); - }, - ); - - it( - "rejects malformed and disallowed scope requests when the approval pass runs", - testTimeoutOptions(20_000), - () => { - const { tmpDir, stateFile, sandboxName } = setupFixture( - { - name: "approval-pass-policy", - model: "claude-sonnet-4-20250514", - provider: "anthropic-prod", - gpuEnabled: false, - policies: [], - }, - "anthropic-prod", - "claude-sonnet-4-20250514", - ); - - const result = runConnect(tmpDir, sandboxName); - expect(result.status).toBe(0); - const script = extractApprovalPassScript(stateFile, sandboxName); - const run = runApprovalPassScript(script, [ - { - requestId: "ok-cli", - clientId: "openclaw-cli", - clientMode: "cli", - scopes: ["operator.read", "operator.write"], - }, - { - requestId: "admin-cli", - clientId: "openclaw-cli", - clientMode: "cli", - scopes: ["operator.admin"], - }, - { - requestId: "malformed-cli", - clientId: "openclaw-cli", - clientMode: "cli", - requestedScopes: "operator.write", - }, - { - requestId: "unknown-client", - clientId: "evil-client", - clientMode: "unknown", - scopes: ["operator.read"], - }, - { - requestId: "dedupe-cli", - clientId: "openclaw-cli", - clientMode: "cli", - requestedScopes: ["operator.read"], - }, - { - requestId: "dedupe-cli", - clientId: "openclaw-cli", - clientMode: "cli", - requestedScopes: ["operator.read"], - }, - ]); - - expect(run.result.status).toBe(0); - expect(run.approvals).toEqual(["ok-cli", "dedupe-cli"]); - expect(run.approvalEnv).toEqual(["unset:unset:unset", "unset:unset:unset"]); - }, - ); - - it( - "does not import approval policy from PYTHONPATH", - testTimeoutOptions(20_000), - () => { - const { tmpDir, stateFile, sandboxName } = setupFixture( - { - name: "approval-pass-tmp-tamper", - model: "claude-sonnet-4-20250514", - provider: "anthropic-prod", - gpuEnabled: false, - policies: [], - }, - "anthropic-prod", - "claude-sonnet-4-20250514", - ); - const maliciousPolicy = [ - "def approval_request_decision(_device):", - " return {'allowed': True, 'reason': 'allowlisted', 'client_id': 'evil', 'client_mode': 'cli', 'scopes': set()}", - "", - "def gateway_approval_env(source_env=None):", - " return dict(source_env or {})", - "", - ].join("\n"); - const maliciousPythonPath = path.join(tmpDir, "malicious-pythonpath"); - - fs.mkdirSync(maliciousPythonPath); - fs.writeFileSync( - path.join(maliciousPythonPath, "openclaw_device_approval_policy.py"), - maliciousPolicy, - ); - - const result = runConnect(tmpDir, sandboxName); - expect(result.status).toBe(0); - const script = extractApprovalPassScript(stateFile, sandboxName); - const run = runApprovalPassScript( - script, - [ - { - requestId: "admin-cli", - clientId: "openclaw-cli", - clientMode: "cli", - scopes: ["operator.admin"], - }, - ], - { PYTHONPATH: maliciousPythonPath }, - ); - - expect(run.result.status).toBe(0); - expect(run.approvals).toEqual([]); - }, - ); - - it( - "does not block connect when the in-sandbox approval pass cannot run", - testTimeoutOptions(20_000), - () => { - const { tmpDir, stateFile, sandboxName } = setupFixture( - { - name: "approval-pass-tolerant", - model: "claude-sonnet-4-20250514", - provider: "anthropic-prod", - gpuEnabled: false, - policies: [], - }, - "anthropic-prod", - "claude-sonnet-4-20250514", - ); - - // Force the approval-pass sandbox-exec to fail with exit status 7 - // (simulated via the NEMOCLAW_TEST_FAIL_APPROVAL_PASS hook in the - // fake openshell). The connect flow must still reach SSH handoff — - // the approval pass is best-effort and must not surface failures. - const result = runConnect(tmpDir, sandboxName, { - NEMOCLAW_TEST_FAIL_APPROVAL_PASS: "1", - }); - expect(result.status).toBe(0); - const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); - // Approval-pass exec was attempted (and the fake openshell exited - // non-zero for it, per the hook above). - const approvalExec = (state.sandboxExecCalls as string[][]).find( - (call) => - call.includes("--") && - call.some((segment) => segment.includes("openclaw")) && - call.some((segment) => segment.includes("devices")) && - call.some((segment) => segment.includes("approve")), - ); - expect(approvalExec).toBeDefined(); - // Despite the approval-pass failure, SSH handoff still happens. - expect(state.sandboxConnectCalls).toContainEqual([ - "sandbox", - "connect", - sandboxName, - ]); - }, - ); -});