diff --git a/CHANGELOG.md b/CHANGELOG.md index 11fdd815d30f..5e1f188c4407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai ### Fixes - **WhatsApp restart recovery:** stop automatic restart loops after logged-out or connection-replaced disconnects until the account reconnects. (#78511) Thanks @openperf. +- **Local Gateway CLI auth:** keep loopback CLI token/password calls off durable device scopes so read probes cannot block later write/admin commands behind a stale pairing baseline. (#95997) Thanks @vincentkoc. - **iMessage group warnings:** suppress the false drop-all startup warning when an effective group sender allowlist can admit groups, and point true empty-allowlist configurations at the correct remedy. (#100046) - **Control UI mobile login:** keep Gateway recovery guidance visible after connection failures, make the disconnected gate scroll safely on constrained screens, and improve mobile keyboard and tap-target behavior. (#100208) diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index fe4eed6993e0..4c1dcbd31b22 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -552,7 +552,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.password).toBeUndefined(); }); - it("keeps device identity enabled for explicit CLI loopback shared-token auth", async () => { + it("omits device identity for explicit CLI loopback shared-token auth", async () => { setLocalLoopbackGatewayConfig(); await callGateway({ @@ -564,6 +564,19 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); expect(lastClientOptions?.token).toBe("explicit-token"); + expect(lastClientOptions?.deviceIdentity).toBeNull(); + }); + + it("keeps CLI device identity when an ambient token is inactive under auth mode none", async () => { + getRuntimeConfig.mockReturnValue({ + gateway: { mode: "local", bind: "loopback", auth: { mode: "none" } }, + }); + setGatewayNetworkDefaults(); + process.env.OPENCLAW_GATEWAY_TOKEN = "inactive-env-token"; + + await callGatewayCli({ method: "health" }); + + expect(lastClientOptions?.token).toBe("inactive-env-token"); expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value); }); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 10b907e00e66..3103bdc90c14 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -484,31 +484,33 @@ function isLoopbackGatewayUrl(rawUrl: string): boolean { function shouldOmitDeviceIdentityForGatewayCall(params: { opts: CallGatewayBaseOptions; url: string; + authMode: ReturnType["mode"]; token?: string; password?: string; allowAuthNone?: boolean; }): boolean { const mode = params.opts.mode ?? GATEWAY_CLIENT_MODES.CLI; const clientName = params.opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI; - const hasDirectLocalBackendAuth = - Boolean(params.token || params.password) || params.allowAuthNone === true; - return ( + // Inactive ambient credentials must not turn an auth-none CLI call device-less. + // Omit identity only when the Gateway will actually authenticate the supplied secret. + const hasSharedSecretAuth = + (params.authMode === "token" && Boolean(params.token)) || + (params.authMode === "password" && Boolean(params.password)); + const isLoopback = isLoopbackGatewayUrl(params.url); + const isLocalBackendSharedAuth = mode === GATEWAY_CLIENT_MODES.BACKEND && clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT && - hasDirectLocalBackendAuth && - isLoopbackGatewayUrl(params.url) - ); + (hasSharedSecretAuth || params.allowAuthNone === true) && + isLoopback; + const isLocalCliSharedAuth = + mode === GATEWAY_CLIENT_MODES.CLI && + clientName === GATEWAY_CLIENT_NAMES.CLI && + hasSharedSecretAuth && + isLoopback; + return isLocalBackendSharedAuth || isLocalCliSharedAuth; } -function resolveDeviceIdentityForGatewayCall(params: { - opts: CallGatewayBaseOptions; - url: string; - token?: string; - password?: string; -}): ReturnType | null { - if (shouldOmitDeviceIdentityForGatewayCall(params)) { - return null; - } +function resolveDeviceIdentityForGatewayCall(): DeviceIdentity | null { try { return gatewayCallDeps.loadOrCreateDeviceIdentity(); } catch { @@ -1163,12 +1165,12 @@ async function callGatewayWithScopes>( const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url }); const token = useStoredDeviceAuth ? undefined : resolvedCredentials.token; const password = useStoredDeviceAuth ? undefined : resolvedCredentials.password; - const allowAuthNone = - opts.requireLocalBackendSharedAuth === true && - resolveGatewayCallAuth(context.config).mode === "none"; + const authMode = resolveGatewayCallAuth(context.config).mode; + const allowAuthNone = opts.requireLocalBackendSharedAuth === true && authMode === "none"; const omitDeviceIdentity = shouldOmitDeviceIdentityForGatewayCall({ opts, url, + authMode, token, password, allowAuthNone, @@ -1182,7 +1184,7 @@ async function callGatewayWithScopes>( opts.deviceIdentity === undefined ? omitDeviceIdentity ? null - : resolveDeviceIdentityForGatewayCall({ opts, url, token, password }) + : resolveDeviceIdentityForGatewayCall() : opts.deviceIdentity; if (useStoredDeviceAuth) { const storedAuth = loadStoredOperatorDeviceAuthToken(deviceIdentity); diff --git a/src/gateway/server.auth.compat-baseline.test.ts b/src/gateway/server.auth.compat-baseline.test.ts index 456d89c4bfaa..2579a650a7d6 100644 --- a/src/gateway/server.auth.compat-baseline.test.ts +++ b/src/gateway/server.auth.compat-baseline.test.ts @@ -11,6 +11,8 @@ import { ConnectErrorDetailCodes, createSignedDevice, getFreePort, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, readConnectChallengeNonce, openWs, originForPort, @@ -23,6 +25,13 @@ import { installGatewayTestHooks({ scope: "suite" }); +const CLI_CLIENT = { + id: GATEWAY_CLIENT_NAMES.CLI, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.CLI, +}; + function expectAuthErrorDetails(params: { details: unknown; expectedCode: string; @@ -96,6 +105,36 @@ async function expectLocalBackendGatewayClientScopesPreserved( } } +async function expectLocalCliSharedAuthScopesPreserved( + port: number, + auth: { token?: string; password?: string }, +) { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + ...auth, + client: { ...CLI_CLIENT }, + scopes: ["operator.admin"], + device: null, + }); + expect(res.ok, JSON.stringify(res)).toBe(true); + + const helloOk = res.payload as + | { + auth?: { + scopes?: unknown; + }; + } + | undefined; + expect(helloOk?.auth?.scopes).toEqual(["operator.admin"]); + + const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false }); + expect(adminRes.ok).toBe(true); + } finally { + ws.close(); + } +} + describe("gateway auth compatibility baseline", () => { describe("token mode", () => { let server: Awaited>; @@ -133,6 +172,10 @@ describe("gateway auth compatibility baseline", () => { await expectLocalBackendGatewayClientScopesPreserved(port, { token: "secret" }); }); + test("preserves scopes for direct-local CLI shared-token connects without device identity", async () => { + await expectLocalCliSharedAuthScopesPreserved(port, { token: "secret" }); + }); + test("returns stable token-missing details for control ui without token", async () => { const ws = await openWs(port, { origin: originForPort(port) }); try { @@ -306,6 +349,10 @@ describe("gateway auth compatibility baseline", () => { test("preserves scopes for direct-local backend shared-password connects without device identity", async () => { await expectLocalBackendGatewayClientScopesPreserved(port, { password: "secret" }); }); + + test("preserves scopes for direct-local CLI shared-password connects without device identity", async () => { + await expectLocalCliSharedAuthScopesPreserved(port, { password: "secret" }); + }); }); describe("none mode", () => { diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index fd62a87f9e6c..f91e2c5bb2d1 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -271,6 +271,44 @@ describe("gateway silent scope-upgrade reconnect", () => { } }); + test("keeps direct-local CLI shared-token calls off stale paired CLI baseline", async () => { + const started = await startServerWithClient("secret"); + const identity = await approveReadScopedDevice({ + clientId: GATEWAY_CLIENT_NAMES.CLI, + clientMode: GATEWAY_CLIENT_MODES.CLI, + }); + + try { + const health = await callGateway({ + url: `ws://127.0.0.1:${started.port}`, + token: "secret", + method: "health", + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + timeoutMs: 2_000, + }); + expect(health.ok).toBe(true); + + const admin = await callGateway({ + url: `ws://127.0.0.1:${started.port}`, + token: "secret", + method: "set-heartbeats", + params: { enabled: false }, + scopes: ["operator.admin"], + clientName: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + timeoutMs: 2_000, + }); + expect(admin.ok).toBe(true); + + const pending = await devicePairingModule.listDevicePairing(); + expect(pending.pending).toHaveLength(0); + await expectReadScopedPairing(identity.deviceId); + } finally { + await closeStartedGateway(started); + } + }); + test("keeps local native approval clients off stale paired gateway-client baseline", async () => { const started = await startServerWithClient("secret"); const identity = await approveReadScopedDevice({ diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index 4a21510d1f54..ce5c45fe9217 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -13,6 +13,7 @@ import { resolvePairingLocality, resolveUnauthorizedHandshakeContext, shouldAllowSilentLocalPairing, + shouldPreserveLocalCliSharedAuthScopes, shouldSkipLocalBackendSelfPairing, } from "./handshake-auth-helpers.js"; @@ -30,6 +31,7 @@ type PairingLocalityOverrides = { }; type SilentLocalPairingParams = Parameters[0]; type BackendSelfPairingParams = Parameters[0]; +type LocalCliSharedAuthScopeParams = Parameters[0]; const CONTROL_UI_WEBCHAT_CONNECT_PARAMS = { client: { @@ -132,6 +134,17 @@ function skipBackendSelfPairing(overrides: Partial = { }); } +function preserveLocalCliSharedAuthScopes(overrides: Partial = {}) { + return shouldPreserveLocalCliSharedAuthScopes({ + connectParams: CLI_CONNECT_PARAMS, + locality: "direct_local", + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + ...overrides, + }); +} + describe("handshake auth helpers", () => { it("pins browser-origin loopback clients to the synthetic rate-limit ip", () => { const rateLimiter = createRateLimiter(); @@ -422,6 +435,36 @@ describe("handshake auth helpers", () => { ).toBe(false); }); + it("preserves local CLI shared-auth scopes only for token/password loopback auth", () => { + expect(preserveLocalCliSharedAuthScopes()).toBe(true); + expect( + preserveLocalCliSharedAuthScopes({ + locality: "cli_container_local", + authMethod: "password", + }), + ).toBe(true); + expect( + preserveLocalCliSharedAuthScopes({ + locality: "remote", + }), + ).toBe(false); + expect( + preserveLocalCliSharedAuthScopes({ + authMethod: "device-token", + }), + ).toBe(false); + expect( + preserveLocalCliSharedAuthScopes({ + hasBrowserOriginHeader: true, + }), + ).toBe(false); + expect( + preserveLocalCliSharedAuthScopes({ + connectParams: GATEWAY_BACKEND_CONNECT_PARAMS, + }), + ).toBe(false); + }); + it("rejects pairing bypass when browser origin header is present", () => { expect( skipBackendSelfPairing({ diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 79314af7cbc7..c589f88c7356 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -283,6 +283,24 @@ export function shouldSkipLocalBackendSelfPairing(params: { return (params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth; } +export function shouldPreserveLocalCliSharedAuthScopes(params: { + connectParams: ConnectParams; + locality: PairingLocalityKind; + hasBrowserOriginHeader: boolean; + sharedAuthOk: boolean; + authMethod: GatewayAuthResult["method"]; +}): boolean { + const isCliClient = + params.connectParams.client.id === GATEWAY_CLIENT_IDS.CLI && + params.connectParams.client.mode === GATEWAY_CLIENT_MODES.CLI; + if (!isCliClient) { + return false; + } + const isLocal = params.locality === "direct_local" || params.locality === "cli_container_local"; + const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; + return isLocal && !params.hasBrowserOriginHeader && params.sharedAuthOk && usesSharedSecretAuth; +} + function resolveSignatureToken(connectParams: ConnectParams): string | null { return ( connectParams.auth?.token ?? diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index ca040319ff4b..a383b749fe60 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -169,6 +169,7 @@ import { resolvePairingLocality, resolveUnauthorizedHandshakeContext, shouldAllowSilentLocalPairing, + shouldPreserveLocalCliSharedAuthScopes, shouldSkipLocalBackendSelfPairing, } from "./handshake-auth-helpers.js"; import { @@ -1026,6 +1027,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar sharedAuthOk, authMethod, }); + let preserveLocalCliSharedAuthScopes = shouldPreserveLocalCliSharedAuthScopes({ + connectParams, + locality: pairingLocality, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }); const handleMissingDeviceIdentity = (): boolean => { const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({ isControlUi, @@ -1051,13 +1059,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar hasSharedAuth, isLocalClient, }); - // Shared token/password auth can bypass pairing for trusted operators. - // Device-less clients still clear self-declared scopes by default, with - // one narrow exception: the direct-local backend gateway-client shared- - // auth handoff used for in-process control-plane coordination. + // Device-less shared auth clears self-declared scopes by default. + // Only first-party local control paths preserve scopes: backend self- + // calls and CLI shared-secret calls that already proved loopback auth. if ( !device && !skipLocalBackendSelfPairing && + !preserveLocalCliSharedAuthScopes && shouldClearUnboundScopesForMissingDeviceIdentity({ decision, controlUiAuthPolicy, @@ -1237,6 +1245,13 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar sharedAuthOk, authMethod, }); + preserveLocalCliSharedAuthScopes = shouldPreserveLocalCliSharedAuthScopes({ + connectParams, + locality: pairingLocality, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }); if (!authOk) { rejectUnauthorized(authResult); return;