fix(gateway): align handshake client timeouts

This commit is contained in:
Peter Steinberger
2026-04-29 05:53:38 +01:00
parent 5e2f6ce294
commit 7994833fac
26 changed files with 432 additions and 33 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -58,6 +58,7 @@ export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void
url: bootstrap.url,
token: bootstrap.auth.token,
password: bootstrap.auth.password,
preauthHandshakeTimeoutMs: bootstrap.preauthHandshakeTimeoutMs,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "ACP",
clientVersion: "acp",

View File

@@ -138,6 +138,43 @@ describe("probeGatewayStatus", () => {
});
});
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();

View File

@@ -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 } : {}),

View File

@@ -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"],

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,
});

View File

@@ -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 {

View File

@@ -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",

View File

@@ -120,10 +120,12 @@ async function applyLocalStatusRpcFallback(params: {
password?: string;
};
timeoutMs: number;
timeoutMsExplicit: boolean;
}): Promise<GatewayProbeResult | null> {
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) &&

View File

@@ -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();

View File

@@ -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<T>(params: {
token?: string;
password?: string;
tlsFingerprint?: string;
preauthHandshakeTimeoutMs?: number;
timeoutMs: number;
safeTimerTimeoutMs: number;
connectionDetails: GatewayConnectionDetails;
}): Promise<T> {
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<T>(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<T = Record<string, unknown>>(
opts: CallGatewayBaseOptions,
scopes: OperatorScope[],
): Promise<T> {
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<T = Record<string, unknown>>(
token,
password,
tlsFingerprint,
preauthHandshakeTimeoutMs: context.config.gateway?.handshakeTimeoutMs,
timeoutMs,
safeTimerTimeoutMs,
connectionDetails,

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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<GatewayClientOptions, "connectChallengeTimeoutMs" | "connectDelayMs">,
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;

View File

@@ -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 () => {

View File

@@ -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,
);
});
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -222,6 +222,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
url,
token: token || undefined,
password: password || undefined,
preauthHandshakeTimeoutMs: cfg.gateway?.handshakeTimeoutMs,
instanceId: nodeId,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: displayName,

View File

@@ -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 () => {

View File

@@ -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,
};
}