diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 76a89372493..f91ac285c20 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -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); diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts new file mode 100644 index 00000000000..ed39db0271e --- /dev/null +++ b/src/cli/daemon-cli/probe.test.ts @@ -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) => 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(); + }); +}); diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index 9398220f097..22ed7d32a99 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -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, diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 301c9b49783..f6ad1e9ae41 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -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" }, diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 3ef45b9686b..aaa367af365 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -284,6 +284,7 @@ export async function gatherDaemonStatus( opts: { rpc: GatewayRpcOpts; probe: boolean; + requireRpc?: boolean; deep?: boolean; } & FindExtraGatewayServicesOptions, ): Promise { @@ -369,6 +370,7 @@ export async function gatherDaemonStatus( : undefined, timeoutMs, json: opts.rpc.json, + requireRpc: opts.requireRpc, configPath: daemonConfigSummary.path, }) : undefined; diff --git a/src/cli/daemon-cli/status.test.ts b/src/cli/daemon-cli/status.test.ts index 5cf0484120e..98f3b8c6c88 100644 --- a/src/cli/daemon-cli/status.test.ts +++ b/src/cli/daemon-cli/status.test.ts @@ -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({ diff --git a/src/cli/daemon-cli/status.ts b/src/cli/daemon-cli/status.ts index 44ae4b0a686..479a4c5f426 100644 --- a/src/cli/daemon-cli/status.ts +++ b/src/cli/daemon-cli/status.ts @@ -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) }); diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index 525b99db98c..76c7f7f94fb 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -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); }); }); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 0a8054729ab..e2597bbc89f 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -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): 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, + target: Pick, ): 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 { diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 615463295c0..585b0581f03 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -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; + 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[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 () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index bb52fa119a2..7b9ae07c975 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -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(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) => { diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 01c69be5199..04f965ab252 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -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 | null, requests: [] as string[], })); +const deviceIdentityState = vi.hoisted(() => ({ + value: { id: "test-device-identity" } as Record, + throwOnLoad: false, +})); + class MockGatewayClient { private readonly opts: Record; @@ -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"); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index f09ad3c997f..5ae951bf908 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -42,6 +42,7 @@ export async function probeGateway(opts: { timeoutMs: number; includeDetails?: boolean; detailLevel?: "none" | "presence" | "full"; + tlsFingerprint?: string; }): Promise { 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((resolve) => { let settled = false; let timer: ReturnType | 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); },