Files
openclaw/packages/gateway-client/src/client.ts
2026-07-14 05:38:38 -07:00

1231 lines
41 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
type GatewayClientMode,
type GatewayClientName,
} from "@openclaw/gateway-protocol/client-info";
import {
ConnectErrorDetailCodes,
formatConnectErrorMessage,
readConnectErrorDetailCode,
} from "@openclaw/gateway-protocol/connect-error-details";
import type {
ConnectParams,
ErrorShape,
EventFrame,
HelloOk,
} from "@openclaw/gateway-protocol/frame-guards";
import { resolveGatewayStartupRetryAfterMs } from "@openclaw/gateway-protocol/startup-unavailable";
import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version";
import {
isLoopbackIpAddress,
normalizeIpAddress,
parseCanonicalIpAddress,
type ParsedIpAddress,
} from "@openclaw/net-policy/ip";
import { WebSocket, type ClientOptions, type CertMeta } from "ws";
import {
buildGatewayConnectAuth,
type GatewayConnectAuthSelection,
resolveGatewayConnectScopes,
selectGatewayConnectAuth,
shouldRetryGatewayWithDeviceToken,
} from "./connect-auth.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import {
GatewayProtocolClient,
GatewayProtocolRequestError,
type GatewayProtocolCloseContext,
type GatewayProtocolRequestOptions,
type GatewayProtocolSocket,
type GatewayProtocolSocketHandlers,
} from "./protocol-client.js";
import { shouldPauseGatewayReconnect } from "./reconnect-policy.js";
import { resolveConnectChallengeTimeoutMs, resolveSafeTimeoutDelayMs } from "./timeouts.js";
import { rawDataToString } from "./websocket-data.js";
export type DeviceIdentity = {
deviceId: string;
privateKeyPem: string;
publicKeyPem: string;
};
export type DeviceAuthTokenRecord = {
token?: string;
scopes?: string[];
};
// The package stays reusable by depending on host callbacks for OpenClaw-owned
// state: device keys, token storage, proxy routing, logging, and TLS formatting.
export type GatewayClientHostDeps = {
loadOrCreateDeviceIdentity?: () => DeviceIdentity | undefined;
signDevicePayload?: (privateKeyPem: string, payload: string) => string;
publicKeyRawBase64UrlFromPem?: (publicKeyPem: string) => string;
loadDeviceAuthToken?: (params: {
deviceId: string;
role: string;
env?: NodeJS.ProcessEnv;
}) => DeviceAuthTokenRecord | null;
storeDeviceAuthToken?: (params: {
deviceId: string;
role: string;
token: string;
scopes: string[];
env?: NodeJS.ProcessEnv;
}) => void;
clearDeviceAuthToken?: (params: {
deviceId: string;
role: string;
env?: NodeJS.ProcessEnv;
}) => void;
beforeConnect?: () => void;
registerGatewayLoopbackBypass?: (url: string) => (() => void) | undefined;
logDebug?: (message: string) => void;
logError?: (message: string) => void;
redactForLog?: (message: string) => string;
normalizeTlsFingerprint?: (fingerprint: string | undefined) => string;
};
const DEFAULT_HOST_DEPS: Required<GatewayClientHostDeps> = {
loadOrCreateDeviceIdentity: () => undefined,
signDevicePayload: () => {
throw new Error("GatewayClient device signature dependency is not configured");
},
publicKeyRawBase64UrlFromPem: () => {
throw new Error("GatewayClient public key dependency is not configured");
},
loadDeviceAuthToken: () => null,
storeDeviceAuthToken: () => {},
clearDeviceAuthToken: () => {},
beforeConnect: () => {},
registerGatewayLoopbackBypass: () => undefined,
logDebug: () => {},
logError: () => {},
redactForLog: (message) => message,
normalizeTlsFingerprint: normalizeFingerprint,
};
function resolveHostDeps(overrides?: GatewayClientHostDeps): Required<GatewayClientHostDeps> {
return Object.fromEntries(
Object.entries(DEFAULT_HOST_DEPS).map(([key, fallback]) => [
key,
overrides?.[key as keyof GatewayClientHostDeps] ?? fallback,
]),
) as Required<GatewayClientHostDeps>;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function isSensitiveUrlQueryParamName(key: string): boolean {
return /(?:token|password|secret|key|auth|credential)/iu.test(key);
}
function normalizeFingerprint(fingerprint: string | undefined): string {
return (fingerprint ?? "").replaceAll(":", "").trim().toLowerCase();
}
function parseHostForAddressChecks(
host: string,
): { isLocalhost: boolean; unbracketedHost: string } | null {
if (!host) {
return null;
}
const normalizedHost = host.toLowerCase().trim();
const canonicalHost = normalizedHost.replace(/\.+$/, "");
if (canonicalHost === "localhost") {
return { isLocalhost: true, unbracketedHost: canonicalHost };
}
return {
isLocalhost: false,
// URL.hostname canonicalizes IPv6 with brackets in some call sites. Strip
// them before net.isIP so address checks do not fall back to hostname rules.
unbracketedHost:
normalizedHost.startsWith("[") && normalizedHost.endsWith("]")
? normalizedHost.slice(1, -1)
: normalizedHost,
};
}
const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set<string>([
"loopback",
"private",
"linkLocal",
"carrierGradeNat",
]);
const PRIVATE_OR_LOOPBACK_IPV6_RANGES = new Set<string>([
"loopback",
"linkLocal",
"uniqueLocal",
"deprecatedSiteLocal",
]);
function parseGatewayIpAddress(host: string): ParsedIpAddress | undefined {
const normalized = normalizeIpAddress(host);
return normalized ? parseCanonicalIpAddress(normalized) : undefined;
}
function isPrivateOrLoopbackIpAddress(address: ParsedIpAddress): boolean {
const ranges =
address.kind() === "ipv4" ? PRIVATE_OR_LOOPBACK_IPV4_RANGES : PRIVATE_OR_LOOPBACK_IPV6_RANGES;
return ranges.has(address.range());
}
function isLoopbackHost(host: string): boolean {
const parsed = parseHostForAddressChecks(host);
if (!parsed) {
return false;
}
if (parsed.isLocalhost) {
return true;
}
return isLoopbackIpAddress(parsed.unbracketedHost);
}
function isPrivateOrLoopbackHost(host: string): boolean {
const parsed = parseHostForAddressChecks(host);
if (!parsed) {
return false;
}
if (parsed.isLocalhost) {
return true;
}
const address = parseGatewayIpAddress(parsed.unbracketedHost);
if (!address) {
return false;
}
return isPrivateOrLoopbackIpAddress(address);
}
function isTrustedPlaintextWebSocketHost(hostname: string): boolean {
if (isPrivateOrLoopbackHost(hostname)) {
return true;
}
const normalized = hostname.toLowerCase().trim().replace(/\.+$/, "");
// Plain ws:// is still useful for local discovery and Tailnet names. Public
// hostnames must use wss:// unless the caller opts into the private break-glass.
return normalized.endsWith(".local") || normalized.endsWith(".ts.net");
}
function isSecureWebSocketUrl(rawUrl: string, options?: { allowPrivateWs?: boolean }): boolean {
try {
const url = new URL(rawUrl);
const protocol =
url.protocol === "https:" ? "wss:" : url.protocol === "http:" ? "ws:" : url.protocol;
if (protocol === "wss:") {
return true;
}
if (protocol !== "ws:") {
return false;
}
if (isLoopbackHost(url.hostname) || isTrustedPlaintextWebSocketHost(url.hostname)) {
return true;
}
if (options?.allowPrivateWs === true) {
const hostForIpCheck =
url.hostname.startsWith("[") && url.hostname.endsWith("]")
? url.hostname.slice(1, -1)
: url.hostname;
return (
isPrivateOrLoopbackHost(url.hostname) || parseGatewayIpAddress(hostForIpCheck) === undefined
);
}
return false;
} catch {
return false;
}
}
export type GatewayClientRequestOptions = GatewayProtocolRequestOptions;
type AssembledConnect = {
params: ConnectParams;
authApprovalRuntimeToken: string | undefined;
authAgentRuntimeIdentityToken: string | undefined;
resolvedDeviceToken: string | undefined;
storedToken: string | undefined;
usingStoredDeviceToken: boolean | undefined;
};
type FingerprintCheckingClientOptions = Omit<ClientOptions, "checkServerIdentity"> & {
checkServerIdentity?: (servername: string, cert: CertMeta) => Error | undefined;
};
const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789";
const DEFAULT_CLIENT_VERSION = "0.0.0";
export type GatewayReconnectPausedInfo = {
code: number;
reason: string;
detailCode: string | null;
};
export type GatewayClientCloseInfo = {
phase: "pre-hello" | "post-hello";
socketOpened: boolean;
transportValidated: boolean;
transientPreHelloCleanClose: boolean;
};
export class GatewayClientRequestError extends GatewayProtocolRequestError {
constructor(error: Partial<ErrorShape>) {
super({
...error,
message: formatConnectErrorMessage({ message: error.message, details: error.details }),
});
this.name = "GatewayClientRequestError";
}
}
class GatewayClientTransientPreHelloCloseError extends Error {
constructor() {
super("gateway transient pre-hello clean close");
this.name = "GatewayClientTransientPreHelloCloseError";
}
}
class GatewayClientTransportPolicyError extends Error {}
const GATEWAY_CONNECT_ASSEMBLY_ERROR = Symbol("gateway.connectAssemblyError");
type GatewayConnectAssemblyError = Error & {
[GATEWAY_CONNECT_ASSEMBLY_ERROR]?: true;
};
function markGatewayConnectAssemblyError(error: Error): Error {
Object.defineProperty(error, GATEWAY_CONNECT_ASSEMBLY_ERROR, {
configurable: true,
value: true,
});
return error;
}
export function isGatewayConnectAssemblyError(value: unknown): value is Error {
return (
value instanceof Error &&
(value as GatewayConnectAssemblyError)[GATEWAY_CONNECT_ASSEMBLY_ERROR] === true
);
}
export type GatewayClientOptions = {
url?: string; // ws://127.0.0.1:18789
origin?: string;
connectChallengeTimeoutMs?: number;
/**
* Server-side pre-auth handshake budget. Config-derived local clients use
* this to keep the connect-challenge watchdog aligned with the gateway.
*/
preauthHandshakeTimeoutMs?: number;
tickWatchMinIntervalMs?: number;
tickWatchTimeoutMs?: number;
requestTimeoutMs?: number;
token?: string;
bootstrapToken?: string;
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
agentRuntimeIdentityToken?: string;
instanceId?: string;
clientName?: GatewayClientName;
clientDisplayName?: string;
clientVersion?: string;
platform?: string;
deviceFamily?: string;
mode?: GatewayClientMode;
role?: string;
scopes?: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
pathEnv?: string;
env?: NodeJS.ProcessEnv;
deviceIdentity?: DeviceIdentity | null;
hostDeps?: GatewayClientHostDeps;
minProtocol?: number;
maxProtocol?: number;
tlsFingerprint?: string;
onEvent?: (evt: EventFrame) => void;
onHelloOk?: (hello: HelloOk) => void;
onConnectError?: (err: Error) => void;
onReconnectPaused?: (info: GatewayReconnectPausedInfo) => void;
onClose?: (code: number, reason: string, info?: GatewayClientCloseInfo) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
export type GatewayClientConnectionMetadata = {
clientName?: GatewayClientName;
hasDeviceIdentity: boolean;
mode?: GatewayClientMode;
preauthHandshakeTimeoutMs?: number;
};
function readConnectChallengeTimeoutOverride(
opts: Pick<GatewayClientOptions, "connectChallengeTimeoutMs">,
): number | undefined {
if (
typeof opts.connectChallengeTimeoutMs === "number" &&
Number.isFinite(opts.connectChallengeTimeoutMs)
) {
return opts.connectChallengeTimeoutMs;
}
return undefined;
}
function isGatewayClientStoppedError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return message === "gateway client stopped" || message === "Error: gateway client stopped";
}
function formatGatewayClientErrorForLog(err: unknown): string {
const redactedUrlLikeString = String(err)
.replace(/\/\/([^@/?#\s]+)@/g, "//***:***@")
.replace(/(Authorization:\s*Bearer\s+)[^\s]+/giu, "$1***")
.replace(/([?&])([^=&\s]+)=([^&#\s"'<>)]*)/g, (match, prefix: string, key: string) =>
isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match,
);
return redactedUrlLikeString;
}
function resolveGatewayClientConnectChallengeTimeoutMs(
opts: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "env" | "preauthHandshakeTimeoutMs"
>,
): number {
return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), {
env: opts.env,
configuredTimeoutMs: opts.preauthHandshakeTimeoutMs,
});
}
const FORCE_STOP_TERMINATE_GRACE_MS = 250;
const STOP_AND_WAIT_TIMEOUT_MS = 1_000;
const MAX_SUPPRESSED_TRANSIENT_PRE_HELLO_CLEAN_CLOSES = 1;
type PendingStop = {
ws: WebSocket;
promise: Promise<void>;
resolve: () => void;
terminateTimer?: NodeJS.Timeout;
};
export class GatewayClient {
private readonly protocol: GatewayProtocolClient<AssembledConnect>;
private ws: WebSocket | null = null;
private opts: GatewayClientOptions;
private deps: Required<GatewayClientHostDeps>;
private stopped = false;
private pendingDeviceTokenRetry = false;
private deviceTokenRetryBudgetUsed = false;
private approvalRuntimeTokenCompatibilityDisabled = false;
private approvalRuntimeTokenRetryBudgetUsed = false;
// Track last tick to detect silent stalls.
private lastTick: number | null = null;
private tickIntervalMs = 30_000;
private tickTimer: NodeJS.Timeout | null = null;
private readonly requestTimeoutMs: number;
private pendingStop: PendingStop | null = null;
private transportValidated = false;
private suppressedTransientPreHelloCleanCloses = 0;
constructor(opts: GatewayClientOptions) {
// Defaults keep the package inert until device identity support is used.
this.deps = resolveHostDeps(opts.hostDeps);
this.opts = {
...opts,
deviceIdentity:
opts.deviceIdentity === null
? undefined
: (opts.deviceIdentity ?? this.deps.loadOrCreateDeviceIdentity()),
};
this.requestTimeoutMs =
typeof opts.requestTimeoutMs === "number" && Number.isFinite(opts.requestTimeoutMs)
? resolveSafeTimeoutDelayMs(opts.requestTimeoutMs, { minMs: 0 })
: 30_000;
this.protocol = new GatewayProtocolClient<AssembledConnect>({
createSocket: (handlers) => this.createSocket(handlers),
createRequestId: randomUUID,
createRequestError: (error) => new GatewayClientRequestError(error),
createRequestTimeoutError: (method) => new Error(`gateway request timeout for ${method}`),
createRequestAbortError: createGatewayRequestAbortError,
buildConnectPlan: ({ nonce }) => {
if (!nonce) {
throw new Error("gateway connect challenge missing nonce");
}
return this.assembleConnectParams({ role: this.opts.role ?? "operator", nonce });
},
buildConnectParams: (assembled) => assembled.params,
onConnectPlanError: (error) => {
this.stopped = true;
const marked = markGatewayConnectAssemblyError(error);
const msg = `gateway connect failed: ${formatGatewayClientErrorForLog(error)}`;
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE || isGatewayClientStoppedError(error)) {
this.logDebug(msg);
} else {
this.logError(msg);
}
return { closeCode: 1008, closeReason: "connect failed", stop: true, error: marked };
},
onConnectHello: (hello, context) => this.handleConnectHello(hello, context.plan),
onHello: (hello) => this.opts.onHelloOk?.(hello),
onConnectFailure: (error, context) => this.handleConnectRequestFailure(error, context.plan),
resolveClose: (context) => this.resolveClose(context),
onClose: (context, decision) => {
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
if (decision.notify) {
this.opts.onClose?.(context.code, context.reason, this.closeInfo(context));
}
},
notifyStoppedClose: true,
onConnectError: (error) => this.notifyConnectError(error),
onParseError: (error) =>
this.logDebug(`gateway client parse error: ${formatGatewayClientErrorForLog(error)}`),
onEvent: (event) => this.opts.onEvent?.(event),
onGap: (info) => this.opts.onGap?.(info),
onActivity: () => {
this.lastTick = Date.now();
},
onCallbackError: (label, error) =>
this.logDebug(
`gateway client ${label === "hello" ? "hello-ok" : label === "gap" ? "event" : label} handler error: ${formatGatewayClientErrorForLog(error)}`,
),
handshake: {
mode: "require-challenge",
timeoutMs: resolveGatewayClientConnectChallengeTimeoutMs(this.opts),
timeoutMessage: (elapsedMs) =>
`gateway connect challenge timeout (waited ${elapsedMs}ms, limit ${resolveGatewayClientConnectChallengeTimeoutMs(this.opts)}ms)`,
},
reconnect: { initialMs: 1_000, multiplier: 2, maxMs: 30_000 },
requestTimeoutMs: this.requestTimeoutMs,
rethrowSocketFactoryError: (error) => error instanceof GatewayClientTransportPolicyError,
});
}
getConnectionMetadata(): GatewayClientConnectionMetadata {
return {
clientName: this.opts.clientName,
hasDeviceIdentity: Boolean(this.opts.deviceIdentity),
mode: this.opts.mode,
preauthHandshakeTimeoutMs: this.opts.preauthHandshakeTimeoutMs,
};
}
start() {
if (this.stopped) {
return;
}
this.protocol.start();
}
private createSocket(handlers: GatewayProtocolSocketHandlers): GatewayProtocolSocket {
const url = this.opts.url ?? DEFAULT_GATEWAY_CLIENT_URL;
if (this.opts.tlsFingerprint && !url.startsWith("wss://")) {
throw new Error("gateway tls fingerprint requires wss:// gateway url");
}
const allowPrivateWs =
(this.opts.env ?? process.env).OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1";
// Block plaintext before device-token lookup. Credentials may be loaded from
// host storage later in sendConnect(), and chat payloads are sensitive too.
if (!isSecureWebSocketUrl(url, { allowPrivateWs })) {
// Safe hostname extraction - avoid throwing on malformed URLs in error path
let displayHost = url;
try {
displayHost = new URL(url).hostname || url;
} catch {
// Use raw URL if parsing fails
}
throw new Error(
`SECURITY ERROR: Cannot connect to "${displayHost}" over plaintext ws://. ` +
"Both credentials and chat data would be exposed to network interception. " +
"Use wss:// for remote URLs. Safe defaults: keep gateway.bind=loopback and connect via SSH tunnel " +
"(ssh -N -L 18789:127.0.0.1:18789 user@gateway-host), or use Tailscale Serve/Funnel. " +
(allowPrivateWs
? ""
: "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1. ") +
"Run `openclaw doctor --fix` for guidance.",
);
}
// Allow node screen snapshots and other large responses.
this.deps.beforeConnect();
const wsOptions: FingerprintCheckingClientOptions = {
maxPayload: 25 * 1024 * 1024,
...(this.opts.origin ? { origin: this.opts.origin } : {}),
};
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
wsOptions.rejectUnauthorized = false;
wsOptions.checkServerIdentity = (_hostValue: string, cert: CertMeta) => {
const fingerprintValue =
typeof cert === "object" && cert && "fingerprint256" in cert
? ((cert as { fingerprint256?: string }).fingerprint256 ?? "")
: "";
const fingerprint = this.deps.normalizeTlsFingerprint(
typeof fingerprintValue === "string" ? fingerprintValue : "",
);
const expected = this.deps.normalizeTlsFingerprint(this.opts.tlsFingerprint ?? "");
if (!expected) {
return undefined;
}
if (!fingerprint) {
return new Error("Missing server TLS fingerprint");
}
if (fingerprint !== expected) {
return new Error("Server TLS fingerprint mismatch");
}
return undefined;
};
}
let ws: WebSocket;
// Managed proxies can intercept local traffic; the host owns the bypass
// lifecycle and must remove it immediately after the socket is created.
let unregisterGatewayLoopbackBypass: (() => void) | undefined;
try {
unregisterGatewayLoopbackBypass = this.deps.registerGatewayLoopbackBypass(url);
} catch (error) {
throw new GatewayClientTransportPolicyError(
error instanceof Error ? error.message : String(error),
);
}
try {
ws = new WebSocket(url, wsOptions as ClientOptions);
ws.binaryType = "nodebuffer";
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
} finally {
unregisterGatewayLoopbackBypass?.();
}
this.ws = ws;
this.transportValidated = false;
ws.on("open", () => {
handlers.open();
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
const tlsError = this.validateTlsFingerprint();
if (tlsError) {
handlers.error(tlsError);
ws.close(1008, tlsError.message);
return;
}
}
this.transportValidated = true;
});
ws.on("message", (data) => handlers.message(rawDataToString(data)));
ws.on("close", (code, reason) => {
const reasonText = reason.toString();
if (this.ws === ws) {
this.ws = null;
}
this.resolvePendingStop(ws);
handlers.close(code, reasonText);
});
ws.on("error", (err) => {
this.logDebug(`gateway client error: ${formatGatewayClientErrorForLog(err)}`);
handlers.error(err instanceof Error ? err : new Error(String(err)));
});
return {
isOpen: () => ws.readyState === WebSocket.OPEN,
send: (data) => ws.send(data),
close: (code, reason) => ws.close(code, reason),
};
}
stop() {
void this.beginStop();
}
async stopAndWait(opts?: { timeoutMs?: number }): Promise<void> {
// Some callers need teardown ordering, not just "close requested". Wait for
// the socket to close or the terminate fallback to fire.
const stopPromise = this.beginStop();
if (!stopPromise) {
return;
}
const timeoutMs =
opts?.timeoutMs === undefined
? STOP_AND_WAIT_TIMEOUT_MS
: resolveSafeTimeoutDelayMs(opts.timeoutMs);
let timeout: NodeJS.Timeout | null = null;
try {
await Promise.race([
stopPromise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`gateway client stop timed out after ${timeoutMs}ms`));
}, timeoutMs);
timeout.unref?.();
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
private beginStop(): Promise<void> | null {
this.stopped = true;
this.pendingDeviceTokenRetry = false;
this.deviceTokenRetryBudgetUsed = false;
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
if (this.pendingStop) {
return this.pendingStop.promise;
}
const ws = this.ws;
this.ws = null;
if (ws) {
const pendingStop = this.createPendingStop(ws);
const forceTerminateTimer = setTimeout(() => {
try {
ws.terminate();
} finally {
this.resolvePendingStop(ws);
}
}, FORCE_STOP_TERMINATE_GRACE_MS);
forceTerminateTimer.unref?.();
pendingStop.terminateTimer = forceTerminateTimer;
if (this.protocol.connecting) {
const error = new Error("gateway client stopped");
this.notifyConnectError(error);
this.logDebug(`gateway connect failed: ${formatGatewayClientErrorForLog(error)}`);
}
this.protocol.stop();
return pendingStop.promise;
}
this.protocol.stop();
return null;
}
private createPendingStop(ws: WebSocket): PendingStop {
if (this.pendingStop?.ws === ws) {
return this.pendingStop;
}
let resolve = () => {};
const promise = new Promise<void>((done) => {
resolve = done;
});
this.pendingStop = { ws, promise, resolve };
return this.pendingStop;
}
private resolvePendingStop(ws: WebSocket): void {
if (this.pendingStop?.ws !== ws) {
return;
}
const { resolve, terminateTimer } = this.pendingStop;
if (terminateTimer) {
clearTimeout(terminateTimer);
}
this.pendingStop = null;
resolve();
}
private logDebug(message: string): void {
this.deps.logDebug(this.deps.redactForLog(message));
}
private logError(message: string): void {
this.deps.logError(this.deps.redactForLog(message));
}
private assembleConnectParams(params: { role: string; nonce: string }): AssembledConnect {
const { role, nonce } = params;
// Auth selection is intentionally centralized: retry decisions depend on
// whether a token was explicit, cached, or compatibility-derived.
const selectedAuth = this.selectConnectAuth(role);
const {
authDeviceToken,
authApprovalRuntimeToken,
authAgentRuntimeIdentityToken,
signatureToken,
resolvedDeviceToken,
storedToken,
storedScopes,
usingStoredDeviceToken,
} = selectedAuth;
if (this.pendingDeviceTokenRetry && authDeviceToken) {
this.pendingDeviceTokenRetry = false;
}
const auth = buildGatewayConnectAuth(selectedAuth);
const signedAtMs = Date.now();
const scopes = resolveGatewayConnectScopes({
requestedScopes: this.opts.scopes,
usingStoredDeviceToken,
storedScopes,
defaultScopes: ["operator.admin"],
});
const platform = this.opts.platform ?? process.platform;
return {
params: {
minProtocol: this.opts.minProtocol ?? MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
client: {
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
displayName: this.opts.clientDisplayName,
version: this.opts.clientVersion ?? DEFAULT_CLIENT_VERSION,
platform,
deviceFamily: this.opts.deviceFamily,
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
instanceId: this.opts.instanceId,
},
caps: Array.isArray(this.opts.caps) ? this.opts.caps : [],
commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined,
permissions:
this.opts.permissions && typeof this.opts.permissions === "object"
? this.opts.permissions
: undefined,
pathEnv: this.opts.pathEnv,
auth,
role,
scopes,
device: this.buildDeviceConnectParams({
nonce,
role,
scopes,
signatureToken,
signedAtMs,
platform,
}),
},
authApprovalRuntimeToken,
authAgentRuntimeIdentityToken,
resolvedDeviceToken,
storedToken,
usingStoredDeviceToken,
};
}
private buildDeviceConnectParams(params: {
nonce: string;
role: string;
scopes: string[];
signatureToken: string | undefined;
signedAtMs: number;
platform: string;
}): ConnectParams["device"] {
if (!this.opts.deviceIdentity) {
return undefined;
}
const { nonce, role, scopes, signatureToken, signedAtMs, platform } = params;
// The signed payload mirrors server verification exactly; keep metadata
// normalized here so different hosts sign the same logical device facts.
const payload = buildDeviceAuthPayloadV3({
deviceId: this.opts.deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
role,
scopes,
signedAtMs,
token: signatureToken ?? null,
nonce,
platform,
deviceFamily: this.opts.deviceFamily,
});
const signature = this.deps.signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
return {
id: this.opts.deviceIdentity.deviceId,
publicKey: this.deps.publicKeyRawBase64UrlFromPem(this.opts.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
nonce,
};
}
private handleConnectHello(helloOk: HelloOk, assembled: AssembledConnect): void {
this.pendingDeviceTokenRetry = false;
this.deviceTokenRetryBudgetUsed = false;
this.suppressedTransientPreHelloCleanCloses = 0;
const role = this.opts.role ?? "operator";
const authInfo = helloOk.auth;
if (authInfo?.deviceToken && this.opts.deviceIdentity) {
this.deps.storeDeviceAuthToken({
deviceId: this.opts.deviceIdentity.deviceId,
role: authInfo.role ?? role,
token: authInfo.deviceToken,
scopes: authInfo.scopes ?? [],
env: this.opts.env,
});
}
this.tickIntervalMs =
typeof helloOk.policy?.tickIntervalMs === "number" ? helloOk.policy.tickIntervalMs : 30_000;
this.lastTick = Date.now();
this.startTickWatch();
void assembled;
}
private handleConnectRequestFailure(
error: GatewayProtocolRequestError,
assembled: AssembledConnect,
) {
const role = this.opts.role ?? "operator";
const shouldRetryWithDeviceToken = shouldRetryGatewayWithDeviceToken({
retryBudgetUsed: this.deviceTokenRetryBudgetUsed,
currentDeviceToken: assembled.resolvedDeviceToken,
explicitToken: this.opts.token?.trim() || undefined,
storedToken: assembled.storedToken,
trustedEndpoint: this.isTrustedDeviceRetryEndpoint(),
errorDetails: error instanceof GatewayClientRequestError ? error.details : undefined,
});
if (
this.opts.deviceIdentity &&
assembled.usingStoredDeviceToken &&
error instanceof GatewayClientRequestError &&
readConnectErrorDetailCode(error.details) ===
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
) {
const deviceId = this.opts.deviceIdentity.deviceId;
try {
this.deps.clearDeviceAuthToken({ deviceId, role, env: this.opts.env });
this.logDebug(`cleared stale device-auth token for device ${deviceId}`);
} catch (clearError) {
this.logDebug(
`failed clearing stale device-auth token for device ${deviceId}: ${String(clearError)}`,
);
}
}
if (shouldRetryWithDeviceToken) {
this.pendingDeviceTokenRetry = true;
this.deviceTokenRetryBudgetUsed = true;
this.protocol.resetReconnectBackoff(250);
}
const startupRetryAfterMs = resolveGatewayStartupRetryAfterMs(error);
if (startupRetryAfterMs !== null) {
this.logDebug(`gateway connect failed: ${formatGatewayClientErrorForLog(error)}`);
return {
closeCode: 1013,
closeReason: "gateway starting",
reconnectDelayMs: startupRetryAfterMs,
};
}
if (
this.shouldFailClosedForUnsupportedAgentRuntimeIdentity({
error,
authAgentRuntimeIdentityToken: assembled.authAgentRuntimeIdentityToken,
})
) {
const unsupportedIdentityError = new Error(
"gateway rejected required agent runtime identity auth field; refusing to retry without it",
);
this.stopped = true;
this.notifyConnectError(unsupportedIdentityError);
this.logError(`gateway connect failed: ${unsupportedIdentityError.message}`);
return { closeCode: 1008, closeReason: "connect failed", stop: true };
}
if (
this.shouldRetryWithoutApprovalRuntimeToken({
error,
authApprovalRuntimeToken: assembled.authApprovalRuntimeToken,
})
) {
this.approvalRuntimeTokenCompatibilityDisabled = true;
this.approvalRuntimeTokenRetryBudgetUsed = true;
this.protocol.resetReconnectBackoff(250);
this.logDebug("gateway rejected approval runtime auth field; retrying without it");
return { closeCode: 1008, closeReason: "connect retry" };
}
this.notifyConnectError(error);
const message = `gateway connect failed: ${formatGatewayClientErrorForLog(error)}`;
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE || isGatewayClientStoppedError(error)) {
this.logDebug(message);
} else {
this.logError(message);
}
return {
closeCode: 1008,
closeReason: "connect failed",
};
}
private resolveClose(context: GatewayProtocolCloseContext) {
const info = this.closeInfo(context);
const detailCode =
context.connectFailure?.error instanceof GatewayClientRequestError
? readConnectErrorDetailCode(context.connectFailure.error.details)
: null;
const details =
context.connectFailure?.error instanceof GatewayClientRequestError
? context.connectFailure.error.details
: undefined;
if (context.code === 1013 && context.connectFailure?.reconnectDelayMs !== undefined) {
return {
retry: true,
notify: false,
reconnectDelayMs: context.connectFailure.reconnectDelayMs,
};
}
if (
info.transientPreHelloCleanClose &&
this.suppressedTransientPreHelloCleanCloses < MAX_SUPPRESSED_TRANSIENT_PRE_HELLO_CLEAN_CLOSES
) {
this.suppressedTransientPreHelloCleanCloses += 1;
return {
retry: true,
notify: true,
pendingError: new GatewayClientTransientPreHelloCloseError(),
};
}
if (
info.transientPreHelloCleanClose ||
(context.connectRequestSent && !context.helloReceived && !context.connectFailure)
) {
const error = new Error(`gateway closed (${context.code}): ${context.reason}`);
this.notifyConnectError(error);
this.logError(`gateway connect failed: ${formatGatewayClientErrorForLog(error)}`);
}
this.clearStaleDeviceTokenForClose(context.code, context.reason);
if (
shouldPauseGatewayReconnect({
details,
deviceTokenRetryPending: this.pendingDeviceTokenRetry,
tokenMismatchIsTerminal: true,
clientVersionMismatchIsTerminal: true,
})
) {
this.notifyReconnectPaused({ code: context.code, reason: context.reason, detailCode });
return { retry: false, notify: true };
}
return {
retry: true,
notify: true,
reconnectDelayMs: context.connectFailure?.reconnectDelayMs,
};
}
private closeInfo(context: GatewayProtocolCloseContext): GatewayClientCloseInfo {
return {
phase: context.helloReceived ? "post-hello" : "pre-hello",
socketOpened: context.socketOpened,
transportValidated: this.transportValidated,
transientPreHelloCleanClose:
!context.helloReceived && context.code === 1000 && context.reason === "",
};
}
private clearStaleDeviceTokenForClose(code: number, reason: string): void {
if (
code !== 1008 ||
!normalizeLowercaseStringOrEmpty(reason).includes("device token mismatch") ||
this.opts.token ||
this.opts.password ||
!this.opts.deviceIdentity
) {
return;
}
const deviceId = this.opts.deviceIdentity.deviceId;
const role = this.opts.role ?? "operator";
try {
this.deps.clearDeviceAuthToken({ deviceId, role, env: this.opts.env });
this.logDebug(`cleared stale device-auth token for device ${deviceId}`);
} catch (error) {
this.logDebug(
`failed clearing stale device-auth token for device ${deviceId}: ${String(error)}`,
);
}
}
private notifyConnectError(error: Error) {
try {
this.opts.onConnectError?.(error);
} catch (err) {
this.logDebug(
`gateway client connect error handler error: ${formatGatewayClientErrorForLog(err)}`,
);
}
}
private notifyReconnectPaused(info: GatewayReconnectPausedInfo): void {
try {
this.opts.onReconnectPaused?.(info);
} catch (err) {
this.logDebug(
`gateway client reconnect paused handler error: ${formatGatewayClientErrorForLog(err)}`,
);
}
}
private shouldRetryWithoutApprovalRuntimeToken(params: {
error: unknown;
authApprovalRuntimeToken?: string;
}): boolean {
if (this.approvalRuntimeTokenRetryBudgetUsed) {
return false;
}
if (!params.authApprovalRuntimeToken) {
return false;
}
if (!(params.error instanceof GatewayClientRequestError)) {
return false;
}
if (params.error.gatewayCode !== "INVALID_REQUEST") {
return false;
}
const message = normalizeLowercaseStringOrEmpty(params.error.message);
return message.includes("invalid connect params") && message.includes("approvalruntimetoken");
}
private shouldFailClosedForUnsupportedAgentRuntimeIdentity(params: {
error: unknown;
authAgentRuntimeIdentityToken?: string;
}): boolean {
if (!params.authAgentRuntimeIdentityToken) {
return false;
}
if (!(params.error instanceof GatewayClientRequestError)) {
return false;
}
if (params.error.gatewayCode !== "INVALID_REQUEST") {
return false;
}
const message = normalizeLowercaseStringOrEmpty(params.error.message);
return (
message.includes("invalid connect params") && message.includes("agentruntimeidentitytoken")
);
}
private isTrustedDeviceRetryEndpoint(): boolean {
const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789";
try {
const parsed = new URL(rawUrl);
const protocol =
parsed.protocol === "https:"
? "wss:"
: parsed.protocol === "http:"
? "ws:"
: parsed.protocol;
if (isLoopbackHost(parsed.hostname)) {
return true;
}
return protocol === "wss:" && Boolean(this.opts.tlsFingerprint?.trim());
} catch {
return false;
}
}
private selectConnectAuth(role: string): GatewayConnectAuthSelection {
const storedAuth = this.opts.deviceIdentity
? this.deps.loadDeviceAuthToken({
deviceId: this.opts.deviceIdentity.deviceId,
role,
env: this.opts.env,
})
: null;
return selectGatewayConnectAuth({
token: this.opts.token,
bootstrapToken: this.opts.bootstrapToken,
deviceToken: this.opts.deviceToken,
password: this.opts.password,
approvalRuntimeToken: this.approvalRuntimeTokenCompatibilityDisabled
? undefined
: this.opts.approvalRuntimeToken,
agentRuntimeIdentityToken: this.opts.agentRuntimeIdentityToken,
storedToken: storedAuth?.token,
storedScopes: storedAuth?.scopes,
pendingDeviceTokenRetry: this.pendingDeviceTokenRetry,
trustedDeviceTokenRetry: this.isTrustedDeviceRetryEndpoint(),
});
}
private startTickWatch() {
if (this.tickTimer) {
clearInterval(this.tickTimer);
}
const rawMinInterval = this.opts.tickWatchMinIntervalMs;
const minInterval =
typeof rawMinInterval === "number" && Number.isFinite(rawMinInterval)
? Math.max(1, Math.min(30_000, rawMinInterval))
: 1000;
const interval = resolveSafeTimeoutDelayMs(Math.max(this.tickIntervalMs, minInterval));
this.tickTimer = setInterval(() => {
if (this.stopped) {
return;
}
if (!this.lastTick) {
return;
}
const allPendingRequestsHaveTimeouts =
this.protocol.hasPendingRequests && !this.protocol.hasUnboundedPendingRequests;
// Finite requests own their deadline. One unbounded request keeps the
// transport watchdog active so a dead socket cannot strand it forever.
if (allPendingRequestsHaveTimeouts) {
return;
}
const gap = Date.now() - this.lastTick;
const rawTimeoutMs = this.opts.tickWatchTimeoutMs;
// Normal gateways use the server-advertised tick interval. Long-running
// harness clients can widen the threshold without mutating internals.
const timeoutMs =
typeof rawTimeoutMs === "number" && Number.isFinite(rawTimeoutMs)
? Math.max(1, rawTimeoutMs)
: this.tickIntervalMs * 2;
if (gap > timeoutMs) {
this.protocol.closeSocket(4000, "tick timeout");
}
}, interval);
}
private validateTlsFingerprint(): Error | null {
if (!this.opts.tlsFingerprint || !this.ws) {
return null;
}
const expected = this.deps.normalizeTlsFingerprint(this.opts.tlsFingerprint);
if (!expected) {
return new Error("gateway tls fingerprint missing");
}
const socket = (
this.ws as WebSocket & {
_socket?: { getPeerCertificate?: () => { fingerprint256?: string } };
}
)["_socket"];
if (!socket || typeof socket.getPeerCertificate !== "function") {
return new Error("gateway tls fingerprint unavailable");
}
const cert = socket.getPeerCertificate();
const fingerprint = this.deps.normalizeTlsFingerprint(cert?.fingerprint256 ?? "");
if (!fingerprint) {
return new Error("gateway tls fingerprint unavailable");
}
if (fingerprint !== expected) {
return new Error("gateway tls fingerprint mismatch");
}
return null;
}
async request<T = Record<string, unknown>>(
method: string,
params?: unknown,
opts?: GatewayClientRequestOptions,
): Promise<T> {
const expectFinal = opts?.expectFinal === true;
const timeoutMs =
opts?.timeoutMs === null
? null
: typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? resolveSafeTimeoutDelayMs(opts.timeoutMs, { minMs: 0 })
: expectFinal
? null
: this.requestTimeoutMs;
return this.protocol.request<T>(method, params, {
expectFinal,
timeoutMs,
signal: opts?.signal,
onAccepted: opts?.onAccepted,
});
}
}
function createGatewayRequestAbortError(method: string): Error {
const err = new Error(`gateway request aborted for ${method}`);
err.name = "AbortError";
return err;
}