Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6cb112b
fix(onboard): require OpenClaw CLI pairing before completion
rsliter Aug 21, 2026
3234a4d
fix(onboard): fence OpenClaw pairing settlement
rsliter Aug 21, 2026
6a49e6c
fix(onboard): harden pairing failure handling
rsliter Aug 21, 2026
e0f659d
fix(onboard): close pairing readiness review gaps
rsliter Aug 21, 2026
77eee08
fix(onboard): preserve convergence with malformed requests
rsliter Aug 21, 2026
3c51826
Merge branch 'main' into codex/fix-9844-openclaw-pairing-readiness
rsliter Aug 21, 2026
28e8f2a
fix(onboard): skip pairing gate during rebuild handoff
rsliter Aug 21, 2026
e25a36d
fix(rebuild): wait for OpenClaw doctor completion
rsliter Aug 21, 2026
b0acddd
fix(onboard): drop shared auth during pairing settlement
rsliter Aug 21, 2026
3f51a64
test(e2e): preserve cleanup on onboarding failure
rsliter Aug 21, 2026
53792f7
fix(openclaw): authenticate pairing settlement list
rsliter Aug 21, 2026
1fcbde9
merge: refresh main
rsliter Aug 21, 2026
a7f3e9a
Merge remote-tracking branch 'origin/main' into codex/fix-9844-opencl…
rsliter Aug 21, 2026
44f1b1a
fix(onboard): allow pairing after policy skip
rsliter Aug 21, 2026
b8cb4af
fix(onboard): use pairing auth for warm-up polling
rsliter Aug 21, 2026
afbb5dc
fix(onboard): authenticate pairing warm-up poll
rsliter Aug 21, 2026
df716bc
merge: reconcile pairing E2E repair
rsliter Aug 21, 2026
156e0d8
merge: resolve conflicts with main
github-actions[bot] Aug 21, 2026
6e8ada5
fix(rebuild): preserve doctor timeout after merge
rsliter Aug 22, 2026
04032b6
merge(main): refresh pairing readiness base
rsliter Aug 22, 2026
f757690
fix(onboard): reuse lifecycle lock facade
rsliter Aug 22, 2026
0110595
fix(onboard): avoid inference during pairing warmup
rsliter Aug 22, 2026
05b10e1
Merge remote-tracking branch 'origin/main' into codex/9847-e2e-repair
rsliter Aug 22, 2026
dab1c5e
fix(onboard): reserve pairing settlement budgets
rsliter Aug 22, 2026
f89557e
merge(onboard): combine pairing settlement repairs
rsliter Aug 22, 2026
f158d64
merge(main): refresh pairing readiness base
rsliter Aug 22, 2026
fb68f0f
merge(main): refresh pairing readiness base
rsliter Aug 22, 2026
952a367
test(onboard): name pairing appearance deadline
rsliter Aug 22, 2026
2190ce5
Merge remote-tracking branch 'origin/main' into codex/9847-e2e-repair
rsliter Aug 22, 2026
c236cc2
docs(onboard): clarify pairing phase budgets
rsliter Aug 22, 2026
c5d0e55
fix(openclaw): synchronize self-approval credential state
rsliter Aug 22, 2026
ed69823
merge(main): refresh PR branch
rsliter Aug 22, 2026
066cdf5
fix(openclaw): validate self-approval auth scopes
rsliter Aug 22, 2026
881524a
merge(main): refresh OpenClaw credential hardening
rsliter Aug 22, 2026
abd8b41
merge(main): refresh OpenClaw credential hardening
rsliter Aug 22, 2026
5d3aa00
fix(ci): complete pairing readiness validation
jyaunches Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/get-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ Review the [Prerequisites](prerequisites) before you begin.

<Step title="Confirm the Sandbox Is Ready">
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
Expand Down
27 changes: 20 additions & 7 deletions internal/security-reviews/openclaw-2026.7.1-dependency-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
163 changes: 132 additions & 31 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -2934,24 +2947,54 @@ 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
# the fast→slow transition and the 8h deadline check.
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(
Expand Down Expand Up @@ -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,
)
Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
)
Expand All @@ -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
Expand Down
Loading
Loading