diff --git a/src/lib/actions/sandbox/auto-pair-approval-receipt.test.ts b/src/lib/actions/sandbox/auto-pair-approval-receipt.test.ts new file mode 100644 index 00000000000..a514ba43376 --- /dev/null +++ b/src/lib/actions/sandbox/auto-pair-approval-receipt.test.ts @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { type SpawnSyncOptionsWithStringEncoding, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { + buildAutoPairApprovalScript, + classifyAutoPairApprovalExecReceipt, + parseAutoPairApprovalReceipt, + readAutoPairApprovalPolicyModule, + runSandboxAutoPairApprovalPass, +} from "./auto-pair-approval"; + +describe("auto-pair approval receipts (#4616)", () => { + const pythonUnavailable = + spawnSync("sh", ["-c", "command -v python3"], { stdio: "ignore" }).status !== 0; + + it.skipIf(pythonUnavailable)("omits raw output from devices-list failure classifications", () => { + const policy = readAutoPairApprovalPolicyModule(); + expect(policy).toBeTruthy(); + const script = buildAutoPairApprovalScript( + Buffer.from(policy as string, "utf-8").toString("base64"), + { + emitReceipt: true, + budget: { listTimeoutS: 0.5 }, + }, + ); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-list-receipt-")); + try { + fs.writeFileSync( + path.join(tmpDir, "openclaw"), + `#!${process.execPath} +const args = process.argv.slice(2); +if (args[0] !== "devices" || args[1] !== "list") process.exit(2); +const sleepMs = Number(process.env.NEMOCLAW_LIST_SLEEP_MS || "0"); +if (sleepMs > 0) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, sleepMs); +} +process.stdout.write(process.env.NEMOCLAW_LIST_STDOUT || ""); +process.stderr.write(process.env.NEMOCLAW_LIST_STDERR || ""); +process.exit(Number(process.env.NEMOCLAW_LIST_EXIT_CODE || "0")); +`, + { mode: 0o755 }, + ); + for (const [environment, receipt] of [ + [{ NEMOCLAW_LIST_SLEEP_MS: "800" }, "list-timeout"], + [ + { NEMOCLAW_LIST_EXIT_CODE: "1", NEMOCLAW_LIST_STDERR: "raw failure" }, + "list-command-failed", + ], + [ + { + NEMOCLAW_LIST_EXIT_CODE: "1", + NEMOCLAW_LIST_STDERR: "scope upgrade pending approval raw detail", + }, + "list-scope-upgrade-pending", + ], + [ + { + NEMOCLAW_LIST_EXIT_CODE: "1", + NEMOCLAW_LIST_STDERR: "device pairing required raw detail", + }, + "list-device-pairing-required", + ], + [ + { + NEMOCLAW_LIST_EXIT_CODE: "1", + NEMOCLAW_LIST_STDERR: "gateway connect failed raw detail", + }, + "list-gateway-connect-failed", + ], + [{ NEMOCLAW_LIST_STDOUT: "" }, "list-empty-output"], + [{ NEMOCLAW_LIST_STDOUT: "raw invalid json" }, "list-invalid-json"], + [{ NEMOCLAW_LIST_STDOUT: "[]\n" }, "list-invalid-output"], + [{ NEMOCLAW_LIST_STDOUT: "{}\n" }, "list-missing-pending"], + ] as const) { + const result = spawnSync("sh", ["-c", script], { + encoding: "utf-8", + env: { + ...process.env, + PATH: `${tmpDir}:/usr/bin:/bin`, + ...environment, + }, + timeout: 10_000, + }); + expect(parseAutoPairApprovalReceipt(result.stdout)).toBe(receipt); + expect(`${result.stdout}${result.stderr}`).not.toContain("raw "); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("distinguishes host execution failures without returning their details", () => { + const timeoutError = Object.assign(new Error("private timeout detail"), { + code: "ETIMEDOUT", + }); + const spawnError = Object.assign(new Error("private spawn detail"), { code: "ENOENT" }); + + expect( + classifyAutoPairApprovalExecReceipt( + { error: timeoutError, status: null, signal: "SIGTERM" }, + "private output", + ), + ).toBe("exec-timeout"); + expect( + classifyAutoPairApprovalExecReceipt( + { error: spawnError, status: null, signal: null }, + "private output", + ), + ).toBe("exec-spawn-failed"); + expect( + classifyAutoPairApprovalExecReceipt({ status: null, signal: "SIGKILL" }, "private output"), + ).toBe("exec-signal"); + expect(classifyAutoPairApprovalExecReceipt({ status: 1, signal: null }, "private output")).toBe( + "exec-command-failed", + ); + expect(classifyAutoPairApprovalExecReceipt({ status: 0, signal: null }, "private output")).toBe( + "exec-invalid-receipt", + ); + expect( + classifyAutoPairApprovalExecReceipt( + { status: 0, signal: null }, + "__NEMOCLAW_AUTO_PAIR_RECEIPT__=approved-one\n", + ), + ).toBe("approved-one"); + }); + + it("streams the approval program through stdin instead of OpenShell command argv", () => { + const run = vi.fn( + ( + _command: string, + _args: readonly string[], + _options: SpawnSyncOptionsWithStringEncoding, + ) => ({ + status: 0, + signal: null, + stdout: "__NEMOCLAW_AUTO_PAIR_RECEIPT__=approved-one\n", + stderr: "", + }), + ); + + const result = runSandboxAutoPairApprovalPass( + "beta", + { localDeviceOnly: true, receipt: true }, + { + getOpenshellBinary: () => "/usr/local/bin/openshell", + spawnSync: run as unknown as typeof spawnSync, + }, + ); + + expect(result.receipt).toBe("approved-one"); + expect(run).toHaveBeenCalledOnce(); + expect(run.mock.calls[0]?.[1]).toEqual(["sandbox", "exec", "--name", "beta", "--", "sh", "-s"]); + expect(run.mock.calls[0]?.[1].join(" ")).not.toContain("PYAPPROVE"); + expect(run.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ + input: expect.stringContaining("PYAPPROVE"), + stdio: ["pipe", "pipe", "pipe"], + }), + ); + }); +}); diff --git a/src/lib/actions/sandbox/auto-pair-approval-restored-root.test.ts b/src/lib/actions/sandbox/auto-pair-approval-restored-root.test.ts new file mode 100644 index 00000000000..eed2d53af3f --- /dev/null +++ b/src/lib/actions/sandbox/auto-pair-approval-restored-root.test.ts @@ -0,0 +1,85 @@ +// 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 { + buildAutoPairApprovalScript, + parseAutoPairApprovalReceipt, + readAutoPairApprovalPolicyModule, +} from "./auto-pair-approval"; + +describe("restored-clone state-root traversal (#4616)", () => { + const pyIt = + spawnSync("sh", ["-c", "command -v python3"], { stdio: "ignore" }).status === 0 ? it : it.skip; + + pyIt("traverses the clone layout when root read access is denied", () => { + const policy = readAutoPairApprovalPolicyModule(); + expect(policy).toBeTruthy(); + const restoredClonePolicy = `${policy} +import errno as _nemoclaw_test_errno +import os as _nemoclaw_test_os +_nemoclaw_test_original_open = _nemoclaw_test_os.open +_nemoclaw_test_synthetic_path_flag = not hasattr(_nemoclaw_test_os, 'O_PATH') +_nemoclaw_test_path_flag = getattr(_nemoclaw_test_os, 'O_PATH', 1 << 30) +if _nemoclaw_test_synthetic_path_flag: + _nemoclaw_test_os.O_PATH = _nemoclaw_test_path_flag + +def _nemoclaw_test_open(path_value, flags, mode=0o777, *, dir_fd=None): + if ( + dir_fd is None + and _nemoclaw_test_os.fspath(path_value) == _nemoclaw_test_os.sep + and not flags & _nemoclaw_test_path_flag + ): + raise PermissionError( + _nemoclaw_test_errno.EACCES, + 'restored clone denies a read-directory handle for the filesystem root', + ) + effective_flags = ( + flags & ~_nemoclaw_test_path_flag + if _nemoclaw_test_synthetic_path_flag + else flags + ) + return _nemoclaw_test_original_open(path_value, effective_flags, mode, dir_fd=dir_fd) + +_nemoclaw_test_os.open = _nemoclaw_test_open +`; + const script = buildAutoPairApprovalScript( + Buffer.from(restoredClonePolicy, "utf-8").toString("base64"), + { + emitReceipt: true, + localDeviceOnly: true, + budget: { maxApprovals: 1 }, + }, + ); + const legacyScript = script.replace("getattr(os, 'O_PATH', os.O_RDONLY)", "os.O_RDONLY"); + const tmpDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-restored-root-")), + ); + try { + const stateDir = path.join(tmpDir, "sandbox", ".openclaw"); + fs.mkdirSync(path.join(stateDir, "devices"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "openclaw"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const run = (approvalScript: string) => + spawnSync("sh", { + encoding: "utf-8", + input: approvalScript, + env: { + ...process.env, + PATH: `${tmpDir}:/usr/bin:/bin`, + OPENCLAW_STATE_DIR: stateDir, + }, + timeout: 10_000, + }); + + expect(parseAutoPairApprovalReceipt(run(legacyScript).stdout)).toBe("list-state-root-failed"); + expect(parseAutoPairApprovalReceipt(run(script).stdout)).toBe("list-pending-unavailable"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/actions/sandbox/auto-pair-approval-script.test.ts b/src/lib/actions/sandbox/auto-pair-approval-script.test.ts index eed73390389..25a004c3f86 100644 --- a/src/lib/actions/sandbox/auto-pair-approval-script.test.ts +++ b/src/lib/actions/sandbox/auto-pair-approval-script.test.ts @@ -67,10 +67,10 @@ describe("buildAutoPairApprovalScript (#4263/#4616)", () => { expect(restoredClone).toContain("local_approval_auth_mode == 'paired-token'"); expect(restoredClone).toContain("sync_approved_clone_device_auth"); expect(restoredClone).toContain("os.O_DIRECTORY | os.O_NOFOLLOW"); + expect(restoredClone).toContain("getattr(os, 'O_PATH', os.O_RDONLY)"); expect(restoredClone).toContain("dir_fd=clone_state_dir_fd"); expect(restoredClone).toContain("clone_devices_dir_fd,"); expect(restoredClone).toContain("clone_identity_dir_fd,"); - expect(restoredClone).toContain("metadata.st_nlink != 1"); expect(restoredClone).toContain("pass_fds=approval_pass_fds"); expect(restoredClone).toContain("approve_env['NODE_DISABLE_COMPILE_CACHE'] = '1'"); expect(restoredClone).toContain("approve_env['OPENCLAW_NO_RESPAWN'] = '1'"); @@ -113,6 +113,13 @@ describe("buildAutoPairApprovalScript (#4263/#4616)", () => { for (const receipt of [ "approved-one", "list-failed", + "list-state-path-invalid", + "list-platform-unsupported", + "list-state-root-failed", + "list-devices-directory-failed", + "list-pending-unsafe", + "list-pending-unstable", + "list-pending-invalid-shape", "list-pending-unavailable", "list-timeout", "list-exec-failed", diff --git a/src/lib/actions/sandbox/auto-pair-approval.test.ts b/src/lib/actions/sandbox/auto-pair-approval.test.ts index 0d9da9bf0ec..f3d1cc813c6 100644 --- a/src/lib/actions/sandbox/auto-pair-approval.test.ts +++ b/src/lib/actions/sandbox/auto-pair-approval.test.ts @@ -16,85 +16,6 @@ import { const SUMMARY_MARKER = "__NEMOCLAW_AUTO_PAIR_APPROVED__"; describe("auto-pair approval pass behaviour (#4616)", () => { - it("reports one sanitized devices-list failure classification", () => { - if (spawnSync("sh", ["-c", "command -v python3"], { stdio: "ignore" }).status !== 0) { - return; - } - const policy = readAutoPairApprovalPolicyModule(); - expect(policy).toBeTruthy(); - const script = buildAutoPairApprovalScript( - Buffer.from(policy as string, "utf-8").toString("base64"), - { - emitReceipt: true, - budget: { listTimeoutS: 0.5 }, - }, - ); - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-list-receipt-")); - try { - fs.writeFileSync( - path.join(tmpDir, "openclaw"), - `#!${process.execPath} -const args = process.argv.slice(2); -if (args[0] !== "devices" || args[1] !== "list") process.exit(2); -const sleepMs = Number(process.env.NEMOCLAW_LIST_SLEEP_MS || "0"); -if (sleepMs > 0) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, sleepMs); -} -process.stdout.write(process.env.NEMOCLAW_LIST_STDOUT || ""); -process.stderr.write(process.env.NEMOCLAW_LIST_STDERR || ""); -process.exit(Number(process.env.NEMOCLAW_LIST_EXIT_CODE || "0")); -`, - { mode: 0o755 }, - ); - for (const [environment, receipt] of [ - [{ NEMOCLAW_LIST_SLEEP_MS: "800" }, "list-timeout"], - [ - { NEMOCLAW_LIST_EXIT_CODE: "1", NEMOCLAW_LIST_STDERR: "raw failure" }, - "list-command-failed", - ], - [ - { - NEMOCLAW_LIST_EXIT_CODE: "1", - NEMOCLAW_LIST_STDERR: "scope upgrade pending approval raw detail", - }, - "list-scope-upgrade-pending", - ], - [ - { - NEMOCLAW_LIST_EXIT_CODE: "1", - NEMOCLAW_LIST_STDERR: "device pairing required raw detail", - }, - "list-device-pairing-required", - ], - [ - { - NEMOCLAW_LIST_EXIT_CODE: "1", - NEMOCLAW_LIST_STDERR: "gateway connect failed raw detail", - }, - "list-gateway-connect-failed", - ], - [{ NEMOCLAW_LIST_STDOUT: "" }, "list-empty-output"], - [{ NEMOCLAW_LIST_STDOUT: "raw invalid json" }, "list-invalid-json"], - [{ NEMOCLAW_LIST_STDOUT: "[]\n" }, "list-invalid-output"], - [{ NEMOCLAW_LIST_STDOUT: "{}\n" }, "list-missing-pending"], - ] as const) { - const result = spawnSync("sh", ["-c", script], { - encoding: "utf-8", - env: { - ...process.env, - PATH: `${tmpDir}:/usr/bin:/bin`, - ...environment, - }, - timeout: 10_000, - }); - expect(parseAutoPairApprovalReceipt(result.stdout)).toBe(receipt); - expect(`${result.stdout}${result.stderr}`).not.toContain("raw "); - } - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - const pyIt = spawnSync("sh", ["-c", "command -v python3"], { stdio: "ignore" }).status === 0 ? it : it.skip; const pyIt25s = (name: string, test: () => void) => pyIt(name, test, 25_000); @@ -487,6 +408,37 @@ ${persistentRaceNeedle}`, expect(persistentDevicesRaceScript.includes("NEMOCLAW_TEST_PERSISTENT_DEVICES_RACE")).toBe( true, ); + const transientPendingPublicationNeedle = ` fd = os.open(entry_name, clone_file_flags, dir_fd=directory_fd) + try: + validate_clone_json_descriptor(fd)`; + const transientPendingPublicationScript = script.replace( + transientPendingPublicationNeedle, + ` fd = os.open(entry_name, clone_file_flags, dir_fd=directory_fd) + try: + serialized_pending = os.environ.pop('NEMOCLAW_TEST_TRANSIENT_PENDING_JSON', '') + if serialized_pending and directory_name == 'devices' and entry_name == 'pending.json': + with open(${JSON.stringify(path.join(devicesDir, "pending.json"))}, 'w', encoding='utf-8') as handle: + handle.write(serialized_pending) + raise json.JSONDecodeError('transient pending publication', '', 0) + validate_clone_json_descriptor(fd)`, + ); + expect(transientPendingPublicationScript).toContain("transient pending publication"); + const rotatedPendingPublicationNeedle = ` with os.fdopen(os.dup(fd), encoding='utf-8') as handle: + parsed = json.load(handle) + validate_clone_json_descriptor(fd)`; + const rotatedPendingPublicationScript = script.replace( + rotatedPendingPublicationNeedle, + ` with os.fdopen(os.dup(fd), encoding='utf-8') as handle: + parsed = json.load(handle) + serialized_pending = os.environ.pop('NEMOCLAW_TEST_ROTATED_PENDING_JSON', '') + if serialized_pending and directory_name == 'devices' and entry_name == 'pending.json': + replacement_path = ${JSON.stringify(path.join(devicesDir, "pending-replacement.json"))} + with open(replacement_path, 'w', encoding='utf-8') as handle: + handle.write(serialized_pending) + os.replace(replacement_path, ${JSON.stringify(path.join(devicesDir, "pending.json"))}) + validate_clone_json_descriptor(fd)`, + ); + expect(rotatedPendingPublicationScript).toContain("NEMOCLAW_TEST_ROTATED_PENDING_JSON"); const execute = ( failApproval = false, gatewayToken = "secret-token", @@ -495,20 +447,33 @@ ${persistentRaceNeedle}`, invalidWatcherState = false, timeoutAfterCommit = false, devicesRace: "child-entry" | "none" | "persistent" | "transient" = "none", + transientPendingJson = "", + rotatedPendingJson = "", + hardLinkPending = false, ) => { const approvalEnv = { ...process.env }; delete approvalEnv.NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING; delete approvalEnv.NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING; - const approvalScript = timeoutAfterCommit - ? timeoutScript - : devicesRace === "child-entry" - ? childEntryRaceScript - : devicesRace === "persistent" - ? persistentDevicesRaceScript - : devicesRace === "transient" - ? transientDevicesRaceScript - : script; - return spawnSync("sh", { + const approvalScript = rotatedPendingJson + ? rotatedPendingPublicationScript + : transientPendingJson + ? transientPendingPublicationScript + : timeoutAfterCommit + ? timeoutScript + : devicesRace === "child-entry" + ? childEntryRaceScript + : devicesRace === "persistent" + ? persistentDevicesRaceScript + : devicesRace === "transient" + ? transientDevicesRaceScript + : script; + const pendingHardLinkPath = path.join(tmpDir, "pending-hard-link.json"); + fs.rmSync(pendingHardLinkPath, { force: true }); + const preparePendingHardLink = hardLinkPending + ? () => fs.linkSync(path.join(devicesDir, "pending.json"), pendingHardLinkPath) + : () => undefined; + preparePendingHardLink(); + const result = spawnSync("sh", { encoding: "utf-8", input: approvalScript, env: { @@ -521,6 +486,8 @@ ${persistentRaceNeedle}`, NEMOCLAW_TEST_PERSISTENT_DEVICES_RACE: devicesRace === "persistent" ? "1" : "0", NEMOCLAW_TEST_TRANSIENT_DEVICES_RACE: devicesRace === "transient" ? "1" : "0", NEMOCLAW_TEST_CHILD_ENTRY_RACE: devicesRace === "child-entry" ? "1" : "0", + NEMOCLAW_TEST_TRANSIENT_PENDING_JSON: transientPendingJson, + NEMOCLAW_TEST_ROTATED_PENDING_JSON: rotatedPendingJson, NEMOCLAW_TEST_CLONE_STATE_DIR: stateDir, NEMOCLAW_TEST_CLONE_STATE_BACKUP: stateRaceBackup, NEMOCLAW_PRIMARY_STATE_DIR: primaryStateDir, @@ -532,6 +499,8 @@ ${persistentRaceNeedle}`, }, timeout: 10_000, }); + fs.rmSync(pendingHardLinkPath, { force: true }); + return result; }; const run = ( pending: unknown[], @@ -546,6 +515,9 @@ ${persistentRaceNeedle}`, pairedById?: Record; clientAuth?: "matching" | "missing" | "primary-symlink" | "stale"; devicesRace?: "child-entry" | "persistent" | "transient"; + hardLinkPending?: boolean; + rotatedPendingPublication?: boolean; + transientPendingPublication?: boolean; } = {}, ) => { const pendingById = @@ -606,6 +578,9 @@ ${persistentRaceNeedle}`, options.invalidWatcherState, options.timeoutAfterCommit, options.devicesRace, + options.transientPendingPublication ? JSON.stringify(pendingById) : "", + options.rotatedPendingPublication ? JSON.stringify(pendingById) : "", + options.hardLinkPending, ); }; const readApprovals = () => @@ -1071,15 +1046,15 @@ ${persistentRaceNeedle}`, primaryPending, ); - for (const preparePendingState of [ - () => fs.writeFileSync(clonePendingPath, "{"), - () => fs.writeFileSync(clonePendingPath, "[]"), - ]) { + for (const [preparePendingState, receipt] of [ + [() => fs.writeFileSync(clonePendingPath, "{"), "list-pending-unstable"], + [() => fs.writeFileSync(clonePendingPath, "[]"), "list-pending-invalid-shape"], + ] as const) { resetLogs(); preparePendingState(); const failed = execute(); expect(failed.status).toBe(0); - expect(parseAutoPairApprovalReceipt(failed.stdout)).toBe("list-failed"); + expect(parseAutoPairApprovalReceipt(failed.stdout)).toBe(receipt); expect(readApprovals()).toEqual([]); expect(`${failed.stdout}${failed.stderr}`.includes("raw list output")).toBe(false); expect(fs.existsSync(listEnvFile)).toBe(false); @@ -1088,6 +1063,29 @@ ${persistentRaceNeedle}`, ); } + resetLogs(); + const stabilized = run([foreignRequest, repairRequest], { + transientPendingPublication: true, + }); + expect(stabilized.status).toBe(0); + expect(parseAutoPairApprovalReceipt(stabilized.stdout)).toBe("approved-one"); + expect(readApprovals()).toEqual([repairRequest.requestId]); + expect(`${stabilized.stdout}${stabilized.stderr}`).not.toContain( + "transient pending publication", + ); + + resetLogs(); + const stabilizedRotation = run([foreignRequest, repairRequest], { + rotatedPendingPublication: true, + }); + expect(parseAutoPairApprovalReceipt(stabilizedRotation.stdout)).toBe("approved-one"); + expect(readApprovals()).toEqual([repairRequest.requestId]); + + resetLogs(); + const hardLinked = run([foreignRequest, repairRequest], { hardLinkPending: true }); + expect(parseAutoPairApprovalReceipt(hardLinked.stdout)).toBe("list-pending-unsafe"); + expect(readApprovals()).toEqual([]); + resetLogs(); const noMatch = run([foreignRequest]); expect(parseAutoPairApprovalReceipt(noMatch.stdout)).toBe("clone-no-match"); @@ -1157,7 +1155,9 @@ ${persistentRaceNeedle}`, expect(persistentSwapOccurred).toBe(true); fs.unlinkSync(devicesDir); fs.renameSync(devicesRaceBackup, devicesDir); - expect(parseAutoPairApprovalReceipt(persistentDevicesRace.stdout)).toBe("list-failed"); + expect(parseAutoPairApprovalReceipt(persistentDevicesRace.stdout)).toBe( + "list-pending-unsafe", + ); expect(persistentDevicesRace.stdout.trim().split(/\r?\n/).length).toBe(1); expect(persistentDevicesRace.stderr.length).toBe(0); expect(readApproveCalls().length).toBe(0); diff --git a/src/lib/actions/sandbox/auto-pair-approval.ts b/src/lib/actions/sandbox/auto-pair-approval.ts index 333ea84a162..77a944cdc1b 100644 --- a/src/lib/actions/sandbox/auto-pair-approval.ts +++ b/src/lib/actions/sandbox/auto-pair-approval.ts @@ -45,6 +45,11 @@ import path from "node:path"; import { shellQuote } from "../../core/shell-quote"; import { ROOT } from "../../state/paths"; +import { + CONNECT_AUTO_PAIR_PENDING_READ_ATTEMPTS, + CONNECT_AUTO_PAIR_PENDING_READ_POLL_S, + CONNECT_AUTO_PAIR_POST_TIMEOUT_OBSERVE_S, +} from "./connect-autopair-budget"; // 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 @@ -55,12 +60,11 @@ export const AUTO_PAIR_APPROVAL_TIMEOUT_MS = 12_000; // Default per-call budgets (seconds) for the in-sandbox openclaw subcommands. const AUTO_PAIR_LIST_TIMEOUT_S = 2; const AUTO_PAIR_APPROVE_TIMEOUT_S = 1; -const AUTO_PAIR_POST_TIMEOUT_OBSERVE_S = 4; const AUTO_PAIR_POST_TIMEOUT_POLL_S = 0.1; // Per-surface budget overrides. The connect/probe/finalization surfaces (#4504) // supply a tighter budget — a single realistic pending CLI/webchat scope -// upgrade (maxApprovals = 1) on the watcher's 10s approve budget with a 15s +// upgrade (maxApprovals = 1) on the watcher's 10s approve budget with a 25s // outer cap — via ./connect-autopair-budget. The doctor surface (#4616) uses // the defaults above to drain a backlog. Callers that omit a field inherit the // default, so the historical doctor payload stays byte-stable. @@ -89,10 +93,27 @@ export type AutoPairApprovalResult = { receipt: AutoPairApprovalReceipt | null; }; +type AutoPairApprovalExecDeps = { + getOpenshellBinary: () => string; + spawnSync: typeof spawnSync; +}; + export type AutoPairApprovalReceipt = | "policy-missing" | "exec-failed" + | "exec-timeout" + | "exec-spawn-failed" + | "exec-command-failed" + | "exec-signal" + | "exec-invalid-receipt" | "list-failed" + | "list-state-path-invalid" + | "list-platform-unsupported" + | "list-state-root-failed" + | "list-devices-directory-failed" + | "list-pending-unsafe" + | "list-pending-unstable" + | "list-pending-invalid-shape" | "list-pending-unavailable" | "list-timeout" | "list-exec-failed" @@ -111,7 +132,7 @@ export type AutoPairApprovalReceipt = | "approved-one"; const AUTO_PAIR_RECEIPT_LINE_RE = - /^__NEMOCLAW_AUTO_PAIR_RECEIPT__=(policy-missing|exec-failed|list-failed|list-pending-unavailable|list-timeout|list-exec-failed|list-scope-upgrade-pending|list-device-pairing-required|list-gateway-connect-failed|list-command-failed|list-empty-output|list-invalid-json|list-invalid-output|list-missing-pending|clone-no-match|clone-ambiguous|request-rejected|approve-failed|approved-one)$/; + /^__NEMOCLAW_AUTO_PAIR_RECEIPT__=(policy-missing|exec-failed|list-failed|list-state-path-invalid|list-platform-unsupported|list-state-root-failed|list-devices-directory-failed|list-pending-unsafe|list-pending-unstable|list-pending-invalid-shape|list-pending-unavailable|list-timeout|list-exec-failed|list-scope-upgrade-pending|list-device-pairing-required|list-gateway-connect-failed|list-command-failed|list-empty-output|list-invalid-json|list-invalid-output|list-missing-pending|clone-no-match|clone-ambiguous|request-rejected|approve-failed|approved-one)$/; /** * Parse one fixed receipt only when it is the sole receipt and terminal output @@ -128,6 +149,35 @@ export function parseAutoPairApprovalReceipt(output: string): AutoPairApprovalRe return (match?.[1] as AutoPairApprovalReceipt | undefined) ?? null; } +type AutoPairApprovalExecResult = { + error?: Error; + status: number | null; + signal?: NodeJS.Signals | null; +}; + +/** + * Collapse host-side sandbox-exec failures into fixed, non-secret receipts. + * Command output, error messages, signal names, and identifiers stay private. + */ +export function classifyAutoPairApprovalExecReceipt( + result: AutoPairApprovalExecResult, + output: string, +): AutoPairApprovalReceipt { + if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT") { + return "exec-timeout"; + } + if (result.error) { + return "exec-spawn-failed"; + } + if (result.signal) { + return "exec-signal"; + } + if (result.status !== 0) { + return "exec-command-failed"; + } + return parseAutoPairApprovalReceipt(output) ?? "exec-invalid-receipt"; +} + export function readAutoPairApprovalPolicyModule(): string | null { try { return readFileSync(AUTO_PAIR_POLICY_PATH, "utf-8"); @@ -281,7 +331,7 @@ def exit_with_receipt(receipt): approve_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None) approve_env.pop('NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', None) local_paired_operator_token = '' - observe_deadline = time.monotonic() + ${AUTO_PAIR_POST_TIMEOUT_OBSERVE_S} + observe_deadline = time.monotonic() + ${CONNECT_AUTO_PAIR_POST_TIMEOUT_OBSERVE_S} while not sync_approved_clone_device_auth(device, previous_approval_token): remaining_observe_time = observe_deadline - time.monotonic() if remaining_observe_time <= 0: @@ -319,32 +369,54 @@ def exit_with_receipt(receipt): # Removal condition: delete this path when the pinned OpenClaw release exposes # that bootstrap/list API for an unpaired clone. import stat +import time state_dir = os.environ.get('OPENCLAW_STATE_DIR') or '/sandbox/.openclaw' if not os.path.isabs(state_dir): - ${exitWithReceipt("list-failed")} + ${exitWithReceipt("list-state-path-invalid")} for required_flag in ('O_DIRECTORY', 'O_NOFOLLOW'): if not hasattr(os, required_flag): - ${exitWithReceipt("list-failed")} + ${exitWithReceipt("list-platform-unsupported")} clone_directory_flags = ( os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0) ) +clone_path_flags = ( + getattr(os, 'O_PATH', os.O_RDONLY) + | os.O_DIRECTORY + | os.O_NOFOLLOW + | getattr(os, 'O_CLOEXEC', 0) +) clone_file_flags = ( os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0) ) +PENDING_READ_ATTEMPTS = ${CONNECT_AUTO_PAIR_PENDING_READ_ATTEMPTS} +PENDING_READ_POLL_S = ${CONNECT_AUTO_PAIR_PENDING_READ_POLL_S} + +class CloneStateEntryRotated(OSError): + pass + +def validate_clone_json_descriptor(fd): + metadata = os.fstat(fd) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink > 1: + raise OSError('clone state entry is not a single regular file') + if metadata.st_nlink == 0: + raise CloneStateEntryRotated('clone state entry was replaced after open') def open_clone_state_root(): - root_fd = os.open(os.sep, clone_directory_flags) + # Ancestors such as / are traversal boundaries, not state directories. + # OpenShell's restored-clone policy permits path traversal but intentionally + # denies a read-directory handle for the whole filesystem root. + root_fd = os.open(os.sep, clone_path_flags) try: for component in (part for part in state_dir.split(os.sep) if part): if component in ('.', '..'): raise OSError('unsafe clone state root') next_fd = os.open( component, - clone_directory_flags, + clone_path_flags, dir_fd=root_fd, ) os.close(root_fd) @@ -357,7 +429,7 @@ def open_clone_state_root(): try: clone_state_dir_fd = open_clone_state_root() except OSError: - ${exitWithReceipt("list-failed")} + ${exitWithReceipt("list-state-root-failed")} def clone_state_root_is_current(): try: @@ -410,11 +482,10 @@ def open_clone_json_descriptor(directory_fd, directory_name, entry_name): raise OSError('unsafe clone state entry') fd = os.open(entry_name, clone_file_flags, dir_fd=directory_fd) try: - metadata = os.fstat(fd) - if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: - raise OSError('clone state entry is not a single regular file') + validate_clone_json_descriptor(fd) with os.fdopen(os.dup(fd), encoding='utf-8') as handle: parsed = json.load(handle) + validate_clone_json_descriptor(fd) os.lseek(fd, 0, os.SEEK_SET) if not clone_directory_is_current(directory_name, directory_fd): raise OSError('clone state directory changed') @@ -431,19 +502,35 @@ def read_clone_json(directory_fd, directory_name, entry_name): try: clone_devices_dir_fd = open_clone_directory('devices') except OSError: - ${exitWithReceipt("list-failed")} -try: - local_pending_by_id, clone_pending_snapshot_fd = open_clone_json_descriptor( - clone_devices_dir_fd, - 'devices', - 'pending.json', - ) -except FileNotFoundError: - ${exitWithReceipt("list-pending-unavailable")} -except (OSError, ValueError): - ${exitWithReceipt("list-failed")} -if not isinstance(local_pending_by_id, dict): - ${exitWithReceipt("list-failed")} + ${exitWithReceipt("list-devices-directory-failed")} +# The gateway can publish pending.json immediately after the warm-up. Retry only +# a missing entry before publication, invalid JSON during truncate/write, or an +# opened inode that an atomic replace unlinked. Unsafe filesystem shapes and +# persistently malformed documents fail closed before request selection. +pending_read_failure = 'unavailable' +for pending_read_attempt in range(PENDING_READ_ATTEMPTS): + try: + local_pending_by_id, clone_pending_snapshot_fd = open_clone_json_descriptor( + clone_devices_dir_fd, + 'devices', + 'pending.json', + ) + except FileNotFoundError: + pending_read_failure = 'unavailable' + except (CloneStateEntryRotated, json.JSONDecodeError): + pending_read_failure = 'unstable' + except (OSError, ValueError): + ${exitWithReceipt("list-pending-unsafe")} + else: + if not isinstance(local_pending_by_id, dict): + ${exitWithReceipt("list-pending-invalid-shape")} + break + if pending_read_attempt + 1 < PENDING_READ_ATTEMPTS: + time.sleep(PENDING_READ_POLL_S) +else: + if pending_read_failure == 'unavailable': + ${exitWithReceipt("list-pending-unavailable")} + ${exitWithReceipt("list-pending-unstable")} pending = list(local_pending_by_id.values()) ` : ` @@ -969,6 +1056,7 @@ export function runSandboxAutoPairApprovalPass( budget?: AutoPairApprovalBudget; localDeviceOnly?: boolean; } = {}, + execDeps?: AutoPairApprovalExecDeps, ): AutoPairApprovalResult { const emitReceipt = options.receipt === true && options.localDeviceOnly === true; const capture = options.capture === true || emitReceipt; @@ -992,26 +1080,33 @@ export function runSandboxAutoPairApprovalPass( // Lazy require: `adapters/openshell/runtime` pulls in `runner`, whose // load-time `require("./platform")` cannot be resolved by the Vitest TS // loader. Importing it here keeps this module unit-testable in-process. - const { getOpenshellBinary } = - require("../../adapters/openshell/runtime") as typeof import("../../adapters/openshell/runtime"); + const deps = + execDeps ?? + (() => { + const { getOpenshellBinary } = + require("../../adapters/openshell/runtime") as typeof import("../../adapters/openshell/runtime"); + return { getOpenshellBinary, spawnSync }; + })(); try { - const result = spawnSync( - getOpenshellBinary(), - ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], + // The restored-clone program is larger than OpenShell's command-argument + // transport boundary. Use the repository's supported stdin execution path + // so script growth cannot fail before Python emits its fixed receipt. + const result = deps.spawnSync( + deps.getOpenshellBinary(), + ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-s"], { cwd: ROOT, env: process.env, - stdio: capture ? ["ignore", "pipe", "pipe"] : ["ignore", "ignore", "ignore"], + input: script, + stdio: capture ? ["pipe", "pipe", "pipe"] : ["pipe", "ignore", "ignore"], encoding: "utf-8", timeout: outerTimeoutMs, }, ); const output = String(result.stdout || ""); - const receipt: AutoPairApprovalReceipt | null = !emitReceipt - ? null - : result.error || result.status !== 0 || result.signal - ? "exec-failed" - : (parseAutoPairApprovalReceipt(output) ?? "exec-failed"); + const receipt: AutoPairApprovalReceipt | null = emitReceipt + ? classifyAutoPairApprovalExecReceipt(result, output) + : null; if (!options.capture) { return { attempted: true, reported: false, approved: 0, receipt }; } diff --git a/src/lib/actions/sandbox/connect-autopair-budget.test.ts b/src/lib/actions/sandbox/connect-autopair-budget.test.ts index 8c3fc446da3..73eccb0d00b 100644 --- a/src/lib/actions/sandbox/connect-autopair-budget.test.ts +++ b/src/lib/actions/sandbox/connect-autopair-budget.test.ts @@ -6,16 +6,22 @@ import { CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S, CONNECT_AUTO_PAIR_LIST_TIMEOUT_S, CONNECT_AUTO_PAIR_MAX_APPROVALS, + CONNECT_AUTO_PAIR_PENDING_READ_ATTEMPTS, + CONNECT_AUTO_PAIR_PENDING_READ_POLL_S, + CONNECT_AUTO_PAIR_POST_TIMEOUT_OBSERVE_S, CONNECT_AUTO_PAIR_TIMEOUT_MS, } from "./connect-autopair-budget"; -// Worst-case time the in-sandbox script can legitimately spend inside the outer -// spawnSync timer: one `devices list` plus up to MAX_APPROVALS `devices approve` -// calls, each at its full budget. Expressed in ms to compare against the outer cap. -const innerWorstCaseMs = +const ordinaryInnerWorstCaseMs = (CONNECT_AUTO_PAIR_LIST_TIMEOUT_S + CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S * CONNECT_AUTO_PAIR_MAX_APPROVALS) * 1000; +const restoredCloneInnerWorstCaseMs = + ((CONNECT_AUTO_PAIR_PENDING_READ_ATTEMPTS - 1) * CONNECT_AUTO_PAIR_PENDING_READ_POLL_S + + CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S + + CONNECT_AUTO_PAIR_POST_TIMEOUT_OBSERVE_S) * + 1000; +const innerWorstCaseMs = Math.max(ordinaryInnerWorstCaseMs, restoredCloneInnerWorstCaseMs); describe("connect auto-pair budget", () => { it("keeps the outer spawnSync cap above the worst-case inner runtime", () => { @@ -24,21 +30,25 @@ describe("connect auto-pair budget", () => { expect(CONNECT_AUTO_PAIR_TIMEOUT_MS).toBeGreaterThan(innerWorstCaseMs); }); - it("leaves headroom for shell/python startup before the inner loop begins", () => { - // The outer timer starts at `sh` spawn, before proxy env is sourced and - // python3 launches. The module documents 5 seconds of slack for that startup. - expect(CONNECT_AUTO_PAIR_TIMEOUT_MS - innerWorstCaseMs).toBeGreaterThanOrEqual(5000); + it("keeps 10 seconds beyond inner work for OpenShell and interpreter startup", () => { + expect(CONNECT_AUTO_PAIR_TIMEOUT_MS - innerWorstCaseMs).toBeGreaterThanOrEqual(10_000); }); - it("uses positive, whole-number budgets", () => { + it("uses positive whole numbers for attempt, command, and outer budgets", () => { for (const value of [ CONNECT_AUTO_PAIR_MAX_APPROVALS, CONNECT_AUTO_PAIR_LIST_TIMEOUT_S, CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S, + CONNECT_AUTO_PAIR_PENDING_READ_ATTEMPTS, + CONNECT_AUTO_PAIR_POST_TIMEOUT_OBSERVE_S, CONNECT_AUTO_PAIR_TIMEOUT_MS, ]) { expect(Number.isInteger(value)).toBe(true); expect(value).toBeGreaterThan(0); } }); + + it("uses a positive pending-read poll interval", () => { + expect(CONNECT_AUTO_PAIR_PENDING_READ_POLL_S).toBeGreaterThan(0); + }); }); diff --git a/src/lib/actions/sandbox/connect-autopair-budget.ts b/src/lib/actions/sandbox/connect-autopair-budget.ts index bf3396f6b90..55fe761f6d5 100644 --- a/src/lib/actions/sandbox/connect-autopair-budget.ts +++ b/src/lib/actions/sandbox/connect-autopair-budget.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 // SOURCE_OF_TRUTH_REVIEW: Budget constants for the connect-time auto-pair scope-approval pass -// (runConnectAutoPairApprovalPass in ./connect). Kept in a dependency-free leaf -// module so tests can import and assert the invariant on the real values -// without pulling in connect.ts's heavy transitive requires (#4504). +// (runConnectAutoPairApprovalPass in ./connect). Kept in a lightweight module +// so tests can import and assert the invariant on the real values without +// pulling in connect.ts's heavy transitive requires (#4504). export const CONNECT_AUTO_PAIR_MAX_APPROVALS = 1; // `openclaw devices list` budget (seconds), interpolated into the in-sandbox @@ -17,10 +17,17 @@ export const CONNECT_AUTO_PAIR_LIST_TIMEOUT_S = 5; // `openclaw devices approve` budget (seconds); matches the in-sandbox watcher's // RUN_TIMEOUT_SECS = 10 (nemoclaw-start.sh). export const CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S = 10; +// Bounds for reading restored-clone pending state while the agent gateway +// publishes it, and for observing a timed-out approval. These feed the +// in-sandbox script and the outer-cap invariant below so the budgets cannot +// drift apart. +export const CONNECT_AUTO_PAIR_PENDING_READ_ATTEMPTS = 10; +export const CONNECT_AUTO_PAIR_PENDING_READ_POLL_S = 0.1; +export const CONNECT_AUTO_PAIR_POST_TIMEOUT_OBSERVE_S = 4; // Outer spawnSync cap (ms). Must exceed the internal worst case -// (CONNECT_AUTO_PAIR_LIST_TIMEOUT_S + CONNECT_AUTO_PAIR_APPROVE_TIMEOUT_S × -// CONNECT_AUTO_PAIR_MAX_APPROVALS) PLUS shell/python startup, since the outer -// timer starts at `sh` spawn before the proxy env is sourced and python3 -// launches; the 5s slack prevents the outer timeout from terminating a -// legitimate slow approve mid-loop, which would strand the allowlisted request. -export const CONNECT_AUTO_PAIR_TIMEOUT_MS = 20_000; +// for either ordinary listing or restored-clone publication and observation. +// The outer timer starts at `openshell sandbox exec`, before the remote shell +// sources the proxy environment and launches Python. Keep 10s beyond the longer +// inner path so the outer timer cannot terminate a legitimate approval before +// its fixed receipt is returned. +export const CONNECT_AUTO_PAIR_TIMEOUT_MS = 25_000; diff --git a/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts b/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts index 7b7a9e991d2..be9ce1d2e8d 100644 --- a/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts +++ b/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts @@ -1354,7 +1354,7 @@ test("keeps issue 4462 scope-upgrade approval on the gateway path without an adm await artifacts.writeText(`phase-2-fresh-agent-${attempt}.txt`, freshAgentOutput); expect(freshAgent.exitCode, freshAgentOutput).toBe(0); expect(freshAgentOutput).not.toMatch( - /EMBEDDED FALLBACK|gateway connect failed|scope upgrade pending approval|device pairing required|pairing required|fallbackFrom[": ]+gateway|transport[": ]+embedded/i, + /EMBEDDED FALLBACK|gateway connect failed|scope upgrade pending approval|scope-upgrade-pending|approval=list-failed|device pairing required|pairing required|fallbackFrom[": ]+gateway|transport[": ]+embedded/i, ); expect(freshAgent.stdout.trim(), freshAgentOutput).not.toBe(""); diff --git a/test/e2e/live/snapshot-commands.test.ts b/test/e2e/live/snapshot-commands.test.ts index ad0baef15f1..d13efbca540 100644 --- a/test/e2e/live/snapshot-commands.test.ts +++ b/test/e2e/live/snapshot-commands.test.ts @@ -532,7 +532,6 @@ test("snapshot commands preserve create/list/latest restore/targeted restore/no- }, ); const cloneRestoreResult = classifySnapshotRestoreResult(cloneRestore); - expect(["restored", "restored-pairing-unverified"]).toContain(cloneRestoreResult); progress.phase("verify the restored clone state and gateway pairing"); expect(cloneRestoreResult).toBe("restored"); await expectSandboxFileContent( diff --git a/test/sandbox-connect-inference/auto-pair-approval.test.ts b/test/sandbox-connect-inference/auto-pair-approval.test.ts index 7ba528e9286..836f2df8619 100644 --- a/test/sandbox-connect-inference/auto-pair-approval.test.ts +++ b/test/sandbox-connect-inference/auto-pair-approval.test.ts @@ -19,13 +19,14 @@ import { setupFixture, } from "./helpers"; -function findApprovalExec(sandboxExecCalls: string[][]): string[] | undefined { - // OpenShell carries the approval pass as one multiline command argument. - return sandboxExecCalls.find((call) => { - if (!call.includes("--")) return false; - const inner = call[call.length - 1] || ""; - return inner.includes("openclaw") && inner.includes("devices") && inner.includes("approve"); - }); +function findApprovalExec(state: { + sandboxExecCalls: string[][]; + sandboxExecInputs: string[]; +}): string[] | undefined { + const approvalIndex = state.sandboxExecInputs.findIndex( + (input) => input.includes("openclaw") && input.includes("devices") && input.includes("approve"), + ); + return state.sandboxExecCalls[approvalIndex]; } function findGatewayControlExec(dockerCalls: string[][]): string[] | undefined { @@ -232,11 +233,7 @@ describe("sandbox connect auto-pair approval pass (#4263)", () => { 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) => { - if (!call.includes("--")) return false; - const inner = call[call.length - 1] || ""; - return inner.includes("openclaw") && inner.includes("devices") && inner.includes("approve"); - }); + const approvalExec = findApprovalExec(state); expect(approvalExec).toBeDefined(); // Despite the approval-pass failure, SSH handoff still happens. expect(state.sandboxConnectCalls).toContainEqual(["sandbox", "connect", sandboxName]); @@ -291,7 +288,7 @@ describe("sandbox connect scope-upgrade approval on recover/probe (#4504)", () = expect(controlExec).toContain("PYTHONNOUSERSITE=1"); expect(controlExec?.[userIndex + 5]).toMatch(/^[0-9a-f]{64}$/); expect(state.gatewayRunning).toBe(true); - const approvalExec = findApprovalExec(state.sandboxExecCalls as string[][]); + const approvalExec = findApprovalExec(state); expect(approvalExec).toBeDefined(); expect(approvalExec).toContain("sandbox"); expect(approvalExec).toContain("exec"); @@ -327,7 +324,7 @@ describe("sandbox connect scope-upgrade approval on recover/probe (#4504)", () = expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); - const approvalExec = findApprovalExec(state.sandboxExecCalls as string[][]); + const approvalExec = findApprovalExec(state); expect(approvalExec).toBeDefined(); }, ); @@ -358,7 +355,7 @@ describe("sandbox connect scope-upgrade approval on recover/probe (#4504)", () = expect(result.status).toBe(1); const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); - const approvalExec = findApprovalExec(state.sandboxExecCalls as string[][]); + const approvalExec = findApprovalExec(state); expect(approvalExec).toBeUndefined(); // And it never opens an SSH session on the failure path. expect(state.sandboxConnectCalls).toEqual([]); diff --git a/test/sandbox-connect-inference/helpers.ts b/test/sandbox-connect-inference/helpers.ts index 08e2b0d2d20..899b18d7a7c 100644 --- a/test/sandbox-connect-inference/helpers.ts +++ b/test/sandbox-connect-inference/helpers.ts @@ -176,6 +176,7 @@ function initStateFile(stateFile: string, options: SetupFixtureOptions) { inferenceSetCalls: [], sandboxConnectCalls: [], sandboxExecCalls: [], + sandboxExecInputs: [], gatewayControlCalls: [], gatewaySupervisorRecovery: options.gatewaySupervisorRecovery ?? false, gatewayRunning: options.gatewaySupervisorRecovery !== true, @@ -224,15 +225,17 @@ if (args[0] === "sandbox" && args[1] === "list") { } if (args[0] === "sandbox" && args[1] === "exec") { + const input = fs.readFileSync(0, "utf8"); state.sandboxExecCalls.push(args); - const command = args.join(" "); + state.sandboxExecInputs.push(input); + const command = [args.join(" "), input].filter(Boolean).join("\\n"); 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. OpenShell carries the script as - // one multiline command argument, identifiable by its embedded approval. - const approvalCmd = args[args.length - 1] || ""; + // path the production code must tolerate. The approval program is carried + // on stdin so it does not exceed OpenShell command-argument transport. + const approvalCmd = input; if ( process.env.OPENSHELL_TEST_FAIL_APPROVAL_PASS === "1" && approvalCmd.includes("openclaw") && @@ -580,18 +583,19 @@ export function runConnect( export function extractApprovalPassScript(stateFile: string, sandboxName: string): string { const state = JSON.parse(fs.readFileSync(stateFile, "utf-8")); - // OpenShell carries the approval pass as one multiline command argument. - const approvalExec = (state.sandboxExecCalls as string[][]).find((call) => { - if (!call.includes("--")) return false; - const inner = call[call.length - 1] || ""; - return inner.includes("openclaw") && inner.includes("devices") && inner.includes("approve"); - }); + const approvalIndex = (state.sandboxExecInputs as string[]).findIndex( + (input) => input.includes("openclaw") && input.includes("devices") && input.includes("approve"), + ); + const approvalExec = (state.sandboxExecCalls as string[][])[approvalIndex]; + const approvalScript = (state.sandboxExecInputs as string[])[approvalIndex]; expect(approvalExec).toBeDefined(); expect(approvalExec).toContain("sandbox"); expect(approvalExec).toContain("exec"); expect(approvalExec).toContain("--name"); expect(approvalExec).toContain(sandboxName); - return approvalExec?.[approvalExec.length - 1] || ""; + expect(approvalExec?.slice(-2)).toEqual(["sh", "-s"]); + expect(approvalExec?.join(" ")).not.toContain("PYAPPROVE"); + return approvalScript || ""; } export function runApprovalPassScript(