diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ad5d820fdea..3374be8b773 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -137,7 +137,9 @@ jobs: cli-test-shards: runs-on: ubuntu-24.04 - timeout-minutes: 15 + # Keep the post-merge budget aligned with pull requests so the same + # duration-weighted coverage roster can finish and upload its artifacts. + timeout-minutes: 30 strategy: fail-fast: false matrix: diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b97fd6ddc7a..1b65a5a7cc0 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -255,7 +255,9 @@ jobs: needs: changes if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 - timeout-minutes: 15 + # Coverage startup plus the stable, duration-weighted roster can exceed + # the former 15-minute cap before Vitest writes its shard artifacts. + timeout-minutes: 30 strategy: fail-fast: false matrix: diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 55586bd3500..f9d06b98e4b 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -65,6 +65,9 @@ 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. ```bash nemoclaw my-assistant status diff --git a/internal/security-reviews/openclaw-2026.7.1-dependency-review.md b/internal/security-reviews/openclaw-2026.7.1-dependency-review.md index 66f6114c5c6..ee793860189 100644 --- a/internal/security-reviews/openclaw-2026.7.1-dependency-review.md +++ b/internal/security-reviews/openclaw-2026.7.1-dependency-review.md @@ -457,19 +457,32 @@ It removes shared gateway credentials and the configuration path, and it disables pathname-backed device-auth reads and writes. The live pairing list must match the descriptor-backed preflight before one canonical approval can run. -OpenClaw reloads the state under its pairing lock, rotates the token, persists -the paired state, broadcasts the change, and responds. -NemoClaw then verifies the exact pending-to-paired transition and atomically -writes the rotated token to the clone's `identity/device-auth.json` with mode -`0600`. +OpenClaw reloads the state under its pairing lock and the version-scoped patch +requires the authenticated device token to match the operator token in both +the paired-device and stored-auth before-images. +It then rotates the token and records the pending, paired, and +`identity/device-auth.json` before- and after-images in the version 2 +self-approval journal. +The canonical writer waits for all three state writes, commits the journal, +and replaces it with the idle form before the handler broadcasts the change +and responds. +If publication is interrupted, the next locked pairing-state read restores a +prepared journal or completes a committed journal across all three files. +Prepared and committed journal snapshots contain device tokens only in a +mode-`0600` file under a mode-`0700` directory. Approval returns success only +after the credential-free idle journal replaces those snapshots. If that final +rewrite fails, approval reports failure and the committed journal remains until +the next locked pairing-state read completes and clears it. +The wrapper then verifies the exact pending-to-paired transition and rewrites +the same rotated token to the clone's `identity/device-auth.json` with mode +`0600`; this remains a post-state verification boundary rather than the owner +of stored-auth synchronization. The wrapper and approval child keep the old token in memory only for the bounded pass. Any pre-approval identity, state, transport, or live-preflight mismatch prevents the approval call. A post-state mismatch reports failure and does not treat the client credential as synchronized. -It does not roll back a canonical server transition that OpenClaw already -persisted. ## Transient Remote MCP Startup Recovery diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index bfab0583038..4131af277db 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2619,10 +2619,18 @@ import os import re import stat import subprocess +import sys import time print('[auto-pair] watcher started', flush=True) + +def report_unhandled_watcher_exception(exc_type, _exc_value, _traceback): + print(f'[auto-pair] stage=watcher-execution failed error={exc_type.__name__}', flush=True) + + +sys.excepthook = report_unhandled_watcher_exception + APPROVAL_POLICY_FILE = '/usr/local/lib/nemoclaw/openclaw_device_approval_policy.py' @@ -2722,7 +2730,12 @@ QUIET_POLLS = 0 APPROVED = 0 SLOW_MODE = False HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing +OBSERVED_REQUEST_IDS = set() +VALIDATED_REQUEST_IDS = set() +LAST_LIST_FAILURE_REASON = None +REQUEST_CREATION_WAITING_REPORTED = False PAIRING_BOOTSTRAPPED = False +MALFORMED_REQUEST_ID_REPORTED = False # SECURITY NOTE: clientId/clientMode are client-supplied and spoofable # (the gateway stores connectParams.client.id verbatim). The policy requires # an explicit known clientId and never trusts an allowlisted mode by itself. @@ -2856,7 +2869,7 @@ def is_pairing_required_list_failure(out, err): return 'pairing required' in message and 'device is not approved yet' in message -REQUEST_ID_RE = re.compile(r'^[A-Za-z0-9._:-]{1,128}$') +REQUEST_ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$') def _structured_request_ids(text): @@ -2934,15 +2947,41 @@ def brief_child_error(out, err): lines = [line.strip() for line in f'{err}\n{out}'.splitlines() if line.strip()] return (lines[-1] if lines else '')[:400] + +def report_request_observed(request_id): + if request_id in OBSERVED_REQUEST_IDS: + return + OBSERVED_REQUEST_IDS.add(request_id) + print(f'[auto-pair] stage=request-creation observed request={request_id}') + + +def report_request_validation(request_id, accepted, reason): + if request_id in VALIDATED_REQUEST_IDS: + return + VALIDATED_REQUEST_IDS.add(request_id) + outcome = 'accepted' if accepted else 'rejected' + print(f'[auto-pair] stage=validation {outcome} request={request_id} reason={reason}') + + +def list_failure_reason(rc, out, err): + if rc == 124: + return 'timeout' + if is_pairing_required_list_failure(out, err): + return 'pairing-required' + if rc != 0: + return 'command-failed' + return 'empty-output' + # Workaround boundary (NemoClaw#4462): the watcher child sources the trusted # runtime environment, so its first list call resolves the live gateway through # local loopback and retains the shared token plus a private child marker. The # reviewed 2026.7.1 dist patch uses that marker to retain CLI identity before a -# stored device credential exists. Once OpenClaw issues that credential, the -# patch retains identity for ordinary loopback CLI calls automatically. Later -# list and approval calls drop the gateway env triplet and use the stored device -# credential. Remove both pieces when upstream supports that flow. -def run(*args, strip_gateway_env=False, force_device_pairing=False): +# stored device credential exists. Once OpenClaw issues that credential, later +# list calls drop the gateway env triplet and use the reviewed settlement marker +# to select pairing-only stored-device auth. Approval calls keep their separate +# bounded credential selection. Remove these pieces when upstream supports that +# flow. +def run(*args, strip_gateway_env=False, force_device_pairing=False, pairing_settlement=False): # Bound every openclaw CLI invocation so a wedged child cannot pin # the watcher beyond DEADLINE (CodeRabbit #4292): subprocess.run with # no timeout would hold a hung `openclaw devices list/approve` past @@ -2950,8 +2989,12 @@ def run(*args, strip_gateway_env=False, force_device_pairing=False): env = None if strip_gateway_env: env = gateway_approval_env(os.environ) + env.pop('NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT', None) + if pairing_settlement: + env['NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT'] = '1' elif force_device_pairing: env = dict(os.environ) + env.pop('NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT', None) env['NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING'] = '1' try: proc = subprocess.run( @@ -3007,14 +3050,31 @@ while time.time() < DEADLINE: '--json', strip_gateway_env=PAIRING_BOOTSTRAPPED, force_device_pairing=not PAIRING_BOOTSTRAPPED, + pairing_settlement=PAIRING_BOOTSTRAPPED, ) if rc != 0 or not out: + failure_reason = list_failure_reason(rc, out, err) + if failure_reason != LAST_LIST_FAILURE_REASON: + print(f'[auto-pair] stage=listing failed reason={failure_reason}') + LAST_LIST_FAILURE_REASON = failure_reason initial_request_id = pairing_required_request_id(out, err) - if ( - initial_request_id - and initial_request_id not in HANDLED - and initial_cli_request_is_allowlisted(initial_request_id) - ): + if initial_request_id and initial_request_id not in HANDLED: + live_request_ids = {initial_request_id} + HANDLED.intersection_update(live_request_ids) + OBSERVED_REQUEST_IDS.intersection_update(live_request_ids) + VALIDATED_REQUEST_IDS.intersection_update(live_request_ids) + FAST_REENTRY_BUMPED_REQUEST_IDS.intersection_update(live_request_ids) + report_request_observed(initial_request_id) + initial_request_allowed = initial_cli_request_is_allowlisted(initial_request_id) + report_request_validation( + initial_request_id, + initial_request_allowed, + 'allowlisted-initial-cli' if initial_request_allowed else 'not-allowlisted', + ) + else: + initial_request_allowed = False + if initial_request_id and initial_request_id not in HANDLED and initial_request_allowed: + print(f'[auto-pair] stage=approval attempting request={initial_request_id}') arc, aout, aerr = run( OPENCLAW, 'devices', 'approve', initial_request_id, '--json', strip_gateway_env=True, ) @@ -3025,54 +3085,98 @@ while time.time() < DEADLINE: 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' + print(f'[auto-pair] stage=approval failed reason={approval_failure_reason}') failure = brief_child_error(aout, aerr) if arc != 124 and failure: print(f'[auto-pair] initial CLI approve failed request={initial_request_id}: {failure}') sleep_for_next_poll(SLOW_INTERVAL if SLOW_MODE else 1, productive=False) continue - if not PAIRING_BOOTSTRAPPED: - PAIRING_BOOTSTRAPPED = True - print('[auto-pair] loopback CLI pairing bootstrap completed') try: data = json.loads(out) except Exception: + if LAST_LIST_FAILURE_REASON != 'invalid-json': + print('[auto-pair] stage=listing failed reason=invalid-json') + LAST_LIST_FAILURE_REASON = 'invalid-json' sleep_for_next_poll(SLOW_INTERVAL if SLOW_MODE else 1, productive=False) continue - - pending = data.get('pending') or [] - paired = data.get('paired') or [] + if not isinstance(data, dict): + if LAST_LIST_FAILURE_REASON != 'invalid-response': + print('[auto-pair] stage=listing failed reason=invalid-response') + LAST_LIST_FAILURE_REASON = 'invalid-response' + sleep_for_next_poll(SLOW_INTERVAL if SLOW_MODE else 1, productive=False) + continue + pending = data.get('pending') + paired = data.get('paired') + if not isinstance(pending, list) or not isinstance(paired, list): + if LAST_LIST_FAILURE_REASON != 'invalid-response': + print('[auto-pair] stage=listing failed reason=invalid-response') + LAST_LIST_FAILURE_REASON = 'invalid-response' + sleep_for_next_poll(SLOW_INTERVAL if SLOW_MODE else 1, productive=False) + continue + LAST_LIST_FAILURE_REASON = None + has_cli_pairing = any( + d.get('clientId') == 'cli' and d.get('clientMode') == 'cli' + for d in paired + if isinstance(d, dict) + ) + 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)) - if pending: + normalized_pending = [] + saw_malformed_request_id = False + for device in pending: + request_id = device.get('requestId') if isinstance(device, dict) else None + if not isinstance(request_id, str) or REQUEST_ID_RE.fullmatch(request_id) is None: + saw_malformed_request_id = True + if not MALFORMED_REQUEST_ID_REPORTED: + print('[auto-pair] stage=validation rejected reason=malformed-request-id') + MALFORMED_REQUEST_ID_REPORTED = True + continue + normalized_pending.append((request_id, device)) + if not saw_malformed_request_id: + MALFORMED_REQUEST_ID_REPORTED = False + pending_request_ids = {request_id for request_id, _device in normalized_pending} + HANDLED.intersection_update(pending_request_ids) + OBSERVED_REQUEST_IDS.intersection_update(pending_request_ids) + VALIDATED_REQUEST_IDS.intersection_update(pending_request_ids) + 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: + 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() - pending_request_ids = set() - for device in pending: - if not isinstance(device, dict): - continue - request_id = device.get('requestId') - if not request_id: - continue - pending_request_ids.add(request_id) + for request_id, device in normalized_pending: if request_id in HANDLED: continue + report_request_observed(request_id) 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') 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') 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') print(f'[auto-pair] rejected disallowed scopes={sorted(scopes)} client={client_id} mode={client_mode}') continue + report_request_validation(request_id, True, 'allowlisted-request') attempted_request_ids.add(request_id) + print(f'[auto-pair] stage=approval attempting request={request_id}') arc, aout, aerr = run( OPENCLAW, 'devices', 'approve', request_id, '--json', strip_gateway_env=True, ) @@ -3082,20 +3186,17 @@ while time.time() < DEADLINE: # retryable too; only intentionally rejected unknown clients # and confirmed successful approvals are marked handled. if arc == 124: + print('[auto-pair] stage=approval failed reason=timeout') continue if arc == 0: HANDLED.add(request_id) APPROVED += 1 print(f'[auto-pair] approved request={request_id} client={client_id} mode={client_mode}') else: + 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}') - # Drop previously-bumped requestIds that the gateway no longer reports - # as pending so a future re-appearance of the same id (very unlikely, - # but kept robust) can bump again. The set is otherwise small and - # never crosses out of the watcher process. - FAST_REENTRY_BUMPED_REQUEST_IDS.intersection_update(pending_request_ids) # 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 diff --git a/scripts/patch-openclaw-device-self-approval.mts b/scripts/patch-openclaw-device-self-approval.mts index 989bce2ac51..4c2fb51143a 100644 --- a/scripts/patch-openclaw-device-self-approval.mts +++ b/scripts/patch-openclaw-device-self-approval.mts @@ -20,6 +20,8 @@ * approval in OpenClaw instead: for the exact bounded CLI mismatch, continue * only into OpenClaw's pairing gate. For the resulting same-device scope * transition, use OpenClaw's stored device credential with operator.pairing. + * NemoClaw's settlement list uses the same pairing-only stored credential so + * it can observe that transition without a shared gateway credential. * A restored clone whose paired server state exists before its client-auth * store converges instead opts into one narrower path: its pairing state and * signed identity are loaded only from inherited clone-file descriptors, the @@ -51,6 +53,7 @@ const CLI_APPROVE_MARKER = const CLI_SCOPE_MARKER = "nemoclaw: reach gateway for bounded same-device scope approval"; const CLI_RETRY_MARKER = "nemoclaw: keep bounded device auth fail closed"; const CLI_LIST_MARKER = "nemoclaw: preflight bounded stored device auth before live pairing list"; +const CLI_SETTLEMENT_LIST_MARKER = "nemoclaw: use stored device auth for pairing settlement list"; const CLI_PAIRED_TOKEN_MARKER = "nemoclaw: preflight bounded paired token before live pairing list"; const CALL_FORCE_IDENTITY_MARKER = "nemoclaw: force device identity for loopback pairing bootstrap"; const CALL_STORED_IDENTITY_MARKER = @@ -67,6 +70,7 @@ const CLI_APPLIED_MARKERS = [ CLI_SCOPE_MARKER, CLI_RETRY_MARKER, CLI_LIST_MARKER, + CLI_SETTLEMENT_LIST_MARKER, CLI_PAIRED_TOKEN_MARKER, ] as const; const AUTH_SCOPE_UPGRADE_MARKER = @@ -468,8 +472,17 @@ const CLI_CALL_GATEWAY_REPLACEMENT = [ ].join("\n"); const CLI_LIST_SIGNATURE_TARGET = "async function listPairingWithFallback(opts) {"; -const CLI_LIST_SIGNATURE_REPLACEMENT = +const CLI_LIST_SIGNATURE_LEGACY_REPLACEMENT = "async function listPairingWithFallback(opts, callOpts) { // nemoclaw: preflight bounded stored device auth before live pairing list (#4462)"; +const CLI_LIST_SIGNATURE_REPLACEMENT = [ + CLI_LIST_SIGNATURE_LEGACY_REPLACEMENT, + '\tconst nemoclawSettlementListCallOpts = process.env.NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT === "1" ? {', + "\t\tscopes: [PAIRING_SCOPE],", + "\t\tuseStoredDeviceAuth: true,", + "\t\trequiredStoredDeviceAuthScopes: [PAIRING_SCOPE]", + `\t} : void 0; // ${CLI_SETTLEMENT_LIST_MARKER} (#9844)`, + "\tcallOpts ??= nemoclawSettlementListCallOpts;", +].join("\n"); const CLI_LIST_CALL_TARGET = '\t\treturn parseDevicePairingList(await callGatewayCli("device.pair.list", opts, {}));'; const CLI_LIST_CALL_REPLACEMENT = @@ -646,6 +659,7 @@ const HANDLER_HELPER = [ '\tconst clientRole = typeof client?.connect?.role === "string" ? client.connect.role.trim() : "";', '\tconst clientId = typeof client?.connect?.client?.id === "string" ? client.connect.client.id.trim() : "";', '\tconst clientMode = typeof client?.connect?.client?.mode === "string" ? client.connect.client.mode.trim() : "";', + '\tconst deviceToken = typeof client?.connect?.auth?.token === "string" ? client.connect.auth.token.trim() : "";', '\tconst pendingClientId = typeof pending?.clientId === "string" ? pending.clientId.trim() : "";', '\tconst pendingClientMode = typeof pending?.clientMode === "string" ? pending.clientMode.trim() : "";', "\tif (", @@ -653,6 +667,7 @@ const HANDLER_HELPER = [ "\t\tcallerDeviceId !== clientDeviceId ||", "\t\tcallerDeviceId !== pendingDeviceId ||", "\t\t!clientPublicKey ||", + "\t\t!deviceToken ||", "\t\tclientPublicKey !== pendingPublicKey ||", '\t\tclientRole !== "operator" ||', '\t\tclientId !== "cli" ||', @@ -685,7 +700,7 @@ const HANDLER_HELPER = [ "\t\tnemoclawPendingScopes.add(normalized);", "\t}", '\tif (pending.isRepair !== true && (pending.isRepair !== false || !nemoclawPendingScopes.has("operator.write"))) return null;', - "\treturn { deviceId: callerDeviceId, publicKey: clientPublicKey, role: clientRole, clientId, clientMode };", + "\treturn { deviceId: callerDeviceId, publicKey: clientPublicKey, role: clientRole, clientId, clientMode, deviceToken };", "} // nemoclaw: bounded same-device scope approval (#4462)", "", ].join("\n"); @@ -728,7 +743,7 @@ const HANDLER_APPROVE_REPLACEMENT = "\t\tconst approved = await approveDevicePairing(requestId, { callerScopes: authz.callerScopes, nemoclawSelfApprovalIdentity });"; const STATE_TRANSACTION_HELPER = [ - "const NEMOCLAW_SELF_APPROVAL_JOURNAL_VERSION = 1;", + "const NEMOCLAW_SELF_APPROVAL_JOURNAL_VERSION = 2;", 'const NEMOCLAW_SELF_APPROVAL_JOURNAL_KIND = "nemoclaw-self-approval";', 'const NEMOCLAW_SELF_APPROVAL_JOURNAL_SUFFIX = ".nemoclaw-self-approval-journal";', "const NEMOCLAW_SELF_APPROVAL_JOURNAL_WRITE_OPTIONS = { mode: 384, dirMode: 448, trailingNewline: true };", @@ -746,10 +761,32 @@ const STATE_TRANSACTION_HELPER = [ "function nemoclawIsPairingRecord(value) {", "\treturn nemoclawIsPlainRecord(value) && Object.values(value).every((entry) => nemoclawIsPlainRecord(entry));", "}", + "function nemoclawOperatorToken(value) {", + "\tif (!nemoclawIsPlainRecord(value?.tokens)) return null;", + "\tconst token = value.tokens.operator;", + '\treturn nemoclawIsPlainRecord(token) && typeof token.token === "string" && token.token.trim() === token.token && token.token ? token : null;', + "}", + "function nemoclawExactOperatorScopes(value) {", + "\tif (!Array.isArray(value) || value.length === 0) return null;", + "\tconst scopes = new Set();", + "\tfor (const scope of value) {", + '\t\tif (typeof scope !== "string" || scope.trim() !== scope || (scope !== "operator.pairing" && scope !== "operator.read" && scope !== "operator.write") || scopes.has(scope)) return null;', + "\t\tscopes.add(scope);", + "\t}", + "\treturn [...scopes].toSorted();", + "}", + "function nemoclawIsDeviceAuthStore(value) {", + '\tif (!nemoclawIsPlainRecord(value) || !nemoclawHasExactKeys(value, ["deviceId", "tokens", "version"])) return false;', + '\tif (value.version !== 1 || typeof value.deviceId !== "string" || !value.deviceId.trim() || value.deviceId.trim() !== value.deviceId) return false;', + '\tif (!nemoclawIsPlainRecord(value.tokens) || !nemoclawHasExactKeys(value.tokens, ["operator"])) return false;', + "\tconst operator = nemoclawOperatorToken(value);", + '\treturn Boolean(operator && operator.role === "operator" && nemoclawExactOperatorScopes(operator.scopes));', + "}", "function nemoclawIsSnapshot(value) {", "\treturn (", "\t\tnemoclawIsPlainRecord(value) &&", - '\t\tnemoclawHasExactKeys(value, ["pairedByDeviceId", "pendingById"]) &&', + '\t\tnemoclawHasExactKeys(value, ["auth", "pairedByDeviceId", "pendingById"]) &&', + "\t\tnemoclawIsDeviceAuthStore(value.auth) &&", "\t\tnemoclawIsPairingRecord(value.pendingById) &&", "\t\tnemoclawIsPairingRecord(value.pairedByDeviceId)", "\t);", @@ -767,6 +804,9 @@ const STATE_TRANSACTION_HELPER = [ "function nemoclawResolveJournalPath(baseDir) {", '\treturn `${resolvePairingPaths(baseDir, "devices").pendingPath}${NEMOCLAW_SELF_APPROVAL_JOURNAL_SUFFIX}`;', "}", + "function nemoclawResolveDeviceAuthPath(baseDir) {", + '\treturn `${resolvePairingPaths(baseDir, "identity").dir}/device-auth.json`;', + "}", "function nemoclawIdleJournal() {", '\treturn { version: NEMOCLAW_SELF_APPROVAL_JOURNAL_VERSION, kind: NEMOCLAW_SELF_APPROVAL_JOURNAL_KIND, phase: "idle" };', "}", @@ -785,6 +825,16 @@ const STATE_TRANSACTION_HELPER = [ "\tconst pendingBefore = value.before.pendingById[value.requestId];", "\tconst pairedBefore = value.before.pairedByDeviceId[value.deviceId];", "\tconst pairedAfter = value.after.pairedByDeviceId[value.deviceId];", + "\tconst authBefore = value.before.auth;", + "\tconst authAfter = value.after.auth;", + "\tconst pairedTokenBefore = nemoclawOperatorToken(pairedBefore);", + "\tconst pairedTokenAfter = nemoclawOperatorToken(pairedAfter);", + "\tconst authTokenBefore = nemoclawOperatorToken(authBefore);", + "\tconst authTokenAfter = nemoclawOperatorToken(authAfter);", + "\tconst pairedScopesBefore = nemoclawExactOperatorScopes(pairedTokenBefore?.scopes);", + "\tconst pairedScopesAfter = nemoclawExactOperatorScopes(pairedTokenAfter?.scopes);", + "\tconst authScopesBefore = nemoclawExactOperatorScopes(authTokenBefore?.scopes);", + "\tconst authScopesAfter = nemoclawExactOperatorScopes(authTokenAfter?.scopes);", "\tif (", "\t\t!nemoclawIsPlainRecord(pendingBefore) ||", "\t\tpendingBefore.deviceId !== value.deviceId ||", @@ -792,27 +842,45 @@ const STATE_TRANSACTION_HELPER = [ "\t\tpairedBefore.deviceId !== value.deviceId ||", "\t\tvalue.requestId in value.after.pendingById ||", "\t\t!nemoclawIsPlainRecord(pairedAfter) ||", - "\t\tpairedAfter.deviceId !== value.deviceId", + "\t\tpairedAfter.deviceId !== value.deviceId ||", + "\t\tauthBefore.deviceId !== value.deviceId ||", + "\t\tauthAfter.deviceId !== value.deviceId ||", + "\t\t!pairedTokenBefore ||", + "\t\t!pairedTokenAfter ||", + "\t\t!authTokenBefore ||", + "\t\t!authTokenAfter ||", + "\t\t!pairedScopesBefore ||", + "\t\t!pairedScopesAfter ||", + "\t\t!authScopesBefore ||", + "\t\t!authScopesAfter ||", + "\t\tpairedTokenBefore.token !== authTokenBefore.token ||", + "\t\tpairedTokenAfter.token !== authTokenAfter.token ||", + "\t\tpairedTokenBefore.token === pairedTokenAfter.token ||", + "\t\t!nemoclawStatesEqual(pairedScopesBefore, authScopesBefore) ||", + "\t\t!nemoclawStatesEqual(pairedScopesAfter, authScopesAfter)", '\t) throw new Error("invalid NemoClaw self-approval journal transition");', "\treturn value;", "}", "async function nemoclawReadPairingSnapshot(baseDir) {", '\tconst { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices");', - "\tconst [pending, paired] = await Promise.all([readJsonIfExists(pendingPath), readJsonIfExists(pairedPath)]);", - "\tconst snapshot = { pendingById: pending ?? {}, pairedByDeviceId: paired ?? {} };", + "\tconst authPath = nemoclawResolveDeviceAuthPath(baseDir);", + "\tconst [pending, paired, auth] = await Promise.all([readJsonIfExists(pendingPath), readJsonIfExists(pairedPath), readJsonIfExists(authPath)]);", + "\tconst snapshot = { pendingById: pending ?? {}, pairedByDeviceId: paired ?? {}, auth };", '\tif (!nemoclawIsSnapshot(snapshot)) throw new Error("invalid device pairing state during NemoClaw self-approval transaction");', "\treturn snapshot;", "}", "async function nemoclawWritePairingSnapshot(snapshot, baseDir) {", '\tconst { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices");', - "\tconst settled = await Promise.allSettled([writeJson(pendingPath, snapshot.pendingById), writeJson(pairedPath, snapshot.pairedByDeviceId)]);", + "\tconst authPath = nemoclawResolveDeviceAuthPath(baseDir);", + "\tconst settled = await Promise.allSettled([writeJson(pendingPath, snapshot.pendingById), writeJson(pairedPath, snapshot.pairedByDeviceId), writeJson(authPath, snapshot.auth, NEMOCLAW_SELF_APPROVAL_JOURNAL_WRITE_OPTIONS)]);", '\tconst failures = settled.filter((result) => result.status === "rejected").map((result) => result.reason);', - '\tif (failures.length > 0) throw new AggregateError(failures, "failed to publish both device pairing state files");', + '\tif (failures.length > 0) throw new AggregateError(failures, "failed to publish device pairing and stored-auth state");', "}", "function nemoclawCurrentMatchesJournal(current, journal) {", "\treturn (", "\t\t(nemoclawStatesEqual(current.pendingById, journal.before.pendingById) || nemoclawStatesEqual(current.pendingById, journal.after.pendingById)) &&", - "\t\t(nemoclawStatesEqual(current.pairedByDeviceId, journal.before.pairedByDeviceId) || nemoclawStatesEqual(current.pairedByDeviceId, journal.after.pairedByDeviceId))", + "\t\t(nemoclawStatesEqual(current.pairedByDeviceId, journal.before.pairedByDeviceId) || nemoclawStatesEqual(current.pairedByDeviceId, journal.after.pairedByDeviceId)) &&", + "\t\t(nemoclawStatesEqual(current.auth, journal.before.auth) || nemoclawStatesEqual(current.auth, journal.after.auth))", "\t);", "}", "async function recoverNemoClawSelfApprovalTransaction(baseDir) {", @@ -827,18 +895,28 @@ const STATE_TRANSACTION_HELPER = [ "\tawait writeJson(journalPath, nemoclawIdleJournal(), NEMOCLAW_SELF_APPROVAL_JOURNAL_WRITE_OPTIONS);", "\treturn journal.phase;", "} // nemoclaw: recover bounded self-approval state transaction (#4462)", - "async function persistNemoClawSelfApprovalState(state, baseDir, requestId, deviceId, before) {", + "async function persistNemoClawSelfApprovalState(state, baseDir, requestId, deviceId, before, identity) {", "\tconst journalPath = nemoclawResolveJournalPath(baseDir);", "\tconst current = await nemoclawReadPairingSnapshot(baseDir);", - '\tif (!nemoclawIsSnapshot(before) || !nemoclawStatesEqual(current, before)) throw new Error("device pairing state changed before NemoClaw self-approval publication");', - "\tconst after = { pendingById: state.pendingById, pairedByDeviceId: state.pairedByDeviceId };", + "\tconst currentPairing = { pendingById: current.pendingById, pairedByDeviceId: current.pairedByDeviceId };", + '\tif (!nemoclawIsPlainRecord(before) || !nemoclawStatesEqual(currentPairing, before)) throw new Error("device pairing state changed before NemoClaw self-approval publication");', + "\tconst beforePairedToken = nemoclawOperatorToken(current.pairedByDeviceId[deviceId]);", + "\tconst beforeAuthToken = nemoclawOperatorToken(current.auth);", + "\tconst afterPairedToken = nemoclawOperatorToken(state.pairedByDeviceId[deviceId]);", + "\tconst beforePairedScopes = nemoclawExactOperatorScopes(beforePairedToken?.scopes);", + "\tconst beforeAuthScopes = nemoclawExactOperatorScopes(beforeAuthToken?.scopes);", + "\tconst afterPairedScopes = nemoclawExactOperatorScopes(afterPairedToken?.scopes);", + '\tif (!identity || identity.deviceId !== deviceId || !beforePairedToken || !beforeAuthToken || !afterPairedToken || !beforePairedScopes || !beforeAuthScopes || !afterPairedScopes || identity.deviceToken !== beforePairedToken.token || identity.deviceToken !== beforeAuthToken.token || !nemoclawStatesEqual(beforePairedScopes, beforeAuthScopes)) throw new Error("stored device auth changed before NemoClaw self-approval publication");', + '\tconst afterAuth = { version: 1, deviceId, tokens: { operator: { token: afterPairedToken.token, role: "operator", scopes: [...afterPairedToken.scopes], updatedAtMs: Date.now() } } };', + "\tconst beforeSnapshot = { ...currentPairing, auth: current.auth };", + "\tconst after = { pendingById: state.pendingById, pairedByDeviceId: state.pairedByDeviceId, auth: afterAuth };", "\tconst prepared = nemoclawValidateJournal({", "\t\tversion: NEMOCLAW_SELF_APPROVAL_JOURNAL_VERSION,", "\t\tkind: NEMOCLAW_SELF_APPROVAL_JOURNAL_KIND,", '\t\tphase: "prepared",', "\t\trequestId,", "\t\tdeviceId,", - "\t\tbefore,", + "\t\tbefore: beforeSnapshot,", "\t\tafter", "\t});", "\tawait writeJson(journalPath, prepared, NEMOCLAW_SELF_APPROVAL_JOURNAL_WRITE_OPTIONS);", @@ -854,9 +932,7 @@ const STATE_TRANSACTION_HELPER = [ "\t\t}", "\t\tthrow error;", "\t}", - "\ttry {", - "\t\tawait writeJson(journalPath, nemoclawIdleJournal(), NEMOCLAW_SELF_APPROVAL_JOURNAL_WRITE_OPTIONS);", - "\t} catch {}", + "\tawait writeJson(journalPath, nemoclawIdleJournal(), NEMOCLAW_SELF_APPROVAL_JOURNAL_WRITE_OPTIONS);", "}", "", ].join("\n"); @@ -1085,7 +1161,7 @@ const STATE_APPROVAL_PERSIST_TARGET = [ const STATE_APPROVAL_PERSIST_REPLACEMENT = [ "\t\tdelete state.pendingById[requestId];", "\t\tstate.pairedByDeviceId[device.deviceId] = device;", - "\t\tif (nemoclawSelfApprovalScopes) await persistNemoClawSelfApprovalState(state, baseDir, requestId, device.deviceId, state[NEMOCLAW_SELF_APPROVAL_LOADED_SNAPSHOT]);", + "\t\tif (nemoclawSelfApprovalScopes) await persistNemoClawSelfApprovalState(state, baseDir, requestId, device.deviceId, state[NEMOCLAW_SELF_APPROVAL_LOADED_SNAPSHOT], options.nemoclawSelfApprovalIdentity);", '\t\telse await persistState(state, baseDir, "both");', "\t\treturn {", '\t\t\tstatus: "approved",', @@ -1244,22 +1320,44 @@ const FILE_SPECS: FileSpec[] = [ countOccurrences(source, marker), ); if (appliedMarkerCounts.some((count) => count > 0)) { - if (appliedMarkerCounts.every((count) => count === 1)) { + const settlementMarkerIndex = CLI_APPLIED_MARKERS.indexOf(CLI_SETTLEMENT_LIST_MARKER); + const settlementMarkerCount = appliedMarkerCounts[settlementMarkerIndex]; + const priorMarkerCounts = appliedMarkerCounts.filter( + (_count, index) => index !== settlementMarkerIndex, + ); + if (priorMarkerCounts.every((count) => count === 1) && settlementMarkerCount! <= 1) { + let upgradedSource = source; + let changed = false; const legacyModeLine = '\tconst nemoclawPairedTokenRequested = process.env.NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING === "1";'; - if (source.includes(legacyModeLine)) { + if (upgradedSource.includes(legacyModeLine)) { const result = replaceExactlyOnce( - source, + upgradedSource, legacyModeLine, '\tconst nemoclawPairedTokenRequested = process.env.NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING === "1";', "restored-clone paired-token mode target", file, ); - return result.error - ? { source, status: "no-match", error: result.error } - : { source: result.source, status: "would-apply" }; + if (result.error) return { source, status: "no-match", error: result.error }; + upgradedSource = result.source; + changed = true; } - return { source, status: "already-applied" }; + if (settlementMarkerCount === 0) { + const result = replaceExactlyOnce( + upgradedSource, + CLI_LIST_SIGNATURE_LEGACY_REPLACEMENT, + CLI_LIST_SIGNATURE_REPLACEMENT, + "devices CLI pairing-settlement list target", + file, + ); + if (result.error) return { source, status: "no-match", error: result.error }; + upgradedSource = result.source; + changed = true; + } + return { + source: upgradedSource, + status: changed ? "would-apply" : "already-applied", + }; } return { source, diff --git a/src/lib/actions/sandbox/auto-pair-approval-ordinary.test.ts b/src/lib/actions/sandbox/auto-pair-approval-ordinary.test.ts index b64b07d5b83..5ee48662e9f 100644 --- a/src/lib/actions/sandbox/auto-pair-approval-ordinary.test.ts +++ b/src/lib/actions/sandbox/auto-pair-approval-ordinary.test.ts @@ -18,7 +18,7 @@ const python3Available = describe("ordinary auto-pair approval pass behaviour (#4616)", () => { it.runIf(python3Available)( - "approves allowlisted upgrades, skips unknown clients, and reports the count", + "marks pairing-only list auth and drops shared overrides from both children (#9844)", () => { const policy = readAutoPairApprovalPolicyModule(); expect(policy).toBeTruthy(); @@ -29,6 +29,7 @@ describe("ordinary auto-pair approval pass behaviour (#4616)", () => { try { const approvalsFile = path.join(tmpDir, "approvals.log"); const approveEnvFile = path.join(tmpDir, "approve-env.log"); + const listEnvFile = path.join(tmpDir, "list-env.log"); const pending = [ { requestId: "ok-webchat", @@ -80,6 +81,19 @@ describe("ordinary auto-pair approval pass behaviour (#4616)", () => { const fs = require("fs"); const args = process.argv.slice(2); if (args[0] === "devices" && args[1] === "list") { + fs.appendFileSync( + ${JSON.stringify(listEnvFile)}, + [ + process.env.OPENCLAW_GATEWAY_URL || "unset", + process.env.OPENCLAW_GATEWAY_PORT || "unset", + process.env.OPENCLAW_GATEWAY_TOKEN || "unset", + process.env.NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING || "unset", + process.env.NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT || "unset", + process.env.NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING || "unset", + process.env.OPENCLAW_STATE_DIR || "unset", + process.env.OPENCLAW_CONFIG_PATH || "unset", + ].join(":") + "\\n", + ); process.stdout.write(${JSON.stringify(`${listResponse}\n`)}); process.exit(0); } @@ -91,6 +105,11 @@ if (args[0] === "devices" && args[1] === "approve") { process.env.OPENCLAW_GATEWAY_URL || "unset", process.env.OPENCLAW_GATEWAY_PORT || "unset", process.env.OPENCLAW_GATEWAY_TOKEN || "unset", + process.env.NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING || "unset", + process.env.NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT || "unset", + process.env.NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING || "unset", + process.env.OPENCLAW_STATE_DIR || "unset", + process.env.OPENCLAW_CONFIG_PATH || "unset", ].join(":") + "\\n", ); process.stdout.write("{}\\n"); @@ -109,6 +128,11 @@ process.exit(2); OPENCLAW_GATEWAY_URL: "ws://127.0.0.1:18789", OPENCLAW_GATEWAY_PORT: "18789", OPENCLAW_GATEWAY_TOKEN: "secret-token", + NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING: "1", + NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT: "ambient-settlement-marker", + NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING: "1", + OPENCLAW_STATE_DIR: "/sandbox/.openclaw", + OPENCLAW_CONFIG_PATH: "/sandbox/.openclaw/openclaw.json", }, timeout: 10_000, }); @@ -119,10 +143,20 @@ process.exit(2); const approveEnv = fs.existsSync(approveEnvFile) ? fs.readFileSync(approveEnvFile, "utf-8").trim().split("\n").filter(Boolean) : []; + const listEnv = fs.existsSync(listEnvFile) + ? fs.readFileSync(listEnvFile, "utf-8").trim().split("\n").filter(Boolean) + : []; expect(approvals).toEqual(["ok-webchat", "ok-cli", "ok-agent-cli"]); - // Gateway env stripped on the approve subprocess (#4462 workaround). - expect(approveEnv).toEqual(["unset:unset:unset", "unset:unset:unset", "unset:unset:unset"]); + // The list child carries one fixed pairing-settlement marker. Both + // children drop shared gateway and compatibility overrides while + // retaining the state/config paths for the stored CLI credential. + const expectedListEnv = + "unset:unset:unset:unset:1:unset:/sandbox/.openclaw:/sandbox/.openclaw/openclaw.json"; + const expectedApproveEnv = + "unset:unset:unset:unset:unset:unset:/sandbox/.openclaw:/sandbox/.openclaw/openclaw.json"; + expect(listEnv).toEqual([expectedListEnv]); + expect(approveEnv).toEqual([expectedApproveEnv, expectedApproveEnv, expectedApproveEnv]); expect(result.stdout).toContain(`${SUMMARY_MARKER}=3`); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/src/lib/actions/sandbox/auto-pair-approval-script.test.ts b/src/lib/actions/sandbox/auto-pair-approval-script.test.ts index 7de3edc384e..c0f69c88554 100644 --- a/src/lib/actions/sandbox/auto-pair-approval-script.test.ts +++ b/src/lib/actions/sandbox/auto-pair-approval-script.test.ts @@ -23,7 +23,9 @@ describe("buildAutoPairApprovalScript (#4263/#4616)", () => { expect(script).toContain("'devices', 'approve'"); expect(script).toContain("approval_request_decision(device)"); expect(script).toContain("if not decision['allowed']:"); + expect(script).toContain("list_env['NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT'] = '1'"); expect(script).toContain("approve_env = gateway_approval_env(os.environ)"); + expect(script).toContain("approve_env.pop('NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT', None)"); expect(script).toContain(`MAX_APPROVALS = ${AUTO_PAIR_MAX_APPROVALS}`); expect(script).toContain("'UE9MSUNZ'"); }); @@ -38,6 +40,7 @@ describe("buildAutoPairApprovalScript (#4263/#4616)", () => { expect(ordinary).not.toContain("local_identity_public_key"); expect(ordinary).toContain("approve_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None)"); + expect(ordinary).toContain("approve_env.pop('NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT', None)"); expect(ordinary).toContain("approve_env.pop('NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', None)"); expect(ordinary).not.toContain("approve_env['NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING'] = '1'"); expect(ordinary).not.toContain("approve_env['NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING'] = '1'"); diff --git a/src/lib/actions/sandbox/auto-pair-approval.ts b/src/lib/actions/sandbox/auto-pair-approval.ts index 3e4b9f47c91..afba685494f 100644 --- a/src/lib/actions/sandbox/auto-pair-approval.ts +++ b/src/lib/actions/sandbox/auto-pair-approval.ts @@ -26,17 +26,20 @@ * semantics. In the reviewed OpenClaw 2026.6.10, a gateway-pinned * `devices approve` for a scope-upgrade can request the upgraded scopes for * its own connection and return the pending-scope failure it is trying to - * resolve. The sourced runtime environment makes the list call inspect the - * same live gateway through local loopback. For a restored pairing-only clone, - * the approval child drops config/shared-auth overrides, pins the clone's - * loopback URL, and accepts only its descriptor-backed identity and pairing - * snapshots. The reviewed dist patch uses only the descriptor-backed - * pairing token for the pinned loopback gateway and disables pathname-backed - * stored authentication for that exact self-repair shape. It requires a - * matching live preflight before one canonical approval, then synchronizes the - * rotated token into the clone's client-auth store. Remove this compatibility - * path when OpenClaw can complete scope upgrades natively through device-token - * auth using operator.pairing. + * resolve. The sourced runtime environment identifies the live gateway. + * Ordinary list and approval children drop shared-auth overrides after pairing + * so they use the CLI device credential. For a restored pairing-only clone, + * the approval child also pins the clone's loopback URL and accepts only its + * descriptor-backed identity and pairing snapshots. The reviewed dist patch + * uses only the descriptor-backed pairing token for the pinned loopback + * gateway and disables pathname-backed stored authentication for that exact + * self-repair shape. It requires a matching live preflight before one canonical + * approval. The canonical writer binds the authenticated token to the paired + * and stored-auth before-images, then journals pending, paired, and stored-auth + * publication together. The wrapper verifies the resulting transition and + * rewrites the same rotated token to the clone's client-auth store. Remove this + * compatibility path when OpenClaw can complete scope upgrades natively + * through device-token auth using operator.pairing. */ import { spawnSync } from "node:child_process"; @@ -292,6 +295,7 @@ def exit_with_receipt(receipt): approve_env['NODE_DISABLE_COMPILE_CACHE'] = '1' approve_env['OPENCLAW_NO_RESPAWN'] = '1' approve_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None) + approve_env.pop('NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT', None) approve_env['NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING'] = '1' approve_env['NEMOCLAW_OPENCLAW_PINNED_GATEWAY_URL'] = pinned_gateway_url approve_env['NEMOCLAW_OPENCLAW_EXPECTED_DEVICE_ID'] = local_device_id @@ -306,9 +310,11 @@ def exit_with_receipt(receipt): ) else: approve_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None) + approve_env.pop('NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT', None) approve_env.pop('NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', None)` : `approve_env = gateway_approval_env(os.environ) approve_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None) + approve_env.pop('NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT', None) approve_env.pop('NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', None)`; const pairedTokenSuccess = options.localDeviceOnly ? ` if local_approval_auth_mode == 'paired-token': @@ -553,10 +559,14 @@ else: pending = list(local_pending_by_id.values()) ` : ` +list_env = gateway_approval_env(os.environ) +list_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None) +list_env.pop('NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', None) +list_env['NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT'] = '1' try: proc = subprocess.run( [OPENCLAW, 'devices', 'list', '--json'], - capture_output=True, text=True, timeout=${listTimeoutS}, + capture_output=True, text=True, timeout=${listTimeoutS}, env=list_env, ) except subprocess.TimeoutExpired: ${exitWithReceipt("list-timeout")} @@ -1204,9 +1214,7 @@ export function parsePortableOpenClawPairingApprovalReceipt( output: string, ): PortableOpenClawPairingApprovalReceipt | null { const lines = output.trimEnd().split(/\r?\n/u); - const markerLines = lines.filter((line) => - line.startsWith(PORTABLE_PAIRING_APPROVAL_MARKER), - ); + const markerLines = lines.filter((line) => line.startsWith(PORTABLE_PAIRING_APPROVAL_MARKER)); if (markerLines.length !== 1 || lines.at(-1) !== markerLines[0]) return null; const receipt = markerLines[0]!.slice(PORTABLE_PAIRING_APPROVAL_MARKER.length); return ["approved", "ambiguous", "no-request", "rejected", "unavailable"].includes(receipt) @@ -1220,8 +1228,7 @@ export function buildPortableOpenClawPairingApprovalScript( ): string { if ( !approvalPolicyModuleB64 || - Buffer.from(approvalPolicyModuleB64, "base64").toString("base64") !== - approvalPolicyModuleB64 || + Buffer.from(approvalPolicyModuleB64, "base64").toString("base64") !== approvalPolicyModuleB64 || !PORTABLE_PAIRING_SHA256_RE.test(expectedDeviceIdentitySha256) ) { throw new Error("Portable OpenClaw pairing approval inputs are invalid."); @@ -1328,6 +1335,7 @@ if ( approve_env = gateway_approval_env(os.environ) approve_env.pop('NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', None) +approve_env.pop('NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT', None) approve_env.pop('NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', None) try: approved = subprocess.run( diff --git a/src/lib/actions/sandbox/auto-pair-warmup.test.ts b/src/lib/actions/sandbox/auto-pair-warmup.test.ts index c30675c1843..694537ae8cf 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.test.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.test.ts @@ -29,10 +29,9 @@ const itWithSh = shAvailable ? it : it.skip; describe("scope-upgrade warm-up timeout bound v2 (#4504)", () => { it("uses a fixed 30s outer cap so a wedged warm-up can never block onboard", () => { - // The `-m "ping"` one-shot returns fast even when it falls back to embedded - // mode; 30s covers gateway-connect + the scope-upgrade request plus - // shell/agent startup while still bounding a hung sandbox. The constant is a - // dependency-free export so this assertion stays in-process. + // The direct call performs no inference work. Thirty seconds covers gateway + // connection, the scope-upgrade request, the bounded list poll, and shell + // startup while still bounding a hung sandbox. expect(WARMUP_TIMEOUT_MS).toBe(30_000); expect(typeof WARMUP_TIMEOUT_MS).toBe("number"); expect(WARMUP_TIMEOUT_MS).toBeGreaterThan(0); @@ -56,11 +55,9 @@ 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 provoke command itself may "fail" (the agent - // falls back to embedded mode), but `|| true` + trailing `exit 0` mean the - // wrapped script always exits 0 — so a failed provoke never surfaces as a - // nonzero status to the onboard path. Use `false` to stand in for the failing - // openclaw run. + // 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. 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); @@ -70,7 +67,9 @@ describe("warm-up payload uses native multiline OpenShell exec in v2 (#4504)", ( 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-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)"`); + 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)", () => { @@ -150,18 +149,107 @@ describe("warm-up tags its throwaway session for user-facing filters (#5511)", ( } }); - it("forces device pairing only for the provoke command on OpenClaw 2026.7.1", () => { + it("scopes forced device pairing to the provoke command on OpenClaw 2026.7.1", () => { const [provoke, poll] = WARMUP_SCRIPT.split("command -v python3", 2); expect(WARMUP_SCRIPT).toContain( - 'NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\\n openclaw agent --agent main -m "ping" \\', + 'NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\\n openclaw gateway call sessions.create --params "$params" --json', ); expect(provoke.match(/NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1/g)).toHaveLength(1); - expect(poll).not.toContain("NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING"); + expect(poll).not.toContain("NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1"); expect(WARMUP_SCRIPT).not.toContain("export NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING"); }); - it("keeps the v2 provoke run foreground and within the original budget (#4504)", () => { - expect(WARMUP_SCRIPT).toContain('openclaw agent --agent main -m "ping" \\'); + itWithSh("polls the pending upgrade with pairing-only stored device auth (#9844)", () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-warmup-poll-")); + const binDir = path.join(fixtureRoot, "bin"); + const proxyEnv = path.join(fixtureRoot, "proxy-env.sh"); + const pollEnvLog = path.join(fixtureRoot, "poll-env.log"); + const provokeEnvLog = path.join(fixtureRoot, "provoke-env.log"); + fs.mkdirSync(binDir); + fs.writeFileSync( + proxyEnv, + [ + "export OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789", + "export OPENCLAW_GATEWAY_PORT=18789", + "export OPENCLAW_GATEWAY_TOKEN=shared-token", + "export OPENCLAW_GATEWAY_PASSWORD=shared-password", + "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", + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(binDir, "openclaw"), + [ + "#!/bin/sh", + 'if [ "${1:-}" = "gateway" ]; then', + " {", + " printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"", + " printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"", + " printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"", + " printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"", + " printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"", + " printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"", + " printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"", + " printf 'argv=%s\\n' \"$*\"", + ' } > "$NEMOCLAW_TEST_PROVOKE_ENV_LOG"', + " exit 1", + "fi", + "{", + " printf 'url=%s\\n' \"${OPENCLAW_GATEWAY_URL-unset}\"", + " printf 'port=%s\\n' \"${OPENCLAW_GATEWAY_PORT-unset}\"", + " printf 'token=%s\\n' \"${OPENCLAW_GATEWAY_TOKEN-unset}\"", + " printf 'password=%s\\n' \"${OPENCLAW_GATEWAY_PASSWORD-unset}\"", + " printf 'force=%s\\n' \"${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING-unset}\"", + " printf 'restored=%s\\n' \"${NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING-unset}\"", + " printf 'settlement=%s\\n' \"${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT-unset}\"", + '} > "$NEMOCLAW_TEST_POLL_ENV_LOG"', + 'printf \'%s\\n\' \'{"pending":[{"scopes":["operator.write"]}],"paired":[]}\'', + "", + ].join("\n"), + { mode: 0o700 }, + ); + + try { + const script = WARMUP_SCRIPT.replace("/tmp/nemoclaw-proxy-env.sh", proxyEnv); + const result = spawnSync("sh", ["-c", script], { + encoding: "utf-8", + env: { + ...process.env, + NEMOCLAW_TEST_POLL_ENV_LOG: pollEnvLog, + NEMOCLAW_TEST_PROVOKE_ENV_LOG: provokeEnvLog, + PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, + }, + timeout: 10_000, + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(provokeEnvLog, "utf8")).toMatch( + /^url=unset\nport=unset\ntoken=unset\npassword=unset\nforce=1\nrestored=unset\nsettlement=unset\nargv=gateway call sessions\.create --params \{"key":"agent:main:nemoclaw-onboard-warmup-\d+-\d+","agentId":"main"\} --json\n$/, + ); + expect(fs.readFileSync(pollEnvLog, "utf8")).toBe( + [ + "url=unset", + "port=unset", + "token=unset", + "password=unset", + "force=unset", + "restored=unset", + "settlement=1", + "", + ].join("\n"), + ); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("uses a direct write-scope probe without an embedded inference fallback (#9844)", () => { + expect(WARMUP_SCRIPT).toContain( + 'openclaw gateway call sessions.create --params "$params" --json', + ); + expect(WARMUP_SCRIPT).not.toContain("openclaw agent"); expect(WARMUP_SCRIPT).toContain(">/dev/null 2>&1 || true"); expect(WARMUP_SCRIPT).not.toContain("setsid"); expect(WARMUP_SCRIPT).not.toContain("WARMUP_AGENT_PID"); diff --git a/src/lib/actions/sandbox/auto-pair-warmup.ts b/src/lib/actions/sandbox/auto-pair-warmup.ts index 834678460cb..dab11ad8d8f 100644 --- a/src/lib/actions/sandbox/auto-pair-warmup.ts +++ b/src/lib/actions/sandbox/auto-pair-warmup.ts @@ -7,26 +7,25 @@ * 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* real - * `openclaw agent` run — which happens *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. + * 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. * - * This warm-up provokes the upgrade ourselves: it runs a single, throwaway, - * bounded `openclaw agent --agent main -m "ping"` inside the sandbox during - * finalization. That connects to the gateway exactly as the user's first run - * will, triggers the identical `operator.write` scope-upgrade request, and - * makes it PENDING. The existing `runConnectAutoPairApprovalPass` (run - * immediately after) then approves it, so `operator.write` is persisted before - * handoff and the user's first run connects clean. + * 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 clean. * - * Contract: best-effort, non-blocking, idempotent. The warm-up run will itself - * fall back to embedded mode on this first invocation (EXIT 0) — that is - * expected; its output is discarded. Any failure (exec timeout, gateway not up, - * agent error) is swallowed so finalization is never blocked; behavior then - * degrades to the v1 first-run-falls-back-then-recover path, strictly no worse - * than today. On re-onboard where `operator.write` is already paired the run - * connects clean (no new pending) and the approval pass is a no-op. + * 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. * * Workaround boundary (NemoClaw#4462): OpenClaw owns device-pairing semantics * and exposes only `devices list/get/approve` — there is no way to pre-grant a @@ -39,18 +38,16 @@ import { spawnSync } from "node:child_process"; import { ROOT } from "../../state/paths"; import { WARMUP_SESSION_ID_PREFIX } from "./warmup-session"; -// Outer spawnSync cap (ms) for a throwaway warm-up call. The onboard `-m` -// one-shot prompt ("ping") returns fast even when it falls back to embedded -// mode, so 30s comfortably covers gateway-connect + scope-upgrade request, the -// bounded pending-upgrade poll below, plus shell/CLI startup, while never -// letting a wedged sandbox block onboard or restore. +// Outer spawnSync cap (ms) for the direct write-scope probe and its bounded +// pending-upgrade poll. The cap prevents a wedged sandbox from blocking onboard +// or restore. export const WARMUP_TIMEOUT_MS = 30_000; // Bounded in-sandbox poll for the pending scope upgrade after the provoke run. // Worst case = WARMUP_POLL_ATTEMPTS × WARMUP_POLL_LIST_TIMEOUT_S list calls plus // (WARMUP_POLL_ATTEMPTS - 1) inter-attempt 1s sleeps = 5×2 + 4×1 = 14s, which // leaves clear headroom under WARMUP_TIMEOUT_MS (30s) for shell startup and the -// throwaway agent run that runs first. The gateway persists the upgrade +// direct gateway call that runs first. The gateway persists the upgrade // requestId once created (#4504 evidence), so once the poll sees it pending the // downstream approval pass deterministically finds and approves it before // handoff — making "very first real run, zero fallback" deterministic even on @@ -61,19 +58,26 @@ export const WARMUP_POLL_LIST_TIMEOUT_S = 2; // Best-effort in-sandbox warm-up script. Always exits 0. It connects to the // gateway and provokes the `operator.write` scope-upgrade so the request is // PENDING, then POLLS `devices list` until that allowlisted upgrade is visible -// (or the bounded deadline elapses) before returning — closing the race where +// (or the bounded deadline elapses) before returning, closing the race where // the approval pass that runs immediately after could otherwise list devices // before the gateway has registered the upgrade. The poll bounds are -// interpolated so the cap is asserted on real values, not source text. OpenClaw -// 2026.7.1 otherwise omits CLI device identity for loopback shared-token auth -// before a stored device credential exists; force pairing only on the provoke. +// interpolated so the cap is asserted on real values, not source text. Use the +// stored CLI device credential for the provoke. Shared gateway overrides would +// authorize the owner instead of publishing the device's scope request. +// OpenClaw 2026.7.1 can omit CLI identity on loopback shared auth, so force +// device pairing only on this command. export const WARMUP_SCRIPT = ` PROXY_ENV=/tmp/nemoclaw-proxy-env.sh [ -r "$PROXY_ENV" ] && . "$PROXY_ENV" command -v openclaw >/dev/null 2>&1 || 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 +session_key="agent:main:${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" +params="$(printf '{"key":"%s","agentId":"main"}' "$session_key")" NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING=1 \\ - openclaw agent --agent main -m "ping" \\ - --session-id "${WARMUP_SESSION_ID_PREFIX}$$-$(date +%s)" >/dev/null 2>&1 || true + openclaw gateway call sessions.create --params "$params" --json >/dev/null 2>&1 || true command -v python3 >/dev/null 2>&1 || exit 0 OPENCLAW_BIN="$(command -v openclaw)" i=0 @@ -85,10 +89,24 @@ import subprocess import sys OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw') +# The proxy environment is shared gateway routing. Settlement must instead use +# the paired CLI identity with its current pairing-only credential so the list +# call can observe the write-scope request that the provoke command just made. +list_env = dict(os.environ) +for key in ( + 'OPENCLAW_GATEWAY_URL', + 'OPENCLAW_GATEWAY_PORT', + 'OPENCLAW_GATEWAY_TOKEN', + 'OPENCLAW_GATEWAY_PASSWORD', + 'NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING', + 'NEMOCLAW_OPENCLAW_RESTORED_CLONE_PAIRING', +): + list_env.pop(key, None) +list_env['NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT'] = '1' try: proc = subprocess.run( [OPENCLAW, 'devices', 'list', '--json'], - capture_output=True, text=True, timeout=${WARMUP_POLL_LIST_TIMEOUT_S}, + capture_output=True, text=True, timeout=${WARMUP_POLL_LIST_TIMEOUT_S}, env=list_env, ) except (subprocess.TimeoutExpired, FileNotFoundError, OSError): sys.exit(1) @@ -180,8 +198,8 @@ function runSandboxWarmupScript(sandboxName: string, script: string): void { /** * 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: this is best-effort and - * must never throw — onboard finalization must not be blocked. + * missing openclaw, gateway unreachable) are swallowed. The finalization + * settlement gate decides readiness from a later canonical observation. */ export function runSandboxScopeWarmupRun(sandboxName: string): void { runSandboxWarmupScript(sandboxName, WARMUP_SCRIPT); diff --git a/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts new file mode 100644 index 00000000000..d5fddff9cea --- /dev/null +++ b/src/lib/actions/sandbox/launch-readiness-ordinary-pairing.test.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { loadAgent } from "../../agent/defs"; +import type { SandboxEntry } from "../../state/registry"; +import { type LaunchReadinessDeps, resolveOrdinaryOpenClawPairingTarget } from "./launch-readiness"; + +const SANDBOX_NAME = "alpha"; +const GATEWAY_NAME = "nemoclaw"; +const FINGERPRINT = "b".repeat(64); + +function openClawEntry(): SandboxEntry { + return { + name: SANDBOX_NAME, + openshellDriver: "docker", + openshellVersion: "0.0.99", + gatewayName: GATEWAY_NAME, + gatewayPort: 8080, + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: FINGERPRINT, + agent: null, + agentVersion: "1.0.0", + nemoclawVersion: "2.0.0", + imageTag: "example@sha256:immutable", + policyPresetsFinalized: true, + policies: ["managed_inference"], + policyTier: "standard", + provider: null, + model: null, + endpointUrl: null, + credentialEnv: null, + preferredInferenceApi: null, + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + }; +} + +describe("ordinary OpenClaw pairing target", () => { + const deps: LaunchReadinessDeps = { + getSandbox: vi.fn(), + listAgents: vi.fn(() => ["openclaw"]), + loadAgent: vi.fn(() => loadAgent("openclaw")), + }; + + it("resolves the finalized default OpenClaw runtime identity (#9844)", () => { + vi.mocked(deps.getSandbox!).mockReturnValue(openClawEntry()); + + expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toEqual({ + gatewayName: GATEWAY_NAME, + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: FINGERPRINT, + stateDirectory: "/sandbox/.openclaw", + version: "1.0.0", + }); + }); + + it("resolves ordinary pairing after a supported policy-skip onboarding (#9817)", () => { + vi.mocked(deps.getSandbox!).mockReturnValue({ + ...openClawEntry(), + policyPresetsFinalized: undefined, + }); + + expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toEqual({ + gatewayName: GATEWAY_NAME, + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: FINGERPRINT, + stateDirectory: "/sandbox/.openclaw", + version: "1.0.0", + }); + }); + + it.each([ + ["missing agent identity", { agent: undefined }], + ["pending route reservation", { pendingRouteReservation: true }], + ["changed gateway binding", { gatewayName: "nemoclaw-8081" }], + ["missing lifecycle generation", { lifecycleGeneration: undefined }], + ])("rejects %s (#9844)", (_label, mutation) => { + vi.mocked(deps.getSandbox!).mockReturnValue({ + ...openClawEntry(), + ...mutation, + } as SandboxEntry); + + expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toBeNull(); + }); + + it("returns no target when registry observation fails (#9844)", () => { + vi.mocked(deps.getSandbox!).mockImplementation(() => { + throw new Error("registry unavailable"); + }); + + expect(resolveOrdinaryOpenClawPairingTarget(SANDBOX_NAME, deps)).toBeNull(); + }); +}); diff --git a/src/lib/actions/sandbox/launch-readiness.ts b/src/lib/actions/sandbox/launch-readiness.ts index 598ef7bd38c..ee6c2720ee6 100644 --- a/src/lib/actions/sandbox/launch-readiness.ts +++ b/src/lib/actions/sandbox/launch-readiness.ts @@ -156,6 +156,14 @@ export type PortableOpenClawPairingSettlementResult = | "portable-pairing-incomplete"; }; +export interface OpenClawPairingSettlementTarget { + readonly gatewayName: string; + readonly lifecycleGeneration: string; + readonly lifecycleLiveIdentityFingerprint: string; + readonly stateDirectory: string; + readonly version: string; +} + type LaunchReadinessPublicationValidationCategory = Extract< LaunchReadinessPublicationResult, { kind: "validation-failed" } @@ -890,23 +898,20 @@ function portableReceiptChanged( ); } -function resolvePortablePairingTarget( +function resolveOpenClawPairingSettlementTarget( sandboxName: string, entry: SandboxEntry | null, - registryGeneration: string, deps: LaunchReadinessDeps, -): { - readonly gatewayName: string; - readonly stateDirectory: string; - readonly version: string; -} | null { + requiredGeneration?: string, +): OpenClawPairingSettlementTarget | null { + // Policy eligibility belongs to the settlement caller. Ordinary onboarding + // permits policy skip, while Portable pairing requires the finalized marker. if ( !entry || entry.name !== sandboxName || - entry.agent !== "openclaw" || - entry.policyPresetsFinalized !== true || - entry.lifecycleGeneration !== registryGeneration || - !normalizedString(entry.lifecycleLiveIdentityFingerprint) || + (entry.agent !== null && entry.agent !== "openclaw") || + entry.pendingRouteReservation === true || + entry.reservationSessionId || !Number.isInteger(entry.gatewayPort) || (entry.gatewayPort ?? 0) < 1 || (entry.gatewayPort ?? 0) > 65535 @@ -925,8 +930,38 @@ function resolvePortablePairingTarget( const version = normalizedString(entry.agentVersion); const expectedVersion = normalizedString(agent.expected_version); const stateDirectory = normalizedString(agent.config?.dir); - if (!version || !expectedVersion || !stateDirectory) return null; - return { gatewayName, stateDirectory, version }; + const lifecycleGeneration = normalizedString(entry.lifecycleGeneration); + const lifecycleLiveIdentityFingerprint = normalizedString(entry.lifecycleLiveIdentityFingerprint); + if ( + !version || + !expectedVersion || + !stateDirectory || + !lifecycleGeneration || + !lifecycleLiveIdentityFingerprint || + (requiredGeneration !== undefined && lifecycleGeneration !== requiredGeneration) + ) { + return null; + } + return { + gatewayName, + lifecycleGeneration, + lifecycleLiveIdentityFingerprint, + stateDirectory, + version, + }; +} + +/** Resolve the finalized ordinary OpenClaw runtime that owns pairing state. */ +export function resolveOrdinaryOpenClawPairingTarget( + sandboxName: string, + deps: LaunchReadinessDeps = {}, +): OpenClawPairingSettlementTarget | null { + try { + const getSandbox = deps.getSandbox ?? registry.getSandbox; + return resolveOpenClawPairingSettlementTarget(sandboxName, getSandbox(sandboxName), deps); + } catch { + return null; + } } /** @@ -993,11 +1028,11 @@ export async function settlePortableOpenClawPairing( if (firstEntry.policyPresetsFinalized !== true) { return incompletePortablePairing("portable-policy-incomplete"); } - const firstTarget = resolvePortablePairingTarget( + const firstTarget = resolveOpenClawPairingSettlementTarget( sandboxName, firstEntry, - firstReceipt.registryGeneration, deps, + firstReceipt.registryGeneration, ); if (!firstTarget) return incompletePortablePairing("portable-runtime-identity-invalid"); @@ -1010,11 +1045,11 @@ export async function settlePortableOpenClawPairing( if (lockedEntry?.policyPresetsFinalized !== true) { return incompletePortablePairing("portable-policy-incomplete"); } - const target = resolvePortablePairingTarget( + const target = resolveOpenClawPairingSettlementTarget( sandboxName, lockedEntry, - lockedReceipt.registryGeneration, deps, + lockedReceipt.registryGeneration, ); if ( !target || 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 cc274a31a32..dbe87ef2fb9 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 @@ -19,6 +19,7 @@ import { buildOpenClawPairingObservationScript, observeOpenClawPairingQualification, observeOpenClawPairingSettlement, + observeOrdinaryOpenClawPairingSettlement, OPENCLAW_PAIRING_REQUEST_SCOPES, OPENCLAW_PAIRING_REQUIRED_SCOPES, parseOpenClawPairingObservation, @@ -164,6 +165,20 @@ describe("OpenClaw launch-readiness pairing qualification", () => { }); } + function observeOrdinarySettlement(approvalPolicy = POLICY) { + return observeOrdinaryOpenClawPairingSettlement( + "alpha", + "nemoclaw-8080", + "2026.7.1", + stateDirectory, + { + getOpenshellBinary: () => "openshell", + readApprovalPolicy: () => approvalPolicy, + spawnSync: localScriptSpawn as typeof spawnSync, + }, + ); + } + function writePairingOnlyState(): void { const pairedPath = path.join(stateDirectory, "devices", "paired.json"); const authPath = path.join(stateDirectory, "identity", "device-auth.json"); @@ -276,6 +291,39 @@ describe("OpenClaw launch-readiness pairing qualification", () => { expect(() => observeSettlement()).toThrow("OpenClaw pairing qualification is unavailable"); }); + it("allows unrelated pending requests but rejects same-device pending state during ordinary onboarding (#9844)", () => { + writeJson(path.join(stateDirectory, "devices", "pending.json"), { + unrelated: { + requestId: "unrelated", + deviceId: "b".repeat(64), + publicKey: "unrelated-public-key", + clientId: "unknown-client", + scopes: ["operator.admin"], + }, + }); + + expect(observeOrdinarySettlement()).toEqual({ + state: "settled", + deviceIdentitySha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(() => observeSettlement()).toThrow( + "OpenClaw pairing qualification is unavailable", + ); + + writeJson(path.join(stateDirectory, "devices", "pending.json"), { + related: { + requestId: "related", + deviceId, + publicKey, + clientId: "unknown-client", + scopes: ["operator.admin"], + }, + }); + expect(() => observeOrdinarySettlement()).toThrow( + "OpenClaw pairing qualification is unavailable", + ); + }); + it("qualifies the persisted result of the complete canonical approval transition (#9023)", () => { const approvalPolicy = readAutoPairApprovalPolicyModule(); expect(approvalPolicy).toBeTruthy(); 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 3c837902899..6036cfe627d 100644 --- a/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts +++ b/src/lib/actions/sandbox/launch-readiness/openclaw-pairing-qualification.ts @@ -130,7 +130,7 @@ export function parseOpenClawPairingSettlementObservation( export function buildOpenClawPairingObservationScript( approvalPolicyModuleB64: string, stateDirectory: string, - mode: "qualification" | "settlement" = "qualification", + mode: "ordinary-settlement" | "qualification" | "settlement" = "qualification", ): string { if (!path.posix.isAbsolute(stateDirectory)) { throw new OpenClawPairingQualificationError(); @@ -142,7 +142,7 @@ export function buildOpenClawPairingObservationScript( throw new OpenClawPairingQualificationError(); } const stateDirectoryB64 = Buffer.from(stateDirectory, "utf8").toString("base64"); - const marker = mode === "settlement" ? SETTLEMENT_MARKER : QUALIFICATION_MARKER; + const marker = mode === "qualification" ? QUALIFICATION_MARKER : SETTLEMENT_MARKER; // OpenClaw owns these in-sandbox state files. This observer reads // descriptor-pinned state through the requested gateway and returns only // allowlisted fields and digests. Pairing changes remain owned by the @@ -167,6 +167,8 @@ REQUIRED_ROLES = ['operator'] PAIRING_ONLY_SCOPES = ['operator.pairing'] REQUEST_SCOPES = ['operator.pairing', 'operator.write'] TOKEN_SCOPES = ['operator.pairing', 'operator.read', 'operator.write'] +ORDINARY_SETTLEMENT = ${mode === "ordinary-settlement" ? "True" : "False"} +STRICT_SETTLEMENT = ${mode === "settlement" ? "True" : "False"} ED25519_SPKI_PREFIX = bytes.fromhex('302a300506032b6570032100') RAW_PUBLIC_KEY_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') @@ -474,7 +476,8 @@ try: ): reject() - ${mode === "settlement" ? "if pending:\n reject()" : ""} + if STRICT_SETTLEMENT and pending: + reject() for request_id, request in pending.items(): if ( not isinstance(request_id, str) @@ -483,6 +486,10 @@ try: or request.get('requestId') != request_id ): reject() + if ORDINARY_SETTLEMENT: + if request.get('deviceId') == device_id or request.get('publicKey') == device_public_key: + sys.exit(2) + continue decision = approval_request_decision(request) if decision.get('reason') == 'malformed-scopes': reject() @@ -517,7 +524,7 @@ try: 'publicKey': device_public_key, }, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest() ${ - mode === "settlement" + mode !== "qualification" ? "print(MARKER + json.dumps({\n 'deviceIdentitySha256': device_identity_sha256,\n 'state': 'settled' if settled else 'pairing-only',\n }, sort_keys=True, separators=(',', ':')))\n sys.exit(0)" : "if not settled:\n reject()" } @@ -575,7 +582,7 @@ function runOpenClawPairingObservation( gatewayName: string, openclawVersion: string, stateDirectory: string, - mode: "qualification" | "settlement", + mode: "ordinary-settlement" | "qualification" | "settlement", execDeps?: Partial, ): { readonly output: string; readonly policy: string } { const approvalPolicy = (execDeps?.readApprovalPolicy ?? readAutoPairApprovalPolicyModule)(); @@ -641,6 +648,31 @@ export function observeOpenClawPairingSettlement( } } +export function observeOrdinaryOpenClawPairingSettlement( + sandboxName: string, + gatewayName: string, + openclawVersion: string, + stateDirectory: string, + execDeps?: Partial, +): OpenClawPairingSettlementObservation { + try { + const executed = runOpenClawPairingObservation( + sandboxName, + gatewayName, + openclawVersion, + stateDirectory, + "ordinary-settlement", + execDeps, + ); + const observation = parseOpenClawPairingSettlementObservation(executed.output); + if (!observation) throw new OpenClawPairingQualificationError(); + return observation; + } catch (error) { + if (error instanceof OpenClawPairingQualificationError) throw error; + throw new OpenClawPairingQualificationError(); + } +} + export function observeOpenClawPairingQualification( sandboxName: string, gatewayName: string, diff --git a/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts b/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts index f03829d7205..5fbb1ce6e2d 100644 --- a/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts +++ b/src/lib/actions/sandbox/launch-readiness/portable-openclaw-pairing-settlement.test.ts @@ -159,6 +159,24 @@ describe("Portable OpenClaw pairing settlement", () => { expect(scope.runApproval).not.toHaveBeenCalled(); }); + it("fails closed when Portable policy finalization changes inside the gateway lock (#9207)", async () => { + const getSandbox = vi + .fn() + .mockReturnValueOnce(ENTRY) + .mockReturnValue({ ...ENTRY, policyPresetsFinalized: undefined }); + const scope = settlementDeps({ getSandbox }); + + await expect(settlePortableOpenClawPairing("alpha", {}, scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "portable-policy-incomplete", + }); + expect(scope.calls).toEqual(["sandbox-lock", "gateway-lock"]); + expect(getSandbox).toHaveBeenCalledTimes(2); + expect(scope.observePairing).not.toHaveBeenCalled(); + expect(scope.runProducer).not.toHaveBeenCalled(); + expect(scope.runApproval).not.toHaveBeenCalled(); + }); + it("leaves a current Portable receipt on the ordinary non-OpenClaw path (#9207)", async () => { const scope = settlementDeps({ getSandbox: vi.fn(() => ({ ...ENTRY, agent: "hermes" })), diff --git a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts index 119d3652acc..8a7b2c54391 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -153,10 +153,11 @@ describe("rebuildSandbox flow: lifecycle", () => { policyTier: "balanced", policyPresetsFinalized: true, }); - expect(harness.executeSandboxCommandSpy).toHaveBeenCalledWith( + expect(harness.executeSandboxExecCommandSpy).toHaveBeenCalledWith( "alpha", "openclaw doctor --fix", 300_000, + { allowLocalDockerFallback: false }, ); expect(harness.relockSpy).toHaveBeenCalledWith("alpha", expect.any(Object), true, "nemoclaw"); expect(process.env.NEMOCLAW_SANDBOX_NAME).toBe(originalSandboxName); diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts index 7fbe5405aa7..5d7ad46936d 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.test.ts @@ -31,7 +31,7 @@ describe("rebuild post-restore phase", () => { vi.spyOn(agentDefs, "loadAgent").mockImplementation( () => ({ name: agentName, expectedVersion: null }) as never, ); - vi.spyOn(processRecovery, "executeSandboxCommand").mockImplementation(() => { + vi.spyOn(processRecovery, "executeSandboxExecCommand").mockImplementation(() => { order.push("doctor"); return { status: 0, stdout: "", stderr: "" }; }); @@ -126,15 +126,40 @@ describe("rebuild post-restore phase", () => { await runRebuildPostRestorePhase(input()); expect(order).toEqual(["doctor", "reconcile", "messaging", "config-hash", "config-hash-final"]); - expect(processRecovery.executeSandboxCommand).toHaveBeenCalledWith( + }); + + it("uses bounded OpenShell doctor execution without a direct-container fallback (#9844)", async () => { + await runRebuildPostRestorePhase(input()); + + expect(processRecovery.executeSandboxExecCommand).toHaveBeenCalledExactlyOnceWith( "alpha", "openclaw doctor --fix", 300_000, + { allowLocalDockerFallback: false }, ); }); + it("reports incomplete when OpenShell cannot confirm doctor completion (#9844)", async () => { + vi.mocked(processRecovery.executeSandboxExecCommand).mockReturnValue(null); + const args = input(); + + await runRebuildPostRestorePhase(args); + + expect(rebuildConfigHash.verifyFinalMutableOpenClawConfigHash).toHaveBeenCalledOnce(); + expect(args.bail).toHaveBeenCalledWith( + "OpenClaw post-upgrade structure repair completion was not verified after rebuild.", + ); + const output = vi.mocked(console.log).mock.calls.flat().join("\n"); + expect(output).toContain("Post-upgrade structure repair completion was not verified"); + expect(output).toContain( + "OpenClaw post-upgrade structure repair did not return a trusted completion result", + ); + expect(output).not.toContain("Post-upgrade structure check skipped"); + expect(output).not.toContain("rebuilt successfully"); + }); + it("fails when doctor returns 255 and the final OpenClaw config hash is unverified (#9530)", async () => { - vi.mocked(processRecovery.executeSandboxCommand).mockReturnValue({ + vi.mocked(processRecovery.executeSandboxExecCommand).mockReturnValue({ status: 255, stdout: "", stderr: "", @@ -197,7 +222,7 @@ describe("rebuild post-restore phase", () => { expect(args.bail).not.toHaveBeenCalled(); expect(sessionModels.reconcileStalePinnedSessionModelsAfterRebuild).not.toHaveBeenCalled(); - expect(processRecovery.executeSandboxCommand).not.toHaveBeenCalled(); + expect(processRecovery.executeSandboxExecCommand).not.toHaveBeenCalled(); }); it("keeps cron dispatch blocked through replacement health verification (#8472)", async () => { diff --git a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts index 91960e199fc..3a53d039a84 100644 --- a/src/lib/actions/sandbox/rebuild-post-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-post-restore-phase.ts @@ -12,7 +12,7 @@ import type * as sandboxVersion from "../../sandbox/version"; import * as shields from "../../shields"; import * as registry from "../../state/registry"; import { ensureMessagingHostForwardAfterRebuild } from "./messaging-host-forward-lifecycle"; -import { executeSandboxCommand } from "./process-recovery"; +import { executeSandboxExecCommand } from "./process-recovery"; import type { RebuildBackupManifest } from "./rebuild-backup-phase"; import { refreshMutableOpenClawConfigHashAfterPostRestoreWrites, @@ -229,6 +229,7 @@ export async function runRebuildPostRestorePhase( let mutablePermsRepairUnverified = false; let mutableConfigHashRefreshUnverified = false; let finalMutableConfigHashUnverified = false; + let openClawDoctorTransportUnverified = false; let messagingHostForwardUnverified = false; const policyPresetRestoreIncomplete = failedPresets.length > 0 || @@ -237,19 +238,23 @@ export async function runRebuildPostRestorePhase( if (targetAgentName === "openclaw") { log("Running openclaw doctor --fix inside sandbox for post-upgrade structure repair"); - const doctorResult = executeSandboxCommand( + const doctorResult = executeSandboxExecCommand( sandboxName, "openclaw doctor --fix", OPENCLAW_DOCTOR_TIMEOUT_MS, + { allowLocalDockerFallback: false }, ); log( `doctor --fix: exit=${doctorResult?.status}, stdout=${(doctorResult?.stdout || "").substring(0, 200)}`, ); - if (doctorResult && doctorResult.status === 0) { + if (doctorResult === null) { + openClawDoctorTransportUnverified = true; + console.log(` ${D}Post-upgrade structure repair completion was not verified${R}`); + } else if (doctorResult.status === 0) { console.log(` ${G}\u2713${R} Post-upgrade structure check passed`); } else { console.log( - ` ${D}Post-upgrade structure check skipped (doctor returned ${doctorResult?.status ?? "null"})${R}`, + ` ${D}Post-upgrade structure repair completed with exit ${doctorResult.status}${R}`, ); } @@ -411,16 +416,18 @@ export async function runRebuildPostRestorePhase( } console.log(""); - const postRestoreComplete = postRestoreCompleted({ - hermesGatewayRestoreUnverified, - messagingHostForwardUnverified, - mcpBridgeRestoreUnverified, - mutableConfigHashRefreshUnverified: - mutableConfigHashRefreshUnverified || finalMutableConfigHashUnverified, - mutablePermsRepairUnverified, - policyPresetRestoreIncomplete, - restoreSucceeded, - }); + const postRestoreComplete = + !openClawDoctorTransportUnverified && + postRestoreCompleted({ + hermesGatewayRestoreUnverified, + messagingHostForwardUnverified, + mcpBridgeRestoreUnverified, + mutableConfigHashRefreshUnverified: + mutableConfigHashRefreshUnverified || finalMutableConfigHashUnverified, + mutablePermsRepairUnverified, + policyPresetRestoreIncomplete, + restoreSucceeded, + }); if (postRestoreComplete) { printSuccessfulRebuildSummary({ sandboxName, @@ -454,6 +461,11 @@ export async function runRebuildPostRestorePhase( ` Final OpenClaw configuration hash verification failed after post-restore finalization \u2014 restart the sandbox or re-run \`${CLI_NAME} ${sandboxName} rebuild\` before relying on config integrity checks`, ); } + if (openClawDoctorTransportUnverified) { + console.log( + ` OpenClaw post-upgrade structure repair did not return a trusted completion result; re-run \`${CLI_NAME} ${sandboxName} rebuild\` before relying on config integrity checks`, + ); + } if (messagingHostForwardUnverified) { console.log( ` Messaging webhook forward was not verified \u2014 run \`${CLI_NAME} ${sandboxName} connect\` after resolving the port conflict`, @@ -486,9 +498,15 @@ export async function runRebuildPostRestorePhase( } if ( targetAgentName === "openclaw" && - (mutableConfigHashRefreshUnverified || finalMutableConfigHashUnverified) + (openClawDoctorTransportUnverified || + mutableConfigHashRefreshUnverified || + finalMutableConfigHashUnverified) ) { - bail("OpenClaw config integrity verification failed after rebuild."); + bail( + openClawDoctorTransportUnverified + ? "OpenClaw post-upgrade structure repair completion was not verified after rebuild." + : "OpenClaw config integrity verification failed after rebuild.", + ); return; } if ( diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index 00d1c4762e6..91cbf7810ed 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -268,6 +268,7 @@ export function createSandboxOnboardFlowPhase< context: mergeSandboxCreatedContext(context, { session: sandboxStateResult.session, sandboxName: sandboxStateResult.sandboxName, + recreateJournalHandoff: Boolean(options.recreateJournalTargetIntentFingerprint), webSearchConfig: sandboxStateResult.webSearchConfig, webSearchConfigChanged: sandboxStateResult.webSearchConfigChanged, hermesToolGateways: sandboxStateResult.hermesToolGateways, diff --git a/src/lib/onboard/machine/final-flow-phases.ts b/src/lib/onboard/machine/final-flow-phases.ts index 0350d34af19..9de5af8825a 100644 --- a/src/lib/onboard/machine/final-flow-phases.ts +++ b/src/lib/onboard/machine/final-flow-phases.ts @@ -132,6 +132,7 @@ export function createFinalOnboardFlowPhases< ? options.finalization.webSearchProvider(context.webSearchConfig) : null, portableProfileSelected: context.session?.checkpoint?.profile.value === "portable", + recreateJournalHandoff: context.recreateJournalHandoff, deps: finalizationDeps, }); return { result: finalizationResult.stateResult }; @@ -156,6 +157,7 @@ export function createFinalOnboardFlowPhases< ? options.finalization.webSearchProvider(context.webSearchConfig) : null, portableProfileSelected: context.session?.checkpoint?.profile.value === "portable", + recreateJournalHandoff: context.recreateJournalHandoff, deps: finalizationDeps, }); return { result: postVerifyResult.stateResult }; diff --git a/src/lib/onboard/machine/finalization-deps.test.ts b/src/lib/onboard/machine/finalization-deps.test.ts index 7f9c3afc9c0..eae24feaf7b 100644 --- a/src/lib/onboard/machine/finalization-deps.test.ts +++ b/src/lib/onboard/machine/finalization-deps.test.ts @@ -1,10 +1,535 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; import type { VerifyDeploymentResult } from "../../verify-deployment"; -import { finalizationHandlerDeps, finalizationHandlerRuntime } from "./finalization-deps"; +import 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 { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock"; +import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; +import { + finalizationHandlerDeps, + finalizationHandlerRuntime, + OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS, + OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS, + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS, + ordinaryOpenClawPairingIncompleteMessage, + settleOrdinaryOpenClawPairing, +} from "./finalization-deps"; + +const PAIRING_TARGET = { + gatewayName: "nemoclaw", + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: "fingerprint-1", + stateDirectory: "/sandbox/.openclaw", + version: "2026.7.1", +}; + +const PAIRING_ONLY: OpenClawPairingSettlementObservation = { + state: "pairing-only", + deviceIdentitySha256: "a".repeat(64), +}; + +const SETTLED: OpenClawPairingSettlementObservation = { + state: "settled", + deviceIdentitySha256: PAIRING_ONLY.deviceIdentitySha256, +}; + +function ordinaryPairingDeps( + overrides: Partial[1]> = {}, +) { + let now = 0; + const calls: string[] = []; + const deps = { + getTarget: vi.fn(() => PAIRING_TARGET), + observePairing: vi.fn(() => SETTLED), + runWarmup: vi.fn(() => { + calls.push("warmup"); + }), + runApproval: vi.fn(() => { + calls.push("approval"); + }), + withSandboxLock: vi.fn(async (_name, operation) => operation()), + withGatewayLock: vi.fn(async (_gatewayName, operation) => operation()), + now: vi.fn(() => now), + sleep: vi.fn(async (milliseconds: number) => { + calls.push("sleep"); + now += milliseconds; + }), + ...overrides, + }; + return { calls, deps }; +} + +describe("ordinary OpenClaw pairing settlement", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("accepts one already-settled canonical CLI device without pairing writes (#9844)", async () => { + const scope = ordinaryPairingDeps(); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "settled", + }); + + expect(scope.deps.observePairing).toHaveBeenCalledExactlyOnceWith( + "alpha", + "nemoclaw", + "2026.7.1", + "/sandbox/.openclaw", + ); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runApproval).not.toHaveBeenCalled(); + }); + + it("waits for canonical pairing before one warm-up and approval pass (#9844)", async () => { + const scope = ordinaryPairingDeps({ + observePairing: vi + .fn() + .mockImplementationOnce(() => { + throw new Error("not published"); + }) + .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValue(SETTLED), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "settled", + }); + + expect(scope.calls).toEqual(["sleep", "warmup", "approval"]); + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha"); + expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + }); + + it("holds lifecycle then gateway-route ownership across the full settlement (#9844)", async () => { + const events: string[] = []; + const scope = ordinaryPairingDeps({ + observePairing: vi + .fn() + .mockImplementationOnce(() => { + events.push("observe:baseline"); + return PAIRING_ONLY; + }) + .mockImplementation(() => { + events.push("observe:final"); + return SETTLED; + }), + runWarmup: vi.fn(() => { + events.push("warmup"); + }), + runApproval: vi.fn(() => { + events.push("approval"); + }), + withSandboxLock: vi.fn(async (_name, operation) => { + events.push("sandbox-lock:start"); + const result = await operation(); + events.push("sandbox-lock:end"); + return result; + }), + withGatewayLock: vi.fn(async (_gatewayName, operation) => { + events.push("gateway-lock:start"); + const result = await operation(); + events.push("gateway-lock:end"); + return result; + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "settled", + }); + + expect(events).toEqual([ + "sandbox-lock:start", + "gateway-lock:start", + "observe:baseline", + "warmup", + "approval", + "observe:final", + "gateway-lock:end", + "sandbox-lock:end", + ]); + }); + + 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; + }); + const approvalStarted = new Promise((resolve) => { + reportApprovalStarted = resolve; + }); + const mutationEvents: string[] = []; + const approvalTargets: string[] = []; + const lockOptions = { pollIntervalMs: 1, stateDir, timeoutMs: 5_000 }; + let replacement: Promise | undefined; + let routeReuse: Promise | undefined; + try { + const scope = ordinaryPairingDeps({ + getTarget: vi.fn(() => currentTarget), + observePairing: vi.fn().mockReturnValueOnce(PAIRING_ONLY).mockReturnValue(SETTLED), + runApproval: vi.fn(async (_name, gatewayName) => { + approvalTargets.push(`${currentTarget.lifecycleGeneration}:${gatewayName}`); + reportApprovalStarted(); + await approvalPending; + }), + withSandboxLock: (name, operation) => withMcpLifecycleLock(name, operation, lockOptions), + withGatewayLock: (gatewayName, operation) => + withGatewayRouteMutationLock(gatewayName, operation, lockOptions), + }); + + const settlement = settleOrdinaryOpenClawPairing("alpha", scope.deps); + await approvalStarted; + replacement = withMcpLifecycleLock( + "alpha", + () => { + mutationEvents.push("replacement-entered"); + currentTarget = { ...PAIRING_TARGET, lifecycleGeneration: "generation-2" }; + }, + lockOptions, + ); + routeReuse = withGatewayRouteMutationLock( + "nemoclaw", + () => { + mutationEvents.push("route-reuse-entered"); + }, + lockOptions, + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mutationEvents).toEqual([]); + expect(approvalTargets).toEqual(["generation-1:nemoclaw"]); + + releaseApproval(); + await expect(settlement).resolves.toEqual({ kind: "settled" }); + await Promise.all([replacement, routeReuse]); + expect(mutationEvents).toEqual( + expect.arrayContaining(["replacement-entered", "route-reuse-entered"]), + ); + expect(currentTarget.lifecycleGeneration).toBe("generation-2"); + } finally { + releaseApproval(); + const pendingMutations = [replacement, routeReuse].filter( + (mutation): mutation is Promise => mutation !== undefined, + ); + await Promise.allSettled(pendingMutations); + await fs.rm(stateDir, { recursive: true, force: true }); + } + }); + + it("reports unavailable when pairing lock acquisition fails (#9844)", async () => { + const scope = ordinaryPairingDeps({ + withGatewayLock: vi.fn(async () => { + throw new Error("lock timeout"); + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "pairing-lock-unavailable", + }); + 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 () => { + const scope = ordinaryPairingDeps({ + withSandboxLock: vi.fn(async () => { + throw new Error("lock timeout"); + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "pairing-lock-unavailable", + }); + expect(scope.deps.getTarget).not.toHaveBeenCalled(); + expect(scope.deps.withGatewayLock).not.toHaveBeenCalled(); + }); + + it("does not relabel a settlement-body failure as lock acquisition (#9844)", async () => { + const failure = new Error("registry read failed"); + const scope = ordinaryPairingDeps({ + getTarget: vi.fn(() => { + throw failure; + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).rejects.toBe(failure); + }); + + it("stops before approval when the runtime changes during warm-up (#9844)", async () => { + let currentTarget = PAIRING_TARGET; + let reportWarmupStarted: () => void = () => {}; + let releaseWarmup: () => void = () => {}; + const warmupStarted = new Promise((resolve) => { + reportWarmupStarted = resolve; + }); + const warmupPending = new Promise((resolve) => { + releaseWarmup = resolve; + }); + const scope = ordinaryPairingDeps({ + getTarget: vi.fn(() => currentTarget), + observePairing: vi.fn(() => PAIRING_ONLY), + runWarmup: vi.fn(async () => { + reportWarmupStarted(); + await warmupPending; + }), + }); + + const settlement = settleOrdinaryOpenClawPairing("alpha", scope.deps); + await warmupStarted; + currentTarget = { ...PAIRING_TARGET, lifecycleGeneration: "generation-2" }; + releaseWarmup(); + + await expect(settlement).resolves.toEqual({ + kind: "incomplete", + 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 () => { + let currentTarget = PAIRING_TARGET; + let reportApprovalStarted: () => void = () => {}; + let releaseApproval: () => void = () => {}; + const approvalStarted = new Promise((resolve) => { + reportApprovalStarted = resolve; + }); + const approvalPending = new Promise((resolve) => { + releaseApproval = resolve; + }); + const scope = ordinaryPairingDeps({ + getTarget: vi.fn(() => currentTarget), + observePairing: vi.fn(() => PAIRING_ONLY), + runApproval: vi.fn(async () => { + reportApprovalStarted(); + await approvalPending; + }), + }); + + const settlement = settleOrdinaryOpenClawPairing("alpha", scope.deps); + await approvalStarted; + currentTarget = { ...PAIRING_TARGET, lifecycleGeneration: "generation-2" }; + releaseApproval(); + + await expect(settlement).resolves.toEqual({ + kind: "incomplete", + reason: "runtime-identity-invalid", + }); + expect(scope.deps.runApproval).toHaveBeenCalledOnce(); + expect(scope.deps.observePairing).toHaveBeenCalledOnce(); + }); + + it("keeps pairing appearance and final observation independently bounded (#9844)", async () => { + let attempts = 0; + const unavailable = () => { + throw new Error("not published"); + }; + const scope = ordinaryPairingDeps({ + observePairing: vi.fn(() => (attempts++ < 10 ? unavailable() : PAIRING_ONLY)), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "scope-upgrade-incomplete", + }); + expect(scope.deps.sleep).toHaveBeenCalledTimes(40); + expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); + expect(scope.deps.runApproval).toHaveBeenCalledOnce(); + }); + + it("reserves approval and final observation after bounded child caps (#9844)", async () => { + let now = 0; + const scope = ordinaryPairingDeps({ + now: vi.fn(() => now), + sleep: vi.fn(async () => { + now += OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS - 1_000; + }), + observePairing: vi + .fn() + .mockImplementationOnce(() => { + throw new Error("not published"); + }) + .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValue(SETTLED), + runWarmup: vi.fn(() => { + now += WARMUP_TIMEOUT_MS; + }), + runApproval: vi.fn(() => { + now += CONNECT_AUTO_PAIR_TIMEOUT_MS; + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "settled", + }); + + expect(scope.deps.runWarmup).toHaveBeenCalledExactlyOnceWith("alpha"); + expect(scope.deps.runApproval).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + expect(scope.deps.observePairing).toHaveBeenCalledTimes(3); + expect(now).toBe( + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS - + 1_000 + + WARMUP_TIMEOUT_MS + + CONNECT_AUTO_PAIR_TIMEOUT_MS, + ); + expect(OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS).toBe( + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS + + WARMUP_TIMEOUT_MS + + CONNECT_AUTO_PAIR_TIMEOUT_MS + + OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS, + ); + }); + + it("rejects an observation that finishes after the pairing-appearance deadline (#9844)", async () => { + let now = 0; + const scope = ordinaryPairingDeps({ + now: vi.fn(() => now), + observePairing: vi.fn(() => { + now = 30_001; + return SETTLED; + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "pairing-unavailable", + }); + expect(scope.deps.sleep).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runApproval).not.toHaveBeenCalled(); + }); + + it("performs no writes when a canonical CLI pairing never appears (#9844)", async () => { + const scope = ordinaryPairingDeps({ + observePairing: vi.fn(() => { + throw new Error("not published"); + }), + }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "pairing-unavailable", + }); + + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runApproval).not.toHaveBeenCalled(); + }); + + it("does not repeat pairing writes when baseline scopes never settle (#9844)", async () => { + const scope = ordinaryPairingDeps({ observePairing: vi.fn(() => PAIRING_ONLY) }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "scope-upgrade-incomplete", + }); + + expect(scope.deps.runWarmup).toHaveBeenCalledOnce(); + expect(scope.deps.runApproval).toHaveBeenCalledOnce(); + }); + + it("fails closed without writes when the recorded runtime target changes (#9844)", async () => { + const getTarget = vi + .fn() + .mockReturnValueOnce(PAIRING_TARGET) + .mockReturnValueOnce(PAIRING_TARGET) + .mockReturnValueOnce({ ...PAIRING_TARGET, lifecycleGeneration: "generation-2" }); + const scope = ordinaryPairingDeps({ getTarget }); + + await expect(settleOrdinaryOpenClawPairing("alpha", scope.deps)).resolves.toEqual({ + kind: "incomplete", + reason: "runtime-identity-invalid", + }); + + expect(scope.deps.observePairing).not.toHaveBeenCalled(); + expect(scope.deps.runWarmup).not.toHaveBeenCalled(); + expect(scope.deps.runApproval).not.toHaveBeenCalled(); + }); + + it("resolves the finalized default OpenClaw runtime before observation (#9844)", async () => { + const observePairing = vi.fn(() => SETTLED); + const resolveTarget = vi.fn(() => PAIRING_TARGET); + vi.spyOn(finalizationHandlerRuntime, "loadLaunchReadiness").mockReturnValue({ + resolveOrdinaryOpenClawPairingTarget: resolveTarget, + } as never); + vi.spyOn(finalizationHandlerRuntime, "loadPairingQualification").mockReturnValue({ + observeOrdinaryOpenClawPairingSettlement: observePairing, + } as never); + vi.spyOn(finalizationHandlerRuntime, "loadSandboxLifecycleLock").mockReturnValue({ + withMcpLifecycleLock: async (_name: string, operation: () => unknown) => operation(), + } as never); + vi.spyOn(finalizationHandlerRuntime, "loadGatewayRouteLock").mockReturnValue({ + withGatewayRouteMutationLock: async (_name: string, operation: () => unknown) => operation(), + } as never); + + await expect(finalizationHandlerDeps.settleOrdinaryOpenClawPairing("alpha")).resolves.toEqual({ + kind: "settled", + }); + expect(resolveTarget).toHaveBeenCalledWith("alpha"); + expect(observePairing).toHaveBeenCalledWith( + "alpha", + "nemoclaw", + "2026.7.1", + "/sandbox/.openclaw", + ); + }); + + it("wires default warm-up and approval adapters to the finalized gateway (#9844)", async () => { + const observePairing = vi + .fn() + .mockReturnValueOnce(PAIRING_ONLY) + .mockReturnValueOnce(SETTLED); + const runSandboxScopeWarmupRun = vi.fn(); + const runConnectAutoPairApprovalPass = vi.fn(); + vi.spyOn(finalizationHandlerRuntime, "loadLaunchReadiness").mockReturnValue({ + resolveOrdinaryOpenClawPairingTarget: vi.fn(() => PAIRING_TARGET), + } as never); + vi.spyOn(finalizationHandlerRuntime, "loadPairingQualification").mockReturnValue({ + observeOrdinaryOpenClawPairingSettlement: observePairing, + } as never); + vi.spyOn(finalizationHandlerRuntime, "loadAutoPairWarmup").mockReturnValue({ + runSandboxScopeWarmupRun, + } as never); + vi.spyOn(finalizationHandlerRuntime, "loadAutoPairApproval").mockReturnValue({ + runConnectAutoPairApprovalPass, + } as never); + vi.spyOn(finalizationHandlerRuntime, "loadSandboxLifecycleLock").mockReturnValue({ + withMcpLifecycleLock: async (_name: string, operation: () => unknown) => operation(), + } as never); + vi.spyOn(finalizationHandlerRuntime, "loadGatewayRouteLock").mockReturnValue({ + withGatewayRouteMutationLock: async (_name: string, operation: () => unknown) => operation(), + } as never); + + await expect(finalizationHandlerDeps.settleOrdinaryOpenClawPairing("alpha")).resolves.toEqual({ + kind: "settled", + }); + expect(runSandboxScopeWarmupRun).toHaveBeenCalledExactlyOnceWith("alpha"); + expect(runConnectAutoPairApprovalPass).toHaveBeenCalledExactlyOnceWith("alpha", "nemoclaw"); + }); + + it("explains the bounded failure without exposing runtime identifiers (#9844)", () => { + expect(ordinaryOpenClawPairingIncompleteMessage("alpha", "pairing-unavailable")).toBe( + "OpenClaw onboarding for 'alpha' is incomplete because its canonical CLI device pairing did not appear. Resume or rerun onboarding.", + ); + expect(ordinaryOpenClawPairingIncompleteMessage("alpha", "pairing-lock-unavailable")).toBe( + "OpenClaw onboarding for 'alpha' is incomplete because NemoClaw could not acquire the pairing settlement locks. Resume or rerun onboarding.", + ); + }); +}); describe("finalizationHandlerDeps.waitForSandboxControlPlaneReady", () => { afterEach(() => { diff --git a/src/lib/onboard/machine/finalization-deps.ts b/src/lib/onboard/machine/finalization-deps.ts index 9c2a633db84..a783d517e98 100644 --- a/src/lib/onboard/machine/finalization-deps.ts +++ b/src/lib/onboard/machine/finalization-deps.ts @@ -1,20 +1,294 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { OpenClawPairingSettlementObservation } from "../../actions/sandbox/launch-readiness/openclaw-pairing-qualification"; +import type { OpenClawPairingSettlementTarget } from "../../actions/sandbox/launch-readiness"; +import { WARMUP_TIMEOUT_MS } from "../../actions/sandbox/auto-pair-warmup"; +import { CONNECT_AUTO_PAIR_TIMEOUT_MS } from "../../actions/sandbox/connect-autopair-budget"; + // The lazy `require` calls avoid an import cycle because connect.ts and // process-recovery.ts both import onboarding helpers. type ProcessRecoveryDeps = Pick< typeof import("../../actions/sandbox/process-recovery"), "checkAndRecoverSandboxProcesses" | "waitForRecreatedSandboxOpenShellReady" >; +type SandboxLifecycleLock = typeof import("../../state/mcp-lifecycle-lock").withMcpLifecycleLock; +type GatewayRouteLock = + typeof import("../../inference/gateway-route-mutation-lock").withGatewayRouteMutationLock; + +export const OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS = 30_000; +export const OPENCLAW_ONBOARDING_PAIRING_POLL_MS = 1_000; +export const OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS = 30_000; +// Keep one outer cap while reserving each bounded child's fixed budget. +// Pairing appearance retains its existing 30-second limit, and +// a capped warm-up can no longer consume the approval or final-read budget. +export const OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS = + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS + + WARMUP_TIMEOUT_MS + + CONNECT_AUTO_PAIR_TIMEOUT_MS + + OPENCLAW_ONBOARDING_PAIRING_FINAL_OBSERVATION_TIMEOUT_MS; + +export type OrdinaryOpenClawPairingSettlementResult = + | { readonly kind: "settled" } + | { + readonly kind: "incomplete"; + readonly reason: + | "runtime-identity-invalid" + | "pairing-lock-unavailable" + | "pairing-unavailable" + | "scope-upgrade-incomplete"; + }; + +type OrdinaryOpenClawPairingIncompleteReason = Extract< + OrdinaryOpenClawPairingSettlementResult, + { kind: "incomplete" } +>["reason"]; + +const ORDINARY_OPENCLAW_PAIRING_INCOMPLETE_CAUSES: Record< + OrdinaryOpenClawPairingIncompleteReason, + string +> = { + "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", +}; + +interface OrdinaryOpenClawPairingSettlementDeps { + getTarget(name: string): OpenClawPairingSettlementTarget | null; + observePairing( + name: string, + gatewayName: string, + version: string, + stateDirectory: string, + ): OpenClawPairingSettlementObservation; + runWarmup(name: string): Promise | void; + runApproval(name: string, gatewayName: string): Promise | void; + withSandboxLock: SandboxLifecycleLock; + withGatewayLock: GatewayRouteLock; + now(): number; + sleep(milliseconds: number): Promise; +} export const finalizationHandlerRuntime = { loadProcessRecovery: () => require("../../actions/sandbox/process-recovery") as ProcessRecoveryDeps, loadRegistryPersistence: () => require("../../state/registry/persistence") as typeof import("../../state/registry/persistence"), + loadLaunchReadiness: () => + 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: () => + require("../../state/mcp-lifecycle-lock") as typeof import("../../state/mcp-lifecycle-lock"), + loadGatewayRouteLock: () => + require("../../inference/gateway-route-mutation-lock") as typeof import("../../inference/gateway-route-mutation-lock"), }; +function samePairingTarget( + left: OpenClawPairingSettlementTarget, + right: OpenClawPairingSettlementTarget | null, +): right is OpenClawPairingSettlementTarget { + return ( + right !== null && + left.gatewayName === right.gatewayName && + left.lifecycleGeneration === right.lifecycleGeneration && + left.lifecycleLiveIdentityFingerprint === right.lifecycleLiveIdentityFingerprint && + left.stateDirectory === right.stateDirectory && + left.version === right.version + ); +} + +type PairingWaitResult = + | { readonly kind: "observed"; readonly value: OpenClawPairingSettlementObservation } + | { readonly kind: "target-changed" } + | { readonly kind: "timeout" }; + +async function waitForPairingObservation( + name: string, + target: OpenClawPairingSettlementTarget, + deadline: number, + accept: (value: OpenClawPairingSettlementObservation) => boolean, + deps: OrdinaryOpenClawPairingSettlementDeps, +): Promise { + while (true) { + const remaining = deadline - deps.now(); + if (remaining <= 0) return { kind: "timeout" }; + if (!samePairingTarget(target, deps.getTarget(name))) return { kind: "target-changed" }; + try { + const value = deps.observePairing( + name, + target.gatewayName, + target.version, + target.stateDirectory, + ); + if (!samePairingTarget(target, deps.getTarget(name))) return { kind: "target-changed" }; + if (deadline - deps.now() <= 0) return { kind: "timeout" }; + 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" }; + await deps.sleep(Math.min(OPENCLAW_ONBOARDING_PAIRING_POLL_MS, remainingAfterAttempt)); + } +} + +function defaultPairingSettlementDeps(): OrdinaryOpenClawPairingSettlementDeps { + return { + getTarget: (name) => { + try { + return finalizationHandlerRuntime + .loadLaunchReadiness() + .resolveOrdinaryOpenClawPairingTarget(name); + } catch { + return null; + } + }, + observePairing: (...args) => + finalizationHandlerRuntime + .loadPairingQualification() + .observeOrdinaryOpenClawPairingSettlement(...args), + runWarmup: (name) => + finalizationHandlerRuntime.loadAutoPairWarmup().runSandboxScopeWarmupRun(name), + runApproval: (name, gatewayName) => + finalizationHandlerRuntime + .loadAutoPairApproval() + .runConnectAutoPairApprovalPass(name, gatewayName), + withSandboxLock: (name, operation, options) => + finalizationHandlerRuntime + .loadSandboxLifecycleLock() + .withMcpLifecycleLock(name, operation, options), + withGatewayLock: (gatewayName, operation, options) => + finalizationHandlerRuntime + .loadGatewayRouteLock() + .withGatewayRouteMutationLock(gatewayName, operation, options), + now: () => performance.now(), + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + }; +} + +/** + * Wait for the startup watcher to publish one canonical CLI pairing. When the + * device has only its pairing scope, request and approve the write scope once. + * A final read verifies the exact device and no pending request for that device. + */ +export async function settleOrdinaryOpenClawPairing( + name: string, + deps: OrdinaryOpenClawPairingSettlementDeps = defaultPairingSettlementDeps(), +): Promise { + let sandboxBodyEntered = false; + try { + return await deps.withSandboxLock(name, async () => { + sandboxBodyEntered = true; + const firstTarget = deps.getTarget(name); + if (!firstTarget) return { kind: "incomplete", reason: "runtime-identity-invalid" }; + + let gatewayBodyEntered = false; + try { + return await deps.withGatewayLock(firstTarget.gatewayName, async () => { + gatewayBodyEntered = true; + const target = deps.getTarget(name); + if (!samePairingTarget(firstTarget, target)) { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + const settlementDeadline = deps.now() + OPENCLAW_ONBOARDING_PAIRING_SETTLEMENT_TIMEOUT_MS; + const pairingAppearanceDeadline = Math.min( + settlementDeadline, + deps.now() + OPENCLAW_ONBOARDING_PAIRING_TIMEOUT_MS, + ); + + const baseline = await waitForPairingObservation( + name, + target, + pairingAppearanceDeadline, + () => true, + deps, + ); + if (baseline.kind === "target-changed") { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + if (baseline.kind === "timeout") { + return { kind: "incomplete", reason: "pairing-unavailable" }; + } + 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" }; + } + + let warmupFailed = false; + try { + await deps.runWarmup(name); + } catch { + warmupFailed = true; + } + if (!samePairingTarget(target, deps.getTarget(name))) { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + if (warmupFailed || deps.now() >= settlementDeadline) { + return { kind: "incomplete", reason: "scope-upgrade-incomplete" }; + } + + let approvalFailed = false; + try { + await deps.runApproval(name, target.gatewayName); + } catch { + approvalFailed = true; + } + 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, + deps, + ); + if (final.kind === "target-changed") { + return { kind: "incomplete", reason: "runtime-identity-invalid" }; + } + return final.kind === "observed" + ? { kind: "settled" } + : { kind: "incomplete", reason: "scope-upgrade-incomplete" }; + }); + } catch (error) { + if (gatewayBodyEntered) throw error; + return { kind: "incomplete", reason: "pairing-lock-unavailable" }; + } + }); + } catch (error) { + if (sandboxBodyEntered) throw error; + return { kind: "incomplete", reason: "pairing-lock-unavailable" }; + } +} + +export function ordinaryOpenClawPairingIncompleteMessage( + name: string, + reason: OrdinaryOpenClawPairingIncompleteReason, +): string { + const cause = ORDINARY_OPENCLAW_PAIRING_INCOMPLETE_CAUSES[reason]; + return `OpenClaw onboarding for '${name}' is incomplete because ${cause}. Resume or rerun onboarding.`; +} + export const finalizationHandlerDeps = { waitForSandboxControlPlaneReady(name: string): boolean { return finalizationHandlerRuntime @@ -25,23 +299,8 @@ export const finalizationHandlerDeps = { const processRecovery = finalizationHandlerRuntime.loadProcessRecovery(); processRecovery.checkAndRecoverSandboxProcesses(name, options); }, - // Best-effort device-approval sweep that clears pending allowlisted - // CLI/webchat scope upgrades so onboard hands off without a stuck pairing - // request (#4504). Never throws. - autoPairScopeApproval(name: string): void { - const { - runConnectAutoPairApprovalPass, - }: typeof import("../../actions/sandbox/auto-pair-approval") = require("../../actions/sandbox/auto-pair-approval"); - runConnectAutoPairApprovalPass(name); - }, - // Provoke the operator.write scope upgrade with a throwaway in-sandbox agent - // run so the request is PENDING when the approval pass above clears it, - // letting the user's first real run connect without an embedded fallback - // (#4504-v2). Best-effort; never throws. - warmupScopeUpgrade(name: string): void { - const warmup: typeof import("../../actions/sandbox/auto-pair-warmup") = require("../../actions/sandbox/auto-pair-warmup"); - warmup.runSandboxScopeWarmupRun(name); - }, + settleOrdinaryOpenClawPairing, + ordinaryOpenClawPairingIncompleteMessage, readRegistryAgent(name: string): string | null { try { const value = finalizationHandlerRuntime.loadRegistryPersistence().load().sandboxes[ diff --git a/src/lib/onboard/machine/flow-context.ts b/src/lib/onboard/machine/flow-context.ts index 079a52c972a..a1fab15c506 100644 --- a/src/lib/onboard/machine/flow-context.ts +++ b/src/lib/onboard/machine/flow-context.ts @@ -10,6 +10,7 @@ import type { OnboardStateHandlerResult } from "./runner"; export interface OnboardFlowContext { resume: boolean; fresh: boolean; + recreateJournalHandoff?: boolean; session: Session | null; agent: Agent; recordedSandboxName: string | null; @@ -89,6 +90,7 @@ export interface ProviderModelSelectedContextUpdate { export interface SandboxCreatedContextUpdate { session: Session | null; sandboxName: string; + recreateJournalHandoff?: boolean; webSearchConfig: WebSearchConfig | null; webSearchConfigChanged: boolean; hermesToolGateways: string[]; diff --git a/src/lib/onboard/machine/handlers/finalization.test.ts b/src/lib/onboard/machine/handlers/finalization.test.ts index 887403c3eb4..07ae616c09f 100644 --- a/src/lib/onboard/machine/handlers/finalization.test.ts +++ b/src/lib/onboard/machine/handlers/finalization.test.ts @@ -34,8 +34,10 @@ function createDeps( removeLegacy: vi.fn(), cleanupHost: vi.fn(), recoverProcesses: vi.fn(), - warmupScopeUpgrade: vi.fn(), - autoPairScopeApproval: vi.fn(), + settleOrdinaryPairing: vi.fn(async () => ({ kind: "settled" as const })), + ordinaryPairingIncompleteMessage: vi.fn( + () => "OpenClaw onboarding is incomplete; resume onboarding.", + ), readRegistryAgent: vi.fn(() => "openclaw"), settlePortablePairing: vi.fn(async () => ({ kind: "settled" as const })), portablePairingIncompleteMessage: vi.fn( @@ -62,8 +64,8 @@ function createDeps( removeLegacyCredentialsFile: calls.removeLegacy, cleanupStaleHostFiles: calls.cleanupHost, checkAndRecoverSandboxProcesses: calls.recoverProcesses, - warmupScopeUpgrade: calls.warmupScopeUpgrade, - autoPairScopeApproval: calls.autoPairScopeApproval, + settleOrdinaryOpenClawPairing: calls.settleOrdinaryPairing, + ordinaryOpenClawPairingIncompleteMessage: calls.ordinaryPairingIncompleteMessage, readRegistryAgent: calls.readRegistryAgent, settlePortablePairing: calls.settlePortablePairing, portablePairingIncompleteMessage: calls.portablePairingIncompleteMessage, @@ -168,7 +170,7 @@ describe("finalization handlers", () => { expect(result.verificationDiagnostics).toEqual([" ✓ verified"]); }); - it("uses strict Portable settlement instead of ordinary warm-up and approval (#9207)", async () => { + it("uses strict Portable settlement instead of ordinary pairing settlement (#9207)", async () => { const { deps, calls } = createDeps(); const options = { ...baseOptions(deps), @@ -179,8 +181,7 @@ describe("finalization handlers", () => { const result = await runFinalizationHandlers(options); expect(result.stateResult.type).toBe("complete"); - expect(calls.warmupScopeUpgrade).not.toHaveBeenCalled(); - expect(calls.autoPairScopeApproval).not.toHaveBeenCalled(); + expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); expect(calls.settlePortablePairing).toHaveBeenCalledExactlyOnceWith("my-assistant", { portableRequired: true, }); @@ -197,8 +198,7 @@ describe("finalization handlers", () => { const result = await runFinalizationHandlers(options); expect(result.stateResult.type).toBe("complete"); - expect(calls.warmupScopeUpgrade).not.toHaveBeenCalled(); - expect(calls.autoPairScopeApproval).not.toHaveBeenCalled(); + expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); expect(calls.settlePortablePairing).toHaveBeenCalledExactlyOnceWith("my-assistant", { portableRequired: true, }); @@ -229,8 +229,7 @@ describe("finalization handlers", () => { }); expect(calls.verify).not.toHaveBeenCalled(); expect(calls.dashboard).not.toHaveBeenCalled(); - expect(calls.warmupScopeUpgrade).not.toHaveBeenCalled(); - expect(calls.autoPairScopeApproval).not.toHaveBeenCalled(); + expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); expect(calls.reportReadiness).toHaveBeenCalledWith(false); expect(calls.error).toHaveBeenCalledWith( " Portable onboarding is incomplete; resume onboarding.", @@ -248,8 +247,7 @@ describe("finalization handlers", () => { const result = await runFinalizationHandlers(options); expect(result.stateResult.type).toBe("complete"); - expect(calls.warmupScopeUpgrade).toHaveBeenCalledOnce(); - expect(calls.autoPairScopeApproval).toHaveBeenCalledOnce(); + expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); expect(calls.settlePortablePairing).not.toHaveBeenCalled(); }); @@ -271,8 +269,7 @@ describe("finalization handlers", () => { metadata: { state: "post_verify", reason: "portable_pairing_incomplete" }, }, }); - expect(calls.warmupScopeUpgrade).not.toHaveBeenCalled(); - expect(calls.autoPairScopeApproval).not.toHaveBeenCalled(); + expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); expect(calls.settlePortablePairing).not.toHaveBeenCalled(); expect(calls.verify).not.toHaveBeenCalled(); }); @@ -399,9 +396,9 @@ describe("finalization handlers", () => { const recoveryOrders = calls.recoverProcesses.mock.invocationCallOrder; const refreshOrder = calls.ensureAgentDashboard.mock.invocationCallOrder[0]; expect(recoveryOrders).toHaveLength(2); - expect(recoveryOrders[1]).toBeGreaterThan(calls.warmupScopeUpgrade.mock.invocationCallOrder[0]); + expect(calls.settleOrdinaryPairing).toHaveBeenCalledExactlyOnceWith("my-assistant"); expect(recoveryOrders[1]).toBeGreaterThan( - calls.autoPairScopeApproval.mock.invocationCallOrder[0], + calls.settleOrdinaryPairing.mock.invocationCallOrder[0], ); expect(refreshOrder).toBeGreaterThan(recoveryOrders[1]); expect(calls.verifyWebSearch.mock.invocationCallOrder[0]).toBeGreaterThan(refreshOrder); @@ -431,7 +428,7 @@ describe("finalization handlers", () => { expect(calls.ensureAgentDashboard).not.toHaveBeenCalled(); expect(calls.recoverProcesses).not.toHaveBeenCalled(); - expect(calls.autoPairScopeApproval).not.toHaveBeenCalled(); + expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); expect(calls.getChatUiUrl).not.toHaveBeenCalled(); expect(calls.buildChain).not.toHaveBeenCalled(); expect(calls.verify).not.toHaveBeenCalled(); @@ -544,114 +541,72 @@ describe("finalization handlers", () => { expect(calls.reportReadiness).toHaveBeenCalledWith(false); }); - // Scenario A (#4504): the auto-pair scope-approval sweep runs against the - // freshly-recovered gateway — strictly after process recovery (which can - // restart the gateway, #3573) and strictly before deployment verification - // (so the gateway state is settled before we probe it). - it("runs the auto-pair scope-approval sweep after process recovery and before verify (#4504)", async () => { + it("settles ordinary OpenClaw pairing after recovery and before verification (#9844)", async () => { const { deps, calls } = createDeps(); await runFinalizationHandlers(baseOptions(deps)); - expect(calls.autoPairScopeApproval).toHaveBeenCalledOnce(); - expect(calls.autoPairScopeApproval).toHaveBeenCalledWith("my-assistant"); - // Ordering: recover → autoPairScopeApproval → verify. - expect(calls.autoPairScopeApproval.mock.invocationCallOrder[0]).toBeGreaterThan( + expect(calls.settleOrdinaryPairing).toHaveBeenCalledExactlyOnceWith("my-assistant"); + expect(calls.settleOrdinaryPairing.mock.invocationCallOrder[0]).toBeGreaterThan( calls.recoverProcesses.mock.invocationCallOrder[0], ); - expect(calls.autoPairScopeApproval.mock.invocationCallOrder[0]).toBeLessThan( + expect(calls.settleOrdinaryPairing.mock.invocationCallOrder[0]).toBeLessThan( + calls.recoverProcesses.mock.invocationCallOrder[1], + ); + expect(calls.recoverProcesses.mock.invocationCallOrder[1]).toBeLessThan( calls.verify.mock.invocationCallOrder[0], ); }); - // Scenario B (#4504): the sweep is agent-agnostic — the stuck CLI/webchat - // scope upgrade can occur regardless of which agent the sandbox runs. - it("runs the scope-approval sweep regardless of agent type (#4504)", async () => { + it("does not settle ordinary OpenClaw pairing during an inner rebuild handoff (#9844)", async () => { const { deps, calls } = createDeps(); - const agent = { name: "hermes" }; - await runFinalizationHandlers({ ...baseOptions(deps), agent }); - - expect(calls.autoPairScopeApproval).toHaveBeenCalledWith("my-assistant"); - }); - - // Scenario C (#4504): the dep is documented as best-effort / never-throws and - // the handler wraps no try/catch around it. Per the contract we assert the - // implemented behavior: the sweep is invoked and, because it returns cleanly, - // post verification proceeds to completion. A dependency that threw would - // abort finalization here — the regression this guards. - it("treats the scope-approval sweep as best-effort and still completes the session (#4504)", async () => { - const { deps, calls } = createDeps(); - - const result = await runFinalizationHandlers(baseOptions(deps)); + const result = await runFinalizationHandlers({ + ...baseOptions(deps), + recreateJournalHandoff: true, + }); - expect(calls.autoPairScopeApproval).toHaveBeenCalledOnce(); - // The non-throwing sweep does not abort finalization: it proceeds through - // verification and the dashboard print to a completed result. (#4472 moved - // session completion to the imported completeOnboardMachine, so completion - // is asserted via the downstream dashboard + diagnostics rather than a dep.) - expect(calls.dashboard).toHaveBeenCalledOnce(); - expect(result.verificationDiagnostics).toEqual([" ✓ verified"]); + expect(result.stateResult.type).toBe("complete"); + expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); + expect(calls.ensureAgentDashboard).toHaveBeenCalledWith("my-assistant", null); + expect(calls.verify).toHaveBeenCalledOnce(); }); - // Scenario 1 (#4504-v2, HEADLINE): the warm-up provokes the operator.write - // scope upgrade so the approval pass below has something pending to approve. - // The order is load-bearing: process recovery (gateway live) → warmup - // (provoke / create pending) → autoPairScopeApproval (approve / clear - // pending). Reversing warmup and approval makes the approval pass a no-op and - // the user's first real run falls back — exactly the bug v2 fixes. - it("provokes the scope upgrade after recovery and before the approval pass in v2 (#4504)", async () => { + it("does not run OpenClaw pairing settlement for Hermes (#9844)", async () => { const { deps, calls } = createDeps(); + const agent = { name: "hermes" }; - await runFinalizationHandlers(baseOptions(deps)); + await runFinalizationHandlers({ ...baseOptions(deps), agent }); - expect(calls.warmupScopeUpgrade).toHaveBeenCalledOnce(); - expect(calls.warmupScopeUpgrade).toHaveBeenCalledWith("my-assistant"); - // recover → warmup (provoke) → autoPairScopeApproval (approve). - expect(calls.warmupScopeUpgrade.mock.invocationCallOrder[0]).toBeGreaterThan( - calls.recoverProcesses.mock.invocationCallOrder[0], - ); - expect(calls.warmupScopeUpgrade.mock.invocationCallOrder[0]).toBeLessThan( - calls.autoPairScopeApproval.mock.invocationCallOrder[0], - ); + expect(calls.settleOrdinaryPairing).not.toHaveBeenCalled(); }); - // Scenario 2 (#4504-v2): the warm-up is best-effort / non-blocking. The - // handler wraps no try/catch around the dep and relies on the dep itself - // never throwing (the production leaf swallows every failure — covered in - // auto-pair-warmup.test.ts). Per the contract we assert the implemented - // behavior here: the warm-up is invoked and, because the (non-throwing) dep - // returns cleanly, finalization is NOT ordered to depend on its success — it - // proceeds straight to the approval pass, verification, and the dashboard. - // The dep returning nothing useful (no pending provoked, gateway slow) does - // not change the downstream flow: behavior degrades to v1, never blocks. - it("completes v2 finalization without depending on the warm-up succeeding (#4504)", async () => { - // The default warm-up mock returns undefined (e.g. gateway not up → the - // production leaf swallowed and provoked nothing). Finalization must be - // unaffected. - const { deps, calls } = createDeps(); + it("pauses onboarding when canonical OpenClaw pairing does not settle (#9844)", async () => { + const { deps, calls } = createDeps({ + settleOrdinaryOpenClawPairing: vi.fn(async () => ({ + kind: "incomplete" as const, + reason: "pairing-unavailable" as const, + })), + }); const result = await runFinalizationHandlers(baseOptions(deps)); - expect(calls.warmupScopeUpgrade).toHaveBeenCalledOnce(); - expect(calls.warmupScopeUpgrade.mock.results[0]).toEqual({ type: "return", value: undefined }); - // The approval pass still runs after it (degrades to v1, not skipped). - expect(calls.autoPairScopeApproval).toHaveBeenCalledOnce(); - expect(calls.dashboard).toHaveBeenCalledOnce(); - expect(result.verificationDiagnostics).toEqual([" ✓ verified"]); - }); - - // Scenario 3 (#4504-v2): the warm-up is agent-agnostic — the first-run scope - // upgrade is provoked regardless of which agent the sandbox runs (the - // contract says run it unconditionally; idempotent once operator.write is - // paired). - it("provokes the v2 scope upgrade regardless of agent type (#4504)", async () => { - const { deps: depsHermes, calls: callsHermes } = createDeps(); - await runFinalizationHandlers({ ...baseOptions(depsHermes), agent: { name: "hermes" } }); - expect(callsHermes.warmupScopeUpgrade).toHaveBeenCalledWith("my-assistant"); - - const { deps: depsOpenclaw, calls: callsOpenclaw } = createDeps(); - await runFinalizationHandlers({ ...baseOptions(depsOpenclaw), agent: { name: "openclaw" } }); - expect(callsOpenclaw.warmupScopeUpgrade).toHaveBeenCalledWith("my-assistant"); + expect(result).toMatchObject({ + deploymentHealthy: false, + stateResult: { + type: "pause", + metadata: { state: "post_verify", reason: "deployment_not_ready" }, + }, + }); + expect(result.verificationDiagnostics).toEqual([ + "OpenClaw onboarding is incomplete; resume onboarding.", + ]); + expect(calls.verify).not.toHaveBeenCalled(); + expect(calls.dashboard).not.toHaveBeenCalled(); + expect(calls.ensureAgentDashboard).not.toHaveBeenCalled(); + expect(calls.reportReadiness).toHaveBeenCalledWith(false); + expect(calls.error).toHaveBeenCalledWith( + " OpenClaw onboarding is incomplete; resume onboarding.", + ); }); }); diff --git a/src/lib/onboard/machine/handlers/finalization.ts b/src/lib/onboard/machine/handlers/finalization.ts index e7fe8bccba1..b6b05105ad9 100644 --- a/src/lib/onboard/machine/handlers/finalization.ts +++ b/src/lib/onboard/machine/handlers/finalization.ts @@ -5,6 +5,7 @@ import { CLI_NAME } from "../../../cli/branding"; import { type DashboardRuntimeAgent, shouldManageDashboardForAgent } from "../../dashboard-runtime"; import type { WebSearchVerifyProvider } from "../../web-search-verify"; import type { PortableOpenClawPairingSettlementResult } from "../../../actions/sandbox/launch-readiness"; +import type { OrdinaryOpenClawPairingSettlementResult } from "../finalization-deps"; import { advanceTo, completeOnboardMachine, @@ -27,6 +28,7 @@ export interface FinalizationStateOptions | number; persistDashboardPort(sandboxName: string, dashboardPort: number): void; @@ -42,26 +44,13 @@ export interface FinalizationStateOptions; + ordinaryOpenClawPairingIncompleteMessage( + sandboxName: string, + reason: Extract["reason"], + ): string; readRegistryAgent(sandboxName: string): string | null; settlePortablePairing( sandboxName: string, @@ -153,6 +142,12 @@ function portableAgentDisposition( return "invalid"; } +function selectedAgentName(agent: unknown): string | null { + if (agent === null) return "openclaw"; + const name = (agent as { readonly name?: unknown })?.name; + return typeof name === "string" && name.trim() === name && name ? name : null; +} + function logTerminalReadyBlock( sandboxName: string, agent: unknown, @@ -183,6 +178,7 @@ export async function handleFinalizationState 0) deps.persistDashboardPort(sandboxName, dashboardPort); + } + } if (manageDashboard) { // Probe web-search credential isolation and egress now that the final // policy, provider, process, and forwarding state are live. Egress diff --git a/src/lib/onboard/machine/rebuild-pairing-handoff.test.ts b/src/lib/onboard/machine/rebuild-pairing-handoff.test.ts new file mode 100644 index 00000000000..8ac45c1d061 --- /dev/null +++ b/src/lib/onboard/machine/rebuild-pairing-handoff.test.ts @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + handleSandboxState: vi.fn(), + handleFinalizationState: vi.fn(), + handlePostVerifyState: vi.fn(), +})); + +vi.mock("./handlers/sandbox", async (importOriginal) => ({ + ...(await importOriginal()), + handleSandboxState: mocks.handleSandboxState, +})); + +vi.mock("./handlers/finalization", async (importOriginal) => ({ + ...(await importOriginal()), + handleFinalizationState: mocks.handleFinalizationState, + handlePostVerifyState: mocks.handlePostVerifyState, +})); + +import { createSandboxOnboardFlowPhase } from "./core-flow-phases"; +import { createFinalOnboardFlowPhases } from "./final-flow-phases"; +import type { OnboardFlowContext } from "./flow-context"; +import { advanceTo, branchTo, completeOnboardMachine } from "./result"; +import { createSession } from "../../state/onboard-session"; + +function context( + recreateJournalHandoff?: boolean, +): OnboardFlowContext> { + return { + resume: true, + fresh: false, + recreateJournalHandoff, + session: createSession(), + agent: null, + recordedSandboxName: "alpha", + requestedSandboxName: "alpha", + sandboxName: "alpha", + fromDockerfile: null, + model: "model-a", + provider: "nvidia", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: "NVIDIA_API_KEY", + hermesAuthMethod: null, + hermesToolGateways: [], + preferredInferenceApi: "openai", + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + nimContainer: null, + webSearchConfig: null, + webSearchSupported: false, + selectedMessagingChannels: [], + gpu: null, + sandboxGpuConfig: {}, + gpuPassthrough: false, + }; +} + +describe("rebuild pairing handoff", () => { + beforeEach(() => { + mocks.handleSandboxState.mockReset().mockResolvedValue({ + sandboxName: "alpha", + webSearchConfig: null, + webSearchConfigChanged: false, + hermesToolGateways: [], + selectedMessagingChannels: [], + webSearchSupported: false, + session: createSession(), + stateResult: branchTo("openclaw", { metadata: { state: "sandbox" } }), + }); + mocks.handleFinalizationState.mockReset().mockResolvedValue({ + stateResult: advanceTo("post_verify", { metadata: { state: "finalizing" } }), + unmigratedLegacyKeys: [], + }); + mocks.handlePostVerifyState.mockReset().mockResolvedValue({ + stateResult: completeOnboardMachine({}, { metadata: { state: "post_verify" } }), + verificationDiagnostics: [], + deploymentHealthy: true, + }); + }); + + it.each([ + { fingerprint: "intent-1", expected: true }, + { fingerprint: null, expected: false }, + ])( + "maps journal fingerprint $fingerprint to handoff=$expected (#9844)", + async ({ fingerprint, expected }) => { + const phase = createSandboxOnboardFlowPhase({ + gatewayName: "nemoclaw", + recreateJournalTargetIntentFingerprint: fingerprint, + resumeAgentChanged: false, + endpointProvenance: { getSandboxRegistryEntry: () => null }, + recreateSandbox: () => true, + controlUiPort: null, + rootDir: "/repo", + env: {}, + deps: {} as never, + }); + + const result = await phase.run(context()); + + expect(result.context.recreateJournalHandoff).toBe(expected); + expect(mocks.handleSandboxState).toHaveBeenCalledWith( + expect.objectContaining({ recreateJournalTargetIntentFingerprint: fingerprint }), + ); + }, + ); + + it.each([true, false])( + "passes handoff=%s from final-flow context to both final handlers (#9844)", + async (recreateJournalHandoff) => { + const phases = createFinalOnboardFlowPhases({ + branchState: "openclaw", + agentSetupDeps: {} as never, + policiesDeps: {} as never, + finalization: { + stagedLegacyKeys: [], + migratedLegacyKeys: new Set(), + webSearchEnabled: () => false, + webSearchProvider: () => "brave", + }, + finalizationDeps: {} as never, + }); + const finalContext = context(recreateJournalHandoff); + + await phases[2].run(finalContext); + await phases[3].run(finalContext); + + expect(mocks.handleFinalizationState).toHaveBeenCalledWith( + expect.objectContaining({ recreateJournalHandoff }), + ); + expect(mocks.handlePostVerifyState).toHaveBeenCalledWith( + expect.objectContaining({ recreateJournalHandoff }), + ); + }, + ); +}); diff --git a/test/credential-migration-reconciliation.test.ts b/test/credential-migration-reconciliation.test.ts index 6f23218cc53..747957a6934 100644 --- a/test/credential-migration-reconciliation.test.ts +++ b/test/credential-migration-reconciliation.test.ts @@ -74,8 +74,9 @@ async function finalizeMigration( removeLegacyCredentialsFile, cleanupStaleHostFiles: () => undefined, checkAndRecoverSandboxProcesses: () => undefined, - warmupScopeUpgrade: () => undefined, - autoPairScopeApproval: () => undefined, + settleOrdinaryOpenClawPairing: async () => ({ kind: "settled" }), + ordinaryOpenClawPairingIncompleteMessage: () => + "OpenClaw onboarding is incomplete; resume onboarding.", readRegistryAgent: () => "openclaw", settlePortablePairing: async () => ({ kind: "settled" }), portablePairingIncompleteMessage: () => diff --git a/test/e2e/fixtures/issue-4462-diagnostics.ts b/test/e2e/fixtures/issue-4462-diagnostics.ts new file mode 100644 index 00000000000..1aad44e9adf --- /dev/null +++ b/test/e2e/fixtures/issue-4462-diagnostics.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxClient } from "./clients/sandbox.ts"; +import type { CleanupRegistry } from "./cleanup.ts"; + +interface Issue4462FailureDiagnosticsOptions { + env: NodeJS.ProcessEnv; + redactionValues: readonly string[]; + sandboxName: string; +} + +const PAIRING_LOG_PATHS = ["/tmp/auto-pair.log", "/tmp/gateway.log"] as const; + +const PROJECT_PAIRING_DIAGNOSTICS_PROGRAM = String.raw` +"use strict"; +const fs = require("node:fs"); +const logPaths = process.argv.slice(1); +const MAX_LOG_BYTES = 384 * 1024; +const SAFE_STAGE_OUTCOMES = new Set([ + "request-creation:observed", + "request-creation:waiting", + "listing:failed", + "validation:accepted", + "validation:rejected", + "approval:attempting", + "approval:failed", + "watcher-execution:failed", +]); +const SAFE_REASONS = new Set([ + "allowlisted-initial-cli", + "allowlisted-request", + "command-failed", + "disallowed-scopes", + "empty-output", + "invalid-json", + "invalid-response", + "malformed-request-id", + "malformed-scopes", + "no-request", + "not-allowlisted", + "pairing-required", + "timeout", + "unknown-client", +]); + +function readTail(logPath) { + const fd = fs.openSync(logPath, "r"); + try { + const size = fs.fstatSync(fd).size; + const start = Math.max(0, size - MAX_LOG_BYTES); + const buffer = Buffer.alloc(size - start); + fs.readSync(fd, buffer, 0, buffer.length, start); + let text = buffer.toString("utf8"); + if (start > 0) { + const firstNewline = text.indexOf("\n"); + text = firstNewline >= 0 ? text.slice(firstNewline + 1) : ""; + } + return text.split(/\r?\n/).slice(-400).join("\n"); + } finally { + fs.closeSync(fd); + } +} + +function readAvailableTail(logPath) { + try { + return readTail(logPath); + } catch { + return null; + } +} + +function projectAutoPair(text) { + if (text === null) return { readable: false, events: [] }; + const events = []; + const seen = new Set(); + for (const line of text.split(/\r?\n/)) { + const stageMatch = line.match(/^\[auto-pair\] stage=(request-creation|listing|validation|approval|watcher-execution) (observed|waiting|failed|accepted|rejected|attempting)\b/); + if (!stageMatch) continue; + const stage = stageMatch[1]; + const outcome = stageMatch[2]; + if (!SAFE_STAGE_OUTCOMES.has(stage + ":" + outcome)) continue; + const reasonMatch = line.match(/\breason=([a-z-]+)\b/); + const reason = reasonMatch && SAFE_REASONS.has(reasonMatch[1]) ? reasonMatch[1] : undefined; + const key = stage + ":" + outcome + ":" + (reason || ""); + if (seen.has(key)) continue; + seen.add(key); + events.push(reason ? { stage, outcome, reason } : { stage, outcome }); + if (events.length >= 100) break; + } + return { readable: true, events }; +} + +function signalCount(text, pattern) { + return text === null ? 0 : (text.match(pattern) || []).length; +} + +function projectGateway(text) { + return { + readable: text !== null, + signals: { + pairingRequired: signalCount(text, /\bpairing required\b/gi), + scopeUpgradePending: signalCount(text, /\bscope upgrade pending approval\b/gi), + pairingApprovalDenied: signalCount(text, /\bdevice pairing approval denied\b/gi), + gatewayUnavailable: signalCount(text, /\bgateway unavailable\b/gi), + }, + }; +} + +try { + if (logPaths.length !== 2) throw new Error("invalid diagnostics inputs"); + const autoPair = readAvailableTail(logPaths[0]); + const gateway = readAvailableTail(logPaths[1]); + process.stdout.write(JSON.stringify({ + schemaVersion: 1, + autoPair: projectAutoPair(autoPair), + gateway: projectGateway(gateway), + }) + "\n"); +} catch { + process.stdout.write(JSON.stringify({ schemaVersion: 1, status: "unavailable" }) + "\n"); + process.exitCode = 1; +} +`; + +export function buildIssue4462DiagnosticsCommand( + logPaths: readonly string[] = PAIRING_LOG_PATHS, +): string[] { + return ["node", "-e", PROJECT_PAIRING_DIAGNOSTICS_PROGRAM, ...logPaths]; +} + +/** Preserve startup pairing evidence without replacing the scenario's primary failure. */ +export async function captureIssue4462FailureDiagnostics( + sandbox: Pick, + options: Issue4462FailureDiagnosticsOptions, +): Promise { + try { + await sandbox.exec(options.sandboxName, buildIssue4462DiagnosticsCommand(), { + artifactName: "failure-openclaw-pairing-diagnostics", + captureLimitBytes: 1024 * 1024, + env: options.env, + redactionValues: [...options.redactionValues], + timeoutMs: 30_000, + }); + } catch { + // Preserve the primary failure when the sandbox or its logs are unavailable. + } +} + +export function trackIssue4462FailureDiagnostics( + cleanup: Pick, + sandbox: Pick, + sandboxName: string, + env: NodeJS.ProcessEnv, + redactionValues: readonly string[], +): void { + cleanup.trackDisposable("capture OpenClaw pairing failure diagnostics", () => + captureIssue4462FailureDiagnostics(sandbox, { env, redactionValues, sandboxName }), + ); +} diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index eb4cb7e8481..bd01ca194a7 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -364,6 +364,9 @@ async function runRuntimeIdentityE2EScenario( cleanup.add(`best-effort runtime identity sandbox cleanup for ${sandboxName}`, () => cleanupSandbox(host, sandbox, sandboxName), ); + cleanup.add(`strict runtime identity sandbox cleanup for ${sandboxName}`, () => + cleanupSandbox(host, sandbox, sandboxName, { strict: true }), + ); await cleanupSandbox(host, sandbox, sandboxName); const inference = await startFakeOpenAiCompatibleServer({ @@ -402,9 +405,6 @@ async function runRuntimeIdentityE2EScenario( ONBOARD_FINAL_HANDOFF_COMMAND_TIMEOUT_MS, ); expectOnboardSuccess(onboard, `${scenario.testId} real OpenShell prerequisite onboard`); - cleanup.add(`strict runtime identity sandbox cleanup for ${sandboxName}`, () => - cleanupSandbox(host, sandbox, sandboxName, { strict: true }), - ); // Remove stale fixture-owned objects left by a previously interrupted local // run. Both operations are best-effort and target only this E2E namespace. @@ -1027,6 +1027,9 @@ test("TC-INF-09 Deep Agents Code uses a local compatible endpoint through infere cleanup.add(`best-effort inference-routing compatible-endpoint cleanup for ${sandboxName}`, () => cleanupSandbox(host, sandbox, sandboxName), ); + cleanup.add(`strict inference-routing compatible-endpoint cleanup for ${sandboxName}`, () => + cleanupSandbox(host, sandbox, sandboxName, { strict: true }), + ); await cleanupSandbox(host, sandbox, sandboxName); progress.phase("start the local compatible endpoint"); const fake = await startFakeOpenAiCompatibleServer({ @@ -1077,9 +1080,6 @@ test("TC-INF-09 Deep Agents Code uses a local compatible endpoint through infere ONBOARD_FINAL_HANDOFF_COMMAND_TIMEOUT_MS, ); expectOnboardSuccess(onboard, "TC-INF-09 compatible-endpoint onboard"); - cleanup.add(`strict inference-routing compatible-endpoint cleanup for ${sandboxName}`, () => - cleanupSandbox(host, sandbox, sandboxName, { strict: true }), - ); progress.phase("inspect the compatible provider route"); const provider = await sandbox.openshell( ["provider", "get", "-g", "nemoclaw", "compatible-endpoint"], @@ -1171,6 +1171,9 @@ test("TC-INF-11 DNS-backed HTTPS custom endpoint routes through the local pinnin cleanup.add(`best-effort inference-routing https-pin cleanup for ${sandboxName}`, () => cleanupSandbox(host, sandbox, sandboxName), ); + cleanup.add(`strict inference-routing https-pin cleanup for ${sandboxName}`, () => + cleanupSandbox(host, sandbox, sandboxName, { strict: true }), + ); progress.phase("clear the HTTPS pin sandbox"); await cleanupSandbox(host, sandbox, sandboxName); @@ -1260,9 +1263,6 @@ test("TC-INF-11 DNS-backed HTTPS custom endpoint routes through the local pinnin ONBOARD_FINAL_HANDOFF_COMMAND_TIMEOUT_MS, ); expectOnboardSuccess(onboard, "TC-INF-11 https-pin-endpoint placeholder onboard"); - cleanup.add(`strict inference-routing https-pin cleanup for ${sandboxName}`, () => - cleanupSandbox(host, sandbox, sandboxName, { strict: true }), - ); progress.phase("reject credential-bearing endpoint state"); const userinfoEndpoint = new URL(endpointUrl); diff --git a/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts b/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts index be9ce1d2e8d..eeaeaef092c 100644 --- a/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts +++ b/test/e2e/live/issue-4462-scope-upgrade-approval.test.ts @@ -9,6 +9,7 @@ import { resultText } from "../fixtures/clients/command.ts"; import { type HostCliClient } from "../fixtures/clients/host.ts"; import { type SandboxClient, validateSandboxName } from "../fixtures/clients/sandbox.ts"; import { expect, test } from "../fixtures/e2e-test.ts"; +import { trackIssue4462FailureDiagnostics } from "../fixtures/issue-4462-diagnostics.ts"; import { ISSUE_4462_PAIRING_SEED_PY } from "../fixtures/issue-4462-pairing-seed.ts"; import { CLI_ENTRYPOINT, REPO_ROOT } from "../fixtures/paths.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; @@ -1259,11 +1260,10 @@ test("keeps issue 4462 scope-upgrade approval on the gateway path without an adm }), ); cleanupRegistry.trackSandbox(host, SANDBOX_NAME, { - artifactName: "cleanup-nemoclaw-destroy", - env: env({ NEMOCLAW_CLEANUP_GATEWAY: "1" }), - redactionValues: [apiKey], - timeoutMs: 120_000, + artifactName: "cleanup-nemoclaw-destroy", env: env(), + redactionValues: [apiKey], timeoutMs: 120_000, }); + trackIssue4462FailureDiagnostics(cleanupRegistry, sandbox, SANDBOX_NAME, env(), [apiKey]); await cleanup(host, sandbox); progress.phase("install the OpenClaw sandbox"); const install = await host.command( diff --git a/test/e2e/live/managed-image-activation-e2e-helpers.ts b/test/e2e/live/managed-image-activation-e2e-helpers.ts index f685796ff87..58b7e2d3e05 100644 --- a/test/e2e/live/managed-image-activation-e2e-helpers.ts +++ b/test/e2e/live/managed-image-activation-e2e-helpers.ts @@ -27,6 +27,7 @@ import { } from "../fixtures/clients/index.ts"; import { expect } from "../fixtures/e2e-test.ts"; import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts"; +import { captureIssue4462FailureDiagnostics } from "../fixtures/issue-4462-diagnostics.ts"; import type { LifecyclePhaseFixture } from "../fixtures/phases/lifecycle.ts"; import type { TestProgress } from "../fixtures/progress.ts"; @@ -65,6 +66,20 @@ export function summarizeOnboardFailureStartupSignals( ) as Record; } +export async function captureManagedImageOnboardPairingDiagnostics( + sandbox: Pick, + agent: ShippedManagedImageAgent, + sandboxName: string, + env: NodeJS.ProcessEnv, +): Promise { + if (agent !== "openclaw") return; + await captureIssue4462FailureDiagnostics(sandbox, { + env, + redactionValues: [API_KEY], + sandboxName, + }); +} + const SANDBOX_NAMES: Record = { openclaw: "mi-act-openclaw", hermes: "mi-act-hermes", @@ -473,6 +488,7 @@ async function qualifyAgent( }, ); if (onboard.exitCode !== 0) { + await captureManagedImageOnboardPairingDiagnostics(sandbox, agent, sandboxName, env); await collectOnboardFailureDockerDiagnostics(artifacts, host, agent, sandboxName, env); } expect(onboard.exitCode, resultText(onboard)).toBe(0); diff --git a/test/e2e/support/issue-4462-diagnostics.test.ts b/test/e2e/support/issue-4462-diagnostics.test.ts new file mode 100644 index 00000000000..e002bd6407a --- /dev/null +++ b/test/e2e/support/issue-4462-diagnostics.test.ts @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { CleanupRegistry } from "../fixtures/cleanup.ts"; +import { + buildIssue4462DiagnosticsCommand, + captureIssue4462FailureDiagnostics, + trackIssue4462FailureDiagnostics, +} from "../fixtures/issue-4462-diagnostics.ts"; + +describe("pairing failure evidence", () => { + it("invokes structured auto-pair and gateway diagnostics with fixed arguments (#9844)", async () => { + const exec = vi.fn(async () => ({ exitCode: 0 })); + + await captureIssue4462FailureDiagnostics({ exec } as never, { + env: { PATH: "/usr/bin" }, + redactionValues: ["secret-api-key"], + sandboxName: "issue-4462", + }); + + expect(exec).toHaveBeenCalledExactlyOnceWith( + "issue-4462", + ["node", "-e", expect.any(String), "/tmp/auto-pair.log", "/tmp/gateway.log"], + expect.objectContaining({ + artifactName: "failure-openclaw-pairing-diagnostics", + redactionValues: ["secret-api-key"], + }), + ); + }); + + it("emits only structured allowlisted diagnostics from secret-bearing logs (#9844)", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-issue4462-diagnostics-")); + const autoPairPath = join(fixtureRoot, "auto-pair.log"); + const gatewayPath = join(fixtureRoot, "gateway.log"); + const secrets = [ + "runtime-generated-gateway-token", + "nvapi-secret-value", + "opaque-runtime-secret-value", + "private-operator-message", + "cookie-session-secret", + "set-cookie-auth-secret", + "generic-query-key-secret", + "plain-password-secret", + "plain-secret-value", + "plain-token-secret", + "github_pat_secret-value", + "private-key-shaped-secret", + ]; + + try { + writeFileSync( + autoPairPath, + `[auto-pair] stage=request-creation waiting reason=no-request token=${secrets[0]}\n` + + `[auto-pair] stage=listing failed reason=invalid-response password=${secrets[7]}\n` + + `[auto-pair] stage=validation accepted request=request-secret reason=allowlisted-request\n` + + `[auto-pair] stage=approval failed reason=command-failed secret=${secrets[8]}\n` + + `[auto-pair] stage=approval failed reason=timeout token=${secrets[9]}\n` + + `[auto-pair] stage=watcher-execution failed error=RuntimeError\n` + + `[auto-pair] approve failed request=request-secret: [auto-pair] stage=validation rejected reason=malformed-request-id token=${secrets[9]}\n` + + `unknown raw line secret=${secrets[8]}\n`, + ); + writeFileSync( + gatewayPath, + `pairing required Authorization: Bearer ${secrets[0]}\n` + + `scope upgrade pending approval x-api-key=${secrets[1]}\n` + + `${JSON.stringify({ Authorization: secrets[2], message: secrets[3] })}\n` + + `Cookie: session=${secrets[4]}\nSet-Cookie: auth=${secrets[5]}\n` + + `https://example.invalid/?key=${secrets[6]} password=${secrets[7]}\n` + + `secret=${secrets[8]} token=${secrets[9]} ${secrets[10]}\n` + + `${JSON.stringify({ privateKey: secrets[11] })}\n` + + `device pairing approval denied\ngateway unavailable\n`, + ); + const [command, ...args] = buildIssue4462DiagnosticsCommand([autoPairPath, gatewayPath]); + const result = spawnSync(command, args, { encoding: "utf8" }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + schemaVersion: 1, + autoPair: { + readable: true, + events: [ + { stage: "request-creation", outcome: "waiting", reason: "no-request" }, + { stage: "listing", outcome: "failed", reason: "invalid-response" }, + { stage: "validation", outcome: "accepted", reason: "allowlisted-request" }, + { stage: "approval", outcome: "failed", reason: "command-failed" }, + { stage: "approval", outcome: "failed", reason: "timeout" }, + { stage: "watcher-execution", outcome: "failed" }, + ], + }, + gateway: { + readable: true, + signals: { + pairingRequired: 1, + scopeUpgradePending: 1, + pairingApprovalDenied: 1, + gatewayUnavailable: 1, + }, + }, + }); + expect(secrets.some((secret) => result.stdout.includes(secret))).toBe(false); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("marks missing logs unreadable without emitting their paths (#9844)", () => { + const fixtureRoot = mkdtempSync(join(tmpdir(), "nemoclaw-issue4462-diagnostics-")); + const autoPairPath = join(fixtureRoot, "auto-pair.log"); + const gatewayPath = join(fixtureRoot, "gateway.log"); + + try { + const [command, ...args] = buildIssue4462DiagnosticsCommand([autoPairPath, gatewayPath]); + const result = spawnSync(command, args, { encoding: "utf8" }); + + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + schemaVersion: 1, + autoPair: { readable: false, events: [] }, + gateway: { + readable: false, + signals: { + pairingRequired: 0, + scopeUpgradePending: 0, + pairingApprovalDenied: 0, + gatewayUnavailable: 0, + }, + }, + }); + expect(result.stdout).not.toContain(autoPairPath); + expect(result.stdout).not.toContain(gatewayPath); + expect(result.stderr).toBe(""); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + + it("fails closed with a fixed record when diagnostics inputs are invalid (#9844)", () => { + const [command, ...args] = buildIssue4462DiagnosticsCommand([]); + const result = spawnSync(command, args, { encoding: "utf8" }); + + expect(result.status).toBe(1); + expect(result.stdout).toBe('{"schemaVersion":1,"status":"unavailable"}\n'); + expect(result.stderr).toBe(""); + }); + + it("preserves the primary failure through unavailable diagnostic cleanup (#9844)", async () => { + const cleanup = new CleanupRegistry(); + const exec = vi.fn(async () => { + throw new Error("sandbox not found"); + }); + trackIssue4462FailureDiagnostics(cleanup, { exec } as never, "issue-4462", {}, []); + const primaryFailure = new Error("sentinel primary failure"); + let observedFailure: unknown; + + try { + throw primaryFailure; + } catch (error) { + observedFailure = error; + } finally { + expect(await cleanup.runAll()).toEqual({ + failures: [], + passed: ["capture OpenClaw pairing failure diagnostics"], + }); + } + + expect(observedFailure).toBe(primaryFailure); + expect(exec).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/e2e/support/managed-image-activation-diagnostics.test.ts b/test/e2e/support/managed-image-activation-diagnostics.test.ts index f25d6ac318b..7680def1979 100644 --- a/test/e2e/support/managed-image-activation-diagnostics.test.ts +++ b/test/e2e/support/managed-image-activation-diagnostics.test.ts @@ -1,8 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; -import { summarizeOnboardFailureStartupSignals } from "../live/managed-image-activation-e2e-helpers.ts"; +import { describe, expect, it, vi } from "vitest"; +import { + captureManagedImageOnboardPairingDiagnostics, + summarizeOnboardFailureStartupSignals, +} from "../live/managed-image-activation-e2e-helpers.ts"; describe("managed image activation failure diagnostics", () => { it("emits only fixed startup signals from arbitrary container output (#8543)", () => { @@ -33,4 +36,30 @@ describe("managed image activation failure diagnostics", () => { expect(summary.hermesApiPortRejected).toBe(false); expect(summary.hermesRuntimeMarkerRefused).toBe(false); }); + + it("captures bounded pairing stages only for OpenClaw onboarding failures (#9844)", async () => { + const exec = vi.fn(async () => ({ exitCode: 0 })); + + await captureManagedImageOnboardPairingDiagnostics( + { exec } as never, + "openclaw", + "mi-act-openclaw", + { PATH: "/usr/bin" }, + ); + await captureManagedImageOnboardPairingDiagnostics( + { exec } as never, + "hermes", + "mi-act-hermes", + { PATH: "/usr/bin" }, + ); + + expect(exec).toHaveBeenCalledExactlyOnceWith( + "mi-act-openclaw", + ["node", "-e", expect.any(String), "/tmp/auto-pair.log", "/tmp/gateway.log"], + expect.objectContaining({ + artifactName: "failure-openclaw-pairing-diagnostics", + redactionValues: ["nemoclaw-managed-activation-e2e-key"], + }), + ); + }); }); diff --git a/test/helpers/onboard-final-flow-phases.ts b/test/helpers/onboard-final-flow-phases.ts index 7da309f8ebe..f304f42c43f 100644 --- a/test/helpers/onboard-final-flow-phases.ts +++ b/test/helpers/onboard-final-flow-phases.ts @@ -258,8 +258,10 @@ export function createPhases( removeLegacyCredentialsFile: vi.fn(), cleanupStaleHostFiles: vi.fn(), checkAndRecoverSandboxProcesses: vi.fn(), - warmupScopeUpgrade: vi.fn(), - autoPairScopeApproval: vi.fn(), + settleOrdinaryOpenClawPairing: vi.fn(async () => ({ kind: "settled" as const })), + ordinaryOpenClawPairingIncompleteMessage: vi.fn( + () => "OpenClaw onboarding is incomplete; resume onboarding.", + ), readRegistryAgent: vi.fn(() => "openclaw"), settlePortablePairing: vi.fn(async () => ({ kind: "settled" as const })), portablePairingIncompleteMessage: vi.fn( diff --git a/test/helpers/openclaw-device-self-approval-patch-harness.ts b/test/helpers/openclaw-device-self-approval-patch-harness.ts index 3a011c695b8..23b91ce578a 100644 --- a/test/helpers/openclaw-device-self-approval-patch-harness.ts +++ b/test/helpers/openclaw-device-self-approval-patch-harness.ts @@ -563,6 +563,7 @@ const writes = []; let delayedPairedWrite = null; let failNextPendingWrite = false; let failCommittedJournalAfterWrite = false; +let failNextIdleJournalWrite = false; let driftOnBuild = null; function cloneJson(value) { return value === null || value === undefined ? value : JSON.parse(JSON.stringify(value)); } function createAsyncLock() { @@ -575,9 +576,10 @@ function createAsyncLock() { try { return await fn(); } finally { release(); } }; } -function resolvePairingPaths(baseDir) { +function resolvePairingPaths(baseDir, subdir = "devices") { const root = baseDir ?? "/fixture"; - return { pendingPath: \`\${root}/pending.json\`, pairedPath: \`\${root}/paired.json\` }; + const dir = \`\${root}/\${subdir}\`; + return { dir, pendingPath: \`\${dir}/pending.json\`, pairedPath: \`\${dir}/paired.json\` }; } function coercePairingStateRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function pruneExpiredPending() {} @@ -595,6 +597,10 @@ async function writeJson(file, value, options) { delayed.started(); await delayed.gate; } + if (file.endsWith(".nemoclaw-self-approval-journal") && value?.phase === "idle" && failNextIdleJournalWrite) { + failNextIdleJournalWrite = false; + throw new Error("idle journal cleanup failed"); + } files.set(file, cloneJson(value)); if (file.endsWith(".nemoclaw-self-approval-journal") && value?.phase === "committed" && failCommittedJournalAfterWrite) { failCommittedJournalAfterWrite = false; @@ -603,14 +609,38 @@ async function writeJson(file, value, options) { } function setPairingState(pendingById, pairedByDeviceId, baseDir = "/fixture") { const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices"); + const authPath = \`\${resolvePairingPaths(baseDir, "identity").dir}/device-auth.json\`; files.set(pendingPath, cloneJson(pendingById)); files.set(pairedPath, cloneJson(pairedByDeviceId)); + const pairedDevice = Object.values(pairedByDeviceId)[0]; + const tokens = pairedDevice?.tokens; + const operator = Array.isArray(tokens) + ? tokens.find((entry) => entry?.role === "operator") + : tokens?.operator; + if (typeof operator?.token === "string" && operator.token) { + files.set(authPath, cloneJson({ + version: 1, + deviceId: pairedDevice.deviceId, + tokens: { + operator: { + token: operator.token, + role: "operator", + scopes: operator.scopes, + updatedAtMs: 1, + }, + }, + })); + } } function setFile(file, value) { files.set(file, cloneJson(value)); } function getFile(file) { return files.has(file) ? cloneJson(files.get(file)) : null; } function getPairingPaths(baseDir = "/fixture") { const paths = resolvePairingPaths(baseDir, "devices"); - return { ...paths, journalPath: \`\${paths.pendingPath}.nemoclaw-self-approval-journal\` }; + return { + ...paths, + authPath: \`\${resolvePairingPaths(baseDir, "identity").dir}/device-auth.json\`, + journalPath: \`\${paths.pendingPath}.nemoclaw-self-approval-journal\`, + }; } function armLateWriterFailure() { failNextPendingWrite = true; @@ -623,6 +653,7 @@ function armLateWriterFailure() { } function releaseLateWriter() { delayedPairedWrite?.release(); } function armCommittedJournalFailure() { failCommittedJournalAfterWrite = true; } +function armIdleJournalFailure() { failNextIdleJournalWrite = true; } function armStateDrift(file, value) { driftOnBuild = { file, value: cloneJson(value) }; } async function loadState(baseDir) { const { pendingPath, pairedPath } = resolvePairingPaths(baseDir, "devices"); @@ -766,6 +797,47 @@ export function runFixture(source: string, expression: string): T { return vm.runInNewContext(`${source}\n${expression}`, { Buffer, URL }) as T; } +export interface PairingFixtureRuntime { + writes: Array<{ file: string; value: unknown; options?: Record }>; + setPairingState( + pendingById: Record, + pairedByDeviceId: Record, + baseDir?: string, + ): void; + setFile(file: string, value: unknown): void; + getFile(file: string): unknown; + getPairingPaths(baseDir?: string): { + authPath: string; + pendingPath: string; + pairedPath: string; + journalPath: string; + }; + listDevicePairing(baseDir?: string): Promise<{ + pending: Array>; + paired: Array>; + }>; + getPairedDevice(deviceId: string, baseDir?: string): Promise | null>; + getPendingDevicePairing( + requestId: string, + baseDir?: string, + ): Promise | null>; + approveDevicePairing( + requestId: string, + options: Record, + baseDir?: string, + ): Promise | null>; + approveBootstrapDevicePairing( + requestId: string, + bootstrapProfile: Record, + baseDir?: string, + ): Promise | null>; + armLateWriterFailure(): Promise; + releaseLateWriter(): void; + armCommittedJournalFailure(): void; + armIdleJournalFailure(): void; + armStateDrift(file: string, value: unknown): void; +} + export function validPending(overrides: Record = {}) { return { requestId: "request-1", @@ -796,6 +868,91 @@ export function validPaired(overrides: Record = {}) { }; } +export function selfApprovalTransactionSnapshots() { + const pending = validPending({ ts: 100 }); + const pairedBefore = validPaired({ + approvedAtMs: 100, + tokens: { + operator: { token: "token-before", role: "operator", scopes: ["operator.pairing"] }, + }, + }); + const pairedAfter = validPaired({ + approvedAtMs: 200, + scopes: ["operator.pairing", "operator.read", "operator.write"], + approvedScopes: ["operator.pairing", "operator.read", "operator.write"], + tokens: { + operator: { + token: "token-after", + role: "operator", + scopes: ["operator.pairing", "operator.read", "operator.write"], + }, + }, + }); + return { + before: { + auth: { + version: 1, + deviceId: "device-1", + tokens: { + operator: { + token: "token-before", + role: "operator", + scopes: ["operator.pairing"], + updatedAtMs: 1, + }, + }, + }, + pendingById: { "request-1": pending }, + pairedByDeviceId: { "device-1": pairedBefore }, + }, + after: { + auth: { + version: 1, + deviceId: "device-1", + tokens: { + operator: { + token: "token-after", + role: "operator", + scopes: ["operator.pairing", "operator.read", "operator.write"], + updatedAtMs: 1, + }, + }, + }, + pendingById: {}, + pairedByDeviceId: { "device-1": pairedAfter }, + }, + }; +} + +export function selfApprovalTransactionJournal( + phase: "prepared" | "committed", + snapshots: ReturnType, +) { + return { + version: 2, + kind: "nemoclaw-self-approval", + phase, + requestId: "request-1", + deviceId: "device-1", + before: snapshots.before, + after: snapshots.after, + }; +} + +export function selfApprovalOptions() { + return { + callerScopes: ["operator.pairing"], + nemoclawSelfApprovalIdentity: { + deviceId: "device-1", + publicKey: "public-key-1", + role: "operator", + clientId: "cli", + clientMode: "cli", + deviceToken: "token-before", + }, + }; +} + export function validClient(overrides: Record = {}) { return { isDeviceTokenAuth: true, @@ -807,6 +964,7 @@ export function validClient(overrides: Record = {}) { connect: { role: "operator", scopes: ["operator.pairing"], + auth: { token: "token-before" }, device: { id: "device-1", publicKey: "public-key-1" }, client: { id: "cli", mode: "cli" }, }, diff --git a/test/helpers/openclaw-real-device-self-approval-proof.ts b/test/helpers/openclaw-real-device-self-approval-proof.ts index db519322ee1..0cc3a91d3e9 100644 --- a/test/helpers/openclaw-real-device-self-approval-proof.ts +++ b/test/helpers/openclaw-real-device-self-approval-proof.ts @@ -245,6 +245,11 @@ function requireRealStoredDeviceAuthLinkage(sources: DistSource[], cliSource: Di [ "async function listPairingWithFallback(opts, callOpts)", "nemoclaw: preflight bounded stored device auth before live pairing list", + "NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT", + "useStoredDeviceAuth: true", + "requiredStoredDeviceAuthScopes: [PAIRING_SCOPE]", + "nemoclaw: use stored device auth for pairing settlement list", + "callOpts ??= nemoclawSettlementListCallOpts", 'callGatewayCli("device.pair.list", opts, {}, callOpts)', "const nemoclawLocalList = nemoclawPairedTokenRequested ? readNemoClawPinnedPairingSnapshot() : await listDevicePairing();", "nemoclawLocalStoredAuthCandidate = !nemoclawPairedTokenRequested && nemoclawLocalContext.useStoredDeviceAuth;", @@ -309,9 +314,10 @@ function asRecord(value: unknown): Record | null { } function requireOperatorToken( - container: Record, + container: Record | null, label: string, ): Record { + requireLiveProof(container, `${label}: missing token container`); const tokens = asRecord(container.tokens); requireLiveProof(tokens, `${label}: missing role-keyed tokens`); const operator = asRecord(tokens.operator); @@ -330,9 +336,11 @@ function requireExactScopes(value: unknown, expected: string[], label: string): ); } -type PairingStateSide = "pending" | "paired"; +type PairingStateSide = "auth" | "pending" | "paired"; interface PairingTransactionFixture { + authPath: string; + beforeAuth: Record; beforePaired: Record; beforePending: Record; deviceId: string; @@ -345,8 +353,10 @@ interface PairingTransactionFixture { } interface PreparedPairingJournal { + afterAuth: Record; afterPaired: Record; afterPending: Record; + beforeAuth: Record; beforePaired: Record; beforePending: Record; } @@ -358,6 +368,48 @@ function requireJsonEqual(actual: unknown, expected: unknown, label: string): vo ); } +function requireOnlyAuthenticationAuditChanges( + beforeSerialized: string, + afterState: Record, + deviceId: string, + label: string, +): void { + const beforeState = asRecord(JSON.parse(beforeSerialized) as unknown); + const normalizedAfterState = asRecord(JSON.parse(JSON.stringify(afterState)) as unknown); + const beforeDevice = asRecord(beforeState?.[deviceId]); + const afterDevice = asRecord(normalizedAfterState?.[deviceId]); + const beforeOperator = asRecord(asRecord(beforeDevice?.tokens)?.operator); + const afterOperator = asRecord(asRecord(afterDevice?.tokens)?.operator); + requireLiveProof( + beforeState && + normalizedAfterState && + beforeDevice && + afterDevice && + beforeOperator && + afterOperator, + `${label}: paired authentication state shape changed`, + ); + const deviceActivityChanged = + afterDevice.lastSeenAtMs !== beforeDevice.lastSeenAtMs || + afterDevice.lastSeenReason !== beforeDevice.lastSeenReason; + requireLiveProof( + !deviceActivityChanged || + ((afterDevice.lastSeenReason === "device-token-auth" || + afterDevice.lastSeenReason === "connect") && + typeof afterDevice.lastSeenAtMs === "number"), + `${label}: invalid device authentication activity`, + ); + const tokenActivityChanged = afterOperator.lastUsedAtMs !== beforeOperator.lastUsedAtMs; + requireLiveProof( + !tokenActivityChanged || typeof afterOperator.lastUsedAtMs === "number", + `${label}: invalid token authentication activity`, + ); + afterDevice.lastSeenAtMs = beforeDevice.lastSeenAtMs; + afterDevice.lastSeenReason = beforeDevice.lastSeenReason; + afterOperator.lastUsedAtMs = beforeOperator.lastUsedAtMs; + requireJsonEqual(normalizedAfterState, beforeState, `${label}: authorization state`); +} + function requireExactObjectKeys( value: Record, expected: string[], @@ -373,8 +425,8 @@ function requireIdlePairingJournal(journalPath: string, label: string): void { const journal = readJsonObject(journalPath, label); requireExactObjectKeys(journal, ["version", "kind", "phase"], label); requireLiveProof( - journal.version === 1 && journal.kind === "nemoclaw-self-approval" && journal.phase === "idle", - `${label}: expected an idle v1 self-approval journal`, + journal.version === 2 && journal.kind === "nemoclaw-self-approval" && journal.phase === "idle", + `${label}: expected an idle v2 self-approval journal`, ); } @@ -389,7 +441,7 @@ function requirePreparedPairingJournal( label, ); requireLiveProof( - journal.version === 1 && + journal.version === 2 && journal.kind === "nemoclaw-self-approval" && journal.phase === "prepared" && journal.requestId === fixture.requestId && @@ -399,16 +451,19 @@ function requirePreparedPairingJournal( const before = asRecord(journal.before); const after = asRecord(journal.after); requireLiveProof(before && after, `${label}: before/after snapshots missing`); - requireExactObjectKeys(before, ["pendingById", "pairedByDeviceId"], `${label} before`); - requireExactObjectKeys(after, ["pendingById", "pairedByDeviceId"], `${label} after`); + requireExactObjectKeys(before, ["auth", "pendingById", "pairedByDeviceId"], `${label} before`); + requireExactObjectKeys(after, ["auth", "pendingById", "pairedByDeviceId"], `${label} after`); + const beforeAuth = asRecord(before.auth); const beforePending = asRecord(before.pendingById); const beforePaired = asRecord(before.pairedByDeviceId); + const afterAuth = asRecord(after.auth); const afterPending = asRecord(after.pendingById); const afterPaired = asRecord(after.pairedByDeviceId); requireLiveProof( - beforePending && beforePaired && afterPending && afterPaired, + beforeAuth && beforePending && beforePaired && afterAuth && afterPending && afterPaired, `${label}: state snapshots must be plain records`, ); + requireJsonEqual(beforeAuth, fixture.beforeAuth, `${label} auth before-image`); requireJsonEqual(beforePending, fixture.beforePending, `${label} pending before-image`); requireJsonEqual(beforePaired, fixture.beforePaired, `${label} paired before-image`); requireLiveProof( @@ -421,6 +476,7 @@ function requirePreparedPairingJournal( `${label}: paired after-image identity changed`, ); const operatorAfter = requireOperatorToken(pairedAfter, `${label} paired after-image`); + const authOperatorAfter = requireOperatorToken(afterAuth, `${label} auth after-image`); const pairedBefore = asRecord(fixture.beforePaired[fixture.deviceId]); requireLiveProof(pairedBefore, `${label}: paired before-image device missing`); const operatorBefore = requireOperatorToken(pairedBefore, `${label} paired before-image`); @@ -435,6 +491,15 @@ function requirePreparedPairingJournal( ["operator.pairing", "operator.read", "operator.write"], `${label} paired after-image operator scopes`, ); + requireLiveProof( + afterAuth.deviceId === fixture.deviceId && authOperatorAfter.token === operatorAfter.token, + `${label}: stored auth after-image did not match paired state`, + ); + requireExactScopes( + authOperatorAfter.scopes, + ["operator.pairing", "operator.read", "operator.write"], + `${label} auth after-image operator scopes`, + ); requireJsonEqual( afterPending.unrelated, fixture.beforePending.unrelated, @@ -445,7 +510,7 @@ function requirePreparedPairingJournal( fixture.beforePaired["unrelated-device"], `${label} unrelated paired after-image`, ); - return { beforePending, beforePaired, afterPending, afterPaired }; + return { beforeAuth, beforePending, beforePaired, afterAuth, afterPending, afterPaired }; } function requirePairingState( @@ -458,6 +523,14 @@ function requirePairingState( requireJsonEqual(readJsonObject(fixture.pairedPath, `${label} paired`), expectedPaired, label); } +function requirePairingAuthState( + fixture: PairingTransactionFixture, + expectedAuth: Record, + label: string, +): void { + requireJsonEqual(readJsonObject(fixture.authPath, `${label} auth`), expectedAuth, label); +} + function createPairingTransactionFixture( tmp: string, label: string, @@ -465,8 +538,10 @@ function createPairingTransactionFixture( ): PairingTransactionFixture { const stateDir = path.join(tmp, `device-approval-transaction-${label}`); const devicesDir = path.join(stateDir, "devices"); + const identityDir = path.join(stateDir, "identity"); fs.rmSync(stateDir, { force: true, recursive: true }); fs.mkdirSync(devicesDir, { recursive: true }); + fs.mkdirSync(identityDir, { recursive: true }); const requestId = `transaction-request-${label}`; const deviceId = `transaction-device-${label}`; const publicKey = `transaction-public-key-${label}`; @@ -526,9 +601,25 @@ function createPairingTransactionFixture( }; const pendingPath = path.join(devicesDir, "pending.json"); const pairedPath = path.join(devicesDir, "paired.json"); + const authPath = path.join(identityDir, "device-auth.json"); + const beforeAuth = { + version: 1, + deviceId, + tokens: { + operator: { + token: `baseline-token-${label}`, + role: "operator", + scopes: ["operator.pairing"], + updatedAtMs: now, + }, + }, + }; fs.writeFileSync(pendingPath, JSON.stringify(beforePending)); fs.writeFileSync(pairedPath, JSON.stringify(beforePaired)); + fs.writeFileSync(authPath, JSON.stringify(beforeAuth)); return { + authPath, + beforeAuth, beforePaired, beforePending, deviceId, @@ -579,6 +670,8 @@ function requireCompletedPairingApproval(fixture: PairingTransactionFixture, lab `${label}: approved device identity changed`, ); const operatorAfter = requireOperatorToken(pairedAfter, `${label} approved device`); + const authAfter = readJsonObject(fixture.authPath, `${label} stored auth`); + const authOperatorAfter = requireOperatorToken(authAfter, `${label} stored auth`); const operatorBefore = requireOperatorToken(pairedBefore, `${label} baseline device`); requireLiveProof( typeof operatorAfter.token === "string" && @@ -591,6 +684,15 @@ function requireCompletedPairingApproval(fixture: PairingTransactionFixture, lab ["operator.pairing", "operator.read", "operator.write"], `${label} approved operator scopes`, ); + requireLiveProof( + authAfter.deviceId === fixture.deviceId && authOperatorAfter.token === operatorAfter.token, + `${label}: stored auth did not match the approved paired token`, + ); + requireExactScopes( + authOperatorAfter.scopes, + ["operator.pairing", "operator.read", "operator.write"], + `${label} stored auth scopes`, + ); requireIdlePairingJournal(fixture.journalPath, `${label} journal`); } @@ -605,8 +707,14 @@ function runPairingCrashDirectionProof( `crash-${durableSide}`, journalBasename, ); - const durablePath = durableSide === "pending" ? fixture.pendingPath : fixture.pairedPath; - const interruptedPath = durableSide === "pending" ? fixture.pairedPath : fixture.pendingPath; + const statePaths = { + auth: fixture.authPath, + paired: fixture.pairedPath, + pending: fixture.pendingPath, + }; + const durablePath = statePaths[durableSide]; + const interruptedSide = durableSide === "pending" ? "paired" : "pending"; + const interruptedPath = statePaths[interruptedSide]; const crash = spawnSync( options.nodeExecutable, [ @@ -669,6 +777,7 @@ const result = await approveDevicePairing(requireEnv("NEMOCLAW_REQUEST_ID"), { role: "operator", clientId: "cli", clientMode: "cli", + deviceToken: requireEnv("NEMOCLAW_DEVICE_TOKEN"), }, }, stateDir); if (result?.status !== "approved") throw new Error("injected crash path escaped approval"); @@ -682,6 +791,12 @@ throw new Error("injected crash did not terminate the process"); NEMOCLAW_DEVICE_APPROVAL_STATE: fixture.stateDir, NEMOCLAW_DEVICE_BOOTSTRAP_URL: deviceBootstrapUrl, NEMOCLAW_DEVICE_ID: fixture.deviceId, + NEMOCLAW_DEVICE_TOKEN: String( + requireOperatorToken( + asRecord(fixture.beforePaired[fixture.deviceId]), + `real-dist ${durableSide}-first baseline`, + ).token, + ), NEMOCLAW_DURABLE_STATE_PATH: durablePath, NEMOCLAW_INTERRUPTED_STATE_PATH: interruptedPath, NEMOCLAW_PUBLIC_KEY: fixture.publicKey, @@ -700,12 +815,44 @@ throw new Error("injected crash did not terminate the process"); fixture, `real-dist ${durableSide}-first transaction journal`, ); - requirePairingState( - fixture, - durableSide === "pending" ? prepared.afterPending : prepared.beforePending, - durableSide === "paired" ? prepared.afterPaired : prepared.beforePaired, - `real-dist ${durableSide}-first mixed transaction`, - ); + const mixedState = { + auth: readJsonObject(fixture.authPath, `real-dist ${durableSide}-first mixed auth`), + paired: readJsonObject(fixture.pairedPath, `real-dist ${durableSide}-first mixed paired`), + pending: readJsonObject(fixture.pendingPath, `real-dist ${durableSide}-first mixed pending`), + }; + const beforeState = { + auth: prepared.beforeAuth, + paired: prepared.beforePaired, + pending: prepared.beforePending, + }; + const afterState = { + auth: prepared.afterAuth, + paired: prepared.afterPaired, + pending: prepared.afterPending, + }; + for (const side of ["auth", "paired", "pending"] as const) { + if (side === durableSide) { + requireJsonEqual( + mixedState[side], + afterState[side], + `real-dist ${durableSide}-first durable ${side}`, + ); + continue; + } + if (side === interruptedSide) { + requireJsonEqual( + mixedState[side], + beforeState[side], + `real-dist ${durableSide}-first interrupted ${side}`, + ); + continue; + } + requireLiveProof( + JSON.stringify(mixedState[side]) === JSON.stringify(beforeState[side]) || + JSON.stringify(mixedState[side]) === JSON.stringify(afterState[side]), + `real-dist ${durableSide}-first sibling ${side} escaped journal images`, + ); + } const restart = spawnSync( options.nodeExecutable, @@ -722,17 +869,18 @@ const requireEnv = (name) => { const stateDir = requireEnv("NEMOCLAW_DEVICE_APPROVAL_STATE"); const pendingPath = requireEnv("NEMOCLAW_PENDING_STATE_PATH"); const pairedPath = requireEnv("NEMOCLAW_PAIRED_STATE_PATH"); +const authPath = requireEnv("NEMOCLAW_AUTH_STATE_PATH"); const journalPath = requireEnv("NEMOCLAW_JOURNAL_PATH"); const { listDevicePairing } = await import(requireEnv("NEMOCLAW_DEVICE_BOOTSTRAP_URL")); if (typeof listDevicePairing !== "function") throw new Error("reviewed pairing list export missing"); await listDevicePairing(stateDir); -const first = [pendingPath, pairedPath, journalPath].map((file) => fs.readFileSync(file, "utf8")); -const journal = JSON.parse(first[2]); -if (journal?.version !== 1 || journal?.kind !== "nemoclaw-self-approval" || journal?.phase !== "idle") { +const first = [pendingPath, pairedPath, authPath, journalPath].map((file) => fs.readFileSync(file, "utf8")); +const journal = JSON.parse(first[3]); +if (journal?.version !== 2 || journal?.kind !== "nemoclaw-self-approval" || journal?.phase !== "idle") { throw new Error("fresh restart did not leave an idle transaction journal"); } await listDevicePairing(stateDir); -const second = [pendingPath, pairedPath, journalPath].map((file) => fs.readFileSync(file, "utf8")); +const second = [pendingPath, pairedPath, authPath, journalPath].map((file) => fs.readFileSync(file, "utf8")); if (JSON.stringify(first) !== JSON.stringify(second)) throw new Error("second recovery pass changed state"); `, ], @@ -741,6 +889,7 @@ if (JSON.stringify(first) !== JSON.stringify(second)) throw new Error("second re env: { ...process.env, NEMOCLAW_DEVICE_APPROVAL_STATE: fixture.stateDir, + NEMOCLAW_AUTH_STATE_PATH: fixture.authPath, NEMOCLAW_DEVICE_BOOTSTRAP_URL: deviceBootstrapUrl, NEMOCLAW_JOURNAL_PATH: fixture.journalPath, NEMOCLAW_PAIRED_STATE_PATH: fixture.pairedPath, @@ -757,6 +906,7 @@ if (JSON.stringify(first) !== JSON.stringify(second)) throw new Error("second re fixture.beforePaired, `real-dist ${durableSide}-first rollback`, ); + requirePairingAuthState(fixture, fixture.beforeAuth, `real-dist ${durableSide}-first rollback`); requireIdlePairingJournal(fixture.journalPath, `real-dist ${durableSide}-first rollback journal`); const retry = spawnSync( @@ -781,6 +931,7 @@ const result = await approveDevicePairing(requireEnv("NEMOCLAW_REQUEST_ID"), { role: "operator", clientId: "cli", clientMode: "cli", + deviceToken: requireEnv("NEMOCLAW_DEVICE_TOKEN"), }, }, stateDir); if (result?.status !== "approved") throw new Error("approval retry did not succeed"); @@ -794,6 +945,12 @@ await listDevicePairing(stateDir); NEMOCLAW_DEVICE_APPROVAL_STATE: fixture.stateDir, NEMOCLAW_DEVICE_BOOTSTRAP_URL: deviceBootstrapUrl, NEMOCLAW_DEVICE_ID: fixture.deviceId, + NEMOCLAW_DEVICE_TOKEN: String( + requireOperatorToken( + asRecord(fixture.beforePaired[fixture.deviceId]), + `real-dist ${durableSide}-first retry baseline`, + ).token, + ), NEMOCLAW_PUBLIC_KEY: fixture.publicKey, NEMOCLAW_REQUEST_ID: fixture.requestId, OPENCLAW_STATE_DIR: fixture.stateDir, @@ -828,10 +985,12 @@ const requireEnv = (name) => { const stateDir = requireEnv("NEMOCLAW_DEVICE_APPROVAL_STATE"); const pendingPath = requireEnv("NEMOCLAW_PENDING_STATE_PATH"); const pairedPath = requireEnv("NEMOCLAW_PAIRED_STATE_PATH"); +const authPath = requireEnv("NEMOCLAW_AUTH_STATE_PATH"); const journalPath = requireEnv("NEMOCLAW_JOURNAL_PATH"); const canonicalJson = (file) => JSON.stringify(JSON.parse(fs.readFileSync(file, "utf8"))); const pendingBefore = canonicalJson(pendingPath); const pairedBefore = canonicalJson(pairedPath); +const authBefore = canonicalJson(authPath); const promises = fs.promises; const rename = promises.rename.bind(promises); let rejectedOnce = false; @@ -869,6 +1028,7 @@ try { role: "operator", clientId: "cli", clientMode: "cli", + deviceToken: requireEnv("NEMOCLAW_DEVICE_TOKEN"), }, }, stateDir); } catch { @@ -876,12 +1036,12 @@ try { } if (!rejected) throw new Error("injected rename rejection did not reject approval"); if (!delayedCompleted) throw new Error("approval rejected before the sibling rename settled"); -if (canonicalJson(pendingPath) !== pendingBefore || canonicalJson(pairedPath) !== pairedBefore) { +if (canonicalJson(pendingPath) !== pendingBefore || canonicalJson(pairedPath) !== pairedBefore || canonicalJson(authPath) !== authBefore) { throw new Error("rename rejection was not rolled back before approval rejected"); } const journalBeforeList = fs.readFileSync(journalPath, "utf8"); const journal = JSON.parse(journalBeforeList); -if (journal?.version !== 1 || journal?.kind !== "nemoclaw-self-approval" || journal?.phase !== "idle") { +if (journal?.version !== 2 || journal?.kind !== "nemoclaw-self-approval" || journal?.phase !== "idle") { throw new Error("rename rejection did not leave an idle transaction journal"); } await listDevicePairing(stateDir); @@ -889,6 +1049,7 @@ await listDevicePairing(stateDir); if ( canonicalJson(pendingPath) !== pendingBefore || canonicalJson(pairedPath) !== pairedBefore || + canonicalJson(authPath) !== authBefore || fs.readFileSync(journalPath, "utf8") !== journalBeforeList ) throw new Error("idle restart changed the rejected transaction rollback"); `, @@ -898,8 +1059,15 @@ if ( env: { ...process.env, NEMOCLAW_DEVICE_APPROVAL_STATE: fixture.stateDir, + NEMOCLAW_AUTH_STATE_PATH: fixture.authPath, NEMOCLAW_DEVICE_BOOTSTRAP_URL: deviceBootstrapUrl, NEMOCLAW_DEVICE_ID: fixture.deviceId, + NEMOCLAW_DEVICE_TOKEN: String( + requireOperatorToken( + asRecord(fixture.beforePaired[fixture.deviceId]), + "real-dist rejected-rename baseline", + ).token, + ), NEMOCLAW_JOURNAL_PATH: fixture.journalPath, NEMOCLAW_PAIRED_STATE_PATH: fixture.pairedPath, NEMOCLAW_PENDING_STATE_PATH: fixture.pendingPath, @@ -917,6 +1085,7 @@ if ( fixture.beforePaired, "real-dist rejected-rename rollback", ); + requirePairingAuthState(fixture, fixture.beforeAuth, "real-dist rejected-rename rollback"); requireIdlePairingJournal(fixture.journalPath, "real-dist rejected-rename rollback journal"); } @@ -1125,11 +1294,11 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { OPENCLAW_STATE_DIR: stateDir, PATH: `${proofBin}:${inheritedEnv.PATH ?? ""}`, }; - const runCli = (args: string[]) => + const runCli = (args: string[], envOverrides: NodeJS.ProcessEnv = {}) => spawnSync(options.nodeExecutable, [openclawEntry, ...args], { cwd: packageDir, encoding: "utf8", - env, + env: { ...env, ...envOverrides }, timeout: Math.min(options.timeoutMs, 60_000), }); @@ -1204,12 +1373,73 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { "bootstrap paired operator token scopes", ); + proofPhase = "pairing-settlement-list-gateway-restart"; await stopChild(gateway); - proofPhase = "scope-upgrade-trigger"; writeGatewayConfig({ mode: "token" }); gateway = startGateway({ ...env, OPENCLAW_GATEWAY_TOKEN: gatewayToken }, true); await waitForGatewayReady(gateway, port, options.timeoutMs); + proofPhase = "pairing-settlement-list"; + const pendingBeforeSettlementList = fs.readFileSync(pendingPath, "utf8"); + const pairedBeforeSettlementList = fs.readFileSync(pairedPath, "utf8"); + const settlementList = runCli(["devices", "list", "--json"], { + NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT: "1", + OPENCLAW_TEST_RUNTIME_LOG: "1", + }); + proofPhase = `pairing-settlement-list-exit-${settlementList.status ?? "signal"}`; + requireSuccess(settlementList, "list paired state with pairing-only stored device auth"); + proofPhase = "pairing-settlement-list-view"; + const settlementView = asRecord(JSON.parse(settlementList.stdout ?? "null") as unknown); + const settlementPending = settlementView?.pending; + const settlementPaired = settlementView?.paired; + const settlementPairedDevice = asRecord( + Array.isArray(settlementPaired) ? settlementPaired[0] : null, + ); + requireLiveProof( + Array.isArray(settlementPending) && + settlementPending.length === 0 && + Array.isArray(settlementPaired) && + settlementPaired.length === 1 && + settlementPaired.every((value) => asRecord(value) !== null) && + settlementPairedDevice?.deviceId === identity.deviceId, + "pairing settlement list did not return the expected settled pairing records", + ); + requireExactScopes( + settlementPairedDevice?.scopes, + ["operator.pairing"], + "pairing settlement list paired scopes", + ); + proofPhase = "pairing-settlement-list-server-state"; + const pendingAfterSettlementList = fs.readFileSync(pendingPath, "utf8"); + const pairedAfterSettlementList = readJsonObject( + pairedPath, + "real paired state after pairing settlement list", + ); + const pairedDeviceAfterSettlementList = asRecord( + pairedAfterSettlementList[String(identity.deviceId)], + ); + requireLiveProof( + pairedDeviceAfterSettlementList, + "pairing settlement list removed the exact paired CLI device", + ); + proofPhase = "pairing-settlement-list-pending-state"; + requireLiveProof( + pendingAfterSettlementList === pendingBeforeSettlementList, + "pairing settlement list changed canonical pending state bytes", + ); + // Stored-device authentication can update only the gateway's last-seen + // audit fields. Normalize those three optional writes, then require every + // device, token, scope, and remaining metadata field to match the exact + // before-image. + proofPhase = "pairing-settlement-list-authorization-state"; + requireOnlyAuthenticationAuditChanges( + pairedBeforeSettlementList, + pairedAfterSettlementList, + String(identity.deviceId), + "pairing settlement list", + ); + + proofPhase = "scope-upgrade-trigger"; const createSession = runCli([ "gateway", "call", @@ -1255,6 +1485,182 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { "real same-device repair request id missing", ); const requestId = String(repair.requestId); + proofPhase = "pending-pairing-settlement-list"; + const pendingBeforePendingSettlementList = fs.readFileSync(pendingPath, "utf8"); + const pairedBeforePendingSettlementList = fs.readFileSync(pairedPath, "utf8"); + const pendingSettlementList = runCli(["devices", "list", "--json"], { + NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT: "1", + OPENCLAW_TEST_RUNTIME_LOG: "1", + }); + proofPhase = `pending-pairing-settlement-list-exit-${pendingSettlementList.status ?? "signal"}`; + requireSuccess( + pendingSettlementList, + "list pending scope upgrade with pairing-only stored device auth", + ); + proofPhase = "pending-pairing-settlement-list-view"; + const pendingSettlementOutput = String(pendingSettlementList.stdout ?? "").trim(); + let pendingSettlementView: unknown; + try { + pendingSettlementView = JSON.parse(pendingSettlementOutput); + } catch { + const firstObject = pendingSettlementOutput.indexOf("{"); + const lastObject = pendingSettlementOutput.lastIndexOf("}"); + let containsJsonObject = false; + if (firstObject >= 0 && lastObject > firstObject) { + try { + JSON.parse(pendingSettlementOutput.slice(firstObject, lastObject + 1)); + containsJsonObject = true; + } catch { + containsJsonObject = false; + } + } + proofPhase = [ + "pending-pairing-settlement-list-json", + pendingSettlementOutput ? "stdout-present" : "stdout-empty", + String(pendingSettlementList.stderr ?? "").trim() ? "stderr-present" : "stderr-empty", + containsJsonObject ? "json-with-prefix" : "json-absent", + ].join("-"); + throw new Error("pairing settlement list did not return plain JSON"); + } + const pendingSettlementRecord = asRecord(pendingSettlementView); + const visiblePending = pendingSettlementRecord?.pending; + const visiblePaired = pendingSettlementRecord?.paired; + const visiblePendingRequest = asRecord( + Array.isArray(visiblePending) ? visiblePending[0] : null, + ); + const visiblePairedDevice = asRecord(Array.isArray(visiblePaired) ? visiblePaired[0] : null); + proofPhase = "pending-pairing-settlement-list-pending-count"; + requireLiveProof( + Array.isArray(visiblePending) && + visiblePending.length === 1 && + visiblePending.every((value) => asRecord(value) !== null), + "pairing settlement list did not return one expected pending request", + ); + proofPhase = "pending-pairing-settlement-list-request"; + requireLiveProof( + visiblePendingRequest?.requestId === requestId && + visiblePendingRequest?.deviceId === identity.deviceId, + "pairing settlement list did not return the expected pending scope upgrade", + ); + proofPhase = "pending-pairing-settlement-list-paired"; + requireLiveProof( + Array.isArray(visiblePaired) && + visiblePaired.length === 1 && + visiblePaired.every((value) => asRecord(value) !== null) && + visiblePairedDevice?.deviceId === identity.deviceId, + "pairing settlement list did not return the expected paired device", + ); + requireExactScopes( + visiblePairedDevice?.scopes, + ["operator.pairing"], + "pairing settlement list paired scopes during pending upgrade", + ); + proofPhase = "pending-pairing-settlement-list-server-state"; + requireLiveProof( + fs.readFileSync(pendingPath, "utf8") === pendingBeforePendingSettlementList, + "pairing settlement list changed pending scope-upgrade state", + ); + const pairedAfterPendingSettlementList = readJsonObject( + pairedPath, + "real paired state after pending pairing settlement list", + ); + requireOnlyAuthenticationAuditChanges( + pairedBeforePendingSettlementList, + pairedAfterPendingSettlementList, + String(identity.deviceId), + "pending pairing settlement list", + ); + const pendingBeforeOrdinaryApproval = fs.readFileSync(pendingPath, "utf8"); + const pairedBeforeOrdinaryApproval = fs.readFileSync(pairedPath, "utf8"); + const authBeforeOrdinaryApproval = fs.readFileSync(deviceAuthPath, "utf8"); + proofPhase = "ordinary-stored-device-approval"; + const ordinaryApproval = runCli(["devices", "approve", requestId, "--json"]); + requireSuccess(ordinaryApproval, "approve the ordinary stored-device scope upgrade"); + const pendingAfterOrdinaryApproval = readJsonObject( + pendingPath, + "real pending state after ordinary approval", + ); + proofPhase = "ordinary-stored-device-approval-post-state"; + requireLiveProof( + !(requestId in pendingAfterOrdinaryApproval), + "ordinary stored-device approval left the request pending", + ); + const pairedAfterOrdinaryApproval = readJsonObject( + pairedPath, + "real paired state after ordinary approval", + ); + const pairedDeviceAfterOrdinaryApproval = asRecord( + pairedAfterOrdinaryApproval[String(identity.deviceId)], + ); + requireLiveProof( + pairedDeviceAfterOrdinaryApproval, + "ordinary stored-device approval removed the paired device", + ); + requireExactScopes( + pairedDeviceAfterOrdinaryApproval.scopes, + ["operator.pairing", "operator.write"], + "ordinary stored-device approval paired scopes", + ); + const ordinaryPairedOperator = requireOperatorToken( + pairedDeviceAfterOrdinaryApproval, + "ordinary paired device", + ); + const authAfterOrdinaryApproval = readJsonObject( + deviceAuthPath, + "real stored device auth after ordinary approval", + ); + const ordinaryAuthOperator = requireOperatorToken( + authAfterOrdinaryApproval, + "ordinary stored device auth", + ); + requireLiveProof( + authAfterOrdinaryApproval.deviceId === identity.deviceId && + ordinaryAuthOperator.token === ordinaryPairedOperator.token && + ordinaryAuthOperator.token !== storedTokenBefore, + "ordinary stored-device approval did not publish the rotated paired token", + ); + requireExactScopes( + ordinaryPairedOperator.scopes, + ["operator.pairing", "operator.read", "operator.write"], + "ordinary paired operator scopes", + ); + requireExactScopes( + ordinaryAuthOperator.scopes, + ["operator.pairing", "operator.read", "operator.write"], + "ordinary stored-device approval auth scopes", + ); + proofPhase = "ordinary-stored-device-approval-output"; + const ordinaryApprovalOutput = `${String(ordinaryApproval.stdout ?? "")}\n${String(ordinaryApproval.stderr ?? "")}`; + requireLiveProof( + ![gatewayToken, serverTokenBefore, storedTokenBefore, ordinaryPairedOperator.token].some( + (token) => ordinaryApprovalOutput.includes(String(token)), + ), + "ordinary stored-device approval exposed a device or gateway token", + ); + proofPhase = "ordinary-stored-device-approval-client-auth"; + const ordinaryApprovalVerifier = runCli([ + "gateway", + "call", + "sessions.create", + "--params", + "{}", + "--json", + ]); + requireSuccess( + ordinaryApprovalVerifier, + "authorize sessions.create with the rotated stored device token", + ); + if (process.platform !== "linux") return; + proofPhase = "ordinary-stored-device-approval-state-reset"; + await stopChild(gateway); + fs.writeFileSync(pendingPath, pendingBeforeOrdinaryApproval); + fs.writeFileSync(pairedPath, pairedBeforeOrdinaryApproval); + fs.writeFileSync(deviceAuthPath, authBeforeOrdinaryApproval); + gateway = startGateway({ ...env, OPENCLAW_GATEWAY_TOKEN: gatewayToken }, true); + await waitForGatewayReady(gateway, port, options.timeoutMs); + // The remaining restored-clone proof pins inherited /proc/self/fd + // descriptors. Linux CI exercises that boundary; other hosts stop after + // the ordinary approval and matching stored-auth check above. const pendingBeforeApproval = pending; const exactRepair = asRecord(pendingBeforeApproval[requestId]); requireLiveProof( @@ -1309,6 +1715,14 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { fs.readFileSync(pairedPath, "utf8") === clonePairedBefore, "restored-clone matching credential setup changed another clone state file", ); + // The ordinary settlement list above must read the clone's stored device + // credential. Baseline both sentinels after that allowed read so the + // descriptor-only restored-clone approval still proves it performs no + // later pathname-backed auth read. + const cloneAuthReadBaseline = "ordinary-settlement-list-clone-baseline\n"; + const primaryAuthReadBaseline = "ordinary-settlement-list-primary-baseline\n"; + fs.writeFileSync(proofCloneAuthReadMarker, cloneAuthReadBaseline); + fs.writeFileSync(proofPrimaryAuthReadMarker, primaryAuthReadBaseline); proofPhase = "paired-token-repair-approval-process"; const approval = spawnSync("sh", ["-c", pairedTokenApprovalScript], { cwd: packageDir, @@ -1324,19 +1738,27 @@ fs.statSync = function nemoclawProofStatSync(candidate, ...args) { timeout: CONNECT_AUTO_PAIR_TIMEOUT_MS, }); requireLiveProof(approval.status === 0, "restored-clone paired-token approval process failed"); - proofPhase = "paired-token-repair-default-state-race"; - requireLiveProof( - fs.existsSync(proofDefaultStateRaceMarker) && - !fs.existsSync(proofCloneAuthReadMarker) && - !fs.existsSync(proofPrimaryAuthReadMarker), - "forced clone approval used a pathname-backed default or stored-auth credential", - ); const approvalReceipt = parseAutoPairApprovalReceipt(approval.stdout); proofPhase = `paired-token-repair-approval-receipt-${approvalReceipt ?? "invalid"}`; requireLiveProof( approvalReceipt === "approved-one", "restored-clone paired-token approval returned a fixed non-success classification", ); + const defaultStateRaceObserved = fs.existsSync(proofDefaultStateRaceMarker); + const cloneAuthSentinelUnchanged = + fs.readFileSync(proofCloneAuthReadMarker, "utf8") === cloneAuthReadBaseline; + const primaryAuthSentinelUnchanged = + fs.readFileSync(proofPrimaryAuthReadMarker, "utf8") === primaryAuthReadBaseline; + proofPhase = [ + "paired-token-repair-default-state-race", + `default-${defaultStateRaceObserved}`, + `clone-${cloneAuthSentinelUnchanged}`, + `primary-${primaryAuthSentinelUnchanged}`, + ].join("-"); + requireLiveProof( + defaultStateRaceObserved && cloneAuthSentinelUnchanged && primaryAuthSentinelUnchanged, + "forced clone approval used a pathname-backed default or stored-auth credential", + ); proofPhase = "paired-token-repair-approval-stdout-shape"; requireLiveProof( approval.stdout.trim() === "__NEMOCLAW_AUTO_PAIR_RECEIPT__=approved-one", @@ -1557,7 +1979,9 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio const deviceState = path.join(options.tmp, "device-approval-state"); const devicesDir = path.join(deviceState, "devices"); + const identityDir = path.join(deviceState, "identity"); fs.mkdirSync(devicesDir, { recursive: true }); + fs.mkdirSync(identityDir, { recursive: true }); const now = Date.now(); const pending = { "handler-request": { @@ -1635,6 +2059,21 @@ export async function runRealOpenClawDeviceSelfApprovalProof(options: ProofOptio ); fs.writeFileSync(path.join(devicesDir, "pending.json"), JSON.stringify(pending)); fs.writeFileSync(path.join(devicesDir, "paired.json"), JSON.stringify(paired)); + fs.writeFileSync( + path.join(identityDir, "device-auth.json"), + JSON.stringify({ + version: 1, + deviceId: "handler-device", + tokens: { + operator: { + token: "handler-token", + role: "operator", + scopes: ["operator.pairing"], + updatedAtMs: now, + }, + }, + }), + ); const deviceBootstrapFile = path.join(options.dist, "plugin-sdk", "device-bootstrap.js"); const deviceBootstrapSource = fs.readFileSync(deviceBootstrapFile, "utf8"); @@ -1663,6 +2102,7 @@ const { deviceHandlers } = await import(${JSON.stringify(deviceHandlerUrl)}); const { nemoclawResolveApprovePairingScopesForRequest, nemoclawResolveSelfRepairPairingContext } = await import(${JSON.stringify(cliProofUrl)}); const stateDir = process.env.NEMOCLAW_DEVICE_APPROVAL_STATE; const distDir = process.env.NEMOCLAW_OPENCLAW_DIST; +const authPath = path.join(stateDir, "identity", "device-auth.json"); const pairingFiles = fs.readdirSync(distDir).filter((name) => /^device-pairing-.*[.]js$/.test(name)); if (pairingFiles.length !== 1) throw new Error(\`expected one device-pairing runtime, found \${pairingFiles.length}\`); const pairingRuntime = await import(pathToFileURL(path.join(distDir, pairingFiles[0])).href); @@ -1673,7 +2113,17 @@ const identity = (suffix) => ({ role: "operator", clientId: "cli", clientMode: "cli", + deviceToken: \`token-\${suffix}\`, }); +const writeDeviceAuth = (deviceId, token, scopes = ["operator.pairing"]) => { + fs.writeFileSync(authPath, JSON.stringify({ + version: 1, + deviceId, + tokens: { + operator: { token, role: "operator", scopes, updatedAtMs: Date.now() }, + }, + })); +}; const coldCloneDevice = { deviceId: "cold-clone-device", publicKey: "cold-clone-public-key", @@ -1815,6 +2265,7 @@ const handlerClient = (overrides = {}) => ({ connect: { role: "operator", scopes: ["operator.pairing"], + auth: { token: "handler-token" }, device: { id: "handler-device", publicKey: "handler-public-key" }, client: { id: "cli", mode: "cli" }, }, @@ -1828,6 +2279,7 @@ const crossDeviceResponse = await invokeHandler(handlerClient({ connect: { role: "operator", scopes: ["operator.pairing"], + auth: { token: "handler-token" }, device: { id: "other-device", publicKey: "other-public-key" }, client: { id: "cli", mode: "cli" }, }, @@ -1837,6 +2289,12 @@ const handlerResponse = await invokeHandler(handlerClient()); if (handlerResponse?.ok !== true) throw new Error("device-token handler approval failed"); handlerState = JSON.parse(fs.readFileSync(path.join(stateDir, "devices", "paired.json"), "utf8")); if (handlerState["handler-device"]?.tokens?.operator?.token === "handler-token") throw new Error("handler did not run canonical token rotation"); +const handlerAuth = JSON.parse(fs.readFileSync(authPath, "utf8")); +if ( + handlerAuth.deviceId !== "handler-device" || + handlerAuth.tokens?.operator?.token !== handlerState["handler-device"]?.tokens?.operator?.token || + !hasExactScopes(handlerAuth.tokens?.operator?.scopes, ["operator.pairing", "operator.read", "operator.write"]) +) throw new Error("handler did not publish matching stored device auth"); if (handlerBroadcasts.length !== 1) throw new Error("handler did not broadcast exactly one successful approval"); if (handlerResponses.length !== 3) throw new Error("handler did not respond exactly once per request"); const denied = await approveDevicePairing("request-1", { @@ -1844,6 +2302,7 @@ const denied = await approveDevicePairing("request-1", { nemoclawSelfApprovalIdentity: identity("wrong"), }, stateDir); if (denied?.status !== "forbidden") throw new Error("mismatched identity was not denied"); +writeDeviceAuth("device-1", "token-1"); const [first, _inserted, _updated, second] = await Promise.all([ approveDevicePairing("request-1", { callerScopes: ["operator.pairing"], @@ -1860,8 +2319,7 @@ const [first, _inserted, _updated, second] = await Promise.all([ }, stateDir), pairingRuntime.v("device-3", { displayName: "concurrent-update" }, stateDir), approveDevicePairing("request-2", { - callerScopes: ["operator.pairing"], - nemoclawSelfApprovalIdentity: identity("2"), + callerScopes: ["operator.admin"], }, stateDir), ]); if (first?.status !== "approved" || second?.status !== "approved") throw new Error("concurrent canonical approvals failed"); @@ -1874,6 +2332,12 @@ if (pairedAfter["device-3"]?.displayName !== "concurrent-update") throw new Erro if (pairedAfter["device-1"]?.tokens?.operator?.token === "token-1") throw new Error("canonical token rotation did not run"); const scopes = pairedAfter["device-1"]?.tokens?.operator?.scopes ?? []; if (!["operator.pairing", "operator.read", "operator.write"].every((scope) => scopes.includes(scope))) throw new Error("bounded write scope closure missing"); +const authAfter = JSON.parse(fs.readFileSync(authPath, "utf8")); +if ( + authAfter.deviceId !== "device-1" || + authAfter.tokens?.operator?.token !== pairedAfter["device-1"]?.tokens?.operator?.token || + !hasExactScopes(authAfter.tokens?.operator?.scopes, ["operator.pairing", "operator.read", "operator.write"]) +) throw new Error("concurrent self-approval did not publish matching stored device auth"); `, ], { @@ -1894,6 +2358,7 @@ if (!["operator.pairing", "operator.read", "operator.write"].every((scope) => sc } runPairingCrashDirectionProof(options, deviceBootstrapUrl, journalBasename, "pending"); runPairingCrashDirectionProof(options, deviceBootstrapUrl, journalBasename, "paired"); + runPairingCrashDirectionProof(options, deviceBootstrapUrl, journalBasename, "auth"); runRejectedRenameRollbackProof(options, deviceBootstrapUrl, journalBasename); await runLiveStoredDeviceAuthSelfApprovalProof(options); } diff --git a/test/helpers/rebuild-flow-dcode-harness.ts b/test/helpers/rebuild-flow-dcode-harness.ts index 5afb50d3ea2..c1b2f8fab2f 100644 --- a/test/helpers/rebuild-flow-dcode-harness.ts +++ b/test/helpers/rebuild-flow-dcode-harness.ts @@ -68,6 +68,7 @@ export type RebuildFlowOverrides = { error?: Error; }; executeSandboxCommand?: () => { status: number; stdout: string; stderr: string } | null; + executeSandboxExecCommand?: () => { status: number; stdout: string; stderr: string } | null; checkAndRecoverSandboxProcesses?: () => { checked: boolean; wasRunning: boolean | null; @@ -141,6 +142,7 @@ export type RebuildFlowHarness = { restoreTrustedAgentBaseImageOverrideSpy: MockInstance; restoreTrustedAgentRemoteBaseImageOverrideSpy: MockInstance; executeSandboxCommandSpy: MockInstance; + executeSandboxExecCommandSpy: MockInstance; checkAndRecoverSandboxProcessesSpy: MockInstance; restartSandboxGatewaySpy: MockInstance; ensureMessagingHostForwardAfterRebuildSpy: MockInstance; @@ -662,6 +664,12 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): .mockImplementation( overrides.executeSandboxCommand ?? (() => ({ status: 0, stdout: "doctor ok", stderr: "" })), ); + const executeSandboxExecCommandSpy = vi + .spyOn(processRecovery, "executeSandboxExecCommand") + .mockImplementation( + overrides.executeSandboxExecCommand ?? + (() => ({ status: 0, stdout: "doctor ok", stderr: "" })), + ); const checkAndRecoverSandboxProcessesSpy = vi .spyOn(processRecovery, "checkAndRecoverSandboxProcesses") .mockImplementation( @@ -739,6 +747,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): restoreTrustedAgentBaseImageOverrideSpy, restoreTrustedAgentRemoteBaseImageOverrideSpy, executeSandboxCommandSpy, + executeSandboxExecCommandSpy, checkAndRecoverSandboxProcessesSpy, restartSandboxGatewaySpy, ensureMessagingHostForwardAfterRebuildSpy, diff --git a/test/helpers/rebuild-flow-generic-harness.ts b/test/helpers/rebuild-flow-generic-harness.ts index 0b2c467216e..4dc7812dac9 100644 --- a/test/helpers/rebuild-flow-generic-harness.ts +++ b/test/helpers/rebuild-flow-generic-harness.ts @@ -587,6 +587,12 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): .mockImplementation( overrides.executeSandboxCommand ?? (() => ({ status: 0, stdout: "doctor ok", stderr: "" })), ); + const executeSandboxExecCommandSpy = vi + .spyOn(processRecovery, "executeSandboxExecCommand") + .mockImplementation( + overrides.executeSandboxExecCommand ?? + (() => ({ status: 0, stdout: "doctor ok", stderr: "" })), + ); const checkAndRecoverSandboxProcessesSpy = vi .spyOn(processRecovery, "checkAndRecoverSandboxProcesses") .mockImplementation( @@ -658,6 +664,7 @@ export function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): restartSandboxGatewaySpy, errorSpy, executeSandboxCommandSpy, + executeSandboxExecCommandSpy, ensureMessagingHostForwardAfterRebuildSpy, ensureRebuildAgentBaseImageSpy, ensureTargetGatewaySpy, diff --git a/test/helpers/rebuild-flow-test-support.ts b/test/helpers/rebuild-flow-test-support.ts index b01e15a1797..57b2dd7e9f5 100644 --- a/test/helpers/rebuild-flow-test-support.ts +++ b/test/helpers/rebuild-flow-test-support.ts @@ -14,7 +14,7 @@ import type { PreservedEnvFile } from "../../src/lib/state/preserved-env"; import type { SandboxRemovalReceipt } from "../../src/lib/state/registry"; export type RebuildSandbox = - typeof import("../../src/lib/actions/sandbox/rebuild")["rebuildSandbox"]; + (typeof import("../../src/lib/actions/sandbox/rebuild"))["rebuildSandbox"]; export type RebuildFlowStep = { status: string; startedAt: string | null; @@ -43,6 +43,7 @@ export type RebuildFlowOverrides = { disposeImageRef?: () => boolean; }; executeSandboxCommand?: () => { status: number; stdout: string; stderr: string } | null; + executeSandboxExecCommand?: () => { status: number; stdout: string; stderr: string } | null; checkAndRecoverSandboxProcesses?: () => { checked: boolean; wasRunning: boolean | null; @@ -137,6 +138,7 @@ export type RebuildFlowHarness = { restartSandboxGatewaySpy: MockInstance; errorSpy: MockInstance; executeSandboxCommandSpy: MockInstance; + executeSandboxExecCommandSpy: MockInstance; ensureMessagingHostForwardAfterRebuildSpy: MockInstance; ensureRebuildAgentBaseImageSpy: MockInstance; ensureTargetGatewaySpy: MockInstance; diff --git a/test/nemoclaw-start-auto-pair-bootstrap.test.ts b/test/nemoclaw-start-auto-pair-bootstrap.test.ts index ff4c5caea7b..89ad8856732 100644 --- a/test/nemoclaw-start-auto-pair-bootstrap.test.ts +++ b/test/nemoclaw-start-auto-pair-bootstrap.test.ts @@ -139,6 +139,12 @@ exit 2 }); expect(run.status).toBe(0); + expect(run.stdout).toContain("[auto-pair] stage=listing failed reason=pairing-required"); + expect(run.stdout).toContain("[auto-pair] stage=request-creation observed request=request-1"); + expect(run.stdout).toContain( + "[auto-pair] stage=validation accepted request=request-1 reason=allowlisted-initial-cli", + ); + expect(run.stdout).toContain("[auto-pair] stage=approval attempting request=request-1"); expect(run.stdout).toContain("[auto-pair] approved initial CLI pairing request=request-1"); expect(JSON.parse(fs.readFileSync(approveLog, "utf-8"))).toEqual({ url: null, @@ -262,6 +268,9 @@ exit 2 expect(run.status).toBe(0); expect(run.stdout).not.toContain("approved initial CLI pairing"); + expect(run.stdout).toContain( + "[auto-pair] stage=validation rejected request=request-1 reason=not-allowlisted", + ); expect(fs.existsSync(approveLog)).toBe(false); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -296,6 +305,7 @@ exit 2 "quoted request id with trailing text", 'pairing required: device is not approved yet requestId: "request" 1', ], + ["option-like request id", "pairing required: device is not approved yet requestId: --help"], ])( "rejects %s from gated-list errors before initial CLI approve (#6113)", (_name, listError) => { @@ -337,6 +347,7 @@ exit 2 ["p".repeat(128)]: { requestId: "p".repeat(128), ...validRequest }, "request 1": { requestId: "request 1", ...validRequest }, request: { requestId: "request", ...validRequest }, + "--help": { requestId: "--help", ...validRequest }, }), ); fs.writeFileSync( @@ -371,6 +382,7 @@ exit 2 expect(run.status).toBe(0); expect(run.stdout).not.toContain("approved initial CLI pairing"); + expect(run.stdout).not.toContain("--help"); expect(fs.existsSync(approveLog)).toBe(false); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -540,45 +552,50 @@ exit 2 } }, 40_000); - it("retries a transient initial CLI approve failure on the next gated-list poll (#6113)", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-retry-")); - const fakeOpenclaw = path.join(tmpDir, "openclaw"); - const stateDir = path.join(tmpDir, "state"); - const devicesDir = path.join(stateDir, "devices"); - const identityDir = path.join(stateDir, "identity"); - const pendingFile = path.join(devicesDir, "pending.json"); - const approveCount = path.join(tmpDir, "approve-count"); - fs.mkdirSync(devicesDir, { recursive: true }); - fs.mkdirSync(identityDir, { recursive: true }); - const publicKey = "y3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8"; - const deviceId = "04a4c561c730435e9f6a2e38d2e7b929bcbec2ea1c37d3dd053f3341ecce4e47"; - fs.writeFileSync( - path.join(identityDir, "device.json"), - JSON.stringify({ - deviceId, - publicKeyPem: - "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAy3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8=\n-----END PUBLIC KEY-----\n", - }), - ); - fs.writeFileSync( - pendingFile, - JSON.stringify({ - "request-1": { - requestId: "request-1", + it.each([ + ["command failure", 'echo "gateway restarting" >&2\n exit 1', "command-failed", "10"], + ["timeout", "sleep 1", "timeout", "0.5"], + ])( + "retries a transient initial CLI approve %s on the next gated-list poll (#6113)", + (_name, firstAction, failureReason, runTimeoutSeconds) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-retry-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const stateDir = path.join(tmpDir, "state"); + const devicesDir = path.join(stateDir, "devices"); + const identityDir = path.join(stateDir, "identity"); + const pendingFile = path.join(devicesDir, "pending.json"); + const approveCount = path.join(tmpDir, "approve-count"); + fs.mkdirSync(devicesDir, { recursive: true }); + fs.mkdirSync(identityDir, { recursive: true }); + const publicKey = "y3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8"; + const deviceId = "04a4c561c730435e9f6a2e38d2e7b929bcbec2ea1c37d3dd053f3341ecce4e47"; + fs.writeFileSync( + path.join(identityDir, "device.json"), + JSON.stringify({ deviceId, - publicKey, - clientId: "cli", - clientMode: "cli", - role: "operator", - roles: ["operator"], - scopes: ["operator.pairing"], - ts: 100, - }, - }), - ); - fs.writeFileSync( - fakeOpenclaw, - `#!/usr/bin/env bash + publicKeyPem: + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAy3vjb9p8tAecivI1l5f1Hdc9QdZJSt3BmLkJMM7wZD8=\n-----END PUBLIC KEY-----\n", + }), + ); + fs.writeFileSync( + pendingFile, + JSON.stringify({ + "request-1": { + requestId: "request-1", + deviceId, + publicKey, + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.pairing"], + ts: 100, + }, + }), + ); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash set -euo pipefail if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then printf '%s\\n' '{"ok":false,"error":{"reason":"pairing required: device is not approved yet (requestId: request-1)"}}' @@ -592,39 +609,39 @@ if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then count=$((count + 1)) printf '%s' "$count" > ${JSON.stringify(approveCount)} if [ "$count" -eq 1 ]; then - echo "gateway restarting" >&2 - exit 1 + ${firstAction} fi exit 0 fi exit 2 `, - { mode: 0o755 }, - ); + { mode: 0o755 }, + ); - try { - const run = spawnSync("python3", ["-c", autoPairPythonScript(src, tmpDir)], { - encoding: "utf-8", - env: { - ...process.env, - OPENCLAW_BIN: fakeOpenclaw, - OPENCLAW_STATE_DIR: stateDir, - NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", - NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", - }, - timeout: 30_000, - }); + try { + const run = spawnSync("python3", ["-c", autoPairPythonScript(src, tmpDir)], { + encoding: "utf-8", + env: { + ...process.env, + OPENCLAW_BIN: fakeOpenclaw, + OPENCLAW_STATE_DIR: stateDir, + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", + NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: runTimeoutSeconds, + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", + }, + timeout: 30_000, + }); - expect(run.status).toBe(0); - expect(fs.readFileSync(approveCount, "utf-8")).toBe("2"); - expect(run.stdout).toContain( - "[auto-pair] initial CLI approve failed request=request-1: gateway restarting", - ); - expect(run.stdout).toContain("[auto-pair] approved initial CLI pairing request=request-1"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }, 40_000); + expect(run.status).toBe(0); + expect(fs.readFileSync(approveCount, "utf-8")).toBe("2"); + expect(run.stdout).toContain(`[auto-pair] stage=approval failed reason=${failureReason}`); + expect(run.stdout).toContain("[auto-pair] approved initial CLI pairing request=request-1"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + 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 @@ -706,6 +723,7 @@ exit 2 expect(run.stdout).toContain( "[auto-pair] initial CLI approve failed request=request-1: gateway permanently unavailable", ); + expect(run.stdout).toContain("[auto-pair] stage=approval failed reason=command-failed"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -779,4 +797,331 @@ exit 1 fs.rmSync(tmpDir, { recursive: true, force: true }); } }, 40_000); + + it("reports the request-creation stage while a valid device list stays empty (#9844)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-empty-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +printf '%s\\n' '{"pending":[],"paired":[]}' +`, + { mode: 0o755 }, + ); + + try { + const run = spawnSync("python3", ["-c", autoPairPythonScript(src, tmpDir)], { + encoding: "utf-8", + env: { + ...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, + }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("[auto-pair] stage=request-creation waiting reason=no-request"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it.each([ + ["top-level array", "[]"], + ["non-array pending", '{"pending":{},"paired":[]}'], + ["non-array paired", '{"pending":[],"paired":"device"}'], + ["null pending", '{"pending":null,"paired":[]}'], + ["null paired", '{"pending":[],"paired":null}'], + ["missing pending", '{"paired":[]}'], + ["missing paired", '{"pending":[]}'], + ])("rejects a valid JSON response with %s (#9844)", (_name, response) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-shape-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const approvalMarker = path.join(tmpDir, "approval-called"); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + touch ${JSON.stringify(approvalMarker)} +fi +printf '%s\\n' ${JSON.stringify(response)} +`, + { mode: 0o755 }, + ); + + try { + const run = spawnSync("python3", ["-c", autoPairPythonScript(src, tmpDir)], { + encoding: "utf-8", + env: { + ...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, + }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("[auto-pair] stage=listing failed reason=invalid-response"); + expect(fs.existsSync(approvalMarker)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("keeps forced CLI pairing until a validated paired CLI record appears (#9844)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-bootstrap-state-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const listCount = path.join(tmpDir, "list-count"); + const listEnv = path.join(tmpDir, "list-env"); + const approveEnv = path.join(tmpDir, "approve-env"); + const approvalMarker = path.join(tmpDir, "approval-called"); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +set -euo pipefail +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "list" ]; then + count=0 + if [ -f ${JSON.stringify(listCount)} ]; then count=$(cat ${JSON.stringify(listCount)}); fi + count=$((count + 1)) + printf '%s' "$count" > ${JSON.stringify(listCount)} + printf '%s:%s:%s\n' "\${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING:-}" "\${OPENCLAW_GATEWAY_TOKEN:-}" "\${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT:-}" >> ${JSON.stringify(listEnv)} + if [ "$count" -eq 1 ]; then + printf '%s\n' '{"pending":null,"paired":[]}' + elif [ "$count" -eq 2 ]; then + printf '%s\n' '{"pending":[{"requestId":"request-1","clientId":"cli","clientMode":"cli","role":"operator","roles":["operator"],"scopes":["operator.pairing"]}],"paired":[]}' + elif [ "$count" -eq 3 ]; then + printf '%s\n' '{"pending":[],"paired":[{"clientId":"not-cli","clientMode":"cli"}]}' + else + printf '%s\n' '{"pending":[],"paired":[{"clientId":"cli","clientMode":"cli"}]}' + fi + exit 0 +fi +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + printf '%s:%s:%s\n' "\${NEMOCLAW_OPENCLAW_FORCE_DEVICE_PAIRING:-}" "\${OPENCLAW_GATEWAY_TOKEN:-}" "\${NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT:-}" >> ${JSON.stringify(approveEnv)} + touch ${JSON.stringify(approvalMarker)} + exit 0 +fi +exit 2 +`, + { mode: 0o755 }, + ); + + try { + const run = spawnSync("python3", ["-c", autoPairPythonScript(src, tmpDir)], { + encoding: "utf-8", + env: { + ...process.env, + OPENCLAW_BIN: fakeOpenclaw, + OPENCLAW_GATEWAY_TOKEN: "gateway-token", + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", + }, + timeout: 10_000, + }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain("[auto-pair] stage=listing failed reason=invalid-response"); + expect(run.stdout).toContain("[auto-pair] approved request=request-1 client=cli mode=cli"); + expect(run.stdout).toContain("[auto-pair] loopback CLI pairing bootstrap completed"); + expect(fs.existsSync(approvalMarker)).toBe(true); + const environments = fs.readFileSync(listEnv, "utf-8").trim().split("\n"); + expect(environments.slice(0, 4)).toEqual([ + "1:gateway-token:", + "1:gateway-token:", + "1:gateway-token:", + "1:gateway-token:", + ]); + expect(environments[4]).toBe("::1"); + expect(fs.readFileSync(approveEnv, "utf-8").trim()).toBe("::"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it.each([ + ["object", { value: "object-request-secret" }, "object-request-secret"], + ["array", ["array-request-secret"], "array-request-secret"], + ["number", 927461835, "927461835"], + ["newline", "line\nnewline-request-secret", "newline-request-secret"], + ["overlong", "x".repeat(129), "x".repeat(40)], + ["option-like", "--help", "--help"], + ])( + "rejects a malformed %s request ID without approval or disclosure (#9844)", + (_name, requestId, secretMarker) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-request-id-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const approvalMarker = path.join(tmpDir, "approval-called"); + const response = JSON.stringify({ + pending: [ + { + requestId, + clientId: "cli", + clientMode: "cli", + role: "operator", + roles: ["operator"], + scopes: ["operator.pairing"], + }, + ], + paired: [], + }); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + touch ${JSON.stringify(approvalMarker)} +fi +printf '%s\n' ${JSON.stringify(response)} +`, + { mode: 0o755 }, + ); + + try { + const run = spawnSync("python3", ["-c", autoPairPythonScript(src, tmpDir)], { + encoding: "utf-8", + env: { + ...process.env, + OPENCLAW_BIN: fakeOpenclaw, + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", + }, + timeout: 10_000, + }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain( + "[auto-pair] stage=validation rejected reason=malformed-request-id", + ); + expect(run.stdout).toContain( + "[auto-pair] stage=request-creation waiting reason=no-request", + ); + expect(`${run.stdout}\n${run.stderr}`).not.toContain(secretMarker); + expect(fs.existsSync(approvalMarker)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + + it("enters slow mode for a paired CLI record when all pending request IDs are malformed (#9844)", () => { + 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"); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +if [ "\${1:-}" = "devices" ] && [ "\${2:-}" = "approve" ]; then + touch ${JSON.stringify(approvalMarker)} +fi +printf '%s\n' '{"pending":[{"requestId":"--help","clientId":"cli","clientMode":"cli"}],"paired":[{"clientId":"cli","clientMode":"cli"}]}' +`, + { mode: 0o755 }, + ); + + try { + const run = spawnSync("python3", ["-c", autoPairPythonScript(src, tmpDir)], { + encoding: "utf-8", + env: { + ...process.env, + OPENCLAW_BIN: fakeOpenclaw, + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", + }, + timeout: 10_000, + }); + + expect(run.status).toBe(0); + expect(run.stdout).toContain( + "[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("--help"); + expect(fs.existsSync(approvalMarker)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("forgets request diagnostics after the gateway removes the request (#9844)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-request-prune-")); + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + const listCount = path.join(tmpDir, "list-count"); + fs.writeFileSync( + fakeOpenclaw, + `#!/usr/bin/env bash +set -euo pipefail +count=0 +if [ -f ${JSON.stringify(listCount)} ]; then count=$(cat ${JSON.stringify(listCount)}); fi +count=$((count + 1)) +printf '%s' "$count" > ${JSON.stringify(listCount)} +if [ "$count" -eq 1 ] || [ "$count" -eq 3 ]; then + printf '%s\n' '{"pending":[{"requestId":"reused-request","clientId":"unknown","clientMode":"unknown","role":"operator","roles":["operator"],"scopes":["operator.pairing"]},{"requestId":{"secret":"malformed-request-secret"},"clientId":"cli","clientMode":"cli"}],"paired":[]}' +else + printf '%s\n' '{"pending":[],"paired":[]}' +fi +`, + { mode: 0o755 }, + ); + + try { + const run = spawnSync("python3", ["-c", autoPairPythonScript(src, tmpDir)], { + encoding: "utf-8", + env: { + ...process.env, + OPENCLAW_BIN: fakeOpenclaw, + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "2", + NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "1", + }, + timeout: 10_000, + }); + + expect(run.status).toBe(0); + expect( + run.stdout.match(/stage=request-creation observed request=reused-request/g), + ).toHaveLength(2); + expect( + run.stdout.match(/stage=validation rejected request=reused-request reason=unknown-client/g), + ).toHaveLength(2); + expect( + run.stdout.match(/stage=validation rejected reason=malformed-request-id/g), + ).toHaveLength(2); + expect(`${run.stdout}\n${run.stderr}`).not.toContain("malformed-request-secret"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("reports a fixed watcher-execution stage without raw exception details (#9844)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-auto-pair-exception-")); + const writablePolicy = path.join(tmpDir, "openclaw_device_approval_policy.py"); + fs.writeFileSync(writablePolicy, "def approval_request_decision(_device): return {}\n", { + 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(writablePolicy)}`, + ); + + try { + const run = spawnSync("python3", ["-c", script], { + encoding: "utf-8", + env: { ...process.env, OPENCLAW_BIN: "/bin/false" }, + timeout: 10_000, + }); + + expect(run.status).toBe(1); + expect(run.stdout).toContain("[auto-pair] stage=watcher-execution failed error=RuntimeError"); + expect(run.stdout).not.toContain(writablePolicy); + expect(run.stderr).toBe(""); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 0bd435cd741..1e5d8b92bf6 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -1409,7 +1409,7 @@ describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { }); expect(run.status).toBe(1); - expect(run.stderr).toContain("approval policy helper is writable by the current user"); + expect(run.stdout).toContain("[auto-pair] stage=watcher-execution failed error=RuntimeError"); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -1433,7 +1433,7 @@ describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { }); const pairedJson = JSON.stringify({ pending: [], - paired: [{ clientId: "openclaw-control-ui", clientMode: "webchat" }], + paired: [{ clientId: "openclaw-control-ui", clientMode: "webchat" }, { clientId: "cli", clientMode: "cli" }], }); fs.writeFileSync( fakeOpenclaw, diff --git a/test/onboard-fsm-live-slices.test.ts b/test/onboard-fsm-live-slices.test.ts index 9ee309988ed..b755cdcb32f 100644 --- a/test/onboard-fsm-live-slices.test.ts +++ b/test/onboard-fsm-live-slices.test.ts @@ -224,8 +224,7 @@ const sentinel = new Error("slice-called"); if (scenario.mode === "dashboard-port-composition") { const finalizationHandlerDeps = require(${finalizationDepsPath}).finalizationHandlerDeps; finalizationHandlerDeps.checkAndRecoverSandboxProcesses = () => undefined; - finalizationHandlerDeps.warmupScopeUpgrade = () => undefined; - finalizationHandlerDeps.autoPairScopeApproval = () => undefined; + finalizationHandlerDeps.settleOrdinaryOpenClawPairing = async () => ({ kind: "settled" }); const onboardDashboard = require(${onboardDashboardPath}); const createOnboardDashboardHelpers = onboardDashboard.createOnboardDashboardHelpers; let dashboardForwardCalls = 0; diff --git a/test/openclaw-device-self-approval-auth-scopes.test.ts b/test/openclaw-device-self-approval-auth-scopes.test.ts new file mode 100644 index 00000000000..f1cb40f375f --- /dev/null +++ b/test/openclaw-device-self-approval-auth-scopes.test.ts @@ -0,0 +1,127 @@ +// 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"; + +import { describe, expect, it } from "vitest"; + +import { + type PairingFixtureRuntime, + runFixture, + runPatch, + selfApprovalOptions, + selfApprovalTransactionJournal as transactionJournal, + selfApprovalTransactionSnapshots as transactionSnapshots, + writeFixtureDist, +} from "./helpers/openclaw-device-self-approval-patch-harness"; + +function openPatchedPairingFixture(): { runtime: PairingFixtureRuntime; tmp: string } { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-auth-scope-runtime-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeFixtureDist(dist); + const apply = runPatch(dist); + expect(apply.status, `${apply.stdout}${apply.stderr}`).toBe(0); + const source = fs.readFileSync(path.join(dist, "device-pairing-fixture.js"), "utf8"); + const runtime = runFixture( + source, + `({ + writes, + setPairingState, + setFile, + getFile, + getPairingPaths, + listDevicePairing, + getPairedDevice, + getPendingDevicePairing, + approveDevicePairing, + approveBootstrapDevicePairing, + armLateWriterFailure, + releaseLateWriter, + armCommittedJournalFailure, + armIdleJournalFailure, + armStateDrift + })`, + ); + return { runtime, tmp }; +} + +describe("OpenClaw self-approval stored-auth scope validation (#4462)", () => { + it.each([ + [ + "mismatched before", + "before", + ["operator.pairing", "operator.read"], + ["operator.pairing"], + "invalid NemoClaw self-approval journal transition", + ], + [ + "unknown after", + "after", + ["operator.pairing", "operator.unknown"], + ["operator.pairing", "operator.unknown"], + "invalid NemoClaw self-approval journal snapshots", + ], + [ + "duplicate after", + "after", + ["operator.pairing", "operator.pairing"], + ["operator.pairing", "operator.pairing"], + "invalid NemoClaw self-approval journal snapshots", + ], + ] as const)( + "rejects %s paired scopes in a stored transaction journal", + async (_case, side, pairedScopes, authScopes, expectedError) => { + const { runtime, tmp } = openPatchedPairingFixture(); + try { + const snapshots = transactionSnapshots(); + const { journalPath } = runtime.getPairingPaths(); + const journal = transactionJournal("prepared", snapshots); + const snapshot = journal[side]; + const pairedDevice = snapshot.pairedByDeviceId["device-1"] as unknown as { + tokens: { operator: { scopes: string[] } }; + }; + pairedDevice.tokens.operator.scopes = [...pairedScopes]; + snapshot.auth.tokens.operator.scopes = [...authScopes]; + runtime.setPairingState(snapshots.before.pendingById, snapshots.before.pairedByDeviceId); + runtime.setFile(journalPath, journal); + + await expect(runtime.listDevicePairing()).rejects.toThrow(expectedError); + expect(runtime.getFile(journalPath)).toEqual(journal); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + ); + + it.each([ + ["mismatched", ["operator.pairing", "operator.read"], "stored device auth changed"], + ["unknown", ["operator.pairing", "operator.unknown"], "invalid device pairing state"], + ["duplicate", ["operator.pairing", "operator.pairing"], "invalid device pairing state"], + ] as const)( + "rejects %s stored-auth scopes before preparing a journal", + async (_case, scopes, expectedError) => { + const { runtime, tmp } = openPatchedPairingFixture(); + try { + const snapshots = transactionSnapshots(); + const paths = runtime.getPairingPaths(); + runtime.setPairingState(snapshots.before.pendingById, snapshots.before.pairedByDeviceId); + runtime.armStateDrift(paths.authPath, { + ...snapshots.before.auth, + tokens: { + operator: { token: "token-before", role: "operator", scopes: [...scopes] }, + }, + }); + + await expect( + runtime.approveDevicePairing("request-1", selfApprovalOptions(), "/fixture"), + ).rejects.toThrow(expectedError); + expect(runtime.getFile(paths.journalPath)).toBeNull(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/test/openclaw-device-self-approval-patch-upgrade.test.ts b/test/openclaw-device-self-approval-patch-upgrade.test.ts index d023bf11b30..3aa914c3502 100644 --- a/test/openclaw-device-self-approval-patch-upgrade.test.ts +++ b/test/openclaw-device-self-approval-patch-upgrade.test.ts @@ -10,6 +10,36 @@ import { describe, expect, it } from "vitest"; import { runPatch, writeFixtureDist } from "./helpers/openclaw-device-self-approval-patch-harness"; describe("OpenClaw device self-approval patch upgrades (#4462)", () => { + it("adds pairing-only stored auth to an earlier patched settlement list (#9844)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-list-upgrade-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeFixtureDist(dist); + try { + expect(runPatch(dist).status).toBe(0); + const file = path.join(dist, "devices-cli.runtime-fixture.js"); + const current = [ + "async function listPairingWithFallback(opts, callOpts) { // nemoclaw: preflight bounded stored device auth before live pairing list (#4462)", + '\tconst nemoclawSettlementListCallOpts = process.env.NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT === "1" ? {', + "\t\tscopes: [PAIRING_SCOPE],", + "\t\tuseStoredDeviceAuth: true,", + "\t\trequiredStoredDeviceAuthScopes: [PAIRING_SCOPE]", + "\t} : void 0; // nemoclaw: use stored device auth for pairing settlement list (#9844)", + "\tcallOpts ??= nemoclawSettlementListCallOpts;", + ].join("\n"); + const legacy = current.split("\n")[0] as string; + const source = fs.readFileSync(file, "utf8"); + expect(source).toContain(current); + fs.writeFileSync(file, source.replace(current, legacy)); + + expect(runPatch(dist).status).toBe(0); + expect(fs.readFileSync(file, "utf8")).toContain(current); + expect(runPatch(dist).status).toBe(0); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("migrates the restored-clone mode from the force flag", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-clone-mode-upgrade-")); const dist = path.join(tmp, "dist"); diff --git a/test/openclaw-device-self-approval-patch.test.ts b/test/openclaw-device-self-approval-patch.test.ts index c5ffc85aebc..d385798f721 100644 --- a/test/openclaw-device-self-approval-patch.test.ts +++ b/test/openclaw-device-self-approval-patch.test.ts @@ -8,53 +8,18 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + type PairingFixtureRuntime, runFixture, runPatch, + selfApprovalOptions, + selfApprovalTransactionJournal as transactionJournal, + selfApprovalTransactionSnapshots as transactionSnapshots, validClient, validPaired, validPending, writeFixtureDist, } from "./helpers/openclaw-device-self-approval-patch-harness"; -interface PairingFixtureRuntime { - writes: Array<{ file: string; value: unknown; options?: Record }>; - setPairingState( - pendingById: Record, - pairedByDeviceId: Record, - baseDir?: string, - ): void; - setFile(file: string, value: unknown): void; - getFile(file: string): unknown; - getPairingPaths(baseDir?: string): { - pendingPath: string; - pairedPath: string; - journalPath: string; - }; - listDevicePairing(baseDir?: string): Promise<{ - pending: Array>; - paired: Array>; - }>; - getPairedDevice(deviceId: string, baseDir?: string): Promise | null>; - getPendingDevicePairing( - requestId: string, - baseDir?: string, - ): Promise | null>; - approveDevicePairing( - requestId: string, - options: Record, - baseDir?: string, - ): Promise | null>; - approveBootstrapDevicePairing( - requestId: string, - bootstrapProfile: Record, - baseDir?: string, - ): Promise | null>; - armLateWriterFailure(): Promise; - releaseLateWriter(): void; - armCommittedJournalFailure(): void; - armStateDrift(file: string, value: unknown): void; -} - interface CliFixtureRuntime { gatewayCalls: Array>; setPairingLists(local: Record, live?: Record): void; @@ -127,72 +92,13 @@ function openPatchedPairingFixture(): { armLateWriterFailure, releaseLateWriter, armCommittedJournalFailure, + armIdleJournalFailure, armStateDrift })`, ); return { runtime, source, tmp }; } -function transactionSnapshots() { - const pending = validPending({ ts: 100 }); - const pairedBefore = validPaired({ - approvedAtMs: 100, - tokens: { - operator: { token: "token-before", role: "operator", scopes: ["operator.pairing"] }, - }, - }); - const pairedAfter = validPaired({ - approvedAtMs: 200, - scopes: ["operator.pairing", "operator.read", "operator.write"], - approvedScopes: ["operator.pairing", "operator.read", "operator.write"], - tokens: { - operator: { - token: "token-after", - role: "operator", - scopes: ["operator.pairing", "operator.read", "operator.write"], - }, - }, - }); - return { - before: { - pendingById: { "request-1": pending }, - pairedByDeviceId: { "device-1": pairedBefore }, - }, - after: { - pendingById: {}, - pairedByDeviceId: { "device-1": pairedAfter }, - }, - }; -} - -function transactionJournal( - phase: "prepared" | "committed", - snapshots: ReturnType, -) { - return { - version: 1, - kind: "nemoclaw-self-approval", - phase, - requestId: "request-1", - deviceId: "device-1", - before: snapshots.before, - after: snapshots.after, - }; -} - -function selfApprovalOptions() { - return { - callerScopes: ["operator.pairing"], - nemoclawSelfApprovalIdentity: { - deviceId: "device-1", - publicKey: "public-key-1", - role: "operator", - clientId: "cli", - clientMode: "cli", - }, - }; -} - describe("OpenClaw bounded device self-approval patch (#4462)", () => { it("applies and audits each reviewed CLI, gateway, and canonical-state target", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-self-approval-")); @@ -477,18 +383,16 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { } }); - it.each( - [ - { client: { id: "control-ui", mode: "ui" }, role: "operator", scopes: ["operator.write"] }, - { client: { id: "cli", mode: "cli" }, role: "node", scopes: ["operator.write"] }, - { client: { id: "cli", mode: "cli" }, role: "operator", scopes: ["operator.admin"] }, - { - client: { id: "cli", mode: "cli" }, - role: "operator", - scopes: ["operator.write", "operator.write"], - }, - ], - )( + it.each([ + { client: { id: "control-ui", mode: "ui" }, role: "operator", scopes: ["operator.write"] }, + { client: { id: "cli", mode: "cli" }, role: "node", scopes: ["operator.write"] }, + { client: { id: "cli", mode: "cli" }, role: "operator", scopes: ["operator.admin"] }, + { + client: { id: "cli", mode: "cli" }, + role: "operator", + scopes: ["operator.write", "operator.write"], + }, + ])( "routes only a bounded CLI device-token scope mismatch into canonical pairing [case %#]", async (candidate) => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-auth-upgrade-")); @@ -606,10 +510,12 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { runtime.approvePairingWithFallback({ json: true }, "request-1"), ).resolves.toEqual({ requestId: "request-1", approved: true }); expect(runtime.gatewayCalls).toHaveLength(2); - ([ - ["device.pair.list", runtime.gatewayCalls[0]], - ["device.pair.approve", runtime.gatewayCalls[1]], - ] as const).forEach(([method, call]) => { + ( + [ + ["device.pair.list", runtime.gatewayCalls[0]], + ["device.pair.approve", runtime.gatewayCalls[1]], + ] as const + ).forEach(([method, call]) => { expect(call).toMatchObject({ method, scopes: ["operator.pairing"], @@ -965,6 +871,7 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { role: "operator", clientId: "cli", clientMode: "cli", + deviceToken: "token-before", }, }, }); @@ -989,6 +896,7 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { validClient({ connect: { role: "operator", + auth: { token: "token-before" }, device: { id: "device-2", publicKey: "public-key-1" }, client: { id: "cli", mode: "cli" }, }, @@ -999,6 +907,7 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { validClient({ connect: { role: "operator", + auth: { token: "token-before" }, device: { id: "device-1", publicKey: "public-key-2" }, client: { id: "cli", mode: "cli" }, }, @@ -1009,6 +918,17 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { validClient({ connect: { role: "node", + auth: { token: "token-before" }, + device: { id: "device-1", publicKey: "public-key-1" }, + client: { id: "cli", mode: "cli" }, + }, + }), + ], + [ + "missing device token", + validClient({ + connect: { + role: "operator", device: { id: "device-1", publicKey: "public-key-1" }, client: { id: "cli", mode: "cli" }, }, @@ -1179,20 +1099,23 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { ); it.each([ - ["prepared", "pending published first", "after", "before"], - ["prepared", "paired published first", "before", "after"], - ["committed", "pending published first", "after", "before"], - ["committed", "paired published first", "before", "after"], + ["prepared", "pending published first", "after", "before", "before"], + ["prepared", "paired published first", "before", "after", "before"], + ["prepared", "stored auth published first", "before", "before", "after"], + ["committed", "pending published first", "after", "before", "before"], + ["committed", "paired published first", "before", "after", "before"], + ["committed", "stored auth published first", "before", "before", "after"], ] as const)( "recovers a %s journal when %s", - async (phase, _direction, pendingSide, pairedSide) => { + async (phase, _direction, pendingSide, pairedSide, authSide) => { const { runtime, tmp } = openPatchedPairingFixture(); try { const snapshots = transactionSnapshots(); const currentPending = snapshots[pendingSide].pendingById; const currentPaired = snapshots[pairedSide].pairedByDeviceId; - const { journalPath } = runtime.getPairingPaths(); + const { authPath, journalPath } = runtime.getPairingPaths(); runtime.setPairingState(currentPending, currentPaired); + runtime.setFile(authPath, snapshots[authSide].auth); runtime.setFile(journalPath, transactionJournal(phase, snapshots)); const listed = await runtime.listDevicePairing(); @@ -1203,8 +1126,9 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { expect(runtime.getFile(runtime.getPairingPaths().pairedPath)).toEqual( expected.pairedByDeviceId, ); + expect(runtime.getFile(runtime.getPairingPaths().authPath)).toEqual(expected.auth); expect(runtime.getFile(journalPath)).toEqual({ - version: 1, + version: 2, kind: "nemoclaw-self-approval", phase: "idle", }); @@ -1227,7 +1151,7 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { const snapshots = transactionSnapshots(); const { journalPath, pendingPath } = runtime.getPairingPaths(); const malformed = { - version: 1, + version: 2, kind: "nemoclaw-self-approval", phase: "prepared", }; @@ -1283,6 +1207,10 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { transactionJournal("prepared", { before: snapshots.before, after: { + auth: expect.objectContaining({ + deviceId: "device-1", + tokens: expect.objectContaining({ operator: expect.any(Object) }), + }), pendingById: {}, pairedByDeviceId: expect.objectContaining({ "device-1": expect.objectContaining({ deviceId: "device-1" }), @@ -1292,11 +1220,14 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { ); runtime.releaseLateWriter(); - await expect(approval).rejects.toThrow("failed to publish both device pairing state files"); + await expect(approval).rejects.toThrow( + "failed to publish device pairing and stored-auth state", + ); expect(runtime.getFile(paths.pendingPath)).toEqual(snapshots.before.pendingById); expect(runtime.getFile(paths.pairedPath)).toEqual(snapshots.before.pairedByDeviceId); + expect(runtime.getFile(paths.authPath)).toEqual(snapshots.before.auth); expect(runtime.getFile(paths.journalPath)).toEqual({ - version: 1, + version: 2, kind: "nemoclaw-self-approval", phase: "idle", }); @@ -1332,6 +1263,18 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { expect(runtime.getFile(paths.pendingPath)).toEqual(driftedPending); expect(runtime.getFile(paths.pairedPath)).toEqual(snapshots.before.pairedByDeviceId); expect(runtime.getFile(paths.journalPath)).toBeNull(); + + runtime.setPairingState(snapshots.before.pendingById, snapshots.before.pairedByDeviceId); + runtime.armStateDrift(paths.authPath, { + ...snapshots.before.auth, + tokens: { + operator: { token: "other-token", role: "operator", scopes: ["operator.pairing"] }, + }, + }); + await expect( + runtime.approveDevicePairing("request-1", selfApprovalOptions(), "/fixture"), + ).rejects.toThrow("stored device auth changed before NemoClaw self-approval publication"); + expect(runtime.getFile(paths.journalPath)).toBeNull(); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -1352,8 +1295,42 @@ describe("OpenClaw bounded device self-approval patch (#4462)", () => { expect(runtime.getFile(paths.pairedPath)).toMatchObject({ "device-1": { deviceId: "device-1", publicKey: "public-key-1" }, }); + expect(runtime.getFile(paths.authPath)).toMatchObject({ + deviceId: "device-1", + tokens: { + operator: { + role: "operator", + scopes: ["operator.write"], + token: "token", + }, + }, + }); expect(runtime.getFile(paths.journalPath)).toEqual({ - version: 1, + version: 2, + kind: "nemoclaw-self-approval", + phase: "idle", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("reports failure until a committed journal clears its credential snapshots", async () => { + const { runtime, tmp } = openPatchedPairingFixture(); + try { + const snapshots = transactionSnapshots(); + const paths = runtime.getPairingPaths(); + runtime.setPairingState(snapshots.before.pendingById, snapshots.before.pairedByDeviceId); + runtime.armIdleJournalFailure(); + + await expect( + runtime.approveDevicePairing("request-1", selfApprovalOptions(), "/fixture"), + ).rejects.toThrow("idle journal cleanup failed"); + expect(runtime.getFile(paths.journalPath)).toMatchObject({ phase: "committed", version: 2 }); + + await expect(runtime.listDevicePairing()).resolves.toMatchObject({ pending: [] }); + expect(runtime.getFile(paths.journalPath)).toEqual({ + version: 2, kind: "nemoclaw-self-approval", phase: "idle", }); diff --git a/test/openclaw-device-stored-auth-patch.test.ts b/test/openclaw-device-stored-auth-patch.test.ts index d4360637af9..412661f4320 100644 --- a/test/openclaw-device-stored-auth-patch.test.ts +++ b/test/openclaw-device-stored-auth-patch.test.ts @@ -16,6 +16,48 @@ import { } from "./helpers/openclaw-device-self-approval-patch-harness"; describe("OpenClaw bounded stored-device-auth selection (#4462)", () => { + it("uses pairing-only stored auth only for a marked settlement list (#9844)", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-cli-settlement-list-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeFixtureDist(dist); + try { + expect(runPatch(dist).status).toBe(0); + const source = fs.readFileSync(path.join(dist, "devices-cli.runtime-fixture.js"), "utf8"); + const runtime = runFixture<{ + list: (opts: Record) => Promise; + calls: Array>; + setSettlement: (enabled: boolean) => void; + }>( + source, + `({ + list: listPairingWithFallback, + calls: gatewayCalls, + setSettlement: (enabled) => { + if (enabled) process.env.NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT = "1"; + else delete process.env.NEMOCLAW_OPENCLAW_PAIRING_SETTLEMENT; + }, + })`, + ); + + runtime.setSettlement(false); + await runtime.list({ json: true }); + runtime.setSettlement(true); + await runtime.list({ json: true }); + + expect(runtime.calls[0]).toMatchObject({ method: "device.pair.list" }); + expect(runtime.calls[0]).not.toHaveProperty("useStoredDeviceAuth"); + expect(runtime.calls[1]).toMatchObject({ + method: "device.pair.list", + scopes: ["operator.pairing"], + useStoredDeviceAuth: true, + requiredStoredDeviceAuthScopes: ["operator.pairing"], + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it.each([ ["repair", validPending()], ["nonrepair", validPending({ isRepair: false })], @@ -100,28 +142,28 @@ describe("OpenClaw bounded stored-device-auth selection (#4462)", () => { ])( "does not select stored device auth for %s", async (_label, pending, paired, expectPairingTransport) => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-cli-no-stored-auth-")); - const dist = path.join(tmp, "dist"); - fs.mkdirSync(dist); - writeFixtureDist(dist); - try { - expect(runPatch(dist).status).toBe(0); - const source = fs.readFileSync(path.join(dist, "devices-cli.runtime-fixture.js"), "utf8"); - const classify = runFixture< - ( - request: Record, - pairedDevice: Record | undefined, - ) => { usePairingTransport: boolean; useStoredDeviceAuth: boolean } - >(source, "resolveNemoClawSelfRepairPairingContext"); - const result = classify( - pending as Record, - paired as Record | undefined, - ); - expect(result.useStoredDeviceAuth).toBe(false); - expect(result.usePairingTransport).toBe(expectPairingTransport); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-device-cli-no-stored-auth-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeFixtureDist(dist); + try { + expect(runPatch(dist).status).toBe(0); + const source = fs.readFileSync(path.join(dist, "devices-cli.runtime-fixture.js"), "utf8"); + const classify = runFixture< + ( + request: Record, + pairedDevice: Record | undefined, + ) => { usePairingTransport: boolean; useStoredDeviceAuth: boolean } + >(source, "resolveNemoClawSelfRepairPairingContext"); + const result = classify( + pending as Record, + paired as Record | undefined, + ); + expect(result.useStoredDeviceAuth).toBe(false); + expect(result.usePairingTransport).toBe(expectPairingTransport); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } }, ); diff --git a/test/pr-workflow-contract.test.ts b/test/pr-workflow-contract.test.ts index 989ab64e7a8..06a92c67d90 100644 --- a/test/pr-workflow-contract.test.ts +++ b/test/pr-workflow-contract.test.ts @@ -81,6 +81,7 @@ const trustedActionDirs = [ ] as const; const cliShardCount = "12"; +const cliShardTimeoutMinutes = 30; function stepRuns(jobOrAction: WorkflowJob | CompositeAction): string[] { const steps = "runs" in jobOrAction ? jobOrAction.runs.steps : (jobOrAction.steps ?? []); @@ -348,6 +349,15 @@ describe("pull request and main workflow contracts", () => { ), }; + it.each([ + ["pull_request", prWorkflow], + ["main", mainWorkflow], + ] as const)("keeps the %s CLI coverage shard budget aligned", (_workflowName, workflow) => { + expect(workflow.jobs["cli-test-shards"]?.["timeout-minutes"]).toBe( + cliShardTimeoutMinutes, + ); + }); + // source-shape-contract: security -- PR base SHA action execution prevents pull-request code from authorizing installer hashes it("executes pull request installer hash checks only from the PR base SHA", () => { expect(installerHashTrustViolations(installerHashWorkflow)).toEqual([]);