mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(cli): render gateway transport failures as expected conditions (#125556)
* fix(cli): render gateway transport failures as expected conditions An unreachable gateway is an expected, recoverable operator condition, but only the devices command family surfaced it through the root failure handler, where it rendered as "The CLI command failed" with debug hints. Extract GatewayTransportError into its own module so the failure renderer can classify it without importing the transport stack, and treat it as an expected CLI error alongside missing credentials. * fix(gateway): import the transport error kind used by the JSON shape * test(cli): extract the process-test gateway harness The new devices coverage pushed gateway-backed-exit.process.test.ts past the max-lines budget, and that file is not in the ratchet baseline. Move the mock Gateway servers into a sibling test-helpers module instead of suppressing the rule.
This commit is contained in:
committed by
GitHub
parent
7e7b860bca
commit
01eec285d9
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ChildProcessWithoutNullStreams>();
|
||||
const activeServers = new Set<WebSocketServer>();
|
||||
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<string | undefined>;
|
||||
calls: string[];
|
||||
url: string;
|
||||
}> {
|
||||
const authTokens: Array<string | undefined> = [];
|
||||
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<Record<string, string>> {
|
||||
const snapshot: Record<string, string> = {};
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<WebSocketServer>();
|
||||
|
||||
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<string | undefined>;
|
||||
calls: string[];
|
||||
url: string;
|
||||
}> {
|
||||
const authTokens: Array<string | undefined> = [];
|
||||
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<void> {
|
||||
await Promise.all(Array.from(activeServers, closeMinimalGatewayServer));
|
||||
activeServers.clear();
|
||||
}
|
||||
+10
-48
@@ -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 = <T = Record<string, unknown>>(
|
||||
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<GatewayTransportError>;
|
||||
return (
|
||||
(candidate.kind === "closed" || candidate.kind === "timeout") &&
|
||||
typeof candidate.connectionDetails === "object" &&
|
||||
candidate.connectionDetails !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function isGatewayCredentialsRequiredError(
|
||||
value: unknown,
|
||||
): value is GatewayCredentialsRequiredError {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user