Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
{
"name": "@openclaw/gateway-client",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./readiness": {
"types": "./dist/readiness.d.mts",
"import": "./dist/readiness.mjs",
"default": "./dist/readiness.mjs"
},
"./timeouts": {
"types": "./dist/timeouts.d.mts",
"import": "./dist/timeouts.mjs",
"default": "./dist/timeouts.mjs"
}
},
"scripts": {
"build": "tsdown src/index.ts src/readiness.ts src/timeouts.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"@openclaw/gateway-protocol": "workspace:*",
"ipaddr.js": "2.4.0",
"ws": "8.21.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,612 @@
// Gateway Client tests cover client.watchdog behavior.
import { createServer as createHttpsServer } from "node:https";
import { createServer } from "node:net";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { GatewayClient, resolveGatewayClientConnectChallengeTimeoutMs } from "./client.js";
import {
DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS,
MAX_SAFE_TIMEOUT_DELAY_MS,
MAX_CONNECT_CHALLENGE_TIMEOUT_MS,
MIN_CONNECT_CHALLENGE_TIMEOUT_MS,
} from "./timeouts.js";
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as { port: number }).port;
server.close((err) => (err ? reject(err) : resolve(port)));
});
});
}
function rawDataToString(data: unknown): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data.map((entry) => Buffer.from(entry))).toString("utf8");
}
return String(data);
}
function createOpenGatewayClient(requestTimeoutMs: number): {
client: GatewayClient;
send: ReturnType<typeof vi.fn>;
} {
const client = new GatewayClient({
requestTimeoutMs,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
return { client, send };
}
function getPendingCount(client: GatewayClient): number {
return (client as unknown as { pending: Map<string, unknown> }).pending.size;
}
function trackSettlement(promise: Promise<unknown>): () => boolean {
let settled = false;
void promise.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
return () => settled;
}
describe("GatewayClient", () => {
let wss: WebSocketServer | null = null;
let httpsServer: ReturnType<typeof createHttpsServer> | null = null;
afterEach(async () => {
if (wss) {
for (const client of wss.clients) {
client.terminate();
}
await new Promise<void>((resolve) => {
wss?.close(() => resolve());
});
wss = null;
}
if (httpsServer) {
httpsServer.closeAllConnections?.();
httpsServer.closeIdleConnections?.();
await new Promise<void>((resolve) => {
httpsServer?.close(() => resolve());
});
httpsServer = null;
}
});
test("prefers connectChallengeTimeoutMs and still honors the legacy alias", () => {
expect(resolveGatewayClientConnectChallengeTimeoutMs({})).toBe(
DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS,
);
expect(resolveGatewayClientConnectChallengeTimeoutMs({ connectDelayMs: 0 })).toBe(
MIN_CONNECT_CHALLENGE_TIMEOUT_MS,
);
expect(resolveGatewayClientConnectChallengeTimeoutMs({ connectDelayMs: 20_000 })).toBe(
MAX_CONNECT_CHALLENGE_TIMEOUT_MS,
);
expect(
resolveGatewayClientConnectChallengeTimeoutMs({
connectDelayMs: 2_000,
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);
expect(
resolveGatewayClientConnectChallengeTimeoutMs({
env: { OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "6000" },
}),
).toBe(6_000);
});
test("closes on missing ticks", async () => {
const port = await getFreePort();
wss = new WebSocketServer({ port, host: "127.0.0.1" });
wss.on("connection", (socket) => {
socket.once("message", (data) => {
const first = JSON.parse(rawDataToString(data)) as { id?: string };
const id = first.id ?? "connect";
// Respond with tiny tick interval to trigger watchdog quickly.
const helloOk = {
type: "hello-ok",
protocol: 2,
server: { version: "dev", connId: "c1" },
features: { methods: [], events: [] },
snapshot: {
presence: [],
health: {},
stateVersion: { presence: 1, health: 1 },
uptimeMs: 1,
},
policy: {
maxPayload: 512 * 1024,
maxBufferedBytes: 1024 * 1024,
tickIntervalMs: 5,
},
};
socket.send(JSON.stringify({ type: "res", id, ok: true, payload: helloOk }));
});
});
const closed = new Promise<{ code: number; reason: string }>((resolve) => {
const client = new GatewayClient({
url: `ws://127.0.0.1:${port}`,
connectChallengeTimeoutMs: 0,
tickWatchMinIntervalMs: 5,
onClose: (code, reason) => resolve({ code, reason }),
});
client.start();
});
const res = await closed;
// Depending on auth/challenge timing in the harness, the client can either
// hit the tick watchdog (4000) or close with policy violation (1008).
expect([4000, 1008]).toContain(res.code);
if (res.code === 4000) {
expect(res.reason).toContain("tick timeout");
}
}, 4000);
test("lets pending requests own their timeout when ticks are missing", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
requestTimeoutMs: 10_000,
tickWatchMinIntervalMs: 5,
});
const close = vi.fn();
const pending = (client as unknown as { pending: Map<string, unknown> }).pending;
Object.assign(
client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number },
{
ws: {
readyState: WebSocket.OPEN,
send: vi.fn(),
close,
},
tickIntervalMs: 5,
lastTick: Date.now(),
},
);
pending.set("long-rpc", {
resolve: vi.fn(),
reject: vi.fn(),
expectFinal: false,
timeout: null,
});
(
client as unknown as {
startTickWatch: () => void;
}
).startTickWatch();
await vi.advanceTimersByTimeAsync(20);
expect(close).not.toHaveBeenCalled();
pending.clear();
await vi.advanceTimersByTimeAsync(5);
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
} finally {
vi.useRealTimers();
}
});
test("honors explicit tick watchdog timeout threshold", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({
tickWatchMinIntervalMs: 5,
tickWatchTimeoutMs: 50,
});
const close = vi.fn();
Object.assign(
client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number },
{
ws: {
readyState: WebSocket.OPEN,
send: vi.fn(),
close,
},
tickIntervalMs: 5,
lastTick: Date.now(),
},
);
(
client as unknown as {
startTickWatch: () => void;
}
).startTickWatch();
await vi.advanceTimersByTimeAsync(20);
expect(close).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(35);
expect(close).toHaveBeenCalledWith(4000, "tick timeout");
} finally {
vi.useRealTimers();
}
});
test("clamps oversized tick watchdog intervals before scheduling", () => {
vi.useFakeTimers();
try {
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
const client = new GatewayClient({
tickWatchMinIntervalMs: 5,
});
Object.assign(
client as unknown as { ws: unknown; tickIntervalMs: number; lastTick: number },
{
ws: {
readyState: WebSocket.OPEN,
send: vi.fn(),
close: vi.fn(),
},
tickIntervalMs: Number.MAX_SAFE_INTEGER,
lastTick: Date.now(),
},
);
(
client as unknown as {
startTickWatch: () => void;
}
).startTickWatch();
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
client.stop();
} finally {
vi.useRealTimers();
}
});
test("times out unresolved requests and clears pending state", async () => {
vi.useFakeTimers();
try {
const { client, send } = createOpenGatewayClient(25);
const requestPromise = client.request("status");
const requestExpectation = expect(requestPromise).rejects.toThrow(
"gateway request timeout for status",
);
expect(send).toHaveBeenCalledTimes(1);
expect(getPendingCount(client)).toBe(1);
await vi.advanceTimersByTimeAsync(25);
await requestExpectation;
expect(getPendingCount(client)).toBe(0);
} finally {
vi.useRealTimers();
}
});
test("cleans pending request state when websocket send throws", async () => {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const sendError = new Error("synthetic send failure");
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send: vi.fn(() => {
throw sendError;
}),
close: vi.fn(),
};
await expect(client.request("status")).rejects.toThrow("synthetic send failure");
expect(getPendingCount(client)).toBe(0);
});
test("does not auto-timeout expectFinal requests", async () => {
vi.useFakeTimers();
try {
const { client, send } = createOpenGatewayClient(25);
const requestPromise = client.request("chat.send", undefined, { expectFinal: true });
const isSettled = trackSettlement(requestPromise);
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(25);
expect(isSettled()).toBe(false);
expect(getPendingCount(client)).toBe(1);
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");
} finally {
vi.useRealTimers();
}
});
test("notifies accepted expectFinal requests while continuing to wait for final", async () => {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: (data: string) => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
const onAccepted = vi.fn();
const requestPromise = client.request<{ status: string }>("agent", undefined, {
expectFinal: true,
onAccepted,
});
const frame = JSON.parse(String(send.mock.calls[0]?.[0])) as { id: string };
(
client as unknown as {
handleMessage: (raw: string) => void;
}
).handleMessage(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: { status: "accepted", runId: "run-1" },
}),
);
expect(onAccepted).toHaveBeenCalledWith({ status: "accepted", runId: "run-1" });
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
(
client as unknown as {
handleMessage: (raw: string) => void;
}
).handleMessage(
JSON.stringify({
type: "res",
id: frame.id,
ok: true,
payload: { status: "ok" },
}),
);
await expect(requestPromise).resolves.toEqual({ status: "ok" });
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
});
test("aborts in-flight requests from caller AbortSignal", async () => {
const client = new GatewayClient({
requestTimeoutMs: 25,
});
const send = vi.fn();
(
client as unknown as {
ws: WebSocket | { readyState: number; send: () => void; close: () => void };
}
).ws = {
readyState: WebSocket.OPEN,
send,
close: vi.fn(),
};
const controller = new AbortController();
const requestPromise = client.request("status", undefined, {
signal: controller.signal,
timeoutMs: null,
});
expect(send).toHaveBeenCalledTimes(1);
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
controller.abort();
await expect(requestPromise).rejects.toThrow("gateway request aborted for status");
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
});
test("clamps oversized explicit request timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const { client } = createOpenGatewayClient(25);
const requestPromise = client.request("status", undefined, { timeoutMs: 2_592_010_000 });
const isSettled = trackSettlement(requestPromise);
await vi.advanceTimersByTimeAsync(1);
expect(isSettled()).toBe(false);
expect(getPendingCount(client)).toBe(1);
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");
} finally {
vi.useRealTimers();
}
});
test("clamps oversized default request timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const { client } = createOpenGatewayClient(2_592_010_000);
const requestPromise = client.request("status");
const isSettled = trackSettlement(requestPromise);
await vi.advanceTimersByTimeAsync(1);
expect(isSettled()).toBe(false);
expect(getPendingCount(client)).toBe(1);
client.stop();
await expect(requestPromise).rejects.toThrow("gateway client stopped");
} finally {
vi.useRealTimers();
}
});
test("clamps oversized stopAndWait timeouts before scheduling", async () => {
vi.useFakeTimers();
try {
const client = new GatewayClient({});
const ws = {
readyState: WebSocket.OPEN,
close: vi.fn(),
terminate: vi.fn(),
};
(client as unknown as { ws: unknown }).ws = ws;
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const stopPromise = client.stopAndWait({ timeoutMs: Number.MAX_SAFE_INTEGER });
await vi.advanceTimersByTimeAsync(1);
expect(ws.terminate).not.toHaveBeenCalled();
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
await vi.advanceTimersByTimeAsync(249);
await expect(stopPromise).resolves.toBeUndefined();
expect(ws.terminate).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
test("rejects mismatched tls fingerprint", async () => {
const key = [
"-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret
"MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrur5CWp4psMMb",
"DTPY1aN46HPDxRchGgh8XedNkrlc4z1KFiyLUsXpVIhuyoXq1fflpTDz7++pGEDJ",
"Q5pEdChn3fuWgi7gC+pvd5VQ1eAX/7qVE72fhx14NxhaiZU3hCzXjG2SflTEEExk",
"UkQTm0rdHSjgLVMhTM3Pqm6Kzfdgtm9ZyXwlAsorE/pvgbUxG3Q4xKNBGzbirZ+1",
"EzPDwsjf3fitNtakZJkymu6Kg5lsUihQVXOP0U7f989FmevoTMvJmkvJzsoTRd7s",
"XNSOjzOwJr8da8C4HkXi21md1yEccyW0iSh7tWvDrpWDAgW6RMuMHC0tW4bkpDGr",
"FpbQOgzVAgMBAAECggEAIMhwf8Ve9CDVTWyNXpU9fgnj2aDOCeg3MGaVzaO/XCPt",
"KOHDEaAyDnRXYgMP0zwtFNafo3klnSBWmDbq3CTEXseQHtsdfkKh+J0KmrqXxval",
"YeikKSyvBEIzRJoYMqeS3eo1bddcXgT/Pr9zIL/qzivpPJ4JDttBzyTeaTbiNaR9",
"KphGNueo+MTQMLreMqw5VAyJ44gy7Z/2TMiMEc/d95wfubcOSsrIfpOKnMvWd/rl",
"vxIS33s95L7CjREkixskj5Yo5Wpt3Yf5b0Zi70YiEsCfAZUDrPW7YzMlylzmhMzm",
"MARZKfN1Tmo74SGpxUrBury+iPwf1sYcRnsHR+zO8QKBgQD6ISQHRzPboZ3J/60+",
"fRLETtrBa9WkvaH9c+woF7l47D4DIlvlv9D3N1KGkUmhMnp2jNKLIlalBNDxBdB+",
"iwZP1kikGz4629Ch3/KF/VYscLTlAQNPE42jOo7Hj7VrdQx9zQrK9ZBLteXmSvOh",
"bB3aXwXPF3HoTMt9gQ9thhXZJQKBgQDxQxUnQSw43dRlqYOHzPUEwnJkGkuW/qxn",
"aRc8eopP5zUaebiDFmqhY36x2Wd+HnXrzufy2o4jkXkWTau8Ns+OLhnIG3PIU9L/",
"LYzJMckGb75QYiK1YKMUUSQzlNCS8+TFVCTAvG2u2zCCk7oTIe8aT516BQNjWDjK",
"gWo2f87N8QKBgHoVANO4kfwJxszXyMPuIeHEpwquyijNEap2EPaEldcKXz4CYB4j",
"4Cc5TkM12F0gGRuRohWcnfOPBTgOYXPSATOoX+4RCe+KaCsJ9gIl4xBvtirrsqS+",
"42ue4h9O6fpXt9AS6sii0FnTnzEmtgC8l1mE9X3dcJA0I0HPYytOvY0tAoGAAYJj",
"7Xzw4+IvY/ttgTn9BmyY/ptTgbxSI8t6g7xYhStzH5lHWDqZrCzNLBuqFBXosvL2",
"bISFgx9z3Hnb6y+EmOUc8C2LyeMMXOBSEygmk827KRGUGgJiwsvHKDN0Ipc4BSwD",
"ltkW7pMceJSoA1qg/k8lMxA49zQkFtA8c97U0mECgYEAk2DDN78sRQI8RpSECJWy",
"l1O1ikVUAYVeh5HdZkpt++ddfpo695Op9OeD2Eq27Y5EVj8Xl58GFxNk0egLUnYq",
"YzSbjcNkR2SbVvuLaV1zlQKm6M5rfvhj4//YrzrrPUQda7Q4eR0as/3q91uzAO2O",
"++pfnSCVCyp/TxSkhEDEawU=",
"-----END PRIVATE KEY-----",
].join("\n");
const cert = `-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUel0Lv05cjrViyI/H3tABBJxM7NgwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDEyMDEyMjEzMloXDTI2MDEy
MTEyMjEzMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEA67q+QlqeKbDDGw0z2NWjeOhzw8UXIRoIfF3nTZK5XOM9
ShYsi1LF6VSIbsqF6tX35aUw8+/vqRhAyUOaRHQoZ937loIu4Avqb3eVUNXgF/+6
lRO9n4cdeDcYWomVN4Qs14xtkn5UxBBMZFJEE5tK3R0o4C1TIUzNz6puis33YLZv
Wcl8JQLKKxP6b4G1MRt0OMSjQRs24q2ftRMzw8LI3934rTbWpGSZMpruioOZbFIo
UFVzj9FO3/fPRZnr6EzLyZpLyc7KE0Xe7FzUjo8zsCa/HWvAuB5F4ttZndchHHMl
tIkoe7Vrw66VgwIFukTLjBwtLVuG5KQxqxaW0DoM1QIDAQABo1MwUTAdBgNVHQ4E
FgQUwNdNkEQtd0n/aofzN7/EeYPPPbIwHwYDVR0jBBgwFoAUwNdNkEQtd0n/aofz
N7/EeYPPPbIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAnOnw
o8Az/bL0A6bGHTYra3L9ArIIljMajT6KDHxylR4LhliuVNAznnhP3UkcZbUdjqjp
MNOM0lej2pNioondtQdXUskZtqWy6+dLbTm1RYQh1lbCCZQ26o7o/oENzjPksLAb
jRM47DYxRweTyRWQ5t9wvg/xL0Yi1tWq4u4FCNZlBMgdwAEnXNwVWTzRR9RHwy20
lmUzM8uQ/p42bk4EvPEV4PI1h5G0khQ6x9CtkadCTDs/ZqoUaJMwZBIDSrdJJSLw
4Vh8Lqzia1CFB4um9J4S1Gm/VZMBjjeGGBJk7VSYn4ZmhPlbPM+6z39lpQGEG0x4
r1USnb+wUdA7Zoj/mQ==
-----END CERTIFICATE-----`;
httpsServer = createHttpsServer({ key, cert });
wss = new WebSocketServer({ server: httpsServer, maxPayload: 1024 * 1024 });
const port = await new Promise<number>((resolve, reject) => {
httpsServer?.once("error", reject);
httpsServer?.listen(0, "127.0.0.1", () => {
const address = httpsServer?.address();
if (!address || typeof address === "string") {
reject(new Error("https server address unavailable"));
return;
}
resolve(address.port);
});
});
let client: GatewayClient | null = null;
const error = await new Promise<Error>((resolve) => {
let settled = false;
const finish = (err: Error) => {
if (settled) {
return;
}
settled = true;
resolve(err);
};
const timeout = setTimeout(() => {
client?.stop();
finish(new Error("timeout waiting for tls error"));
}, 2000);
client = new GatewayClient({
url: `wss://127.0.0.1:${port}`,
connectChallengeTimeoutMs: 0,
tlsFingerprint: "deadbeef",
onConnectError: (err) => {
clearTimeout(timeout);
client?.stop();
finish(err);
},
onClose: () => {
clearTimeout(timeout);
client?.stop();
finish(new Error("closed without tls error"));
},
});
client.start();
});
expect(String(error)).toContain("tls fingerprint mismatch");
});
});

View File

@@ -0,0 +1,65 @@
// Gateway Client module implements device auth behavior.
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (!trimmed) {
return "";
}
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
}
type DeviceAuthPayloadParams = {
deviceId: string;
clientId: string;
clientMode: string;
role: string;
scopes: string[];
signedAtMs: number;
token?: string | null;
nonce: string;
};
type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & {
platform?: string | null;
deviceFamily?: string | null;
};
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";
return [
"v2",
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
params.nonce,
].join("|");
}
export function buildDeviceAuthPayloadV3(params: DeviceAuthPayloadV3Params): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";
// Device signatures are byte-for-byte compared by the gateway. Normalize
// optional metadata before joining so case differences do not break auth.
const platform = normalizeDeviceMetadataForAuth(params.platform);
const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily);
return [
"v3",
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
params.nonce,
platform,
deviceFamily,
].join("|");
}

