mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-26 01:11:37 +00:00
Gateway: treat scope-limited probe RPC as degraded
This commit is contained in:
@@ -201,6 +201,54 @@ describe("gateway-status command", () => {
|
||||
expect(targets[0]?.summary).toBeTruthy();
|
||||
});
|
||||
|
||||
it("treats missing-scope RPC probe failures as degraded but reachable", async () => {
|
||||
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||
readBestEffortConfig.mockResolvedValueOnce({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
auth: { mode: "token", token: "ltok" },
|
||||
},
|
||||
} as never);
|
||||
probeGateway.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: 51,
|
||||
error: "missing scope: operator.read",
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
|
||||
await runGatewayStatus(runtime, { timeout: "1000", json: true });
|
||||
|
||||
expect(runtimeErrors).toHaveLength(0);
|
||||
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
|
||||
ok?: boolean;
|
||||
degraded?: boolean;
|
||||
warnings?: Array<{ code?: string; targetIds?: string[] }>;
|
||||
targets?: Array<{
|
||||
connect?: {
|
||||
ok?: boolean;
|
||||
rpcOk?: boolean;
|
||||
scopeLimited?: boolean;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.degraded).toBe(true);
|
||||
expect(parsed.targets?.[0]?.connect).toMatchObject({
|
||||
ok: true,
|
||||
rpcOk: false,
|
||||
scopeLimited: true,
|
||||
});
|
||||
const scopeLimitedWarning = parsed.warnings?.find(
|
||||
(warning) => warning.code === "probe_scope_limited",
|
||||
);
|
||||
expect(scopeLimitedWarning?.targetIds).toContain("localLoopback");
|
||||
});
|
||||
|
||||
it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
|
||||
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
import {
|
||||
buildNetworkHints,
|
||||
extractConfigSummary,
|
||||
isProbeReachable,
|
||||
isScopeLimitedProbeFailure,
|
||||
type GatewayStatusTarget,
|
||||
parseTimeoutMs,
|
||||
pickGatewaySelfPresence,
|
||||
@@ -193,8 +195,10 @@ export async function gatewayStatusCommand(
|
||||
},
|
||||
);
|
||||
|
||||
const reachable = probed.filter((p) => p.probe.ok);
|
||||
const reachable = probed.filter((p) => isProbeReachable(p.probe));
|
||||
const ok = reachable.length > 0;
|
||||
const degradedScopeLimited = probed.filter((p) => isScopeLimitedProbeFailure(p.probe));
|
||||
const degraded = degradedScopeLimited.length > 0;
|
||||
const multipleGateways = reachable.length > 1;
|
||||
const primary =
|
||||
reachable.find((p) => p.target.kind === "explicit") ??
|
||||
@@ -236,12 +240,21 @@ export async function gatewayStatusCommand(
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const result of degradedScopeLimited) {
|
||||
warnings.push({
|
||||
code: "probe_scope_limited",
|
||||
message:
|
||||
"Probe diagnostics are limited by gateway scopes (missing operator.read). Connection succeeded, but status details may be incomplete. Hint: pair device identity or use credentials with operator.read.",
|
||||
targetIds: [result.target.id],
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok,
|
||||
degraded,
|
||||
ts: Date.now(),
|
||||
durationMs: Date.now() - startedAt,
|
||||
timeoutMs: overallTimeoutMs,
|
||||
@@ -274,7 +287,9 @@ export async function gatewayStatusCommand(
|
||||
active: p.target.active,
|
||||
tunnel: p.target.tunnel ?? null,
|
||||
connect: {
|
||||
ok: p.probe.ok,
|
||||
ok: isProbeReachable(p.probe),
|
||||
rpcOk: p.probe.ok,
|
||||
scopeLimited: isScopeLimitedProbeFailure(p.probe),
|
||||
latencyMs: p.probe.connectLatencyMs,
|
||||
error: p.probe.error,
|
||||
close: p.probe.close,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js";
|
||||
import {
|
||||
extractConfigSummary,
|
||||
isProbeReachable,
|
||||
isScopeLimitedProbeFailure,
|
||||
renderProbeSummaryLine,
|
||||
resolveAuthForTarget,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("extractConfigSummary", () => {
|
||||
it("marks SecretRef-backed gateway auth credentials as configured", () => {
|
||||
@@ -229,3 +235,41 @@ describe("resolveAuthForTarget", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probe reachability classification", () => {
|
||||
it("treats missing-scope RPC failures as scope-limited and reachable", () => {
|
||||
const probe = {
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: 51,
|
||||
error: "missing scope: operator.read",
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
|
||||
expect(isScopeLimitedProbeFailure(probe)).toBe(true);
|
||||
expect(isProbeReachable(probe)).toBe(true);
|
||||
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: limited");
|
||||
});
|
||||
|
||||
it("keeps non-scope RPC failures as unreachable", () => {
|
||||
const probe = {
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: 43,
|
||||
error: "unknown method: status",
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
|
||||
expect(isScopeLimitedProbeFailure(probe)).toBe(false);
|
||||
expect(isProbeReachable(probe)).toBe(false);
|
||||
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
|
||||
import { colorize, theme } from "../../terminal/theme.js";
|
||||
import { pickGatewaySelfPresence } from "../gateway-presence.js";
|
||||
|
||||
const MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i;
|
||||
|
||||
type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
|
||||
|
||||
export type GatewayStatusTarget = {
|
||||
@@ -324,6 +326,17 @@ export function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
|
||||
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
|
||||
}
|
||||
|
||||
export function isScopeLimitedProbeFailure(probe: GatewayProbeResult): boolean {
|
||||
if (probe.ok || probe.connectLatencyMs == null) {
|
||||
return false;
|
||||
}
|
||||
return MISSING_SCOPE_PATTERN.test(probe.error ?? "");
|
||||
}
|
||||
|
||||
export function isProbeReachable(probe: GatewayProbeResult): boolean {
|
||||
return probe.ok || isScopeLimitedProbeFailure(probe);
|
||||
}
|
||||
|
||||
export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
|
||||
if (probe.ok) {
|
||||
const latency =
|
||||
@@ -335,7 +348,10 @@ export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean)
|
||||
if (probe.connectLatencyMs != null) {
|
||||
const latency =
|
||||
typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown";
|
||||
return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.error, "RPC: failed")}${detail}`;
|
||||
const rpcStatus = isScopeLimitedProbeFailure(probe)
|
||||
? colorize(rich, theme.warn, "RPC: limited")
|
||||
: colorize(rich, theme.error, "RPC: failed");
|
||||
return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${rpcStatus}${detail}`;
|
||||
}
|
||||
|
||||
return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;
|
||||
|
||||
Reference in New Issue
Block a user