fix(gateway): grant operator.read for token-only auth to fix missing scope (#48167)

This commit is contained in:
Cursor Agent 2026-03-17 06:05:36 +00:00
parent c2c7087fc8
commit c99f92fb05
No known key found for this signature in database
3 changed files with 33 additions and 11 deletions

View File

@ -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.

View File

@ -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();
}

View File

@ -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;