openclaw - ✅(Solved) Fix [Bug]: openclaw nodes approve requires operator.admin — blocks automation after 2026.5.18 node surface gate [2 pull requests, 3 comments, 3 participants]

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…
GitHub stats
openclaw/openclaw#84144Fetched 2026-05-20 03:43:31
View on GitHub
Comments
3
Participants
3
Timeline
22
Reactions
1
Author
Assignees
Timeline (top)
labeled ×9commented ×3cross-referenced ×3mentioned ×2

Since the gateway commit "fix(gateway): hide unapproved node surfaces" (released in 2026.5.18), approving a node's command surface requires calling openclaw nodes approve <requestId>, which requires operator.admin scope. No token or device available to automation carries that scope, so automated provisioning of nodes is permanently broken on 2026.5.18+.

Error Message

Attempting surface approval via CLI ✓ pending surface request: b7c0c676-88cf-4e1e-a058-cb586c888f44

Running: openclaw nodes approve 'b7c0c676-...' --token <gateway-token> Output: nodes approve failed: GatewayClientRequestError: missing scope: operator.admin

✗ BLOCKED — missing scope: operator.admin

Step D — openclaw nodes approve WITH operator.admin in devices/paired.json CLI device scopes after patch: ['operator.admin', 'operator.read', ...] Output: nodes approve failed: GatewayClientRequestError: missing scope: operator.admin

✗ STILL blocked — scope enforced at session level, not from devices/paired.json record

Root Cause

Since the gateway commit "fix(gateway): hide unapproved node surfaces" (released in 2026.5.18), approving a node's command surface requires calling openclaw nodes approve <requestId>, which requires operator.admin scope. No token or device available to automation carries that scope, so automated provisioning of nodes is permanently broken on 2026.5.18+.

Fix Action

Fix / Workaround

Since the gateway commit "fix(gateway): hide unapproved node surfaces" (released in 2026.5.18), approving a node's command surface requires calling openclaw nodes approve <requestId>, which requires operator.admin scope. No token or device available to automation carries that scope, so automated provisioning of nodes is permanently broken on 2026.5.18+.

Script 2 — prove operator.admin patch doesn't help (02-scope-escalation-test.sh)

info "Step A — Escalate CLI device to operator.admin (via devices/paired.json patch)" gw "openclaw cron list --token '$GW_TOKEN' 2>&1 || true" sleep 3 CLI_REQ=$(gw "openclaw devices list --json 2>/dev/null |
python3 -c "import sys,json; d=json.load(sys.stdin);
r=[p['requestId'] for p in d.get('pending',[]) if p.get('clientMode')=='cli'];
print(r[0] if r else '')"" 2>/dev/null || true) [ -n "$CLI_REQ" ] && gw "openclaw devices approve '$CLI_REQ' --token '$GW_TOKEN' 2>&1" || true sleep 2

PR fix notes

PR #84251: fix(node-pairing): require only operator.pairing scope for all node approvals

Description (problem / solution / changelog)

Summary

  • Problem: After 2026.5.18, openclaw nodes approve requires operator.admin scope for exec-capable nodes, blocking automation workflows (claws, CI) that cannot obtain that scope.
  • Solution: Change resolveNodePairApprovalScopes() to always return ["operator.pairing"] regardless of node commands.
  • What changed: src/infra/node-pairing-authz.ts now returns only operator.pairing for all nodes.
  • What did NOT change: Device pairing scope checks, gateway RPC authorization, or other scope-related code.

Motivation

Automation workflows (like claws/openclaw-swarm) use tokens with operator.pairing scope to provision nodes. After the 2026.5.18 "hide unapproved node surfaces" change, nodes with exec capabilities (system.run) require operator.admin to approve their surfaces. Automation cannot obtain operator.admin, so automated node provisioning is permanently broken for exec-capable nodes.

See issue #84144 for full reproduction steps.

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 #84144
  • This PR fixes a bug or regression

Real behavior proof (required for external PRs)

  • Behavior or issue addressed: Automation with operator.pairing scope cannot approve exec-capable nodes
  • Real environment tested: macOS 15.4, Node.js v22.22.2, local OpenClaw dev build
  • Exact steps or command run after this patch:
npx vitest run src/infra/node-pairing-authz.test.ts src/agents/tools/nodes-tool.test.ts --reporter=verbose
  • Evidence after fix (terminal output):
✓ unit-fast src/infra/node-pairing-authz.test.ts > resolveNodePairApprovalScopes > requires only operator.pairing for system.run commands 1ms
✓ unit-fast src/infra/node-pairing-authz.test.ts > resolveNodePairApprovalScopes > requires only operator.pairing for non-exec commands 0ms
✓ unit-fast src/infra/node-pairing-authz.test.ts > resolveNodePairApprovalScopes > requires only operator.pairing without commands 0ms
✓ agents-tools src/agents/tools/nodes-tool.test.ts > uses only operator.pairing to approve exec-capable node pair requests 0ms
✓ agents-tools src/agents/tools/nodes-tool.test.ts > uses only operator.pairing to approve non-exec node pair requests 0ms
✓ agents-tools src/agents/tools/nodes-tool.test.ts > uses operator.pairing for commandless node pair requests 0ms
✓ agents-tools src/agents/tools/nodes-tool.test.ts > falls back to command inspection when the gateway does not advertise required scopes 0ms

Test Files  3 passed (3)
     Tests  29 passed (29)
  • Observed result after fix: All node types (exec-capable, non-exec, commandless) now require only operator.pairing scope
  • What was not tested: Full end-to-end claws provisioning (would require multipass VM setup with patched OpenClaw build)
  • Before evidence: Tests previously expected ["operator.pairing", "operator.admin"] for system.run nodes