View File

@@ -0,0 +1,114 @@
// Gateway Client tests cover event loop ready behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitForEventLoopReady } from "./event-loop-ready.js";
import { MAX_SAFE_TIMEOUT_DELAY_MS } from "./timeouts.js";
describe("waitForEventLoopReady", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("falls back when maxWaitMs is non-finite instead of arming a NaN timer", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const readiness = waitForEventLoopReady({
maxWaitMs: Number.NaN,
intervalMs: 25,
consecutiveReadyChecks: 2,
});
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 25);
await vi.advanceTimersByTimeAsync(50);
await expect(readiness).resolves.toMatchObject({
ready: true,
checks: 2,
});
});
it("clamps oversized readiness intervals before scheduling", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const readiness = waitForEventLoopReady({
maxWaitMs: Number.MAX_SAFE_INTEGER,
intervalMs: Number.MAX_SAFE_INTEGER,
consecutiveReadyChecks: 1,
});
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
await vi.advanceTimersByTimeAsync(1);
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(MAX_SAFE_TIMEOUT_DELAY_MS - 1);
await expect(readiness).resolves.toMatchObject({
ready: true,
checks: 1,
});
});
it("resolves ready after consecutive low-drift timer checks", async () => {
vi.useFakeTimers();
const readiness = waitForEventLoopReady({
maxWaitMs: 100,
intervalMs: 10,
consecutiveReadyChecks: 2,
});
await vi.advanceTimersByTimeAsync(20);
await expect(readiness).resolves.toEqual({
ready: true,
aborted: false,
elapsedMs: 20,
checks: 2,
maxDriftMs: 0,
});
});
it("resolves not-ready when the readiness deadline expires", async () => {
vi.useFakeTimers();
const readiness = waitForEventLoopReady({
maxWaitMs: 5,
intervalMs: 5,
consecutiveReadyChecks: 2,
});
await vi.advanceTimersByTimeAsync(5);
await expect(readiness).resolves.toEqual({
ready: false,
aborted: false,
elapsedMs: 5,
checks: 1,
maxDriftMs: 0,
});
});
it("clears pending readiness timers when aborted", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const readiness = waitForEventLoopReady({
maxWaitMs: 100,
intervalMs: 10,
signal: controller.signal,
});
controller.abort();
await expect(readiness).resolves.toEqual({
ready: false,
aborted: true,
elapsedMs: 0,
maxDriftMs: 0,
checks: 0,
});
expect(vi.getTimerCount()).toBe(0);
});
});

