Skip to content

Commit f4fef64

Browse files
joshavantMainframe
andauthored
Gateway: treat scope-limited probe RPC as degraded reachability (#45622)
* Gateway: treat scope-limited probe RPC as degraded * Docs: clarify gateway probe degraded scope output * test: fix CI type regressions in gateway and outbound suites * Tests: fix Node24 diffs theme loading and Windows assertions * Tests: fix extension typing after main rebase * Tests: fix Windows CI regressions after rebase * Tests: normalize executable path assertions on Windows * Tests: remove duplicate gateway daemon result alias * Tests: stabilize Windows approval path assertions * Tests: fix Discord rate-limit startup fixture typing * Tests: use Windows-friendly relative exec fixtures --------- Co-authored-by: Mainframe <mainframe@MainfraacStudio.localdomain>
1 parent f251e7e commit f4fef64

25 files changed

Lines changed: 394 additions & 93 deletions

docs/cli/gateway.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,23 @@ openclaw gateway probe
126126
openclaw gateway probe --json
127127
```
128128

129+
Interpretation:
130+
131+
- `Reachable: yes` means at least one target accepted a WebSocket connect.
132+
- `RPC: ok` means detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded.
133+
- `RPC: limited - missing scope: operator.read` means connect succeeded but detail RPC is scope-limited. This is reported as **degraded** reachability, not full failure.
134+
- Exit code is non-zero only when no probed target is reachable.
135+
136+
JSON notes (`--json`):
137+
138+
- Top level:
139+
- `ok`: at least one target is reachable.
140+
- `degraded`: at least one target had scope-limited detail RPC.
141+
- Per target (`targets[].connect`):
142+
- `ok`: reachability after connect + degraded classification.
143+
- `rpcOk`: full detail RPC success.
144+
- `scopeLimited`: detail RPC failed due to missing operator scope.
145+
129146
#### Remote over SSH (Mac app parity)
130147

131148
The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:<port>`.

docs/help/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Good output in one line:
2828

2929
- `openclaw status` → shows configured channels and no obvious auth errors.
3030
- `openclaw status --all` → full report is present and shareable.
31-
- `openclaw gateway probe` → expected gateway target is reachable.
31+
- `openclaw gateway probe` → expected gateway target is reachable (`Reachable: yes`). `RPC: limited - missing scope: operator.read` is degraded diagnostics, not a connect failure.
3232
- `openclaw gateway status``Runtime: running` and `RPC probe: ok`.
3333
- `openclaw doctor` → no blocking config/service errors.
3434
- `openclaw channels status --probe` → channels report `connected` or `ready`.

extensions/diffs/index.test.ts

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { IncomingMessage } from "node:http";
2+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs";
23
import { describe, expect, it, vi } from "vitest";
34
import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js";
45
import { createTestPluginApi } from "../test-utils/plugin-api.js";
@@ -42,48 +43,46 @@ describe("diffs plugin registration", () => {
4243
});
4344

4445
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
45-
let registeredTool:
46-
| { execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown> }
47-
| undefined;
48-
let registeredHttpRouteHandler:
49-
| ((
50-
req: IncomingMessage,
51-
res: ReturnType<typeof createMockServerResponse>,
52-
) => Promise<boolean>)
53-
| undefined;
46+
type RegisteredTool = {
47+
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
48+
};
49+
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
5450

55-
plugin.register?.(
56-
createTestPluginApi({
57-
id: "diffs",
58-
name: "Diffs",
59-
description: "Diffs",
60-
source: "test",
61-
config: {
62-
gateway: {
63-
port: 18789,
64-
bind: "loopback",
65-
},
66-
},
67-
pluginConfig: {
68-
defaults: {
69-
mode: "view",
70-
theme: "light",
71-
background: false,
72-
layout: "split",
73-
showLineNumbers: false,
74-
diffIndicators: "classic",
75-
lineSpacing: 2,
76-
},
77-
},
78-
runtime: {} as never,
79-
registerTool(tool) {
80-
registeredTool = typeof tool === "function" ? undefined : tool;
51+
let registeredTool: RegisteredTool | undefined;
52+
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
53+
54+
const api = createTestPluginApi({
55+
id: "diffs",
56+
name: "Diffs",
57+
description: "Diffs",
58+
source: "test",
59+
config: {
60+
gateway: {
61+
port: 18789,
62+
bind: "loopback",
8163
},
82-
registerHttpRoute(params) {
83-
registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler;
64+
},
65+
pluginConfig: {
66+
defaults: {
67+
mode: "view",
68+
theme: "light",
69+
background: false,
70+
layout: "split",
71+
showLineNumbers: false,
72+
diffIndicators: "classic",
73+
lineSpacing: 2,
8474
},
85-
}),
86-
);
75+
},
76+
runtime: {} as never,
77+
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
78+
registeredTool = typeof tool === "function" ? undefined : tool;
79+
},
80+
registerHttpRoute(params: RegisteredHttpRouteParams) {
81+
registeredHttpRouteHandler = params.handler;
82+
},
83+
});
84+
85+
plugin.register?.(api as unknown as OpenClawPluginApi);
8786

8887
const result = await registeredTool?.execute?.("tool-1", {
8988
before: "one\n",

extensions/diffs/src/render.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
2-
import { parsePatchFiles } from "@pierre/diffs";
1+
import fs from "node:fs/promises";
2+
import { createRequire } from "node:module";
3+
import type {
4+
FileContents,
5+
FileDiffMetadata,
6+
SupportedLanguages,
7+
ThemeRegistrationResolved,
8+
} from "@pierre/diffs";
9+
import { RegisteredCustomThemes, parsePatchFiles } from "@pierre/diffs";
310
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
411
import type {
512
DiffInput,
@@ -13,6 +20,45 @@ import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
1320
const DEFAULT_FILE_NAME = "diff.txt";
1421
const MAX_PATCH_FILE_COUNT = 128;
1522
const MAX_PATCH_TOTAL_LINES = 120_000;
23+
const diffsRequire = createRequire(import.meta.resolve("@pierre/diffs"));
24+
25+
let pierreThemesPatched = false;
26+
27+
function createThemeLoader(
28+
themeName: "pierre-dark" | "pierre-light",
29+
themePath: string,
30+
): () => Promise<ThemeRegistrationResolved> {
31+
let cachedTheme: ThemeRegistrationResolved | undefined;
32+
return async () => {
33+
if (cachedTheme) {
34+
return cachedTheme;
35+
}
36+
const raw = await fs.readFile(themePath, "utf8");
37+
const parsed = JSON.parse(raw) as Record<string, unknown>;
38+
cachedTheme = {
39+
...parsed,
40+
name: themeName,
41+
} as ThemeRegistrationResolved;
42+
return cachedTheme;
43+
};
44+
}
45+
46+
function patchPierreThemeLoadersForNode24(): void {
47+
if (pierreThemesPatched) {
48+
return;
49+
}
50+
try {
51+
const darkThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-dark.json");
52+
const lightThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-light.json");
53+
RegisteredCustomThemes.set("pierre-dark", createThemeLoader("pierre-dark", darkThemePath));
54+
RegisteredCustomThemes.set("pierre-light", createThemeLoader("pierre-light", lightThemePath));
55+
pierreThemesPatched = true;
56+
} catch {
57+
// Keep upstream loaders if theme files cannot be resolved.
58+
}
59+
}
60+
61+
patchPierreThemeLoadersForNode24();
1662

1763
function escapeCssString(value: string): string {
1864
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');

src/commands/gateway-status.test.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, vi } from "vitest";
2+
import type { GatewayProbeResult } from "../gateway/probe.js";
23
import type { RuntimeEnv } from "../runtime.js";
34
import { withEnvAsync } from "../test-utils/env.js";
45

@@ -33,7 +34,7 @@ const startSshPortForward = vi.fn(async (_opts?: unknown) => ({
3334
stderr: [],
3435
stop: sshStop,
3536
}));
36-
const probeGateway = vi.fn(async (opts: { url: string }) => {
37+
const probeGateway = vi.fn(async (opts: { url: string }): Promise<GatewayProbeResult> => {
3738
const { url } = opts;
3839
if (url.includes("127.0.0.1")) {
3940
return {
@@ -52,7 +53,16 @@ const probeGateway = vi.fn(async (opts: { url: string }) => {
5253
},
5354
sessions: { count: 0 },
5455
},
55-
presence: [{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" }],
56+
presence: [
57+
{
58+
mode: "gateway",
59+
reason: "self",
60+
host: "local",
61+
ip: "127.0.0.1",
62+
text: "Gateway: local (127.0.0.1) · app test · mode gateway · reason self",
63+
ts: Date.now(),
64+
},
65+
],
5666
configSnapshot: {
5767
path: "/tmp/cfg.json",
5868
exists: true,
@@ -81,7 +91,16 @@ const probeGateway = vi.fn(async (opts: { url: string }) => {
8191
},
8292
sessions: { count: 2 },
8393
},
84-
presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }],
94+
presence: [
95+
{
96+
mode: "gateway",
97+
reason: "self",
98+
host: "remote",
99+
ip: "100.64.0.2",
100+
text: "Gateway: remote (100.64.0.2) · app test · mode gateway · reason self",
101+
ts: Date.now(),
102+
},
103+
],
85104
configSnapshot: {
86105
path: "/tmp/remote.json",
87106
exists: true,
@@ -201,6 +220,54 @@ describe("gateway-status command", () => {
201220
expect(targets[0]?.summary).toBeTruthy();
202221
});
203222

223+
it("treats missing-scope RPC probe failures as degraded but reachable", async () => {
224+
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
225+
readBestEffortConfig.mockResolvedValueOnce({
226+
gateway: {
227+
mode: "local",
228+
auth: { mode: "token", token: "ltok" },
229+
},
230+
} as never);
231+
probeGateway.mockResolvedValueOnce({
232+
ok: false,
233+
url: "ws://127.0.0.1:18789",
234+
connectLatencyMs: 51,
235+
error: "missing scope: operator.read",
236+
close: null,
237+
health: null,
238+
status: null,
239+
presence: null,
240+
configSnapshot: null,
241+
});
242+
243+
await runGatewayStatus(runtime, { timeout: "1000", json: true });
244+
245+
expect(runtimeErrors).toHaveLength(0);
246+
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
247+
ok?: boolean;
248+
degraded?: boolean;
249+
warnings?: Array<{ code?: string; targetIds?: string[] }>;
250+
targets?: Array<{
251+
connect?: {
252+
ok?: boolean;
253+
rpcOk?: boolean;
254+
scopeLimited?: boolean;
255+
};
256+
}>;
257+
};
258+
expect(parsed.ok).toBe(true);
259+
expect(parsed.degraded).toBe(true);
260+
expect(parsed.targets?.[0]?.connect).toMatchObject({
261+
ok: true,
262+
rpcOk: false,
263+
scopeLimited: true,
264+
});
265+
const scopeLimitedWarning = parsed.warnings?.find(
266+
(warning) => warning.code === "probe_scope_limited",
267+
);
268+
expect(scopeLimitedWarning?.targetIds).toContain("localLoopback");
269+
});
270+
204271
it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
205272
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
206273
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
@@ -361,7 +428,16 @@ describe("gateway-status command", () => {
361428
},
362429
sessions: { count: 1 },
363430
},
364-
presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }],
431+
presence: [
432+
{
433+
mode: "gateway",
434+
reason: "self",
435+
host: "remote",
436+
ip: "100.64.0.2",
437+
text: "Gateway: remote (100.64.0.2) · app test · mode gateway · reason self",
438+
ts: Date.now(),
439+
},
440+
],
365441
configSnapshot: {
366442
path: "/tmp/secretref-config.json",
367443
exists: true,

src/commands/gateway-status.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
1010
import {
1111
buildNetworkHints,
1212
extractConfigSummary,
13+
isProbeReachable,
14+
isScopeLimitedProbeFailure,
1315
type GatewayStatusTarget,
1416
parseTimeoutMs,
1517
pickGatewaySelfPresence,
@@ -193,8 +195,10 @@ export async function gatewayStatusCommand(
193195
},
194196
);
195197

196-
const reachable = probed.filter((p) => p.probe.ok);
198+
const reachable = probed.filter((p) => isProbeReachable(p.probe));
197199
const ok = reachable.length > 0;
200+
const degradedScopeLimited = probed.filter((p) => isScopeLimitedProbeFailure(p.probe));
201+
const degraded = degradedScopeLimited.length > 0;
198202
const multipleGateways = reachable.length > 1;
199203
const primary =
200204
reachable.find((p) => p.target.kind === "explicit") ??
@@ -236,12 +240,21 @@ export async function gatewayStatusCommand(
236240
});
237241
}
238242
}
243+
for (const result of degradedScopeLimited) {
244+
warnings.push({
245+
code: "probe_scope_limited",
246+
message:
247+
"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.",
248+
targetIds: [result.target.id],
249+
});
250+
}
239251

240252
if (opts.json) {
241253
runtime.log(
242254
JSON.stringify(
243255
{
244256
ok,
257+
degraded,
245258
ts: Date.now(),
246259
durationMs: Date.now() - startedAt,
247260
timeoutMs: overallTimeoutMs,
@@ -274,7 +287,9 @@ export async function gatewayStatusCommand(
274287
active: p.target.active,
275288
tunnel: p.target.tunnel ?? null,
276289
connect: {
277-
ok: p.probe.ok,
290+
ok: isProbeReachable(p.probe),
291+
rpcOk: p.probe.ok,
292+
scopeLimited: isScopeLimitedProbeFailure(p.probe),
278293
latencyMs: p.probe.connectLatencyMs,
279294
error: p.probe.error,
280295
close: p.probe.close,

0 commit comments

Comments
 (0)