Root Cause (if applicable)

  • Root cause: resolveNodePairApprovalScopes() returned different scopes based on node commands - operator.admin for exec-capable nodes, operator.write for other commands, operator.pairing only for commandless nodes
  • Missing detection / guardrail: No integration test covering automation token scope with exec-capable nodes
  • Contributing context: The 2026.5.18 "hide unapproved node surfaces" change made this scope check actually matter, exposing the automation gap

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/infra/node-pairing-authz.test.ts, src/agents/tools/nodes-tool.test.ts
  • Scenario the test should lock in: All node types require only operator.pairing scope
  • Why this is the smallest reliable guardrail: Unit tests directly verify the scope resolution function
  • Existing test that already covers this: Tests updated in this PR now enforce the new behavior

User-visible / Behavior Changes

  • Automation tokens with only operator.pairing scope can now approve exec-capable nodes
  • No config or CLI changes required

Diagram (if applicable)

Before:
[automation token: operator.pairing] -> [node.pair.approve system.run node] -> REJECTED (needs operator.admin)

After:
[automation token: operator.pairing] -> [node.pair.approve system.run node] -> APPROVED

Security Impact (required)

  • New permissions/capabilities? No - reduces required scope, does not add new capabilities
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No - node surfaces still require device pairing approval first
  • Data access scope changed? No
  • If any Yes, explain risk + mitigation: N/A

Repro + Verification

Environment

  • OS: macOS 15.4
  • Runtime/container: Node.js v22.22.2
  • Model/provider: N/A
  • Integration/channel: N/A
  • Relevant config: Local dev build

Steps

  1. Apply this patch
  2. Run npx vitest run src/infra/node-pairing-authz.test.ts src/agents/tools/nodes-tool.test.ts
  3. Verify all 29 tests pass

Expected

All node approval scope tests pass with operator.pairing only

Actual

All 29 tests pass

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios:
    • Unit tests for resolveNodePairApprovalScopes() with system.run, non-exec, and commandless nodes
    • Nodes-tool approve action tests with mocked gateway
  • Edge cases checked:
    • Fallback behavior when gateway does not advertise required scopes
  • What you did NOT verify:
    • Full end-to-end claws provisioning in multipass VMs (requires building patched npm package)

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 - only reduces required scopes
  • Config/env changes? No
  • Migration needed? No

Risks and Mitigations

  • Risk: Lower scope requirement could be seen as security relaxation
    • Mitigation: Device pairing approval is still required first; this only affects the secondary node surface approval step. Automation tokens are already trusted with operator.pairing scope for device approval.

Changed files

  • src/agents/tools/nodes-tool.test.ts (modified, +11/-7)
  • src/infra/node-pairing-authz.test.ts (modified, +8/-10)
  • src/infra/node-pairing-authz.ts (modified, +13/-16)

PR #84392: Fix node approval scope requests

Description (problem / solution / changelog)

Summary

  • Fixes openclaw nodes approve for pending node surfaces that require elevated approval scopes, such as system.run requiring operator.admin.
  • Resolves the pending request first, requests the gateway-advertised or command-derived approval scopes, and keeps the privileged backend approval path limited to node.pair.list and node.pair.approve.
  • Adds regression coverage that normal node commands still use CLI identity while the approval helper rejects unsupported backend methods at runtime.

Verification

  • node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts src/cli/program.nodes-basic.e2e.test.ts --reporter=verbose
  • git diff --check
  • AUTOREVIEW_AUTO_TESTS=0 .agents/skills/autoreview/scripts/autoreview --reviewer claude --fallback-reviewer none
  • Live AWS Crabbox proof after the patch: provider aws, lease cbx_33c68aff3b77, run run_83a1f4ee38f2, exit 0.

Real behavior proof

Behavior addressed: openclaw nodes approve <requestId> failed with missing scope: operator.admin for exec-capable pending node surfaces after the 2026.5.18 node surface gate.

Real environment tested: AWS Crabbox from this branch against a live gateway and live node pairing flow.

Exact steps or command run after this patch: provisioned a gateway and node, approved device pairing, confirmed the pending node surface required ["operator.pairing","operator.admin"], ran openclaw nodes approve <requestId> with the gateway token, and inspected pending/paired node state afterward.

Evidence after fix: AWS Crabbox provider aws, lease cbx_33c68aff3b77, run run_83a1f4ee38f2, exit 0; output included requiredApproveScopes=["operator.pairing","operator.admin"], approve_output success with redacted token, pending_after=[], and paired node commands including system.run.

Observed result after fix: the exec-capable pending node surface moved from pending to paired, the CLI device remained pairing-scoped, and node approval completed without manually editing gateway state files.

What was not tested: full cross-platform release CI was not run locally before PR creation; PR CI is expected to cover broader repository gates.

Fixes #84144.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • src/cli/nodes-cli/register.pairing.ts (modified, +67/-5)
  • src/cli/nodes-cli/rpc.runtime.ts (modified, +34/-0)
  • src/cli/nodes-cli/rpc.ts (modified, +11/-0)
  • src/cli/program.nodes-basic.e2e.test.ts (modified, +94/-4)

Code Example

#!/usr/bin/env bash
# OPENCLAW_VERSION=2026.5.12 bash 01-reproduce.sh  ← passes
# OPENCLAW_VERSION=2026.5.18 bash 01-reproduce.sh  ← shows failure
set -euo pipefail

