openclaw - ✅(Solved) Fix Internal tool (cron) misidentified as remote client, causing unresolvable 'pairing required' on scope-upgrade [1 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Internal tools (e.g. cron) connecting from localhost with valid token auth are classified as remote clients, triggering a scope-upgrade pairing flow that cannot be resolved through the Web UI since the Control UI has no pairing approval interface for scope-upgrade requests.

Error Message

The internal tool receives a 1008 close code with no actionable information. cron list works (read scope) but cron add fails (requires write scope), making the error message misleading — it says "pairing required" when the real issue is scope insufficiency. 2. Improve error message: return "insufficient scope" instead of "pairing required" when the device is already paired but lacks the required scope

Root Cause

Root Cause Analysis

Fix Action

Fix / Workaround

  • Severity: High for self-hosted deployments
  • Affected tools: Any internal tool that requires scopes beyond operator.read (cron add/update/remove, etc.)
  • Workaround: Manually edit ~/.openclaw/devices/paired.json to add required scopes to approvedScopes and restart the gateway

Workaround (for other users)

PR fix notes

PR #69431: fix(gateway): classify loopback shared-secret clients as local for pairing

Description (problem / solution / changelog)

Summary

  • Problem: Internal tools (node-host cron scheduler, TUI, gateway-client backend) connecting from loopback with valid shared-secret auth are classified as remote by resolvePairingLocality(), causing close(1008, "pairing required") on scope-upgrade with no recovery path.
  • Why it matters: Users cannot use internal tools that require scope upgrades — the gateway silently kills the connection and no approval UI exists for scope-upgrade.
  • What changed: Added new PairingLocalityKind variant "shared_secret_loopback_local" with gate function isSharedSecretLoopbackLocalEquivalent in handshake-auth-helpers.ts. Any loopback-origin connection with valid shared-secret (token/password), no proxy headers, and no browser origin header now classifies as local for pairing purposes.
  • What did NOT change (scope boundary): No changes to message-handler.ts, error messages, pending persistence, Control UI, or silent-pairing policy. shouldAllowSilentLocalPairing already uses locality !== "remote" — the new kind passes automatically.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

  • Closes #69397
  • This PR fixes a bug or regression

Root Cause (if applicable)

  • Root cause: resolvePairingLocality() only recognized three local shapes: direct_local, browser_container_local (Control UI), and cli_container_local (CLI clients). Internal tools using NODE_HOST, GATEWAY_CLIENT, or TUI client IDs fell through to "remote" despite connecting from loopback with valid shared-secret auth.
  • Missing detection / guardrail: No catch-all gate for loopback + shared-secret clients beyond CLI and Control UI.
  • Contributing context: The gate functions were designed when only CLI and Control UI needed loopback classification. As internal tools (cron scheduler, TUI) were added, they inherited the remote fallback.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/gateway/server/ws-connection/handshake-auth-helpers.test.ts
  • Scenario the test should lock in: NODE_HOST client + loopback + token auth → "shared_secret_loopback_local" (not "remote")
  • Why this is the smallest reliable guardrail: The gate function is pure logic with no I/O — unit tests cover all decision paths exhaustively.
  • Existing test that already covers this: None existed. Added 4 new tests + updated 1 existing test.

User-visible / Behavior Changes

  • Internal tools (node-host cron, TUI, gateway-client backend) connecting from loopback with valid token/password auth will now silently auto-approve scope-upgrade, role-upgrade, and not-paired pairing requests instead of being disconnected with 1008 "pairing required".

Diagram (if applicable)

Before:
[node-host loopback+token] -> resolvePairingLocality -> "remote" -> close(1008, "pairing required")

After:
[node-host loopback+token] -> resolvePairingLocality -> "shared_secret_loopback_local" -> silent pairing approved

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No — locality only affects the approval path, not authentication. Connections must still pass sharedAuthOk before locality is evaluated.

Repro + Verification

Environment

  • OS: Windows 11
  • Runtime/container: Node.js via pnpm
  • Model/provider: N/A (gateway infrastructure)
  • Integration/channel: N/A

Steps

  1. Start gateway with token auth configured
  2. Connect node-host runner from loopback (127.0.0.1) with valid token
  3. Trigger scope-upgrade on the connection

Expected

  • Connection silently auto-approves scope-upgrade (same as CLI clients)

Actual

  • Before fix: Connection closed with 1008 "pairing required"
  • After fix: Scope-upgrade silently approved

Evidence

  • Failing test/log before + passing after
  • 19/19 unit tests pass including:
    • classifies non-CLI loopback + shared-secret clients as shared_secret_loopback_local
    • keeps non-CLI loopback clients remote without shared-secret auth (5 negative cases)
    • allows silent scope-upgrade for shared_secret_loopback_local
    • prefers cli_container_local over shared_secret_loopback_local for CLI clients

Human Verification (required)

  • Verified scenarios: All 19 unit tests pass (vitest verbose). Type safety confirmed via successful test compilation. Spec compliance review + code quality review both approved.
  • Edge cases checked: Non-loopback remote (192.168.1.10 → remote), proxy headers (→ remote), browser origin (→ remote), device-token auth (→ remote), sharedAuthOk=false (→ remote), CLI precedence preserved.
  • What you did not verify: Full pnpm check / pnpm build (OOM on local machine — CI will validate). E2E with live gateway connection.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: Broadening locality classification could silently upgrade scopes for clients that previously failed.
    • Mitigation: Gate requires all 5 conditions simultaneously: sharedAuthOk + shared-secret auth method (token/password) + no proxy headers + no browser origin + loopback address + private/loopback host. Remote attackers cannot satisfy these conditions. The existing reason allowlist (not-paired, scope-upgrade, role-upgrade only — not metadata) is preserved unchanged.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/gateway/server/ws-connection/handshake-auth-helpers.test.ts (modified, +130/-2)
  • src/gateway/server/ws-connection/handshake-auth-helpers.ts (modified, +33/-0)

Code Example

function resolvePairingLocality(params) {
    if (params.isLocalClient) return "direct_local";
    if (isControlUiBrowserContainerLocalEquivalent(...)) return "browser_container_local";
    if (isCliContainerLocalEquivalent(...)) return "cli_container_local";
    return "remote";  // ← internal tools fall through here
}

---

function shouldAllowSilentLocalPairing(params) {
    return params.locality !== "remote"  // ← false for internal tools
        && (!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat)
        && (params.reason === "not-paired" || params.reason === "scope-upgrade" || params.reason === "role-upgrade");
}

---

context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });

