diff --git a/CHANGELOG.md b/CHANGELOG.md index 5edd3c6c24e..902c16bbc62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - CLI/TUI: keep `chat.history` off model-catalog discovery so initial Gateway-backed TUI history loads cannot block behind slow provider/plugin model scans on low-core hosts. Refs #73524. Thanks @harshcatsystems-collab. - Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07. - Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog. +- Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07. - Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers. - Agents/Claude CLI: reuse already-cached macOS Keychain credentials for no-prompt Claude credential reads, so doctor/runtime checks do not miss fresh interactive Claude auth. Fixes #73682. Thanks @RyanSandoval. - Agents/Claude CLI doctor: scope workspace and project-dir checks to agents that actually use the Claude CLI runtime, so non-default Claude agents no longer make the default agent look Claude-backed. Fixes #73903. Thanks @bobfreeman1989. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 021581cf84e..d863a6ad39a 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -550,19 +550,19 @@ enumeration of `src/gateway/server-methods/*.ts`. The reference client in `src/gateway/client.ts` uses these defaults. Values are stable across protocol v3 and are the expected baseline for third-party clients. -| Constant | Default | Source | -| ----------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | -| `PROTOCOL_VERSION` | `3` | `src/gateway/protocol/schema/protocol-schemas.ts` | -| Request timeout (per RPC) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) | -| Preauth / connect-challenge timeout | `15_000` ms | `src/gateway/handshake-timeouts.ts` (clamp `250`–`15_000`) | -| Initial reconnect backoff | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) | -| Max reconnect backoff | `30_000` ms | `src/gateway/client.ts` (`scheduleReconnect`) | -| Fast-retry clamp after device-token close | `250` ms | `src/gateway/client.ts` | -| Force-stop grace before `terminate()` | `250` ms | `FORCE_STOP_TERMINATE_GRACE_MS` | -| `stopAndWait()` default timeout | `1_000` ms | `STOP_AND_WAIT_TIMEOUT_MS` | -| Default tick interval (pre `hello-ok`) | `30_000` ms | `src/gateway/client.ts` | -| Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `src/gateway/client.ts` | -| `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` | +| Constant | Default | Source | +| ----------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `PROTOCOL_VERSION` | `3` | `src/gateway/protocol/schema/protocol-schemas.ts` | +| Request timeout (per RPC) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) | +| Preauth / connect-challenge timeout | `15_000` ms | `src/gateway/handshake-timeouts.ts` (config/env can raise the paired server/client budget) | +| Initial reconnect backoff | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) | +| Max reconnect backoff | `30_000` ms | `src/gateway/client.ts` (`scheduleReconnect`) | +| Fast-retry clamp after device-token close | `250` ms | `src/gateway/client.ts` | +| Force-stop grace before `terminate()` | `250` ms | `FORCE_STOP_TERMINATE_GRACE_MS` | +| `stopAndWait()` default timeout | `1_000` ms | `STOP_AND_WAIT_TIMEOUT_MS` | +| Default tick interval (pre `hello-ok`) | `30_000` ms | `src/gateway/client.ts` | +| Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `src/gateway/client.ts` | +| `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` | The server advertises the effective `policy.tickIntervalMs`, `policy.maxPayload`, and `policy.maxBufferedBytes` in `hello-ok`; clients should honor those values diff --git a/src/acp/server.ts b/src/acp/server.ts index 9c6eb22fdf1..6819a347624 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -58,6 +58,7 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { }); }); + it("forwards configured handshake timeout to the connect probe and status RPC", async () => { + callGatewayMock.mockReset(); + probeGatewayMock.mockReset(); + callGatewayMock.mockResolvedValueOnce({ status: "ok" }); + probeGatewayMock.mockResolvedValueOnce({ + ok: true, + auth: { + role: "operator", + scopes: ["operator.admin"], + capability: "admin_capable", + }, + }); + const config = { gateway: { handshakeTimeoutMs: 30_000 } }; + + await probeGatewayStatus({ + url: "ws://127.0.0.1:19191", + token: "temp-token", + config, + preauthHandshakeTimeoutMs: 30_000, + timeoutMs: 30_000, + requireRpc: true, + }); + + expect(probeGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + preauthHandshakeTimeoutMs: 30_000, + timeoutMs: 30_000, + }), + ); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + config, + timeoutMs: 30_000, + }), + ); + }); + it("falls back to read-only when the status RPC succeeds but the auth probe is inconclusive", async () => { callGatewayMock.mockReset(); probeGatewayMock.mockReset(); diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index 486d02c93a9..155f2c5639b 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "../../config/types.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { withProgress } from "../progress.js"; @@ -27,8 +28,10 @@ export async function probeGatewayStatus(opts: { url: string; token?: string; password?: string; + config?: OpenClawConfig; tlsFingerprint?: string; timeoutMs: number; + preauthHandshakeTimeoutMs?: number; json?: boolean; requireRpc?: boolean; configPath?: string; @@ -50,6 +53,9 @@ export async function probeGatewayStatus(opts: { password: opts.password, }, tlsFingerprint: opts.tlsFingerprint, + ...(opts.preauthHandshakeTimeoutMs !== undefined + ? { preauthHandshakeTimeoutMs: opts.preauthHandshakeTimeoutMs } + : {}), timeoutMs: opts.timeoutMs, includeDetails: false, }; @@ -60,6 +66,7 @@ export async function probeGatewayStatus(opts: { token: opts.token, password: opts.password, tlsFingerprint: opts.tlsFingerprint, + ...(opts.config ? { config: opts.config } : {}), method: "status", timeoutMs: opts.timeoutMs, ...(opts.configPath ? { configPath: opts.configPath } : {}), diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index c64b78b8cd7..d63c7befde8 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -231,6 +231,31 @@ describe("gatherDaemonStatus", () => { ); }); + it("uses configured handshake timeout as the default daemon probe budget", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + handshakeTimeoutMs: 30_000, + auth: { token: "daemon-token" }, + }, + }; + + await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(callGatewayStatusProbe).toHaveBeenCalledWith( + expect.objectContaining({ + config: daemonLoadedConfig, + preauthHandshakeTimeoutMs: 30_000, + timeoutMs: 30_000, + }), + ); + }); + it("reuses the shared CLI config snapshot when the daemon uses the same config path", async () => { serviceReadCommand.mockResolvedValueOnce({ programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"], diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 173929cfcaf..3787a37efff 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -478,7 +478,9 @@ export async function gatherDaemonStatus( .catch(() => []) : []; - const timeoutMs = parseStrictPositiveInteger(opts.rpc.timeout ?? "10000") ?? 10_000; + const timeoutMs = + parseStrictPositiveInteger(opts.rpc.timeout ?? undefined) ?? + Math.max(10_000, daemonCfg.gateway?.handshakeTimeoutMs ?? 0); const tlsEnabled = daemonCfg.gateway?.tls?.enabled === true; const shouldUseLocalTlsRuntime = opts.probe && !probeUrlOverride && tlsEnabled; @@ -513,10 +515,12 @@ export async function gatherDaemonStatus( url: gateway.probeUrl, token: daemonProbeAuth?.token, password: daemonProbeAuth?.password, + config: daemonCfg, tlsFingerprint: shouldUseLocalTlsRuntime && tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined, + preauthHandshakeTimeoutMs: daemonCfg.gateway?.handshakeTimeoutMs, timeoutMs, json: opts.rpc.json, requireRpc: opts.requireRpc, diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index ebe9f9b38a6..d7205441b83 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -794,6 +794,28 @@ describe("gateway-status command", () => { ); }); + it("uses configured handshake timeout as the default local probe budget", async () => { + const { runtime } = createRuntimeCapture(); + probeGateway.mockClear(); + readBestEffortConfig.mockResolvedValueOnce({ + gateway: { + mode: "local", + handshakeTimeoutMs: 30_000, + auth: { mode: "token", token: "ltok" }, + }, + } as never); + + await gatewayStatusCommand({ json: true }, asRuntimeEnv(runtime)); + + expect(probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + url: "ws://127.0.0.1:18789", + preauthHandshakeTimeoutMs: 30_000, + timeoutMs: 30_000, + }), + ); + }); + it("keeps inactive local loopback probes on the short timeout in remote mode", async () => { const { runtime } = createRuntimeCapture(); probeGateway.mockClear(); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index 1d9a279f6fa..d1d77d8a07f 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -53,7 +53,8 @@ export async function gatewayStatusCommand( const startedAt = Date.now(); const cfg = await readBestEffortConfig(); const rich = isRich() && opts.json !== true; - const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000); + const defaultTimeoutMs = Math.max(3000, cfg.gateway?.handshakeTimeoutMs ?? 0); + const overallTimeoutMs = parseTimeoutMs(opts.timeout, defaultTimeoutMs); const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: cfg.discovery?.wideArea?.domain, }); diff --git a/src/commands/gateway-status/probe-run.ts b/src/commands/gateway-status/probe-run.ts index ba55a0380df..f3d9100d68f 100644 --- a/src/commands/gateway-status/probe-run.ts +++ b/src/commands/gateway-status/probe-run.ts @@ -131,6 +131,7 @@ export async function runGatewayStatusProbePass(params: { target.kind === "localLoopback" && target.url.startsWith("wss://") ? params.localTlsFingerprint : undefined, + preauthHandshakeTimeoutMs: params.cfg.gateway?.handshakeTimeoutMs, timeoutMs: resolveProbeBudgetMs(params.overallTimeoutMs, target), }); return { diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts index 6e5feb0925a..3707af8bae8 100644 --- a/src/commands/status.scan.shared.test.ts +++ b/src/commands/status.scan.shared.test.ts @@ -238,6 +238,91 @@ describe("resolveGatewayProbeSnapshot", () => { expect(result.gatewayProbeAuthWarning).toBe("warn"); }); + it("keeps the local status RPC fallback timeout aligned with configured handshake timeout", async () => { + mocks.resolveGatewayProbeTarget.mockReturnValue({ + mode: "local", + gatewayMode: "local", + remoteUrlMissing: false, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + auth: { + role: null, + scopes: [], + capability: "unknown", + }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.callGateway.mockResolvedValue({ sessions: 1 }); + + await resolveGatewayProbeSnapshot({ + cfg: { gateway: { handshakeTimeoutMs: 30_000 } }, + opts: {}, + }); + + expect(mocks.probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + preauthHandshakeTimeoutMs: 30_000, + timeoutMs: 30_000, + }), + ); + expect(mocks.callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + config: { gateway: { handshakeTimeoutMs: 30_000 } }, + timeoutMs: 30_000, + }), + ); + }); + + it("does not raise an explicit local status RPC fallback timeout", async () => { + mocks.resolveGatewayProbeTarget.mockReturnValue({ + mode: "local", + gatewayMode: "local", + remoteUrlMissing: false, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + auth: { + role: null, + scopes: [], + capability: "unknown", + }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.callGateway.mockResolvedValue({ sessions: 1 }); + + await resolveGatewayProbeSnapshot({ + cfg: { gateway: { handshakeTimeoutMs: 30_000 } }, + opts: { timeoutMs: 1000 }, + }); + + expect(mocks.probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ + preauthHandshakeTimeoutMs: 30_000, + timeoutMs: 1000, + }), + ); + expect(mocks.callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 1000, + }), + ); + }); + it("lets callGateway reuse paired-device auth for local status RPC fallback", async () => { mocks.resolveGatewayProbeTarget.mockReturnValue({ mode: "local", diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index d5c03aaf39f..1588e3ffdb3 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -120,10 +120,12 @@ async function applyLocalStatusRpcFallback(params: { password?: string; }; timeoutMs: number; + timeoutMsExplicit: boolean; }): Promise { if (!shouldTryLocalStatusRpcFallback(params)) { return params.gatewayProbe; } + const boundedFallbackTimeoutMs = Math.min(2000, Math.max(1000, params.timeoutMs)); const status = await loadGatewayCallModule() .then(({ callGateway }) => callGateway({ @@ -131,7 +133,9 @@ async function applyLocalStatusRpcFallback(params: { method: "status", token: params.gatewayProbeAuth.token, password: params.gatewayProbeAuth.password, - timeoutMs: Math.min(2000, Math.max(1000, params.timeoutMs)), + timeoutMs: params.timeoutMsExplicit + ? boundedFallbackTimeoutMs + : Math.max(params.cfg.gateway?.handshakeTimeoutMs ?? 0, boundedFallbackTimeoutMs), mode: GATEWAY_CLIENT_MODES.BACKEND, clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, }), @@ -206,13 +210,19 @@ export async function resolveGatewayProbeSnapshot(params: { ) : { auth: {}, warning: undefined }; let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning; - const probeTimeoutMs = Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000); + const defaultProbeTimeoutMs = Math.max( + params.opts.all ? 5000 : 2500, + params.cfg.gateway?.handshakeTimeoutMs ?? 0, + ); + const timeoutMsExplicit = params.opts.timeoutMs !== undefined; + const probeTimeoutMs = params.opts.timeoutMs ?? defaultProbeTimeoutMs; const initialGatewayProbe = shouldProbe ? await loadProbeGatewayModule() .then(({ probeGateway }) => probeGateway({ url: gatewayConnection.url, auth: gatewayProbeAuthResolution.auth, + preauthHandshakeTimeoutMs: params.cfg.gateway?.handshakeTimeoutMs, timeoutMs: probeTimeoutMs, detailLevel: params.opts.detailLevel ?? "presence", }), @@ -226,6 +236,7 @@ export async function resolveGatewayProbeSnapshot(params: { gatewayProbe: initialGatewayProbe, gatewayProbeAuth: gatewayProbeAuthResolution.auth, timeoutMs: probeTimeoutMs, + timeoutMsExplicit, }); if ( (params.opts.mergeAuthWarningIntoProbeError ?? true) && diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 32361ac2ed9..e92bf03324c 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -27,6 +27,7 @@ let lastClientOptions: { token?: string; password?: string; tlsFingerprint?: string; + preauthHandshakeTimeoutMs?: number; clientName?: string; clientDisplayName?: string; mode?: string; @@ -61,6 +62,7 @@ vi.mock("./client.js", () => ({ url?: string; token?: string; password?: string; + preauthHandshakeTimeoutMs?: number; clientName?: string; clientDisplayName?: string; mode?: string; @@ -101,6 +103,7 @@ class StubGatewayClient { url?: string; token?: string; password?: string; + preauthHandshakeTimeoutMs?: number; clientName?: string; clientDisplayName?: string; mode?: string; @@ -838,6 +841,51 @@ describe("callGateway error details", () => { expect(errMessage).toContain("Bind: loopback"); }); + it("keeps the default wrapper timeout aligned with configured handshake timeout", async () => { + startMode = "silent"; + getRuntimeConfig.mockReturnValue({ + gateway: { mode: "local", bind: "loopback", handshakeTimeoutMs: 30_000 }, + }); + setGatewayNetworkDefaults(); + + vi.useFakeTimers(); + let errMessage = ""; + const promise = callGateway({ method: "health" }).catch((caught) => { + errMessage = caught instanceof Error ? caught.message : String(caught); + }); + + await vi.advanceTimersByTimeAsync(10_000); + expect(errMessage).toBe(""); + await vi.advanceTimersByTimeAsync(20_000); + await promise; + + expect(errMessage).toContain("gateway timeout after 30000ms"); + }); + + it("keeps the default wrapper timeout aligned with env handshake timeout", async () => { + const envSnapshot = captureEnv(["OPENCLAW_HANDSHAKE_TIMEOUT_MS"]); + try { + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = "30000"; + startMode = "silent"; + setLocalLoopbackGatewayConfig(); + + vi.useFakeTimers(); + let errMessage = ""; + const promise = callGateway({ method: "health" }).catch((caught) => { + errMessage = caught instanceof Error ? caught.message : String(caught); + }); + + await vi.advanceTimersByTimeAsync(10_000); + expect(errMessage).toBe(""); + await vi.advanceTimersByTimeAsync(20_000); + await promise; + + expect(errMessage).toContain("gateway timeout after 30000ms"); + } finally { + envSnapshot.restore(); + } + }); + it("does not overflow very large timeout values", async () => { startMode = "silent"; setLocalLoopbackGatewayConfig(); @@ -866,6 +914,17 @@ describe("callGateway error details", () => { expect(lastRequestOptions?.opts?.timeoutMs).toBe(45_000); }); + it("passes configured gateway handshake timeout to the client watchdog", async () => { + getRuntimeConfig.mockReturnValue({ + gateway: { mode: "local", bind: "loopback", handshakeTimeoutMs: 30_000 }, + }); + setGatewayNetworkDefaults(); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.preauthHandshakeTimeoutMs).toBe(30_000); + }); + it("does not inject wrapper timeout defaults into expectFinal requests", async () => { setLocalLoopbackGatewayConfig(); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 2984a74fcef..ee027c618f3 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -33,6 +33,7 @@ import { type GatewayRemoteCredentialPrecedence, } from "./credentials.js"; import { canSkipGatewayConfigLoad } from "./explicit-connection-policy.js"; +import { resolvePreauthHandshakeTimeoutMs } from "./handshake-timeouts.js"; import { CLI_DEFAULT_OPERATOR_SCOPES, isGatewayMethodClassified, @@ -318,12 +319,30 @@ type ResolvedGatewayCallContext = { remotePasswordFallback?: GatewayRemoteCredentialFallback; }; -function resolveGatewayCallTimeout(timeoutValue: unknown): { +function resolveGatewayCallTimeout( + timeoutValue: unknown, + configuredHandshakeTimeoutMs?: number | null, +): { timeoutMs: number; safeTimerTimeoutMs: number; } { + const hasConfiguredHandshakeTimeout = + typeof configuredHandshakeTimeoutMs === "number" && + Number.isFinite(configuredHandshakeTimeoutMs) && + configuredHandshakeTimeoutMs > 0; + const hasEnvHandshakeTimeout = + Boolean(process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS) || + Boolean(process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); + const resolvedHandshakeTimeoutMs = + hasConfiguredHandshakeTimeout || hasEnvHandshakeTimeout + ? resolvePreauthHandshakeTimeoutMs({ configuredTimeoutMs: configuredHandshakeTimeoutMs }) + : undefined; const timeoutMs = - typeof timeoutValue === "number" && Number.isFinite(timeoutValue) ? timeoutValue : 10_000; + typeof timeoutValue === "number" && Number.isFinite(timeoutValue) + ? timeoutValue + : typeof resolvedHandshakeTimeoutMs === "number" && resolvedHandshakeTimeoutMs > 10_000 + ? resolvedHandshakeTimeoutMs + : 10_000; const safeTimerTimeoutMs = resolveSafeTimeoutDelayMs(timeoutMs); return { timeoutMs, safeTimerTimeoutMs }; } @@ -505,12 +524,22 @@ async function executeGatewayRequestWithScopes(params: { token?: string; password?: string; tlsFingerprint?: string; + preauthHandshakeTimeoutMs?: number; timeoutMs: number; safeTimerTimeoutMs: number; connectionDetails: GatewayConnectionDetails; }): Promise { - const { opts, scopes, url, token, password, tlsFingerprint, timeoutMs, safeTimerTimeoutMs } = - params; + const { + opts, + scopes, + url, + token, + password, + tlsFingerprint, + preauthHandshakeTimeoutMs, + timeoutMs, + safeTimerTimeoutMs, + } = params; // Yield to the event loop before starting the WebSocket connection. // On Windows with large dist bundles, heavy synchronous module loading // can starve the event loop, preventing timely processing of the @@ -539,6 +568,7 @@ async function executeGatewayRequestWithScopes(params: { token, password, tlsFingerprint, + preauthHandshakeTimeoutMs, instanceId: opts.instanceId ?? randomUUID(), clientName: opts.clientName ?? GATEWAY_CLIENT_NAMES.CLI, clientDisplayName: resolveGatewayClientDisplayName(opts), @@ -593,8 +623,11 @@ async function callGatewayWithScopes>( opts: CallGatewayBaseOptions, scopes: OperatorScope[], ): Promise { - const { timeoutMs, safeTimerTimeoutMs } = resolveGatewayCallTimeout(opts.timeoutMs); const context = resolveGatewayCallContext(opts); + const { timeoutMs, safeTimerTimeoutMs } = resolveGatewayCallTimeout( + opts.timeoutMs, + context.config.gateway?.handshakeTimeoutMs, + ); const resolvedCredentials = await resolveGatewayCredentials(context); ensureExplicitGatewayAuth({ urlOverride: context.urlOverride, @@ -621,6 +654,7 @@ async function callGatewayWithScopes>( token, password, tlsFingerprint, + preauthHandshakeTimeoutMs: context.config.gateway?.handshakeTimeoutMs, timeoutMs, safeTimerTimeoutMs, connectionDetails, diff --git a/src/gateway/client-bootstrap.test.ts b/src/gateway/client-bootstrap.test.ts index 99c9b9c6e5c..4dd3a70a947 100644 --- a/src/gateway/client-bootstrap.test.ts +++ b/src/gateway/client-bootstrap.test.ts @@ -51,6 +51,7 @@ describe("resolveGatewayClientBootstrap", () => { expect(result).toEqual({ url: "wss://override.example/ws", urlSource: "cli --url", + preauthHandshakeTimeoutMs: undefined, auth: { token: undefined, password: undefined, @@ -84,4 +85,18 @@ describe("resolveGatewayClientBootstrap", () => { }), ); }); + + it("carries configured preauth handshake timeout for GatewayClient callers", async () => { + mockState.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "local loopback", + }); + + const result = await resolveGatewayClientBootstrap({ + config: { gateway: { handshakeTimeoutMs: 30_000 } } as never, + env: process.env, + }); + + expect(result.preauthHandshakeTimeoutMs).toBe(30_000); + }); }); diff --git a/src/gateway/client-bootstrap.ts b/src/gateway/client-bootstrap.ts index b4c90db6265..768efbf1fa9 100644 --- a/src/gateway/client-bootstrap.ts +++ b/src/gateway/client-bootstrap.ts @@ -21,6 +21,7 @@ export async function resolveGatewayClientBootstrap(params: { }): Promise<{ url: string; urlSource: string; + preauthHandshakeTimeoutMs?: number; auth: { token?: string; password?: string; @@ -41,6 +42,7 @@ export async function resolveGatewayClientBootstrap(params: { return { url: connection.url, urlSource: connection.urlSource, + preauthHandshakeTimeoutMs: params.config.gateway?.handshakeTimeoutMs, auth, }; } diff --git a/src/gateway/client.ts b/src/gateway/client.ts index d0f3c5149e9..6655c0692a3 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -127,6 +127,11 @@ export type GatewayClientOptions = { connectChallengeTimeoutMs?: number; /** @deprecated Use connectChallengeTimeoutMs. */ connectDelayMs?: number; + /** + * Server-side pre-auth handshake budget. Config-derived local clients use + * this to keep the connect-challenge watchdog aligned with the gateway. + */ + preauthHandshakeTimeoutMs?: number; tickWatchMinIntervalMs?: number; requestTimeoutMs?: number; token?: string; @@ -190,9 +195,14 @@ function isGatewayClientStoppedError(err: unknown): boolean { } export function resolveGatewayClientConnectChallengeTimeoutMs( - opts: Pick, + opts: Pick< + GatewayClientOptions, + "connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs" + >, ): number { - return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts)); + return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), { + configuredTimeoutMs: opts.preauthHandshakeTimeoutMs, + }); } const FORCE_STOP_TERMINATE_GRACE_MS = 250; diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index 4ab4e385568..3b46ec1c29e 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -94,6 +94,17 @@ describe("GatewayClient", () => { connectChallengeTimeoutMs: 5_000, }), ).toBe(5_000); + expect( + resolveGatewayClientConnectChallengeTimeoutMs({ + preauthHandshakeTimeoutMs: 30_000, + }), + ).toBe(30_000); + expect( + resolveGatewayClientConnectChallengeTimeoutMs({ + connectChallengeTimeoutMs: 45_000, + preauthHandshakeTimeoutMs: 30_000, + }), + ).toBe(30_000); }); test("closes on missing ticks", async () => { diff --git a/src/gateway/handshake-timeouts.test.ts b/src/gateway/handshake-timeouts.test.ts index c3e2f01b600..96b358585a1 100644 --- a/src/gateway/handshake-timeouts.test.ts +++ b/src/gateway/handshake-timeouts.test.ts @@ -19,6 +19,7 @@ describe("gateway handshake timeouts", () => { expect(clampConnectChallengeTimeoutMs(0)).toBe(MIN_CONNECT_CHALLENGE_TIMEOUT_MS); expect(clampConnectChallengeTimeoutMs(2_000)).toBe(2_000); expect(clampConnectChallengeTimeoutMs(20_000)).toBe(MAX_CONNECT_CHALLENGE_TIMEOUT_MS); + expect(clampConnectChallengeTimeoutMs(30_000, 30_000)).toBe(30_000); }); test("prefers OPENCLAW_HANDSHAKE_TIMEOUT_MS and falls back on the test-only env", () => { @@ -107,17 +108,38 @@ describe("gateway handshake timeouts", () => { test("resolveConnectChallengeTimeoutMs falls back to env override", () => { const original = process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS; + const originalHandshake = process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS; try { process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS = "5000"; expect(resolveConnectChallengeTimeoutMs()).toBe(5_000); // Explicit value still takes precedence over env expect(resolveConnectChallengeTimeoutMs(3_000)).toBe(3_000); + process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS = ""; + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = "30000"; + expect(resolveConnectChallengeTimeoutMs()).toBe(30_000); } finally { if (original === undefined) { delete process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS; } else { process.env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS = original; } + if (originalHandshake === undefined) { + delete process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS; + } else { + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = originalHandshake; + } } }); + + test("resolveConnectChallengeTimeoutMs follows configured preauth timeout", () => { + expect( + resolveConnectChallengeTimeoutMs(undefined, { env: {}, configuredTimeoutMs: 30_000 }), + ).toBe(30_000); + expect(resolveConnectChallengeTimeoutMs(45_000, { env: {}, configuredTimeoutMs: 30_000 })).toBe( + 30_000, + ); + expect(resolveConnectChallengeTimeoutMs(0, { env: {}, configuredTimeoutMs: 30_000 })).toBe( + MIN_CONNECT_CHALLENGE_TIMEOUT_MS, + ); + }); }); diff --git a/src/gateway/handshake-timeouts.ts b/src/gateway/handshake-timeouts.ts index f01c2514cfc..9eb36f75b2c 100644 --- a/src/gateway/handshake-timeouts.ts +++ b/src/gateway/handshake-timeouts.ts @@ -2,10 +2,13 @@ export const DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 15_000; export const MIN_CONNECT_CHALLENGE_TIMEOUT_MS = 250; export const MAX_CONNECT_CHALLENGE_TIMEOUT_MS = DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS; -export function clampConnectChallengeTimeoutMs(timeoutMs: number): number { +export function clampConnectChallengeTimeoutMs( + timeoutMs: number, + maxTimeoutMs = MAX_CONNECT_CHALLENGE_TIMEOUT_MS, +): number { return Math.max( MIN_CONNECT_CHALLENGE_TIMEOUT_MS, - Math.min(MAX_CONNECT_CHALLENGE_TIMEOUT_MS, timeoutMs), + Math.min(Math.max(MIN_CONNECT_CHALLENGE_TIMEOUT_MS, maxTimeoutMs), timeoutMs), ); } @@ -22,15 +25,32 @@ export function getConnectChallengeTimeoutMsFromEnv( return undefined; } -export function resolveConnectChallengeTimeoutMs(timeoutMs?: number | null): number { +function normalizePositiveTimeoutMs(timeoutMs: unknown): number | undefined { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : undefined; +} + +export function resolveConnectChallengeTimeoutMs( + timeoutMs?: number | null, + params?: { + env?: NodeJS.ProcessEnv; + configuredTimeoutMs?: number | null; + }, +): number { + const configuredPreauthTimeoutMs = resolvePreauthHandshakeTimeoutMs({ + env: params?.env, + configuredTimeoutMs: params?.configuredTimeoutMs, + }); + const maxTimeoutMs = Math.max(DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS, configuredPreauthTimeoutMs); if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { - return clampConnectChallengeTimeoutMs(timeoutMs); + return clampConnectChallengeTimeoutMs(timeoutMs, maxTimeoutMs); } - const envOverride = getConnectChallengeTimeoutMsFromEnv(); + const envOverride = getConnectChallengeTimeoutMsFromEnv(params?.env); if (envOverride !== undefined) { - return clampConnectChallengeTimeoutMs(envOverride); + return clampConnectChallengeTimeoutMs(envOverride, Math.max(maxTimeoutMs, envOverride)); } - return DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS; + return clampConnectChallengeTimeoutMs(configuredPreauthTimeoutMs, maxTimeoutMs); } export function getPreauthHandshakeTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number { @@ -58,8 +78,8 @@ export function resolvePreauthHandshakeTimeoutMs(params?: { return parsed; } } - const configured = params?.configuredTimeoutMs; - if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) { + const configured = normalizePositiveTimeoutMs(params?.configuredTimeoutMs); + if (configured !== undefined) { return configured; } return DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS; diff --git a/src/gateway/operator-approvals-client.ts b/src/gateway/operator-approvals-client.ts index 4428cef76f0..86a6bcddac7 100644 --- a/src/gateway/operator-approvals-client.ts +++ b/src/gateway/operator-approvals-client.ts @@ -27,6 +27,7 @@ export async function createOperatorApprovalsGatewayClient( url: bootstrap.url, token: bootstrap.auth.token, password: bootstrap.auth.password, + preauthHandshakeTimeoutMs: bootstrap.preauthHandshakeTimeoutMs, clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, clientDisplayName: params.clientDisplayName, mode: GATEWAY_CLIENT_MODES.BACKEND, diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 991687bdea7..4d1acaeac79 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -144,6 +144,7 @@ export async function probeGateway(opts: { url: string; auth?: GatewayProbeAuth; timeoutMs: number; + preauthHandshakeTimeoutMs?: number; includeDetails?: boolean; detailLevel?: "none" | "presence" | "full"; tlsFingerprint?: string; @@ -255,6 +256,7 @@ export async function probeGateway(opts: { token: opts.auth?.token, password: opts.auth?.password, tlsFingerprint: opts.tlsFingerprint, + preauthHandshakeTimeoutMs: opts.preauthHandshakeTimeoutMs, scopes: [READ_SCOPE], clientName: GATEWAY_CLIENT_NAMES.CLI, clientVersion: "dev", diff --git a/src/mcp/channel-bridge.ts b/src/mcp/channel-bridge.ts index 428c14e5d62..ef420648186 100644 --- a/src/mcp/channel-bridge.ts +++ b/src/mcp/channel-bridge.ts @@ -114,6 +114,7 @@ export class OpenClawChannelBridge { url: bootstrap.url, token: bootstrap.auth.token, password: bootstrap.auth.password, + preauthHandshakeTimeoutMs: bootstrap.preauthHandshakeTimeoutMs, clientName: GATEWAY_CLIENT_NAMES.CLI, clientDisplayName: "OpenClaw MCP", clientVersion: VERSION, diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index b73f5823252..14aba18d66d 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -222,6 +222,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { url, token: token || undefined, password: password || undefined, + preauthHandshakeTimeoutMs: cfg.gateway?.handshakeTimeoutMs, instanceId: nodeId, clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, clientDisplayName: displayName, diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index d6a0975b9f9..2b9d31b0ef8 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -166,9 +166,24 @@ describe("resolveGatewayConnection", () => { expect(result).toEqual({ url: "wss://override.example/ws", ...expected, + preauthHandshakeTimeoutMs: undefined, allowInsecureLocalOperatorUi: false, }); }); + + it("carries configured handshake timeout to the TUI client connection", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + handshakeTimeoutMs: 30_000, + auth: { token: "config-token" }, + }, + }); + + const result = await resolveGatewayConnection({}); + + expect(result.preauthHandshakeTimeoutMs).toBe(30_000); + }); it("uses config auth token for local mode when both config and env tokens are set", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "config-token" } } }); @@ -504,6 +519,7 @@ describe("GatewayChatClient", () => { const client = new GatewayChatClient({ url: "ws://127.0.0.1:18789", token: "test-token", + preauthHandshakeTimeoutMs: 30_000, allowInsecureLocalOperatorUi: true, }); @@ -519,6 +535,10 @@ describe("GatewayChatClient", () => { (client as unknown as { client: { opts: { deviceIdentity?: unknown } } }).client.opts .deviceIdentity, ).toBeUndefined(); + expect( + (client as unknown as { client: { opts: { preauthHandshakeTimeoutMs?: number } } }).client + .opts.preauthHandshakeTimeoutMs, + ).toBe(30_000); }); it("retries startup-unavailable chat history until the gateway finishes booting", async () => { diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 7cefc298ef1..80e78d471f9 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -49,6 +49,7 @@ type ResolvedGatewayConnection = { url: string; token?: string; password?: string; + preauthHandshakeTimeoutMs?: number; allowInsecureLocalOperatorUi?: boolean; }; @@ -117,6 +118,7 @@ export class GatewayChatClient implements TuiBackend { url: connection.url, token: connection.token, password: connection.password, + preauthHandshakeTimeoutMs: connection.preauthHandshakeTimeoutMs, clientName: GATEWAY_CLIENT_NAMES.TUI, clientDisplayName: "openclaw-tui", clientVersion: VERSION, @@ -284,6 +286,7 @@ export async function resolveGatewayConnection( url, token: explicitAuth.token, password: explicitAuth.password, + preauthHandshakeTimeoutMs: config.gateway?.handshakeTimeoutMs, allowInsecureLocalOperatorUi, }; } @@ -302,6 +305,7 @@ export async function resolveGatewayConnection( url, token: resolved.token, password: resolved.password, + preauthHandshakeTimeoutMs: config.gateway?.handshakeTimeoutMs, allowInsecureLocalOperatorUi: false, }; } @@ -317,6 +321,7 @@ export async function resolveGatewayConnection( url, token: resolved.token, password: resolved.password, + preauthHandshakeTimeoutMs: config.gateway?.handshakeTimeoutMs, allowInsecureLocalOperatorUi, }; } @@ -341,6 +346,7 @@ export async function resolveGatewayConnection( url, token: resolved.token, password: resolved.password, + preauthHandshakeTimeoutMs: config.gateway?.handshakeTimeoutMs, allowInsecureLocalOperatorUi, }; }