View File

@@ -0,0 +1,120 @@
// Gateway Client module implements event loop ready behavior.
import { resolveFiniteTimeoutDelayMs } from "./timeouts.js";
/** Readiness probe outcome with timing data for diagnosing event-loop stalls. */
export type EventLoopReadyResult = {
ready: boolean;
elapsedMs: number;
maxDriftMs: number;
checks: number;
aborted: boolean;
};
/** Controls how aggressively the client waits for low-drift timer checks before starting IO. */
export type EventLoopReadyOptions = {
maxWaitMs?: number;
intervalMs?: number;
driftThresholdMs?: number;
consecutiveReadyChecks?: number;
signal?: AbortSignal;
};
const DEFAULT_MAX_WAIT_MS = 10_000;
const DEFAULT_INTERVAL_MS = 1;
const DEFAULT_DRIFT_THRESHOLD_MS = 200;
const DEFAULT_CONSECUTIVE_READY_CHECKS = 2;
function resolvePositiveInteger(value: number | undefined, fallback: number): number {
return Number.isFinite(value) && value !== undefined ? Math.max(1, Math.floor(value)) : fallback;
}
/** Waits until timer drift stays low for consecutive checks, or aborts/times out. */
export async function waitForEventLoopReady(
options: EventLoopReadyOptions = {},
): Promise<EventLoopReadyResult> {
const maxWaitMs = resolveFiniteTimeoutDelayMs(options.maxWaitMs, DEFAULT_MAX_WAIT_MS, {
minMs: 0,
});
const intervalMs = resolveFiniteTimeoutDelayMs(options.intervalMs, DEFAULT_INTERVAL_MS);
const driftThresholdMs = resolvePositiveInteger(
options.driftThresholdMs,
DEFAULT_DRIFT_THRESHOLD_MS,
);
const consecutiveReadyChecks = resolvePositiveInteger(
options.consecutiveReadyChecks,
DEFAULT_CONSECUTIVE_READY_CHECKS,
);
const signal = options.signal;
const startedAt = Date.now();
let readyChecks = 0;
let checks = 0;
let maxDriftMs = 0;
return await new Promise<EventLoopReadyResult>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const clearTimer = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
const finish = (ready: boolean, aborted = false) => {
if (settled) {
return;
}
settled = true;
clearTimer();
signal?.removeEventListener("abort", onAbort);
resolve({
ready,
elapsedMs: Math.max(0, Date.now() - startedAt),
maxDriftMs,
checks,
aborted,
});
};
const onAbort = () => {
finish(false, true);
};
if (signal?.aborted) {
finish(false, true);
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
const scheduleNext = () => {
if (signal?.aborted) {
finish(false, true);
return;
}
const elapsedMs = Math.max(0, Date.now() - startedAt);
const remainingMs = maxWaitMs - elapsedMs;
if (remainingMs <= 0) {
finish(false);
return;
}
const delayMs = Math.min(intervalMs, remainingMs);
const scheduledAt = Date.now();
timer = setTimeout(() => {
timer = null;
checks += 1;
const driftMs = Math.max(0, Date.now() - scheduledAt - delayMs);
maxDriftMs = Math.max(maxDriftMs, driftMs);
if (driftMs > driftThresholdMs) {
readyChecks = 0;
} else {
readyChecks += 1;
}
if (readyChecks >= consecutiveReadyChecks) {
finish(true);
return;
}
scheduleNext();
}, delayMs);
};
scheduleNext();
});
}