---

close(1008, "pairing required");

---

# Check current approvedScopes
cat ~/.openclaw/devices/paired.json | python3 -m json.tool

# Add full operator scopes (edit the file)
# Set approvedScopes to: ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"]
# Also update tokens.operator.scopes to match

# Restart gateway
openclaw gateway restart
RAW_BUFFERClick to expand / collapse

Summary

Internal tools (e.g. cron) connecting from localhost with valid token auth are classified as remote clients, triggering a scope-upgrade pairing flow that cannot be resolved through the Web UI since the Control UI has no pairing approval interface for scope-upgrade requests.

Environment

  • OpenClaw version: 2026.4.15 (commit 041266a)
  • OS: Linux (Ubuntu, x86_64)
  • Gateway auth mode: token
  • Gateway bind: lan (0.0.0.0)

Steps to Reproduce

  1. Fresh install OpenClaw, configure gateway.auth.mode = token
  2. Approve the initial device pairing (gateway-client) — at this point the device only gets operator.read approvedScopes
  3. Run cron add (or any internal tool that requires write/admin scopes)
  4. Observe: gateway closed (1008): pairing required
  5. Open the Control UI → no pairing request visible, no approval mechanism

Root Cause Analysis

1. resolvePairingLocality does not cover internal tool client IDs

In server.impl-GQ72oJBa.js, resolvePairingLocality only recognizes three client IDs as local:

