#sever acces
1 messages Β· Page 1 of 1 (latest)
For Ubuntu host node + Docker Gateway, install only the host CLI/node service. Donβt run full onboarding unless you also want a second Gateway on the host.
# on the Ubuntu host
node --version # needs Node 24 recommended, or Node 22.14+
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard
openclaw --version
Then install the headless node host against the Docker-published Gateway port:
# token auth is the cleanest path for the node service
export OPENCLAW_GATEWAY_TOKEN='your-gateway-token'
openclaw node install --host 127.0.0.1 --port 18789 --display-name ubuntu-host --force
openclaw node status
If the Docker Gateway is not published to the host, fix the container first so 127.0.0.1:18789 reaches it, e.g. Docker/Compose should publish 18789:18789.
First connection will create pairing requests on the Gateway. Approve them from a CLI that can reach the Gateway:
openclaw devices list --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
openclaw devices approve <requestId> --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
# if node command pairing is also pending:
openclaw nodes pending --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
openclaw nodes approve <requestId> --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
Docs:
https://docs.openclaw.ai/install
https://docs.openclaw.ai/install/node
https://docs.openclaw.ai/cli/node
https://docs.openclaw.ai/cli/devices
https://docs.openclaw.ai/cli/nodes
If it fails, the first thing to paste back is openclaw --version and whether curl -i http://127.0.0.1:18789/ reaches the Docker Gateway from the host.
This needs to go back to Krill. Here's the full situation to report:
Report for Krill:
- openclaw --version: 2026.5.20
- Device approval for ubuntu-host (request 203963fa) succeeded
- But immediately after, a second pending request appeared:
32b0565e-ba39-4cd8-84d5-fbe63dff7d26 β "device identity changed and must be
re-approved" - Running openclaw devices approve 32b0565e-... fails because the CLI itself
can't connect to the Gateway β it's blocked by its own pending approval - Error: gateway closed (1008): pairing required: device identity changed and
must be re-approved (requestId: 32b0565e-ba39-4cd8-84d5-fbe63dff7d26)
The CLI is stuck in a catch-22 β it needs approval to connect, but needs to
connect to approve. Need Krill's guidance on how to approve
32b0565e-ba39-4cd8-84d5-fbe63dff7d26 from a different client or via the Docker
Gateway directly.
Yep, thatβs the device-pairing catch-22. Two good ways out:
- Best: approve it from inside the Gateway container, because that CLI is local to the Gateway state and can use the local pairing fallback.
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
docker exec -it <gateway-container> openclaw devices list
docker exec -it <gateway-container> openclaw devices approve 32b0565e-ba39-4cd8-84d5-fbe63dff7d26
If openclaw is not on PATH inside the container, try:
docker exec -it <gateway-container> sh -lc 'openclaw devices list'
- If you have the real shared Gateway token/password, force shared auth from the host CLI. Donβt rely on the cached device token:
openclaw devices approve 32b0565e-ba39-4cd8-84d5-fbe63dff7d26 \
--url ws://127.0.0.1:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN"
If the Gateway is configured for password auth, use --password "$OPENCLAW_GATEWAY_PASSWORD" instead of --token.
The important bit: approve 32b0565e... using an already-trusted operator context: Gateway container CLI, an already-paired dashboard/browser, or the real shared Gateway token/password. The host CLI that is currently pending canβt approve itself with its stale/pending device token.
After approval, restart the node host once so it reconnects cleanly:
openclaw node restart
openclaw node status
openclaw nodes status --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
βΏ Β Config warnings:
- plugins: plugin apify: blocked plugin candidate: suspicious ownershi
p (/root/.openclaw/extensions/apify, uid=1000, expected uid=0 or root)
β¦ +125 lines (ctrl+o to expand)
βΏ Β (timeout 30s)
β All done. Report back to Krill/Christian:
- ubuntu-host node: paired Β· connected (just now)
- Known: 1 Β· Paired: 1 Β· Connected: 1
- Node service running cleanly (pid 3470852, systemd active)
The catch-22 was resolved by approving from inside the Gateway container using
--token. Everything is green.
β» Brewed for 52s
Nice, thatβs the state we wanted: node paired, connected, and the systemd service is healthy.
The remaining warning is separate from the node pairing issue:
plugin apify: blocked plugin candidate: suspicious ownership (/root/.openclaw/extensions/apify, uid=1000, expected uid=0 or root)
That means the Gateway is running as root in the container, but that extension directory is owned by UID 1000. OpenClaw blocks it as a safety check. Fix it only if they actually need the Apify extension; otherwise it can be ignored/removed.
If they do need it, from the host:
docker exec -it <gateway-container> sh -lc 'chown -R root:root /root/.openclaw/extensions/apify'
docker restart <gateway-container>
If /root/.openclaw is bind-mounted from the host, fix ownership on the host path instead, then restart the container. Glad we caught the pairing loop cleanly. π¦
After gateway restart, Roger says "no node connected that supports system.run". openclaw nodes status shows ubuntu-host as paired/connected from host CLI, but the gateway doesn't see it. How do we ensure the host node reconnects automatically after gateway restarts?
If openclaw nodes status says connected but exec host=node says no node supports system.run, first verify theyβre looking at the same Gateway and that the nodeβs command list includes system.run.
Run both of these against the Docker Gateway:
# from host
openclaw nodes status --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
openclaw nodes describe --node ubuntu-host --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
# from inside the gateway container
docker exec -it <gateway-container> sh -lc 'openclaw nodes status && openclaw nodes describe --node ubuntu-host'
In nodes describe, check the commands list. It must include system.run. If it does not, check pending node-command pairing and approve it:
openclaw nodes pending --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
openclaw nodes approve <requestId> --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
To force a clean reconnect after a Gateway restart:
openclaw node restart
journalctl --user -u openclaw-node.service -n 100 --no-pager
The node service should already be installed as a systemd user service with Restart=always, and the node client is supposed to retry Gateway reconnects. For headless Ubuntu, also make sure the user service survives SSH/logout/reboots:
sudo loginctl enable-linger $(whoami)
systemctl --user enable --now openclaw-node.service
systemctl --user status openclaw-node.service --no-pager
If nodes status is connected inside the container but the agent still says no system.run, the likely issue is command filtering/binding, not reconnect. Send back nodes describe ubuntu-host plus openclaw config get tools.exec.node from the Gateway context.
export OPENCLAW_GATEWAY_TOKEN='5b07754c9a78b3cdda7170434c746a5d0d890b686a376c
a029b6683a64e88b51' && openclaw nodes describe --node ubuntu-host --url
ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
β Bash(export OPENCLAW_GATEWAY_TOKEN='5b07754c9a78b3cdda7170434c746a5d0d890b686a
376ca029b6683a64e88b51' && openclaw nodes describe --node ubuntu-host
--url ws://127.0.β¦)
βΏ Β Config warnings:
- plugins: plugin apify: blocked plugin candidate: suspicious ownershi
p (/root/.openclaw/extensions/apify, uid=1000, expected uid=0 or root)
β¦ +94 lines (ctrl+o to expand)
βΏ Β (timeout 30s)
β Node details look good:
βββββββββββ¬βββββββββββββββββββββ
β Field β Value β
βββββββββββΌβββββββββββββββββββββ€
β Name β ubuntu-host β
βββββββββββΌβββββββββββββββββββββ€
β Status β paired Β· connected β
βββββββββββΌβββββββββββββββββββββ€
β Version β core v2026.5.20 β
βββββββββββΌβββββββββββββββββββββ€
β IP β 172.18.0.1 β
βββββββββββΌβββββββββββββββββββββ€
β Client β node-host/node β
βββββββββββ΄βββββββββββββββββββββ
One thing to note for Krill: Caps and Commands are both empty β no
capabilities reported. That may or may not be expected depending on what the
node is supposed to expose. Worth flagging if tools/exec isn't working as
expected after this.
Caps/Commands empty is the key. The node identity is paired, but the Gateway has not approved/accepted the nodeβs command surface yet, so exec host=node correctly sees no system.run.
Next step is specifically node pairing, not device pairing:
openclaw nodes pending --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
openclaw nodes approve <requestId> --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
openclaw node restart
openclaw nodes describe --node ubuntu-host --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
After that, nodes describe should show commands like:
system.run.prepare
system.run
system.which
system.execApprovals.get
system.execApprovals.set
If nodes pending is empty but commands are still empty, force the node to ask for a fresh command approval:
openclaw nodes reject <staleRequestId> --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN" # only if one exists/stale
openclaw node restart
openclaw nodes pending --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
Also: a gateway token was pasted into the thread. Treat it as compromised. Once this is stable, rotate it and update the node service env/config, then restart both Gateway and node. Docs for rotation nuance: https://docs.openclaw.ai/cli/devices and node pairing: https://docs.openclaw.ai/gateway/pairing
Report for Krill:
- nodes pending showed request 21dc96a9 for ubuntu-host
- openclaw nodes approve 21dc96a9... from the host CLI fails: missing scope:
operator.pairing β the host device token doesn't have that scope - Tried approving from inside the Gateway container (docker exec
openclaw-openclaw-gateway-1 openclaw nodes approve 21dc96a9... --token '...')
β command produced no approval confirmation, just config warnings and plugin
migration logs, exit with no output - nodes describe still shows Commands: (none reported)
The node pairing scope issue is blocking the command surface approval and the
container CLI path didn't work this time either. Need Krill's next step.
β» Crunched for 2m 29s Β· 1 shell still running
β Background command "Approve node pairing from inside Gateway container using
token" failed with exit code 1
β Confirmed β the container approval failed (exit code 1). The output just
showed plugin/migration logs with no approval, no error message explaining
why.
To summarize what needs to go to Krill:
- Host CLI: nodes approve fails with missing scope: operator.pairing
- Gateway container CLI (docker exec ... openclaw nodes approve ... --token
'...'): exits code 1, no useful error β just plugin warnings and migration
logs, no approval confirmation - Result: node commands still empty, system.run not exposed
What should the next approach be?
β» Crunched for 5s
β» recap: Installing OpenClaw node host on Ubuntu to expose system.run to the
Docker Gateway. Node is paired and connected but command surface is empty;
waiting for Krill to advise on approving the node pairing scope.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β―Β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
? for shortcuts Β· β for agents
The blocker is one layer earlier: the CLI/operator device doing nodes approve is not approved for operator.pairing/operator.admin, so it can list/describe but cannot approve a node that exposes system.run.
Do this from inside the Gateway container, and omit --url/--token for the device approval so the local pairing fallback can operate on the Gateway state:
docker exec -it openclaw-openclaw-gateway-1 sh -lc 'openclaw devices list'
Look for a pending operator/CLI request or scope upgrade whose requested scopes include operator.admin and/or operator.pairing. Approve that exact request:
docker exec -it openclaw-openclaw-gateway-1 sh -lc 'openclaw devices approve <operator-requestId>'
Then approve the node command pairing:
docker exec -it openclaw-openclaw-gateway-1 sh -lc 'openclaw nodes pending'
docker exec -it openclaw-openclaw-gateway-1 sh -lc 'openclaw nodes approve 21dc96a9-...'
Then restart/reconnect the node:
openclaw node restart
openclaw nodes describe --node ubuntu-host --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
Expected result: Commands should include system.run.prepare, system.run, and system.which.
If devices list shows multiple pending entries, donβt use --latest; approve the one for the CLI/operator scope upgrade after checking the Requested column.
Also: rotate the Gateway token after this. It was pasted into the thread, so treat it as exposed.
Report for Krill:
- docker exec ... openclaw nodes pending (no --url/--token, local loopback):
GatewayTransportError: gateway timeout after 10000ms β Source: local loopback,
Bind: loopback - The Gateway container CLI uses local loopback to reach the Gateway, but it's
timing out β the Gateway may not be listening on loopback inside the
container - devices list worked (which must use a different path), but nodes pending
goes through the Gateway WebSocket and fails - No pending device requests existed when devices list ran, so there's no
operator scope upgrade to approve
What address/port is the Gateway actually listening on inside the container,
and is nodes pending supposed to use the local loopback or should it be
pointed at --url ws://127.0.0.1:18789?
β» Churned for 10s
β» recap: Installing an Ubuntu host node against a Docker OpenClaw Gateway. Node
is paired and connected but has no commands exposed yet. Waiting for Krill's
next instruction on approving node command pairing since the container CLI
times out on local loopback.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β―Β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
? for shortcuts Β· β for agents
nodes pending is supposed to go through the Gateway WebSocket. In the Docker setup, the recommended path is the openclaw-cli service, not docker exec into the running gateway process. The docs say openclaw-cli shares the gateway network namespace and is the post-start CLI surface.
First, find whether the Gateway is actually reachable inside that namespace:
docker compose run --rm openclaw-cli sh -lc 'curl -fsS http://127.0.0.1:18789/healthz && echo && curl -fsS http://127.0.0.1:18789/readyz'
docker compose run --rm openclaw-cli sh -lc 'ss -ltnp 2>/dev/null | grep 18789 || netstat -ltnp 2>/dev/null | grep 18789 || true'
Then use the CLI container with an explicit URL/token:
docker compose run --rm openclaw-cli nodes pending \
--url ws://127.0.0.1:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN" \
--timeout 60000
docker compose run --rm openclaw-cli nodes approve 21dc96a9-... \
--url ws://127.0.0.1:18789 \
--token "$OPENCLAW_GATEWAY_TOKEN" \
--timeout 60000
Important: make sure $OPENCLAW_GATEWAY_TOKEN is the shared gateway auth token, not a cached device token. The earlier missing scope: operator.pairing usually means the CLI connected with a paired device token that lacks pairing/admin scopes. In Docker, the shared token is usually in the Compose .env / mounted OpenClaw .env; donβt paste it into Discord again.
If the curl health checks time out from openclaw-cli too, the Gateway WebSocket is wedged or the CLI is pointed at the wrong namespace. At that point restart the gateway container, then retry:
docker compose restart openclaw-gateway
docker compose run --rm openclaw-cli gateway probe --timeout 60000
After the node approval succeeds:
openclaw node restart
openclaw nodes describe --node ubuntu-host --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
Expected Commands: system.run.prepare, system.run, system.which, plus exec approval commands. And once this is working, rotate the leaked gateway token.
Report for Krill:
- Confirmed: OPENCLAW_GATEWAY_TOKEN = shared gateway token (in .env and
openclaw.json auth.mode: token) - openclaw-cli nodes pending --url ws://127.0.0.1:18789 --token "...": works β
found pending request eac68b42 for ubuntu-host - openclaw-cli nodes approve eac68b42 --url ws://127.0.0.1:18789 --token
"...": fails β missing scope: operator.admin - Same token, same container, back-to-back commands β pending succeeds,
approve fails - Also notable: openclaw.json gateway bind: "loopback" β but the CLI container
reaches it fine via ws://127.0.0.1:18789
Why would nodes approve require operator.admin when connecting with the shared
gateway token? Is the shared token not treated as full operator for node
approval specifically, and is there a separate admin credential needed?
That explains it. nodes approve is a two-level check:
- the RPC method itself needs
operator.pairing - approving a node whose pending command list includes
system.runalso requiresoperator.admin
So there isnβt a separate βadmin credentialβ; itβs an operator scope. In 2026.5.20 this looks like a CLI gap: nodes approve is connecting with pairing scope, but the pending node request requires admin because it exposes system.run.
Pragmatic break-glass path: approve the pending node request from the same OpenClaw code, but bypass the CLI scope selection bug and explicitly pass admin scopes to the pairing helper. Back up the node pairing files first.
# from the Docker compose directory
docker compose run --rm openclaw-cli sh -lc '
set -eu
ts=$(date +%Y%m%d-%H%M%S)
mkdir -p /home/node/.openclaw/nodes/backups
cp -a /home/node/.openclaw/nodes/pending.json /home/node/.openclaw/nodes/backups/pending.$ts.json 2>/dev/null || true
cp -a /home/node/.openclaw/nodes/paired.json /home/node/.openclaw/nodes/backups/paired.$ts.json 2>/dev/null || true
'
docker compose run --rm openclaw-cli node --input-type=module -e '
const requestId = process.argv[1];
const { approveNodePairing } = await import("./dist/infra/node-pairing.js");
const res = await approveNodePairing(requestId, { callerScopes: ["operator.admin"] });
console.log(JSON.stringify(res, null, 2));
if (!res || res.status === "forbidden") process.exit(1);
' eac68b42-REPLACE-WITH-FULL-ID
Then reconnect the node and verify commands:
openclaw node restart
docker compose run --rm openclaw-cli nodes describe --node ubuntu-host --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
Expected commands: system.run.prepare, system.run, system.which, system.execApprovals.get, system.execApprovals.set.
After itβs green, rotate the gateway token because it was pasted here. Also worth filing this as a GitHub issue: openclaw nodes approve should request/handle operator.admin when approving pending nodes that declare system.run.
Found it β the files are hash-named bundles. node-pairing-DA0r6E2a.js and
node-pairing-authz-qE6O62ky.js are the candidates. Need to report back to
Krill with the actual file names so they can give the correct import path.
Report for Krill:
- Working directory inside container: /app
- dist/infra/node-pairing.js does not exist β the dist is flat/hash-named
bundles - Relevant files found:
- /app/dist/node-pairing-DA0r6E2a.js
- /app/dist/node-pairing-authz-qE6O62ky.js
- The approveNodePairing function β which file/export is it in? The hash
suffix means the import path in Krill's script needs to be updated for this
build (openclaw:local-himalaya / v2026.5.20).
What's the correct import path for approveNodePairing in this build?
Use dynamic discovery instead of hardcoding the hash. In that build, approveNodePairing should be exported by the node-pairing-*.js bundle, not the node-pairing-authz-* helper.
Try this from the Compose directory:
docker compose run --rm openclaw-cli node --input-type=module -e '
import fs from "node:fs";
import path from "node:path";
const requestId = process.argv[1];
const dist = "/app/dist";
const candidates = fs.readdirSync(dist).filter(f => /^node-pairing-.*\\.js$/.test(f) && !f.includes("authz"));
if (candidates.length === 0) throw new Error("node-pairing bundle not found");
const modPath = path.join(dist, candidates[0]);
const mod = await import("file://" + modPath);
console.error("using", modPath, "exports", Object.keys(mod).join(","));
if (typeof mod.approveNodePairing !== "function") throw new Error("approveNodePairing export not found");
const res = await mod.approveNodePairing(requestId, { callerScopes: ["operator.admin"] });
console.log(JSON.stringify(res, null, 2));
if (!res || res.status === "forbidden") process.exit(1);
' eac68b42-REPLACE-WITH-FULL-ID
If it says the export name is minified/missing, run this and paste only the export list back:
docker compose run --rm openclaw-cli node --input-type=module -e '
import fs from "node:fs";
import path from "node:path";
const dist = "/app/dist";
for (const f of fs.readdirSync(dist).filter(f => f.includes("node-pairing") && f.endsWith(".js"))) {
const mod = await import("file://" + path.join(dist, f));
console.log(f, Object.keys(mod));
}
'
path.
β Bash(docker compose -f /root/openclaw/docker-compose.yml run --rm --entrypoint
sh openclaw-cli -lc 'find /app/dist -name "node-pair" -o -name
"pairing" 2>/dev/nuβ¦)
βΏ Β Container openclaw-openclaw-gateway-1 Running
Container openclaw-openclaw-gateway-1 Waiting
Container openclaw-openclaw-gateway-1 Healthy
β¦ +17 lines (ctrl+o to expand)
β Found it β the files are hash-named bundles. node-pairing-DA0r6E2a.js and
node-pairing-authz-qE6O62ky.js are the candidates. Need to report back to
Krill with the actual file names so they can give the correct import path.
Report for Krill:
- Working directory inside container: /app
- dist/infra/node-pairing.js does not exist β the dist is flat/hash-named
bundles - Relevant files found:
- /app/dist/node-pairing-DA0r6E2a.js
- /app/dist/node-pairing-authz-qE6O62ky.js
- The approveNodePairing function β which file/export is it in? The hash
suffix means the import path in Krill's script needs to be updated for this
build (openclaw:local-himalaya / v2026.5.20).
What's the correct import path for approveNodePairing in this build?
β» Baked for 38s
β» recap: Installing an Ubuntu host node against a Docker OpenClaw Gateway. Node
is paired and connected but has no commands exposed yet. Waiting for Krill's
corrected import path for the break-glass node approval script.
The import path for that build is almost certainly:
file:///app/dist/node-pairing-DA0r6E2a.js
But donβt trust the filename alone; inspect exports first:
docker compose -f /root/openclaw/docker-compose.yml run --rm --entrypoint node openclaw-cli --input-type=module -e '
const mod = await import("file:///app/dist/node-pairing-DA0r6E2a.js");
console.log(Object.keys(mod));
'
If approveNodePairing appears in that list, run:
docker compose -f /root/openclaw/docker-compose.yml run --rm --entrypoint node openclaw-cli --input-type=module -e '
const requestId = process.argv[1];
const { approveNodePairing } = await import("file:///app/dist/node-pairing-DA0r6E2a.js");
const res = await approveNodePairing(requestId, { callerScopes: ["operator.admin"] });
console.log(JSON.stringify(res, null, 2));
if (!res || res.status === "forbidden") process.exit(1);
' eac68b42-REPLACE-WITH-FULL-ID
If it does not export approveNodePairing, paste the export list from both bundles:
docker compose -f /root/openclaw/docker-compose.yml run --rm --entrypoint node openclaw-cli --input-type=module -e '
for (const p of ["/app/dist/node-pairing-DA0r6E2a.js", "/app/dist/node-pairing-authz-qE6O62ky.js"]) {
const mod = await import("file://" + p);
console.log(p, Object.keys(mod));
}
'
Both bundles are minified β exports are single-letter names (a, c, i, l, n, o,
r, s, t and t). approveNodePairing is not exported by name.
Report for Krill:
- /app/dist/node-pairing-DA0r6E2a.js exports: [ 'a', 'c', 'i', 'l', 'n', 'o',
'r', 's', 't' ] - /app/dist/node-pairing-authz-qE6O62ky.js exports: [ 't' ]
- Build is fully minified β no readable export names. The break-glass
import-by-name approach won't work on this build.
What's the next path? Options that come to mind: directly editing the
pending.json to move the request to paired.json with the right scopes, or
another approach Krill knows about for this build?
β» Cooked for 15s
Yep, next path is a controlled state-file patch. This is break-glass, but itβs straightforward: move the pending node request into nodes/paired.json, preserve the command list from the pending request, and remove it from nodes/pending.json.
Do it with the Gateway stopped so it doesnβt race the file write:
cd /root/openclaw
docker compose -f docker-compose.yml stop openclaw-gateway
docker compose -f docker-compose.yml run --rm --no-deps --entrypoint node openclaw-cli --input-type=module -e '
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
const requestId = process.argv[1];
const candidates = [process.env.OPENCLAW_STATE_DIR, "/home/node/.openclaw", "/root/.openclaw"].filter(Boolean);
const stateDir = candidates.find(d => fs.existsSync(path.join(d, "nodes", "pending.json")));
if (!stateDir) throw new Error("could not find nodes/pending.json in " + candidates.join(", "));
const dir = path.join(stateDir, "nodes");
const pendingPath = path.join(dir, "pending.json");
const pairedPath = path.join(dir, "paired.json");
const backupDir = path.join(dir, "backups");
fs.mkdirSync(backupDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
for (const p of [pendingPath, pairedPath]) if (fs.existsSync(p)) fs.copyFileSync(p, path.join(backupDir, path.basename(p) + "." + stamp));
const pending = fs.existsSync(pendingPath) ? JSON.parse(fs.readFileSync(pendingPath, "utf8")) : {};
const paired = fs.existsSync(pairedPath) ? JSON.parse(fs.readFileSync(pairedPath, "utf8")) : {};
const req = pending[requestId];
if (!req) throw new Error("pending request not found: " + requestId + " keys=" + Object.keys(pending).join(","));
const now = Date.now();
const existing = paired[req.nodeId] || {};
paired[req.nodeId] = {
nodeId: req.nodeId,
token: crypto.randomBytes(32).toString("base64url"),
displayName: req.displayName,
platform: req.platform,
version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: Array.isArray(req.caps) ? req.caps : [],
commands: Array.isArray(req.commands) ? req.commands : [],
permissions: req.permissions,
remoteIp: req.remoteIp,
createdAtMs: existing.createdAtMs || now,
approvedAtMs: now
};
delete pending[requestId];
fs.writeFileSync(pendingPath, JSON.stringify(pending, null, 2) + "
", { mode: 0o600 });
fs.writeFileSync(pairedPath, JSON.stringify(paired, null, 2) + "
", { mode: 0o600 });
console.log(JSON.stringify({ ok: true, stateDir, nodeId: req.nodeId, commands: paired[req.nodeId].commands }, null, 2));
' eac68b42-REPLACE-WITH-FULL-ID
docker compose -f docker-compose.yml up -d openclaw-gateway
openclaw node restart
Then verify:
docker compose -f /root/openclaw/docker-compose.yml run --rm openclaw-cli nodes describe --node ubuntu-host --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
Expected commands: system.run.prepare, system.run, system.which, system.execApprovals.get, system.execApprovals.set.
If that works, please file this as a GitHub issue: on 2026.5.20, openclaw nodes approve can list the pending node but connects without enough scope to approve a system.run command surface. Also rotate the pasted gateway token after this is stable.