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:
ClaudeZackaryFair 2026-03-16 09:40:08 +08:00 committed by OpenClaw Assistant
parent d2e8a493a3
commit 618939ef6d
5 changed files with 95 additions and 18 deletions

View File

@ -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,

View File

@ -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);
});
});

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 { 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);

View File

@ -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 () => {

View File

@ -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) => {