diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ff9e33f36..40ba6231f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ Docs: https://docs.openclaw.ai - CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD. - Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev. - Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev. +- Gateway/auth: grant operator.read for device-less token/password auth so CLI and Dashboard can run read RPCs (devices list, status, probe) instead of getting "missing scope: operator.read". Fixes #48167, #46117, #46716, #17095. +- Gateway/handshake: restore handshake timeout to 10s (was reduced to 3s in #44089) so CLI commands like `openclaw devices list` no longer fail with "gateway closed (1000 normal closure)" on slower systems. (#47103) - Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli. - Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman. - Agents/openai-responses: strip `prompt_cache_key` and `prompt_cache_retention` for non-OpenAI-compatible Responses endpoints while keeping them on direct OpenAI and Azure OpenAI paths, so third-party OpenAI-compatible providers no longer reject those requests with HTTP 400. (#49877) Thanks @ShaunTsai. diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index ed15150a029..eaa7f99ba61 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -178,13 +178,14 @@ export function registerDefaultAuthTokenSuite(): void { expectConnectError?: string; expectStatusOk?: boolean; expectStatusError?: string; + expectAdminRestricted?: boolean; }> = [ { - name: "operator + valid shared token => connected with cleared scopes", + name: "operator + valid shared token => connected with operator.read (fixes #48167)", opts: { role: "operator", token, device: null }, expectConnectOk: true, - expectStatusOk: false, - expectStatusError: "missing scope", + expectStatusOk: true, + expectAdminRestricted: true, }, { name: "node + valid shared token => rejected without device", @@ -220,6 +221,13 @@ export function registerDefaultAuthTokenSuite(): void { ); } } + if (scenario.expectAdminRestricted) { + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok, scenario.name).toBe(false); + expect(adminRes.error?.message ?? "", scenario.name).toContain( + "missing scope: operator.admin", + ); + } } finally { ws.close(); } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 80aa6437342..544246949b8 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -33,6 +33,7 @@ import { mintCanvasCapabilityToken, } from "../../canvas-capability.js"; import { normalizeDeviceMetadataForAuth } from "../../device-auth.js"; +import { READ_SCOPE } from "../../method-scopes.js"; import { isLocalishHost, isLoopbackAddress, @@ -506,9 +507,12 @@ export function attachGatewayWsMessageHandler(params: { }); close(1008, truncateCloseReason(authMessage)); }; - const clearUnboundScopes = () => { - if (scopes.length > 0) { - scopes = []; + const clearUnboundScopes = (grantDefaultForTokenAuth = false) => { + if (scopes.length > 0 || grantDefaultForTokenAuth) { + // When shared token/password auth succeeds for device-less operator, grant + // operator.read instead of zero scopes so CLI/Dashboard can run read RPCs + // (devices list, status, probe, etc.). Fixes #48167, #46117, #46716, #17095. + scopes = grantDefaultForTokenAuth ? [READ_SCOPE] : []; connectParams.scopes = scopes; } }; @@ -531,11 +535,19 @@ export function attachGatewayWsMessageHandler(params: { hasSharedAuth, isLocalClient, }); - // Shared token/password auth can bypass pairing for trusted operators. - // Device-less clients only keep self-declared scopes on the explicit - // allow path, including trusted token-authenticated backend operators. - if (!device && decision.kind !== "allow") { - clearUnboundScopes(); + // Shared token/password auth can bypass pairing for trusted operators, but + // device-less backend clients must not self-declare scopes. Control UI + // keeps its explicitly allowed device-less scopes on the allow path. + // When allowing device-less token auth, grant operator.read so read RPCs work. + // Restrict read-scope fallback to token/password auth only; trusted-proxy + // sessions must not gain read scope without a bound device identity. + const grantReadForTokenAuth = + decision.kind === "allow" && + sharedAuthOk && + (authMethod === "token" || authMethod === "password"); + if (!device && (!isControlUi || decision.kind !== "allow" || trustedProxyAuthOk)) { + clearUnboundScopes(grantReadForTokenAuth); + } } if (decision.kind === "allow") { return true;