fix(gateway): cover explicit loopback probes and call fallback
Cherry-picked-by-hand from heavenlost/openclaw@d2b0ea9906 after review and local test verification.
This commit is contained in:
parent
d2e8a493a3
commit
618939ef6d
@ -162,7 +162,7 @@ export async function gatewayStatusCommand(
|
||||
token: authResolution.token,
|
||||
password: authResolution.password,
|
||||
};
|
||||
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
|
||||
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target);
|
||||
const probe = await probeGateway({
|
||||
url: target.url,
|
||||
auth,
|
||||
|
||||
@ -276,10 +276,42 @@ describe("probe reachability classification", () => {
|
||||
});
|
||||
|
||||
describe("resolveProbeBudgetMs", () => {
|
||||
it("gives local loopback probes enough time for detail RPCs", () => {
|
||||
expect(resolveProbeBudgetMs(10_000, "localLoopback")).toBe(3000);
|
||||
expect(resolveProbeBudgetMs(1200, "localLoopback")).toBe(1200);
|
||||
expect(resolveProbeBudgetMs(10_000, "sshTunnel")).toBe(2000);
|
||||
expect(resolveProbeBudgetMs(10_000, "explicit")).toBe(1500);
|
||||
it("gives loopback probes enough time for detail RPCs", () => {
|
||||
expect(
|
||||
resolveProbeBudgetMs(10_000, {
|
||||
kind: "localLoopback",
|
||||
url: "ws://127.0.0.1:18789",
|
||||
}),
|
||||
).toBe(3000);
|
||||
expect(
|
||||
resolveProbeBudgetMs(1200, {
|
||||
kind: "localLoopback",
|
||||
url: "ws://127.0.0.1:18789",
|
||||
}),
|
||||
).toBe(1200);
|
||||
expect(
|
||||
resolveProbeBudgetMs(10_000, {
|
||||
kind: "explicit",
|
||||
url: "ws://127.0.0.1:18789",
|
||||
}),
|
||||
).toBe(3000);
|
||||
expect(
|
||||
resolveProbeBudgetMs(10_000, {
|
||||
kind: "explicit",
|
||||
url: "wss://localhost:18789/ws",
|
||||
}),
|
||||
).toBe(3000);
|
||||
expect(
|
||||
resolveProbeBudgetMs(10_000, {
|
||||
kind: "explicit",
|
||||
url: "wss://gateway.example/ws",
|
||||
}),
|
||||
).toBe(1500);
|
||||
expect(
|
||||
resolveProbeBudgetMs(10_000, {
|
||||
kind: "sshTunnel",
|
||||
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 { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
|
||||
@ -116,13 +117,28 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number {
|
||||
if (kind === "localLoopback") {
|
||||
// Full localhost detail probes can take longer than the old 800ms budget,
|
||||
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" | "url">,
|
||||
): number {
|
||||
if (isLoopbackProbeTarget(target)) {
|
||||
// Full localhost detail probes can take longer than the old short budgets,
|
||||
// especially when they exercise status + heartbeat + presence RPCs.
|
||||
// Treat explicit loopback URLs the same way as discovered local loopback.
|
||||
return Math.min(3000, overallMs);
|
||||
}
|
||||
if (kind === "sshTunnel") {
|
||||
if (target.kind === "sshTunnel") {
|
||||
return Math.min(2000, overallMs);
|
||||
}
|
||||
return Math.min(1500, overallMs);
|
||||
|
||||
@ -8,6 +8,11 @@ import {
|
||||
resolveGatewayPortMock as resolveGatewayPort,
|
||||
} from "./gateway-connection.test-mocks.js";
|
||||
|
||||
const deviceIdentityState = vi.hoisted(() => ({
|
||||
value: { id: "test-device-identity" } as Record<string, unknown>,
|
||||
throwOnLoad: false,
|
||||
}));
|
||||
|
||||
let lastClientOptions: {
|
||||
url?: string;
|
||||
token?: string;
|
||||
@ -28,7 +33,6 @@ let startMode: StartMode = "hello";
|
||||
let closeCode = 1006;
|
||||
let closeReason = "";
|
||||
let helloMethods: string[] | undefined = ["health", "secrets.resolve"];
|
||||
const deviceIdentityMarker = { id: "test-device-identity" };
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
describeGatewayCloseCode: (code: number) => {
|
||||
@ -75,7 +79,12 @@ vi.mock("./client.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../infra/device-identity.js", () => ({
|
||||
loadOrCreateDeviceIdentity: () => deviceIdentityMarker,
|
||||
loadOrCreateDeviceIdentity: () => {
|
||||
if (deviceIdentityState.throwOnLoad) {
|
||||
throw new Error("read-only identity dir");
|
||||
}
|
||||
return deviceIdentityState.value;
|
||||
},
|
||||
}));
|
||||
|
||||
const { buildGatewayConnectionDetails, callGateway, callGatewayCli, callGatewayScoped } =
|
||||
@ -92,6 +101,7 @@ function resetGatewayCallMocks() {
|
||||
closeCode = 1006;
|
||||
closeReason = "";
|
||||
helloMethods = ["health", "secrets.resolve"];
|
||||
deviceIdentityState.throwOnLoad = false;
|
||||
}
|
||||
|
||||
function setGatewayNetworkDefaults(port = 18789) {
|
||||
@ -224,7 +234,22 @@ describe("callGateway url resolution", () => {
|
||||
|
||||
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789");
|
||||
expect(lastClientOptions?.token).toBe("explicit-token");
|
||||
expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityMarker);
|
||||
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 () => {
|
||||
|
||||
@ -81,11 +81,17 @@ export type GatewayConnectionDetails = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
function shouldAttachDeviceIdentityForGatewayCall(): boolean {
|
||||
function resolveDeviceIdentityForGatewayCall() {
|
||||
// Even when local CLI calls authenticate with a shared gateway token/password,
|
||||
// we still want to attach device identity so paired operator scopes remain
|
||||
// available for detail RPCs such as status / system-presence / last-heartbeat.
|
||||
return true;
|
||||
try {
|
||||
return 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 = {
|
||||
@ -825,9 +831,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
|
||||
mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI,
|
||||
role: "operator",
|
||||
scopes,
|
||||
deviceIdentity: shouldAttachDeviceIdentityForGatewayCall()
|
||||
? loadOrCreateDeviceIdentity()
|
||||
: undefined,
|
||||
deviceIdentity: resolveDeviceIdentityForGatewayCall(),
|
||||
minProtocol: opts.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||
onHelloOk: async (hello) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user