diff --git a/CHANGELOG.md b/CHANGELOG.md index bb22198f6a1..a494056a546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. - Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. - Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. - Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. diff --git a/src/gateway/server.auth.modes.suite.ts b/src/gateway/server.auth.modes.suite.ts index 3a589bdbd89..f0efddc455e 100644 --- a/src/gateway/server.auth.modes.suite.ts +++ b/src/gateway/server.auth.modes.suite.ts @@ -145,9 +145,18 @@ export function registerAuthModesSuite(): void { describe("tailscale auth", () => { let server: Awaited>; let port: number; + const tailscaleOrigin = "https://gateway.tailnet.ts.net"; beforeAll(async () => { testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true }; + testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] }; + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + auth: testState.gatewayAuth, + controlUi: testState.gatewayControlUi, + }, + }); port = await getFreePort(); server = await startGatewayServer(port); }); @@ -158,6 +167,7 @@ export function registerAuthModesSuite(): void { beforeEach(() => { testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true }; + testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] }; testTailscaleWhois.value = { login: "peter", name: "Peter" }; }); @@ -173,6 +183,20 @@ export function registerAuthModesSuite(): void { ws.close(); }); + test("skips pairing for tailscale-authenticated control ui with device identity", async () => { + const ws = await openTailscaleWs(port, { origin: tailscaleOrigin }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { + ...CONTROL_UI_CLIENT, + }, + }); + expect(res.ok, JSON.stringify(res)).toBe(true); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(true); + ws.close(); + }); + test("connects with shared token but clears scopes when tailscale auth skips device", async () => { const ws = await openTailscaleWs(port); const res = await connectReq(ws, { token: "secret", device: null }); diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts index e57523c11f4..aadfa1053ce 100644 --- a/src/gateway/server.auth.shared.ts +++ b/src/gateway/server.auth.shared.ts @@ -74,7 +74,7 @@ const readConnectChallengeNonce = async (ws: WebSocket) => { return String(nonce); }; -const openTailscaleWs = async (port: number) => { +const openTailscaleWs = async (port: number, headers?: Record) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`, { headers: { "x-forwarded-for": "100.64.0.1", @@ -82,6 +82,7 @@ const openTailscaleWs = async (port: number) => { "x-forwarded-host": "gateway.tailnet.ts.net", "tailscale-user-login": "peter", "tailscale-user-name": "Peter", + ...headers, }, }); trackConnectChallengeNonce(ws); diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index ad50272622e..49f1fdbce7d 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -251,6 +251,47 @@ describe("ws connect policy", () => { expect(shouldSkipControlUiPairing(controlUi, "operator", false)).toBe(false); }); + test("tailscale auth skips pairing only for operator control-ui with device identity", () => { + const device = { + id: "dev-1", + publicKey: "pk", + signature: "sig", + signedAt: Date.now(), + nonce: "nonce-1", + }; + const controlUiWithDevice = resolveControlUiAuthPolicy({ + isControlUi: true, + controlUiConfig: undefined, + deviceRaw: device, + }); + const controlUiWithoutDevice = resolveControlUiAuthPolicy({ + isControlUi: true, + controlUiConfig: undefined, + deviceRaw: null, + }); + const nonControlUiWithDevice = resolveControlUiAuthPolicy({ + isControlUi: false, + controlUiConfig: undefined, + deviceRaw: device, + }); + + expect( + shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "tailscale"), + ).toBe(true); + expect( + shouldSkipControlUiPairing(controlUiWithoutDevice, "operator", false, "token", "tailscale"), + ).toBe(false); + expect( + shouldSkipControlUiPairing(controlUiWithDevice, "node", false, "token", "tailscale"), + ).toBe(false); + expect( + shouldSkipControlUiPairing(nonControlUiWithDevice, "operator", false, "token", "tailscale"), + ).toBe(false); + expect( + shouldSkipControlUiPairing(controlUiWithDevice, "operator", false, "token", "token"), + ).toBe(false); + }); + test("trusted-proxy control-ui bypass only applies to operator + trusted-proxy auth", () => { const cases: Array<{ role: "operator" | "node"; diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index c3628623b9c..27284609174 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -39,10 +39,14 @@ export function shouldSkipControlUiPairing( role: GatewayRole, trustedProxyAuthOk = false, authMode?: string, + authMethod?: string, ): boolean { if (trustedProxyAuthOk) { return true; } + if (policy.isControlUi && role === "operator" && authMethod === "tailscale" && policy.device) { + return true; + } // When auth is completely disabled (mode=none), there is no shared secret // or token to gate pairing. Requiring pairing in this configuration adds // friction without security value since any client can already connect diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 58aac425ae8..f3bf99bc8ed 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -844,6 +844,7 @@ export function attachGatewayWsMessageHandler(params: { role, trustedProxyAuthOk, resolvedAuth.mode, + authMethod, ); if (device && devicePublicKey) { const formatAuditList = (items: string[] | undefined): string => {