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
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:
36
packages/gateway-client/package.json
Normal file
36
packages/gateway-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1706
packages/gateway-client/src/client.ts
Normal file
1706
packages/gateway-client/src/client.ts
Normal file
File diff suppressed because it is too large
Load Diff
612
packages/gateway-client/src/client.watchdog.test.ts
Normal file
612
packages/gateway-client/src/client.watchdog.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
65
packages/gateway-client/src/device-auth.ts
Normal file
65
packages/gateway-client/src/device-auth.ts
Normal 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("|");
|
||||
}
|
||||
114
packages/gateway-client/src/event-loop-ready.test.ts
Normal file
114
packages/gateway-client/src/event-loop-ready.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
120
packages/gateway-client/src/event-loop-ready.ts
Normal file
120
packages/gateway-client/src/event-loop-ready.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
7
packages/gateway-client/src/index.ts
Normal file
7
packages/gateway-client/src/index.ts
Normal 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";
|
||||
28
packages/gateway-client/src/readiness.test.ts
Normal file
28
packages/gateway-client/src/readiness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
72
packages/gateway-client/src/readiness.ts
Normal file
72
packages/gateway-client/src/readiness.ts
Normal 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);
|
||||
}
|
||||
104
packages/gateway-client/src/timeouts.test.ts
Normal file
104
packages/gateway-client/src/timeouts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
146
packages/gateway-client/src/timeouts.ts
Normal file
146
packages/gateway-client/src/timeouts.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user