diff --git a/apps/server/src/openclaw/GatewayClient.test.ts b/apps/server/src/openclaw/GatewayClient.test.ts new file mode 100644 index 000000000..f70767bef --- /dev/null +++ b/apps/server/src/openclaw/GatewayClient.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocketServer, type WebSocket } from "ws"; + +import { generateOpenclawDeviceIdentity } from "./deviceAuth.ts"; +import { OpenclawGatewayClient } from "./GatewayClient.ts"; + +const servers = new Set(); + +type GatewayRequestFrame = { + type?: unknown; + id?: unknown; + method?: unknown; + params?: { + auth?: { + password?: unknown; + token?: unknown; + deviceToken?: unknown; + }; + }; +}; + +type GatewayAuthPayload = NonNullable["auth"]; + +afterEach(async () => { + await Promise.all( + [...servers].map( + (server) => + new Promise((resolve) => { + for (const client of server.clients) { + client.terminate(); + } + server.close(() => resolve()); + }), + ), + ); + servers.clear(); +}); + +async function createGatewayServer( + onConnection: (socket: WebSocket) => void, +): Promise<{ url: string }> { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + servers.add(server); + await new Promise((resolve) => { + server.once("listening", () => resolve()); + }); + server.on("connection", onConnection); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected a TCP address for the test websocket server."); + } + return { url: `ws://127.0.0.1:${address.port}` }; +} + +function sendChallenge(socket: WebSocket): void { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123", ts: Date.now() }, + }), + ); +} + +describe("OpenclawGatewayClient", () => { + it("retries with auth.password when a gateway rejects token-style shared-secret auth", async () => { + const attemptedAuths: GatewayAuthPayload[] = []; + const gateway = await createGatewayServer((socket) => { + sendChallenge(socket); + socket.on("message", (data) => { + const message = JSON.parse(data.toString()) as GatewayRequestFrame; + if (message.type === "req" && message.method === "connect") { + attemptedAuths.push(message.params?.auth); + if (message.params?.auth?.password === "topsecret") { + socket.send( + JSON.stringify({ + type: "res", + id: message.id, + ok: true, + payload: { type: "hello-ok", protocol: 3 }, + }), + ); + return; + } + + socket.send( + JSON.stringify({ + type: "res", + id: message.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message: "unauthorized: gateway password missing", + details: { + code: "AUTH_PASSWORD_MISSING", + reason: "provide gateway auth password", + recommendedNextStep: "update_auth_configuration", + }, + }, + }), + ); + } + }); + }); + + const connection = await OpenclawGatewayClient.connect({ + url: gateway.url, + identity: generateOpenclawDeviceIdentity(), + sharedSecret: "topsecret", + clientId: "okcode", + clientVersion: "test", + clientPlatform: "macos", + clientMode: "operator", + locale: "en-US", + userAgent: "okcode/test", + role: "operator", + scopes: ["operator.read", "operator.write"], + }); + + expect(attemptedAuths.some((auth) => auth?.password === "topsecret")).toBe(true); + await connection.client.close(); + }); +}); diff --git a/apps/server/src/openclaw/GatewayClient.ts b/apps/server/src/openclaw/GatewayClient.ts index 3bff51c5f..bf709b2b7 100644 --- a/apps/server/src/openclaw/GatewayClient.ts +++ b/apps/server/src/openclaw/GatewayClient.ts @@ -93,6 +93,12 @@ function uniqueScopes(scopes: ReadonlyArray | undefined): string[] { return [...values]; } +function isPasswordAuthError(error: ParsedGatewayError | undefined): boolean { + return ( + error?.detailCode === "AUTH_PASSWORD_MISSING" || error?.detailCode === "AUTH_PASSWORD_MISMATCH" + ); +} + function closeDetail(code: number | undefined, reason: string | undefined): string { if (code === undefined) { return ""; @@ -292,25 +298,40 @@ export class OpenclawGatewayClient { typeof this.options.deviceToken === "string" && this.options.deviceToken.length > 0; try { - return await this.performConnectAttempt("shared"); - } catch (error) { - const parsedError = + return await this.performConnectAttempt("sharedToken"); + } catch (caughtError) { + let error = caughtError; + let parsedError = error instanceof OpenclawGatewayClientError ? error.gatewayError : undefined; + + if (this.options.sharedSecret !== undefined && isPasswordAuthError(parsedError)) { + await this.closeCurrentSocket(); + try { + return await this.performConnectAttempt("sharedPassword"); + } catch (passwordError) { + error = passwordError; + parsedError = + passwordError instanceof OpenclawGatewayClientError + ? passwordError.gatewayError + : undefined; + } + } + const shouldRetryWithDeviceToken = canUseStoredDeviceToken && parsedError?.canRetryWithDeviceToken === true && this.options.sharedSecret !== undefined; - if (!shouldRetryWithDeviceToken) { - throw error; + if (shouldRetryWithDeviceToken) { + await this.closeCurrentSocket(); + return await this.performConnectAttempt("deviceToken"); } - await this.closeCurrentSocket(); - return await this.performConnectAttempt("deviceToken"); + throw error; } } private async performConnectAttempt( - authMode: "shared" | "deviceToken", + authMode: "sharedToken" | "sharedPassword" | "deviceToken", ): Promise { await this.openSocket(); const challenge = await this.waitForEvent("connect.challenge"); @@ -331,7 +352,10 @@ export class OpenclawGatewayClient { const authToken = authMode === "deviceToken" ? (this.options.deviceToken ?? "") - : (this.options.sharedSecret ?? ""); + : authMode === "sharedToken" + ? (this.options.sharedSecret ?? "") + : ""; + const authPassword = authMode === "sharedPassword" ? (this.options.sharedSecret ?? "") : ""; const signedDevice = signOpenclawDeviceChallenge(this.options.identity, { clientId: this.options.clientId, clientMode: this.options.clientMode, @@ -356,7 +380,10 @@ export class OpenclawGatewayClient { caps: [], commands: [], permissions: {}, - ...(authMode === "shared" && authToken.length > 0 ? { auth: { token: authToken } } : {}), + ...(authMode === "sharedToken" && authToken.length > 0 ? { auth: { token: authToken } } : {}), + ...(authMode === "sharedPassword" && authPassword.length > 0 + ? { auth: { password: authPassword } } + : {}), ...(authMode === "deviceToken" && authToken.length > 0 ? { auth: { deviceToken: authToken } } : {}), diff --git a/apps/server/src/openclawGatewayTest.test.ts b/apps/server/src/openclawGatewayTest.test.ts index bd2db7be8..88e82b1a3 100644 --- a/apps/server/src/openclawGatewayTest.test.ts +++ b/apps/server/src/openclawGatewayTest.test.ts @@ -152,6 +152,55 @@ describe("runOpenclawGatewayTest", () => { expect(typeof connectParams?.device?.signedAt).toBe("number"); }); + it("retries with password-style auth when the gateway requires auth.password", async () => { + const attemptedParams: GatewayRequestFrame["params"][] = []; + + const gateway = await createGatewayServer((socket) => { + sendChallenge(socket); + socket.on("message", (data) => { + const message = JSON.parse(data.toString()) as GatewayRequestFrame; + if (message.type === "req" && message.method === "connect") { + attemptedParams.push(message.params); + if (message.params?.auth?.password === "topsecret") { + socket.send( + JSON.stringify({ + type: "res", + id: message.id, + ok: true, + payload: { type: "hello-ok", protocol: 3 }, + }), + ); + return; + } + + socket.send( + JSON.stringify({ + type: "res", + id: message.id, + ok: false, + error: { + message: "unauthorized: gateway password missing", + details: { + code: "AUTH_PASSWORD_MISSING", + reason: "provide gateway auth password", + recommendedNextStep: "update_auth_configuration", + }, + }, + }), + ); + } + }); + }); + + const result = await runOpenclawGatewayTest({ + gatewayUrl: gateway.url, + password: "topsecret", + }); + + expect(result.success).toBe(true); + expect(attemptedParams.some((params) => params?.auth?.password === "topsecret")).toBe(true); + }); + it("reports pairing-required detail codes from the connect handshake", async () => { const gateway = await createGatewayServer((socket) => { sendChallenge(socket); @@ -194,4 +243,25 @@ describe("runOpenclawGatewayTest", () => { expect(result.diagnostics?.gatewayRecommendedNextStep).toBe("approve_device"); expect(result.diagnostics?.hints.some((hint) => hint.includes("pairing approval"))).toBe(true); }); + + it("adds a shared-secret hint for password-missing handshake errors", () => { + const hints = OpenclawGatewayTestInternals.buildHints( + new URL("wss://vals-mini.example.ts.net"), + { + resolvedAddresses: ["100.90.12.34"], + hostKind: "tailscale", + healthStatus: "pass", + observedNotifications: ["connect.challenge"], + hints: [], + gatewayErrorDetailCode: "AUTH_PASSWORD_MISSING", + }, + "Gateway handshake", + "unauthorized: gateway password missing (AUTH_PASSWORD_MISSING)", + false, + ); + + expect(hints.some((hint) => hint.includes("add the configured secret and test again"))).toBe( + true, + ); + }); }); diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index f6b5d69da..faf174fdd 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -334,7 +334,10 @@ function buildHints( if ( !sharedSecretProvided && - (detailCode === "AUTH_TOKEN_MISSING" || errorLower.includes("auth_token_missing")) + (detailCode === "AUTH_TOKEN_MISSING" || + detailCode === "AUTH_PASSWORD_MISSING" || + errorLower.includes("auth_token_missing") || + errorLower.includes("auth_password_missing")) ) { hints.push( "No shared secret was provided for this test. If your OpenClaw gateway uses token/password auth, add the configured secret and test again.", @@ -344,8 +347,10 @@ function buildHints( if ( sharedSecretProvided && (detailCode === "AUTH_TOKEN_MISMATCH" || + detailCode === "AUTH_PASSWORD_MISMATCH" || detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || - errorLower.includes("auth_token_mismatch")) + errorLower.includes("auth_token_mismatch") || + errorLower.includes("auth_password_mismatch")) ) { hints.push( "The gateway rejected the provided auth material. Re-check the configured shared secret and confirm whether this gateway expects token auth, password auth, or a paired device token.", diff --git a/apps/server/src/provider/Layers/OpenClawGatewayClient.ts b/apps/server/src/provider/Layers/OpenClawGatewayClient.ts index bddfadbd9..451c2ea17 100644 --- a/apps/server/src/provider/Layers/OpenClawGatewayClient.ts +++ b/apps/server/src/provider/Layers/OpenClawGatewayClient.ts @@ -155,6 +155,7 @@ interface GatewayConnectPayload { } type OpenClawGatewayAuthSelection = + | { readonly kind: "token"; readonly value: string } | { readonly kind: "password"; readonly value: string } | { readonly kind: "deviceToken"; readonly value: string } | { readonly kind: "none" }; @@ -434,18 +435,23 @@ function buildConnectParams(input: { }): Record { const signedAtMs = Date.now(); const auth = - input.auth.kind === "password" + input.auth.kind === "token" ? { token: input.auth.value, } - : input.auth.kind === "deviceToken" + : input.auth.kind === "password" ? { - // Legacy compatibility: device-token auth keeps `token` populated too. - token: input.auth.value, - deviceToken: input.auth.value, + password: input.auth.value, } - : undefined; - const signatureToken = input.auth.kind !== "none" ? input.auth.value : undefined; + : input.auth.kind === "deviceToken" + ? { + // Legacy compatibility: device-token auth keeps `token` populated too. + token: input.auth.value, + deviceToken: input.auth.value, + } + : undefined; + const signatureToken = + input.auth.kind === "token" || input.auth.kind === "deviceToken" ? input.auth.value : undefined; return { minProtocol: OPENCLAW_PROTOCOL_VERSION, maxProtocol: OPENCLAW_PROTOCOL_VERSION, @@ -496,6 +502,17 @@ function isDeviceTokenError(error: OpenClawGatewayError | undefined): boolean { ); } +function isPasswordAuthError(error: OpenClawGatewayError | undefined): boolean { + const code = + error?.details && isObject(error.details) ? readString(error.details.code) : undefined; + return ( + code === "AUTH_PASSWORD_MISSING" || + code === "AUTH_PASSWORD_MISMATCH" || + error?.message.toLowerCase().includes("auth_password_missing") === true || + error?.message.toLowerCase().includes("auth_password_mismatch") === true + ); +} + export function createOpenClawIdempotencyKey(parts: ReadonlyArray): string { return `okcode-${createHash("sha256").update(parts.join("\u0000")).digest("hex")}`; } @@ -511,67 +528,85 @@ export async function connectOpenClawGateway( const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; - const candidateAuthSelections: OpenClawGatewayAuthSelection[] = []; - if (options.password && options.password.length > 0) { - candidateAuthSelections.push({ kind: "password", value: options.password }); - } + const deviceTokenSelections: OpenClawGatewayAuthSelection[] = []; if (options.deviceToken && options.deviceToken.length > 0) { - candidateAuthSelections.push({ kind: "deviceToken", value: options.deviceToken }); + deviceTokenSelections.push({ kind: "deviceToken", value: options.deviceToken }); } const cachedDeviceToken = await authStore.getDeviceToken(origin); if (cachedDeviceToken) { - candidateAuthSelections.push({ kind: "deviceToken", value: cachedDeviceToken }); - } - if (candidateAuthSelections.length === 0) { - candidateAuthSelections.push({ kind: "none" }); + deviceTokenSelections.push({ kind: "deviceToken", value: cachedDeviceToken }); } let lastError: Error | undefined; + const attemptConnect = async (auth: OpenClawGatewayAuthSelection) => + await connectOnce({ + gatewayUrl: options.gatewayUrl, + origin, + authStore, + deviceIdentity, + auth, + connectTimeoutMs, + requestTimeoutMs, + onEvent: options.onEvent, + client: options.client, + role: options.role, + scopes: options.scopes, + userAgent: options.userAgent, + locale: options.locale, + caps: options.caps, + commands: options.commands, + permissions: options.permissions, + sessionKey: options.sessionKey ?? `okcode:${normalizePathSegments(options.client.id)}`, + }); - for (let index = 0; index < candidateAuthSelections.length; index += 1) { - const auth = candidateAuthSelections[index]; - if (auth === undefined) { - continue; - } + const sharedSecret = options.password?.trim(); + if (sharedSecret) { try { - const connection = await connectOnce({ - gatewayUrl: options.gatewayUrl, - origin, - authStore, - deviceIdentity, - auth, - connectTimeoutMs, - requestTimeoutMs, - onEvent: options.onEvent, - client: options.client, - role: options.role, - scopes: options.scopes, - userAgent: options.userAgent, - locale: options.locale, - caps: options.caps, - commands: options.commands, - permissions: options.permissions, - sessionKey: options.sessionKey ?? `okcode:${normalizePathSegments(options.client.id)}`, - }); - return connection; + return await attemptConnect({ kind: "token", value: sharedSecret }); } catch (cause) { const error = cause instanceof Error ? cause : new Error(String(cause)); lastError = error; const parsedError = error as Error & { readonly gatewayError?: OpenClawGatewayError }; const gatewayError = parsedError.gatewayError; - const usedExplicitPassword = options.password !== undefined && options.password.length > 0; - const canRetryWithCachedToken = - usedExplicitPassword && - cachedDeviceToken !== undefined && - auth.kind === "password" && - isDeviceTokenError(gatewayError); - - if (!canRetryWithCachedToken || index + 1 >= candidateAuthSelections.length) { - break; + + if (isPasswordAuthError(gatewayError)) { + try { + return await attemptConnect({ kind: "password", value: sharedSecret }); + } catch (passwordCause) { + lastError = + passwordCause instanceof Error ? passwordCause : new Error(String(passwordCause)); + } + } else if (deviceTokenSelections.length > 0 && isDeviceTokenError(gatewayError)) { + for (const auth of deviceTokenSelections) { + try { + return await attemptConnect(auth); + } catch (deviceTokenCause) { + lastError = + deviceTokenCause instanceof Error + ? deviceTokenCause + : new Error(String(deviceTokenCause)); + } + } } } } + for (const auth of deviceTokenSelections) { + try { + return await attemptConnect(auth); + } catch (cause) { + lastError = cause instanceof Error ? cause : new Error(String(cause)); + } + } + + if (!sharedSecret) { + try { + return await attemptConnect({ kind: "none" }); + } catch (cause) { + lastError = cause instanceof Error ? cause : new Error(String(cause)); + } + } + throw lastError ?? new Error("OpenClaw gateway connect failed."); } diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index d4e0dab85..2b644bb44 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -7,6 +7,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { checkClaudeProviderStatus, checkCodexProviderStatus, + isOpenClawGatewayUnauthenticatedDetailCode, parseAuthStatusFromOutput, parseClaudeAuthStatusFromOutput, setCodexAppServerThreadStartProbeForTest, @@ -105,6 +106,21 @@ function withCodexAppServerThreadStartProbe( } it.layer(NodeServices.layer)("ProviderHealth", (it) => { + describe("isOpenClawGatewayUnauthenticatedDetailCode", () => { + it("treats password-mode auth failures as unauthenticated", () => { + assert.strictEqual(isOpenClawGatewayUnauthenticatedDetailCode("AUTH_PASSWORD_MISSING"), true); + assert.strictEqual( + isOpenClawGatewayUnauthenticatedDetailCode("AUTH_PASSWORD_MISMATCH"), + true, + ); + assert.strictEqual(isOpenClawGatewayUnauthenticatedDetailCode("AUTH_TOKEN_MISSING"), true); + assert.strictEqual( + isOpenClawGatewayUnauthenticatedDetailCode("SOME_OTHER_GATEWAY_CODE"), + false, + ); + }); + }); + // ── checkCodexProviderStatus tests ──────────────────────────────── // // These tests control CODEX_HOME to ensure the custom-provider detection diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 7f5bcec92..c9cd1064a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -74,6 +74,20 @@ const OPENCLAW_HEALTH_REQUIRED_METHODS = [ "sessions.messages.subscribe", ] as const; +export function isOpenClawGatewayUnauthenticatedDetailCode( + detailCode: string | undefined, +): boolean { + return ( + detailCode === "PAIRING_REQUIRED" || + detailCode === "AUTH_TOKEN_MISSING" || + detailCode === "AUTH_PASSWORD_MISSING" || + detailCode === "AUTH_TOKEN_MISMATCH" || + detailCode === "AUTH_PASSWORD_MISMATCH" || + detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || + detailCode?.startsWith("DEVICE_AUTH_") === true + ); +} + // ── Pure helpers ──────────────────────────────────────────────────── export interface CommandResult { @@ -1076,13 +1090,7 @@ const checkOpenClawProviderStatus: Effect.Effect< if (error instanceof OpenclawGatewayClientError) { const detailCode = error.gatewayError?.detailCode; const gatewayMessage = error.gatewayError?.message ?? error.message; - if ( - detailCode === "PAIRING_REQUIRED" || - detailCode === "AUTH_TOKEN_MISSING" || - detailCode === "AUTH_TOKEN_MISMATCH" || - detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || - detailCode?.startsWith("DEVICE_AUTH_") - ) { + if (isOpenClawGatewayUnauthenticatedDetailCode(detailCode)) { return createServerProviderStatus({ provider: OPENCLAW_PROVIDER, enabled: true, diff --git a/apps/web/src/components/sme/smeConversationConfig.test.ts b/apps/web/src/components/sme/smeConversationConfig.test.ts index 15dbf3680..dd97bb0bb 100644 --- a/apps/web/src/components/sme/smeConversationConfig.test.ts +++ b/apps/web/src/components/sme/smeConversationConfig.test.ts @@ -3,14 +3,14 @@ import { describe, expect, it } from "vitest"; import { getDefaultSmeAuthMethod, getSmeAuthMethodOptions } from "./smeConversationConfig"; describe("smeConversationConfig", () => { - it("keeps OpenClaw auth copy aligned with shared secret/token terminology", () => { + it("keeps OpenClaw auth copy aligned with shared-secret terminology", () => { const options = getSmeAuthMethodOptions("openclaw"); expect(getDefaultSmeAuthMethod("openclaw")).toBe("password"); expect(options).toEqual([ - { value: "password", label: "Gateway Shared Secret / Token" }, + { value: "password", label: "Gateway Shared Secret" }, { value: "none", label: "Device Token Only" }, - { value: "auto", label: "Auto (prefer shared secret/token)" }, + { value: "auto", label: "Auto (prefer shared secret)" }, ]); }); }); diff --git a/apps/web/src/components/sme/smeConversationConfig.ts b/apps/web/src/components/sme/smeConversationConfig.ts index 77a5d75d1..acb49fb2f 100644 --- a/apps/web/src/components/sme/smeConversationConfig.ts +++ b/apps/web/src/components/sme/smeConversationConfig.ts @@ -44,9 +44,9 @@ export function getSmeAuthMethodOptions( ]; case "openclaw": return [ - { value: "password", label: "Gateway Shared Secret / Token" }, + { value: "password", label: "Gateway Shared Secret" }, { value: "none", label: "Device Token Only" }, - { value: "auto", label: "Auto (prefer shared secret/token)" }, + { value: "auto", label: "Auto (prefer shared secret)" }, ]; case "gemini": return [ diff --git a/apps/web/src/lib/settingsProviderMetadata.test.ts b/apps/web/src/lib/settingsProviderMetadata.test.ts index ce49b1b87..2be06d763 100644 --- a/apps/web/src/lib/settingsProviderMetadata.test.ts +++ b/apps/web/src/lib/settingsProviderMetadata.test.ts @@ -3,11 +3,12 @@ import { describe, expect, it } from "vitest"; import { PROVIDER_AUTH_GUIDES } from "./settingsProviderMetadata"; describe("PROVIDER_AUTH_GUIDES", () => { - it("describes OpenClaw auth as a shared secret/token flow", () => { + it("describes OpenClaw auth as a shared-secret flow", () => { const guide = PROVIDER_AUTH_GUIDES.openclaw; - expect(guide.authCmd).toBe("Use gateway shared secret/token"); - expect(guide.note).toContain("shared secret/token"); + expect(guide.authCmd).toBe("Use gateway shared secret"); + expect(guide.note).toContain("shared secret"); + expect(guide.note).toContain("password-style auth"); expect(guide.note).toContain("remote gateways"); }); }); diff --git a/apps/web/src/lib/settingsProviderMetadata.tsx b/apps/web/src/lib/settingsProviderMetadata.tsx index c112badc7..e2d618249 100644 --- a/apps/web/src/lib/settingsProviderMetadata.tsx +++ b/apps/web/src/lib/settingsProviderMetadata.tsx @@ -101,9 +101,9 @@ export const PROVIDER_AUTH_GUIDES: Record = { note: "GitHub Copilot must be installed and signed in before it appears in the thread picker.", }, openclaw: { - authCmd: "Use gateway shared secret/token", + authCmd: "Use gateway shared secret", verifyCmd: "Test Connection", - note: "OpenClaw uses the gateway URL and shared secret/token below rather than a local CLI login. Shared-secret auth usually works without device pairing and is the recommended default for Tailscale and remote gateways. Connection is verified by a WebSocket handshake plus /health probe and a connect handshake; click Test Connection again if the gateway restarts or your network changes.", + note: "OpenClaw uses the gateway URL and shared secret below rather than a local CLI login. Depending on gateway auth mode, OK Code sends that shared secret as token-style or password-style auth. Shared-secret auth usually works without device pairing and is the recommended default for Tailscale and remote gateways. Connection is verified by a WebSocket handshake plus /health probe and a connect handshake; click Test Connection again if the gateway restarts or your network changes.", }, }; diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index 59fcea507..e0db98e78 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -1641,9 +1641,7 @@ function SettingsRouteView() {