function resolvePairingLocality(params) {
    if (params.isLocalClient) return "direct_local";
    if (isControlUiBrowserContainerLocalEquivalent(...)) return "browser_container_local";
    if (isCliContainerLocalEquivalent(...)) return "cli_container_local";
    return "remote";  // ← internal tools fall through here
}

isCliContainerLocalEquivalent checks for client.id === GATEWAY_CLIENT_IDS.CLI — internal tools like cron have different client IDs, so they are classified as "remote" even when connecting from 127.0.0.1 with valid token auth.

2. shouldAllowSilentLocalPairing rejects "remote" locality

function shouldAllowSilentLocalPairing(params) {
    return params.locality !== "remote"  // ← false for internal tools
        && (!params.hasBrowserOriginHeader || params.isControlUi || params.isWebchat)
        && (params.reason === "not-paired" || params.reason === "scope-upgrade" || params.reason === "role-upgrade");
}

Since locality is "remote", silent is set to false, and the pairing request enters the manual approval path.

3. Web UI does not implement scope-upgrade pairing approval

The pairing request is broadcast via:

context.broadcast("device.pair.requested", pairing.request, { dropIfSlow: true });

But the Control UI frontend does not listen for or render scope-upgrade pairing requests. Additionally, the pending request is not persisted to pending.json, so it is lost when the connection closes.

4. Connection is closed with no recovery path

close(1008, "pairing required");

The internal tool receives a 1008 close code with no actionable information. cron list works (read scope) but cron add fails (requires write scope), making the error message misleading — it says "pairing required" when the real issue is scope insufficiency.

Impact

  • Severity: High for self-hosted deployments
  • Affected tools: Any internal tool that requires scopes beyond operator.read (cron add/update/remove, etc.)
  • Workaround: Manually edit ~/.openclaw/devices/paired.json to add required scopes to approvedScopes and restart the gateway

Suggested Fixes

  1. Extend resolvePairingLocality to recognize internal tool client IDs (or any connection from loopback with valid shared-secret auth) as local
  2. Improve error message: return "insufficient scope" instead of "pairing required" when the device is already paired but lacks the required scope
  3. Persist pending scope-upgrade requests to pending.json so they survive connection closures
  4. Add scope-upgrade approval UI in the Control UI, or auto-approve scope-upgrades for localhost token-authenticated connections

Workaround (for other users)

# Check current approvedScopes
cat ~/.openclaw/devices/paired.json | python3 -m json.tool

# Add full operator scopes (edit the file)
# Set approvedScopes to: ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"]
# Also update tokens.operator.scopes to match

# Restart gateway
openclaw gateway restart

extent analysis

TL;DR

The most likely fix is to extend the resolvePairingLocality function to recognize internal tool client IDs as local when connecting from localhost with valid token auth.

Guidance

  • Review the resolvePairingLocality function in server.impl-GQ72oJBa.js to understand how locality is determined for internal tools.
  • Consider adding a check for internal tool client IDs or loopback connections with valid shared-secret auth to classify them as local.
  • Evaluate the suggested fixes, including improving error messages, persisting pending scope-upgrade requests, and adding a scope-upgrade approval UI in the Control UI.
  • Apply the provided workaround by manually editing ~/.openclaw/devices/paired.json to add required scopes to approvedScopes and restarting the gateway.

Example

function resolvePairingLocality(params) {
    if (params.isLocalClient) return "direct_local";
    if (isControlUiBrowserContainerLocalEquivalent(...)) return "browser_container_local";
    if (isCliContainerLocalEquivalent(...)) return "cli_container_local";
    // Add check for internal tool client IDs or loopback connections
    if (params.clientId === INTERNAL_TOOL_CLIENT_ID && params.ip === "127.0.0.1") return "local";
    return "remote";
}

Notes

The provided workaround may not be suitable for all users, and a more permanent fix is needed to address the root cause of the issue. The suggested fixes require careful evaluation and testing to ensure they do not introduce new issues.

Recommendation

Apply the workaround by manually editing ~/.openclaw/devices/paired.json to add required scopes to approvedScopes and restarting the gateway, as this provides a temporary solution to the problem. A more permanent fix should be implemented as soon as possible to address the root cause of the issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING