mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 18:33:37 +00:00
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:
@@ -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);
|
||||
|
||||
113
src/cli/daemon-cli/probe.test.ts
Normal file
113
src/cli/daemon-cli/probe.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user