View File

@@ -0,0 +1,7 @@
// Public gateway-client package surface: connection client, device auth,
// readiness helpers, event-loop readiness, and timeout utilities.
export * from "./client.js";
export * from "./device-auth.js";
export * from "./event-loop-ready.js";
export * from "./readiness.js";
export * from "./timeouts.js";

View File

@@ -0,0 +1,28 @@
// Gateway Client tests cover readiness behavior.
import { describe, expect, it, vi } from "vitest";
import { startGatewayClientWithReadinessWait } from "./readiness.js";
describe("startGatewayClientWithReadinessWait", () => {
it("uses the injected client env when resolving the readiness timeout", async () => {
const waitForReady = vi.fn(async () => ({
ready: true,
aborted: false,
elapsedMs: 0,
checks: 1,
maxDriftMs: 0,
}));
const client = { start: vi.fn() };
await startGatewayClientWithReadinessWait(waitForReady, client, {
clientOptions: {
env: { OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "6000" },
},
});
expect(waitForReady).toHaveBeenCalledWith({
maxWaitMs: 6_000,
signal: undefined,
});
expect(client.start).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,72 @@
// Gateway Client module implements readiness behavior.
import type { GatewayClientOptions } from "./client.js";
import {
waitForEventLoopReady,
type EventLoopReadyOptions,
type EventLoopReadyResult,
} from "./event-loop-ready.js";
import { resolveConnectChallengeTimeoutMs } from "./timeouts.js";
export type GatewayClientStartable = {
start(): void;
};
/** Injectable readiness waiter used by tests and alternate event-loop probes. */
export type EventLoopReadyWaiter = (
options?: EventLoopReadyOptions,
) => Promise<EventLoopReadyResult>;
/** Timeout and abort controls for delaying client start until the loop can process IO. */
export type GatewayClientStartReadinessOptions = {
timeoutMs?: number;
clientOptions?: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "connectDelayMs" | "env" | "preauthHandshakeTimeoutMs"
>;
signal?: AbortSignal;
};
function resolveGatewayClientStartReadinessTimeoutMs(
options: GatewayClientStartReadinessOptions = {},
): number {
if (typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)) {
return options.timeoutMs;
}
const clientOptions = options.clientOptions ?? {};
const timeoutOverride =
typeof clientOptions.connectChallengeTimeoutMs === "number" &&
Number.isFinite(clientOptions.connectChallengeTimeoutMs)
? clientOptions.connectChallengeTimeoutMs
: typeof clientOptions.connectDelayMs === "number" &&
Number.isFinite(clientOptions.connectDelayMs)
? clientOptions.connectDelayMs
: undefined;
return resolveConnectChallengeTimeoutMs(timeoutOverride, {
env: clientOptions.env,
configuredTimeoutMs: clientOptions.preauthHandshakeTimeoutMs,
});
}
/** Starts a gateway client only after the supplied readiness probe succeeds. */
export async function startGatewayClientWithReadinessWait(
waitForReady: EventLoopReadyWaiter,
client: GatewayClientStartable,
options: GatewayClientStartReadinessOptions = {},
): Promise<EventLoopReadyResult> {
const readiness = await waitForReady({
maxWaitMs: resolveGatewayClientStartReadinessTimeoutMs(options),
signal: options.signal,
});
if (readiness.ready && !readiness.aborted && options.signal?.aborted !== true) {
client.start();
}
return readiness;
}
/** Starts a gateway client after the default event-loop readiness probe succeeds. */
export async function startGatewayClientWhenEventLoopReady(
client: GatewayClientStartable,
options: GatewayClientStartReadinessOptions = {},
): Promise<EventLoopReadyResult> {
return startGatewayClientWithReadinessWait(waitForEventLoopReady, client, options);
}

View File

@@ -0,0 +1,104 @@
// Gateway Client tests cover timeouts behavior.
import { describe, expect, it } from "vitest";
import {
addSafeTimeoutDelayGraceMs,
getConnectChallengeTimeoutMsFromEnv,
getPreauthHandshakeTimeoutMsFromEnv,
MAX_SAFE_TIMEOUT_DELAY_MS,
resolveFiniteTimeoutDelayMs,
resolveConnectChallengeTimeoutMs,
resolvePreauthHandshakeTimeoutMs,
resolveSafeTimeoutDelayMs,
} from "./timeouts.js";
describe("resolveSafeTimeoutDelayMs", () => {
it("clamps to Node's signed-32-bit timer ceiling", () => {
expect(resolveSafeTimeoutDelayMs(3_000_000_000)).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
});
it("falls back to the minimum for non-finite delays", () => {
expect(resolveSafeTimeoutDelayMs(Number.NaN)).toBe(1);
expect(resolveSafeTimeoutDelayMs(Number.POSITIVE_INFINITY, { minMs: 250 })).toBe(250);
});
it("preserves callers that intentionally allow zero-delay timers", () => {
expect(resolveSafeTimeoutDelayMs(Number.NaN, { minMs: 0 })).toBe(0);
expect(resolveSafeTimeoutDelayMs(-5, { minMs: 0 })).toBe(0);
});
});
describe("addSafeTimeoutDelayGraceMs", () => {
it("adds grace before applying Node timer bounds", () => {
expect(addSafeTimeoutDelayGraceMs(10_000, 5_000)).toBe(15_000);
expect(addSafeTimeoutDelayGraceMs(MAX_SAFE_TIMEOUT_DELAY_MS - 100, 500)).toBe(
MAX_SAFE_TIMEOUT_DELAY_MS,
);
});
it("caps overflowed finite sums instead of falling back to the minimum", () => {
expect(addSafeTimeoutDelayGraceMs(Number.MAX_VALUE, 5_000)).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
});
});
describe("resolveFiniteTimeoutDelayMs", () => {
it("uses the fallback for missing or non-finite overrides", () => {
expect(resolveFiniteTimeoutDelayMs(undefined, 10_000, { minMs: 0 })).toBe(10_000);
expect(resolveFiniteTimeoutDelayMs(Number.NaN, 10_000, { minMs: 0 })).toBe(10_000);
expect(resolveFiniteTimeoutDelayMs(Number.POSITIVE_INFINITY, 10_000, { minMs: 0 })).toBe(
10_000,
);
});
it("still clamps finite overrides through safe timer bounds", () => {
expect(resolveFiniteTimeoutDelayMs(3_000_000_000, 10_000)).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
expect(resolveFiniteTimeoutDelayMs(-5, 10_000, { minMs: 0 })).toBe(0);
});
});
describe("gateway client handshake timeouts", () => {
it("caps preauth handshake timeout env and config values to the safe timer range", () => {
expect(
getPreauthHandshakeTimeoutMsFromEnv({
OPENCLAW_HANDSHAKE_TIMEOUT_MS: "3000000000",
}),
).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
expect(
resolvePreauthHandshakeTimeoutMs({
env: {},
configuredTimeoutMs: 3_000_000_000,
}),
).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
});
it("accepts existing strict timeout env integer forms", () => {
expect(
getPreauthHandshakeTimeoutMsFromEnv({
OPENCLAW_HANDSHAKE_TIMEOUT_MS: " +75000 ",
}),
).toBe(75_000);
expect(
getConnectChallengeTimeoutMsFromEnv({
OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: " 015000 ",
}),
).toBe(15_000);
});
it("caps connect challenge timeout env and explicit values to the safe timer range", () => {
expect(
getConnectChallengeTimeoutMsFromEnv({
OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "3000000000",
}),
).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
expect(
resolveConnectChallengeTimeoutMs(3_000_000_000, {
env: {},
configuredTimeoutMs: 3_000_000_000,
}),
).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
expect(
resolveConnectChallengeTimeoutMs(undefined, {
env: { OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS: "3000000000" },
}),
).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
});
});

View File

@@ -0,0 +1,146 @@
// Gateway Client module implements timeouts behavior.
function parseStrictPositiveInteger(value: string): number | undefined {
const trimmed = value.trim();
if (!/^\+?\d+$/u.test(trimmed)) {
return undefined;
}
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
/** Maximum delay Node timers can represent without overflow warnings. */
export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647;
/** Default server-side window for gateway preauth handshakes. */
export const DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 15_000;
/** Minimum client watchdog delay for connect challenge setup. */
export const MIN_CONNECT_CHALLENGE_TIMEOUT_MS = 250;
/** Default maximum client watchdog delay, aligned with the preauth server timeout. */
export const MAX_CONNECT_CHALLENGE_TIMEOUT_MS = DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS;
/** Clamps arbitrary timer delays to Node's safe range and an optional floor. */
export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: number }): number {
const rawMinMs = opts?.minMs ?? 1;
const minMs = Math.min(
MAX_SAFE_TIMEOUT_DELAY_MS,
Math.max(0, Number.isFinite(rawMinMs) ? Math.floor(rawMinMs) : 1),
);
const candidateMs = Number.isFinite(delayMs) ? Math.floor(delayMs) : minMs;
return Math.min(MAX_SAFE_TIMEOUT_DELAY_MS, Math.max(minMs, candidateMs));
}
/** Adds grace time while preserving safe timer bounds if inputs overflow or are invalid. */
export function addSafeTimeoutDelayGraceMs(
delayMs: number,
graceMs: number,
opts?: { minMs?: number },
): number {
if (!Number.isFinite(delayMs) || !Number.isFinite(graceMs)) {
return resolveSafeTimeoutDelayMs(MAX_SAFE_TIMEOUT_DELAY_MS, opts);
}
const withGrace = delayMs + graceMs;
return resolveSafeTimeoutDelayMs(
Number.isFinite(withGrace) ? withGrace : MAX_SAFE_TIMEOUT_DELAY_MS,
opts,
);
}
/** Resolves optional timeout values through a fallback and safe timer clamp. */
export function resolveFiniteTimeoutDelayMs(
delayMs: number | null | undefined,
fallbackMs: number,
opts?: { minMs?: number },
): number {
const candidateMs =
typeof delayMs === "number" && Number.isFinite(delayMs) ? delayMs : fallbackMs;
return resolveSafeTimeoutDelayMs(candidateMs, opts);
}
/** Clamps connect challenge watchdog timeouts to the gateway-supported range. */
export function clampConnectChallengeTimeoutMs(
timeoutMs: number,
maxTimeoutMs = MAX_CONNECT_CHALLENGE_TIMEOUT_MS,
): number {
return Math.max(
MIN_CONNECT_CHALLENGE_TIMEOUT_MS,
Math.min(Math.max(MIN_CONNECT_CHALLENGE_TIMEOUT_MS, maxTimeoutMs), timeoutMs),
);
}
/** Reads the connect challenge watchdog override from the process environment. */
export function getConnectChallengeTimeoutMsFromEnv(
env: NodeJS.ProcessEnv = process.env,
): number | undefined {
const raw = env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS;
if (raw) {
const parsed = parseStrictPositiveInteger(raw);
if (parsed !== undefined) {
return resolveSafeTimeoutDelayMs(parsed);
}
}
return undefined;
}
function normalizePositiveTimeoutMs(timeoutMs: unknown): number | undefined {
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
? resolveSafeTimeoutDelayMs(timeoutMs)
: undefined;
}
/** Resolves the client watchdog timeout using explicit, env, then preauth defaults. */
export function resolveConnectChallengeTimeoutMs(
timeoutMs?: number | null,
params?: {
env?: NodeJS.ProcessEnv;
configuredTimeoutMs?: number | null;
},
): number {
const configuredPreauthTimeoutMs = resolvePreauthHandshakeTimeoutMs({
env: params?.env,
configuredTimeoutMs: params?.configuredTimeoutMs,
});
// The client watchdog must never fire before the server-side preauth timeout.
// Tests may raise the env override above that server default, so widen the cap.
const maxTimeoutMs = Math.max(DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS, configuredPreauthTimeoutMs);
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
return clampConnectChallengeTimeoutMs(timeoutMs, maxTimeoutMs);
}
const envOverride = getConnectChallengeTimeoutMsFromEnv(params?.env);
if (envOverride !== undefined) {
return clampConnectChallengeTimeoutMs(envOverride, Math.max(maxTimeoutMs, envOverride));
}
return clampConnectChallengeTimeoutMs(configuredPreauthTimeoutMs, maxTimeoutMs);
}
/** Reads the preauth handshake timeout override from environment variables. */
export function getPreauthHandshakeTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
const configuredTimeout =
env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || (env.VITEST && env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS);
if (configuredTimeout) {
const parsed = parseStrictPositiveInteger(configuredTimeout);
if (parsed !== undefined) {
return resolveSafeTimeoutDelayMs(parsed);
}
}
return DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS;
}
/** Resolves the server preauth timeout from env, explicit config, or default. */
export function resolvePreauthHandshakeTimeoutMs(params?: {
env?: NodeJS.ProcessEnv;
configuredTimeoutMs?: number | null;
}): number {
const env = params?.env ?? process.env;
const configuredTimeout =
env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || (env.VITEST && env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS);
if (configuredTimeout) {
const parsed = parseStrictPositiveInteger(configuredTimeout);
if (parsed !== undefined) {
return resolveSafeTimeoutDelayMs(parsed);
}
}
const configured = normalizePositiveTimeoutMs(params?.configuredTimeoutMs);
if (configured !== undefined) {
return configured;
}
return DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS;
}