OC_VERSION="${OPENCLAW_VERSION:-2026.5.18}"
GW_VM="oc-issue-gateway"
NODE_VM="oc-issue-node"
GW_PORT=18789

info()  { echo ""; echo ">>> $*"; }
ok()    { echo "    ✓ $*"; }
fail()  { echo "    ✗ $*"; }

version_gte() {
  python3 -c "
a=[int(x) for x in '$1'.split('.')]
b=[int(x) for x in '$2'.split('.')]
exit(0 if a >= b else 1)
"
}

info "Cleaning up any previous VMs"
multipass delete "$GW_VM" "$NODE_VM" 2>/dev/null || true
multipass purge 2>/dev/null || true

info "Launching gateway VM"
multipass launch --name "$GW_VM" --cpus 2 --memory 2G --disk 8G 24.04
GW_IP=$(multipass info "$GW_VM" --format json | python3 -c \
  "import sys,json; d=json.load(sys.stdin); print(list(d['info'].values())[0]['ipv4'][0])")
ok "gateway VM: $GW_VM ($GW_IP)"

info "Launching node VM"
multipass launch --name "$NODE_VM" --cpus 1 --memory 1G --disk 6G 24.04
ok "node VM: $NODE_VM"

gw()   { multipass exec "$GW_VM"   -- bash -c "$1"; }
node() { multipass exec "$NODE_VM" -- bash -c "$1"; }

info "Installing Node.js and OpenClaw $OC_VERSION on both VMs"
gw "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && \
    sudo apt-get install -y nodejs && sudo npm install -g openclaw@$OC_VERSION" 2>&1 | \
    grep -E "npm (warn|error)|installed|OpenClaw" | head -5 || true
node "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && \
     sudo apt-get install -y nodejs && sudo npm install -g openclaw@$OC_VERSION" 2>&1 | \
     grep -E "npm (warn|error)|installed|OpenClaw" | head -5 || true
ok "openclaw $OC_VERSION installed on both VMs"

info "Bootstrapping gateway"
gw "openclaw onboard --non-interactive --mode local --auth-choice skip \
    --gateway-bind lan --gateway-token mytoken123 \
    --install-daemon --skip-health --accept-risk 2>&1 | tail -3"

for i in $(seq 1 20); do
  if gw "nc -z 127.0.0.1 $GW_PORT 2>/dev/null"; then break; fi
  sleep 2
done
ok "gateway listening on :$GW_PORT"
GW_TOKEN=$(gw "openclaw config get gateway.token 2>/dev/null | tr -d '\n'")

info "Connecting node to gateway"
node "export XDG_RUNTIME_DIR=/run/user/\$(id -u); \
     OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 OPENCLAW_GATEWAY_TOKEN=mytoken123 \
     openclaw node install --host $GW_IP --port $GW_PORT \
     --display-name scraper-node --runtime node --force 2>&1 | tail -3"

REQ_ID=""
for i in $(seq 1 15); do
  REQ_ID=$(gw "openclaw devices list --json 2>/dev/null | \
    python3 -c \"import sys,json; \
    data=json.load(sys.stdin); \
    reqs=[p['requestId'] for p in data.get('pending',[]) if p.get('clientMode')=='node']; \
    print(reqs[0] if reqs else '')\"" 2>/dev/null || true)
  [ -n "$REQ_ID" ] && break
  sleep 2
done
[ -n "$REQ_ID" ] || { fail "no pending device request after 30s"; exit 1; }

info "Step 1 — Approving device pairing (openclaw devices approve)"
gw "openclaw devices approve '$REQ_ID' --token '$GW_TOKEN' 2>&1"
sleep 4

node "export XDG_RUNTIME_DIR=/run/user/\$(id -u); \
      export DBUS_SESSION_BUS_ADDRESS=unix:path=\$XDG_RUNTIME_DIR/bus; \
      systemctl --user restart openclaw-node 2>/dev/null || true"
sleep 5

DEVICE_PAIRED=$(gw "openclaw devices list --json 2>/dev/null | \
  python3 -c \"import sys,json; \
  data=json.load(sys.stdin); \
  paired=[d for d in data.get('paired',[]) if d.get('displayName')=='scraper-node']; \
  print('yes' if paired else 'no')\"" 2>/dev/null || echo "error")
[ "$DEVICE_PAIRED" = "yes" ] || { fail "device not in paired list"; exit 1; }
ok "device is in openclaw devices list paired[] — on < 2026.5.18 this was ALL that was needed"

info "Inspecting gateway on-disk state"
echo "  nodes/paired.json (what agents SEE on >= 2026.5.18):"
gw "cat ~/.openclaw/nodes/paired.json 2>/dev/null || echo '  (file not found)'"
echo "  nodes/pending.json (surface approval queue):"
gw "cat ~/.openclaw/nodes/pending.json 2>/dev/null | \
    python3 -c \"import sys,json; d=json.load(sys.stdin); \
    [print('  requestId:', k, '| displayName:', v.get('displayName'), '| commands:', v.get('commands',[])) for k,v in d.items()]\" \
    2>/dev/null || echo '  (file not found or empty)'"

