fix(gateway): restore loopback detail probes and identity fallback (#51087)

Merged via squash.

Prepared head SHA: f8a66ffde2
Co-authored-by: heavenlost <70937055+heavenlost@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
heavenlost
2026-03-27 15:09:41 +08:00
committed by GitHub
parent 6f92148da9
commit 3cbd4de95c
13 changed files with 469 additions and 70 deletions

View File

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
const callGateway = vi.fn(async (..._args: unknown[]) => ({ ok: true }));
const probeGatewayStatus = vi.fn(async (..._args: unknown[]) => ({ ok: true }));
const resolveGatewayProgramArguments = vi.fn(async (_opts?: unknown) => ({
programArguments: ["/bin/node", "cli", "gateway", "--port", "18789"],
}));
@@ -36,8 +36,8 @@ const buildGatewayInstallPlan = vi.fn(
const { runtimeLogs, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGateway(opts),
vi.mock("./daemon-cli/probe.js", () => ({
probeGatewayStatus: (opts: unknown) => probeGatewayStatus(opts),
}));
vi.mock("../gateway/probe-auth.js", () => ({
@@ -146,19 +146,21 @@ describe("daemon-cli coverage", () => {
it("probes gateway status by default", async () => {
resetRuntimeCapture();
callGateway.mockClear();
probeGatewayStatus.mockClear();
await runDaemonCommand(["daemon", "status"]);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "status" }));
expect(probeGatewayStatus).toHaveBeenCalledTimes(1);
expect(probeGatewayStatus).toHaveBeenCalledWith(
expect.objectContaining({ url: "ws://127.0.0.1:18789" }),
);
expect(findExtraGatewayServices).toHaveBeenCalled();
expect(inspectPortUsage).toHaveBeenCalled();
});
it("derives probe URL from service args + env (json)", async () => {
resetRuntimeCapture();
callGateway.mockClear();
probeGatewayStatus.mockClear();
inspectPortUsage.mockClear();
serviceReadCommand.mockResolvedValueOnce({
@@ -174,10 +176,9 @@ describe("daemon-cli coverage", () => {
await runDaemonCommand(["daemon", "status", "--json"]);
expect(callGateway).toHaveBeenCalledWith(
expect(probeGatewayStatus).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:19001",
method: "status",
}),
);
expect(inspectPortUsage).toHaveBeenCalledWith(19001);

View File

@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.hoisted(() => vi.fn());
const probeGatewayMock = vi.hoisted(() => vi.fn());
vi.mock("../../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => callGatewayMock(...args),
}));
vi.mock("../../gateway/probe.js", () => ({
probeGateway: (...args: unknown[]) => probeGatewayMock(...args),
}));
vi.mock("../progress.js", () => ({
withProgress: async (_opts: unknown, fn: () => Promise<unknown>) => await fn(),
}));
const { probeGatewayStatus } = await import("./probe.js");
describe("probeGatewayStatus", () => {
it("uses lightweight token-only probing for daemon status", async () => {
callGatewayMock.mockReset();
probeGatewayMock.mockResolvedValueOnce({ ok: true });
const result = await probeGatewayStatus({
url: "ws://127.0.0.1:19191",
token: "temp-token",
tlsFingerprint: "abc123",
timeoutMs: 5_000,
json: true,
});
expect(result).toEqual({ ok: true });
expect(callGatewayMock).not.toHaveBeenCalled();
expect(probeGatewayMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:19191",
auth: {
token: "temp-token",
password: undefined,
},
tlsFingerprint: "abc123",
timeoutMs: 5_000,
includeDetails: false,
});
});
it("uses a real status RPC when requireRpc is enabled", async () => {
callGatewayMock.mockReset();
probeGatewayMock.mockReset();
callGatewayMock.mockResolvedValueOnce({ status: "ok" });
const result = await probeGatewayStatus({
url: "ws://127.0.0.1:19191",
token: "temp-token",
tlsFingerprint: "abc123",
timeoutMs: 5_000,
json: true,
requireRpc: true,
configPath: "/tmp/openclaw-daemon/openclaw.json",
});
expect(result).toEqual({ ok: true });
expect(probeGatewayMock).not.toHaveBeenCalled();
expect(callGatewayMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:19191",
token: "temp-token",
password: undefined,
tlsFingerprint: "abc123",
method: "status",
timeoutMs: 5_000,
configPath: "/tmp/openclaw-daemon/openclaw.json",
});
});
it("surfaces probe close details when the handshake fails", async () => {
callGatewayMock.mockReset();
probeGatewayMock.mockReset();
probeGatewayMock.mockResolvedValueOnce({
ok: false,
error: null,
close: { code: 1008, reason: "pairing required" },
});
const result = await probeGatewayStatus({
url: "ws://127.0.0.1:19191",
timeoutMs: 5_000,
});
expect(result).toEqual({
ok: false,
error: "gateway closed (1008): pairing required",
});
});
it("surfaces status RPC errors when requireRpc is enabled", async () => {
callGatewayMock.mockReset();
probeGatewayMock.mockReset();
callGatewayMock.mockRejectedValueOnce(new Error("missing scope: operator.admin"));
const result = await probeGatewayStatus({
url: "ws://127.0.0.1:19191",
token: "temp-token",
timeoutMs: 5_000,
requireRpc: true,
});
expect(result).toEqual({
ok: false,
error: "missing scope: operator.admin",
});
expect(probeGatewayMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,3 @@
import { callGateway } from "../../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { withProgress } from "../progress.js";
export async function probeGatewayStatus(opts: {
@@ -9,29 +7,53 @@ export async function probeGatewayStatus(opts: {
tlsFingerprint?: string;
timeoutMs: number;
json?: boolean;
requireRpc?: boolean;
configPath?: string;
}) {
try {
await withProgress(
const result = await withProgress(
{
label: "Checking gateway status...",
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
async () => {
if (opts.requireRpc) {
const { callGateway } = await import("../../gateway/call.js");
await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
tlsFingerprint: opts.tlsFingerprint,
method: "status",
timeoutMs: opts.timeoutMs,
...(opts.configPath ? { configPath: opts.configPath } : {}),
});
return { ok: true } as const;
}
const { probeGateway } = await import("../../gateway/probe.js");
return await probeGateway({
url: opts.url,
token: opts.token,
password: opts.password,
auth: {
token: opts.token,
password: opts.password,
},
tlsFingerprint: opts.tlsFingerprint,
method: "status",
timeoutMs: opts.timeoutMs,
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
...(opts.configPath ? { configPath: opts.configPath } : {}),
}),
includeDetails: false,
});
},
);
return { ok: true } as const;
if (result.ok) {
return { ok: true } as const;
}
const closeHint = result.close
? `gateway closed (${result.close.code}): ${result.close.reason}`
: null;
return {
ok: false,
error: result.error ?? closeHint ?? "gateway probe failed",
} as const;
} catch (err) {
return {
ok: false,

View File

@@ -196,6 +196,22 @@ describe("gatherDaemonStatus", () => {
expect(status.rpc?.ok).toBe(true);
});
it("forwards requireRpc and configPath to the daemon probe", async () => {
await gatherDaemonStatus({
rpc: {},
probe: true,
requireRpc: true,
deep: false,
});
expect(callGatewayStatusProbe).toHaveBeenCalledWith(
expect.objectContaining({
requireRpc: true,
configPath: "/tmp/openclaw-daemon/openclaw.json",
}),
);
});
it("does not force local TLS fingerprint when probe URL is explicitly overridden", async () => {
const status = await gatherDaemonStatus({
rpc: { url: "wss://override.example:18790" },

View File

@@ -284,6 +284,7 @@ export async function gatherDaemonStatus(
opts: {
rpc: GatewayRpcOpts;
probe: boolean;
requireRpc?: boolean;
deep?: boolean;
} & FindExtraGatewayServicesOptions,
): Promise<DaemonStatus> {
@@ -369,6 +370,7 @@ export async function gatherDaemonStatus(
: undefined,
timeoutMs,
json: opts.rpc.json,
requireRpc: opts.requireRpc,
configPath: daemonConfigSummary.path,
})
: undefined;

View File

@@ -76,6 +76,22 @@ describe("runDaemonStatus", () => {
expect(printDaemonStatus).toHaveBeenCalledTimes(1);
});
it("forwards require-rpc to daemon status gathering", async () => {
await runDaemonStatus({
rpc: {},
probe: true,
requireRpc: true,
json: false,
});
expect(gatherDaemonStatus).toHaveBeenCalledWith({
rpc: {},
probe: true,
requireRpc: true,
deep: false,
});
});
it("rejects require-rpc when probing is disabled", async () => {
await expect(
runDaemonStatus({

View File

@@ -14,6 +14,7 @@ export async function runDaemonStatus(opts: DaemonStatusOptions) {
const status = await gatherDaemonStatus({
rpc: opts.rpc,
probe: Boolean(opts.probe),
requireRpc: Boolean(opts.requireRpc),
deep: Boolean(opts.deep),
});
printDaemonStatus(status, { json: Boolean(opts.json) });

View File

@@ -5,8 +5,8 @@ import {
isProbeReachable,
isScopeLimitedProbeFailure,
renderProbeSummaryLine,
resolveAuthForTarget,
resolveProbeBudgetMs,
resolveAuthForTarget,
} from "./helpers.js";
describe("extractConfigSummary", () => {
@@ -274,21 +274,79 @@ describe("probe reachability classification", () => {
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed");
});
});
describe("resolveProbeBudgetMs", () => {
it("lets active local loopback probes use the full caller budget", () => {
expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: true })).toBe(15_000);
expect(resolveProbeBudgetMs(3_000, { kind: "localLoopback", active: true })).toBe(3_000);
expect(
resolveProbeBudgetMs(15_000, {
kind: "localLoopback",
active: true,
url: "ws://127.0.0.1:18789",
}),
).toBe(15_000);
expect(
resolveProbeBudgetMs(3_000, {
kind: "localLoopback",
active: true,
url: "ws://127.0.0.1:18789",
}),
).toBe(3_000);
});
it("keeps inactive local loopback probes on the short cap", () => {
expect(resolveProbeBudgetMs(15_000, { kind: "localLoopback", active: false })).toBe(800);
expect(resolveProbeBudgetMs(500, { kind: "localLoopback", active: false })).toBe(500);
expect(
resolveProbeBudgetMs(15_000, {
kind: "localLoopback",
active: false,
url: "ws://127.0.0.1:18789",
}),
).toBe(800);
expect(
resolveProbeBudgetMs(500, {
kind: "localLoopback",
active: false,
url: "ws://127.0.0.1:18789",
}),
).toBe(500);
});
it("lets explicit loopback URLs use the full caller budget", () => {
expect(
resolveProbeBudgetMs(15_000, {
kind: "explicit",
active: true,
url: "ws://127.0.0.1:18789",
}),
).toBe(15_000);
expect(
resolveProbeBudgetMs(2_500, {
kind: "explicit",
active: true,
url: "wss://localhost:18789/ws",
}),
).toBe(2_500);
});
it("keeps non-local probe caps unchanged", () => {
expect(resolveProbeBudgetMs(15_000, { kind: "configRemote", active: true })).toBe(1_500);
expect(resolveProbeBudgetMs(15_000, { kind: "explicit", active: true })).toBe(1_500);
expect(resolveProbeBudgetMs(15_000, { kind: "sshTunnel", active: true })).toBe(2_000);
expect(
resolveProbeBudgetMs(15_000, {
kind: "configRemote",
active: true,
url: "wss://gateway.example/ws",
}),
).toBe(1500);
expect(
resolveProbeBudgetMs(15_000, {
kind: "explicit",
active: true,
url: "wss://gateway.example/ws",
}),
).toBe(1500);
expect(
resolveProbeBudgetMs(15_000, {
kind: "sshTunnel",
active: true,
url: "wss://gateway.example/ws",
}),
).toBe(2000);
});
});

View File

@@ -3,6 +3,7 @@ import { resolveGatewayPort } from "../../config/config.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../../config/types.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
import { readGatewayPasswordEnv, readGatewayTokenEnv } from "../../gateway/credentials.js";
import { isLoopbackHost } from "../../gateway/net.js";
import type { GatewayProbeResult } from "../../gateway/probe.js";
import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js";
import { inspectBestEffortPrimaryTailnetIPv4 } from "../../infra/network-discovery-display.js";
@@ -116,21 +117,34 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew
return targets;
}
function isLoopbackProbeTarget(target: Pick<GatewayStatusTarget, "kind" | "url">): boolean {
if (target.kind === "localLoopback") {
return true;
}
try {
return isLoopbackHost(new URL(target.url).hostname);
} catch {
return false;
}
}
export function resolveProbeBudgetMs(
overallMs: number,
target: Pick<GatewayStatusTarget, "kind" | "active">,
target: Pick<GatewayStatusTarget, "kind" | "active" | "url">,
): number {
switch (target.kind) {
case "localLoopback":
// Active loopback probes should honor the caller budget because local shells/containers
// can legitimately take longer to connect. Inactive loopback probes stay bounded so
// remote-mode status checks do not stall on an expected local miss.
return target.active ? overallMs : Math.min(800, overallMs);
case "sshTunnel":
return Math.min(2_000, overallMs);
default:
return Math.min(1_500, overallMs);
if (target.kind === "sshTunnel") {
return Math.min(2000, overallMs);
}
if (!isLoopbackProbeTarget(target)) {
return Math.min(1500, overallMs);
}
if (target.kind === "localLoopback" && !target.active) {
return Math.min(800, overallMs);
}
// Active/discovered loopback probes and explicit loopback URLs should honor
// the caller budget because healthy local detail RPCs can legitimately take
// longer than the legacy short caps.
return overallMs;
}
export function sanitizeSshTarget(value: unknown): string | null {

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { DeviceIdentity } from "../infra/device-identity.js";
import { captureEnv } from "../test-utils/env.js";
import {
loadConfigMock as loadConfig,
@@ -8,6 +9,15 @@ import {
resolveGatewayPortMock as resolveGatewayPort,
} from "./gateway-connection.test-mocks.js";
const deviceIdentityState = vi.hoisted(() => ({
value: {
deviceId: "test-device-identity",
publicKeyPem: "test-public-key",
privateKeyPem: "test-private-key",
} satisfies DeviceIdentity,
throwOnLoad: false,
}));
let lastClientOptions: {
url?: string;
token?: string;
@@ -28,6 +38,51 @@ let startMode: StartMode = "hello";
let closeCode = 1006;
let closeReason = "";
let helloMethods: string[] | undefined = ["health", "secrets.resolve"];
vi.mock("./client.js", () => ({
describeGatewayCloseCode: (code: number) => {
if (code === 1000) {
return "normal closure";
}
if (code === 1006) {
return "abnormal closure (no close frame)";
}
return undefined;
},
GatewayClient: class {
constructor(opts: {
url?: string;
token?: string;
password?: string;
scopes?: string[];
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise<void>;
onClose?: (code: number, reason: string) => void;
}) {
lastClientOptions = opts;
}
async request(
method: string,
params: unknown,
opts?: { expectFinal?: boolean; timeoutMs?: number | null },
) {
lastRequestOptions = { method, params, opts };
return { ok: true };
}
start() {
if (startMode === "hello") {
void lastClientOptions?.onHelloOk?.({
features: {
methods: helloMethods,
},
});
} else if (startMode === "close") {
lastClientOptions?.onClose?.(closeCode, closeReason);
}
}
stop() {}
},
}));
const { __testing, buildGatewayConnectionDetails, callGateway, callGatewayCli, callGatewayScoped } =
await import("./call.js");
@@ -84,8 +139,15 @@ function resetGatewayCallMocks() {
createGatewayClient: (opts) =>
new StubGatewayClient(opts as ConstructorParameters<typeof StubGatewayClient>[0]) as never,
loadConfig: loadConfigForTests,
loadOrCreateDeviceIdentity: () => {
if (deviceIdentityState.throwOnLoad) {
throw new Error("read-only identity dir");
}
return deviceIdentityState.value;
},
resolveGatewayPort: resolveGatewayPortForTests,
});
deviceIdentityState.throwOnLoad = false;
}
function setGatewayNetworkDefaults(port = 18789) {
@@ -221,7 +283,22 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.deviceIdentity).toBeDefined();
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value);
});
it("falls back to token/password auth when device identity cannot be persisted", async () => {
setLocalLoopbackGatewayConfig();
deviceIdentityState.throwOnLoad = true;
await callGateway({
method: "health",
token: "explicit-token",
});
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
expect(lastClientOptions?.token).toBe("explicit-token");
expect(lastClientOptions?.deviceIdentity).toBeNull();
expect(lastRequestOptions?.method).toBe("health");
});
it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => {

View File

@@ -85,6 +85,7 @@ const defaultCreateGatewayClient = (opts: GatewayClientOptions) => new GatewayCl
const defaultGatewayCallDeps = {
createGatewayClient: defaultCreateGatewayClient,
loadConfig,
loadOrCreateDeviceIdentity,
resolveGatewayPort,
resolveConfigPath,
resolveStateDir,
@@ -99,6 +100,8 @@ export const __testing = {
gatewayCallDeps.createGatewayClient =
deps?.createGatewayClient ?? defaultGatewayCallDeps.createGatewayClient;
gatewayCallDeps.loadConfig = deps?.loadConfig ?? defaultGatewayCallDeps.loadConfig;
gatewayCallDeps.loadOrCreateDeviceIdentity =
deps?.loadOrCreateDeviceIdentity ?? defaultGatewayCallDeps.loadOrCreateDeviceIdentity;
gatewayCallDeps.resolveGatewayPort =
deps?.resolveGatewayPort ?? defaultGatewayCallDeps.resolveGatewayPort;
gatewayCallDeps.resolveConfigPath =
@@ -115,6 +118,7 @@ export const __testing = {
resetDepsForTests(): void {
gatewayCallDeps.createGatewayClient = defaultGatewayCallDeps.createGatewayClient;
gatewayCallDeps.loadConfig = defaultGatewayCallDeps.loadConfig;
gatewayCallDeps.loadOrCreateDeviceIdentity = defaultGatewayCallDeps.loadOrCreateDeviceIdentity;
gatewayCallDeps.resolveGatewayPort = defaultGatewayCallDeps.resolveGatewayPort;
gatewayCallDeps.resolveConfigPath = defaultGatewayCallDeps.resolveConfigPath;
gatewayCallDeps.resolveStateDir = defaultGatewayCallDeps.resolveStateDir;
@@ -122,17 +126,19 @@ export const __testing = {
},
};
function shouldAttachDeviceIdentityForGatewayCall(params: {
url: string;
token?: string;
password?: string;
}): boolean {
void params;
// Shared-auth local calls used to skip device identity as an optimization, but
// device-less operator connects now have their self-declared scopes stripped.
// Keep identity enabled so local authenticated calls stay device-bound and
// retain their least-privilege scopes.
return true;
function resolveDeviceIdentityForGatewayCall(): ReturnType<
typeof loadOrCreateDeviceIdentity
> | null {
// Shared-auth local calls should still stay device-bound so operator scopes
// remain available for detail RPCs such as status / system-presence /
// last-heartbeat.
try {
return gatewayCallDeps.loadOrCreateDeviceIdentity();
} catch {
// Read-only or restricted environments should still be able to call the
// gateway with token/password auth without crashing before the RPC.
return null;
}
}
export type ExplicitGatewayAuth = {
@@ -868,9 +874,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
role: "operator",
scopes,
deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({ url, token, password })
? loadOrCreateDeviceIdentity()
: undefined,
deviceIdentity: resolveDeviceIdentityForGatewayCall(),
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
onHelloOk: async (hello) => {

View File

@@ -1,10 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const gatewayClientState = vi.hoisted(() => ({
options: null as Record<string, unknown> | null,
requests: [] as string[],
}));
const deviceIdentityState = vi.hoisted(() => ({
value: { id: "test-device-identity" } as Record<string, unknown>,
throwOnLoad: false,
}));
class MockGatewayClient {
private readonly opts: Record<string, unknown>;
@@ -40,15 +45,27 @@ vi.mock("./client.js", () => ({
GatewayClient: MockGatewayClient,
}));
vi.mock("../infra/device-identity.js", () => ({
loadOrCreateDeviceIdentity: () => {
if (deviceIdentityState.throwOnLoad) {
throw new Error("read-only identity dir");
}
return deviceIdentityState.value;
},
}));
const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js");
describe("probeGateway", () => {
beforeEach(() => {
deviceIdentityState.throwOnLoad = false;
});
it("clamps probe timeout to timer-safe bounds", () => {
expect(clampProbeTimeoutMs(1)).toBe(250);
expect(clampProbeTimeoutMs(2_000)).toBe(2_000);
expect(clampProbeTimeoutMs(3_000_000_000)).toBe(2_147_483_647);
});
it("connects with operator.read scope", async () => {
const result = await probeGateway({
url: "ws://127.0.0.1:18789",
@@ -57,7 +74,7 @@ describe("probeGateway", () => {
});
expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]);
expect(gatewayClientState.options?.deviceIdentity).toBeUndefined();
expect(gatewayClientState.options?.deviceIdentity).toEqual(deviceIdentityState.value);
expect(gatewayClientState.requests).toEqual([
"health",
"status",
@@ -74,7 +91,7 @@ describe("probeGateway", () => {
timeoutMs: 1_000,
});
expect(gatewayClientState.options?.deviceIdentity).toBeUndefined();
expect(gatewayClientState.options?.deviceIdentity).toEqual(deviceIdentityState.value);
});
it("keeps device identity disabled for unauthenticated loopback probes", async () => {
@@ -94,9 +111,42 @@ describe("probeGateway", () => {
});
expect(result.ok).toBe(true);
expect(gatewayClientState.options?.deviceIdentity).toBeNull();
expect(gatewayClientState.requests).toEqual([]);
});
it("keeps device identity enabled for authenticated lightweight probes", async () => {
const result = await probeGateway({
url: "ws://127.0.0.1:18789",
auth: { token: "secret" },
timeoutMs: 1_000,
includeDetails: false,
});
expect(result.ok).toBe(true);
expect(gatewayClientState.options?.deviceIdentity).toEqual(deviceIdentityState.value);
expect(gatewayClientState.requests).toEqual([]);
});
it("falls back to token/password auth when device identity cannot be persisted", async () => {
deviceIdentityState.throwOnLoad = true;
const result = await probeGateway({
url: "ws://127.0.0.1:18789",
auth: { token: "secret" },
timeoutMs: 1_000,
});
expect(result.ok).toBe(true);
expect(gatewayClientState.options?.deviceIdentity).toBeNull();
expect(gatewayClientState.requests).toEqual([
"health",
"status",
"system-presence",
"config.get",
]);
});
it("fetches only presence for presence-only probes", async () => {
const result = await probeGateway({
url: "ws://127.0.0.1:18789",
@@ -110,4 +160,16 @@ describe("probeGateway", () => {
expect(result.status).toBeNull();
expect(result.configSnapshot).toBeNull();
});
it("passes through tls fingerprints for secure daemon probes", async () => {
await probeGateway({
url: "wss://gateway.example/ws",
auth: { token: "secret" },
tlsFingerprint: "sha256:abc",
timeoutMs: 1_000,
includeDetails: false,
});
expect(gatewayClientState.options?.tlsFingerprint).toBe("sha256:abc");
});
});

View File

@@ -42,6 +42,7 @@ export async function probeGateway(opts: {
timeoutMs: number;
includeDetails?: boolean;
detailLevel?: "none" | "presence" | "full";
tlsFingerprint?: string;
}): Promise<GatewayProbeResult> {
const startedAt = Date.now();
const instanceId = randomUUID();
@@ -49,19 +50,30 @@ export async function probeGateway(opts: {
let connectError: string | null = null;
let close: GatewayProbeClose | null = null;
const disableDeviceIdentity = (() => {
const detailLevel = opts.includeDetails === false ? "none" : (opts.detailLevel ?? "full");
const deviceIdentity = await (async () => {
let hostname: string;
try {
const hostname = new URL(opts.url).hostname;
// Local authenticated probes should stay device-bound so read/detail RPCs
// are not scope-limited by the shared-auth scope stripping hardening.
return isLoopbackHost(hostname) && !(opts.auth?.token || opts.auth?.password);
hostname = new URL(opts.url).hostname;
} catch {
return false;
return null;
}
// Local authenticated probes should stay device-bound so read/detail RPCs
// are not scope-limited by the shared-auth scope stripping hardening.
if (isLoopbackHost(hostname) && !(opts.auth?.token || opts.auth?.password)) {
return null;
}
try {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
return loadOrCreateDeviceIdentity();
} catch {
// Read-only or restricted environments should still be able to run
// token/password-auth detail probes without crashing on identity persistence.
return null;
}
})();
const detailLevel = opts.includeDetails === false ? "none" : (opts.detailLevel ?? "full");
return await new Promise<GatewayProbeResult>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
@@ -89,12 +101,13 @@ export async function probeGateway(opts: {
url: opts.url,
token: opts.auth?.token,
password: opts.auth?.password,
tlsFingerprint: opts.tlsFingerprint,
scopes: [READ_SCOPE],
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.PROBE,
instanceId,
deviceIdentity: disableDeviceIdentity ? null : undefined,
deviceIdentity,
onConnectError: (err) => {
connectError = formatErrorMessage(err);
},