diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index cb274dd26999..b377fe0c0bae 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2901,7 +2901,7 @@ src/gateway/agent-turn/internal-facade.ts 4 src/gateway/board-host-tools.ts 1 src/gateway/board-view-ticket.ts 1 src/gateway/boot.ts 1 -src/gateway/call.ts 7 +src/gateway/call.ts 6 src/gateway/channel-health-monitor.ts 6 src/gateway/channel-thaw-restart.ts 1 src/gateway/chat-attachments.ts 4 diff --git a/src/cli/failure-output.test.ts b/src/cli/failure-output.test.ts index c2f9120b4df9..7687cc32033e 100644 --- a/src/cli/failure-output.test.ts +++ b/src/cli/failure-output.test.ts @@ -1,6 +1,6 @@ // Failure output tests cover CLI error formatting and failure summaries. import { describe, expect, it } from "vitest"; -import { GatewayCredentialsRequiredError } from "../gateway/call.js"; +import { GatewayCredentialsRequiredError, GatewayTransportError } from "../gateway/call.js"; import { ExpectedCliError, formatCliFailureLines, @@ -157,6 +157,20 @@ describe("formatCliFailureLines", () => { configPath: "/tmp/openclaw.json", }), }, + { + label: "unreachable gateway", + createError: () => + new GatewayTransportError({ + kind: "closed", + message: + "Gateway not reachable at ws://127.0.0.1:51078 (ECONNREFUSED).\nStart it with `openclaw gateway run` or check `openclaw gateway status`.", + connectionDetails: { + url: "ws://127.0.0.1:51078", + urlSource: "local loopback", + message: "Gateway target: ws://127.0.0.1:51078", + }, + }), + }, ])( "routes $label through the shared expected-condition predicate without crash framing", ({ createError }) => { diff --git a/src/cli/failure-output.ts b/src/cli/failure-output.ts index 97aefa57a2be..abc5c02b4f12 100644 --- a/src/cli/failure-output.ts +++ b/src/cli/failure-output.ts @@ -1,4 +1,5 @@ // Shared root CLI failure formatting with debug stack gating and recovery hints. +import { isGatewayTransportError } from "../gateway/transport-error.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { formatErrorMessage, formatUncaughtError } from "../infra/errors.js"; import { formatCliCommand } from "./command-format.js"; @@ -58,7 +59,11 @@ function isGatewayCredentialsCliError( } export function isExpectedCliError(error: unknown): error is Error { - return error instanceof ExpectedCliError || isGatewayCredentialsCliError(error); + return ( + error instanceof ExpectedCliError || + isGatewayCredentialsCliError(error) || + isGatewayTransportError(error) + ); } export function rethrowExpectedCliError(error: unknown): void { diff --git a/src/cli/gateway-backed-exit.process.test.ts b/src/cli/gateway-backed-exit.process.test.ts index e791b36c841b..8492b5217314 100644 --- a/src/cli/gateway-backed-exit.process.test.ts +++ b/src/cli/gateway-backed-exit.process.test.ts @@ -3,48 +3,33 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child import { createHash } from "node:crypto"; import { once } from "node:events"; import fs from "node:fs/promises"; -import type { AddressInfo } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import { isLoopbackIpAddress, isPrivateOrLoopbackIpAddress } from "@openclaw/net-policy/ip"; import { afterEach, describe, expect, it } from "vitest"; -import { WebSocketServer } from "ws"; import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import { - buildMinimalGatewayHelloOkPayload, - closeMinimalGatewayServer, - parseMinimalGatewayRequestFrame, - sendMinimalGatewayConnectChallenge, - sendMinimalGatewayResponse, -} from "../gateway/minimal-gateway.test-helpers.js"; import { loadOriginDeviceTokenReadOnly, storeOriginDeviceToken, } from "../infra/device-auth-store.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { acquireGatewayLock } from "../infra/gateway-lock.js"; -import { - pickMatchingExternalInterfaceAddress, - readNetworkInterfaces, -} from "../infra/network-interfaces.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { getFreePort } from "../test-utils/ports.js"; +import { + closeActiveGatewayServers, + EMPTY_STABILITY_SNAPSHOT, + startCronListGateway, + startGatewayStabilityRpcServer, + startNodePairingGateway, + startRateLimitedGateway, +} from "./gateway-backed-exit.test-helpers.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); const execFileAsync = promisify(execFile); const activeChildren = new Set(); -const activeServers = new Set(); const UNREACHABLE_GATEWAY_URL = "ws://127.0.0.1:9"; -const EMPTY_STABILITY_SNAPSHOT = { - capacity: 100, - count: 0, - dropped: 0, - events: [], - summary: { byType: {} }, -}; - afterEach(async () => { await Promise.all( Array.from(activeChildren, async (child) => { @@ -56,208 +41,9 @@ afterEach(async () => { }), ); activeChildren.clear(); - await Promise.all(Array.from(activeServers, closeMinimalGatewayServer)); - activeServers.clear(); + await closeActiveGatewayServers(); }); -async function startCronListGateway(token: string): Promise<{ url: string }> { - const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - activeServers.add(wss); - wss.on("connection", (ws) => { - sendMinimalGatewayConnectChallenge(ws); - ws.on("message", (data) => { - const frame = parseMinimalGatewayRequestFrame(data); - if (frame.type !== "req" || !frame.id) { - return; - } - if (frame.method === "connect") { - expect(frame.params?.auth?.token).toBe(token); - sendMinimalGatewayResponse( - ws, - frame.id, - buildMinimalGatewayHelloOkPayload({ - methods: ["cron.list"], - auth: { role: "operator", scopes: ["operator.admin"] }, - }), - ); - return; - } - if (frame.method === "cron.list") { - sendMinimalGatewayResponse(ws, frame.id, { - jobs: [], - snapshotRevision: "test-revision", - total: 0, - offset: 0, - limit: 50, - hasMore: false, - nextOffset: null, - deliveryPreviews: {}, - }); - } - }); - }); - await once(wss, "listening"); - const address = wss.address() as AddressInfo; - return { url: `ws://127.0.0.1:${address.port}` }; -} - -async function startRateLimitedGateway(): Promise<{ url: string }> { - const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - activeServers.add(wss); - wss.on("connection", (ws) => { - sendMinimalGatewayConnectChallenge(ws); - ws.on("message", (data) => { - const frame = parseMinimalGatewayRequestFrame(data); - if (frame.type !== "req" || !frame.id || frame.method !== "connect") { - return; - } - const message = "unauthorized: too many failed authentication attempts (retry later)"; - ws.send( - JSON.stringify({ - type: "res", - id: frame.id, - ok: false, - error: { - code: "INVALID_REQUEST", - message, - retryable: true, - retryAfterMs: 60_000, - details: { - code: "AUTH_RATE_LIMITED", - authReason: "rate_limited", - recommendedNextStep: "wait_then_retry", - }, - }, - }), - () => ws.close(1008, message), - ); - }); - }); - await once(wss, "listening"); - const address = wss.address() as AddressInfo; - return { url: `ws://127.0.0.1:${address.port}` }; -} - -async function startNodePairingGateway( - token: string, - issuedDeviceToken?: string, -): Promise<{ - calls: string[]; - url: string; -}> { - const calls: string[] = []; - const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - activeServers.add(wss); - wss.on("connection", (ws) => { - sendMinimalGatewayConnectChallenge(ws); - ws.on("message", (data) => { - const frame = parseMinimalGatewayRequestFrame(data); - if (frame.type !== "req" || !frame.id) { - return; - } - if (frame.method === "connect") { - expect(frame.params?.auth?.token).toBe(token); - sendMinimalGatewayResponse( - ws, - frame.id, - buildMinimalGatewayHelloOkPayload({ - methods: ["node.pair.list", "node.pair.approve"], - auth: { - role: "operator", - scopes: ["operator.admin"], - ...(issuedDeviceToken ? { deviceToken: issuedDeviceToken } : {}), - }, - }), - ); - return; - } - if (typeof frame.method !== "string") { - return; - } - calls.push(frame.method); - if (frame.method === "node.pair.list") { - sendMinimalGatewayResponse(ws, frame.id, { - pending: [{ requestId: "request-1", nodeId: "node-1", commands: [] }], - paired: [], - }); - return; - } - if (frame.method === "node.pair.approve") { - sendMinimalGatewayResponse(ws, frame.id, { approved: true }); - } - }); - }); - await once(wss, "listening"); - const address = wss.address() as AddressInfo; - return { calls, url: `ws://127.0.0.1:${address.port}` }; -} - -async function startGatewayStabilityRpcServer( - token: string, - issuedDeviceToken: string, -): Promise<{ - authTokens: Array; - calls: string[]; - url: string; -}> { - const authTokens: Array = []; - const calls: string[] = []; - const wss = new WebSocketServer({ host: "0.0.0.0", port: 0 }); - activeServers.add(wss); - wss.on("connection", (ws) => { - sendMinimalGatewayConnectChallenge(ws); - ws.on("message", (data) => { - const frame = parseMinimalGatewayRequestFrame(data); - if (frame.type !== "req" || !frame.id) { - return; - } - if (frame.method === "connect") { - expect(frame.params?.auth?.token).toBe(token); - authTokens.push(frame.params?.auth?.token); - sendMinimalGatewayResponse( - ws, - frame.id, - buildMinimalGatewayHelloOkPayload({ - methods: ["diagnostics.stability", "status"], - auth: { - role: "operator", - scopes: ["operator.admin"], - deviceToken: issuedDeviceToken, - }, - }), - ); - return; - } - if (typeof frame.method !== "string") { - return; - } - calls.push(frame.method); - if (frame.method === "diagnostics.stability") { - sendMinimalGatewayResponse(ws, frame.id, EMPTY_STABILITY_SNAPSHOT); - return; - } - if (frame.method === "status") { - sendMinimalGatewayResponse(ws, frame.id, { - runtimeVersion: "2026.8.17-test", - status: "ok", - }); - } - }); - }); - await once(wss, "listening"); - const address = wss.address() as AddressInfo; - // A private non-loopback target keeps shared-secret auth from bypassing device identity. - const host = pickMatchingExternalInterfaceAddress(readNetworkInterfaces(), { - family: "IPv4", - matches: (candidate) => - isPrivateOrLoopbackIpAddress(candidate) && !isLoopbackIpAddress(candidate), - }); - if (!host) { - throw new Error("test host has no non-loopback private IPv4 address"); - } - return { authTokens, calls, url: `ws://${host}:${address.port}` }; -} - async function snapshotDirectoryContents(root: string): Promise> { const snapshot: Record = {}; const visit = async (directory: string): Promise => { @@ -693,6 +479,106 @@ describe("gateway-backed CLI process exit", () => { 30_000, ); + it.each([ + { label: "list", args: ["devices", "list", "--timeout", "250"] }, + { label: "join-code", args: ["devices", "join-code", "--timeout", "250"] }, + { + label: "remove", + args: ["devices", "remove", "test-device", "--timeout", "250"], + }, + { + label: "clear", + args: ["devices", "clear", "--yes", "--pending", "--timeout", "250"], + }, + { + label: "approve", + args: ["devices", "approve", "test-request", "--timeout", "250"], + }, + { + label: "reject", + args: ["devices", "reject", "test-request", "--timeout", "250"], + }, + { + label: "rename", + args: [ + "devices", + "rename", + "--device", + "test-device", + "--name", + "Test Device", + "--timeout", + "250", + ], + }, + { + label: "rotate", + args: [ + "devices", + "rotate", + "--device", + "test-device", + "--role", + "operator", + "--timeout", + "250", + ], + machineOutput: true, + }, + { + label: "revoke", + args: [ + "devices", + "revoke", + "--device", + "test-device", + "--role", + "operator", + "--timeout", + "250", + ], + machineOutput: true, + }, + ])( + "renders an unreachable gateway as expected guidance for devices $label", + async ({ label, args, machineOutput }) => { + const root = tempDirs.make(`openclaw-devices-${label}-transport-`); + const stateDir = path.join(root, "state"); + const configPath = path.join(stateDir, "openclaw.json"); + const port = await getFreePort(); + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify({ + gateway: { mode: "local", port, auth: { mode: "token", token: "test-token" } }, + })}\n`, + "utf8", + ); + + const result = await runIsolatedGatewayCli({ args, root, stateDir, configPath }); + + expect(result).toMatchObject({ code: 1, signal: null }); + if (machineOutput) { + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { type: "cli_error", message: expect.stringContaining("Gateway not reachable") }, + }); + } else { + expect(result.stdout).toBe(""); + } + expect(result.stderr).toContain(`Gateway not reachable at ws://127.0.0.1:${port}`); + expect(result.stderr).toContain( + "Start it with `openclaw gateway run` or check `openclaw gateway status`.", + ); + expect(result.stderr).not.toContain("The CLI command failed"); + expect(result.stderr).not.toContain("Could not start the CLI"); + expect(result.stderr).not.toContain("OPENCLAW_DEBUG"); + expect(result.stderr).not.toContain("Stack:"); + expect(result.stderr).not.toContain("openclaw doctor"); + }, + 30_000, + ); + it.each([ { label: "absent", seeded: false }, { label: "seeded", seeded: true }, diff --git a/src/cli/gateway-backed-exit.test-helpers.ts b/src/cli/gateway-backed-exit.test-helpers.ts new file mode 100644 index 000000000000..d8415de45227 --- /dev/null +++ b/src/cli/gateway-backed-exit.test-helpers.ts @@ -0,0 +1,231 @@ +// Shared process-test harness: mock Gateway servers used by CLI exit-code proofs. +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { isLoopbackIpAddress, isPrivateOrLoopbackIpAddress } from "@openclaw/net-policy/ip"; +import { expect } from "vitest"; +import { WebSocketServer } from "ws"; +import { + buildMinimalGatewayHelloOkPayload, + closeMinimalGatewayServer, + parseMinimalGatewayRequestFrame, + sendMinimalGatewayConnectChallenge, + sendMinimalGatewayResponse, +} from "../gateway/minimal-gateway.test-helpers.js"; +import { + pickMatchingExternalInterfaceAddress, + readNetworkInterfaces, +} from "../infra/network-interfaces.js"; + +const activeServers = new Set(); + +export const EMPTY_STABILITY_SNAPSHOT = { + capacity: 100, + count: 0, + dropped: 0, + events: [], + summary: { byType: {} }, +}; + +export async function startCronListGateway(token: string): Promise<{ url: string }> { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id) { + return; + } + if (frame.method === "connect") { + expect(frame.params?.auth?.token).toBe(token); + sendMinimalGatewayResponse( + ws, + frame.id, + buildMinimalGatewayHelloOkPayload({ + methods: ["cron.list"], + auth: { role: "operator", scopes: ["operator.admin"] }, + }), + ); + return; + } + if (frame.method === "cron.list") { + sendMinimalGatewayResponse(ws, frame.id, { + jobs: [], + snapshotRevision: "test-revision", + total: 0, + offset: 0, + limit: 50, + hasMore: false, + nextOffset: null, + deliveryPreviews: {}, + }); + } + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + return { url: `ws://127.0.0.1:${address.port}` }; +} + +export async function startRateLimitedGateway(): Promise<{ url: string }> { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id || frame.method !== "connect") { + return; + } + const message = "unauthorized: too many failed authentication attempts (retry later)"; + ws.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: false, + error: { + code: "INVALID_REQUEST", + message, + retryable: true, + retryAfterMs: 60_000, + details: { + code: "AUTH_RATE_LIMITED", + authReason: "rate_limited", + recommendedNextStep: "wait_then_retry", + }, + }, + }), + () => ws.close(1008, message), + ); + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + return { url: `ws://127.0.0.1:${address.port}` }; +} + +export async function startNodePairingGateway( + token: string, + issuedDeviceToken?: string, +): Promise<{ + calls: string[]; + url: string; +}> { + const calls: string[] = []; + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id) { + return; + } + if (frame.method === "connect") { + expect(frame.params?.auth?.token).toBe(token); + sendMinimalGatewayResponse( + ws, + frame.id, + buildMinimalGatewayHelloOkPayload({ + methods: ["node.pair.list", "node.pair.approve"], + auth: { + role: "operator", + scopes: ["operator.admin"], + ...(issuedDeviceToken ? { deviceToken: issuedDeviceToken } : {}), + }, + }), + ); + return; + } + if (typeof frame.method !== "string") { + return; + } + calls.push(frame.method); + if (frame.method === "node.pair.list") { + sendMinimalGatewayResponse(ws, frame.id, { + pending: [{ requestId: "request-1", nodeId: "node-1", commands: [] }], + paired: [], + }); + return; + } + if (frame.method === "node.pair.approve") { + sendMinimalGatewayResponse(ws, frame.id, { approved: true }); + } + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + return { calls, url: `ws://127.0.0.1:${address.port}` }; +} + +export async function startGatewayStabilityRpcServer( + token: string, + issuedDeviceToken: string, +): Promise<{ + authTokens: Array; + calls: string[]; + url: string; +}> { + const authTokens: Array = []; + const calls: string[] = []; + const wss = new WebSocketServer({ host: "0.0.0.0", port: 0 }); + activeServers.add(wss); + wss.on("connection", (ws) => { + sendMinimalGatewayConnectChallenge(ws); + ws.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id) { + return; + } + if (frame.method === "connect") { + expect(frame.params?.auth?.token).toBe(token); + authTokens.push(frame.params?.auth?.token); + sendMinimalGatewayResponse( + ws, + frame.id, + buildMinimalGatewayHelloOkPayload({ + methods: ["diagnostics.stability", "status"], + auth: { + role: "operator", + scopes: ["operator.admin"], + deviceToken: issuedDeviceToken, + }, + }), + ); + return; + } + if (typeof frame.method !== "string") { + return; + } + calls.push(frame.method); + if (frame.method === "diagnostics.stability") { + sendMinimalGatewayResponse(ws, frame.id, EMPTY_STABILITY_SNAPSHOT); + return; + } + if (frame.method === "status") { + sendMinimalGatewayResponse(ws, frame.id, { + runtimeVersion: "2026.8.17-test", + status: "ok", + }); + } + }); + }); + await once(wss, "listening"); + const address = wss.address() as AddressInfo; + // A private non-loopback target keeps shared-secret auth from bypassing device identity. + const host = pickMatchingExternalInterfaceAddress(readNetworkInterfaces(), { + family: "IPv4", + matches: (candidate) => + isPrivateOrLoopbackIpAddress(candidate) && !isLoopbackIpAddress(candidate), + }); + if (!host) { + throw new Error("test host has no non-loopback private IPv4 address"); + } + return { authTokens, calls, url: `ws://${host}:${address.port}` }; +} + +/** Closes every mock Gateway started by these helpers; call from the suite afterEach. */ +export async function closeActiveGatewayServers(): Promise { + await Promise.all(Array.from(activeServers, closeMinimalGatewayServer)); + activeServers.clear(); +} diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 8a2a5a71cb2d..f5f6d889823f 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -87,7 +87,17 @@ import { type OperatorScope, } from "./method-scopes.js"; import { resolveGatewayConnectionTlsFingerprint } from "./tls-fingerprint.js"; +import { + GatewayTransportError, + type GatewayTransportErrorKind, + isGatewayTransportError, +} from "./transport-error.js"; export type { GatewayConnectionDetails }; +export { + GatewayTransportError, + isGatewayTransportError, + type GatewayTransportErrorKind, +} from "./transport-error.js"; export type GatewayRequestFunction = >( method: string, @@ -147,39 +157,6 @@ export type CallGatewayOptions = CallGatewayBaseOptions & { scopes?: OperatorScope[]; }; -export type GatewayTransportErrorKind = "closed" | "timeout"; - -export class GatewayTransportError extends Error { - readonly kind: GatewayTransportErrorKind; - readonly connectionDetails: GatewayConnectionDetails; - readonly code?: number; - readonly reason?: string; - readonly timeoutMs?: number; - - constructor(params: { - kind: GatewayTransportErrorKind; - message: string; - connectionDetails: GatewayConnectionDetails; - code?: number; - reason?: string; - timeoutMs?: number; - }) { - super(params.message); - this.name = "GatewayTransportError"; - this.kind = params.kind; - this.connectionDetails = params.connectionDetails; - if (params.code !== undefined) { - this.code = params.code; - } - if (params.reason !== undefined) { - this.reason = params.reason; - } - if (params.timeoutMs !== undefined) { - this.timeoutMs = params.timeoutMs; - } - } -} - export class GatewayCredentialsRequiredError extends Error { readonly method: string; readonly configPath: string; @@ -375,21 +352,6 @@ export function formatGatewayAuthErrorJson(value: unknown): GatewayAuthErrorJson }; } -export function isGatewayTransportError(value: unknown): value is GatewayTransportError { - if (value instanceof GatewayTransportError) { - return true; - } - if (!(value instanceof Error) || value.name !== "GatewayTransportError") { - return false; - } - const candidate = value as Partial; - return ( - (candidate.kind === "closed" || candidate.kind === "timeout") && - typeof candidate.connectionDetails === "object" && - candidate.connectionDetails !== null - ); -} - export function isGatewayCredentialsRequiredError( value: unknown, ): value is GatewayCredentialsRequiredError { diff --git a/src/gateway/transport-error.ts b/src/gateway/transport-error.ts new file mode 100644 index 000000000000..860969690f62 --- /dev/null +++ b/src/gateway/transport-error.ts @@ -0,0 +1,50 @@ +import type { GatewayConnectionDetails } from "./connection-details.js"; + +export type GatewayTransportErrorKind = "closed" | "timeout"; + +export class GatewayTransportError extends Error { + readonly kind: GatewayTransportErrorKind; + readonly connectionDetails: GatewayConnectionDetails; + readonly code?: number; + readonly reason?: string; + readonly timeoutMs?: number; + + constructor(params: { + kind: GatewayTransportErrorKind; + message: string; + connectionDetails: GatewayConnectionDetails; + code?: number; + reason?: string; + timeoutMs?: number; + }) { + super(params.message); + this.name = "GatewayTransportError"; + this.kind = params.kind; + this.connectionDetails = params.connectionDetails; + if (params.code !== undefined) { + this.code = params.code; + } + if (params.reason !== undefined) { + this.reason = params.reason; + } + if (params.timeoutMs !== undefined) { + this.timeoutMs = params.timeoutMs; + } + } +} + +export function isGatewayTransportError(value: unknown): value is GatewayTransportError { + if (value instanceof GatewayTransportError) { + return true; + } + if (!(value instanceof Error) || value.name !== "GatewayTransportError") { + return false; + } + return ( + "kind" in value && + (value.kind === "closed" || value.kind === "timeout") && + "connectionDetails" in value && + typeof value.connectionDetails === "object" && + value.connectionDetails !== null + ); +}