diff --git a/src/commands/sandbox/doctor.ts b/src/commands/sandbox/doctor.ts index b6204b16fc..fa36db3937 100644 --- a/src/commands/sandbox/doctor.ts +++ b/src/commands/sandbox/doctor.ts @@ -28,7 +28,7 @@ export default class SandboxDoctorCliCommand extends NemoClawCommand { static flags = { fix: Flags.boolean({ description: - "Restore the mutable OpenClaw config permission contract if `openclaw doctor --fix` tightened it", + "Restore the mutable OpenClaw config permission contract if `openclaw doctor --fix` tightened it, and approve pending allowlisted dashboard/CLI tool-scope upgrades", default: false, // `--fix` mutates sandbox permissions; keep it out of the machine-readable // `--json` readiness-gate path so automation cannot trigger a silent repair. diff --git a/src/lib/actions/sandbox/auto-pair-approval.test.ts b/src/lib/actions/sandbox/auto-pair-approval.test.ts new file mode 100644 index 0000000000..120bcfc7c0 --- /dev/null +++ b/src/lib/actions/sandbox/auto-pair-approval.test.ts @@ -0,0 +1,169 @@ +// 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 { + AUTO_PAIR_MAX_APPROVALS, + buildAutoPairApprovalScript, + readAutoPairApprovalPolicyModule, + wrapSandboxShellScript, +} from "./auto-pair-approval"; + +const SUMMARY_MARKER = "__NEMOCLAW_AUTO_PAIR_APPROVED__"; + +describe("buildAutoPairApprovalScript (#4263/#4616)", () => { + it("builds the bounded allowlisted approval pass", () => { + const script = buildAutoPairApprovalScript("UE9MSUNZ"); + expect(script).toContain("/tmp/nemoclaw-proxy-env.sh"); + expect(script).toContain("command -v openclaw"); + expect(script).toContain("command -v python3"); + expect(script).toContain("'devices', 'list', '--json'"); + expect(script).toContain("'devices', 'approve'"); + expect(script).toContain("approval_request_decision(device)"); + expect(script).toContain("if not decision['allowed']:"); + expect(script).toContain("approve_env = gateway_approval_env(os.environ)"); + expect(script).toContain(`MAX_APPROVALS = ${AUTO_PAIR_MAX_APPROVALS}`); + expect(script).toContain("'UE9MSUNZ'"); + }); + + it("omits the summary marker by default and appends it when requested", () => { + const silent = buildAutoPairApprovalScript("UE9MSUNZ"); + const reporting = buildAutoPairApprovalScript("UE9MSUNZ", { emitSummary: true }); + expect(silent).not.toContain(SUMMARY_MARKER); + expect(reporting).toContain(`print(f'${SUMMARY_MARKER}={approved_count}')`); + // The reporting script is the silent script with exactly the summary line + // inserted before the heredoc terminator — nothing else changes. + const stripped = reporting.replace(`print(f'${SUMMARY_MARKER}={approved_count}')\n`, ""); + expect(stripped).toBe(silent); + }); + + it("reads the real policy module from disk", () => { + const module = readAutoPairApprovalPolicyModule(); + expect(module).toBeTruthy(); + expect(module).toContain("def approval_request_decision"); + expect(module).toContain("def gateway_approval_env"); + }); +}); + +describe("wrapSandboxShellScript (#4616)", () => { + it("encodes a multi-line payload onto a single newline-free line", () => { + const wrapped = wrapSandboxShellScript("echo one\necho two\n"); + expect(wrapped).not.toMatch(/[\n\r]/); + expect(wrapped).toContain("base64 -d"); + expect(wrapped).toContain("mktemp"); + }); + + it("round-trips and preserves the inner exit status when run", () => { + const inner = "echo line-one\nprintf 'exit-then\\n'\nexit 3\n"; + const wrapped = wrapSandboxShellScript(inner); + const result = spawnSync("sh", ["-c", wrapped], { encoding: "utf-8", timeout: 10_000 }); + expect(result.stdout).toContain("line-one"); + expect(result.stdout).toContain("exit-then"); + expect(result.status).toBe(3); + }); +}); + +describe("auto-pair approval pass behaviour (#4616)", () => { + it("approves allowlisted upgrades, skips unknown clients, and reports the count", () => { + if ( + spawnSync("sh", ["-c", "command -v python3"], { stdio: "ignore" }).status !== 0 + ) { + // No python3 — the in-sandbox script can't run; skip the behavioural check. + return; + } + const policy = readAutoPairApprovalPolicyModule(); + expect(policy).toBeTruthy(); + const policyB64 = Buffer.from(policy as string, "utf-8").toString("base64"); + const script = buildAutoPairApprovalScript(policyB64, { emitSummary: true }); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-")); + try { + const approvalsFile = path.join(tmpDir, "approvals.log"); + const approveEnvFile = path.join(tmpDir, "approve-env.log"); + const pending = [ + { + requestId: "ok-webchat", + clientId: "openclaw-control-ui", + clientMode: "webchat", + scopes: ["operator.read", "operator.write"], + }, + { + requestId: "ok-cli", + clientId: "openclaw-cli", + clientMode: "cli", + requestedScopes: ["operator.pairing"], + }, + { + requestId: "deny-unknown", + clientId: "evil", + clientMode: "unknown", + scopes: ["operator.read"], + }, + { + requestId: "deny-admin", + clientId: "openclaw-control-ui", + clientMode: "webchat", + scopes: ["operator.admin"], + }, + ]; + const listResponse = JSON.stringify({ pending, paired: [] }); + fs.writeFileSync( + path.join(tmpDir, "openclaw"), + `#!${process.execPath} +const fs = require("fs"); +const args = process.argv.slice(2); +if (args[0] === "devices" && args[1] === "list") { + process.stdout.write(${JSON.stringify(`${listResponse}\n`)}); + process.exit(0); +} +if (args[0] === "devices" && args[1] === "approve") { + fs.appendFileSync(${JSON.stringify(approvalsFile)}, args[2] + "\\n"); + fs.appendFileSync( + ${JSON.stringify(approveEnvFile)}, + [ + 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.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: "secret-token", + }, + timeout: 10_000, + }); + + const approvals = fs.existsSync(approvalsFile) + ? fs.readFileSync(approvalsFile, "utf-8").trim().split("\n").filter(Boolean) + : []; + const approveEnv = fs.existsSync(approveEnvFile) + ? fs.readFileSync(approveEnvFile, "utf-8").trim().split("\n").filter(Boolean) + : []; + + expect(approvals).toEqual(["ok-webchat", "ok-cli"]); + // Gateway env stripped on the approve subprocess (#4462 workaround). + expect(approveEnv).toEqual(["unset:unset:unset", "unset:unset:unset"]); + expect(result.stdout).toContain(`${SUMMARY_MARKER}=2`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/actions/sandbox/auto-pair-approval.ts b/src/lib/actions/sandbox/auto-pair-approval.ts new file mode 100644 index 0000000000..5904731d99 --- /dev/null +++ b/src/lib/actions/sandbox/auto-pair-approval.ts @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shared, bounded OpenClaw device-scope approval pass. + * + * The in-sandbox auto-pair watcher (`scripts/nemoclaw-start.sh`) keeps + * approving allowlisted scope upgrades in slow-mode for hours after startup. + * This host-side pass is the defense-in-depth recovery for the cases where the + * watcher has exited (deadline reached), crashed, or was contended away by a + * second sandbox — leaving a late scope upgrade pending forever. + * + * It is used from two surfaces: + * - `nemoclaw connect` (#4263), which runs it silently before SSH. + * - `nemoclaw doctor --fix` (#4616), which runs it as a + * dashboard-only recovery so a browser/dashboard user can repair pending + * OpenClaw tool-scope approvals without ever opening an SSH `connect`. + * + * Both surfaces apply the SAME narrow allowlist as the startup watcher + * (`scripts/lib/openclaw_device_approval_policy.py`): `openclaw-control-ui` + * clients plus `webchat`/`cli` modes, restricted to operator.pairing/read/write + * scopes. Unknown clients are ignored, never approved. + * + * Workaround boundary (NemoClaw#4462): OpenClaw owns device-pairing approval + * semantics. In OpenClaw 2026.5.x, a gateway-pinned `devices approve` for a + * scope-upgrade can request the upgraded scopes for its own connection and + * return the pending-scope failure it is trying to resolve. The approval call + * therefore strips OPENCLAW_GATEWAY_URL/PORT/TOKEN from the child env to use + * OpenClaw's local pairing fallback; the list call stays gateway-pinned so it + * inspects the live gateway. Remove this local fallback path when OpenClaw + * approve can complete scope upgrades through the gateway using only + * operator.pairing. + */ + +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { shellQuote } from "../../core/shell-quote"; +import { ROOT } from "../../state/paths"; + +// Bound the in-sandbox work: 2s list + 1s × MAX_APPROVALS attempts plus +// shell/python startup slack fits inside the outer spawnSync cap, so a wedged +// sandbox can never block the caller. +export const AUTO_PAIR_MAX_APPROVALS = 8; +export const AUTO_PAIR_APPROVAL_TIMEOUT_MS = 12_000; + +const AUTO_PAIR_POLICY_PATH = path.join( + ROOT, + "scripts", + "lib", + "openclaw_device_approval_policy.py", +); + +export type AutoPairApprovalResult = { + /** The sandbox-exec was issued (false only when the policy helper is absent). */ + attempted: boolean; + /** The in-sandbox script reported a parseable summary (capture mode only). */ + reported: boolean; + /** Number of pending requests approved this pass (capture mode only). */ + approved: number; +}; + +/** + * Wrap a multi-line shell payload so it survives `openshell sandbox exec`. + * + * OpenShell's exec RPC rejects any argument containing a newline or carriage + * return ("command argument N contains newline or carriage return characters"), + * so a multi-line `sh -c