diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8fbf08995..56a03c0c3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg. - Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg. - Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc. +- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc. - Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier. - Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl. - Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu. diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 0d08d1be332..3cfdcb2662e 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -121,6 +121,13 @@ const NODE_CLIENT = { mode: GATEWAY_CLIENT_MODES.NODE, }; +const BACKEND_GATEWAY_CLIENT = { + id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + version: "1.0.0", + platform: "node", + mode: GATEWAY_CLIENT_MODES.BACKEND, +}; + async function expectHelloOkServerVersion(port: number, expectedVersion: string) { const ws = await openWs(port); try { @@ -1791,5 +1798,38 @@ describe("gateway server auth/connect", () => { } }); + test("allows local gateway backend shared-auth connections without device pairing", async () => { + const { server, ws, prevToken } = await startServerWithClient("secret"); + try { + const localBackend = await connectReq(ws, { + token: "secret", + client: BACKEND_GATEWAY_CLIENT, + }); + expect(localBackend.ok).toBe(true); + } finally { + ws.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); + + test("requires pairing for gateway backend clients when connection is not local-direct", async () => { + const { server, ws, port, prevToken } = await startServerWithClient("secret"); + ws.close(); + const wsRemoteLike = await openWs(port, { host: "gateway.example" }); + try { + const remoteLikeBackend = await connectReq(wsRemoteLike, { + token: "secret", + client: BACKEND_GATEWAY_CLIENT, + }); + expect(remoteLikeBackend.ok).toBe(false); + expect(remoteLikeBackend.error?.message ?? "").toContain("pairing required"); + } finally { + wsRemoteLike.close(); + await server.close(); + restoreGatewayToken(prevToken); + } + }); + // Remaining tests require isolated gateway state. }); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index f48c8cccdcb..a28ff379000 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -45,7 +45,7 @@ import { } from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { checkBrowserOrigin } from "../../origin-check.js"; -import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js"; +import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../protocol/client-info.js"; import { ConnectErrorDetailCodes, resolveDeviceAuthConnectErrorDetailCode, @@ -136,6 +136,28 @@ function shouldAllowSilentLocalPairing(params: { ); } +function shouldSkipBackendSelfPairing(params: { + connectParams: ConnectParams; + isLocalClient: boolean; + hasBrowserOriginHeader: boolean; + sharedAuthOk: boolean; + authMethod: GatewayAuthResult["method"]; +}): boolean { + const isGatewayBackendClient = + params.connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && + params.connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND; + if (!isGatewayBackendClient) { + return false; + } + const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; + return ( + params.isLocalClient && + !params.hasBrowserOriginHeader && + params.sharedAuthOk && + usesSharedSecretAuth + ); +} + function resolveDeviceSignaturePayloadVersion(params: { device: { id: string; @@ -712,11 +734,14 @@ export function attachGatewayWsMessageHandler(params: { authOk, authMethod, }); - const skipPairing = shouldSkipControlUiPairing( - controlUiAuthPolicy, - sharedAuthOk, - trustedProxyAuthOk, - ); + const skipPairing = + shouldSkipBackendSelfPairing({ + connectParams, + isLocalClient, + hasBrowserOriginHeader, + sharedAuthOk, + authMethod, + }) || shouldSkipControlUiPairing(controlUiAuthPolicy, sharedAuthOk, trustedProxyAuthOk); if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) {