From d9a882e69c7d538d24e747969c6987315dec9dfc Mon Sep 17 00:00:00 2001 From: Ali S Date: Tue, 1 Sep 2026 23:35:01 +0000 Subject: [PATCH 1/3] fix(items): approve openclaw device pairing in-container Deploying this item produced a Control UI nobody could get into. The browser authenticated, then stopped at "device pairing required (requestId: ...)" and named a CLI on the Gateway host to approve it. Device pairing is a second gate, evaluated only after gateway token auth has already succeeded. A new browser's request can be approved only by an already-paired admin session, and on a fresh deployment nothing is ever paired -- so the item deadlocked, and the remedy the error names needs a host shell a one-click deployment does not have. Neither the token nor the origin allowlist was at fault; both already pass before this point. Satisfy the gate rather than remove it. The component now runs a watcher beside the gateway that polls `devices list --json` and approves pending requests. Upstream anticipates this: `shouldPreserveLocalCliSharedAuthScopes` carries a dedicated `cli_container_local` locality that preserves operator scopes for a token-authenticated CLI inside the gateway's own container, so `devices approve` works over loopback without pairing of its own. Rejected `gateway.controlUi.dangerouslyDisableDeviceAuth`, which clears the same gate by discarding device identity altogether: browsers keep their device keypair and revocable device token this way, `openclaw security audit` stays clean, and the mechanism survives 2026.8.x, where that key is retired and inert and would silently restore the deadlock on an image bump. Also verified against the pinned release that `allowInsecureAuth` ("does not bypass pairing checks", localhost-only) and `nodes.pairing.autoApproveCidrs` (never applies to browser clients) are not alternatives, despite both being widely cited as such. Two greps rather than a JSON parser because the image ships no jq and a node -e script cannot be quoted inside this scalar. `requestId` appears only on pending entries -- paired rows carry deviceId, displayName, roles, scopes, tokens and IP -- so the match cannot touch an existing device. The listing's claim that you just enter the token on first visit was false for every deployment; it now describes both gates and says plainly that the token is what guards an admin surface. Verified: typecheck clean, full corpus 377/377; the folded scalar resolves to one well-formed shell string whose --batch-json argument parses as the three intended ops; `sh -n` accepts the inner script; and the grep pipeline extracts exactly the pending requestId from a payload carrying both a pending entry and a paired device, ignoring the latter's UUID. Not verified -- no container runtime in the devcontainer: the live smoke test that the watcher actually clears the pairing screen. Closes #6 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3 --- items/openclaw/components/openclaw.yaml | 46 +++++++++++++++++++++++++ items/openclaw/listing.yaml | 16 +++++++++ 2 files changed, 62 insertions(+) diff --git a/items/openclaw/components/openclaw.yaml b/items/openclaw/components/openclaw.yaml index 87ab1b1..bb90687 100755 --- a/items/openclaw/components/openclaw.yaml +++ b/items/openclaw/components/openclaw.yaml @@ -28,12 +28,58 @@ spec: # the config lives on a persistent volume, so a guard would pin the very first # boot's origin forever and leave a re-deployed app permanently unreachable. # `config set` is declarative, so re-applying is a no-op. + # + # GATEWAY AUTH IS UNTOUCHED. `gateway.auth.mode` stays at its default + # (`token`), so every Control UI connection still has to present + # OPENCLAW_GATEWAY_TOKEN or it is rejected at the WebSocket handshake. The + # loop below deals with a SECOND, separate gate that runs only after that + # token check has already passed. + # + # That second gate is device pairing, and it deadlocks a one-click + # deployment: a new browser's pairing request can only be approved by an + # already-paired admin session, and on a fresh deployment nothing is ever + # paired. Left alone, the Control UI is permanently unreachable, reporting + # "device pairing required (requestId: ...)" and naming a CLI that a catalog + # user has no host shell to run. + # + # So the container approves them itself. Upstream anticipates exactly this: + # `shouldPreserveLocalCliSharedAuthScopes` in the 2026.7.1 bundle + # (dist/message-handler-*.js) carries a dedicated `cli_container_local` + # locality that preserves operator scopes for a token-authenticated CLI + # running inside the gateway's own container. `devices approve` therefore + # works over loopback with no pairing of its own. + # + # Chosen over `gateway.controlUi.dangerouslyDisableDeviceAuth`, which also + # clears the gate but does so by discarding device identity altogether. This + # keeps it: every browser still binds a device keypair and gets its own + # revocable device token, `openclaw security audit` stays clean, and the + # mechanism survives 2026.8.x, where that key is retired and inert. + # + # It does not widen WHO reaches the gate — a pairing request can only be + # created by a caller that already passed token auth — but it does mean a + # leaked token can enrol a browser without a human approving it. The token + # is the boundary; rotate it if it leaks. + # + # Two greps rather than a JSON parser because the image ships no jq, and a + # node -e script cannot be quoted inside this scalar. `requestId` appears + # only on pending entries (paired devices carry `deviceId`), and the second + # grep takes the UUID out of the match. Failures are swallowed on purpose: + # for the first few seconds the gateway is not listening yet. command: >- sh -c 'set -e; node /app/dist/index.js config set --batch-json "[{\"path\":\"gateway.mode\",\"value\":\"local\"}, {\"path\":\"gateway.bind\",\"value\":\"lan\"}, {\"path\":\"gateway.controlUi.allowedOrigins\",\"value\":[\"${OPENCLAW_PUBLIC_ORIGIN%/}\"]}]"; + (while :; do + node /app/dist/index.js devices list --json 2>/dev/null + | grep -o "\"requestId\":[[:space:]]*\"[^\"]*\"" + | grep -o "[0-9a-f-]\{36\}" + | while read -r id; do + node /app/dist/index.js devices approve "$id" >/dev/null 2>&1 || true; + done || true; + sleep 5; + done) & exec node openclaw.mjs gateway' endpoints: primary: diff --git a/items/openclaw/listing.yaml b/items/openclaw/listing.yaml index c1762c1..682fcbc 100755 --- a/items/openclaw/listing.yaml +++ b/items/openclaw/listing.yaml @@ -36,6 +36,22 @@ spec: keep configuration, session history, and the OAuth-token encryption key across restarts. + ## Access and security + Every connection must present the gateway token, so the Control UI is + never open to an unauthenticated visitor. + + Behind that, OpenClaw adds a one-time device-pairing approval for each + new browser, normally run as `openclaw devices approve` on the Gateway + host. A one-click deployment gives you no host shell, so the container + runs that approval itself and pairing completes without your involvement. + Each browser still gets its own device identity, which you can revoke + individually with `openclaw devices revoke`. + + The practical consequence is that the gateway token is what guards an + admin surface that can run tools and shell commands: anyone holding it + can pair a browser. Treat the token as a secret and rotate it from the + Configuration tab if it leaks. + Messaging-channel onboarding (WhatsApp, Telegram, Discord) requires the interactive CLI and is not available in this deployment yet. category: AI_ML From dad944273e41105b039dc8f13821eba9f4df20b3 Mon Sep 17 00:00:00 2001 From: Ali S Date: Wed, 2 Sep 2026 00:25:15 +0000 Subject: [PATCH 2/3] perf(items): cut openclaw pairing latency by watching pending.json The watcher polled `devices list --json` every five seconds. That call is a cold Node start against a large CLI bundle, and the approve path is a second one, so a user's first connect sat in apparent failure for 10-15 seconds before the Control UI came up -- confirmed on a live deployment. Read the persisted pending-pairing file instead. The gateway writes each pending request to /devices/pending.json before it rejects the browser, and `resolveStateDir` resolves to OPENCLAW_STATE_DIR, else OPENCLAW_HOME/HOME joined with `.openclaw` -- which is exactly where this item mounts its config volume, so the path agrees by construction rather than by coincidence. Detection now costs two greps, spawns no Node process while nothing is pending, and leaves only one CLI start in the user's path. Poll drops to 1s because it is no longer expensive. Reading pending.json is also strictly better isolated than the previous `devices list` output: paired devices live in a separate file, so a paired entry cannot be matched at all. Verified: full corpus 377/377, typecheck clean; the folded scalar still resolves to the three intended config ops; `sh -n` accepts the inner script; and against fixtures written in the persisted shape the pipeline extracts exactly the pending requestId, yields nothing for paired.json, and handles the file being absent (it does not exist until a browser first pairs). The previous approach was confirmed working end-to-end on a live deployment; this change alters only how a pending request is detected, not what is approved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3 --- items/openclaw/components/openclaw.yaml | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/items/openclaw/components/openclaw.yaml b/items/openclaw/components/openclaw.yaml index bb90687..4dfa776 100755 --- a/items/openclaw/components/openclaw.yaml +++ b/items/openclaw/components/openclaw.yaml @@ -60,11 +60,25 @@ spec: # leaked token can enrol a browser without a human approving it. The token # is the boundary; rotate it if it leaks. # - # Two greps rather than a JSON parser because the image ships no jq, and a - # node -e script cannot be quoted inside this scalar. `requestId` appears - # only on pending entries (paired devices carry `deviceId`), and the second - # grep takes the UUID out of the match. Failures are swallowed on purpose: - # for the first few seconds the gateway is not listening yet. + # Watch the persisted pending-pairing file rather than polling `devices + # list`. That call is a cold Node start against a large CLI bundle, and at + # two starts per five-second tick it put 10-15 seconds of apparent failure + # in front of a user's first connect. Reading the file costs two greps, + # spawns no Node process while nothing is pending, and leaves the wait at + # roughly one CLI start. + # + # `$HOME/.openclaw/devices/pending.json` mirrors how the runtime resolves + # that path (OPENCLAW_STATE_DIR, else OPENCLAW_HOME/HOME joined with + # `.openclaw`). This item sets no state-dir override and mounts the config + # volume at exactly that path, so the two agree by construction. Approval + # moves the entry into paired.json, so a request is approved once and the + # loop then finds nothing. + # + # Two greps rather than a JSON parser because the image ships no jq and a + # node -e script cannot be quoted inside this scalar. Every value persisted + # in that file carries `requestId`, and the second grep lifts the UUID out + # of the match. Failures are swallowed: the file does not exist at all until + # a browser first tries to pair. command: >- sh -c 'set -e; node /app/dist/index.js config set --batch-json @@ -72,13 +86,12 @@ spec: {\"path\":\"gateway.bind\",\"value\":\"lan\"}, {\"path\":\"gateway.controlUi.allowedOrigins\",\"value\":[\"${OPENCLAW_PUBLIC_ORIGIN%/}\"]}]"; (while :; do - node /app/dist/index.js devices list --json 2>/dev/null - | grep -o "\"requestId\":[[:space:]]*\"[^\"]*\"" + grep -o "\"requestId\":[[:space:]]*\"[^\"]*\"" "$HOME/.openclaw/devices/pending.json" 2>/dev/null | grep -o "[0-9a-f-]\{36\}" | while read -r id; do node /app/dist/index.js devices approve "$id" >/dev/null 2>&1 || true; done || true; - sleep 5; + sleep 1; done) & exec node openclaw.mjs gateway' endpoints: From 42e54bc725177fbd595860bb9a7c174875467f1e Mon Sep 17 00:00:00 2001 From: Ali S Date: Wed, 2 Sep 2026 00:40:21 +0000 Subject: [PATCH 3/3] fix(items): admit any token-holding browser to openclaw, drop the pairing watcher Replace the in-container `devices approve` watcher with `gateway.controlUi.dangerouslyDisableDeviceAuth`. The intended posture for this item is that anyone holding the gateway token gets in from any browser, with no device enrolled or allowlisted, and no background process load-bearing for reachability. Gateway auth is untouched, despite the key's name. Reading the connect path in the 2026.7.1 bundle, three gates run in order: device identity (allowBypass admits), then an unconditional `if (!authOk) { rejectUnauthorized(authResult); return; }`, then the pairing gate (allowBypass skips). Gate two carries no isControlUi test and no allowBypass exemption and sits between the two the key affects, so the token check stays fully enforced while device identity and pairing go away. The watcher it replaces worked and was confirmed live, but it made reachability depend on a polling loop and put a cold CLI start in front of every first connect. Traded for a declarative key and an instant first load. The cost is stated in both files: no second factor, no per-device revocation, and browsers re-present the token each session rather than holding a stored device token. The image tag is now load-bearing and says so. 2026.7.1 is the last stable release honouring this key: its `shouldSkipControlUiPairing` ends `return role === "operator" && policy.allowBypass`, while 2026.8.1 and 2026.8.2 both end `return null`, and upstream calls the key "a retired break-glass input, now fully inert" with `doctor --fix` deleting it. On 2026.8.x the controlUi schema no longer offers a device-auth toggle at all, and the surviving bypasses are trusted-proxy `deviceAutoApprove` (needs an identity-injecting edge) or node-role pairing policy (excludes browsers). A tag bump therefore silently restores the deadlock; the comment says what has to change alongside it. Verified: full corpus 377/377, typecheck clean, `sh -n` accepts the inner script, and the folded scalar resolves to four config ops with dangerouslyDisableDeviceAuth carried as a JSON boolean rather than a string. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3 --- items/openclaw/components/openclaw.yaml | 106 ++++++++++++------------ items/openclaw/listing.yaml | 21 ++--- 2 files changed, 62 insertions(+), 65 deletions(-) diff --git a/items/openclaw/components/openclaw.yaml b/items/openclaw/components/openclaw.yaml index 4dfa776..0744a15 100755 --- a/items/openclaw/components/openclaw.yaml +++ b/items/openclaw/components/openclaw.yaml @@ -7,7 +7,24 @@ spec: kind: SERVICE source: type: IMAGE - # Latest stable calendar release (beta tags are not pinned for the catalog). + # PINNED, and the pin is load-bearing rather than merely current: 2026.7.1 + # is the last stable release in which + # `gateway.controlUi.dangerouslyDisableDeviceAuth` still works, and this + # item depends on it to be reachable at all (see the block above `command`). + # + # Verified by reading `shouldSkipControlUiPairing` in each published + # bundle: 2026.7.1 ends `return role === "operator" && policy.allowBypass`, + # while 2026.8.1 and 2026.8.2 both end `return null`, having dropped that + # branch. Upstream calls the key "a retired break-glass input, now fully + # inert" and has `doctor --fix` delete it. 2026.7.2 exists only as betas, + # which this catalog does not pin. + # + # So bumping this tag re-breaks the deployment silently. Anything past + # 2026.7.1 has to pair devices, which means either re-adding the + # in-container `devices approve` watcher this item used to carry, or + # `gateway.auth.trustedProxy.deviceAutoApprove` (2026.8.x only), which + # needs `gateway.auth.mode: "trusted-proxy"` and therefore a platform edge + # that injects an identity header. Settle that before changing the tag. ref: ghcr.io/openclaw/openclaw:2026.7.1 # Converge config through OpenClaw's own `config set` CLI, then exec the # stock gateway entrypoint (`tini -s --` passes argv through, so `sh -c` works). @@ -29,70 +46,49 @@ spec: # boot's origin forever and leave a re-deployed app permanently unreachable. # `config set` is declarative, so re-applying is a no-op. # - # GATEWAY AUTH IS UNTOUCHED. `gateway.auth.mode` stays at its default - # (`token`), so every Control UI connection still has to present - # OPENCLAW_GATEWAY_TOKEN or it is rejected at the WebSocket handshake. The - # loop below deals with a SECOND, separate gate that runs only after that - # token check has already passed. - # - # That second gate is device pairing, and it deadlocks a one-click - # deployment: a new browser's pairing request can only be approved by an - # already-paired admin session, and on a fresh deployment nothing is ever - # paired. Left alone, the Control UI is permanently unreachable, reporting - # "device pairing required (requestId: ...)" and naming a CLI that a catalog - # user has no host shell to run. - # - # So the container approves them itself. Upstream anticipates exactly this: - # `shouldPreserveLocalCliSharedAuthScopes` in the 2026.7.1 bundle - # (dist/message-handler-*.js) carries a dedicated `cli_container_local` - # locality that preserves operator scopes for a token-authenticated CLI - # running inside the gateway's own container. `devices approve` therefore - # works over loopback with no pairing of its own. + # GATEWAY AUTH IS UNTOUCHED, despite the name of the key below. + # `gateway.auth.mode` stays at its default (`token`), so every Control UI + # connection still has to present OPENCLAW_GATEWAY_TOKEN. Verified by + # reading the connect path in the 2026.7.1 bundle + # (dist/message-handler-*.js), where three gates run in this order: # - # Chosen over `gateway.controlUi.dangerouslyDisableDeviceAuth`, which also - # clears the gate but does so by discarding device identity altogether. This - # keeps it: every browser still binds a device keypair and gets its own - # revocable device token, `openclaw security audit` stays clean, and the - # mechanism survives 2026.8.x, where that key is retired and inert. + # 1. handleMissingDeviceIdentity() — device identity; allowBypass admits + # 2. `if (!authOk) { rejectUnauthorized(authResult); return; }` + # 3. shouldSkipControlUiPairing() — pairing; allowBypass skips # - # It does not widen WHO reaches the gate — a pairing request can only be - # created by a caller that already passed token auth — but it does mean a - # leaked token can enrol a browser without a human approving it. The token - # is the boundary; rotate it if it leaks. + # Gate 2 is unconditional — no isControlUi test, no allowBypass exemption — + # and it sits BETWEEN the two gates the key affects. So the key removes + # device identity and pairing while the token check stays fully enforced. # - # Watch the persisted pending-pairing file rather than polling `devices - # list`. That call is a cold Node start against a large CLI bundle, and at - # two starts per five-second tick it put 10-15 seconds of apparent failure - # in front of a user's first connect. Reading the file costs two greps, - # spawns no Node process while nothing is pending, and leaves the wait at - # roughly one CLI start. + # That is the intended posture for this item: anyone holding the token gets + # in from any browser, and no device is enrolled or allowlisted. Device + # pairing is a second gate that a valid token does not satisfy, it can only + # be cleared by an already-paired admin session, and on a fresh deployment + # nothing is ever paired — so left alone the Control UI is permanently + # unreachable, reporting "device pairing required (requestId: ...)" and + # naming a CLI that a catalog user has no host shell to run. Upstream was + # asked to let a valid token bypass pairing and declined (openclaw#29908, + # closed as not planned). # - # `$HOME/.openclaw/devices/pending.json` mirrors how the runtime resolves - # that path (OPENCLAW_STATE_DIR, else OPENCLAW_HOME/HOME joined with - # `.openclaw`). This item sets no state-dir override and mounts the config - # volume at exactly that path, so the two agree by construction. Approval - # moves the entry into paired.json, so a request is approved once and the - # loop then finds nothing. + # The alternative, carried here previously, was an in-container watcher that + # approved pending requests via `devices approve`. It worked and kept device + # identity, but it made a background process load-bearing for reachability + # and put a cold CLI start in front of every first connect. Traded away + # deliberately for a declarative key and an instant first load. # - # Two greps rather than a JSON parser because the image ships no jq and a - # node -e script cannot be quoted inside this scalar. Every value persisted - # in that file carries `requestId`, and the second grep lifts the UUID out - # of the match. Failures are swallowed: the file does not exist at all until - # a browser first tries to pair. + # What it costs: with no device identity there is no second factor and no + # per-device revocation, so a leaked token is sufficient on its own, and + # browsers re-present the token each session instead of holding a stored + # device token. `openclaw security audit` reports + # `gateway.control_ui.device_auth_disabled` as critical — expected here, not + # a regression. The token is the whole boundary; rotate it if it leaks. command: >- sh -c 'set -e; node /app/dist/index.js config set --batch-json "[{\"path\":\"gateway.mode\",\"value\":\"local\"}, {\"path\":\"gateway.bind\",\"value\":\"lan\"}, - {\"path\":\"gateway.controlUi.allowedOrigins\",\"value\":[\"${OPENCLAW_PUBLIC_ORIGIN%/}\"]}]"; - (while :; do - grep -o "\"requestId\":[[:space:]]*\"[^\"]*\"" "$HOME/.openclaw/devices/pending.json" 2>/dev/null - | grep -o "[0-9a-f-]\{36\}" - | while read -r id; do - node /app/dist/index.js devices approve "$id" >/dev/null 2>&1 || true; - done || true; - sleep 1; - done) & + {\"path\":\"gateway.controlUi.allowedOrigins\",\"value\":[\"${OPENCLAW_PUBLIC_ORIGIN%/}\"]}, + {\"path\":\"gateway.controlUi.dangerouslyDisableDeviceAuth\",\"value\":true}]"; exec node openclaw.mjs gateway' endpoints: primary: diff --git a/items/openclaw/listing.yaml b/items/openclaw/listing.yaml index 682fcbc..9514340 100755 --- a/items/openclaw/listing.yaml +++ b/items/openclaw/listing.yaml @@ -38,18 +38,19 @@ spec: ## Access and security Every connection must present the gateway token, so the Control UI is - never open to an unauthenticated visitor. + never open to an unauthenticated visitor. Any browser holding the token + can connect — there is no per-device approval step to complete first. - Behind that, OpenClaw adds a one-time device-pairing approval for each - new browser, normally run as `openclaw devices approve` on the Gateway - host. A one-click deployment gives you no host shell, so the container - runs that approval itself and pairing completes without your involvement. - Each browser still gets its own device identity, which you can revoke - individually with `openclaw devices revoke`. + That differs from a default OpenClaw install, which also requires each + new browser to be approved once by running `openclaw devices approve` on + the Gateway host. A one-click deployment gives you no host shell to run + that from, so this item turns the device check off and leaves the token + as the single gate. - The practical consequence is that the gateway token is what guards an - admin surface that can run tools and shell commands: anyone holding it - can pair a browser. Treat the token as a secret and rotate it from the + The practical consequence is that the gateway token is the only thing + guarding an admin surface that can run tools and shell commands. There is + no second factor and no per-device revocation, so treat the token as a + secret, don't paste it into shared channels, and rotate it from the Configuration tab if it leaks. Messaging-channel onboarding (WhatsApp, Telegram, Discord) requires