if version_gte "$OC_VERSION" "2026.5.18"; then
  fail "nodes/paired.json is EMPTY — gateway hides commands for nodes in pending.json"
  echo "  Agents that call system.run receive: command not found / node not available"

  info "Attempting surface approval via CLI"
  SURFACE_REQ=$(gw "openclaw nodes list --json 2>/dev/null | \
    python3 -c \"import sys,json; \
    data=json.load(sys.stdin); \
    pending=[p for p in data.get('pending',[]) if p.get('displayName')=='scraper-node']; \
    print(pending[0].get('requestId','') if pending else '')\"" 2>/dev/null || true)
  [ -n "$SURFACE_REQ" ] || { fail "no pending surface request found"; exit 1; }
  ok "pending surface request: $SURFACE_REQ"

  echo "  Running: openclaw nodes approve '$SURFACE_REQ' --token <gateway-token>"
  APPROVE_OUT=$(gw "openclaw nodes approve '$SURFACE_REQ' --token '$GW_TOKEN' 2>&1" || true)
  echo "  Output: $APPROVE_OUT"
  fail "BLOCKED — missing scope: operator.admin"
  echo "  The gateway token and CLI device both only carry operator.pairing."
  echo "========================================================================"
  echo " RESULT: node surface approval BLOCKED on OpenClaw $OC_VERSION"
  echo "========================================================================"
else
  ok "On $OC_VERSION the gateway served commands from pending nodes — device approval was enough"
  echo "========================================================================"
  echo " RESULT: OpenClaw $OC_VERSION — no regression, device approval was sufficient"
  echo "========================================================================"
fi

---

#!/usr/bin/env bash
# OPENCLAW_VERSION=2026.5.18 bash 02-scope-escalation-test.sh
set -euo pipefail

OC_VERSION="${OPENCLAW_VERSION:-2026.5.18}"
GW_VM="oc-issue-gateway"
NODE_VM="oc-issue-node"
GW_PORT=18789

info()  { echo ""; echo ">>> $*"; }
ok()    { echo "    ✓ $*"; }
fail()  { echo "    ✗ $*"; }

info "Cleaning up any previous VMs"
multipass delete "$GW_VM" "$NODE_VM" 2>/dev/null || true
multipass purge 2>/dev/null || true

info "Launching VMs and installing OpenClaw $OC_VERSION"
multipass launch --name "$GW_VM" --cpus 2 --memory 2G --disk 8G 24.04
multipass launch --name "$NODE_VM" --cpus 1 --memory 1G --disk 6G 24.04
GW_IP=$(multipass info "$GW_VM" --format json | python3 -c \
  "import sys,json; d=json.load(sys.stdin); print(list(d['info'].values())[0]['ipv4'][0])")

gw()   { multipass exec "$GW_VM"   -- bash -c "$1"; }
node() { multipass exec "$NODE_VM" -- bash -c "$1"; }

gw "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && \
    sudo apt-get install -y nodejs && sudo npm install -g openclaw@$OC_VERSION" 2>&1 | tail -3 || true
node "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && \
     sudo apt-get install -y nodejs && sudo npm install -g openclaw@$OC_VERSION" 2>&1 | tail -3 || true

info "Bootstrapping gateway"
gw "openclaw onboard --non-interactive --mode local --auth-choice skip \
    --gateway-bind lan --gateway-token mytoken123 \
    --install-daemon --skip-health --accept-risk 2>&1 | tail -3"
for i in $(seq 1 20); do
  if gw "nc -z 127.0.0.1 $GW_PORT 2>/dev/null"; then break; fi
  sleep 2
done
GW_TOKEN=$(gw "openclaw config get gateway.token 2>/dev/null | tr -d '\n'")
ok "gateway ready"

info "Step A — Escalate CLI device to operator.admin (via devices/paired.json patch)"
gw "openclaw cron list --token '$GW_TOKEN' 2>&1 || true"
sleep 3
CLI_REQ=$(gw "openclaw devices list --json 2>/dev/null | \
  python3 -c \"import sys,json; d=json.load(sys.stdin); \
  r=[p['requestId'] for p in d.get('pending',[]) if p.get('clientMode')=='cli']; \
  print(r[0] if r else '')\"" 2>/dev/null || true)
[ -n "$CLI_REQ" ] && gw "openclaw devices approve '$CLI_REQ' --token '$GW_TOKEN' 2>&1" || true
sleep 2

CLI_DEVICE_ID=$(gw "openclaw devices list --json 2>/dev/null | \
  python3 -c \"import sys,json; d=json.load(sys.stdin); \
  devs=[p for p in d.get('paired',[]) if p.get('clientMode')=='cli']; \
  print(devs[0].get('deviceId','') if devs else '')\"" 2>/dev/null || true)
[ -n "$CLI_DEVICE_ID" ] || { fail "CLI device not found in paired list"; exit 1; }

