diff --git a/Dockerfile b/Dockerfile index cb01379ae4..79666f5bfe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -401,6 +401,7 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ # Copy startup script and shared sandbox initialisation library COPY scripts/lib/sandbox-init.sh /usr/local/lib/nemoclaw/sandbox-init.sh +COPY scripts/lib/openclaw_device_approval_policy.py /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start # Copy NODE_OPTIONS preload modules to a Landlock-accessible path. OpenShell ≥0.0.36 # blocks /opt/nemoclaw-blueprint/ from non-root users, but the entrypoint @@ -416,6 +417,7 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/generate-openclaw-config.mts \ /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py \ /usr/local/lib/nemoclaw/seed-wechat-accounts.py \ + && chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ && chmod 755 /usr/local/share/nemoclaw \ /usr/local/share/nemoclaw/openclaw-plugins \ diff --git a/scripts/lib/openclaw_device_approval_policy.py b/scripts/lib/openclaw_device_approval_policy.py new file mode 100644 index 0000000000..f34200fc15 --- /dev/null +++ b/scripts/lib/openclaw_device_approval_policy.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared OpenClaw device approval policy for NemoClaw sandbox helpers.""" + +import os + + +ALLOWED_CLIENTS = {"openclaw-control-ui"} +ALLOWED_MODES = {"webchat", "cli"} +ALLOWED_SCOPES = {"operator.pairing", "operator.read", "operator.write"} + +GATEWAY_APPROVAL_ENV_KEYS = ( + "OPENCLAW_GATEWAY_URL", + "OPENCLAW_GATEWAY_PORT", + "OPENCLAW_GATEWAY_TOKEN", +) + + +def requested_scopes(device): + if "scopes" in device: + scopes = device.get("scopes") + elif "requestedScopes" in device: + scopes = device.get("requestedScopes") + else: + return set() + if not isinstance(scopes, list): + return None + return {str(scope).strip() for scope in scopes if str(scope or "").strip()} + + +def approval_request_decision(device): + client_id = str(device.get("clientId", "")) + client_mode = str(device.get("clientMode", "")) + if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES: + return { + "allowed": False, + "reason": "unknown-client", + "client_id": client_id, + "client_mode": client_mode, + "scopes": set(), + } + + scopes = requested_scopes(device) + if scopes is None: + return { + "allowed": False, + "reason": "malformed-scopes", + "client_id": client_id, + "client_mode": client_mode, + "scopes": set(), + } + if scopes and not scopes.issubset(ALLOWED_SCOPES): + return { + "allowed": False, + "reason": "disallowed-scopes", + "client_id": client_id, + "client_mode": client_mode, + "scopes": scopes, + } + + return { + "allowed": True, + "reason": "allowlisted", + "client_id": client_id, + "client_mode": client_mode, + "scopes": scopes, + } + + +def gateway_approval_env(source_env=None): + env = dict(os.environ if source_env is None else source_env) + for key in GATEWAY_APPROVAL_ENV_KEYS: + env.pop(key, None) + return env diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e1ef2c25a6..a21b2d0bf6 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1725,10 +1725,32 @@ start_auto_pair() { fi OPENCLAW_BIN="$OPENCLAW" nohup "${run_prefix[@]}" python3 - <<'PYAUTOPAIR' >>/tmp/auto-pair.log 2>&1 & import json +import importlib.util import os +import stat import subprocess import time +APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py' + + +def load_approval_policy(path): + helper_stat = os.stat(path) + mode = helper_stat.st_mode + if mode & (stat.S_IWGRP | stat.S_IWOTH): + raise RuntimeError('approval policy helper is writable by group or other') + if helper_stat.st_uid == os.geteuid() and mode & stat.S_IWUSR: + raise RuntimeError('approval policy helper is writable by the current user') + spec = importlib.util.spec_from_file_location('openclaw_device_approval_policy', path) + if spec is None or spec.loader is None: + raise RuntimeError('approval policy helper could not be loaded') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.approval_request_decision, module.gateway_approval_env + + +approval_request_decision, gateway_approval_env = load_approval_policy(APPROVAL_POLICY_FILE) + OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw') @@ -1760,22 +1782,7 @@ HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing # (the gateway stores connectParams.client.id verbatim). This allowlist # is defense-in-depth, not a trust boundary. PR #690 adds one-shot exit, # timeout reduction, and token cleanup for a more comprehensive fix. -ALLOWED_CLIENTS = {'openclaw-control-ui'} -ALLOWED_MODES = {'webchat', 'cli'} -ALLOWED_SCOPES = {'operator.pairing', 'operator.read', 'operator.write'} - - -def requested_scopes(device): - if 'scopes' in device: - scopes = device.get('scopes') - elif 'requestedScopes' in device: - scopes = device.get('requestedScopes') - else: - return set() - if not isinstance(scopes, list): - return None - return {str(scope).strip() for scope in scopes if str(scope or '').strip()} - +# The approval_request_decision helper is shared with connect-time approvals. RUN_TIMEOUT_SECS = _env_seconds('NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS', 10) @@ -1795,10 +1802,7 @@ def run(*args, strip_gateway_env=False): # the fast→slow transition and the 8h deadline check. env = None if strip_gateway_env: - env = os.environ.copy() - env.pop('OPENCLAW_GATEWAY_URL', None) - env.pop('OPENCLAW_GATEWAY_PORT', None) - env.pop('OPENCLAW_GATEWAY_TOKEN', None) + env = gateway_approval_env(os.environ) try: proc = subprocess.run( args, capture_output=True, text=True, timeout=RUN_TIMEOUT_SECS, env=env, @@ -1844,19 +1848,20 @@ while time.time() < DEADLINE: request_id = device.get('requestId') if not request_id or request_id in HANDLED: continue - client_id = device.get('clientId', '') - client_mode = device.get('clientMode', '') - if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES: + decision = approval_request_decision(device) + client_id = decision['client_id'] + client_mode = decision['client_mode'] + if decision['reason'] == 'unknown-client': HANDLED.add(request_id) print(f'[auto-pair] rejected unknown client={client_id} mode={client_mode}') continue - scopes = requested_scopes(device) - if scopes is None: + if decision['reason'] == 'malformed-scopes': HANDLED.add(request_id) print(f'[auto-pair] rejected malformed scopes client={client_id} mode={client_mode}') continue - if scopes and not scopes.issubset(ALLOWED_SCOPES): + if decision['reason'] == 'disallowed-scopes': HANDLED.add(request_id) + scopes = decision['scopes'] print(f'[auto-pair] rejected disallowed scopes={sorted(scopes)} client={client_id} mode={client_mode}') continue arc, aout, aerr = run( diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index 377fabf359..61690e454b 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; import os from "node:os"; +import path from "node:path"; import { resolveOpenshell } from "../../adapters/openshell/resolve"; import { captureOpenshell, @@ -30,7 +32,7 @@ import { } from "../../inference/ollama/proxy"; import { LOCAL_INFERENCE_TIMEOUT_SECS } from "../../onboard/env"; import { isWsl } from "../../platform"; -import { ROOT } from "../../runner"; +import { ROOT, shellQuote } from "../../runner"; import * as sandboxVersion from "../../sandbox/version"; import { isTerminalSandboxPhase, @@ -681,37 +683,56 @@ function ensureSandboxInferenceRouteOrExit( // mid-loop kill cannot strand allowlisted requests within a normal batch. const CONNECT_AUTO_PAIR_MAX_APPROVALS = 8; const CONNECT_AUTO_PAIR_TIMEOUT_MS = 12_000; +const CONNECT_AUTO_PAIR_POLICY_PATH = path.join( + ROOT, + "scripts", + "lib", + "openclaw_device_approval_policy.py", +); + +function readConnectAutoPairPolicyModule(): string | null { + try { + return readFileSync(CONNECT_AUTO_PAIR_POLICY_PATH, "utf-8"); + } catch { + // The approval pass is best-effort, so a packaging/layout regression must + // not block connect. Build-context and package `files` coverage keep this + // helper present in supported installs. + return null; + } +} function runConnectAutoPairApprovalPass(sandboxName: string): void { + const approvalPolicyModule = readConnectAutoPairPolicyModule(); + if (!approvalPolicyModule) { + return; + } + const approvalPolicyModuleB64 = Buffer.from(approvalPolicyModule, "utf-8").toString("base64"); const script = ` PROXY_ENV=/tmp/nemoclaw-proxy-env.sh [ -r "$PROXY_ENV" ] && . "$PROXY_ENV" command -v openclaw >/dev/null 2>&1 || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -OPENCLAW_BIN="$(command -v openclaw)" python3 - <<'PYAPPROVE' +OPENCLAW_BIN="$(command -v openclaw)" NEMOCLAW_APPROVAL_POLICY_B64=${shellQuote(approvalPolicyModuleB64)} python3 - <<'PYAPPROVE' +import base64 import json import os import subprocess import sys +try: + policy_source = base64.b64decode( + os.environ.get('NEMOCLAW_APPROVAL_POLICY_B64', ''), validate=True, + ).decode('utf-8') + policy_globals = {} + exec(compile(policy_source, 'openclaw_device_approval_policy.py', 'exec'), policy_globals) + approval_request_decision = policy_globals['approval_request_decision'] + gateway_approval_env = policy_globals['gateway_approval_env'] +except Exception: + sys.exit(0) + OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw') -ALLOWED_CLIENTS = {'openclaw-control-ui'} -ALLOWED_MODES = {'webchat', 'cli'} -ALLOWED_SCOPES = {'operator.pairing', 'operator.read', 'operator.write'} MAX_APPROVALS = ${CONNECT_AUTO_PAIR_MAX_APPROVALS} - -def requested_scopes(device): - if 'scopes' in device: - scopes = device.get('scopes') - elif 'requestedScopes' in device: - scopes = device.get('requestedScopes') - else: - return set() - if not isinstance(scopes, list): - return None - return {str(scope).strip() for scope in scopes if str(scope or '').strip()} - try: proc = subprocess.run( [OPENCLAW, 'devices', 'list', '--json'], @@ -741,18 +762,11 @@ for device in pending: request_id = device.get('requestId') if not request_id or request_id in seen_request_ids: continue - client_id = device.get('clientId', '') - client_mode = device.get('clientMode', '') - if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES: - continue - scopes = requested_scopes(device) - if scopes is None or (scopes and not scopes.issubset(ALLOWED_SCOPES)): + decision = approval_request_decision(device) + if not decision['allowed']: continue seen_request_ids.add(request_id) - approve_env = os.environ.copy() - approve_env.pop('OPENCLAW_GATEWAY_URL', None) - approve_env.pop('OPENCLAW_GATEWAY_PORT', None) - approve_env.pop('OPENCLAW_GATEWAY_TOKEN', None) + approve_env = gateway_approval_env(os.environ) attempted_count += 1 try: approve_proc = subprocess.run( diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index d29819e902..6398f3ce96 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -128,6 +128,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "sandbox-init.sh"), path.join(stagedScriptsDir, "lib", "sandbox-init.sh"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "lib", "openclaw_device_approval_policy.py"), + path.join(stagedScriptsDir, "lib", "openclaw_device_approval_policy.py"), + ); // OpenClaw config generator extracted in #2449 (fixed in #2565) fs.copyFileSync( path.join(rootDir, "scripts", "generate-openclaw-config.mts"), diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 76c85d945d..aec8c2598d 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -9,6 +9,7 @@ import { spawnSync } from "node:child_process"; import { describe, it, expect } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +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"); @@ -59,6 +60,28 @@ function startScriptHeredoc(src: string, marker: string): string { return fs.readFileSync(path.join(PRELOAD_SCRIPTS, preload), "utf-8"); } +function trustedApprovalPolicyFile(): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-helper-")); + const helperPath = path.join(tmpDir, "openclaw_device_approval_policy.py"); + fs.copyFileSync(path.join(APPROVAL_POLICY_DIR, "openclaw_device_approval_policy.py"), helperPath); + fs.chmodSync(helperPath, 0o444); + return helperPath; +} + +function localApprovalPolicyPythonScript(src: string): string { + return startScriptHeredoc(src, "PYAUTOPAIR").replace( + "APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py'", + `APPROVAL_POLICY_FILE = ${JSON.stringify(trustedApprovalPolicyFile())}`, + ); +} + +function autoPairPythonScript(src: string): string { + return localApprovalPolicyPythonScript(src).replace( + "import time", + "import time\ntime.sleep = lambda _seconds: None", + ); +} + function extractShellFunctionFromSource(src: string, name: string): string { const header = `${name}() {`; const start = src.indexOf(header); @@ -1487,6 +1510,45 @@ setImmediate(function () { describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); + it("refuses an approval policy helper writable by the current user", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-policy-mode-")); + const writablePolicy = path.join(tmpDir, "openclaw_device_approval_policy.py"); + fs.writeFileSync( + writablePolicy, + [ + "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"), + { mode: 0o600 }, + ); + const autoPairScript = startScriptHeredoc(src, "PYAUTOPAIR").replace( + "APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py'", + `APPROVAL_POLICY_FILE = ${JSON.stringify(writablePolicy)}`, + ); + + try { + const run = spawnSync("python3", ["-c", autoPairScript], { + encoding: "utf-8", + env: { + ...process.env, + OPENCLAW_BIN: "/bin/false", + }, + timeout: 10_000, + }); + + expect(run.status).toBe(1); + expect(run.stderr).toContain( + "approval policy helper is writable by the current user", + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("approves only whitelisted clients and does not reprocess handled requests", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); @@ -1535,10 +1597,7 @@ exit 2 { mode: 0o755 }, ); - const autoPairScript = startScriptHeredoc(src, "PYAUTOPAIR").replace( - "import time", - "import time\ntime.sleep = lambda _seconds: None", - ); + const autoPairScript = autoPairPythonScript(src); try { const run = spawnSync("python3", ["-c", autoPairScript], { @@ -1583,10 +1642,7 @@ describe("nemoclaw-start auto-pair slow-mode keepalive (#4263)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); function buildAutoPairScript(): string { - return startScriptHeredoc(src, "PYAUTOPAIR").replace( - "import time", - "import time\ntime.sleep = lambda _seconds: None", - ); + return autoPairPythonScript(src); } it("approves late CLI scope upgrades after browser pairing converges", () => { @@ -1834,6 +1890,7 @@ exit 2 it("rejects disallowed CLI admin scope requests", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-admin-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const maliciousPolicyDir = path.join(tmpDir, "malicious-policy"); const approveLog = path.join(tmpDir, "approvals.log"); const adminPending = JSON.stringify({ pending: [ @@ -1847,6 +1904,18 @@ exit 2 paired: [], }); + fs.mkdirSync(maliciousPolicyDir); + fs.writeFileSync( + path.join(maliciousPolicyDir, "openclaw_device_approval_policy.py"), + [ + "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"), + ); fs.writeFileSync( fakeOpenclaw, `#!/usr/bin/env bash @@ -1872,6 +1941,7 @@ exit 2 env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, + NEMOCLAW_APPROVAL_POLICY_DIR: maliciousPolicyDir, NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", @@ -2033,9 +2103,8 @@ exit 0 try { // Do NOT monkey-patch time.sleep here: we want real wall-clock // semantics so subprocess.run(..., timeout=...) actually fires. - const watcherSrc = startScriptHeredoc( + const watcherSrc = localApprovalPolicyPythonScript( fs.readFileSync(START_SCRIPT, "utf-8"), - "PYAUTOPAIR", ); const start = Date.now(); const run = spawnSync("python3", ["-c", watcherSrc], { @@ -2128,9 +2197,8 @@ exit 2 ); try { - const watcherSrc = startScriptHeredoc( + const watcherSrc = localApprovalPolicyPythonScript( fs.readFileSync(START_SCRIPT, "utf-8"), - "PYAUTOPAIR", ); const run = spawnSync("python3", ["-c", watcherSrc], { encoding: "utf-8", diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index b5fd9fee54..7299314a96 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -73,6 +73,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "nemoclaw-start.sh")); writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); + writeFixture(path.join("scripts", "lib", "openclaw_device_approval_policy.py")); writeFixture(path.join("scripts", "generate-openclaw-config.mts")); writeFixture(path.join("scripts", "openclaw-build-messaging-plugins.py")); writeFixture(path.join("scripts", "seed-wechat-accounts.py")); @@ -251,6 +252,9 @@ describe("sandbox build context staging", () => { fs.existsSync(path.join(buildCtx, "scripts", "openclaw-build-messaging-plugins.py")), ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "seed-wechat-accounts.py"))).toBe(true); + expect( + fs.existsSync(path.join(buildCtx, "scripts", "lib", "openclaw_device_approval_policy.py")), + ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-tool-catalog.js"))).toBe( true, ); diff --git a/test/sandbox-connect-inference.test.ts b/test/sandbox-connect-inference.test.ts index 189ebe3537..12bb9447b1 100644 --- a/test/sandbox-connect-inference.test.ts +++ b/test/sandbox-connect-inference.test.ts @@ -393,6 +393,87 @@ function runConnect( ); } +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 }); + } +} + describe("sandbox connect inference route swap (#1248)", () => { it( "skips the vLLM model preflight on connect --probe-only but keeps it for a full connect (#4585)", @@ -1308,49 +1389,145 @@ describe("sandbox connect auto-pair approval pass (#4263)", () => { const result = runConnect(tmpDir, sandboxName); expect(result.status).toBe(0); - const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); - // Look for the approval-pass sandbox-exec invocation specifically. - 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(); - // The exec must target the requested sandbox and use `sh -c