diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 8c0d6026607..2da0694d0bd 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3576,14 +3576,9 @@ Session JSONL can contain pasted secrets, such as API keys or tokens, so exporte The in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes. If the staging cleanup fails, the command warns with the retained path and a manual removal command. The retained artifact can contain session JSONL with pasted secrets. -Run the exact command from the warning, which has this form: - -```bash -openshell sandbox exec --name -- rm -f -``` +Run the exact removal command from the warning, then inspect that same retained path to confirm it no longer exists. The export keeps its original success or failure result, so a cleanup warning after a successful download does not make the export fail. -Confirm that the command prints no further cleanup warning after a later export. @@ -3612,14 +3607,9 @@ Session JSONL can contain pasted secrets, such as API keys or tokens, so exporte The in-sandbox staging artefact is additionally created with `umask 077` and removed after the host download completes. If the staging cleanup fails, the command warns with the retained path and a manual removal command. The retained artifact can contain session JSONL with pasted secrets. -Run the exact command from the warning, which has this form: - -```bash -openshell sandbox exec --name -- rm -f -``` +Run the exact removal command from the warning, then inspect that same retained path to confirm it no longer exists. The export keeps its original success or failure result, so a cleanup warning after a successful download does not make the export fail. -Confirm that the command prints no further cleanup warning after a later export. diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index 7d7eb5fd647..a8ec7284bf4 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -9,10 +9,12 @@ import { describe, expect, it } from "vitest"; import { RESTORED_CLONE_WARMUP_SCRIPT, + sandboxWarmupExecArgs, WARMUP_PROBE_TIMEOUT_S, WARMUP_SCRIPT, WARMUP_TIMEOUT_MS, } from "./auto-pair-warmup"; +import { buildTrustedProxyEnvSourceShell } from "./trusted-proxy-env"; import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; const shAvailable = spawnSync("sh", ["-c", "exit 0"], { encoding: "utf-8" }).status === 0; @@ -29,14 +31,14 @@ const itWithSh = shAvailable ? it : it.skip; // spawn/wiring path to the `test/sandbox-connect-inference/` integration // harness (real compiled CLI + fake openshell on PATH). These cases therefore // pin the contract surface that IS testable in-process — the timeout bound and -// the OpenShell-exec wrapping the leaf depends on — and the -// finalization.test.ts ordering tests pin the provoke→approve wiring. +// the OpenShell-exec wrapping the leaf depends on — and the finalization tests +// pin the producer→observation→approval wiring. describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => { it("uses a fixed 30s outer cap so a wedged warm-up can never block onboard", () => { // The direct call performs no inference work. Thirty seconds covers gateway - // connection, the scope-upgrade request, the bounded list poll, and shell - // startup while still bounding a hung sandbox. + // connection, the scope-upgrade request, and shell startup while still + // bounding a hung sandbox. expect(WARMUP_TIMEOUT_MS).toBe(30_000); expect(typeof WARMUP_TIMEOUT_MS).toBe("number"); expect(WARMUP_TIMEOUT_MS).toBeGreaterThan(0); @@ -53,11 +55,23 @@ describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => { }); describe("warm-up payload uses native multiline OpenShell exec in v2 (#4504)", () => { - it("keeps the real warm-up as one multiline command argument", () => { + it("keeps the real warm-up as one multiline command on the owning gateway (#10014)", () => { expect(WARMUP_SCRIPT).toContain("\n"); expect(WARMUP_SCRIPT).toContain("command -v openclaw"); expect(WARMUP_SCRIPT).not.toContain("base64 -d"); expect(WARMUP_SCRIPT).not.toContain("mktemp"); + expect(sandboxWarmupExecArgs("alpha", "nemoclaw-19000", WARMUP_SCRIPT)).toEqual([ + "sandbox", + "exec", + "--name", + "alpha", + "-g", + "nemoclaw-19000", + "--", + "sh", + "-c", + WARMUP_SCRIPT, + ]); }); itWithSh("runs a multiline warm-up-shaped payload and preserves its exit-0 status", () => { @@ -115,6 +129,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( "export OPENCLAW_GATEWAY_PORT=18789", "", ].join("\n"), + { mode: 0o444 }, ); fs.writeFileSync( path.join(binDir, "openclaw"), @@ -135,7 +150,11 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( ); try { - const script = RESTORED_CLONE_WARMUP_SCRIPT.replace("/tmp/nemoclaw-proxy-env.sh", proxyEnv); + expect(RESTORED_CLONE_WARMUP_SCRIPT).toContain(buildTrustedProxyEnvSourceShell()); + const script = RESTORED_CLONE_WARMUP_SCRIPT.replace( + buildTrustedProxyEnvSourceShell(), + buildTrustedProxyEnvSourceShell(proxyEnv), + ); const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", env: { @@ -155,63 +174,61 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( } }); - it("scopes forced device pairing to the provoke command on OpenClaw 2026.7.1", () => { - const [provoke, poll] = WARMUP_SCRIPT.split("i=0\nwhile", 2); - expect(provoke.match(/NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1/g)).toHaveLength(1); - expect(poll).not.toContain("NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1"); + it("scopes forced device pairing to the request producer on OpenClaw 2026.7.1", () => { + expect(WARMUP_SCRIPT.match(/NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1/g)).toHaveLength(1); expect(WARMUP_SCRIPT).not.toContain("export NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING"); }); itWithSh( - "polls after a hung direct probe reaches its own timeout (#9844)", + "bounds a hung direct request without polling pairing state (#10014)", () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-timeout-")); const binDir = path.join(fixtureRoot, "bin"); - const proxyEnv = path.join(fixtureRoot, "proxy-env.sh"); - const pollLog = path.join(fixtureRoot, "poll.log"); + const callLog = path.join(fixtureRoot, "call.log"); fs.mkdirSync(binDir); - fs.writeFileSync(proxyEnv, ""); fs.writeFileSync( path.join(binDir, "openclaw"), [ "#!/bin/sh", + 'printf \'%s\\n\' "$*" >> "$NEMOCLAW_TEST_CALL_LOG"', 'if [ "${1:-}" = "gateway" ]; then', " exec sleep 60", "fi", - "printf 'poll\\n' > \"$NEMOCLAW_TEST_POLL_LOG\"", - 'printf \'%s\\n\' \'{"pending":[{"scopes":["operator.write"]}],"paired":[]}\'', + "exit 64", "", ].join("\n"), { mode: 0o700 }, ); try { - const script = WARMUP_SCRIPT.replace("/tmp/nemoclaw-proxy-env.sh", proxyEnv); - const result = spawnSync("sh", ["-c", script], { + const result = spawnSync("sh", ["-c", WARMUP_SCRIPT], { encoding: "utf-8", env: { ...process.env, - NEMOCLAW_TEST_POLL_LOG: pollLog, + NEMOCLAW_TEST_CALL_LOG: callLog, PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, }, timeout: 12_000, }); expect(result.status, result.stderr).toBe(0); - expect(fs.readFileSync(pollLog, "utf8")).toBe("poll\n"); + expect(fs.readFileSync(callLog, "utf8")).toMatch( + /^gateway call sessions\.create --params \{"key":"agent:main:nemoclaw-onboard-warmup-\d+-\d+","agentId":"main"\} --json\n$/, + ); + expect(fs.readFileSync(callLog, "utf8")).not.toContain("devices list"); } finally { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } }, - 12_000, + 20_000, ); - itWithSh("polls the pending upgrade with pairing-only stored device auth (#9844)", () => { - const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-poll-")); + itWithSh("uses device auth after consuming the trusted proxy environment (#10014)", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-producer-")); const binDir = path.join(fixtureRoot, "bin"); const proxyEnv = path.join(fixtureRoot, "proxy-env.sh"); - const pollEnvLog = path.join(fixtureRoot, "poll-env.log"); - const provokeEnvLog = path.join(fixtureRoot, "provoke-env.log"); + const sourceLog = path.join(fixtureRoot, "source.log"); + const callLog = path.join(fixtureRoot, "call.log"); fs.mkdirSync(binDir); fs.writeFileSync( proxyEnv, @@ -223,70 +240,82 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( "export NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=ambient-force-marker", "export NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING=ambient-clone-marker", "export NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT=ambient-settlement-marker", + 'printf \'consumed\\n\' > "$NEMOCLAW_TEST_PROXY_SOURCE_LOG"', "", ].join("\n"), + { mode: 0o444 }, ); fs.writeFileSync( path.join(binDir, "openclaw"), - [ - "#!/bin/sh", - 'if [ "${1:-}" = "gateway" ]; then', - " {", - " printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"", - " printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"", - " printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"", - " printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"", - " printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"", - " printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"", - " printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"", - " printf 'argv=%s\\n' \"$*\"", - ' } > "$NEMOCLAW_TEST_PROVOKE_ENV_LOG"', - " exit 1", - "fi", - "{", - " printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"", - " printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"", - " printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"", - " printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"", - " printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"", - " printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"", - " printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"", - '} > "$NEMOCLAW_TEST_POLL_ENV_LOG"', - 'printf \'%s\\n\' \'{"pending":[{"scopes":["operator.write"]}],"paired":[]}\'', - "", - ].join("\n"), + [ + "#!/bin/sh", + "{", + " printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"", + " printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"", + " printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"", + " printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"", + " printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"", + " printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"", + " printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"", + " printf 'argv=%s\\n' \"$*\"", + '} > "$NEMOCLAW_TEST_CALL_LOG"', + "exit 1", + "", + ].join("\n"), { mode: 0o700 }, ); try { - const script = WARMUP_SCRIPT.replace("/tmp/nemoclaw-proxy-env.sh", proxyEnv); + const script = WARMUP_SCRIPT.replace( + buildTrustedProxyEnvSourceShell(), + buildTrustedProxyEnvSourceShell(proxyEnv), + ); const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", env: { ...process.env, - NEMOCLAW_TEST_POLL_ENV_LOG: pollEnvLog, - NEMOCLAW_TEST_PROVOKE_ENV_LOG: provokeEnvLog, + NEMOCLAW_TEST_CALL_LOG: callLog, + NEMOCLAW_TEST_PROXY_SOURCE_LOG: sourceLog, PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, }, timeout: 10_000, }); expect(result.status, result.stderr).toBe(0); - expect(fs.readFileSync(provokeEnvLog, "utf8")).toMatch( + expect(fs.readFileSync(sourceLog, "utf8")).toBe("consumed\n"); + expect(fs.readFileSync(callLog, "utf8")).toMatch( /^url=unset\nport=unset\ntoken=unset\npassword=unset\nforce=1\nrestored=unset\nsettlement=unset\nargv=gateway call sessions\.create --params \{"key":"agent:main:nemoclaw-onboard-warmup-\d+-\d+","agentId":"main"\} --json\n$/, ); - expect(fs.readFileSync(pollEnvLog, "utf8")).toBe( + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("rejects unsafe proxy source paths before a warm-up child can read credentials (#10014)", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-unsafe-proxy-")); + const unsafeProxy = path.join(fixtureRoot, "proxy-env.sh"); + const evaluated = path.join(fixtureRoot, "evaluated"); + fs.writeFileSync(unsafeProxy, `printf evaluated > ${evaluated}\n`, { mode: 0o666 }); + + try { + const result = spawnSync( + "sh", [ - "url=unset", - "port=unset", - "token=unset", - "password=unset", - "force=unset", - "restored=unset", - "settlement=1", - "", - ].join("\n"), + "-c", + WARMUP_SCRIPT.replace( + buildTrustedProxyEnvSourceShell(), + buildTrustedProxyEnvSourceShell(unsafeProxy), + ), + ], + { + encoding: "utf8", + env: { ...process.env, OPENCLAW_GATEWAY_TOKEN: "inherited-secret" }, + timeout: 10_000, + }, ); + expect(result.status).toBe(126); + expect(fs.existsSync(evaluated)).toBe(false); + expect(result.stderr).not.toContain("inherited-secret"); } finally { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } @@ -294,6 +323,7 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( it("uses a direct write-scope probe without an embedded inference fallback (#9844)", () => { expect(WARMUP_SCRIPT).not.toContain("openclaw agent"); + expect(WARMUP_SCRIPT).not.toContain("devices list"); expect(WARMUP_SCRIPT).not.toContain("setsid"); expect(WARMUP_SCRIPT).not.toContain("WARMUP_AGENT_PID"); expect(WARMUP_SCRIPT).not.toContain("warmup_cleanup_attempt"); diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index 490548b91db..52238be69f4 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -36,41 +36,22 @@ import { spawnSync } from "node:child_process"; import { ROOT } from "../../state/paths"; +import { buildTrustedProxyEnvSourceShell } from "./trusted-proxy-env"; import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; -// Outer spawnSync cap (ms) for the direct write-scope probe and its bounded -// pending-upgrade poll. The cap prevents a wedged sandbox from blocking onboard -// or restore. +// Outer spawnSync cap (ms) for the direct write-scope probe. The cap prevents a +// wedged sandbox from blocking onboard or restore. export const WARMUP_TIMEOUT_MS = 30_000; export const WARMUP_PROBE_TIMEOUT_S = 5; -// Bounded in-sandbox poll for the pending scope upgrade after the provoke run. -// Worst case = WARMUP_POLL_ATTEMPTS × WARMUP_POLL_LIST_TIMEOUT_S list calls plus -// (WARMUP_POLL_ATTEMPTS - 1) inter-attempt 1s sleeps = 5×2 + 4×1 = 14s, which -// plus the 5s direct-probe timeout consume at most 19s. This leaves clear -// headroom under WARMUP_TIMEOUT_MS (30s) for shell and Python startup. The -// gateway persists the upgrade requestId once created (#4504 evidence), so -// once the poll sees it pending, the downstream approval pass finds and -// approves it before -// handoff — making "very first real run, zero fallback" deterministic even on -// slow/contended gateways. -export const WARMUP_POLL_ATTEMPTS = 5; -export const WARMUP_POLL_LIST_TIMEOUT_S = 2; - -// Best-effort in-sandbox warm-up script. Always exits 0. It connects to the -// gateway and provokes the `operator.write` scope-upgrade so the request is -// PENDING, then POLLS `devices list` until that allowlisted upgrade is visible -// (or the bounded deadline elapses) before returning, closing the race where -// the approval pass that runs immediately after could otherwise list devices -// before the gateway has registered the upgrade. The poll bounds are -// interpolated so the cap is asserted on real values, not source text. Use the -// stored CLI device credential for the provoke. Shared gateway overrides would -// authorize the owner instead of publishing the device's scope request. +// Best-effort in-sandbox request producer. Always exits 0. Use the stored CLI +// device credential for the direct `sessions.create` call. Shared gateway +// overrides would authorize the owner instead of publishing the device's scope +// request. Finalization's canonical observer owns pairing-state polling. // OpenClaw 2026.7.1 can omit CLI identity on loopback shared auth, so force // device pairing only on this command. export const WARMUP_SCRIPT = ` -PROXY_ENV=/tmp/nemoclaw-proxy-env.sh -[ -r "$PROXY_ENV" ] && . "$PROXY_ENV" +${buildTrustedProxyEnvSourceShell()} command -v openclaw >/dev/null 2>&1 || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 unset OPENCLAW_GATEWAY_URL OPENCLAW_GATEWAY_PORT \\ @@ -95,69 +76,6 @@ try: except (subprocess.TimeoutExpired, FileNotFoundError, OSError): pass PYPROBE -i=0 -while [ "$i" -lt ${WARMUP_POLL_ATTEMPTS} ]; do - OPENCLAW_BIN="$OPENCLAW_BIN" python3 - <<'PYPOLL' -import json -import os -import subprocess -import sys - -OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw') -# The proxy environment is shared gateway routing. Settlement must instead use -# the paired CLI identity with its current pairing-only credential so the list -# call can observe the write-scope request that the provoke command created. -list_env = dict(os.environ) -for key in ( - 'OPENCLAW_GATEWAY_URL', - 'OPENCLAW_GATEWAY_PORT', - 'OPENCLAW_GATEWAY_TOKEN', - 'OPENCLAW_GATEWAY_PASSWORD', - 'NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', - 'NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', -): - list_env.pop(key, None) -list_env['NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT'] = '1' -try: - proc = subprocess.run( - [OPENCLAW, 'devices', 'list', '--json'], - capture_output=True, text=True, timeout=${WARMUP_POLL_LIST_TIMEOUT_S}, env=list_env, - ) -except (subprocess.TimeoutExpired, FileNotFoundError, OSError): - sys.exit(1) -if proc.returncode != 0 or not proc.stdout.strip(): - sys.exit(1) -try: - data = json.loads(proc.stdout) -except ValueError: - sys.exit(1) -if not isinstance(data, dict): - sys.exit(1) -# Terminal success = operator.write is satisfied, whether it is a PENDING -# upgrade (the approval pass will grant it next) or ALREADY GRANTED on a -# re-onboard (idempotent no-op — nothing left to do before handoff). Scan -# every device collection the response exposes (pending plus any granted/ -# approved/paired/devices list, and any other top-level list of device dicts) -# rather than only 'pending', so the already-paired path short-circuits -# immediately instead of burning the whole poll budget. -devices = [] -for value in data.values(): - if isinstance(value, list): - devices.extend(d for d in value if isinstance(d, dict)) -for device in devices: - scopes = device.get('scopes') or device.get('requestedScopes') - if isinstance(scopes, str): - scopes = scopes.replace(',', ' ').split() - if isinstance(scopes, list) and 'operator.write' in scopes: - sys.exit(0) -sys.exit(1) -PYPOLL - if [ "$?" -eq 0 ]; then - break - fi - i=$((i + 1)) - [ "$i" -lt ${WARMUP_POLL_ATTEMPTS} ] && sleep 1 -done exit 0 `; @@ -170,8 +88,7 @@ exit 0 // When the clone is already fully paired, the call creates only a tagged empty // warm-up session, matching the existing user-facing session filter contract. export const RESTORED_CLONE_WARMUP_SCRIPT = ` -PROXY_ENV=/tmp/nemoclaw-proxy-env.sh -[ -r "$PROXY_ENV" ] && . "$PROXY_ENV" +${buildTrustedProxyEnvSourceShell()} command -v openclaw >/dev/null 2>&1 || exit 0 unset OPENCLAW_GATEWAY_TOKEN OPENCLAW_GATEWAY_PASSWORD \ NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING || exit 0 @@ -182,7 +99,21 @@ NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ exit 0 `; -function runSandboxWarmupScript(sandboxName: string, script: string): void { +export function sandboxWarmupExecArgs( + sandboxName: string, + gatewayName: string | undefined, + script: string, +): string[] { + const target = ["sandbox", "exec", "--name", sandboxName]; + if (gatewayName) target.push("-g", gatewayName); + return [...target, "--", "sh", "-c", script]; +} + +function runSandboxWarmupScript( + sandboxName: string, + gatewayName: string | undefined, + script: string, +): void { // Lazy require: `adapters/openshell/resolve` 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. @@ -197,7 +128,7 @@ function runSandboxWarmupScript(sandboxName: string, script: string): void { if (!openshellBinary) return; spawnSync( openshellBinary, - ["sandbox", "exec", "--name", sandboxName, "--", "sh", "-c", script], + sandboxWarmupExecArgs(sandboxName, gatewayName, script), { cwd: ROOT, env: process.env, @@ -216,8 +147,8 @@ function runSandboxWarmupScript(sandboxName: string, script: string): void { * missing openclaw, gateway unreachable) are swallowed. The finalization * settlement gate decides readiness from a later canonical observation. */ -export function runSandboxScopeWarmupRun(sandboxName: string): void { - runSandboxWarmupScript(sandboxName, WARMUP_SCRIPT); +export function runSandboxScopeWarmupRun(sandboxName: string, gatewayName: string): void { + runSandboxWarmupScript(sandboxName, gatewayName, WARMUP_SCRIPT); } /** @@ -225,5 +156,5 @@ export function runSandboxScopeWarmupRun(sandboxName: string): void { * embedded fallback. Failures remain non-blocking. */ export function runRestoredSandboxScopeWarmupRun(sandboxName: string): void { - runSandboxWarmupScript(sandboxName, RESTORED_CLONE_WARMUP_SCRIPT); + runSandboxWarmupScript(sandboxName, undefined, RESTORED_CLONE_WARMUP_SCRIPT); } diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts index ef80c45354a..c6d6e01d0b6 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.test.ts @@ -343,7 +343,7 @@ describe("OpenClaw launch-readiness pairing qualification", () => { ); }); - it("observes pairing-only state while one canonical scope upgrade awaits approval (#9817)", () => { + it("observes the exact canonical scope upgrade awaiting approval (#9817)", () => { writePairingOnlyState(); writeJson(path.join(stateDirectory, "devices", "pending.json"), { "canonical-cli-write": { @@ -360,7 +360,7 @@ describe("OpenClaw launch-readiness pairing qualification", () => { }); expect(observeOrdinarySettlement()).toEqual({ - state: "pairing-only", + state: "scope-upgrade-pending", deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/), }); expect(observeRepairSettlement()).toEqual({ diff --git a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts index bdec7a8db15..24e60f275ba 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts @@ -13,7 +13,7 @@ import { readAutoPairApprovalPolicyModule } from "../auto-pair-approval"; const QUALIFICATION_MARKER = "__NEMOCLAW_OPENCLAW_PAIRING_QUALIFICATION__="; const SETTLEMENT_MARKER = "__NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT__="; const SHA256_RE = /^[a-f0-9]{64}$/; -const OBSERVATION_TIMEOUT_MS = 3_000; +export const OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS = 3_000; const OBSERVATION_MAX_OUTPUT_BYTES = 4 * 1_024; export const OPENCLAW_PAIRING_REQUIRED_ROLES = ["operator"] as const; @@ -29,7 +29,7 @@ export const OPENCLAW_PAIRING_REQUIRED_SCOPES = [ export type OpenClawPairingQualification = LaunchReadinessOpenClawSessionQualification; export type OpenClawPairingSettlementObservation = { - readonly state: "pairing-only" | "settled"; + readonly state: "pairing-only" | "scope-upgrade-pending" | "settled"; readonly deviceIdentitySha256: string; }; @@ -122,7 +122,11 @@ function parseOpenClawPairingSettlementRecord(output: string): Record { vi.restoreAllMocks(); }); - it("accepts one already-settled canonical CLI device without pairing writes (#9844)", async () => { + it("accepts one already-settled canonical CLI device without running a producer (#10014)", async () => { const scope = ordinaryPairingDeps(); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ @@ -89,23 +97,46 @@ describe("ordinary OpenClaw pairing settlement", () => { expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); - it("waits for canonical pairing before one warm-up and approval pass (#9844)", async () => { + it("reuses an exact pending upgrade without running another producer (#10014)", async () => { const scope = ordinaryPairingDeps({ observePairing: vi .fn() - .mockImplementationOnce(() => { - throw new Error("not published"); - }) - .mockReturnValueOnce(PAIRING_ONLY) - .mockReturnValue(SETTLED), + .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) + .mockReturnValueOnce(SETTLED), }); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ kind: "settled", }); - expect(scope.calls).toEqual(["sleep", "warmup", "approval"]); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha"); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + }); + + it("runs the canonical request probe before waiting for fresh pairing (#10014)", async () => { + const observePairing = vi + .fn(() => SCOPE_UPGRADE_PENDING) + .mockImplementationOnce(() => { + throw new Error("not published"); + }) + .mockReturnValueOnce(PAIRING_ONLY); + const scope = ordinaryPairingDeps({ + observePairing, + runWarmup: vi.fn(() => { + scope.calls.push("warmup"); + }), + runApproval: vi.fn(() => { + scope.calls.push("approval"); + vi.mocked(scope.deps.observePairing).mockReturnValue(SETTLED); + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "settled", + }); + + expect(scope.calls).toEqual(["warmup", "sleep", "approval"]); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); }); @@ -115,9 +146,13 @@ describe("ordinary OpenClaw pairing settlement", () => { observePairing: vi .fn() .mockImplementationOnce(() => { - events.push("observe:baseline"); + events.push("observe:precheck"); return PAIRING_ONLY; }) + .mockImplementationOnce(() => { + events.push("observe:pending"); + return SCOPE_UPGRADE_PENDING; + }) .mockImplementation(() => { events.push("observe:final"); return SETTLED; @@ -149,8 +184,9 @@ describe("ordinary OpenClaw pairing settlement", () => { expect(events).toEqual([ "sandbox-lock:start", "gateway-lock:start", - "observe:baseline", + "observe:precheck", "warmup", + "observe:pending", "approval", "observe:final", "gateway-lock:end", @@ -177,7 +213,11 @@ describe("ordinary OpenClaw pairing settlement", () => { try { const scope = ordinaryPairingDeps({ getTarget: vi.fn(() => currentTarget), - observePairing: vi.fn().mockReturnValueOnce(PAIRING_ONLY).mockReturnValue(SETTLED), + observePairing: vi + .fn() + .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) + .mockReturnValue(SETTLED), runApproval: vi.fn(async (_name, gatewayName) => { approvalTargets.push(`${currentTarget.lifecycleGeneration}:${gatewayName}`); reportApprovalStarted(); @@ -281,7 +321,10 @@ describe("ordinary OpenClaw pairing settlement", () => { }); const scope = ordinaryPairingDeps({ getTarget: vi.fn(() => currentTarget), - observePairing: vi.fn(() => PAIRING_ONLY), + observePairing: vi + .fn() + .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValue(SCOPE_UPGRADE_PENDING), runWarmup: vi.fn(async () => { reportWarmupStarted(); await warmupPending; @@ -314,7 +357,10 @@ describe("ordinary OpenClaw pairing settlement", () => { }); const scope = ordinaryPairingDeps({ getTarget: vi.fn(() => currentTarget), - observePairing: vi.fn(() => PAIRING_ONLY), + observePairing: vi + .fn() + .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValue(SCOPE_UPGRADE_PENDING), runApproval: vi.fn(async () => { reportApprovalStarted(); await approvalPending; @@ -331,7 +377,27 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "runtime-identity-invalid", }); expect(scope.deps.runApproval).toHaveBeenCalledOnce(); - expect(scope.deps.observePairing).toHaveBeenCalledOnce(); + expect(scope.deps.observePairing).toHaveBeenCalledTimes(2); + }); + + it("rejects device identity drift between precheck and pending upgrade (#10014)", async () => { + const scope = ordinaryPairingDeps({ + observePairing: vi + .fn() + .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValueOnce({ + ...SCOPE_UPGRADE_PENDING, + deviceIdentitySha256: "b".repeat(64), + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "runtime-identity-invalid", + }); + + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); it("keeps pairing appearance and final observation independently bounded (#9844)", async () => { @@ -340,14 +406,16 @@ describe("ordinary OpenClaw pairing settlement", () => { throw new Error("not published"); }; const scope = ordinaryPairingDeps({ - observePairing: vi.fn(() => (attempts++ < 10 ? unavailable() : PAIRING_ONLY)), + observePairing: vi.fn(() => + attempts++ < 10 ? unavailable() : SCOPE_UPGRADE_PENDING, + ), }); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ kind: "incomplete", reason: "scope-upgrade-incomplete", }); - expect(scope.deps.sleep).toHaveBeenCalledTimes(40); + expect(scope.deps.sleep).toHaveBeenCalledTimes(39); expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); expect(scope.deps.runApproval).toHaveBeenCalledOnce(); }); @@ -362,9 +430,13 @@ describe("ordinary OpenClaw pairing settlement", () => { observePairing: vi .fn() .mockImplementationOnce(() => { + now += OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS; throw new Error("not published"); }) - .mockReturnValueOnce(PAIRING_ONLY) + .mockImplementationOnce(() => { + throw new Error("not pending"); + }) + .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) .mockReturnValue(SETTLED), runWarmup: vi.fn(() => { now += WARMUP_TIMEOUT_MS; @@ -378,17 +450,19 @@ describe("ordinary OpenClaw pairing settlement", () => { kind: "settled", }); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha"); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); - expect(scope.deps.observePairing).toHaveBeenCalledTimes(3); + expect(scope.deps.observePairing).toHaveBeenCalledTimes(4); expect(now).toBe( - OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS - + OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS + + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS - 1_000 + WARMUP_TIMEOUT_MS + CONNECT_AUTO_PAIR_TIMEOUT_MS, ); expect(OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS).toBe( - OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS + + OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS + + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS + WARMUP_TIMEOUT_MS + CONNECT_AUTO_PAIR_TIMEOUT_MS + OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS, @@ -399,10 +473,15 @@ describe("ordinary OpenClaw pairing settlement", () => { let now = 0; const scope = ordinaryPairingDeps({ now: vi.fn(() => now), - observePairing: vi.fn(() => { - now = OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS + 1; - return SETTLED; - }), + observePairing: vi + .fn() + .mockImplementationOnce(() => { + throw new Error("not published"); + }) + .mockImplementation(() => { + now = OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS + 1; + return SCOPE_UPGRADE_PENDING; + }), }); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ @@ -410,11 +489,11 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "pairing-unavailable", }); expect(scope.deps.sleep).not.toHaveBeenCalled(); - expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); - it("performs no writes when a canonical CLI pairing never appears (#9844)", async () => { + it("performs one request-producer write when a canonical CLI pairing never appears (#9844)", async () => { const scope = ordinaryPairingDeps({ observePairing: vi.fn(() => { throw new Error("not published"); @@ -426,11 +505,11 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "pairing-unavailable", }); - expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); - it("does not repeat pairing writes when baseline scopes never settle (#9844)", async () => { + it("does not approve before the exact pending upgrade appears (#10014)", async () => { const scope = ordinaryPairingDeps({ observePairing: vi.fn(() => PAIRING_ONLY) }); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ @@ -439,7 +518,7 @@ describe("ordinary OpenClaw pairing settlement", () => { }); expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); - expect(scope.deps.runApproval).toHaveBeenCalledOnce(); + expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); it("fails closed without writes when the recorded runtime target changes (#9844)", async () => { @@ -455,7 +534,7 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "runtime-identity-invalid", }); - expect(scope.deps.observePairing).not.toHaveBeenCalled(); + expect(scope.deps.observePairing).toHaveBeenCalledOnce(); expect(scope.deps.runWarmup).not.toHaveBeenCalled(); expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); @@ -469,6 +548,10 @@ describe("ordinary OpenClaw pairing settlement", () => { vi.spyOn(finalizationHandlerRuntime, "loadPairingQualification").mockReturnValue({ observeOrdinaryOpenClawPairingSettlement: observePairing, } as never); + const runSandboxScopeWarmupRun = vi.fn(); + vi.spyOn(finalizationHandlerRuntime, "loadAutoPairWarmup").mockReturnValue({ + runSandboxScopeWarmupRun, + } as never); vi.spyOn(finalizationHandlerRuntime, "loadSandboxLifecycleLock").mockReturnValue({ withMcpLifecycleLock: async (_name: string, operation: () => unknown) => operation(), } as never); @@ -486,15 +569,17 @@ describe("ordinary OpenClaw pairing settlement", () => { "2026.7.1", "/sandbox/.openclaw", ); + expect(runSandboxScopeWarmupRun).not.toHaveBeenCalled(); }); - it("wires default warm-up and approval adapters to the finalized gateway (#9844)", async () => { + it("wires the device-authenticated request producer and approval (#10014)", async () => { const observePairing = vi .fn() .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) .mockReturnValueOnce(SETTLED); const runSandboxScopeWarmupRun = vi.fn(); - const runConnectAutoPairApprovalPass = vi.fn(); + const runSandboxAutoPairApprovalPass = vi.fn(); vi.spyOn(finalizationHandlerRuntime, "loadLaunchReadiness").mockReturnValue({ resolveOrdinaryOpenClawPairingTarget: vi.fn(() => PAIRING_TARGET), } as never); @@ -505,7 +590,7 @@ describe("ordinary OpenClaw pairing settlement", () => { runSandboxScopeWarmupRun, } as never); vi.spyOn(finalizationHandlerRuntime, "loadAutoPairApproval").mockReturnValue({ - runConnectAutoPairApprovalPass, + runSandboxAutoPairApprovalPass, } as never); vi.spyOn(finalizationHandlerRuntime, "loadSandboxLifecycleLock").mockReturnValue({ withMcpLifecycleLock: async (_name: string, operation: () => unknown) => operation(), @@ -517,8 +602,17 @@ describe("ordinary OpenClaw pairing settlement", () => { await expect(finalizationHandlerDeps.settleOrdinaryOpenClawPairing("alpha")).resolves.toEqual({ kind: "settled", }); - expect(runSandboxScopeWarmupRun).toHaveBeenCalledExactlyOnceWith("alpha"); - expect(runConnectAutoPairApprovalPass).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(runSandboxScopeWarmupRun).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(runSandboxAutoPairApprovalPass).toHaveBeenCalledExactlyOnceWith("alpha", { + budget: { + timeoutMs: CONNECT_AUTO_PAIR_TIMEOUT_MS, + listTimeoutS: 5, + approveTimeoutS: 8, + maxApprovals: 1, + }, + gatewayName: "nemoclaw", + localDeviceOnly: true, + }); }); it("explains the bounded failure without exposing runtime identifiers (#9844)", () => { diff --git a/src/lib/onboard/machine/finalization-deps.ts b/src/lib/onboard/machine/finalization-deps.ts index e842ae3370f..a3343cc2d06 100644 --- a/src/lib/onboard/machine/finalization-deps.ts +++ b/src/lib/onboard/machine/finalization-deps.ts @@ -1,7 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { OpenClawPairingSettlementObservation } from "../../actions/sandbox/launch-readiness/openclaw-pairing-qualification"; +import { + OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS, + type OpenClawPairingSettlementObservation, +} from "../../actions/sandbox/launch-readiness/openclaw-pairing-qualification"; import type { OpenClawPairingSettlementTarget } from "../../actions/sandbox/launch-readiness"; import { WARMUP_TIMEOUT_MS } from "../../actions/sandbox/auto-pair-warmup"; import { CONNECT_AUTO_PAIR_TIMEOUT_MS } from "../../actions/sandbox/connect-autopair-budget"; @@ -22,10 +25,12 @@ type GatewayRouteLock = export const OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS = 60_000; export const OPENCLAW_ONBOARDING_PAIRING_POLL_MS = 1_000; export const OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS = 30_000; -// Keep one outer cap while reserving each bounded child's fixed budget. -// Pairing appearance retains its existing 30-second limit, and +// Keep one outer cap while reserving each bounded child's fixed budget, +// including the settled/already-pending precheck. Pairing appearance retains +// its existing limit, and // a capped warm-up can no longer consume the approval or final-read budget. export const OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS = + OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS + WARMUP_TIMEOUT_MS + CONNECT_AUTO_PAIR_TIMEOUT_MS + @@ -66,7 +71,7 @@ interface OrdinaryOpenClawPairingSettlementDeps { version: string, stateDirectory: string, ): OpenClawPairingSettlementObservation; - runWarmup(name: string): Promise | void; + runWarmup(name: string, gatewayName: string): Promise | void; runApproval(name: string, gatewayName: string): Promise | void; withSandboxLock: SandboxLifecycleLock; withGatewayLock: GatewayRouteLock; @@ -157,12 +162,22 @@ function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { finalizationHandlerRuntime .loadPairingQualification() .observeOrdinaryOpenClawPairingSettlement(...args), - runWarmup: (name) => - finalizationHandlerRuntime.loadAutoPairWarmup().runSandboxScopeWarmupRun(name), - runApproval: (name, gatewayName) => + runWarmup: (name, gatewayName) => finalizationHandlerRuntime - .loadAutoPairApproval() - .runConnectAutoPairApprovalPass(name, gatewayName), + .loadAutoPairWarmup() + .runSandboxScopeWarmupRun(name, gatewayName), + runApproval: (name, gatewayName) => { + finalizationHandlerRuntime.loadAutoPairApproval().runSandboxAutoPairApprovalPass(name, { + budget: { + timeoutMs: CONNECT_AUTO_PAIR_TIMEOUT_MS, + listTimeoutS: 5, + approveTimeoutS: 8, + maxApprovals: 1, + }, + gatewayName, + localDeviceOnly: true, + }); + }, withSandboxLock: (name, operation, options) => finalizationHandlerRuntime .loadSandboxLifecycleLock() @@ -177,8 +192,8 @@ function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { } /** - * Wait for the startup watcher to publish one canonical CLI pairing. When the - * device has only its pairing scope, request and approve the write scope once. + * Observe canonical state, run one bounded request producer when needed, then + * wait for its exact pending write upgrade before approving it once. * A final read verifies the exact device and no pending request for that device. */ export async function settleOrdinaryOpenClawPairing( @@ -201,42 +216,81 @@ export async function settleOrdinaryOpenClawPairing( return { kind: "incomplete", reason: "runtime-identity-invalid" }; } const settlementDeadline = deps.now() + OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS; - const pairingAppearanceDeadline = Math.min( - settlementDeadline, - deps.now() + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS, - ); - const baseline = await waitForPairingObservation( - name, - target, - pairingAppearanceDeadline, - () => true, - deps, - ); - if (baseline.kind === "target-changed") { - return { kind: "incomplete", reason: "runtime-identity-invalid" }; - } - if (baseline.kind === "timeout") { - return { kind: "incomplete", reason: "pairing-unavailable" }; + // Avoid creating another hidden warm-up session when re-onboarding an + // already-settled device, and reuse an exact upgrade already pending. + let initial: OpenClawPairingSettlementObservation | null = null; + let sawCanonicalPairing = false; + try { + initial = deps.observePairing( + name, + target.gatewayName, + target.version, + target.stateDirectory, + ); + sawCanonicalPairing = true; + } catch { + // Pairing may not have appeared yet; the producer handles that path. } - if (baseline.value.state === "settled") return { kind: "settled" }; if (!samePairingTarget(target, deps.getTarget(name))) { return { kind: "incomplete", reason: "runtime-identity-invalid" }; } if (deps.now() >= settlementDeadline) { - return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; + return { kind: "incomplete", reason: "pairing-unavailable" }; } + if (initial?.state === "settled") return { kind: "settled" }; - let warmupFailed = false; - try { - await deps.runWarmup(name); - } catch { - warmupFailed = true; + let baseline: PairingWaitResult; + if (initial?.state === "scope-upgrade-pending") { + baseline = { kind: "observed", value: initial }; + } else { + // A valid non-interactive path can reach finalization before the + // startup watcher publishes its first CLI request. Run the bounded + // direct producer once, then require canonical evidence that its + // exact write upgrade is pending before the approval pass (#10014). + try { + await deps.runWarmup(name, target.gatewayName); + } catch { + // The bounded observation below remains fail closed. + } + if (!samePairingTarget(target, deps.getTarget(name))) { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + const pairingAppearanceDeadline = Math.min( + settlementDeadline, + deps.now() + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS, + ); + baseline = await waitForPairingObservation( + name, + target, + pairingAppearanceDeadline, + (value) => { + sawCanonicalPairing = true; + return value.state !== "pairing-only"; + }, + deps, + ); + } + if (baseline.kind === "target-changed") { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + if (baseline.kind === "timeout") { + return { + kind: "incomplete", + reason: sawCanonicalPairing ? "scope-upgrade-incomplete" : "pairing-unavailable", + }; } + if ( + initial && + baseline.value.deviceIdentitySha256 !== initial.deviceIdentitySha256 + ) { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + if (baseline.value.state === "settled") return { kind: "settled" }; if (!samePairingTarget(target, deps.getTarget(name))) { return { kind: "incomplete", reason: "runtime-identity-invalid" }; } - if (warmupFailed || deps.now() >= settlementDeadline) { + if (deps.now() >= settlementDeadline) { return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; } diff --git a/test/helpers/openclaw-real-device-self-approval-proof.ts b/test/helpers/openclaw-real-device-self-approval-proof.ts index 60a10b6919c..abf35dbf64d 100644 --- a/test/helpers/openclaw-real-device-self-approval-proof.ts +++ b/test/helpers/openclaw-real-device-self-approval-proof.ts @@ -1625,10 +1625,10 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { "pending pairing settlement list", ); proofPhase = "ordinary-settlement-observation-before-approval"; - const pairingOnlyObservation = observeOrdinaryPairing(); + const pendingUpgradeObservation = observeOrdinaryPairing(); requireLiveProof( - pairingOnlyObservation.state === "pairing-only", - "ordinary settlement did not observe pairing-only state before canonical approval", + pendingUpgradeObservation.state === "scope-upgrade-pending", + "ordinary settlement did not observe the pending scope upgrade before canonical approval", ); const portableRepairObservation = observePortableRepairPairing(); requireLiveProof( @@ -1700,7 +1700,7 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { const settledObservation = observeOrdinaryPairing(); requireLiveProof( settledObservation.state === "settled" && - settledObservation.deviceIdentitySha256 === pairingOnlyObservation.deviceIdentitySha256, + settledObservation.deviceIdentitySha256 === pendingUpgradeObservation.deviceIdentitySha256, "ordinary settlement did not preserve the canonical device through scope approval", ); const strictSettledObservation = observeStrictPairing();