gw "jq --arg id '$CLI_DEVICE_ID' '
  if .[\$id] then
    .[\$id].scopes = [\"operator.admin\",\"operator.read\",\"operator.write\",\"operator.pairing\",\"operator.approvals\"] |
    .[\$id].approvedScopes = [\"operator.admin\",\"operator.read\",\"operator.write\",\"operator.pairing\",\"operator.approvals\"]
  else . end
' ~/.openclaw/devices/paired.json > /tmp/p.tmp && mv /tmp/p.tmp ~/.openclaw/devices/paired.json"

gw "export XDG_RUNTIME_DIR=\"\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)}\";
    systemctl --user restart openclaw-gateway 2>/dev/null || sudo systemctl restart openclaw-gateway 2>/dev/null || true"
sleep 5

CLI_SCOPES=$(gw "openclaw devices list --json 2>/dev/null | \
  python3 -c \"import sys,json; d=json.load(sys.stdin); \
  devs=[p for p in d.get('paired',[]) if p.get('clientMode')=='cli']; \
  print(devs[0].get('scopes',[]) if devs else [])\"" 2>/dev/null || true)
echo "  CLI device scopes after patch: $CLI_SCOPES"
echo "$CLI_SCOPES" | grep -q "operator.admin" || { fail "escalation failed"; exit 1; }
ok "CLI device has operator.admin in devices/paired.json"

info "Step B — Connect node and approve device pairing"
node "export XDG_RUNTIME_DIR=/run/user/\$(id -u); \
     OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 OPENCLAW_GATEWAY_TOKEN=mytoken123 \
     openclaw node install --host $GW_IP --port $GW_PORT \
     --display-name scraper-node --runtime node --force 2>&1 | tail -3"
REQ_ID=""
for i in $(seq 1 15); do
  REQ_ID=$(gw "openclaw devices list --json 2>/dev/null | \
    python3 -c \"import sys,json; d=json.load(sys.stdin); \
    r=[p['requestId'] for p in d.get('pending',[]) if p.get('clientMode')=='node']; \
    print(r[0] if r else '')\"" 2>/dev/null || true)
  [ -n "$REQ_ID" ] && break; sleep 2
done
[ -n "$REQ_ID" ] || { fail "no pending device request"; exit 1; }
gw "openclaw devices approve '$REQ_ID' --token '$GW_TOKEN' 2>&1"
sleep 4
node "export XDG_RUNTIME_DIR=/run/user/\$(id -u);
      systemctl --user restart openclaw-node 2>/dev/null || true"
sleep 5
ok "node device pairing approved"

info "Step C — Find pending surface request"
SURFACE_REQ=""
for i in $(seq 1 10); do
  SURFACE_REQ=$(gw "openclaw nodes list --json 2>/dev/null | \
    python3 -c \"import sys,json; d=json.load(sys.stdin); \
    p=[x for x in d.get('pending',[]) if x.get('displayName')=='scraper-node']; \
    print(p[0].get('requestId','') if p else '')\"" 2>/dev/null || true)
  [ -n "$SURFACE_REQ" ] && break
  echo "    waiting..."; sleep 3
done
[ -n "$SURFACE_REQ" ] || { fail "no pending surface request"; exit 1; }
ok "pending surface request: $SURFACE_REQ"

info "Step D — openclaw nodes approve WITH operator.admin in devices/paired.json"
echo "  (CLI device has operator.admin in stored record — will the session pick it up?)"
APPROVE_OUT=$(gw "openclaw nodes approve '$SURFACE_REQ' 2>&1" || true)
echo "  Output: $APPROVE_OUT"

if echo "$APPROVE_OUT" | grep -qi "approved\|success"; then
  ok "SUCCEEDED — hypothesis confirmed, scope patch works for nodes approve too"
elif echo "$APPROVE_OUT" | grep -qi "operator.admin\|missing scope"; then
  fail "STILL blocked — scope enforced at session level, not from devices/paired.json record"
  echo "========================================================================"
  echo " RESULT: patching devices/paired.json does NOT help for nodes approve."
  echo " The RPC enforces operator.admin at the session token level."
  echo " File-surgery on nodes/paired.json is the only workaround."
  echo "========================================================================"
else
  echo "  Unexpected: $APPROVE_OUT"
fi

---

>>> Attempting surface approval via CLI
    ✓ pending surface request: b7c0c676-88cf-4e1e-a058-cb586c888f44

  Running: openclaw nodes approve 'b7c0c676-...' --token <gateway-token>
  Output: nodes approve failed: GatewayClientRequestError: missing scope: operator.admin

BLOCKED — missing scope: operator.admin

>>> Step D — openclaw nodes approve WITH operator.admin in devices/paired.json
  CLI device scopes after patch: ['operator.admin', 'operator.read', ...]
  Output: nodes approve failed: GatewayClientRequestError: missing scope: operator.admin

STILL blocked — scope enforced at session level, not from devices/paired.json record

---

# Move entry from ~/.openclaw/nodes/pending.json to ~/.openclaw/nodes/paired.json
# Restart openclaw-gateway (reload state)
# Restart openclaw-node (reconnect → effectiveCommands populated)
RAW_BUFFERClick to expand / collapse

Summary

Since the gateway commit "fix(gateway): hide unapproved node surfaces" (released in 2026.5.18), approving a node's command surface requires calling openclaw nodes approve <requestId>, which requires operator.admin scope. No token or device available to automation carries that scope, so automated provisioning of nodes is permanently broken on 2026.5.18+.

Before / After

Before 2026.5.18: After openclaw devices approve, the gateway served a node's commands to agents from both nodes/pending.json and nodes/paired.json. Device approval was the only step. One call, done.

After 2026.5.18: The gateway hides commands for nodes in nodes/pending.json. After device approval the node sits in pending indefinitely. Agents that call system.run (or any node command) get command not found / node not available until openclaw nodes approve <requestId> is called — which requires operator.admin scope that automation cannot obtain.

Reproduction (requires multipass)

Script 1 — show the failure (01-reproduce.sh)

Run with OPENCLAW_VERSION=2026.5.12 to see the old clean behaviour, then with OPENCLAW_VERSION=2026.5.18 to see the breakage.

<details> <summary>01-reproduce.sh</summary>
#!/usr/bin/env bash
# OPENCLAW_VERSION=2026.5.12 bash 01-reproduce.sh  ← passes
# OPENCLAW_VERSION=2026.5.18 bash 01-reproduce.sh  ← shows failure
set -euo pipefail

OC_VERSION="${OPENCLAW_VERSION:-2026.5.18}"
GW_VM="oc-issue-gateway"
NODE_VM="oc-issue-node"
GW_PORT=18789

info()  { echo ""; echo ">>> $*"; }
ok()    { echo "    ✓ $*"; }
fail()  { echo "    ✗ $*"; }

version_gte() {
  python3 -c "
a=[int(x) for x in '$1'.split('.')]
b=[int(x) for x in '$2'.split('.')]
exit(0 if a >= b else 1)
"
}

info "Cleaning up any previous VMs"
multipass delete "$GW_VM" "$NODE_VM" 2>/dev/null || true
multipass purge 2>/dev/null || true

info "Launching gateway VM"
multipass launch --name "$GW_VM" --cpus 2 --memory 2G --disk 8G 24.04
GW_IP=$(multipass info "$GW_VM" --format json | python3 -c \
  "import sys,json; d=json.load(sys.stdin); print(list(d['info'].values())[0]['ipv4'][0])")
ok "gateway VM: $GW_VM ($GW_IP)"

info "Launching node VM"
multipass launch --name "$NODE_VM" --cpus 1 --memory 1G --disk 6G 24.04
ok "node VM: $NODE_VM"

gw()   { multipass exec "$GW_VM"   -- bash -c "$1"; }
node() { multipass exec "$NODE_VM" -- bash -c "$1"; }

info "Installing Node.js and OpenClaw $OC_VERSION on both VMs"
gw "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && \
    sudo apt-get install -y nodejs && sudo npm install -g openclaw@$OC_VERSION" 2>&1 | \
    grep -E "npm (warn|error)|installed|OpenClaw" | head -5 || true
node "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && \
     sudo apt-get install -y nodejs && sudo npm install -g openclaw@$OC_VERSION" 2>&1 | \
     grep -E "npm (warn|error)|installed|OpenClaw" | head -5 || true
ok "openclaw $OC_VERSION installed on both VMs"

info "Bootstrapping gateway"
gw "openclaw onboard --non-interactive --mode local --auth-choice skip \
    --gateway-bind lan --gateway-token mytoken123 \
    --install-daemon --skip-health --accept-risk 2>&1 | tail -3"

for i in $(seq 1 20); do
  if gw "nc -z 127.0.0.1 $GW_PORT 2>/dev/null"; then break; fi
  sleep 2
done
ok "gateway listening on :$GW_PORT"
GW_TOKEN=$(gw "openclaw config get gateway.token 2>/dev/null | tr -d '\n'")

info "Connecting node to gateway"
node "export XDG_RUNTIME_DIR=/run/user/\$(id -u); \
     OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 OPENCLAW_GATEWAY_TOKEN=mytoken123 \
     openclaw node install --host $GW_IP --port $GW_PORT \
     --display-name scraper-node --runtime node --force 2>&1 | tail -3"

REQ_ID=""
for i in $(seq 1 15); do
  REQ_ID=$(gw "openclaw devices list --json 2>/dev/null | \
    python3 -c \"import sys,json; \
    data=json.load(sys.stdin); \
    reqs=[p['requestId'] for p in data.get('pending',[]) if p.get('clientMode')=='node']; \
    print(reqs[0] if reqs else '')\"" 2>/dev/null || true)
  [ -n "$REQ_ID" ] && break
  sleep 2
done
[ -n "$REQ_ID" ] || { fail "no pending device request after 30s"; exit 1; }

info "Step 1 — Approving device pairing (openclaw devices approve)"
gw "openclaw devices approve '$REQ_ID' --token '$GW_TOKEN' 2>&1"
sleep 4

node "export XDG_RUNTIME_DIR=/run/user/\$(id -u); \
      export DBUS_SESSION_BUS_ADDRESS=unix:path=\$XDG_RUNTIME_DIR/bus; \
      systemctl --user restart openclaw-node 2>/dev/null || true"
sleep 5

DEVICE_PAIRED=$(gw "openclaw devices list --json 2>/dev/null | \
  python3 -c \"import sys,json; \
  data=json.load(sys.stdin); \
  paired=[d for d in data.get('paired',[]) if d.get('displayName')=='scraper-node']; \
  print('yes' if paired else 'no')\"" 2>/dev/null || echo "error")
[ "$DEVICE_PAIRED" = "yes" ] || { fail "device not in paired list"; exit 1; }
ok "device is in openclaw devices list paired[] — on < 2026.5.18 this was ALL that was needed"

info "Inspecting gateway on-disk state"
echo "  nodes/paired.json (what agents SEE on >= 2026.5.18):"
gw "cat ~/.openclaw/nodes/paired.json 2>/dev/null || echo '  (file not found)'"
echo "  nodes/pending.json (surface approval queue):"
gw "cat ~/.openclaw/nodes/pending.json 2>/dev/null | \
    python3 -c \"import sys,json; d=json.load(sys.stdin); \
    [print('  requestId:', k, '| displayName:', v.get('displayName'), '| commands:', v.get('commands',[])) for k,v in d.items()]\" \
    2>/dev/null || echo '  (file not found or empty)'"

if version_gte "$OC_VERSION" "2026.5.18"; then
  fail "nodes/paired.json is EMPTY — gateway hides commands for nodes in pending.json"
  echo "  Agents that call system.run receive: command not found / node not available"

  info "Attempting surface approval via CLI"
  SURFACE_REQ=$(gw "openclaw nodes list --json 2>/dev/null | \
    python3 -c \"import sys,json; \
    data=json.load(sys.stdin); \
    pending=[p for p in data.get('pending',[]) if p.get('displayName')=='scraper-node']; \
    print(pending[0].get('requestId','') if pending else '')\"" 2>/dev/null || true)
  [ -n "$SURFACE_REQ" ] || { fail "no pending surface request found"; exit 1; }
  ok "pending surface request: $SURFACE_REQ"

  echo "  Running: openclaw nodes approve '$SURFACE_REQ' --token <gateway-token>"
  APPROVE_OUT=$(gw "openclaw nodes approve '$SURFACE_REQ' --token '$GW_TOKEN' 2>&1" || true)
  echo "  Output: $APPROVE_OUT"
  fail "BLOCKED — missing scope: operator.admin"
  echo "  The gateway token and CLI device both only carry operator.pairing."
  echo "========================================================================"
  echo " RESULT: node surface approval BLOCKED on OpenClaw $OC_VERSION"
  echo "========================================================================"
else
  ok "On $OC_VERSION the gateway served commands from pending nodes — device approval was enough"
  echo "========================================================================"
  echo " RESULT: OpenClaw $OC_VERSION — no regression, device approval was sufficient"
  echo "========================================================================"
fi
</details>

Script 2 — prove operator.admin patch doesn't help (02-scope-escalation-test.sh)

We tried escalating the local CLI device to operator.admin via devices/paired.json (the same trick used for other CLI operations) and then calling openclaw nodes approve. It still fails. This proves the RPC enforces scope at the session token level, not from the stored device record.

<details> <summary>02-scope-escalation-test.sh</summary>
#!/usr/bin/env bash
# OPENCLAW_VERSION=2026.5.18 bash 02-scope-escalation-test.sh
set -euo pipefail

OC_VERSION="${OPENCLAW_VERSION:-2026.5.18}"
GW_VM="oc-issue-gateway"
NODE_VM="oc-issue-node"
GW_PORT=18789

info()  { echo ""; echo ">>> $*"; }
ok()    { echo "    ✓ $*"; }
fail()  { echo "    ✗ $*"; }

info "Cleaning up any previous VMs"
multipass delete "$GW_VM" "$NODE_VM" 2>/dev/null || true
multipass purge 2>/dev/null || true

info "Launching VMs and installing OpenClaw $OC_VERSION"
multipass launch --name "$GW_VM" --cpus 2 --memory 2G --disk 8G 24.04
multipass launch --name "$NODE_VM" --cpus 1 --memory 1G --disk 6G 24.04
GW_IP=$(multipass info "$GW_VM" --format json | python3 -c \
  "import sys,json; d=json.load(sys.stdin); print(list(d['info'].values())[0]['ipv4'][0])")

gw()   { multipass exec "$GW_VM"   -- bash -c "$1"; }
node() { multipass exec "$NODE_VM" -- bash -c "$1"; }

gw "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && \
    sudo apt-get install -y nodejs && sudo npm install -g openclaw@$OC_VERSION" 2>&1 | tail -3 || true
node "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && \
     sudo apt-get install -y nodejs && sudo npm install -g openclaw@$OC_VERSION" 2>&1 | tail -3 || true

info "Bootstrapping gateway"
gw "openclaw onboard --non-interactive --mode local --auth-choice skip \
    --gateway-bind lan --gateway-token mytoken123 \
    --install-daemon --skip-health --accept-risk 2>&1 | tail -3"
for i in $(seq 1 20); do
  if gw "nc -z 127.0.0.1 $GW_PORT 2>/dev/null"; then break; fi
  sleep 2
done
GW_TOKEN=$(gw "openclaw config get gateway.token 2>/dev/null | tr -d '\n'")
ok "gateway ready"

info "Step A — Escalate CLI device to operator.admin (via devices/paired.json patch)"
gw "openclaw cron list --token '$GW_TOKEN' 2>&1 || true"
sleep 3
CLI_REQ=$(gw "openclaw devices list --json 2>/dev/null | \
  python3 -c \"import sys,json; d=json.load(sys.stdin); \
  r=[p['requestId'] for p in d.get('pending',[]) if p.get('clientMode')=='cli']; \
  print(r[0] if r else '')\"" 2>/dev/null || true)
[ -n "$CLI_REQ" ] && gw "openclaw devices approve '$CLI_REQ' --token '$GW_TOKEN' 2>&1" || true
sleep 2

CLI_DEVICE_ID=$(gw "openclaw devices list --json 2>/dev/null | \
  python3 -c \"import sys,json; d=json.load(sys.stdin); \
  devs=[p for p in d.get('paired',[]) if p.get('clientMode')=='cli']; \
  print(devs[0].get('deviceId','') if devs else '')\"" 2>/dev/null || true)
[ -n "$CLI_DEVICE_ID" ] || { fail "CLI device not found in paired list"; exit 1; }

gw "jq --arg id '$CLI_DEVICE_ID' '
  if .[\$id] then
    .[\$id].scopes = [\"operator.admin\",\"operator.read\",\"operator.write\",\"operator.pairing\",\"operator.approvals\"] |
    .[\$id].approvedScopes = [\"operator.admin\",\"operator.read\",\"operator.write\",\"operator.pairing\",\"operator.approvals\"]
  else . end
' ~/.openclaw/devices/paired.json > /tmp/p.tmp && mv /tmp/p.tmp ~/.openclaw/devices/paired.json"

gw "export XDG_RUNTIME_DIR=\"\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)}\";
    systemctl --user restart openclaw-gateway 2>/dev/null || sudo systemctl restart openclaw-gateway 2>/dev/null || true"
sleep 5

CLI_SCOPES=$(gw "openclaw devices list --json 2>/dev/null | \
  python3 -c \"import sys,json; d=json.load(sys.stdin); \
  devs=[p for p in d.get('paired',[]) if p.get('clientMode')=='cli']; \
  print(devs[0].get('scopes',[]) if devs else [])\"" 2>/dev/null || true)
echo "  CLI device scopes after patch: $CLI_SCOPES"
echo "$CLI_SCOPES" | grep -q "operator.admin" || { fail "escalation failed"; exit 1; }
ok "CLI device has operator.admin in devices/paired.json"

info "Step B — Connect node and approve device pairing"
node "export XDG_RUNTIME_DIR=/run/user/\$(id -u); \
     OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 OPENCLAW_GATEWAY_TOKEN=mytoken123 \
     openclaw node install --host $GW_IP --port $GW_PORT \
     --display-name scraper-node --runtime node --force 2>&1 | tail -3"
REQ_ID=""
for i in $(seq 1 15); do
  REQ_ID=$(gw "openclaw devices list --json 2>/dev/null | \
    python3 -c \"import sys,json; d=json.load(sys.stdin); \
    r=[p['requestId'] for p in d.get('pending',[]) if p.get('clientMode')=='node']; \
    print(r[0] if r else '')\"" 2>/dev/null || true)
  [ -n "$REQ_ID" ] && break; sleep 2
done
[ -n "$REQ_ID" ] || { fail "no pending device request"; exit 1; }
gw "openclaw devices approve '$REQ_ID' --token '$GW_TOKEN' 2>&1"
sleep 4
node "export XDG_RUNTIME_DIR=/run/user/\$(id -u);
      systemctl --user restart openclaw-node 2>/dev/null || true"
sleep 5
ok "node device pairing approved"

info "Step C — Find pending surface request"
SURFACE_REQ=""
for i in $(seq 1 10); do
  SURFACE_REQ=$(gw "openclaw nodes list --json 2>/dev/null | \
    python3 -c \"import sys,json; d=json.load(sys.stdin); \
    p=[x for x in d.get('pending',[]) if x.get('displayName')=='scraper-node']; \
    print(p[0].get('requestId','') if p else '')\"" 2>/dev/null || true)
  [ -n "$SURFACE_REQ" ] && break
  echo "    waiting..."; sleep 3
done
[ -n "$SURFACE_REQ" ] || { fail "no pending surface request"; exit 1; }
ok "pending surface request: $SURFACE_REQ"

info "Step D — openclaw nodes approve WITH operator.admin in devices/paired.json"
echo "  (CLI device has operator.admin in stored record — will the session pick it up?)"
APPROVE_OUT=$(gw "openclaw nodes approve '$SURFACE_REQ' 2>&1" || true)
echo "  Output: $APPROVE_OUT"

if echo "$APPROVE_OUT" | grep -qi "approved\|success"; then
  ok "SUCCEEDED — hypothesis confirmed, scope patch works for nodes approve too"
elif echo "$APPROVE_OUT" | grep -qi "operator.admin\|missing scope"; then
  fail "STILL blocked — scope enforced at session level, not from devices/paired.json record"
  echo "========================================================================"
  echo " RESULT: patching devices/paired.json does NOT help for nodes approve."
  echo " The RPC enforces operator.admin at the session token level."
  echo " File-surgery on nodes/paired.json is the only workaround."
  echo "========================================================================"
else
  echo "  Unexpected: $APPROVE_OUT"
fi
</details>

Expected output on 2026.5.18

>>> Attempting surface approval via CLI
    ✓ pending surface request: b7c0c676-88cf-4e1e-a058-cb586c888f44

  Running: openclaw nodes approve 'b7c0c676-...' --token <gateway-token>
  Output: nodes approve failed: GatewayClientRequestError: missing scope: operator.admin

    ✗ BLOCKED — missing scope: operator.admin

>>> Step D — openclaw nodes approve WITH operator.admin in devices/paired.json
  CLI device scopes after patch: ['operator.admin', 'operator.read', ...]
  Output: nodes approve failed: GatewayClientRequestError: missing scope: operator.admin

    ✗ STILL blocked — scope enforced at session level, not from devices/paired.json record

Why the scope requirement is inconsistent

openclaw devices approve — grants a machine ongoing WebSocket access to the gateway — requires operator.pairing.
openclaw nodes approve — confirms what that already-trusted machine advertises it can do — requires operator.admin.

The follow-up confirmation step requires a higher scope than the initial trust grant. This is backwards. If an operator can let a machine onto the gateway, they can approve its surface.

Current workaround

The only functional path is bypassing the RPC entirely and editing gateway state files directly on the gateway host:

# Move entry from ~/.openclaw/nodes/pending.json to ~/.openclaw/nodes/paired.json
# Restart openclaw-gateway (reload state)
# Restart openclaw-node (reconnect → effectiveCommands populated)

This replicates exactly what the node.pair.approve RPC handler does server-side — without the scope check.

Proposed fixes

Option A — Lower the scope requirement (preferred)

In the node.pair.approve RPC handler, change the required scope from operator.adminoperator.pairing.

File: src/gateway/rpc/node-pair-approve.ts (or wherever node.pair.approve is registered).

Option B — Combined approval

When openclaw devices approve is called for a node-mode device, automatically promote the corresponding surface request in the same transaction. One CLI call, one scope, restores pre-2026.5.18 behaviour.

Option C — openclaw nodes approve --gateway-token <token>

Allow passing the gateway's own bootstrap token (set during openclaw onboard --gateway-token) as an explicit flag with elevated privilege for this specific RPC.


Environment:

  • OpenClaw: 2026.5.18
  • OS: Ubuntu 24.04 (Multipass VM, arm64/amd64)

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

openclaw - ✅(Solved) Fix [Bug]: openclaw nodes approve requires operator.admin — blocks automation after 2026.5.18 node surface gate [2 pull requests, 3 comments, 3 participants]