From 45afd350fdb403b37b375b42ee2330712f4506c9 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:57:56 -0700 Subject: [PATCH 1/6] fix(onboard): make watcher sole scope approver Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- ci/test-file-size-budget.json | 2 +- docs/get-started/quickstart.mdx | 11 +- docs/reference/commands.mdx | 5 +- scripts/nemoclaw-start.sh | 210 ++++++++---- .../actions/sandbox/auto-pair-warmup.test.ts | 119 +++++-- src/lib/actions/sandbox/auto-pair-warmup.ts | 271 +++++++++++++-- .../openclaw-pairing-qualification.ts | 21 +- .../onboard/machine/finalization-deps.test.ts | 237 +++++++------ src/lib/onboard/machine/finalization-deps.ts | 209 ++++++------ .../runtime/auto-pair-settlement-fixture.ts | 95 ++++++ ...nemoclaw-start-auto-pair-bootstrap.test.ts | 30 +- .../openclaw/runtime/nemoclaw-start.test.ts | 319 +++++++++--------- 12 files changed, 1003 insertions(+), 526 deletions(-) create mode 100644 test/agents/openclaw/runtime/auto-pair-settlement-fixture.ts diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 7442c0483d4..6496eb47eb4 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1875, "test/generation/generate-openclaw-config.test.ts": 1907, "test/installer-integration/install-preflight.test.ts": 3025, - "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4671, + "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4656, "test/onboarding/onboard-messaging.test.ts": 2023, "test/onboarding/onboard-selection.test.ts": 4176 } diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 8636a36378f..c085d59a8da 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -68,9 +68,14 @@ Review the [Prerequisites](prerequisites) before you begin. Wait for the ready summary, then check the sandbox state. - Before it prints this summary, default-profile OpenClaw onboarding waits for exactly one matching local CLI operator device. - It verifies the required baseline scopes and confirms that no pairing request for the same device remains pending. - If this bounded readiness check does not settle, NemoClaw keeps onboarding resumable and tells you to resume or rerun onboarding. + Before it prints this summary, ordinary fresh OpenClaw onboarding waits for exactly one matching local CLI operator device. + The in-sandbox auto-pair watcher is the only component that approves this device's baseline scope upgrade. + The host runs one bounded warm-up only when the canonical device is pairing-only, then observes the same device without sending another approval. + Onboarding verifies the exact paired and token scopes and confirms that no same-device request remains pending. + Onboarding reports whether settlement failed because of an unavailable pairing, failed warm-up, missing request, persistent pending request, stopped watcher, policy rejection, approval timeout, or approval failure. + The failure message omits device identifiers, request identifiers, public keys, tokens, and child command output. + If this bounded readiness check does not settle, NemoClaw keeps onboarding resumable. + Run `nemoclaw onboard --resume` after you correct the reported condition. ```bash nemoclaw my-assistant status diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 8743ec8d6c2..4d4e6ae3a19 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -396,8 +396,9 @@ It also rejects stored authority or filesystem ownership drift without falling b OpenClaw onboarding does not enter the `complete` state until NemoClaw proves that the local CLI operator pairing is settled. Ordinary onboarding first observes the canonical local CLI device. -If the device is not settled and has no exact pending request, NemoClaw runs one bounded request producer on the owning gateway. -It waits for the same-device request, approves at most once, and verifies the final settled state. +If the device is pairing-only, the host runs one bounded request producer on the owning gateway. +The in-sandbox watcher is the only component that approves the ordinary onboarding upgrade. +The host observes through any same-device pending state and verifies the final settled state without sending an approval. Portable onboarding accepts one exact already-pending canonical write upgrade, avoids a duplicate producer, and still requires strict same-device settlement before completion. The paired device must have exactly the `operator.pairing` and `operator.write` scopes. Any pairing request considered during bounded repair must request exactly those scopes. diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index cfbd776b4eb..eaf805a72bb 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2655,10 +2655,26 @@ import subprocess import sys import time +LAST_SANITIZED_STATUS = None + + +def publish_status(state): + global LAST_SANITIZED_STATUS + if state == LAST_SANITIZED_STATUS: + return + LAST_SANITIZED_STATUS = state + print('[auto-pair-status] ' + json.dumps({ + 'schemaVersion': 1, + 'state': state, + }, separators=(',', ':')), flush=True) + + print('[auto-pair] watcher started', flush=True) +publish_status('running') def report_unhandled_watcher_exception(exc_type, _exc_value, _traceback): + publish_status('stopped') print(f'[auto-pair] stage=watcher-execution failed error={exc_type.__name__}', flush=True) @@ -2707,8 +2723,7 @@ def _env_seconds(name, default): # the gateway. Late `openclaw agent` runs (NemoClaw#4263) request additional # scopes that the gateway holds as pending until something approves them; an # exited watcher leaves those upgrades stuck and the agent falls back to -# embedded mode. Defaults: 8h total, 30s slow-mode cadence. -FAST_DEADLINE = time.time() + _env_seconds('NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS', 600) +# embedded mode. Defaults: 8h total, 5s slow-mode cadence. DEADLINE = time.time() + _env_seconds('NEMOCLAW_AUTO_PAIR_DEADLINE_SECS', 28800) # After convergence the watcher polls at SLOW_INTERVAL. A late allowlisted # scope upgrade — e.g. `openclaw tui` or `openclaw agent` invoked after the @@ -2724,8 +2739,9 @@ DEADLINE = time.time() + _env_seconds('NEMOCLAW_AUTO_PAIR_DEADLINE_SECS', 28800) # failures both clear before the OpenClaw client gives up. The counter is # only bumped on the rising edge for each requestId (tracked in # FAST_REENTRY_BUMPED_REQUEST_IDS and garbage-collected against the live -# pending list), so a sticky failing request cannot pin the watcher in fast -# polling. This is a polling-cadence fix only — non-allowlisted scopes such +# pending list). After canonical settlement, a sticky failing request cannot +# repeatedly rearm fast reentry. Before settlement, the watcher stays at the +# 1s cadence by design. This is a polling-cadence fix only. Non-allowlisted scopes such # as `operator.admin` are still rejected by the device approval policy, and # requests that need them must be approved through a separate operator path. SLOW_INTERVAL = _env_seconds('NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS', 5) @@ -2733,7 +2749,7 @@ SLOW_INTERVAL = _env_seconds('NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS', 5) # # * Source boundary: the single SLOW_INTERVAL global above is the only # steady-state inter-poll wait for the in-sandbox auto-pair watcher -# after browser pairing converges. The watcher's faster pre-converge +# after the canonical CLI baseline settles. The watcher's faster pre-settlement # cadence (1s) is unaffected. # * Invalid state at the old default: a late # `openclaw tui` / `openclaw agent` allowlisted scope upgrade lands @@ -2759,7 +2775,6 @@ FAST_REENTRY_POLLS = int(_env_seconds('NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS', 5 FAST_REENTRY_INTERVAL = _env_seconds('NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS', 1) FAST_REENTRY_REMAINING = 0 FAST_REENTRY_BUMPED_REQUEST_IDS = set() -QUIET_POLLS = 0 APPROVED = 0 SLOW_MODE = False HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing @@ -2801,6 +2816,36 @@ def _identity_public_key(identity): return base64.urlsafe_b64encode(der[-32:]).decode('ascii').rstrip('=') +def _local_device_identity(): + state_dir = os.environ.get('OPENCLAW_STATE_DIR') or '/sandbox/.openclaw' + identity = _read_json_object(os.path.join(state_dir, 'identity', 'device.json')) + device_id = str(identity.get('deviceId', '') or '').strip() + public_key = _identity_public_key(identity) + public_key_raw = base64.urlsafe_b64decode(public_key + '=' * (-len(public_key) % 4)) + if ( + not device_id + or len(public_key_raw) != 32 + or hashlib.sha256(public_key_raw).hexdigest() != device_id + ): + raise RuntimeError('local device identity is invalid') + return device_id, public_key + + +def is_local_cli_request(request): + if not isinstance(request, dict): + return False + try: + device_id, public_key = _local_device_identity() + except (OSError, ValueError, RuntimeError, binascii.Error): + return False + return ( + request.get('deviceId') == device_id + and request.get('publicKey') == public_key + and request.get('clientId') == 'cli' + and request.get('clientMode') == 'cli' + ) + + def initial_cli_request_is_allowlisted(request_id): # SOURCE_OF_TRUTH_REVIEW (NemoClaw#6113 gated-list bootstrap): # Invalid state: `devices list --json` can be gated by the same initial @@ -2981,21 +3026,74 @@ def brief_child_error(out, err): return (lines[-1] if lines else '')[:400] -def report_request_observed(request_id): +def report_request_observed(request_id, publish_sanitized=True): if request_id in OBSERVED_REQUEST_IDS: return OBSERVED_REQUEST_IDS.add(request_id) + if publish_sanitized: + publish_status('request-observed') print(f'[auto-pair] stage=request-creation observed request={request_id}') -def report_request_validation(request_id, accepted, reason): +def report_request_validation(request_id, accepted, reason, publish_sanitized=True): if request_id in VALIDATED_REQUEST_IDS: return VALIDATED_REQUEST_IDS.add(request_id) outcome = 'accepted' if accepted else 'rejected' + if not accepted and publish_sanitized: + publish_status('request-rejected') print(f'[auto-pair] stage=validation {outcome} request={request_id} reason={reason}') +def exact_string_set(value, expected): + return ( + isinstance(value, list) + and len(value) == len(expected) + and all(isinstance(item, str) for item in value) + and set(value) == expected + ) + + +def canonical_cli_baseline_settled(paired, pending): + try: + local_device_id, local_public_key = _local_device_identity() + except (OSError, ValueError, RuntimeError, binascii.Error): + return False + candidates = [ + device for device in paired + if isinstance(device, dict) + and device.get('deviceId') == local_device_id + and device.get('publicKey') == local_public_key + and device.get('clientId') == 'cli' + and device.get('clientMode') == 'cli' + and device.get('role') == 'operator' + and exact_string_set(device.get('roles'), {'operator'}) + and exact_string_set(device.get('scopes'), {'operator.pairing', 'operator.write'}) + and exact_string_set(device.get('approvedScopes'), {'operator.pairing', 'operator.write'}) + ] + if len(candidates) != 1: + return False + device = candidates[0] + device_id = str(device.get('deviceId', '') or '').strip() + tokens = device.get('tokens') + operator = tokens.get('operator') if isinstance(tokens, dict) and set(tokens) == {'operator'} else None + if ( + not device_id + or not isinstance(operator, dict) + or operator.get('role') != 'operator' + or operator.get('revokedAtMs') is not None + or not exact_string_set( + operator.get('scopes'), + {'operator.pairing', 'operator.read', 'operator.write'}, + ) + ): + return False + return not any( + isinstance(request, dict) and str(request.get('deviceId', '') or '').strip() == device_id + for request in pending + ) + + def list_failure_reason(rc, out, err): if rc == 124: return 'timeout' @@ -3066,16 +3164,6 @@ def sleep_for_next_poll(default_seconds, productive=True): while time.time() < DEADLINE: - # Fast-to-slow transition is checked at the TOP of every iteration — before - # any list/approve-failure `continue` below — so a permanently failing gated - # list/approve (or a sticky pending request) cannot hold the watcher in 1s - # polling for the full DEADLINE window; after FAST_DEADLINE it drops to - # SLOW_INTERVAL. Preventing that long-timeline re-creation of the - # NemoClaw#2484 connect-handler pile-up is exactly the point. - # (PR #6330 review, cv item 2.) - if not SLOW_MODE and time.time() >= FAST_DEADLINE: - SLOW_MODE = True - print(f'[auto-pair] fast-mode deadline reached; switching to slow-mode approvals={APPROVED}') rc, out, err = run( OPENCLAW, 'devices', @@ -3114,11 +3202,13 @@ while time.time() < DEADLINE: if arc == 0: HANDLED.add(initial_request_id) APPROVED += 1 + publish_status('approval-completed') print(f'[auto-pair] approved initial CLI pairing request={initial_request_id}') FAST_REENTRY_REMAINING = max(FAST_REENTRY_REMAINING, FAST_REENTRY_POLLS) sleep_for_next_poll(FAST_REENTRY_INTERVAL) continue approval_failure_reason = 'timeout' if arc == 124 else 'command-failed' + publish_status('approval-timeout' if arc == 124 else 'approval-failed') print(f'[auto-pair] stage=approval failed reason={approval_failure_reason}') failure = brief_child_error(aout, aerr) if arc != 124 and failure: @@ -3156,8 +3246,6 @@ while time.time() < DEADLINE: if not PAIRING_BOOTSTRAPPED and has_cli_pairing: PAIRING_BOOTSTRAPPED = True print('[auto-pair] loopback CLI pairing bootstrap completed') - has_browser = any((d.get('clientId') == 'openclaw-control-ui') or (d.get('clientMode') == 'webchat') for d in paired if isinstance(d, dict)) - normalized_pending = [] saw_malformed_request_id = False for device in pending: @@ -3178,36 +3266,45 @@ while time.time() < DEADLINE: FAST_REENTRY_BUMPED_REQUEST_IDS.intersection_update(pending_request_ids) if not normalized_pending and not paired and APPROVED == 0 and not REQUEST_CREATION_WAITING_REPORTED: + publish_status('request-not-produced') print('[auto-pair] stage=request-creation waiting reason=no-request') REQUEST_CREATION_WAITING_REPORTED = True if normalized_pending: - QUIET_POLLS = 0 attempted_request_ids = set() for request_id, device in normalized_pending: if request_id in HANDLED: continue - report_request_observed(request_id) + tracks_canonical_cli = is_local_cli_request(device) + report_request_observed(request_id, tracks_canonical_cli) decision = approval_request_decision(device) client_id = decision['client_id'] client_mode = decision['client_mode'] if decision['reason'] == 'unknown-client': HANDLED.add(request_id) - report_request_validation(request_id, False, 'unknown-client') + report_request_validation( + request_id, False, 'unknown-client', tracks_canonical_cli, + ) print(f'[auto-pair] rejected unknown client={client_id} mode={client_mode}') continue if decision['reason'] == 'malformed-scopes': HANDLED.add(request_id) - report_request_validation(request_id, False, 'malformed-scopes') + report_request_validation( + request_id, False, 'malformed-scopes', tracks_canonical_cli, + ) print(f'[auto-pair] rejected malformed scopes client={client_id} mode={client_mode}') continue if decision['reason'] == 'disallowed-scopes': HANDLED.add(request_id) scopes = decision['scopes'] - report_request_validation(request_id, False, 'disallowed-scopes') + report_request_validation( + request_id, False, 'disallowed-scopes', tracks_canonical_cli, + ) print(f'[auto-pair] rejected disallowed scopes={sorted(scopes)} client={client_id} mode={client_mode}') continue - report_request_validation(request_id, True, 'allowlisted-request') + report_request_validation( + request_id, True, 'allowlisted-request', tracks_canonical_cli, + ) attempted_request_ids.add(request_id) print(f'[auto-pair] stage=approval attempting request={request_id}') arc, aout, aerr = run( @@ -3219,24 +3316,27 @@ while time.time() < DEADLINE: # retryable too; only intentionally rejected unknown clients # and confirmed successful approvals are marked handled. if arc == 124: + if tracks_canonical_cli: + publish_status('approval-timeout') print('[auto-pair] stage=approval failed reason=timeout') continue if arc == 0: HANDLED.add(request_id) APPROVED += 1 + if tracks_canonical_cli: + publish_status('approval-completed') print(f'[auto-pair] approved request={request_id} client={client_id} mode={client_mode}') else: + if tracks_canonical_cli: + publish_status('approval-failed') print('[auto-pair] stage=approval failed reason=command-failed') failure = brief_child_error(aout, aerr) if failure: print(f'[auto-pair] approve failed request={request_id}: {failure}') - # Fast-reentry is armed on the rising edge per requestId — once for - # each freshly-observed allowlisted attempt. A sticky pending request - # that fails approval repeatedly therefore stops bumping the counter - # after the first attempt, so it cannot keep the watcher in fast - # polling for the rest of DEADLINE; the next slow-cadence poll - # decides whether to retry. Cascading approvals from new ids still - # bump as they appear, which is the case the override targets. + # Fast reentry is armed once for each freshly observed allowlisted + # request. After canonical settlement, a sticky failure cannot + # repeatedly rearm the temporary 1s cadence. Cascading approvals from + # new request IDs still trigger the bounded override. new_attempted_ids = attempted_request_ids - FAST_REENTRY_BUMPED_REQUEST_IDS # Bump in fast mode too: the cadence override is a no-op there # (min(FAST_REENTRY_INTERVAL=1, default=1) = 1) but the requestId @@ -3251,46 +3351,28 @@ while time.time() < DEADLINE: sleep_for_next_poll(SLOW_INTERVAL if SLOW_MODE else 1) continue - QUIET_POLLS += 1 - # Convergence conditions, checked in order of strength: - # 1. Browser device paired — original control-UI workflow - # 2. Any paired device — covers dangerouslyDisableDeviceAuth setups - # where the gateway auto-pairs CLI clients directly without the - # watcher running `openclaw devices approve` (so APPROVED stays - # 0 forever in those configurations) - # 3. We approved at least one device explicitly - # On convergence the watcher used to exit. That left late CLI scope - # upgrades pending forever (NemoClaw#4263). Now we transition to a slow - # polling cadence (default 30s) so late allowlisted scope upgrades for - # already-paired clients still get approved without saturating the - # gateway connect handler (NemoClaw#2484: WS handshake-timeout). The - # fast-deadline transition is now evaluated above (before the pending - # branch) so a stuck pending request cannot defer it. - if not SLOW_MODE and QUIET_POLLS >= 4: - if has_browser: - SLOW_MODE = True - print(f'[auto-pair] browser pairing converged; entering slow-mode approvals={APPROVED}') - elif paired: - SLOW_MODE = True - print(f'[auto-pair] devices paired ({len(paired)}); entering slow-mode approvals={APPROVED}') - elif APPROVED > 0: - SLOW_MODE = True - print(f'[auto-pair] non-browser pairing converged; entering slow-mode approvals={APPROVED}') - - # Back off polling: 1s in fast mode while waiting for first pairing, - # 5s in fast mode once anything is paired/approved, and SLOW_INTERVAL - # (default 5s) after convergence. Slow-mode keepalive lets late CLI + # Fresh onboarding relies on this watcher as the only scope-upgrade + # approver. Keep the one-second cadence until the canonical CLI record has + # the exact baseline scopes and no same-device pending request. Browser + # pairing, an unrelated paired device, or elapsed time cannot establish + # this transition. + if not SLOW_MODE and canonical_cli_baseline_settled(paired, pending): + SLOW_MODE = True + publish_status('canonical-settled') + print(f'[auto-pair] canonical CLI baseline settled; entering slow-mode approvals={APPROVED}') + + # Poll every 1s until canonical CLI settlement, then use SLOW_INTERVAL + # (default 5s). Slow-mode keepalive lets late CLI # scope upgrades get approved through the rest of DEADLINE without # hammering the gateway. The bounded fast-reentry counter (bumped above # when an allowlisted upgrade was attempted) overrides whichever tier # is selected here so the next few polls catch cascading upgrades. if SLOW_MODE: sleep_for_next_poll(SLOW_INTERVAL) - elif APPROVED > 0 or paired: - sleep_for_next_poll(5) else: sleep_for_next_poll(1) else: + publish_status('stopped') print(f'[auto-pair] watcher deadline reached approvals={APPROVED}') PYAUTOPAIR AUTO_PAIR_PID=$! diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index a8ec7284bf4..91f5d770ded 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -8,8 +8,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + parseAutoPairWatcherStatus, + parseSandboxScopeWarmupResult, RESTORED_CLONE_WARMUP_SCRIPT, sandboxWarmupExecArgs, + WATCHER_STATUS_SCRIPT, WARMUP_PROBE_TIMEOUT_S, WARMUP_SCRIPT, WARMUP_TIMEOUT_MS, @@ -21,8 +24,8 @@ const shAvailable = spawnSync("sh", ["-c", "exit 0"], { encoding: "utf-8" }).sta const itWithSh = shAvailable ? it : it.skip; // NOTE on coverage shape (#4504-v2): `runSandboxScopeWarmupRun` is not exercised -// in-process here. Like its sibling `runSandboxAutoPairApprovalPass`, the leaf -// lazily does a raw `require("../../adapters/openshell/runtime")` — a native +// in-process here. The leaf lazily does a raw +// `require("../../adapters/openshell/resolve")`, a native // CJS require of a relative `.ts` path that Vitest's module-mock registry does // not intercept (mocking `node:child_process` to inspect the spawn args makes // the source resolve that require through native Node, which then fails with @@ -31,8 +34,9 @@ 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 tests -// pin the producer→observation→approval wiring. +// the OpenShell-exec wrapping the leaf depends on. The finalization tests pin +// host production and canonical observation without host approval; the runtime +// watcher tests pin watcher-owned approval. 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", () => { @@ -46,10 +50,8 @@ describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => { }); it("stays within the bounds the contract budgeted for finalization latency", () => { - // The architect budgeted worst-case added finalization latency at the - // warm-up cap (<=30s) plus the existing 15s approval pass. Guard that the - // warm-up cap has not crept past its 30s ceiling — anything larger would - // blow the budget the contract signed off on for a one-time onboard. + // Guard the fixed warm-up cap so this one-time onboarding request producer + // cannot consume the watcher-observation window. expect(WARMUP_TIMEOUT_MS).toBeLessThanOrEqual(30_000); }); }); @@ -75,15 +77,78 @@ describe("warm-up payload uses native multiline OpenShell exec in v2 (#4504)", ( }); itWithSh("runs a multiline warm-up-shaped payload and preserves its exit-0 status", () => { - // Mirror the real warm-up: the direct probe normally returns the pending - // scope error, but its ignored status plus trailing exit 0 keep that expected - // response from surfacing as an onboard command failure. + // Mirror the real warm-up shell boundary: the fixed receipt owns the child + // classification, while the wrapper itself exits 0. const inner = ["false || true", "exit 0", ""].join("\n"); const result = spawnSync("sh", ["-c", inner], { encoding: "utf-8", timeout: 10_000 }); expect(result.status).toBe(0); }); }); +describe("ordinary onboarding warm-up and watcher receipts (#10269)", () => { + it("parses only one terminal fixed warm-up receipt", () => { + expect(parseSandboxScopeWarmupResult("NEMOCLAW_OPENCLAW_WARMUP_RESULT=request-issued\n")).toBe( + "request-issued", + ); + expect(parseSandboxScopeWarmupResult("NEMOCLAW_OPENCLAW_WARMUP_RESULT=already-settled\n")).toBe( + "already-settled", + ); + expect( + parseSandboxScopeWarmupResult( + "NEMOCLAW_OPENCLAW_WARMUP_RESULT=request-issued\ntrailing output\n", + ), + ).toBeNull(); + expect( + parseSandboxScopeWarmupResult( + "NEMOCLAW_OPENCLAW_WARMUP_RESULT=request-issued\nNEMOCLAW_OPENCLAW_WARMUP_RESULT=request-issued\n", + ), + ).toBeNull(); + }); + + it("parses a versioned watcher receipt without accepting extra fields", () => { + const marker = "NEMOCLAW_OPENCLAW_WATCHER_STATUS="; + expect( + parseAutoPairWatcherStatus( + `${marker}{"schemaVersion":1,"state":"approval-timeout","watcherActive":true}\n`, + ), + ).toEqual({ schemaVersion: 1, state: "approval-timeout", watcherActive: true }); + expect( + parseAutoPairWatcherStatus( + `${marker}{"schemaVersion":1,"state":"approval-completed","watcherActive":true,"requestId":"secret"}\n`, + ), + ).toBeNull(); + expect(WATCHER_STATUS_SCRIPT).not.toContain("requestId"); + expect(WATCHER_STATUS_SCRIPT).not.toContain("deviceId"); + expect(WATCHER_STATUS_SCRIPT).not.toContain("publicKey"); + expect(WATCHER_STATUS_SCRIPT).not.toContain("token"); + }); + + itWithSh("returns a fixed request-issued result for the expected pending response", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-receipt-")); + const binDir = path.join(fixtureRoot, "bin"); + fs.mkdirSync(binDir); + fs.writeFileSync( + path.join(binDir, "openclaw"), + "#!/bin/sh\necho 'scope upgrade pending approval' >&2\nexit 23\n", + { mode: 0o700 }, + ); + try { + const result = spawnSync("sh", ["-c", WARMUP_SCRIPT], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }, + timeout: 10_000, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe("NEMOCLAW_OPENCLAW_WARMUP_RESULT=request-issued\n"); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +}); + describe("warm-up tags its throwaway session for user-facing filters (#5511)", () => { it("tags the provoke session with the shared warm-up prefix", () => { expect(WARMUP_SESSION_ID_PREFIX).toBe("nemoclaw-onboard-warmup-"); @@ -240,28 +305,28 @@ 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"', + "printf 'consumed\\n' > \"$NEMOCLAW_TEST_PROXY_SOURCE_LOG\"", "", ].join("\n"), { mode: 0o444 }, ); fs.writeFileSync( path.join(binDir, "openclaw"), - [ - "#!/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"), + [ + "#!/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 }, ); diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index 52238be69f4..a83ff8b62e4 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -4,31 +4,25 @@ /** * Onboard scope-upgrade warm-up (#4504-v2). * - * The connect-time approval pass (`auto-pair-approval.ts`) is purely - * request-driven: it can only approve a scope upgrade that is already PENDING. * During fresh onboard the device is auto-paired with `operator.pairing` only; * the `operator.write` upgrade is not requested until the user's first - * write-scope command, after onboard finalization's approval pass already found - * nothing pending. The result is one silent embedded fallback on that first - * run, then `connect`/`recover` fixes it. + * write-scope command. Without an onboarding request producer, the user's first + * run can fall back to embedded mode. * * This warm-up provokes the upgrade with one bounded `sessions.create` gateway * call inside the sandbox during finalization. The direct call cannot fall back * to an embedded inference turn, so it publishes the `operator.write` * scope-upgrade request without consuming the readiness deadline on model work. - * The existing `runConnectAutoPairApprovalPass` then approves it, so - * `operator.write` is persisted before handoff and the user's first run - * connects without an embedded fallback. + * The in-sandbox watcher is the only ordinary onboarding approval owner. The + * host observes the same device until canonical state has the exact baseline + * scopes and no same-device pending request. * - * Contract: best-effort, bounded, idempotent. The direct call normally returns - * the pending-scope failure after it publishes the request, and its output is - * discarded. The leaf swallows execution failures, and onboarding separately - * observes the canonical pairing state before it reports success. On re-onboard - * where `operator.write` is already paired the call succeeds and the approval - * pass is a no-op. + * Contract: bounded and idempotent. The direct call returns a fixed non-secret + * result. Onboarding uses that result only to classify failures. Canonical + * pairing state remains the only success authority. * * Workaround boundary (NemoClaw#4462): OpenClaw owns device-pairing semantics - * and exposes only `devices list/get/approve` — there is no way to pre-grant a + * and exposes only `devices list/get/approve`. There is no way to pre-grant a * scope the device has not requested. Remove this warm-up when OpenClaw can * pre-approve the full scope set at pairing time. */ @@ -44,7 +38,36 @@ import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; export const WARMUP_TIMEOUT_MS = 30_000; export const WARMUP_PROBE_TIMEOUT_S = 5; -// Best-effort in-sandbox request producer. Always exits 0. Use the stored CLI +const WARMUP_RESULT_MARKER = "NEMOCLAW_OPENCLAW_WARMUP_RESULT="; +const WATCHER_STATUS_MARKER = "NEMOCLAW_OPENCLAW_WATCHER_STATUS="; + +export const WATCHER_STATUS_TIMEOUT_MS = 10_000; + +export type AutoPairWatcherState = + | "running" + | "request-not-produced" + | "request-observed" + | "request-rejected" + | "approval-timeout" + | "approval-failed" + | "approval-completed" + | "canonical-settled" + | "stopped" + | "unavailable"; + +export interface AutoPairWatcherStatus { + readonly schemaVersion: 1; + readonly state: AutoPairWatcherState; + readonly watcherActive: boolean; +} + +export type SandboxScopeWarmupResult = + | "request-issued" + | "already-settled" + | "exec-timeout" + | "exec-failed"; + +// Bounded in-sandbox request producer. 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. @@ -52,33 +75,117 @@ export const WARMUP_PROBE_TIMEOUT_S = 5; // device pairing only on this command. export const WARMUP_SCRIPT = ` ${buildTrustedProxyEnvSourceShell()} -command -v openclaw >/dev/null 2>&1 || exit 0 -command -v python3 >/dev/null 2>&1 || exit 0 +command -v openclaw >/dev/null 2>&1 || { printf '${WARMUP_RESULT_MARKER}exec-failed\\n'; exit 0; } +command -v python3 >/dev/null 2>&1 || { printf '${WARMUP_RESULT_MARKER}exec-failed\\n'; exit 0; } unset OPENCLAW_GATEWAY_URL OPENCLAW_GATEWAY_PORT \\ OPENCLAW_GATEWAY_TOKEN OPENCLAW_GATEWAY_PASSWORD \\ NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING \\ - NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT || exit 0 + NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT || { printf '${WARMUP_RESULT_MARKER}exec-failed\\n'; exit 0; } session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" params="$(printf '{"key":"%s","agentId":"main"}' "$session_key")" OPENCLAW_BIN="$(command -v openclaw)" OPENCLAW_BIN="$OPENCLAW_BIN" NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ python3 - "$params" <<'PYPROBE' import os +import re import subprocess import sys try: - subprocess.run( + result = subprocess.run( [os.environ['OPENCLAW_BIN'], 'gateway', 'call', 'sessions.create', '--params', sys.argv[1], '--json'], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + capture_output=True, text=True, timeout=${WARMUP_PROBE_TIMEOUT_S}, env=dict(os.environ), ) -except (subprocess.TimeoutExpired, FileNotFoundError, OSError): - pass + output = (result.stdout + '\\n' + result.stderr)[:65536] + request_published = re.search( + r'scope upgrade pending approval|pairing required: device is asking for more scopes', + output, + re.IGNORECASE, + ) is not None + if result.returncode == 0: + outcome = 'already-settled' + elif request_published: + outcome = 'request-issued' + else: + outcome = 'exec-failed' +except subprocess.TimeoutExpired: + outcome = 'exec-timeout' +except (FileNotFoundError, OSError): + outcome = 'exec-failed' +print('${WARMUP_RESULT_MARKER}' + outcome) PYPROBE exit 0 `; +export const WATCHER_STATUS_SCRIPT = ` +command -v python3 >/dev/null 2>&1 || { printf '${WATCHER_STATUS_MARKER}{"schemaVersion":1,"state":"unavailable","watcherActive":false}\\n'; exit 0; } +python3 - <<'PYSTATUS' +import json +import os + +allowed_states = { + 'running', 'request-not-produced', 'request-observed', 'request-rejected', + 'approval-timeout', 'approval-failed', 'approval-completed', + 'canonical-settled', 'stopped', +} +state = 'unavailable' +try: + with open('/tmp/auto-pair.log', 'rb') as handle: + handle.seek(0, 2) + size = handle.tell() + handle.seek(max(0, size - 131072)) + lines = handle.read().decode('utf-8', 'replace').splitlines() + for line in reversed(lines): + prefix = '[auto-pair-status] ' + if not line.startswith(prefix): + continue + value = json.loads(line[len(prefix):]) + if ( + isinstance(value, dict) + and set(value) == {'schemaVersion', 'state'} + and value.get('schemaVersion') == 1 + and value.get('state') in allowed_states + ): + state = value['state'] + break +except Exception: + pass + +watcher_active = False +try: + for entry in os.listdir('/proc'): + if not entry.isdigit(): + continue + try: + with open('/proc/%s/cmdline' % entry, 'rb') as handle: + command = handle.read().replace(b'\\x00', b' ').decode('utf-8', 'replace') + except Exception: + continue + if 'python3' not in command: + continue + for descriptor in ('1', '2'): + try: + target = os.readlink('/proc/%s/fd/%s' % (entry, descriptor)) + except Exception: + continue + if target == '/tmp/auto-pair.log': + watcher_active = True + break + if watcher_active: + break +except Exception: + watcher_active = False + +print('${WATCHER_STATUS_MARKER}' + json.dumps({ + 'schemaVersion': 1, + 'state': state, + 'watcherActive': watcher_active, +}, separators=(',', ':'))) +PYSTATUS +exit 0 +`; + // A restored clone must publish its write-scope request before the strict // local-state approval pass from #7834. Use a direct gateway call here: unlike // `openclaw agent`, this command cannot silently continue in embedded mode. @@ -113,42 +220,132 @@ function runSandboxWarmupScript( sandboxName: string, gatewayName: string | undefined, script: string, -): void { +): ReturnType | null { // 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. // Use `resolveOpenshell` (returns null) rather than `getOpenshellBinary`, - // which `process.exit(1)`s when the CLI is missing — that fail-fast escapes - // this try/catch and would turn the best-effort warm-up into a hard onboard - // exit. A missing OpenShell here is a no-op instead. + // which `process.exit(1)`s when the CLI is missing. That fail-fast escapes + // this try/catch. A missing OpenShell returns the fixed `exec-failed` result + // for ordinary onboarding, while the restored-clone caller remains + // non-blocking. const { resolveOpenshell } = require("../../adapters/openshell/resolve") as typeof import("../../adapters/openshell/resolve"); try { const openshellBinary = resolveOpenshell(); - if (!openshellBinary) return; - spawnSync( + if (!openshellBinary) return null; + return spawnSync(openshellBinary, sandboxWarmupExecArgs(sandboxName, gatewayName, script), { + cwd: ROOT, + env: process.env, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: WARMUP_TIMEOUT_MS, + }); + } catch { + return null; + } +} + +function runSandboxStatusScript( + sandboxName: string, + gatewayName: string, +): ReturnType | null { + const { resolveOpenshell } = + require("../../adapters/openshell/resolve") as typeof import("../../adapters/openshell/resolve"); + try { + const openshellBinary = resolveOpenshell(); + if (!openshellBinary) return null; + return spawnSync( openshellBinary, - sandboxWarmupExecArgs(sandboxName, gatewayName, script), + sandboxWarmupExecArgs(sandboxName, gatewayName, WATCHER_STATUS_SCRIPT), { cwd: ROOT, env: process.env, - stdio: ["ignore", "ignore", "ignore"], - timeout: WARMUP_TIMEOUT_MS, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: WATCHER_STATUS_TIMEOUT_MS, }, ); } catch { - /* defense-in-depth — never throw from a warm-up path */ + return null; + } +} + +export function parseSandboxScopeWarmupResult(output: string): SandboxScopeWarmupResult | null { + const lines = output.trimEnd().split(/\r?\n/); + const markers = lines.filter((line) => line.startsWith(WARMUP_RESULT_MARKER)); + if (markers.length !== 1 || lines.at(-1) !== markers[0]) return null; + const value = markers[0]!.slice(WARMUP_RESULT_MARKER.length); + return value === "request-issued" || + value === "already-settled" || + value === "exec-timeout" || + value === "exec-failed" + ? value + : null; +} + +export function parseAutoPairWatcherStatus(output: string): AutoPairWatcherStatus | null { + const lines = output.trimEnd().split(/\r?\n/); + const markers = lines.filter((line) => line.startsWith(WATCHER_STATUS_MARKER)); + if (markers.length !== 1 || lines.at(-1) !== markers[0]) return null; + let value: unknown; + try { + value = JSON.parse(markers[0]!.slice(WATCHER_STATUS_MARKER.length)); + } catch { + return null; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const status = value as Record; + const states: readonly AutoPairWatcherState[] = [ + "running", + "request-not-produced", + "request-observed", + "request-rejected", + "approval-timeout", + "approval-failed", + "approval-completed", + "canonical-settled", + "stopped", + "unavailable", + ]; + if ( + Object.keys(status).sort().join(",") !== "schemaVersion,state,watcherActive" || + status.schemaVersion !== 1 || + typeof status.state !== "string" || + !states.includes(status.state as AutoPairWatcherState) || + typeof status.watcherActive !== "boolean" + ) { + return null; } + return status as unknown as AutoPairWatcherStatus; } /** * Run the bounded, throwaway scope-upgrade warm-up inside the named sandbox via * `openshell sandbox exec`. All failure modes (timeout, sandbox-exec errors, - * missing openclaw, gateway unreachable) are swallowed. The finalization - * settlement gate decides readiness from a later canonical observation. + * missing OpenClaw, and gateway failures) return fixed classifications. The + * finalization settlement gate decides readiness from canonical state. */ -export function runSandboxScopeWarmupRun(sandboxName: string, gatewayName: string): void { - runSandboxWarmupScript(sandboxName, gatewayName, WARMUP_SCRIPT); +export function runSandboxScopeWarmupRun( + sandboxName: string, + gatewayName: string, +): SandboxScopeWarmupResult { + const result = runSandboxWarmupScript(sandboxName, gatewayName, WARMUP_SCRIPT); + if (!result) return "exec-failed"; + if (result.error && "code" in result.error && result.error.code === "ETIMEDOUT") { + return "exec-timeout"; + } + if (result.status !== 0 || typeof result.stdout !== "string") return "exec-failed"; + return parseSandboxScopeWarmupResult(result.stdout) ?? "exec-failed"; +} + +export function readSandboxAutoPairWatcherStatus( + sandboxName: string, + gatewayName: string, +): AutoPairWatcherStatus | null { + const result = runSandboxStatusScript(sandboxName, gatewayName); + if (!result || result.status !== 0 || typeof result.stdout !== "string") return null; + return parseAutoPairWatcherStatus(result.stdout); } /** 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 78b4a1a64e8..3f90d8ae00b 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts @@ -8,9 +8,8 @@ import path from "node:path"; import { resolveOpenshellBinary } from "../../../adapters/openshell/command-argv"; import type { LaunchReadinessOpenClawSessionQualification } from "../../../state/launch-readiness-lease"; import { ROOT } from "../../../state/paths"; -import { WARMUP_TIMEOUT_MS } from "../auto-pair-warmup"; +import { WARMUP_TIMEOUT_MS, WATCHER_STATUS_TIMEOUT_MS } from "../auto-pair-warmup"; import { readAutoPairApprovalPolicyModule } from "../auto-pair-approval"; -import { CONNECT_AUTO_PAIR_TIMEOUT_MS } from "../connect-autopair-budget"; const QUALIFICATION_MARKER = "__NEMOCLAW_OPENCLAW_PAIRING_QUALIFICATION__="; const SETTLEMENT_MARKER = "__NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT__="; @@ -24,14 +23,14 @@ export const OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS = 3_000; 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; -// Reserve every existing bounded child without allowing one stage to consume -// the approval or final-observation window. +// Reserve the bounded request producer and watcher-observation windows. The +// host no longer owns an ordinary onboarding approval child (#10269). 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 + - OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS; + OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS + + WATCHER_STATUS_TIMEOUT_MS; const OBSERVATION_MAX_OUTPUT_BYTES = 4 * 1_024; export const OPENCLAW_PAIRING_REQUIRED_ROLES = ["operator"] as const; @@ -561,11 +560,11 @@ try: ): reject() if ALLOW_CANONICAL_PENDING: - # The startup watcher can publish the canonical write transition - # before finalization observes the pairing-only device. Admit only - # that exact intermediate state so the owning controller can reach - # its one approval pass. Its final observation still requires the - # settled scopes and no same-device pending request. + # The host warm-up or an earlier client command can publish the + # canonical write transition before finalization observes it. + # Admit only that exact intermediate state while the startup + # watcher remains the sole approval owner. Final observation still + # requires settled scopes and no same-device pending request. decision = approval_request_decision(request) request_scopes = request.get('scopes') valid_write_scopes = ( diff --git a/src/lib/onboard/machine/finalization-deps.test.ts b/src/lib/onboard/machine/finalization-deps.test.ts index 9cadcb507ea..6e9c740615c 100644 --- a/src/lib/onboard/machine/finalization-deps.test.ts +++ b/src/lib/onboard/machine/finalization-deps.test.ts @@ -12,8 +12,10 @@ import { OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS, type OpenClawPairingSettlementObservation, } from "../../actions/sandbox/launch-readiness/openclaw-pairing-qualification"; -import { WARMUP_TIMEOUT_MS } from "../../actions/sandbox/auto-pair-warmup"; -import { CONNECT_AUTO_PAIR_TIMEOUT_MS } from "../../actions/sandbox/connect-autopair-budget"; +import { + WARMUP_TIMEOUT_MS, + WATCHER_STATUS_TIMEOUT_MS, +} from "../../actions/sandbox/auto-pair-warmup"; import { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; import { @@ -59,10 +61,13 @@ function ordinaryPairingDeps( observePairing: vi.fn(() => SETTLED), runWarmup: vi.fn(() => { calls.push("warmup"); + return "request-issued" as const; }), - runApproval: vi.fn(() => { - calls.push("approval"); - }), + readWatcherStatus: vi.fn(() => ({ + schemaVersion: 1 as const, + state: "request-observed" as const, + watcherActive: true, + })), withSandboxLock: vi.fn(async (_name, operation) => operation()), withGatewayLock: vi.fn(async (_gatewayName, operation) => operation()), now: vi.fn(() => now), @@ -94,7 +99,6 @@ describe("ordinary OpenClaw pairing settlement", () => { "/sandbox/.openclaw", ); expect(scope.deps.runWarmup).not.toHaveBeenCalled(); - expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); it("reuses an exact pending upgrade without running another producer (#10014)", async () => { @@ -110,12 +114,11 @@ describe("ordinary OpenClaw pairing settlement", () => { }); 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 () => { + it("waits for canonical pairing before running the request probe (#10269)", async () => { const observePairing = vi - .fn(() => SCOPE_UPGRADE_PENDING) + .fn(() => SETTLED) .mockImplementationOnce(() => { throw new Error("not published"); }) @@ -124,10 +127,7 @@ describe("ordinary OpenClaw pairing settlement", () => { observePairing, runWarmup: vi.fn(() => { scope.calls.push("warmup"); - }), - runApproval: vi.fn(() => { - scope.calls.push("approval"); - vi.mocked(scope.deps.observePairing).mockReturnValue(SETTLED); + return "request-issued" as const; }), }); @@ -135,9 +135,8 @@ describe("ordinary OpenClaw pairing settlement", () => { kind: "settled", }); - expect(scope.calls).toEqual(["warmup", "sleep", "approval"]); + expect(scope.calls).toEqual(["warmup"]); expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); - expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); }); it("holds lifecycle then gateway-route ownership across the full settlement (#9844)", async () => { @@ -159,9 +158,7 @@ describe("ordinary OpenClaw pairing settlement", () => { }), runWarmup: vi.fn(() => { events.push("warmup"); - }), - runApproval: vi.fn(() => { - events.push("approval"); + return "request-issued" as const; }), withSandboxLock: vi.fn(async (_name, operation) => { events.push("sandbox-lock:start"); @@ -187,7 +184,6 @@ describe("ordinary OpenClaw pairing settlement", () => { "observe:precheck", "warmup", "observe:pending", - "approval", "observe:final", "gateway-lock:end", "sandbox-lock:end", @@ -197,16 +193,15 @@ describe("ordinary OpenClaw pairing settlement", () => { it("blocks real lifecycle and route mutations until pairing settlement exits (#9844)", async () => { const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "nemoclaw-pairing-locks-")); let currentTarget = PAIRING_TARGET; - let releaseApproval = () => {}; - let reportApprovalStarted = () => {}; - const approvalPending = new Promise((resolve) => { - releaseApproval = resolve; + let releaseObservation = () => {}; + let reportObservationStarted = () => {}; + const observationPending = new Promise((resolve) => { + releaseObservation = resolve; }); - const approvalStarted = new Promise((resolve) => { - reportApprovalStarted = resolve; + const observationStarted = new Promise((resolve) => { + reportObservationStarted = resolve; }); const mutationEvents: string[] = []; - const approvalTargets: string[] = []; const lockOptions = { pollIntervalMs: 1, stateDir, timeoutMs: 5_000 }; let replacement: Promise | undefined; let routeReuse: Promise | undefined; @@ -215,13 +210,12 @@ describe("ordinary OpenClaw pairing settlement", () => { getTarget: vi.fn(() => currentTarget), observePairing: vi .fn() - .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) .mockReturnValue(SETTLED), - runApproval: vi.fn(async (_name, gatewayName) => { - approvalTargets.push(`${currentTarget.lifecycleGeneration}:${gatewayName}`); - reportApprovalStarted(); - await approvalPending; + sleep: vi.fn(async () => { + reportObservationStarted(); + await observationPending; }), withSandboxLock: (name, operation) => withMcpLifecycleLock(name, operation, lockOptions), withGatewayLock: (gatewayName, operation) => @@ -229,7 +223,7 @@ describe("ordinary OpenClaw pairing settlement", () => { }); const settlement = settleOrdinaryOpenClawPairing("alpha", scope.deps); - await approvalStarted; + await observationStarted; replacement = withMcpLifecycleLock( "alpha", () => { @@ -248,9 +242,8 @@ describe("ordinary OpenClaw pairing settlement", () => { await new Promise((resolve) => setTimeout(resolve, 20)); expect(mutationEvents).toEqual([]); - expect(approvalTargets).toEqual(["generation-1:nemoclaw"]); - releaseApproval(); + releaseObservation(); await expect(settlement).resolves.toEqual({ kind: "settled" }); await Promise.all([replacement, routeReuse]); expect(mutationEvents).toEqual( @@ -258,7 +251,7 @@ describe("ordinary OpenClaw pairing settlement", () => { ); expect(currentTarget.lifecycleGeneration).toBe("generation-2"); } finally { - releaseApproval(); + releaseObservation(); const pendingMutations = [replacement, routeReuse].filter( (mutation): mutation is Promise => mutation !== undefined, ); @@ -280,7 +273,6 @@ describe("ordinary OpenClaw pairing settlement", () => { }); expect(scope.deps.observePairing).not.toHaveBeenCalled(); expect(scope.deps.runWarmup).not.toHaveBeenCalled(); - expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); it("does not enter settlement when lifecycle lock acquisition fails (#9844)", async () => { @@ -309,7 +301,7 @@ describe("ordinary OpenClaw pairing settlement", () => { await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).rejects.toBe(failure); }); - it("stops before approval when the runtime changes during warm-up (#9844)", async () => { + it("stops before observation when the runtime changes during warm-up (#9844)", async () => { let currentTarget = PAIRING_TARGET; let reportWarmupStarted: () => void = () => {}; let releaseWarmup: () => void = () => {}; @@ -328,6 +320,7 @@ describe("ordinary OpenClaw pairing settlement", () => { runWarmup: vi.fn(async () => { reportWarmupStarted(); await warmupPending; + return "request-issued" as const; }), }); @@ -341,43 +334,41 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "runtime-identity-invalid", }); expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); - expect(scope.deps.runApproval).not.toHaveBeenCalled(); expect(scope.deps.observePairing).toHaveBeenCalledOnce(); }); - it("does not observe replacement state when the runtime changes during approval (#9844)", async () => { + it("does not observe replacement state when the runtime changes during watcher settlement (#9844)", async () => { let currentTarget = PAIRING_TARGET; - let reportApprovalStarted: () => void = () => {}; - let releaseApproval: () => void = () => {}; - const approvalStarted = new Promise((resolve) => { - reportApprovalStarted = resolve; + let reportSleepStarted: () => void = () => {}; + let releaseSleep: () => void = () => {}; + const sleepStarted = new Promise((resolve) => { + reportSleepStarted = resolve; }); - const approvalPending = new Promise((resolve) => { - releaseApproval = resolve; + const sleepPending = new Promise((resolve) => { + releaseSleep = resolve; }); const scope = ordinaryPairingDeps({ getTarget: vi.fn(() => currentTarget), observePairing: vi .fn() - .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) .mockReturnValue(SCOPE_UPGRADE_PENDING), - runApproval: vi.fn(async () => { - reportApprovalStarted(); - await approvalPending; + sleep: vi.fn(async () => { + reportSleepStarted(); + await sleepPending; }), }); const settlement = settleOrdinaryOpenClawPairing("alpha", scope.deps); - await approvalStarted; + await sleepStarted; currentTarget = { ...PAIRING_TARGET, lifecycleGeneration: "generation-2" }; - releaseApproval(); + releaseSleep(); await expect(settlement).resolves.toEqual({ kind: "incomplete", reason: "runtime-identity-invalid", }); - expect(scope.deps.runApproval).toHaveBeenCalledOnce(); - expect(scope.deps.observePairing).toHaveBeenCalledTimes(2); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); }); it("rejects device identity drift between precheck and pending upgrade (#10014)", async () => { @@ -397,7 +388,6 @@ describe("ordinary OpenClaw pairing settlement", () => { }); expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); - expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); it("keeps pairing appearance and final observation independently bounded (#9844)", async () => { @@ -406,21 +396,18 @@ describe("ordinary OpenClaw pairing settlement", () => { throw new Error("not published"); }; const scope = ordinaryPairingDeps({ - observePairing: vi.fn(() => - attempts++ < 10 ? unavailable() : SCOPE_UPGRADE_PENDING, - ), + observePairing: vi.fn(() => (attempts++ < 10 ? unavailable() : SCOPE_UPGRADE_PENDING)), }); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ kind: "incomplete", - reason: "scope-upgrade-incomplete", + reason: "scope-upgrade-not-approved", }); - expect(scope.deps.sleep).toHaveBeenCalledTimes(39); - expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); - expect(scope.deps.runApproval).toHaveBeenCalledOnce(); + expect(scope.deps.sleep).toHaveBeenCalled(); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); }); - it("reserves approval and final observation after bounded child caps (#9844)", async () => { + it("reserves watcher observation after bounded child caps (#9844)", async () => { let now = 0; const scope = ordinaryPairingDeps({ now: vi.fn(() => now), @@ -436,13 +423,11 @@ describe("ordinary OpenClaw pairing settlement", () => { .mockImplementationOnce(() => { throw new Error("not pending"); }) - .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) + .mockReturnValueOnce(PAIRING_ONLY) .mockReturnValue(SETTLED), runWarmup: vi.fn(() => { now += WARMUP_TIMEOUT_MS; - }), - runApproval: vi.fn(() => { - now += CONNECT_AUTO_PAIR_TIMEOUT_MS; + return "request-issued" as const; }), }); @@ -451,21 +436,19 @@ describe("ordinary OpenClaw pairing settlement", () => { }); expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); - expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); expect(scope.deps.observePairing).toHaveBeenCalledTimes(4); expect(now).toBe( OPENCLAW_PAIRING_OBSERVATION_TIMEOUT_MS + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS - 1_000 + - WARMUP_TIMEOUT_MS + - CONNECT_AUTO_PAIR_TIMEOUT_MS, + WARMUP_TIMEOUT_MS, ); expect(OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS).toBe( 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, + OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS + + WATCHER_STATUS_TIMEOUT_MS, ); }); @@ -489,11 +472,10 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "pairing-unavailable", }); expect(scope.deps.sleep).not.toHaveBeenCalled(); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); - expect(scope.deps.runApproval).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); }); - it("performs one request-producer write when a canonical CLI pairing never appears (#9844)", async () => { + it("does not run a request producer before a canonical CLI pairing appears (#10269)", async () => { const scope = ordinaryPairingDeps({ observePairing: vi.fn(() => { throw new Error("not published"); @@ -505,20 +487,91 @@ describe("ordinary OpenClaw pairing settlement", () => { reason: "pairing-unavailable", }); - expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); - expect(scope.deps.runApproval).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); }); - it("does not approve before the exact pending upgrade appears (#10014)", async () => { + it("reports when the request producer never creates the exact upgrade (#10269)", async () => { const scope = ordinaryPairingDeps({ observePairing: vi.fn(() => PAIRING_ONLY) }); await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ kind: "incomplete", - reason: "scope-upgrade-incomplete", + reason: "scope-upgrade-not-requested", + }); + + expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); + }); + + it("reports a bounded warm-up transport failure while the device stays pairing-only (#10269)", async () => { + let now = 0; + const scope = ordinaryPairingDeps({ + now: vi.fn(() => now), + observePairing: vi.fn(() => PAIRING_ONLY), + runWarmup: vi.fn(() => "exec-timeout" as const), + sleep: vi.fn(async () => { + now += OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS; + }), }); + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "scope-warmup-failed", + }); expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); - expect(scope.deps.runApproval).not.toHaveBeenCalled(); + }); + + it.each([ + ["approval timeout", "approval-timeout", true, "scope-upgrade-approval-timeout"], + ["approval failure", "approval-failed", true, "scope-upgrade-approval-failed"], + ["policy rejection", "request-rejected", true, "scope-upgrade-rejected"], + ["watcher death", "request-observed", false, "scope-upgrade-watcher-unavailable"], + ["persistent pending", "request-observed", true, "scope-upgrade-not-approved"], + ] as const)( + "classifies %s without trusting watcher status as success (#10269)", + async (_label, state, watcherActive, reason) => { + let now = 0; + const scope = ordinaryPairingDeps({ + now: vi.fn(() => now), + observePairing: vi.fn(() => SCOPE_UPGRADE_PENDING), + sleep: vi.fn(async () => { + now += OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS; + }), + readWatcherStatus: vi.fn(() => ({ schemaVersion: 1 as const, state, watcherActive })), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason, + }); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + }, + ); + + it("accepts canonical late settlement after an earlier approval timeout (#10269)", async () => { + let now = 0; + const observePairing = vi.fn(() => SCOPE_UPGRADE_PENDING); + const first = ordinaryPairingDeps({ + now: vi.fn(() => now), + observePairing, + sleep: vi.fn(async () => { + now += OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS; + }), + readWatcherStatus: vi.fn(() => ({ + schemaVersion: 1 as const, + state: "approval-timeout" as const, + watcherActive: true, + })), + }); + await expect(settleOrdinaryOpenClawPairing("alpha", first.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "scope-upgrade-approval-timeout", + }); + + observePairing.mockReturnValue(SETTLED); + const rerun = ordinaryPairingDeps({ observePairing }); + await expect(settleOrdinaryOpenClawPairing("alpha", rerun.deps)).resolves.toEqual({ + kind: "settled", + }); + expect(rerun.deps.runWarmup).not.toHaveBeenCalled(); }); it("fails closed without writes when the recorded runtime target changes (#9844)", async () => { @@ -536,7 +589,6 @@ describe("ordinary OpenClaw pairing settlement", () => { expect(scope.deps.observePairing).toHaveBeenCalledOnce(); expect(scope.deps.runWarmup).not.toHaveBeenCalled(); - expect(scope.deps.runApproval).not.toHaveBeenCalled(); }); it("resolves the finalized default OpenClaw runtime before observation (#9844)", async () => { @@ -572,14 +624,18 @@ describe("ordinary OpenClaw pairing settlement", () => { expect(runSandboxScopeWarmupRun).not.toHaveBeenCalled(); }); - it("wires the device-authenticated request producer and approval (#10014)", async () => { + it("wires the device-authenticated request producer without a host approval (#10269)", async () => { const observePairing = vi .fn() .mockReturnValueOnce(PAIRING_ONLY) .mockReturnValueOnce(SCOPE_UPGRADE_PENDING) .mockReturnValueOnce(SETTLED); - const runSandboxScopeWarmupRun = vi.fn(); - const runSandboxAutoPairApprovalPass = vi.fn(); + const runSandboxScopeWarmupRun = vi.fn(() => "request-issued" as const); + const readSandboxAutoPairWatcherStatus = vi.fn(() => ({ + schemaVersion: 1 as const, + state: "approval-completed" as const, + watcherActive: true, + })); vi.spyOn(finalizationHandlerRuntime, "loadLaunchReadiness").mockReturnValue({ resolveOrdinaryOpenClawPairingTarget: vi.fn(() => PAIRING_TARGET), } as never); @@ -588,9 +644,7 @@ describe("ordinary OpenClaw pairing settlement", () => { } as never); vi.spyOn(finalizationHandlerRuntime, "loadAutoPairWarmup").mockReturnValue({ runSandboxScopeWarmupRun, - } as never); - vi.spyOn(finalizationHandlerRuntime, "loadAutoPairApproval").mockReturnValue({ - runSandboxAutoPairApprovalPass, + readSandboxAutoPairWatcherStatus, } as never); vi.spyOn(finalizationHandlerRuntime, "loadSandboxLifecycleLock").mockReturnValue({ withMcpLifecycleLock: async (_name: string, operation: () => unknown) => operation(), @@ -603,16 +657,7 @@ describe("ordinary OpenClaw pairing settlement", () => { kind: "settled", }); 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, - }); + expect(readSandboxAutoPairWatcherStatus).not.toHaveBeenCalled(); }); 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 55d323f53ea..0af39320cb5 100644 --- a/src/lib/onboard/machine/finalization-deps.ts +++ b/src/lib/onboard/machine/finalization-deps.ts @@ -9,7 +9,11 @@ import { type OpenClawPairingSettlementObservation, } from "../../actions/sandbox/launch-readiness/openclaw-pairing-qualification"; import type { OpenClawPairingSettlementTarget } from "../../actions/sandbox/launch-readiness"; -import { CONNECT_AUTO_PAIR_TIMEOUT_MS } from "../../actions/sandbox/connect-autopair-budget"; +import type { + AutoPairWatcherStatus, + SandboxScopeWarmupResult, +} from "../../actions/sandbox/auto-pair-warmup"; +import { WATCHER_STATUS_TIMEOUT_MS } from "../../actions/sandbox/auto-pair-warmup"; export { OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS, @@ -36,7 +40,13 @@ export type OrdinaryOpenClawPairingSettlementResult = | "runtime-identity-invalid" | "pairing-lock-unavailable" | "pairing-unavailable" - | "scope-upgrade-incomplete"; + | "scope-warmup-failed" + | "scope-upgrade-not-requested" + | "scope-upgrade-not-approved" + | "scope-upgrade-rejected" + | "scope-upgrade-approval-timeout" + | "scope-upgrade-approval-failed" + | "scope-upgrade-watcher-unavailable"; }; type OrdinaryOpenClawPairingIncompleteReason = Extract< @@ -51,8 +61,15 @@ const ORDINARY_OPENCLAW_PAIRING_INCOMPLETE_CAUSES: Record< "runtime-identity-invalid": "its recorded OpenClaw runtime identity changed or is invalid", "pairing-lock-unavailable": "NemoClaw could not acquire the pairing settlement locks", "pairing-unavailable": "its canonical CLI device pairing did not appear", - "scope-upgrade-incomplete": - "its canonical CLI device did not receive the required baseline scopes", + "scope-warmup-failed": "the bounded CLI scope warm-up could not run", + "scope-upgrade-not-requested": "its canonical CLI scope upgrade was not requested", + "scope-upgrade-not-approved": "its canonical CLI scope upgrade remained pending", + "scope-upgrade-rejected": "the sandbox watcher rejected its canonical CLI scope upgrade", + "scope-upgrade-approval-timeout": + "the sandbox watcher timed out while approving its canonical CLI scope upgrade", + "scope-upgrade-approval-failed": + "the sandbox watcher failed to approve its canonical CLI scope upgrade", + "scope-upgrade-watcher-unavailable": "the sandbox scope-upgrade approval watcher was not running", }; interface OrdinaryOpenClawPairingSettlementDeps { @@ -63,8 +80,11 @@ interface OrdinaryOpenClawPairingSettlementDeps { version: string, stateDirectory: string, ): OpenClawPairingSettlementObservation; - runWarmup(name: string, gatewayName: string): Promise | void; - runApproval(name: string, gatewayName: string): Promise | void; + runWarmup( + name: string, + gatewayName: string, + ): Promise | SandboxScopeWarmupResult; + readWatcherStatus(name: string, gatewayName: string): AutoPairWatcherStatus | null; withSandboxLock: SandboxLifecycleLock; withGatewayLock: GatewayRouteLock; now(): number; @@ -80,8 +100,6 @@ export const finalizationHandlerRuntime = { require("../../actions/sandbox/launch-readiness") as typeof import("../../actions/sandbox/launch-readiness"), loadPairingQualification: () => require("../../actions/sandbox/launch-readiness/openclaw-pairing-qualification") as typeof import("../../actions/sandbox/launch-readiness/openclaw-pairing-qualification"), - loadAutoPairApproval: () => - require("../../actions/sandbox/auto-pair-approval") as typeof import("../../actions/sandbox/auto-pair-approval"), loadAutoPairWarmup: () => require("../../actions/sandbox/auto-pair-warmup") as typeof import("../../actions/sandbox/auto-pair-warmup"), loadSandboxLifecycleLock: () => @@ -107,7 +125,11 @@ function samePairingTarget( type PairingWaitResult = | { readonly kind: "observed"; readonly value: OpenClawPairingSettlementObservation } | { readonly kind: "target-changed" } - | { readonly kind: "timeout" }; + | { readonly kind: "identity-changed" } + | { + readonly kind: "timeout"; + readonly last: OpenClawPairingSettlementObservation | null; + }; async function waitForPairingObservation( name: string, @@ -115,10 +137,12 @@ async function waitForPairingObservation( deadline: number, accept: (value: OpenClawPairingSettlementObservation) => boolean, deps: OrdinaryOpenClawPairingSettlementDeps, + expectedDeviceIdentitySha256?: string, ): Promise { + let last: OpenClawPairingSettlementObservation | null = null; while (true) { const remaining = deadline - deps.now(); - if (remaining <= 0) return { kind: "timeout" }; + if (remaining <= 0) return { kind: "timeout", last }; if (!samePairingTarget(target, deps.getTarget(name))) return { kind: "target-changed" }; try { const value = deps.observePairing( @@ -127,14 +151,21 @@ async function waitForPairingObservation( target.version, target.stateDirectory, ); + if ( + expectedDeviceIdentitySha256 && + value.deviceIdentitySha256 !== expectedDeviceIdentitySha256 + ) { + return { kind: "identity-changed" }; + } + last = value; if (!samePairingTarget(target, deps.getTarget(name))) return { kind: "target-changed" }; - if (deadline - deps.now() <= 0) return { kind: "timeout" }; + if (deadline - deps.now() <= 0) return { kind: "timeout", last }; if (accept(value)) return { kind: "observed", value }; } catch { // Pairing state can be absent or changing while the startup watcher runs. } const remainingAfterAttempt = deadline - deps.now(); - if (remainingAfterAttempt <= 0) return { kind: "timeout" }; + if (remainingAfterAttempt <= 0) return { kind: "timeout", last }; await deps.sleep(Math.min(OPENCLAW_ONBOARDING_PAIRING_POLL_MS, remainingAfterAttempt)); } } @@ -155,21 +186,11 @@ function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { .loadPairingQualification() .observeOrdinaryOpenClawPairingSettlement(...args), runWarmup: (name, gatewayName) => + finalizationHandlerRuntime.loadAutoPairWarmup().runSandboxScopeWarmupRun(name, gatewayName), + readWatcherStatus: (name, gatewayName) => finalizationHandlerRuntime .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, - }); - }, + .readSandboxAutoPairWatcherStatus(name, gatewayName), withSandboxLock: (name, operation, options) => finalizationHandlerRuntime .loadSandboxLifecycleLock() @@ -184,9 +205,9 @@ function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { } /** - * 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. + * Observe canonical state, run one bounded request producer for an exact + * pairing-only device, then wait while the sandbox watcher owns approval. + * Canonical state for the same device is the only success authority. */ export async function settleOrdinaryOpenClawPairing( name: string, @@ -207,12 +228,14 @@ export async function settleOrdinaryOpenClawPairing( if (!samePairingTarget(firstTarget, target)) { return { kind: "incomplete", reason: "runtime-identity-invalid" }; } - const settlementDeadline = deps.now() + OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS; + const settlementDeadline = + deps.now() + + OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS - + WATCHER_STATUS_TIMEOUT_MS; // 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, @@ -220,9 +243,9 @@ export async function settleOrdinaryOpenClawPairing( target.version, target.stateDirectory, ); - sawCanonicalPairing = true; } catch { - // Pairing may not have appeared yet; the producer handles that path. + // Pairing may not have appeared yet. Wait for the canonical device + // before running the request producer. } if (!samePairingTarget(target, deps.getTarget(name))) { return { kind: "incomplete", reason: "runtime-identity-invalid" }; @@ -232,92 +255,76 @@ export async function settleOrdinaryOpenClawPairing( } if (initial?.state === "settled") return { kind: "settled" }; - 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( + if (!initial) { + const pairingAppearance = await waitForPairingObservation( name, target, - pairingAppearanceDeadline, - (value) => { - sawCanonicalPairing = true; - return value.state !== "pairing-only"; - }, + Math.min(settlementDeadline, deps.now() + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS), + () => true, deps, ); + if ( + pairingAppearance.kind === "target-changed" || + pairingAppearance.kind === "identity-changed" + ) { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + if (pairingAppearance.kind === "timeout") { + return { kind: "incomplete", reason: "pairing-unavailable" }; + } + initial = pairingAppearance.value; } - 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 (deps.now() >= settlementDeadline) { - return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; - } + if (initial.state === "settled") return { kind: "settled" }; - let approvalFailed = false; - try { - await deps.runApproval(name, target.gatewayName); - } catch { - approvalFailed = true; + const deviceIdentitySha256 = initial.deviceIdentitySha256; + let warmupResult: SandboxScopeWarmupResult | null = null; + if (initial.state === "pairing-only") { + try { + warmupResult = await deps.runWarmup(name, target.gatewayName); + } catch { + warmupResult = "exec-failed"; + } } if (!samePairingTarget(target, deps.getTarget(name))) { return { kind: "incomplete", reason: "runtime-identity-invalid" }; } - if (approvalFailed) { - return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; - } - - const finalObservationDeadline = Math.min( - settlementDeadline, - deps.now() + OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS, - ); const final = await waitForPairingObservation( name, target, - finalObservationDeadline, - (value) => - value.state === "settled" && - value.deviceIdentitySha256 === baseline.value.deviceIdentitySha256, + settlementDeadline, + (value) => value.state === "settled", deps, + deviceIdentitySha256, ); - if (final.kind === "target-changed") { + if (final.kind === "target-changed" || final.kind === "identity-changed") { return { kind: "incomplete", reason: "runtime-identity-invalid" }; } - return final.kind === "observed" - ? { kind: "settled" } - : { kind: "incomplete", reason: "scope-upgrade-incomplete" }; + if (final.kind === "observed") return { kind: "settled" }; + + if ( + final.last?.state === "pairing-only" && + (warmupResult === "exec-failed" || warmupResult === "exec-timeout") + ) { + return { kind: "incomplete", reason: "scope-warmup-failed" }; + } + if (final.last?.state === "pairing-only") { + return { kind: "incomplete", reason: "scope-upgrade-not-requested" }; + } + + const watcher = deps.readWatcherStatus(name, target.gatewayName); + if (watcher && (!watcher.watcherActive || watcher.state === "stopped")) { + return { kind: "incomplete", reason: "scope-upgrade-watcher-unavailable" }; + } + if (watcher?.state === "request-rejected") { + return { kind: "incomplete", reason: "scope-upgrade-rejected" }; + } + if (watcher?.state === "approval-timeout") { + return { kind: "incomplete", reason: "scope-upgrade-approval-timeout" }; + } + if (watcher?.state === "approval-failed") { + return { kind: "incomplete", reason: "scope-upgrade-approval-failed" }; + } + return { kind: "incomplete", reason: "scope-upgrade-not-approved" }; }); } catch (error) { if (gatewayBodyEntered) throw error; diff --git a/test/agents/openclaw/runtime/auto-pair-settlement-fixture.ts b/test/agents/openclaw/runtime/auto-pair-settlement-fixture.ts new file mode 100644 index 00000000000..da5fa7a9907 --- /dev/null +++ b/test/agents/openclaw/runtime/auto-pair-settlement-fixture.ts @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const PUBLIC_KEY = "y3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8"; +const DEVICE_ID = "04a4c561c730435e9f6a2e38d2e7b929bcbec2ea1c37d3dd053f3341ecce4e47"; + +export function createCanonicalCliFixture(stateDir: string) { + fs.mkdirSync(path.join(stateDir, "identity"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "identity", "device.json"), + JSON.stringify({ + deviceId: DEVICE_ID, + publicKeyPem: + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAy3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8=\n-----END PUBLIC KEY-----\n", + }), + ); + return { + deviceId: DEVICE_ID, + publicKey: PUBLIC_KEY, + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.pairing", "operator.write"], + approvedScopes: ["operator.pairing", "operator.write"], + tokens: { + operator: { + role: "operator", + revokedAtMs: null, + scopes: ["operator.pairing", "operator.read", "operator.write"], + }, + }, + }; +} + +export function setupLateCliFixture(prefix: string): { + tmpDir: string; + fakeOpenclaw: string; + approveLog: string; + stateDir: string; +} { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const stateDir = path.join(tmpDir, "state"); + const stateFile = path.join(tmpDir, "list-count"); + const approveLog = path.join(tmpDir, "approvals.log"); + const browserClient = { clientId: "openclaw-control-ui", clientMode: "webchat" }; + const cliClient = { clientId: "cli", clientMode: "cli" }; + const canonicalCliClient = createCanonicalCliFixture(stateDir); + const initialPending = JSON.stringify({ + pending: [{ requestId: "browser-pair", ...browserClient }], + paired: [], + }); + const browserPaired = JSON.stringify({ pending: [], paired: [browserClient] }); + const lateCli = JSON.stringify({ + pending: [ + { requestId: "late-cli", ...cliClient }, + { requestId: "late-cli-b", ...cliClient }, + ], + paired: [browserClient], + }); + const allPaired = JSON.stringify({ + pending: [], + paired: [browserClient, canonicalCliClient], + }); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then + count="$(cat ${JSON.stringify(stateFile)} 2>/dev/null || echo 0)" + count=$((count + 1)) + echo "$count" > ${JSON.stringify(stateFile)} + if [ "$count" -le 2 ]; then printf '%s\n' ${JSON.stringify(initialPending)} + elif [ "$count" -le 6 ]; then printf '%s\n' ${JSON.stringify(browserPaired)} + elif [ "$count" -le 10 ]; then printf '%s\n' ${JSON.stringify(lateCli)} + else printf '%s\n' ${JSON.stringify(allPaired)}; fi + exit 0 +fi +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + echo "$3" >> ${JSON.stringify(approveLog)} + printf '{}\n' + exit 0 +fi +echo "unexpected: $*" >&2 +exit 2 +`, + { mode: 0o755 }, + ); + return { tmpDir, fakeOpenclaw, approveLog, stateDir }; +} diff --git a/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts b/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts index 8e3a7a3322d..d21cca11f00 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts @@ -7,7 +7,13 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -const START_SCRIPT = path.join(import.meta.dirname, "..", "../../..", "scripts", "nemoclaw-start.sh"); +const START_SCRIPT = path.join( + import.meta.dirname, + "..", + "../../..", + "scripts", + "nemoclaw-start.sh", +); const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "../../..", "scripts", "lib"); function startScriptHeredoc(src: string, marker: string): string { @@ -643,13 +649,7 @@ exit 2 40_000, ); - it("drops a permanently-failing gated approve to slow-mode instead of 1s-looping to the deadline (#6113)", () => { - // cv #6330 item 2: the fast->slow transition must be reached even when the - // gated list/approve path keeps failing and `continue`s. With a near-zero - // FAST_DEADLINE the first iteration must emit the slow-mode transition - // (proving the check runs before the failure `continue`), and the watcher - // must exit within the short deadline rather than busy-polling at 1s. - // (`_env_seconds` rejects a literal 0 as non-positive, so use a tiny value.) + it("keeps a permanently failing gated approval in fast mode until the watcher deadline (#10269)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-permfail-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); const stateDir = path.join(tmpDir, "state"); @@ -708,7 +708,6 @@ exit 2 ...process.env, OPENCLAW_BIN: fakeOpenclaw, OPENCLAW_STATE_DIR: stateDir, - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.01", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", }, @@ -716,9 +715,9 @@ exit 2 }); expect(run.status).toBe(0); - // The transition fired on the permanently-failing gated path (loop-top check). + expect(run.stdout).not.toContain("entering slow-mode"); expect(run.stdout).toContain( - "[auto-pair] fast-mode deadline reached; switching to slow-mode", + '[auto-pair-status] {"schemaVersion":1,"state":"approval-failed"}', ); expect(run.stdout).toContain( "[auto-pair] initial CLI approve failed request=request-1: gateway permanently unavailable", @@ -816,7 +815,6 @@ printf '%s\\n' '{"pending":[],"paired":[]}' ...process.env, OPENCLAW_BIN: fakeOpenclaw, NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", }, timeout: 10_000, @@ -859,7 +857,6 @@ printf '%s\\n' ${JSON.stringify(response)} ...process.env, OPENCLAW_BIN: fakeOpenclaw, NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", }, timeout: 10_000, @@ -1007,7 +1004,7 @@ printf '%s\n' ${JSON.stringify(response)} }, ); - it("enters slow mode for a paired CLI record when all pending request IDs are malformed (#9844)", () => { + it("does not treat an incomplete paired CLI record as the canonical baseline (#10269)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-malformed-pending-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); const approvalMarker = path.join(tmpDir, "approval-called"); @@ -1039,9 +1036,8 @@ printf '%s\n' '{"pending":[{"requestId":"--help","clientId":"cli","clientMode":" "[auto-pair] stage=validation rejected reason=malformed-request-id", ); expect(run.stdout).toContain("[auto-pair] loopback CLI pairing bootstrap completed"); - expect(run.stdout).toContain( - "[auto-pair] devices paired (1); entering slow-mode approvals=0", - ); + expect(run.stdout).not.toContain("entering slow-mode"); + expect(run.stdout).toContain('[auto-pair-status] {"schemaVersion":1,"state":"stopped"}'); expect(run.stdout).not.toContain("--help"); expect(fs.existsSync(approvalMarker)).toBe(false); } finally { diff --git a/test/agents/openclaw/runtime/nemoclaw-start.test.ts b/test/agents/openclaw/runtime/nemoclaw-start.test.ts index 6140ad39bc7..48b164ae5eb 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start.test.ts @@ -9,13 +9,38 @@ import path from "node:path"; import * as ts from "typescript"; import { describe, expect, it } from "vitest"; import { extractShellFunctionFromSource } from "../../../helpers/shell-source"; - -const START_SCRIPT = path.join(import.meta.dirname, "..", "../../..", "scripts", "nemoclaw-start.sh"); +import { createCanonicalCliFixture, setupLateCliFixture } from "./auto-pair-settlement-fixture"; + +const START_SCRIPT = path.join( + import.meta.dirname, + "..", + "../../..", + "scripts", + "nemoclaw-start.sh", +); const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "../../..", "scripts", "lib"); const INSTALLED_APPROVAL_POLICY = "/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py"; -const PRELOAD_SCRIPTS = path.join(import.meta.dirname, "..", "../../..", "nemoclaw-blueprint", "scripts"); -const CHANNEL_RUNTIME_SCRIPTS = path.join(import.meta.dirname, "..", "../../..", "src/lib/messaging/channels"); -const JSON5_MODULE = path.join(import.meta.dirname, "..", "../../..", "nemoclaw", "node_modules", "json5"); +const PRELOAD_SCRIPTS = path.join( + import.meta.dirname, + "..", + "../../..", + "nemoclaw-blueprint", + "scripts", +); +const CHANNEL_RUNTIME_SCRIPTS = path.join( + import.meta.dirname, + "..", + "../../..", + "src/lib/messaging/channels", +); +const JSON5_MODULE = path.join( + import.meta.dirname, + "..", + "../../..", + "nemoclaw", + "node_modules", + "json5", +); function runtimeShellEnvBlock(src: string): string { const start = src.indexOf("write_runtime_shell_env() {"); @@ -321,7 +346,6 @@ describe("nemoclaw-start non-root fallback", () => { expect(result.stderr).not.toContain(token); }); - it("runs runtime preloads and scans before explicit non-root commands", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); const script = [ @@ -408,46 +432,47 @@ describe("nemoclaw-start non-root fallback", () => { expect(result.stdout).not.toContain("true"); }); - it.each( - ["workspace", "memory", "credentials", "flows", "telegram", "media"], - )("repairs writable OpenClaw state directories in non-root mode [%s]", (dir) => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); - const match = src.match(/fix_openclaw_ownership\(\) \{([\s\S]*?)^\s*\}/m); - if (!match) { - throw new Error("Expected fix_openclaw_ownership in scripts/nemoclaw-start.sh"); - } - const fn = `fix_openclaw_ownership() {${match[1]}\n}`; - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-ownership-")); - const openclawDir = path.join(tmpDir, ".openclaw"); - const scriptPath = path.join(tmpDir, "run.sh"); - fs.mkdirSync(openclawDir, { recursive: true }); - fs.writeFileSync(path.join(openclawDir, "openclaw.json"), "{}\n", { mode: 0o644 }); - fs.writeFileSync(path.join(openclawDir, ".config-hash"), "hash\n", { mode: 0o644 }); - fs.writeFileSync( - scriptPath, - ["#!/usr/bin/env bash", "set -euo pipefail", fn, "fix_openclaw_ownership"].join("\n"), - { mode: 0o700 }, - ); - try { - const result = spawnSync("bash", [scriptPath], { - encoding: "utf-8", - timeout: 5000, - env: { ...process.env, HOME: tmpDir }, - }); - expect(result.status).toBe(0); - expect(fs.statSync(path.join(openclawDir, dir)).isDirectory()).toBe(true); - expect((fs.statSync(openclawDir).mode & 0o777).toString(8)).toBe("770"); - expect(fs.statSync(openclawDir).mode & 0o2000).toBe(0o2000); - expect((fs.statSync(path.join(openclawDir, "openclaw.json")).mode & 0o777).toString(8)).toBe( - "660", - ); - expect((fs.statSync(path.join(openclawDir, ".config-hash")).mode & 0o777).toString(8)).toBe( - "660", + it.each(["workspace", "memory", "credentials", "flows", "telegram", "media"])( + "repairs writable OpenClaw state directories in non-root mode [%s]", + (dir) => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const match = src.match(/fix_openclaw_ownership\(\) \{([\s\S]*?)^\s*\}/m); + if (!match) { + throw new Error("Expected fix_openclaw_ownership in scripts/nemoclaw-start.sh"); + } + const fn = `fix_openclaw_ownership() {${match[1]}\n}`; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-ownership-")); + const openclawDir = path.join(tmpDir, ".openclaw"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.mkdirSync(openclawDir, { recursive: true }); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), "{}\n", { mode: 0o644 }); + fs.writeFileSync(path.join(openclawDir, ".config-hash"), "hash\n", { mode: 0o644 }); + fs.writeFileSync( + scriptPath, + ["#!/usr/bin/env bash", "set -euo pipefail", fn, "fix_openclaw_ownership"].join("\n"), + { mode: 0o700 }, ); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); + try { + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + timeout: 5000, + env: { ...process.env, HOME: tmpDir }, + }); + expect(result.status).toBe(0); + expect(fs.statSync(path.join(openclawDir, dir)).isDirectory()).toBe(true); + expect((fs.statSync(openclawDir).mode & 0o777).toString(8)).toBe("770"); + expect(fs.statSync(openclawDir).mode & 0o2000).toBe(0o2000); + expect( + (fs.statSync(path.join(openclawDir, "openclaw.json")).mode & 0o777).toString(8), + ).toBe("660"); + expect((fs.statSync(path.join(openclawDir, ".config-hash")).mode & 0o777).toString(8)).toBe( + "660", + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); }); describe("nemoclaw-start gateway token export (#1114)", () => { @@ -1433,7 +1458,10 @@ describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { }); const pairedJson = JSON.stringify({ pending: [], - paired: [{ clientId: "openclaw-control-ui", clientMode: "webchat" }, { clientId: "cli", clientMode: "cli" }], + paired: [ + { clientId: "openclaw-control-ui", clientMode: "webchat" }, + { clientId: "cli", clientMode: "cli" }, + ], }); fs.writeFileSync( fakeOpenclaw, @@ -1487,9 +1515,7 @@ exit 2 expect(run.stdout).toContain("[auto-pair] approved request=ok-agent-cli client=cli mode=cli"); expect(run.stdout).toContain("[auto-pair] rejected unknown client=other-client mode=webchat"); expect(run.stdout).toContain("[auto-pair] rejected unknown client=evil-client mode=unknown"); - expect(run.stdout).toContain( - "[auto-pair] browser pairing converged; entering slow-mode approvals=2", - ); + expect(run.stdout).not.toContain("entering slow-mode"); expect(fs.readFileSync(approveLog, "utf-8").trim().split("\n")).toEqual([ "ok-browser", "ok-agent-cli", @@ -1512,72 +1538,18 @@ describe("nemoclaw-start auto-pair slow-mode keepalive (#4263)", () => { return autoPairPythonScript(src); } - // Shared late-CLI poll timeline: - // 1-2: first-time browser pairing request pending. - // 3-6: browser paired, nothing pending (watcher converges to slow mode). - // 7-10: late CLI scope upgrade arrives. - // 11+: cli paired alongside browser. - function setupLateCliFixture(prefix: string): { - tmpDir: string; - fakeOpenclaw: string; - approveLog: string; - } { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - const fakeOpenclaw = path.join(tmpDir, "openclaw"); - const stateFile = path.join(tmpDir, "list-count"); - const approveLog = path.join(tmpDir, "approvals.log"); - const browserClient = { clientId: "openclaw-control-ui", clientMode: "webchat" }; - const cliClient = { clientId: "cli", clientMode: "cli" }; - const initialPending = JSON.stringify({ - pending: [{ requestId: "browser-pair", ...browserClient }], - paired: [], - }); - const browserPaired = JSON.stringify({ pending: [], paired: [browserClient] }); - const lateCli = JSON.stringify({ - pending: [ - { requestId: "late-cli", ...cliClient }, - { requestId: "late-cli-b", ...cliClient }, - ], - paired: [browserClient], - }); - const allPaired = JSON.stringify({ pending: [], paired: [browserClient, cliClient] }); - fs.writeFileSync( - fakeOpenclaw, - `#!/usr/bin/env bash -set -euo pipefail -if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then - count="$(cat ${JSON.stringify(stateFile)} 2>/dev/null || echo 0)" - count=$((count + 1)) - echo "$count" > ${JSON.stringify(stateFile)} - if [ "$count" -le 2 ]; then printf '%s\n' ${JSON.stringify(initialPending)} - elif [ "$count" -le 6 ]; then printf '%s\n' ${JSON.stringify(browserPaired)} - elif [ "$count" -le 10 ]; then printf '%s\n' ${JSON.stringify(lateCli)} - else printf '%s\n' ${JSON.stringify(allPaired)}; fi - exit 0 -fi -if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then - echo "$3" >> ${JSON.stringify(approveLog)} - printf '{}\n' - exit 0 -fi -echo "unexpected: $*" >&2 -exit 2 -`, - { mode: 0o755 }, + it("stays fast through browser pairing and slows only after the canonical CLI baseline", () => { + const { tmpDir, fakeOpenclaw, approveLog, stateDir } = setupLateCliFixture( + "nemoclaw-auto-pair-slow-", ); - return { tmpDir, fakeOpenclaw, approveLog }; - } - - it("approves concurrent late CLI scope upgrades after browser pairing converges and drops back to fast cadence", () => { - const { tmpDir, fakeOpenclaw, approveLog } = setupLateCliFixture("nemoclaw-auto-pair-slow-"); try { const run = spawnSync("python3", ["-c", buildAutoPairScript()], { encoding: "utf-8", env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, + OPENCLAW_STATE_DIR: stateDir, // SLOW_INTERVAL > FAST_REENTRY_INTERVAL exposes any regression. - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "600", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "5", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "5", NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3", @@ -1589,20 +1561,18 @@ exit 2 expect(run.stdout).toContain( "[auto-pair] approved request=browser-pair client=openclaw-control-ui mode=webchat", ); - expect(run.stdout).toContain( - "[auto-pair] browser pairing converged; entering slow-mode approvals=1", - ); - // Concurrent late wave — proxy for two sibling sandboxes' upgrades. + expect(run.stdout).not.toContain("browser pairing converged"); + // Concurrent late wave is handled before the fast-to-slow transition. expect(run.stdout).toContain("[auto-pair] approved request=late-cli client=cli mode=cli"); expect(run.stdout).toContain("[auto-pair] approved request=late-cli-b client=cli mode=cli"); expect(run.stdout).toContain("watcher deadline reached approvals=3"); - // Single marker per poll wave, transition after convergence. - expect(run.stdout).toContain("[auto-pair] fast-reentry bumped polls=3 approved=3 mode=slow"); - const convergedAt = run.stdout.indexOf("browser pairing converged"); - const bumpedAt = run.stdout.indexOf("fast-reentry bumped polls=3 approved=3 mode=slow"); - expect(bumpedAt).toBeGreaterThan(convergedAt); - const slowMarkerRe = /fast-reentry bumped polls=3 approved=3 mode=slow/g; - expect(run.stdout.match(slowMarkerRe)?.length).toBe(1); + expect(run.stdout).toContain( + "[auto-pair] canonical CLI baseline settled; entering slow-mode approvals=3", + ); + expect(run.stdout).toContain("[auto-pair] fast-reentry bumped polls=3 approved=3 mode=fast"); + const approvedAt = run.stdout.indexOf("approved request=late-cli-b"); + const settledAt = run.stdout.indexOf("canonical CLI baseline settled"); + expect(settledAt).toBeGreaterThan(approvedAt); expect(fs.readFileSync(approveLog, "utf-8").trim().split("\n")).toEqual([ "browser-pair", "late-cli", @@ -1616,19 +1586,45 @@ exit 2 it("rejects unknown clients in slow-mode keepalive", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-slow-evil-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const stateDir = path.join(tmpDir, "state"); const stateFile = path.join(tmpDir, "list-count"); const approveLog = path.join(tmpDir, "approvals.log"); + const publicKey = "y3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8"; + const deviceId = "04a4c561c730435e9f6a2e38d2e7b929bcbec2ea1c37d3dd053f3341ecce4e47"; + fs.mkdirSync(path.join(stateDir, "identity"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "identity", "device.json"), + JSON.stringify({ + deviceId, + publicKeyPem: + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAy3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8=\n-----END PUBLIC KEY-----\n", + }), + ); - // Non-browser paired entry — exercises the `devices paired` slow-mode - // transition (not the browser-specific one) so we can prove the - // allowlist still rejects rogue clients in either convergence path. + const canonicalCli = { + deviceId, + publicKey, + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.pairing", "operator.write"], + approvedScopes: ["operator.pairing", "operator.write"], + tokens: { + operator: { + role: "operator", + revokedAtMs: null, + scopes: ["operator.pairing", "operator.read", "operator.write"], + }, + }, + }; const initialPaired = JSON.stringify({ pending: [], - paired: [{ clientId: "paired-cli", clientMode: "cli" }], + paired: [canonicalCli], }); const evilLate = JSON.stringify({ pending: [{ requestId: "evil-late", clientId: "evil-client", clientMode: "unknown" }], - paired: [{ clientId: "paired-cli", clientMode: "cli" }], + paired: [canonicalCli], }); fs.writeFileSync( @@ -1663,7 +1659,7 @@ exit 2 env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "600", + OPENCLAW_STATE_DIR: stateDir, NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "5", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", }, @@ -1671,7 +1667,7 @@ exit 2 }); expect(run.status).toBe(0); expect(run.stdout).toContain( - "[auto-pair] devices paired (1); entering slow-mode approvals=0", + "[auto-pair] canonical CLI baseline settled; entering slow-mode approvals=0", ); expect(run.stdout).toContain("[auto-pair] rejected unknown client=evil-client mode=unknown"); // Critical: never approved. @@ -1722,7 +1718,6 @@ exit 2 env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", }, @@ -1794,7 +1789,6 @@ exit 2 ...process.env, OPENCLAW_BIN: fakeOpenclaw, NEMOCLAW_APPROVAL_POLICY_DIR: maliciousPolicyDir, - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", }, @@ -1811,7 +1805,7 @@ exit 2 } }, 30_000); - it("falls back to fast-deadline transition when no convergence signal arrives", () => { + it("keeps fast polling when no canonical CLI baseline appears (#10269)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-slow-fastdl-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); const approveLog = path.join(tmpDir, "approvals.log"); @@ -1842,17 +1836,15 @@ exit 2 env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, - // Fast deadline is already past at startup, so the watcher - // immediately enters slow mode without needing convergence. - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", }, timeout: 20_000, }); expect(run.status).toBe(0); + expect(run.stdout).not.toContain("entering slow-mode"); expect(run.stdout).toContain( - "[auto-pair] fast-mode deadline reached; switching to slow-mode approvals=0", + '[auto-pair-status] {"schemaVersion":1,"state":"request-not-produced"}', ); expect(run.stdout).toContain("watcher deadline reached approvals=0"); expect(fs.existsSync(approveLog)).toBe(false); @@ -1861,14 +1853,7 @@ exit 2 } }, 30_000); - it("fast-deadline transitions to slow-mode even while pending requests are sticky", () => { - // Regression for the Codex review finding: a permanently-pending - // request (rejected unknown client added to HANDLED, or approve - // failure that never clears) used to hold the watcher in the - // 1s-polling pending branch for the full DEADLINE, recreating the - // NemoClaw#2484 connect-handler pile-up on an 8h timeline. The - // fast-deadline transition must apply even when `pending` stays - // non-empty. + it("keeps a rejected sticky request in fast mode without approving it (#10269)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-sticky-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); const approveLog = path.join(tmpDir, "approvals.log"); @@ -1902,20 +1887,13 @@ exit 2 env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, - // Fast deadline already past — slow-mode transition must fire - // on the very next poll even though pending is non-empty. - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", }, timeout: 20_000, }); expect(run.status).toBe(0); - // First poll: rejects the evil client, then evaluates fast-deadline - // before the pending-branch continue, and transitions to slow mode. - expect(run.stdout).toContain( - "[auto-pair] fast-mode deadline reached; switching to slow-mode approvals=0", - ); + expect(run.stdout).not.toContain("entering slow-mode"); expect(run.stdout).toContain("[auto-pair] rejected unknown client=evil-client mode=unknown"); expect(run.stdout).toContain("watcher deadline reached approvals=0"); // Unknown client was never approved. @@ -1952,7 +1930,6 @@ exit 0 OPENCLAW_BIN: fakeOpenclaw, // Watcher must finish well before the test timeout while still // exercising a genuine subprocess.run timeout. - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "0.05", NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "0.25", @@ -2030,7 +2007,6 @@ exit 2 env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "3", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "0.05", NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "0.75", @@ -2102,7 +2078,6 @@ exit 2 env: { ...process.env, OPENCLAW_BIN: fakeOpenclaw, - NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "600", NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS: "3", @@ -2264,9 +2239,7 @@ describe("NC-2227-01: legacy migration behavior", () => { } }); - it.each( - ["workspace-existing", "workspace-main", "workspace-alpha", "workspace-beta"], - )( + it.each(["workspace-existing", "workspace-main", "workspace-alpha", "workspace-beta"])( "provisions only canonical workspace paths from OpenClaw config [%s]", (name) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-workspaces-")); @@ -2373,7 +2346,11 @@ describe("seed_default_workspace_templates (#3240)", () => { try { const result = runSeed(workspaceDir, templatesDir, path.join(tmpDir, "seed.sh")); expect(result.status).toBe(0); - expect(["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md"].every((name) => fs.existsSync(path.join(workspaceDir, name)))).toBe(true); + expect( + ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md"].every( + (name) => fs.existsSync(path.join(workspaceDir, name)), + ), + ).toBe(true); expect(fs.existsSync(path.join(workspaceDir, "BOOTSTRAP.md"))).toBe(false); expect(fs.readFileSync(path.join(workspaceDir, "SOUL.md"), "utf-8")).toBe( "# SOUL.md template content\n", @@ -2489,7 +2466,11 @@ describe("seed_default_workspace_templates (#3240)", () => { env: { PATH: `${fakeBin}:${path.dirname(process.execPath)}:${process.env.PATH || ""}` }, }); expect(result.status).toBe(0); - expect(["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md"].every((name) => !fs.existsSync(path.join(workspaceDir, name)))).toBe(true); + expect( + ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md"].every( + (name) => !fs.existsSync(path.join(workspaceDir, name)), + ), + ).toBe(true); expect(result.stderr).toContain("openclaw workspace templates dir not found"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -2617,7 +2598,11 @@ describe("seed_default_workspace_templates (#3240)", () => { expect(result.status).toBe(0); expect(result.stderr).toContain("NEMOCLAW_MINIMAL_BOOTSTRAP=1"); expect(result.stderr).toContain("skipping default workspace template seed"); - expect(["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md"].every((name) => !fs.existsSync(path.join(workspaceDir, name)))).toBe(true); + expect( + ["AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md"].every( + (name) => !fs.existsSync(path.join(workspaceDir, name)), + ), + ).toBe(true); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -3141,15 +3126,13 @@ describe("provider placeholder refresh (#4251)", () => { ); }); - it.each( - [ - "GITHUB_TOKEN", - "AWS_SECRET_ACCESS_KEY", - "NPM_TOKEN", - "KUBECONFIG", - "NEMOCLAW_EXTRA_PLACEHOLDER_KEYS", - ], - )( + it.each([ + "GITHUB_TOKEN", + "AWS_SECRET_ACCESS_KEY", + "NPM_TOKEN", + "KUBECONFIG", + "NEMOCLAW_EXTRA_PLACEHOLDER_KEYS", + ])( "refuses arbitrary host secret names that do not extend a discovered provider envKey inside the sandbox [%s]", (blocked) => { // Defence-in-depth: even if an operator clobbers NEMOCLAW_EXTRA_PLACEHOLDER_KEYS @@ -3334,7 +3317,9 @@ describe("Telegram diagnostics (#2766)", () => { .slice(start, end) .replaceAll("/tmp/gateway.log", gatewayLog) .replaceAll("/tmp/auto-pair.log", autoPairLog); - return kind === "non-root" ? `${extractShellFunctionFromSource(src, "_nemoclaw_capture_epoch_realtime")}\n${block}fi\n` : block; + return kind === "non-root" + ? `${extractShellFunctionFromSource(src, "_nemoclaw_capture_epoch_realtime")}\n${block}fi\n` + : block; } function runPreGatewaySetup(kind: EntryKind) { From cef82e2efbb83933849316f892246815aefe6003 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:09:01 -0700 Subject: [PATCH 2/6] fix(onboard): ignore unrelated pairing requests Signed-off-by: Rebecca Sliter --- .../openclaw-pairing-qualification.test.ts | 19 ++++++++++++------- .../openclaw-pairing-qualification.ts | 15 +++++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) 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 92986209159..cdcf299a71f 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 @@ -309,7 +309,7 @@ describe("OpenClaw launch-readiness pairing qualification", () => { expect(() => observeSettlement()).toThrow("OpenClaw pairing qualification is unavailable"); }); - it("rejects unrelated or same-device pending requests during ordinary onboarding (#9844)", () => { + it("ignores unrelated pending requests during ordinary onboarding (#9844)", () => { writeJson(path.join(stateDirectory, "devices", "pending.json"), { unrelated: { requestId: "unrelated", @@ -320,14 +320,17 @@ describe("OpenClaw launch-readiness pairing qualification", () => { }, }); - expect(() => observeOrdinarySettlement()).toThrow( - "OpenClaw pairing qualification is unavailable", - ); + expect(observeOrdinarySettlement()).toEqual({ + state: "settled", + deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); expect(() => observeRepairSettlement()).toThrow( "OpenClaw pairing qualification is unavailable", ); expect(() => observeSettlement()).toThrow("OpenClaw pairing qualification is unavailable"); + }); + it("rejects malformed same-device pending requests during ordinary onboarding (#9844)", () => { writeJson(path.join(stateDirectory, "devices", "pending.json"), { related: { requestId: "related", @@ -337,6 +340,7 @@ describe("OpenClaw launch-readiness pairing qualification", () => { scopes: ["operator.admin"], }, }); + expect(() => observeOrdinarySettlement()).toThrow( "OpenClaw pairing qualification is unavailable", ); @@ -391,9 +395,10 @@ describe("OpenClaw launch-readiness pairing qualification", () => { scopes: ["operator.admin"], }, }); - expect(() => observeOrdinarySettlement()).toThrow( - "OpenClaw pairing qualification is unavailable", - ); + expect(observeOrdinarySettlement()).toEqual({ + state: "scope-upgrade-pending", + deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); expect(() => observeRepairSettlement()).toThrow( "OpenClaw pairing qualification is unavailable", ); 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 3f90d8ae00b..7daccd7626b 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts @@ -551,14 +551,18 @@ try: if STRICT_SETTLEMENT and pending: reject() + canonical_pending_count = 0 for request_id, request in pending.items(): if ( not isinstance(request_id, str) or not request_id or not isinstance(request, dict) - or request.get('requestId') != request_id ): reject() + if ORDINARY_SETTLEMENT and request.get('deviceId') != device_id: + continue + if request.get('requestId') != request_id: + reject() if ALLOW_CANONICAL_PENDING: # The host warm-up or an earlier client command can publish the # canonical write transition before finalization observes it. @@ -587,6 +591,7 @@ try: or decision.get('allowed') is not True ): reject() + canonical_pending_count += 1 continue decision = approval_request_decision(request) if decision.get('reason') == 'malformed-scopes': @@ -614,8 +619,10 @@ try: and exact_string_set(paired_operator.get('scopes'), PAIRING_ONLY_SCOPES) and exact_string_set(auth_operator.get('scopes'), PAIRING_ONLY_SCOPES) ) - if ALLOW_CANONICAL_PENDING and pending and (len(pending) != 1 or not pairing_only): - reject() + if ALLOW_CANONICAL_PENDING: + relevant_pending_count = canonical_pending_count if ORDINARY_SETTLEMENT else len(pending) + if relevant_pending_count and (relevant_pending_count != 1 or not pairing_only): + reject() if not settled and not pairing_only: reject() @@ -625,7 +632,7 @@ try: }, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest() ${ mode !== "qualification" - ? "print(MARKER + json.dumps({\n 'deviceIdentitySha256': device_identity_sha256,\n 'state': ('settled' if settled else ('scope-upgrade-pending' if ORDINARY_SETTLEMENT and pending else ('pairing-pending' if REPORT_CANONICAL_PENDING and pending else 'pairing-only'))),\n }, sort_keys=True, separators=(',', ':')))\n sys.exit(0)" + ? "print(MARKER + json.dumps({\n 'deviceIdentitySha256': device_identity_sha256,\n 'state': ('settled' if settled else ('scope-upgrade-pending' if ORDINARY_SETTLEMENT and canonical_pending_count else ('pairing-pending' if REPORT_CANONICAL_PENDING and pending else 'pairing-only'))),\n }, sort_keys=True, separators=(',', ':')))\n sys.exit(0)" : "if not settled:\n reject()" } From 44eafa926226916fbe78e0741ec2c2e3992ed3a0 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:18:38 -0700 Subject: [PATCH 3/6] test(onboard): reuse canonical pairing fixture Signed-off-by: Rebecca Sliter --- ci/test-file-size-budget.json | 2 +- .../openclaw/runtime/nemoclaw-start.test.ts | 30 +------------------ 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index aeae5149d75..32eb6c29570 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1875, "test/generation/generate-openclaw-config.test.ts": 1906, "test/installer-integration/install-preflight.test.ts": 3025, - "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4656, + "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4628, "test/onboarding/onboard-messaging.test.ts": 2023, "test/onboarding/onboard-selection.test.ts": 4176 } diff --git a/test/agents/openclaw/runtime/nemoclaw-start.test.ts b/test/agents/openclaw/runtime/nemoclaw-start.test.ts index 48b164ae5eb..080bdbd3ccb 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start.test.ts @@ -1589,35 +1589,7 @@ describe("nemoclaw-start auto-pair slow-mode keepalive (#4263)", () => { const stateDir = path.join(tmpDir, "state"); const stateFile = path.join(tmpDir, "list-count"); const approveLog = path.join(tmpDir, "approvals.log"); - const publicKey = "y3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8"; - const deviceId = "04a4c561c730435e9f6a2e38d2e7b929bcbec2ea1c37d3dd053f3341ecce4e47"; - fs.mkdirSync(path.join(stateDir, "identity"), { recursive: true }); - fs.writeFileSync( - path.join(stateDir, "identity", "device.json"), - JSON.stringify({ - deviceId, - publicKeyPem: - "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAy3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8=\n-----END PUBLIC KEY-----\n", - }), - ); - - const canonicalCli = { - deviceId, - publicKey, - clientId: "cli", - clientMode: "cli", - role: "operator", - roles: ["operator"], - scopes: ["operator.pairing", "operator.write"], - approvedScopes: ["operator.pairing", "operator.write"], - tokens: { - operator: { - role: "operator", - revokedAtMs: null, - scopes: ["operator.pairing", "operator.read", "operator.write"], - }, - }, - }; + const canonicalCli = createCanonicalCliFixture(stateDir); const initialPaired = JSON.stringify({ pending: [], paired: [canonicalCli], From 4688c4287959f52597bd6ab4698be4974d422953 Mon Sep 17 00:00:00 2001 From: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:53:48 -0700 Subject: [PATCH 4/6] docs(openclaw): clarify auto-pair slow cadence Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> --- docs/reference/commands.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 3f919ed78f3..d580b4e4cd1 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -5043,7 +5043,7 @@ OpenClaw-specific onboarding configuration: | `TAVILY_API_KEY` | Tavily Search API key | Supplies and implicitly selects Tavily Search when no provider is set and no Brave key is available. NemoClaw validates the key and stores it in OpenShell rather than the sandbox. | | `NEMOCLAW_AGENT_TIMEOUT` | positive integer (seconds) | Onboarding setting that overrides `agents.defaults.timeoutSeconds` and `models.providers..timeoutSeconds` in the generated OpenClaw config. Set it before onboarding creates or recreates the sandbox. Setting it only for a later `$$nemoclaw agent` invocation does not change the existing sandbox. Raise for slow inference. | | `NEMOCLAW_MCP_SHADOW_DIAGNOSTICS` | literal `1` to enable | Forwards opt-in successful Streamable HTTP MCP timing diagnostics to a newly created or rebuilt OpenClaw sandbox. It does not change timeouts, retries, requests, or responses. Unset it and rebuild after evidence collection to restore failure-only logging. Other values are ignored. | -| `NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS` | positive number of seconds | Sets the post-pairing poll cadence for the in-sandbox OpenClaw auto-pair watcher. Defaults to `5` so late allowlisted CLI and browser scope upgrades are approved before clients time out. Raise only on load-sensitive gateways. | +| `NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS` | positive number of seconds | Sets the in-sandbox OpenClaw auto-pair watcher cadence after the canonical local CLI device reaches the exact baseline scopes and has no same-device pending request. Defaults to `5` seconds. Before that settlement, the watcher polls every `1` second. Raise only on load-sensitive gateways. | | `NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS` | positive integer | Sets how many fast polls run after the watcher observes a fresh allowlisted scope-upgrade request. Defaults to `5`; set lower only when you need to reduce gateway polling. | | `NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS` | positive number of seconds | Sets the fast-reentry interval after a fresh allowlisted scope-upgrade request. Defaults to `1`. | | `NEMOCLAW_CONTEXT_WINDOW` | positive integer (tokens) | Overrides the model's context-window value in the generated OpenClaw config. | From 5885478fd7efd587ddbe62fc1b47a31616b78624 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 26 Aug 2026 11:12:38 -0700 Subject: [PATCH 5/6] test(onboard): prove watcher-only scope approval Signed-off-by: Prekshi Vyas --- ...nemoclaw-start-auto-pair-bootstrap.test.ts | 215 +++++++++++++++++- 1 file changed, 208 insertions(+), 7 deletions(-) diff --git a/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts b/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts index d21cca11f00..621f0a5241d 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts @@ -1,12 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { spawnSync } from "node:child_process"; +import { spawn, 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 { settleOrdinaryOpenClawPairing } from "../../../../src/lib/onboard/machine/finalization-deps"; +import { createCanonicalCliFixture } from "./auto-pair-settlement-fixture"; + const START_SCRIPT = path.join( import.meta.dirname, "..", @@ -29,12 +32,17 @@ function trustedApprovalPolicyFile(tmpDir: string): string { return helperPath; } -function autoPairPythonScript(src: string, tmpDir: string): string { - return startScriptHeredoc(src, "PYAUTOPAIR") - .replace( - "APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py'", - `APPROVAL_POLICY_FILE = ${JSON.stringify(trustedApprovalPolicyFile(tmpDir))}`, - ) +function autoPairPythonScript( + src: string, + tmpDir: string, + options: { realTime?: boolean } = {}, +): string { + const script = startScriptHeredoc(src, "PYAUTOPAIR").replace( + "APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py'", + `APPROVAL_POLICY_FILE = ${JSON.stringify(trustedApprovalPolicyFile(tmpDir))}`, + ); + if (options.realTime) return script; + return script .replaceAll("time.time()", "_nemoclaw_test_time()") .replaceAll("time.sleep(", "_nemoclaw_test_sleep(") .replace( @@ -50,6 +58,199 @@ def _nemoclaw_test_sleep(seconds): _nemoclaw_test_clock.__setitem__(0, _nemoclaw describe("nemoclaw-start initial CLI auto-pair bootstrap (#6113)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); + it.each([ + ["watcher settles before the host observes the request", true], + ["host observes the request before the watcher settles", false], + ] as const)( + "keeps the watcher as the only scope approver when %s (#10269)", + async (_timing, watcherFirst) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-single-approver-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const stateDir = path.join(tmpDir, "state"); + const phaseFile = path.join(tmpDir, "phase"); + const listLog = path.join(tmpDir, "list.log"); + const approvalLog = path.join(tmpDir, "approvals.log"); + const requestId = "scope-upgrade"; + const canonicalCli = createCanonicalCliFixture(stateDir); + const pairingOnlyCli = { + ...canonicalCli, + scopes: ["operator.pairing"], + approvedScopes: ["operator.pairing"], + tokens: { + operator: { + role: "operator", + revokedAtMs: null, + scopes: ["operator.pairing"], + }, + }, + }; + const pendingRequest = { + requestId, + deviceId: canonicalCli.deviceId, + publicKey: canonicalCli.publicKey, + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.write"], + isRepair: false, + }; + const pairingOnlyList = JSON.stringify({ pending: [], paired: [pairingOnlyCli] }); + const pendingList = JSON.stringify({ + pending: [pendingRequest], + paired: [pairingOnlyCli], + }); + const settledList = JSON.stringify({ pending: [], paired: [canonicalCli] }); + fs.writeFileSync(phaseFile, "pairing-only"); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then + printf 'list\n' >> ${JSON.stringify(listLog)} + phase="$(cat ${JSON.stringify(phaseFile)})" + if [ "$phase" = "pairing-only" ]; then printf '%s\n' ${JSON.stringify(pairingOnlyList)} + elif [ "$phase" = "scope-upgrade-pending" ]; then printf '%s\n' ${JSON.stringify(pendingList)} + else printf '%s\n' ${JSON.stringify(settledList)}; fi + exit 0 +fi +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + printf 'watcher:%s\n' "$3" >> ${JSON.stringify(approvalLog)} + [ "$3" = ${JSON.stringify(requestId)} ] || exit 7 + printf 'settled' > ${JSON.stringify(phaseFile)} + printf '{}\n' + exit 0 +fi +echo "unexpected: $*" >&2 +exit 2 +`, + { mode: 0o755 }, + ); + + let watcher: ReturnType | undefined; + let watcherOutput = ""; + let hostApprovalAttempts = 0; + let warmupRuns = 0; + let reportPendingObserved = () => {}; + const pendingObserved = new Promise((resolve) => { + reportPendingObserved = resolve; + }); + const waitFor = async (predicate: () => boolean, label: string) => { + const deadline = Date.now() + 5_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}.`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + const startWatcher = () => { + const child = spawn( + "python3", + ["-u", "-c", autoPairPythonScript(src, tmpDir, { realTime: true })], + { + env: { + ...process.env, + OPENCLAW_BIN: fakeOpenclaw, + OPENCLAW_STATE_DIR: stateDir, + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "6", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "0.05", + NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS: "0.05", + NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + child.stdout?.on("data", (chunk) => { + watcherOutput += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + watcherOutput += String(chunk); + }); + return child; + }; + + try { + if (watcherFirst) { + watcher = startWatcher(); + await waitFor(() => fs.existsSync(listLog), "the watcher's first observation"); + } + + const target = { + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: "fingerprint-1", + stateDirectory: stateDir, + version: "2026.7.1", + }; + const deps = { + getTarget: () => target, + observePairing: () => { + const state = fs.readFileSync(phaseFile, "utf-8").trim() as + | "pairing-only" + | "scope-upgrade-pending" + | "settled"; + if (state === "scope-upgrade-pending") reportPendingObserved(); + return { state, deviceIdentitySha256: canonicalCli.deviceId }; + }, + runWarmup: async () => { + warmupRuns += 1; + fs.writeFileSync(phaseFile, "scope-upgrade-pending"); + if (watcherFirst) { + await waitFor( + () => fs.readFileSync(phaseFile, "utf-8").trim() === "settled", + "the watcher to settle before host observation", + ); + } + return "request-issued" as const; + }, + readWatcherStatus: () => ({ + schemaVersion: 1 as const, + state: "approval-completed" as const, + watcherActive: true, + }), + withSandboxLock: async (_name, operation) => operation(), + withGatewayLock: async (_name, operation) => operation(), + now: () => performance.now(), + sleep: async () => new Promise((resolve) => setTimeout(resolve, 10)), + // The current state machine ignores this legacy dependency. Keeping + // it in the harness makes the test fail against the two-owner design. + runApproval: () => { + hostApprovalAttempts += 1; + fs.appendFileSync(approvalLog, `host:${requestId}\n`); + fs.writeFileSync(phaseFile, "settled"); + }, + }; + + const settlement = settleOrdinaryOpenClawPairing("alpha", deps); + if (!watcherFirst) { + await pendingObserved; + watcher = startWatcher(); + } + + await expect(settlement).resolves.toEqual({ kind: "settled" }); + expect(warmupRuns).toBe(1); + expect(hostApprovalAttempts).toBe(0); + expect(fs.readFileSync(phaseFile, "utf-8").trim()).toBe("settled"); + expect(fs.readFileSync(approvalLog, "utf-8").trim().split("\n")).toEqual([ + `watcher:${requestId}`, + ]); + } finally { + if (watcher && watcher.exitCode === null) watcher.kill("SIGTERM"); + if (watcher && watcher.exitCode === null) { + await Promise.race([ + new Promise((resolve) => watcher?.once("close", resolve)), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + + expect(watcherOutput).toContain( + `[auto-pair] approved request=${requestId} client=cli mode=cli`, + ); + }, + 30_000, + ); + it("approves an initial CLI pairing request when device list is itself gated (#6113)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-bootstrap-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); From ccc37ccb938345811354c368f29f440df4b79013 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Wed, 26 Aug 2026 12:03:29 -0700 Subject: [PATCH 6/6] fix(onboard): expose safe auto-pair status Signed-off-by: Prekshi Vyas --- ci/test-file-size-budget.json | 2 +- scripts/nemoclaw-start.sh | 69 +++++----- .../actions/sandbox/auto-pair-warmup.test.ts | 124 +++++++++++------- src/lib/actions/sandbox/auto-pair-warmup.ts | 49 ++++--- ...nemoclaw-start-auto-pair-bootstrap.test.ts | 124 ++++++++++++------ .../runtime/nemoclaw-start-safe-tmp.test.ts | 19 ++- .../openclaw/runtime/nemoclaw-start.test.ts | 16 +-- test/e2e-gateway-isolation.sh | 28 ++++ 8 files changed, 284 insertions(+), 147 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index f0a45565a13..635e2984d6e 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -7,7 +7,7 @@ "src/lib/onboard/preflight.test.ts": 1875, "test/generation/generate-openclaw-config.test.ts": 1906, "test/installer-integration/install-preflight.test.ts": 3025, - "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4628, + "test/agents/openclaw/runtime/nemoclaw-start.test.ts": 4626, "test/onboarding/onboard-messaging.test.ts": 1979, "test/onboarding/onboard-selection.test.ts": 4176 } diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index eaf805a72bb..c2115e6fa3c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2656,6 +2656,7 @@ import sys import time LAST_SANITIZED_STATUS = None +STATUS_PATH = '/tmp/nemoclaw-auto-pair-status.json' def publish_status(state): @@ -2663,10 +2664,38 @@ def publish_status(state): if state == LAST_SANITIZED_STATUS: return LAST_SANITIZED_STATUS = state - print('[auto-pair-status] ' + json.dumps({ + status = json.dumps({ 'schemaVersion': 1, 'state': state, - }, separators=(',', ':')), flush=True) + }, separators=(',', ':')) + print('[auto-pair-status] ' + status, flush=True) + status_fd = None + try: + status_fd = os.open( + STATUS_PATH, + os.O_WRONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK, + ) + metadata = os.fstat(status_fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.geteuid() + or metadata.st_gid != os.getegid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + ): + raise OSError('unsafe watcher status metadata') + os.ftruncate(status_fd, 0) + remaining = status.encode('utf-8') + while remaining: + written = os.write(status_fd, remaining) + if written <= 0: + raise OSError('watcher status write made no progress') + remaining = remaining[written:] + except Exception: + pass + finally: + if status_fd is not None: + os.close(status_fd) print('[auto-pair] watcher started', flush=True) @@ -2745,32 +2774,8 @@ DEADLINE = time.time() + _env_seconds('NEMOCLAW_AUTO_PAIR_DEADLINE_SECS', 28800) # as `operator.admin` are still rejected by the device approval policy, and # requests that need them must be approved through a separate operator path. SLOW_INTERVAL = _env_seconds('NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS', 5) -# SOURCE_OF_TRUTH_REVIEW (auto-pair slow-mode cadence default 30s → 5s): -# -# * Source boundary: the single SLOW_INTERVAL global above is the only -# steady-state inter-poll wait for the in-sandbox auto-pair watcher -# after the canonical CLI baseline settles. The watcher's faster pre-settlement -# cadence (1s) is unaffected. -# * Invalid state at the old default: a late -# `openclaw tui` / `openclaw agent` allowlisted scope upgrade lands -# inside a 30s window and waits up to one full SLOW_INTERVAL before -# the watcher polls. Two sibling sandboxes onboarded back-to-back -# each hit this window and both fall back to embedded mode (#5343). -# * Source-fix constraint: the 5s default is a bounded 6x increase in -# steady-state `openclaw devices list --json` calls per sandbox — at -# most one extra call per 5s vs. per 30s, which the gateway connect -# handler tolerates easily; the bounded fast-reentry counter above -# keeps cascading upgrades from exceeding this cadence. -# * Migration: operators who relied on the old cadence (load-sensitive -# gateways, large multi-sandbox deployments) can restore it by -# exporting NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS=30 in the sandbox -# environment; the PR body calls this out under "Changes" too. -# * Regression test: test/agents/openclaw/runtime/nemoclaw-start.test.ts's late-CLI fixture -# covers the new default deterministically; #5343 Phase 5 covers it -# end to end. -# * Removal condition: when OpenClaw signals scope-upgrade requests via -# a push channel rather than a poll, the cadence becomes irrelevant -# and the variable retires. +# Fast reentry temporarily restores 1s polling after a fresh allowlisted +# request; canonical settlement and approval policy remain unchanged. FAST_REENTRY_POLLS = int(_env_seconds('NEMOCLAW_AUTO_PAIR_FAST_REENTRY_POLLS', 5)) FAST_REENTRY_INTERVAL = _env_seconds('NEMOCLAW_AUTO_PAIR_FAST_REENTRY_INTERVAL_SECS', 1) FAST_REENTRY_REMAINING = 0 @@ -3387,9 +3392,13 @@ prepare_auto_pair_log() { if [ "$(id -u)" -eq 0 ]; then # PID 1 opens the redirection after CAP_DAC_OVERRIDE is gone, then passes # the already-open descriptor to the stepped-down watcher. - _nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600 root:root + _nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600 root:root || return 1 + # The watcher owns this credential-free diagnostic channel. The host reads + # it through OpenShell as the same sandbox policy user. + _nemoclaw_safe_create_tmp_file /tmp/nemoclaw-auto-pair-status.json 600 sandbox:sandbox || return 1 else - _nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600 + _nemoclaw_safe_create_tmp_file /tmp/auto-pair.log 600 || return 1 + _nemoclaw_safe_create_tmp_file /tmp/nemoclaw-auto-pair-status.json 600 || return 1 fi } diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index 3e675f9b80e..4fdb0a9c5d6 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + AUTO_PAIR_STATUS_PATH, parseAutoPairWatcherStatus, parseSandboxScopeWarmupResult, RESTORED_CLONE_WARMUP_SCRIPT, @@ -18,7 +19,6 @@ import { 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; const itWithSh = shAvailable ? it : it.skip; @@ -57,12 +57,9 @@ 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 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([ + it("passes one multiline payload to the owning gateway without transforming it (#10014)", () => { + const payload = "printf 'first line\\n'\nprintf 'second line\\n'\n"; + expect(sandboxWarmupExecArgs("alpha", "nemoclaw-19000", payload)).toEqual([ "sandbox", "exec", "--name", @@ -72,7 +69,7 @@ describe("warm-up payload uses native multiline OpenShell exec in v2 (#4504)", ( "--", "sh", "-c", - WARMUP_SCRIPT, + payload, ]); }); @@ -117,10 +114,79 @@ describe("ordinary onboarding warm-up and watcher receipts (#10269)", () => { `${marker}{"schemaVersion":1,"state":"approval-completed","watcherActive":true,"requestId":"secret"}\n`, ), ).toBeNull(); - expect(WATCHER_STATUS_SCRIPT).not.toContain("requestId"); - expect(WATCHER_STATUS_SCRIPT).not.toContain("deviceId"); - expect(WATCHER_STATUS_SCRIPT).not.toContain("publicKey"); - expect(WATCHER_STATUS_SCRIPT).not.toContain("token"); + }); + + itWithSh("reads a credential-free watcher state from the dedicated private channel", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-watcher-status-")); + const statusPath = path.join(fixtureRoot, "status.json"); + fs.writeFileSync(statusPath, '{"schemaVersion":1,"state":"approval-timeout"}', { + mode: 0o600, + }); + try { + const script = WATCHER_STATUS_SCRIPT.replace( + JSON.stringify(AUTO_PAIR_STATUS_PATH), + JSON.stringify(statusPath), + ); + const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", timeout: 10_000 }); + expect(result.status, result.stderr).toBe(0); + expect(parseAutoPairWatcherStatus(result.stdout)).toEqual({ + schemaVersion: 1, + state: "approval-timeout", + watcherActive: false, + }); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + itWithSh.each([ + [ + "extra fields", + '{"schemaVersion":1,"state":"approval-failed","requestId":"sensitive-canary"}', + 0o600, + ], + ["public permissions", '{"schemaVersion":1,"state":"approval-failed"}', 0o644], + ])("reports unavailable for a status channel with %s", (_label, payload, mode) => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-watcher-status-")); + const statusPath = path.join(fixtureRoot, "status.json"); + fs.writeFileSync(statusPath, payload, { mode }); + try { + const script = WATCHER_STATUS_SCRIPT.replace( + JSON.stringify(AUTO_PAIR_STATUS_PATH), + JSON.stringify(statusPath), + ); + const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", timeout: 10_000 }); + expect(result.status, result.stderr).toBe(0); + expect(parseAutoPairWatcherStatus(result.stdout)).toEqual({ + schemaVersion: 1, + state: "unavailable", + watcherActive: false, + }); + expect(result.stdout).not.toContain("sensitive-canary"); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + itWithSh("rejects a FIFO status channel without blocking", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-watcher-status-")); + const statusPath = path.join(fixtureRoot, "status.json"); + expect(spawnSync("mkfifo", [statusPath]).status).toBe(0); + try { + const script = WATCHER_STATUS_SCRIPT.replace( + JSON.stringify(AUTO_PAIR_STATUS_PATH), + JSON.stringify(statusPath), + ); + const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", timeout: 10_000 }); + expect(result.status, result.stderr).toBe(0); + expect(parseAutoPairWatcherStatus(result.stdout)).toEqual({ + schemaVersion: 1, + state: "unavailable", + watcherActive: false, + }); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } }); itWithSh("returns a fixed request-issued result for the expected pending response", () => { @@ -150,27 +216,6 @@ describe("ordinary onboarding warm-up and watcher receipts (#10269)", () => { }); describe("warm-up tags its throwaway session for user-facing filters (#5511)", () => { - it("tags the provoke session with the shared warm-up prefix", () => { - expect(WARMUP_SESSION_ID_PREFIX).toBe("nemoclaw-onboard-warmup-"); - expect(WARMUP_SCRIPT).toContain( - `session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)"`, - ); - }); - - it("uses a direct write-scope gateway call for restored clones (#7834)", () => { - expect(RESTORED_CLONE_WARMUP_SCRIPT).toContain( - 'openclaw gateway call sessions.create --params "$params" --json', - ); - expect(RESTORED_CLONE_WARMUP_SCRIPT).toContain( - `session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)"`, - ); - expect(RESTORED_CLONE_WARMUP_SCRIPT).toContain("NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1"); - expect(RESTORED_CLONE_WARMUP_SCRIPT).toContain( - "NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING || exit 0", - ); - expect(RESTORED_CLONE_WARMUP_SCRIPT).not.toContain("openclaw agent"); - }); - itWithSh("keeps the restored-clone warm-up valid POSIX shell (#7834)", () => { const result = spawnSync("sh", ["-n"], { encoding: "utf-8", @@ -215,7 +260,6 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( ); try { - expect(RESTORED_CLONE_WARMUP_SCRIPT).toContain(buildTrustedProxyEnvSourceShell()); const script = RESTORED_CLONE_WARMUP_SCRIPT.replace( buildTrustedProxyEnvSourceShell(), buildTrustedProxyEnvSourceShell(proxyEnv), @@ -239,11 +283,6 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( } }); - 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( "bounds a hung direct request without polling pairing state (#10014)", () => { @@ -386,11 +425,4 @@ 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 0bd2c1922a1..d99cd2ad0ef 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -41,6 +41,7 @@ export const WARMUP_PROBE_TIMEOUT_S = 10; const WARMUP_RESULT_MARKER = "NEMOCLAW_OPENCLAW_WARMUP_RESULT="; const WATCHER_STATUS_MARKER = "NEMOCLAW_OPENCLAW_WATCHER_STATUS="; +export const AUTO_PAIR_STATUS_PATH = "/tmp/nemoclaw-auto-pair-status.json"; export const WATCHER_STATUS_TIMEOUT_MS = 10_000; @@ -124,6 +125,7 @@ command -v python3 >/dev/null 2>&1 || { printf '${WATCHER_STATUS_MARKER}{"schema python3 - <<'PYSTATUS' import json import os +import stat allowed_states = { 'running', 'request-not-produced', 'request-observed', 'request-rejected', @@ -131,27 +133,38 @@ allowed_states = { 'canonical-settled', 'stopped', } state = 'unavailable' +status_fd = None try: - with open('/tmp/auto-pair.log', 'rb') as handle: - handle.seek(0, 2) - size = handle.tell() - handle.seek(max(0, size - 131072)) - lines = handle.read().decode('utf-8', 'replace').splitlines() - for line in reversed(lines): - prefix = '[auto-pair-status] ' - if not line.startswith(prefix): - continue - value = json.loads(line[len(prefix):]) - if ( - isinstance(value, dict) - and set(value) == {'schemaVersion', 'state'} - and value.get('schemaVersion') == 1 - and value.get('state') in allowed_states - ): - state = value['state'] - break + status_fd = os.open( + ${JSON.stringify(AUTO_PAIR_STATUS_PATH)}, + os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK, + ) + metadata = os.fstat(status_fd) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_uid != os.geteuid() + or metadata.st_gid != os.getegid() + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_size > 4096 + ): + raise OSError('unsafe watcher status metadata') + raw = os.read(status_fd, 4097) + if len(raw) > 4096: + raise OSError('oversized watcher status') + value = json.loads(raw.decode('utf-8')) + if ( + isinstance(value, dict) + and set(value) == {'schemaVersion', 'state'} + and value.get('schemaVersion') == 1 + and value.get('state') in allowed_states + ): + state = value['state'] except Exception: pass +finally: + if status_fd is not None: + os.close(status_fd) watcher_active = False try: diff --git a/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts b/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts index 621f0a5241d..391c0b6ba0f 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start-auto-pair-bootstrap.test.ts @@ -37,12 +37,18 @@ function autoPairPythonScript( tmpDir: string, options: { realTime?: boolean } = {}, ): string { - const script = startScriptHeredoc(src, "PYAUTOPAIR").replace( - "APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py'", - `APPROVAL_POLICY_FILE = ${JSON.stringify(trustedApprovalPolicyFile(tmpDir))}`, - ); - if (options.realTime) return script; - return script + const statusPath = path.join(tmpDir, "auto-pair-status.json"); + fs.writeFileSync(statusPath, "", { mode: 0o600 }); + const script = startScriptHeredoc(src, "PYAUTOPAIR") + .replace( + "APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py'", + `APPROVAL_POLICY_FILE = ${JSON.stringify(trustedApprovalPolicyFile(tmpDir))}`, + ) + .replace( + "STATUS_PATH = '/tmp/nemoclaw-auto-pair-status.json'", + `STATUS_PATH = ${JSON.stringify(statusPath)}`, + ); + const simulatedTimeScript = script .replaceAll("time.time()", "_nemoclaw_test_time()") .replaceAll("time.sleep(", "_nemoclaw_test_sleep(") .replace( @@ -53,17 +59,21 @@ _nemoclaw_test_time = lambda: _nemoclaw_test_clock[0] def _nemoclaw_test_sleep(seconds): _nemoclaw_test_clock.__setitem__(0, _nemoclaw_test_clock[0] + min(max(float(seconds), 0), 0.25)) `, ); + return options.realTime ? script : simulatedTimeScript; } +type SettlementDeps = NonNullable[1]>; +type RaceTiming = "watcher-first" | "host-first"; + describe("nemoclaw-start initial CLI auto-pair bootstrap (#6113)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); it.each([ - ["watcher settles before the host observes the request", true], - ["host observes the request before the watcher settles", false], + ["watcher settles before the host observes the request", "watcher-first"], + ["host observes the request before the watcher settles", "host-first"], ] as const)( "keeps the watcher as the only scope approver when %s (#10269)", - async (_timing, watcherFirst) => { + async (_label, timing: RaceTiming) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-single-approver-")); const fakeOpenclaw = path.join(tmpDir, "openclaw"); const stateDir = path.join(tmpDir, "state"); @@ -138,8 +148,8 @@ exit 2 const waitFor = async (predicate: () => boolean, label: string) => { const deadline = Date.now() + 5_000; while (!predicate()) { - if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}.`); - await new Promise((resolve) => setTimeout(resolve, 10)); + expect(Date.now(), `Timed out waiting for ${label}.`).toBeLessThan(deadline); + await new Promise((resolve) => setTimeout(resolve, 10)); } }; const startWatcher = () => { @@ -167,12 +177,39 @@ exit 2 }); return child; }; + const timingStrategy = { + "watcher-first": { + prepare: async () => { + watcher = startWatcher(); + await waitFor(() => fs.existsSync(listLog), "the watcher's first observation"); + }, + afterWarmup: async () => { + await waitFor( + () => fs.readFileSync(phaseFile, "utf-8").trim() === "settled", + "the watcher to settle before host observation", + ); + }, + afterSettlementStarted: async () => {}, + }, + "host-first": { + prepare: async () => {}, + afterWarmup: async () => {}, + afterSettlementStarted: async () => { + await pendingObserved; + watcher = startWatcher(); + }, + }, + } satisfies Record< + RaceTiming, + { + prepare(): Promise; + afterWarmup(): Promise; + afterSettlementStarted(): Promise; + } + >; try { - if (watcherFirst) { - watcher = startWatcher(); - await waitFor(() => fs.existsSync(listLog), "the watcher's first observation"); - } + await timingStrategy[timing].prepare(); const target = { gatewayName: "nemoclaw", @@ -181,25 +218,25 @@ exit 2 stateDirectory: stateDir, version: "2026.7.1", }; - const deps = { + const deps: SettlementDeps & { runApproval(): void } = { getTarget: () => target, observePairing: () => { const state = fs.readFileSync(phaseFile, "utf-8").trim() as | "pairing-only" | "scope-upgrade-pending" | "settled"; - if (state === "scope-upgrade-pending") reportPendingObserved(); + const reportState = { + "pairing-only": () => {}, + "scope-upgrade-pending": reportPendingObserved, + settled: () => {}, + }; + reportState[state](); return { state, deviceIdentitySha256: canonicalCli.deviceId }; }, runWarmup: async () => { warmupRuns += 1; fs.writeFileSync(phaseFile, "scope-upgrade-pending"); - if (watcherFirst) { - await waitFor( - () => fs.readFileSync(phaseFile, "utf-8").trim() === "settled", - "the watcher to settle before host observation", - ); - } + await timingStrategy[timing].afterWarmup(); return "request-issued" as const; }, readWatcherStatus: () => ({ @@ -210,7 +247,9 @@ exit 2 withSandboxLock: async (_name, operation) => operation(), withGatewayLock: async (_name, operation) => operation(), now: () => performance.now(), - sleep: async () => new Promise((resolve) => setTimeout(resolve, 10)), + sleep: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }, // The current state machine ignores this legacy dependency. Keeping // it in the harness makes the test fail against the two-owner design. runApproval: () => { @@ -221,32 +260,39 @@ exit 2 }; const settlement = settleOrdinaryOpenClawPairing("alpha", deps); - if (!watcherFirst) { - await pendingObserved; - watcher = startWatcher(); - } + await timingStrategy[timing].afterSettlementStarted(); await expect(settlement).resolves.toEqual({ kind: "settled" }); + const approvalReceipt = `[auto-pair] approved request=${requestId} client=cli mode=cli`; + await waitFor( + () => watcherOutput.includes(approvalReceipt), + "the watcher approval receipt", + ); expect(warmupRuns).toBe(1); expect(hostApprovalAttempts).toBe(0); expect(fs.readFileSync(phaseFile, "utf-8").trim()).toBe("settled"); expect(fs.readFileSync(approvalLog, "utf-8").trim().split("\n")).toEqual([ `watcher:${requestId}`, ]); + const publishedStatus = JSON.parse( + fs.readFileSync(path.join(tmpDir, "auto-pair-status.json"), "utf-8"), + ) as Record; + expect(Object.keys(publishedStatus).sort()).toEqual(["schemaVersion", "state"]); + expect(publishedStatus.schemaVersion).toBe(1); + expect(["approval-completed", "canonical-settled"]).toContain(publishedStatus.state); + expect(watcherOutput).toContain(approvalReceipt); } finally { - if (watcher && watcher.exitCode === null) watcher.kill("SIGTERM"); - if (watcher && watcher.exitCode === null) { - await Promise.race([ - new Promise((resolve) => watcher?.once("close", resolve)), - new Promise((resolve) => setTimeout(resolve, 2_000)), - ]); - } + watcher?.kill("SIGTERM"); + const watcherClosed = + watcher?.exitCode === null + ? new Promise((resolve) => watcher?.once("close", () => resolve())) + : Promise.resolve(); + await Promise.race([ + watcherClosed, + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); fs.rmSync(tmpDir, { recursive: true, force: true }); } - - expect(watcherOutput).toContain( - `[auto-pair] approved request=${requestId} client=cli mode=cli`, - ); }, 30_000, ); diff --git a/test/agents/openclaw/runtime/nemoclaw-start-safe-tmp.test.ts b/test/agents/openclaw/runtime/nemoclaw-start-safe-tmp.test.ts index 5281a21b9b6..b0ae073971b 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start-safe-tmp.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start-safe-tmp.test.ts @@ -119,9 +119,20 @@ describe("nemoclaw-start safe tmp file creation", () => { }); it.each([ - ["root parent after CAP_DAC_OVERRIDE drop", "0", "3|/tmp/auto-pair.log 600 root:root"], - ["non-root parent", "998", "2|/tmp/auto-pair.log 600"], - ])("creates an auto-pair log for the %s", (_label, uid, expected) => { + [ + "root parent after CAP_DAC_OVERRIDE drop", + "0", + [ + "3|/tmp/auto-pair.log 600 root:root", + "3|/tmp/nemoclaw-auto-pair-status.json 600 sandbox:sandbox", + ], + ], + [ + "non-root parent", + "998", + ["2|/tmp/auto-pair.log 600", "2|/tmp/nemoclaw-auto-pair-status.json 600"], + ], + ])("creates separate auto-pair log and status files for the %s", (_label, uid, expected) => { const prepareAutoPairLog = extractShellFunctionFromSource(src, "prepare_auto_pair_log"); const result = spawnSync( "bash", @@ -139,7 +150,7 @@ describe("nemoclaw-start safe tmp file creation", () => { ); expect(result.status, result.stderr).toBe(0); - expect(result.stdout.trim()).toBe(expected); + expect(result.stdout.trim().split("\n")).toEqual(expected); }); it("creates fixed runtime paths through the safe helper with the requested modes", () => { diff --git a/test/agents/openclaw/runtime/nemoclaw-start.test.ts b/test/agents/openclaw/runtime/nemoclaw-start.test.ts index 080bdbd3ccb..f5310c43c44 100644 --- a/test/agents/openclaw/runtime/nemoclaw-start.test.ts +++ b/test/agents/openclaw/runtime/nemoclaw-start.test.ts @@ -3288,7 +3288,8 @@ describe("Telegram diagnostics (#2766)", () => { const block = src .slice(start, end) .replaceAll("/tmp/gateway.log", gatewayLog) - .replaceAll("/tmp/auto-pair.log", autoPairLog); + .replaceAll("/tmp/auto-pair.log", autoPairLog) + .replaceAll("/tmp/nemoclaw-auto-pair-status.json", `${autoPairLog}.status`); return kind === "non-root" ? `${extractShellFunctionFromSource(src, "_nemoclaw_capture_epoch_realtime")}\n${block}fi\n` : block; @@ -3346,10 +3347,8 @@ describe("Telegram diagnostics (#2766)", () => { "chown_tree_no_symlink_follow() { :; }", "start_persistent_gateway_log_mirror() { :; }", 'setpriv() { while [ "$1" != "--" ]; do shift; done; shift; "$@"; }', - // STEP_DOWN_PREFIX_* are normally populated by init_step_down_prefixes - // in sandbox-init.sh; the test scaffolding doesn't source that, so - // initialize them here because this scaffolding does not source the - // shared privilege-transition helper. + // Test scaffolding skips sandbox-init.sh, so define the shared + // privilege-transition prefixes here. "STEP_DOWN_PREFIX_SANDBOX=(setpriv --reuid=sandbox --regid=sandbox --init-groups --)", "STEP_DOWN_PREFIX_GATEWAY=(setpriv --reuid=gateway --regid=gateway --init-groups --)", 'validate_tmp_permissions() { printf "VALIDATE:%s\\n" "$*"; }', @@ -3361,10 +3360,9 @@ describe("Telegram diagnostics (#2766)", () => { `validate_nemoclaw_tmp_permissions() { validate_tmp_permissions ${JSON.stringify(preloadPath)}; }`, "NEMOCLAW_CMD=()", '_nemoclaw_safe_create_tmp_file() { if [ "$1" = /tmp/auto-pair.log ]; then return 97; fi; : > "$1"; chmod "$2" "$1"; }', - `${extractShellFunctionFromSource(src, "prepare_auto_pair_log").replaceAll( - "/tmp/auto-pair.log", - autoPairLog, - )}\n${preGatewaySetupBlock(kind, gatewayLog, autoPairLog)}`, + `${extractShellFunctionFromSource(src, "prepare_auto_pair_log") + .replaceAll("/tmp/auto-pair.log", autoPairLog) + .replaceAll("/tmp/nemoclaw-auto-pair-status.json", `${autoPairLog}.status`)}\n${preGatewaySetupBlock(kind, gatewayLog, autoPairLog)}`, ].join("\n"), { mode: 0o700 }, ); diff --git a/test/e2e-gateway-isolation.sh b/test/e2e-gateway-isolation.sh index 6a5f043d671..c53ac736a7a 100755 --- a/test/e2e-gateway-isolation.sh +++ b/test/e2e-gateway-isolation.sh @@ -131,6 +131,34 @@ else fail "setpriv/gosu runtime contract failed: $OUT" fi +# ── Test 7a: Auto-pair status crosses the real privilege boundary ─ + +info "7a. Auto-pair status is sandbox-readable while the credential-bearing log stays private" +OUT=$(docker run --rm --user root --entrypoint bash "$IMAGE" -lc ' + set -euo pipefail + { + sed -n "/^_nemoclaw_safe_replace_tmp_file() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^_nemoclaw_safe_create_tmp_file() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + sed -n "/^prepare_auto_pair_log() {$/,/^}$/p" /usr/local/bin/nemoclaw-start + } >/tmp/prepare-auto-pair-files.sh + test -s /tmp/prepare-auto-pair-files.sh + source /tmp/prepare-auto-pair-files.sh + prepare_auto_pair_log + [ "$(stat -c "%a %U:%G" /tmp/auto-pair.log)" = "600 root:root" ] + [ "$(stat -c "%a %U:%G" /tmp/nemoclaw-auto-pair-status.json)" = "600 sandbox:sandbox" ] + /usr/bin/setpriv --reuid=sandbox --regid=sandbox --init-groups -- sh -eu -c " + test ! -r /tmp/auto-pair.log + printf \"%s\" \"{\\\"schemaVersion\\\":1,\\\"state\\\":\\\"approval-completed\\\"}\" >/tmp/nemoclaw-auto-pair-status.json + grep -Fqx \"{\\\"schemaVersion\\\":1,\\\"state\\\":\\\"approval-completed\\\"}\" /tmp/nemoclaw-auto-pair-status.json + " + printf "AUTO_PAIR_STATUS_BOUNDARY_OK\n" +' 2>&1 || true) +if echo "$OUT" | grep -q "AUTO_PAIR_STATUS_BOUNDARY_OK"; then + pass "sandbox can publish fixed-schema status without access to the auto-pair log" +else + fail "auto-pair status privilege-boundary contract failed: $OUT" +fi + # ── Test 8: Entrypoint PATH is locked to system dirs ───────────── info "8. Entrypoint locks PATH to system directories"