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.
01-reproduce.sh
#!/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
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.
02-scope-escalation-test.sh
#!/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
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.admin → operator.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)
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 requiresoperator.adminscope. 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 bothnodes/pending.jsonandnodes/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 callsystem.run(or any node command) getcommand not found / node not availableuntilopenclaw nodes approve <requestId>is called — which requiresoperator.adminscope that automation cannot obtain.Reproduction (requires multipass)
Script 1 — show the failure (
01-reproduce.sh)Run with
OPENCLAW_VERSION=2026.5.12to see the old clean behaviour, then withOPENCLAW_VERSION=2026.5.18to see the breakage.01-reproduce.sh
Script 2 — prove operator.admin patch doesn't help (
02-scope-escalation-test.sh)We tried escalating the local CLI device to
operator.adminviadevices/paired.json(the same trick used for other CLI operations) and then callingopenclaw nodes approve. It still fails. This proves the RPC enforces scope at the session token level, not from the stored device record.02-scope-escalation-test.sh
Expected output on 2026.5.18
Why the scope requirement is inconsistent
openclaw devices approve— grants a machine ongoing WebSocket access to the gateway — requiresoperator.pairing.openclaw nodes approve— confirms what that already-trusted machine advertises it can do — requiresoperator.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:
This replicates exactly what the
node.pair.approveRPC handler does server-side — without the scope check.Proposed fixes
Option A — Lower the scope requirement (preferred)
In the
node.pair.approveRPC handler, change the required scope fromoperator.admin→operator.pairing.File:
src/gateway/rpc/node-pair-approve.ts(or wherevernode.pair.approveis registered).Option B — Combined approval
When
openclaw devices approveis 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: