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>
This commit is contained in:
Josh Avant 2026-03-13 23:13:33 -05:00 committed by GitHub
parent f251e7e2c2
commit f4fef64fc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 394 additions and 93 deletions

View File

@ -126,6 +126,23 @@ openclaw gateway probe
openclaw gateway probe --json openclaw gateway probe --json
``` ```
Interpretation:
- `Reachable: yes` means at least one target accepted a WebSocket connect.
- `RPC: ok` means detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded.
- `RPC: limited - missing scope: operator.read` means connect succeeded but detail RPC is scope-limited. This is reported as **degraded** reachability, not full failure.
- Exit code is non-zero only when no probed target is reachable.
JSON notes (`--json`):
- Top level:
- `ok`: at least one target is reachable.
- `degraded`: at least one target had scope-limited detail RPC.
- Per target (`targets[].connect`):
- `ok`: reachability after connect + degraded classification.
- `rpcOk`: full detail RPC success.
- `scopeLimited`: detail RPC failed due to missing operator scope.
#### Remote over SSH (Mac app parity) #### Remote over SSH (Mac app parity)
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>`. 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>`.

View File

@ -28,7 +28,7 @@ Good output in one line:
- `openclaw status` → shows configured channels and no obvious auth errors. - `openclaw status` → shows configured channels and no obvious auth errors.
- `openclaw status --all` → full report is present and shareable. - `openclaw status --all` → full report is present and shareable.
- `openclaw gateway probe` → expected gateway target is reachable. - `openclaw gateway probe` → expected gateway target is reachable (`Reachable: yes`). `RPC: limited - missing scope: operator.read` is degraded diagnostics, not a connect failure.
- `openclaw gateway status``Runtime: running` and `RPC probe: ok`. - `openclaw gateway status``Runtime: running` and `RPC probe: ok`.
- `openclaw doctor` → no blocking config/service errors. - `openclaw doctor` → no blocking config/service errors.
- `openclaw channels status --probe` → channels report `connected` or `ready`. - `openclaw channels status --probe` → channels report `connected` or `ready`.

View File

@ -1,4 +1,5 @@
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js"; import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js";
import { createTestPluginApi } from "../test-utils/plugin-api.js"; import { createTestPluginApi } from "../test-utils/plugin-api.js";
@ -42,48 +43,46 @@ describe("diffs plugin registration", () => {
}); });
it("applies plugin-config defaults through registered tool and viewer handler", async () => { it("applies plugin-config defaults through registered tool and viewer handler", async () => {
let registeredTool: type RegisteredTool = {
| { execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown> } execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
| undefined; };
let registeredHttpRouteHandler: type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
| ((
req: IncomingMessage,
res: ReturnType<typeof createMockServerResponse>,
) => Promise<boolean>)
| undefined;
plugin.register?.( let registeredTool: RegisteredTool | undefined;
createTestPluginApi({ let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
id: "diffs",
name: "Diffs", const api = createTestPluginApi({
description: "Diffs", id: "diffs",
source: "test", name: "Diffs",
config: { description: "Diffs",
gateway: { source: "test",
port: 18789, config: {
bind: "loopback", gateway: {
}, port: 18789,
bind: "loopback",
}, },
pluginConfig: { },
defaults: { pluginConfig: {
mode: "view", defaults: {
theme: "light", mode: "view",
background: false, theme: "light",
layout: "split", background: false,
showLineNumbers: false, layout: "split",
diffIndicators: "classic", showLineNumbers: false,
lineSpacing: 2, diffIndicators: "classic",
}, lineSpacing: 2,
}, },
runtime: {} as never, },
registerTool(tool) { runtime: {} as never,
registeredTool = typeof tool === "function" ? undefined : tool; registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
}, registeredTool = typeof tool === "function" ? undefined : tool;
registerHttpRoute(params) { },
registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler; registerHttpRoute(params: RegisteredHttpRouteParams) {
}, registeredHttpRouteHandler = params.handler;
}), },
); });
plugin.register?.(api as unknown as OpenClawPluginApi);
const result = await registeredTool?.execute?.("tool-1", { const result = await registeredTool?.execute?.("tool-1", {
before: "one\n", before: "one\n",

View File

@ -1,5 +1,12 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs"; import fs from "node:fs/promises";
import { parsePatchFiles } from "@pierre/diffs"; import { createRequire } from "node:module";
import type {
FileContents,
FileDiffMetadata,
SupportedLanguages,
ThemeRegistrationResolved,
} from "@pierre/diffs";
import { RegisteredCustomThemes, parsePatchFiles } from "@pierre/diffs";
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr"; import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
import type { import type {
DiffInput, DiffInput,
@ -13,6 +20,45 @@ import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
const DEFAULT_FILE_NAME = "diff.txt"; const DEFAULT_FILE_NAME = "diff.txt";
const MAX_PATCH_FILE_COUNT = 128; const MAX_PATCH_FILE_COUNT = 128;
const MAX_PATCH_TOTAL_LINES = 120_000; const MAX_PATCH_TOTAL_LINES = 120_000;
const diffsRequire = createRequire(import.meta.resolve("@pierre/diffs"));
let pierreThemesPatched = false;
function createThemeLoader(
themeName: "pierre-dark" | "pierre-light",
themePath: string,
): () => Promise<ThemeRegistrationResolved> {
let cachedTheme: ThemeRegistrationResolved | undefined;
return async () => {
if (cachedTheme) {
return cachedTheme;
}
const raw = await fs.readFile(themePath, "utf8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
cachedTheme = {
...parsed,
name: themeName,
} as ThemeRegistrationResolved;
return cachedTheme;
};
}
function patchPierreThemeLoadersForNode24(): void {
if (pierreThemesPatched) {
return;
}
try {
const darkThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-dark.json");
const lightThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-light.json");
RegisteredCustomThemes.set("pierre-dark", createThemeLoader("pierre-dark", darkThemePath));
RegisteredCustomThemes.set("pierre-light", createThemeLoader("pierre-light", lightThemePath));
pierreThemesPatched = true;
} catch {
// Keep upstream loaders if theme files cannot be resolved.
}
}
patchPierreThemeLoadersForNode24();
function escapeCssString(value: string): string { function escapeCssString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');

View File

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { GatewayProbeResult } from "../gateway/probe.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { withEnvAsync } from "../test-utils/env.js"; import { withEnvAsync } from "../test-utils/env.js";
@ -33,7 +34,7 @@ const startSshPortForward = vi.fn(async (_opts?: unknown) => ({
stderr: [], stderr: [],
stop: sshStop, stop: sshStop,
})); }));
const probeGateway = vi.fn(async (opts: { url: string }) => { const probeGateway = vi.fn(async (opts: { url: string }): Promise<GatewayProbeResult> => {
const { url } = opts; const { url } = opts;
if (url.includes("127.0.0.1")) { if (url.includes("127.0.0.1")) {
return { return {
@ -52,7 +53,16 @@ const probeGateway = vi.fn(async (opts: { url: string }) => {
}, },
sessions: { count: 0 }, sessions: { count: 0 },
}, },
presence: [{ mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" }], presence: [
{
mode: "gateway",
reason: "self",
host: "local",
ip: "127.0.0.1",
text: "Gateway: local (127.0.0.1) · app test · mode gateway · reason self",
ts: Date.now(),
},
],
configSnapshot: { configSnapshot: {
path: "/tmp/cfg.json", path: "/tmp/cfg.json",
exists: true, exists: true,
@ -81,7 +91,16 @@ const probeGateway = vi.fn(async (opts: { url: string }) => {
}, },
sessions: { count: 2 }, sessions: { count: 2 },
}, },
presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }], presence: [
{
mode: "gateway",
reason: "self",
host: "remote",
ip: "100.64.0.2",
text: "Gateway: remote (100.64.0.2) · app test · mode gateway · reason self",
ts: Date.now(),
},
],
configSnapshot: { configSnapshot: {
path: "/tmp/remote.json", path: "/tmp/remote.json",
exists: true, exists: true,
@ -201,6 +220,54 @@ describe("gateway-status command", () => {
expect(targets[0]?.summary).toBeTruthy(); 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 () => { it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
@ -361,7 +428,16 @@ describe("gateway-status command", () => {
}, },
sessions: { count: 1 }, sessions: { count: 1 },
}, },
presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }], presence: [
{
mode: "gateway",
reason: "self",
host: "remote",
ip: "100.64.0.2",
text: "Gateway: remote (100.64.0.2) · app test · mode gateway · reason self",
ts: Date.now(),
},
],
configSnapshot: { configSnapshot: {
path: "/tmp/secretref-config.json", path: "/tmp/secretref-config.json",
exists: true, exists: true,

View File

@ -10,6 +10,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
import { import {
buildNetworkHints, buildNetworkHints,
extractConfigSummary, extractConfigSummary,
isProbeReachable,
isScopeLimitedProbeFailure,
type GatewayStatusTarget, type GatewayStatusTarget,
parseTimeoutMs, parseTimeoutMs,
pickGatewaySelfPresence, 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 ok = reachable.length > 0;
const degradedScopeLimited = probed.filter((p) => isScopeLimitedProbeFailure(p.probe));
const degraded = degradedScopeLimited.length > 0;
const multipleGateways = reachable.length > 1; const multipleGateways = reachable.length > 1;
const primary = const primary =
reachable.find((p) => p.target.kind === "explicit") ?? 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) { if (opts.json) {
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
{ {
ok, ok,
degraded,
ts: Date.now(), ts: Date.now(),
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
timeoutMs: overallTimeoutMs, timeoutMs: overallTimeoutMs,
@ -274,7 +287,9 @@ export async function gatewayStatusCommand(
active: p.target.active, active: p.target.active,
tunnel: p.target.tunnel ?? null, tunnel: p.target.tunnel ?? null,
connect: { connect: {
ok: p.probe.ok, ok: isProbeReachable(p.probe),
rpcOk: p.probe.ok,
scopeLimited: isScopeLimitedProbeFailure(p.probe),
latencyMs: p.probe.connectLatencyMs, latencyMs: p.probe.connectLatencyMs,
error: p.probe.error, error: p.probe.error,
close: p.probe.close, close: p.probe.close,

View File

@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../../test-utils/env.js"; import { withEnvAsync } from "../../test-utils/env.js";
import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js"; import {
extractConfigSummary,
isProbeReachable,
isScopeLimitedProbeFailure,
renderProbeSummaryLine,
resolveAuthForTarget,
} from "./helpers.js";
describe("extractConfigSummary", () => { describe("extractConfigSummary", () => {
it("marks SecretRef-backed gateway auth credentials as configured", () => { 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");
});
});

View File

@ -9,6 +9,8 @@ import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
import { colorize, theme } from "../../terminal/theme.js"; import { colorize, theme } from "../../terminal/theme.js";
import { pickGatewaySelfPresence } from "../gateway-presence.js"; import { pickGatewaySelfPresence } from "../gateway-presence.js";
const MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i;
type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel"; type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
export type GatewayStatusTarget = { 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)}`; 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) { export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
if (probe.ok) { if (probe.ok) {
const latency = const latency =
@ -335,7 +348,10 @@ export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean)
if (probe.connectLatencyMs != null) { if (probe.connectLatencyMs != null) {
const latency = const latency =
typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown"; 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}`; return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;

View File

@ -59,6 +59,14 @@ function expectStartupFallbackSpawn(env: Record<string, string>) {
); );
} }
function expectGatewayTermination(pid: number) {
if (process.platform === "win32") {
expect(killProcessTree).not.toHaveBeenCalled();
return;
}
expect(killProcessTree).toHaveBeenCalledWith(pid, { graceMs: 300 });
}
function addStartupFallbackMissingResponses( function addStartupFallbackMissingResponses(
extraResponses: Array<{ code: number; stdout: string; stderr: string }> = [], extraResponses: Array<{ code: number; stdout: string; stderr: string }> = [],
) { ) {
@ -179,7 +187,7 @@ describe("Windows startup fallback", () => {
await expect(restartScheduledTask({ env, stdout })).resolves.toEqual({ await expect(restartScheduledTask({ env, stdout })).resolves.toEqual({
outcome: "completed", outcome: "completed",
}); });
expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); expectGatewayTermination(5151);
expectStartupFallbackSpawn(env); expectStartupFallbackSpawn(env);
}); });
}); });
@ -214,7 +222,7 @@ describe("Windows startup fallback", () => {
delete envWithoutPort.OPENCLAW_GATEWAY_PORT; delete envWithoutPort.OPENCLAW_GATEWAY_PORT;
await stopScheduledTask({ env: envWithoutPort, stdout }); await stopScheduledTask({ env: envWithoutPort, stdout });
expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); expectGatewayTermination(5151);
}); });
}); });
}); });

View File

@ -59,6 +59,14 @@ function busyPortUsage(
}; };
} }
function expectGatewayTermination(pid: number) {
if (process.platform === "win32") {
expect(killProcessTree).not.toHaveBeenCalled();
return;
}
expect(killProcessTree).toHaveBeenCalledWith(pid, { graceMs: 300 });
}
async function withPreparedGatewayTask( async function withPreparedGatewayTask(
run: (context: { env: Record<string, string>; stdout: PassThrough }) => Promise<void>, run: (context: { env: Record<string, string>; stdout: PassThrough }) => Promise<void>,
) { ) {
@ -92,7 +100,7 @@ describe("Scheduled Task stop/restart cleanup", () => {
await stopScheduledTask({ env, stdout }); await stopScheduledTask({ env, stdout });
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT); expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT);
expect(killProcessTree).toHaveBeenCalledWith(4242, { graceMs: 300 }); expectGatewayTermination(4242);
expect(inspectPortUsage).toHaveBeenCalledTimes(2); expect(inspectPortUsage).toHaveBeenCalledTimes(2);
}); });
}); });
@ -111,8 +119,12 @@ describe("Scheduled Task stop/restart cleanup", () => {
await stopScheduledTask({ env, stdout }); await stopScheduledTask({ env, stdout });
expect(killProcessTree).toHaveBeenNthCalledWith(1, 4242, { graceMs: 300 }); if (process.platform !== "win32") {
expect(killProcessTree).toHaveBeenNthCalledWith(2, expect.any(Number), { graceMs: 300 }); expect(killProcessTree).toHaveBeenNthCalledWith(1, 4242, { graceMs: 300 });
expect(killProcessTree).toHaveBeenNthCalledWith(2, expect.any(Number), { graceMs: 300 });
} else {
expect(killProcessTree).not.toHaveBeenCalled();
}
expect(inspectPortUsage.mock.calls.length).toBeGreaterThanOrEqual(22); expect(inspectPortUsage.mock.calls.length).toBeGreaterThanOrEqual(22);
}); });
}); });
@ -132,7 +144,7 @@ describe("Scheduled Task stop/restart cleanup", () => {
await stopScheduledTask({ env, stdout }); await stopScheduledTask({ env, stdout });
expect(killProcessTree).toHaveBeenCalledWith(6262, { graceMs: 300 }); expectGatewayTermination(6262);
expect(inspectPortUsage).toHaveBeenCalledTimes(2); expect(inspectPortUsage).toHaveBeenCalledTimes(2);
}); });
}); });
@ -150,7 +162,7 @@ describe("Scheduled Task stop/restart cleanup", () => {
}); });
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT); expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT);
expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); expectGatewayTermination(5151);
expect(inspectPortUsage).toHaveBeenCalledTimes(2); expect(inspectPortUsage).toHaveBeenCalledTimes(2);
expect(schtasksCalls.at(-1)).toEqual(["/Run", "/TN", "OpenClaw Gateway"]); expect(schtasksCalls.at(-1)).toEqual(["/Run", "/TN", "OpenClaw Gateway"]);
}); });

View File

@ -806,7 +806,7 @@ describe("monitorDiscordProvider", () => {
expect(clientFetchUserMock).toHaveBeenCalledWith("@me"); expect(clientFetchUserMock).toHaveBeenCalledWith("@me");
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1); expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("daily application command create limit reached"), expect.stringContaining("native command deploy skipped"),
); );
}); });

View File

@ -1,3 +1,4 @@
import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js"; import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js";
@ -28,9 +29,11 @@ describe("matchesExecAllowlistPattern", () => {
const prevHome = process.env.HOME; const prevHome = process.env.HOME;
process.env.OPENCLAW_HOME = "/srv/openclaw-home"; process.env.OPENCLAW_HOME = "/srv/openclaw-home";
process.env.HOME = "/home/other"; process.env.HOME = "/home/other";
const openClawHome = path.join(path.resolve("/srv/openclaw-home"), "bin", "tool");
const fallbackHome = path.join(path.resolve("/home/other"), "bin", "tool");
try { try {
expect(matchesExecAllowlistPattern("~/bin/tool", "/srv/openclaw-home/bin/tool")).toBe(true); expect(matchesExecAllowlistPattern("~/bin/tool", openClawHome)).toBe(true);
expect(matchesExecAllowlistPattern("~/bin/tool", "/home/other/bin/tool")).toBe(false); expect(matchesExecAllowlistPattern("~/bin/tool", fallbackHome)).toBe(false);
} finally { } finally {
if (prevOpenClawHome === undefined) { if (prevOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME; delete process.env.OPENCLAW_HOME;

View File

@ -112,7 +112,7 @@ describe("exec approvals store helpers", () => {
expect(missing.exists).toBe(false); expect(missing.exists).toBe(false);
expect(missing.raw).toBeNull(); expect(missing.raw).toBeNull();
expect(missing.file).toEqual(normalizeExecApprovals({ version: 1, agents: {} })); expect(missing.file).toEqual(normalizeExecApprovals({ version: 1, agents: {} }));
expect(missing.path).toBe(approvalsFilePath(dir)); expect(path.normalize(missing.path)).toBe(path.normalize(approvalsFilePath(dir)));
fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true }); fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true });
fs.writeFileSync(approvalsFilePath(dir), "{invalid", "utf8"); fs.writeFileSync(approvalsFilePath(dir), "{invalid", "utf8");

View File

@ -80,12 +80,13 @@ describe("exec-command-resolution", () => {
setup: () => { setup: () => {
const dir = makeTempDir(); const dir = makeTempDir();
const cwd = path.join(dir, "project"); const cwd = path.join(dir, "project");
const script = path.join(cwd, "scripts", "run.sh"); const scriptName = process.platform === "win32" ? "run.cmd" : "run.sh";
const script = path.join(cwd, "scripts", scriptName);
fs.mkdirSync(path.dirname(script), { recursive: true }); fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, ""); fs.writeFileSync(script, "");
fs.chmodSync(script, 0o755); fs.chmodSync(script, 0o755);
return { return {
command: "./scripts/run.sh --flag", command: `./scripts/${scriptName} --flag`,
cwd, cwd,
envPath: undefined as NodeJS.ProcessEnv | undefined, envPath: undefined as NodeJS.ProcessEnv | undefined,
expectedPath: script, expectedPath: script,
@ -98,12 +99,13 @@ describe("exec-command-resolution", () => {
setup: () => { setup: () => {
const dir = makeTempDir(); const dir = makeTempDir();
const cwd = path.join(dir, "project"); const cwd = path.join(dir, "project");
const script = path.join(cwd, "bin", "tool"); const scriptName = process.platform === "win32" ? "tool.cmd" : "tool";
const script = path.join(cwd, "bin", scriptName);
fs.mkdirSync(path.dirname(script), { recursive: true }); fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, ""); fs.writeFileSync(script, "");
fs.chmodSync(script, 0o755); fs.chmodSync(script, 0o755);
return { return {
command: '"./bin/tool" --version', command: `"./bin/${scriptName}" --version`,
cwd, cwd,
envPath: undefined as NodeJS.ProcessEnv | undefined, envPath: undefined as NodeJS.ProcessEnv | undefined,
expectedPath: script, expectedPath: script,

View File

@ -66,8 +66,12 @@ describe("executable path helpers", () => {
await fs.chmod(pathTool, 0o755); await fs.chmod(pathTool, 0o755);
expect(resolveExecutablePath(absoluteTool)).toBe(absoluteTool); expect(resolveExecutablePath(absoluteTool)).toBe(absoluteTool);
expect(resolveExecutablePath("~/home-tool", { env: { HOME: homeDir } })).toBe(homeTool); expect(
expect(resolveExecutablePath("runner", { env: { Path: binDir } })).toBe(pathTool); path.normalize(resolveExecutablePath("~/home-tool", { env: { HOME: homeDir } }) ?? ""),
).toBe(path.normalize(homeTool));
expect(path.normalize(resolveExecutablePath("runner", { env: { Path: binDir } }) ?? "")).toBe(
path.normalize(pathTool),
);
expect(resolveExecutablePath("~/missing-tool", { env: { HOME: homeDir } })).toBeUndefined(); expect(resolveExecutablePath("~/missing-tool", { env: { HOME: homeDir } })).toBeUndefined();
}); });
}); });

View File

@ -12,15 +12,33 @@ function resolveWindowsExecutableExtensions(
if (path.extname(executable).length > 0) { if (path.extname(executable).length > 0) {
return [""]; return [""];
} }
return ( return [
env?.PATHEXT ?? "",
env?.Pathext ?? ...(
process.env.PATHEXT ?? env?.PATHEXT ??
process.env.Pathext ?? env?.Pathext ??
".EXE;.CMD;.BAT;.COM" process.env.PATHEXT ??
) process.env.Pathext ??
.split(";") ".EXE;.CMD;.BAT;.COM"
.map((ext) => ext.toLowerCase()); )
.split(";")
.map((ext) => ext.toLowerCase()),
];
}
function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set<string> {
return new Set(
(
env?.PATHEXT ??
env?.Pathext ??
process.env.PATHEXT ??
process.env.Pathext ??
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => ext.toLowerCase())
.filter(Boolean),
);
} }
export function isExecutableFile(filePath: string): boolean { export function isExecutableFile(filePath: string): boolean {
@ -29,9 +47,14 @@ export function isExecutableFile(filePath: string): boolean {
if (!stat.isFile()) { if (!stat.isFile()) {
return false; return false;
} }
if (process.platform !== "win32") { if (process.platform === "win32") {
fs.accessSync(filePath, fs.constants.X_OK); const ext = path.extname(filePath).toLowerCase();
if (!ext) {
return true;
}
return resolveWindowsExecutableExtSet(undefined).has(ext);
} }
fs.accessSync(filePath, fs.constants.X_OK);
return true; return true;
} catch { } catch {
return false; return false;

View File

@ -50,6 +50,7 @@ describe("assertNoHardlinkedFinalPath", () => {
await fs.writeFile(source, "hello", "utf8"); await fs.writeFile(source, "hello", "utf8");
await fs.link(source, linked); await fs.link(source, linked);
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(root); const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(root);
const expectedLinkedPath = path.join("~", "linked.txt");
try { try {
await expect( await expect(
@ -58,7 +59,9 @@ describe("assertNoHardlinkedFinalPath", () => {
root, root,
boundaryLabel: "workspace", boundaryLabel: "workspace",
}), }),
).rejects.toThrow("Hardlinked path is not allowed under workspace (~): ~/linked.txt"); ).rejects.toThrow(
`Hardlinked path is not allowed under workspace (~): ${expectedLinkedPath}`,
);
} finally { } finally {
homedirSpy.mockRestore(); homedirSpy.mockRestore();
} }

View File

@ -109,7 +109,7 @@ describe("expandHomePrefix", () => {
name: "expands exact ~ using explicit home", name: "expands exact ~ using explicit home",
input: "~", input: "~",
opts: { home: " /srv/openclaw-home " }, opts: { home: " /srv/openclaw-home " },
expected: path.resolve("/srv/openclaw-home"), expected: "/srv/openclaw-home",
}, },
{ {
name: "expands ~\\\\ using resolved env home", name: "expands ~\\\\ using resolved env home",

View File

@ -35,8 +35,12 @@ describe("json-file helpers", () => {
const fileMode = fs.statSync(pathname).mode & 0o777; const fileMode = fs.statSync(pathname).mode & 0o777;
const dirMode = fs.statSync(path.dirname(pathname)).mode & 0o777; const dirMode = fs.statSync(path.dirname(pathname)).mode & 0o777;
expect(fileMode).toBe(0o600); if (process.platform === "win32") {
expect(dirMode).toBe(0o700); expect(fileMode & 0o111).toBe(0);
} else {
expect(fileMode).toBe(0o600);
expect(dirMode).toBe(0o700);
}
}); });
}); });

View File

@ -137,8 +137,8 @@ describe("run-node script", () => {
it("returns the build exit code when the compiler step fails", async () => { it("returns the build exit code when the compiler step fails", async () => {
await withTempDir(async (tmp) => { await withTempDir(async (tmp) => {
const spawn = (cmd: string) => { const spawn = (cmd: string, args: string[] = []) => {
if (cmd === "pnpm") { if (cmd === "pnpm" || (cmd === "cmd.exe" && args.includes("pnpm"))) {
return createExitedProcess(23); return createExitedProcess(23);
} }
return createExitedProcess(0); return createExitedProcess(0);

View File

@ -1,4 +1,5 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
/** /**
* Homebrew Cellar paths (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node) * Homebrew Cellar paths (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node)
@ -8,15 +9,18 @@ import fs from "node:fs/promises";
* - Versioned formula "node@22": <prefix>/opt/node@22/bin/node (keg-only) * - Versioned formula "node@22": <prefix>/opt/node@22/bin/node (keg-only)
*/ */
export async function resolveStableNodePath(nodePath: string): Promise<string> { export async function resolveStableNodePath(nodePath: string): Promise<string> {
const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/); const cellarMatch = nodePath.match(
/^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/,
);
if (!cellarMatch) { if (!cellarMatch) {
return nodePath; return nodePath;
} }
const prefix = cellarMatch[1]; // e.g. /opt/homebrew const prefix = cellarMatch[1]; // e.g. /opt/homebrew
const formula = cellarMatch[2]; // e.g. "node" or "node@22" const formula = cellarMatch[2]; // e.g. "node" or "node@22"
const pathModule = nodePath.includes("\\") ? path.win32 : path.posix;
// Try the Homebrew opt symlink first — works for both default and versioned formulas. // Try the Homebrew opt symlink first — works for both default and versioned formulas.
const optPath = `${prefix}/opt/${formula}/bin/node`; const optPath = pathModule.join(prefix, "opt", formula, "bin", "node");
try { try {
await fs.access(optPath); await fs.access(optPath);
return optPath; return optPath;
@ -26,7 +30,7 @@ export async function resolveStableNodePath(nodePath: string): Promise<string> {
// For the default "node" formula, also try the direct bin symlink. // For the default "node" formula, also try the direct bin symlink.
if (formula === "node") { if (formula === "node") {
const binPath = `${prefix}/bin/node`; const binPath = pathModule.join(prefix, "bin", "node");
try { try {
await fs.access(binPath); await fs.access(binPath);
return binPath; return binPath;

View File

@ -56,7 +56,7 @@ describe("update global helpers", () => {
path.join(".bun", "install", "global", "node_modules"), path.join(".bun", "install", "global", "node_modules"),
); );
await expect(resolveGlobalPackageRoot("npm", runCommand, 1000)).resolves.toBe( await expect(resolveGlobalPackageRoot("npm", runCommand, 1000)).resolves.toBe(
"/tmp/npm-root/openclaw", path.join("/tmp/npm-root", "openclaw"),
); );
}); });

View File

@ -41,6 +41,7 @@ type RuntimeFixture = {
expectedArgvIndex: number; expectedArgvIndex: number;
binName?: string; binName?: string;
binNames?: string[]; binNames?: string[];
skipOnWin32?: boolean;
}; };
type UnsafeRuntimeInvocationCase = { type UnsafeRuntimeInvocationCase = {
@ -508,6 +509,7 @@ describe("hardenApprovedExecutionPaths", () => {
scriptName: "run.ts", scriptName: "run.ts",
initialBody: 'console.log("SAFE");\n', initialBody: 'console.log("SAFE");\n',
expectedArgvIndex: 3, expectedArgvIndex: 3,
skipOnWin32: true,
}, },
{ {
name: "pnpm exec double-dash tsx file", name: "pnpm exec double-dash tsx file",
@ -557,6 +559,9 @@ describe("hardenApprovedExecutionPaths", () => {
for (const runtimeCase of mutableOperandCases) { for (const runtimeCase of mutableOperandCases) {
it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => { it(`captures mutable ${runtimeCase.name} operands in approval plans`, () => {
if (runtimeCase.skipOnWin32 && process.platform === "win32") {
return;
}
const binNames = const binNames =
runtimeCase.binNames ?? runtimeCase.binNames ??
(runtimeCase.binName ? [runtimeCase.binName] : ["bunx", "pnpm", "npm", "npx", "tsx"]); (runtimeCase.binName ? [runtimeCase.binName] : ["bunx", "pnpm", "npm", "npx", "tsx"]);

View File

@ -746,6 +746,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
security: "full", security: "full",
ask: "off", ask: "off",
}); });
if (process.platform === "win32") {
expect(runCommand).not.toHaveBeenCalled();
expectInvokeErrorMessage(sendInvokeResult, {
message: "SYSTEM_RUN_DENIED: approval requires a stable executable path",
exact: true,
});
return;
}
expectCommandPinnedToCanonicalPath({ expectCommandPinnedToCanonicalPath({
runCommand, runCommand,
expected: fs.realpathSync(script), expected: fs.realpathSync(script),
@ -779,6 +787,13 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
ask: "off", ask: "off",
}); });
expect(runCommand).not.toHaveBeenCalled(); expect(runCommand).not.toHaveBeenCalled();
if (process.platform === "win32") {
expectInvokeErrorMessage(sendInvokeResult, {
message: "SYSTEM_RUN_DENIED: approval requires a stable executable path",
exact: true,
});
return;
}
expectInvokeErrorMessage(sendInvokeResult, { expectInvokeErrorMessage(sendInvokeResult, {
message: "SYSTEM_RUN_DENIED: approval cwd changed before execution", message: "SYSTEM_RUN_DENIED: approval cwd changed before execution",
exact: true, exact: true,

View File

@ -86,6 +86,7 @@ describe("config-eval helpers", () => {
}); });
it("caches binary lookups until PATH changes", () => { it("caches binary lookups until PATH changes", () => {
setPlatform("linux");
process.env.PATH = ["/missing/bin", "/found/bin"].join(path.delimiter); process.env.PATH = ["/missing/bin", "/found/bin"].join(path.delimiter);
const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation((candidate) => { const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation((candidate) => {
if (String(candidate) === path.join("/found/bin", "tool")) { if (String(candidate) === path.join("/found/bin", "tool")) {
@ -110,10 +111,14 @@ describe("config-eval helpers", () => {
it("checks PATHEXT candidates on Windows", () => { it("checks PATHEXT candidates on Windows", () => {
setPlatform("win32"); setPlatform("win32");
process.env.PATH = "/tools"; const toolsDir = path.join(path.sep, "tools");
process.env.PATH = toolsDir;
process.env.PATHEXT = ".EXE;.CMD"; process.env.PATHEXT = ".EXE;.CMD";
const plainCandidate = path.join(toolsDir, "tool");
const exeCandidate = path.join(toolsDir, "tool.EXE");
const cmdCandidate = path.join(toolsDir, "tool.CMD");
const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation((candidate) => { const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation((candidate) => {
if (String(candidate) === "/tools/tool.CMD") { if (String(candidate) === cmdCandidate) {
return undefined; return undefined;
} }
throw new Error("missing"); throw new Error("missing");
@ -121,9 +126,9 @@ describe("config-eval helpers", () => {
expect(hasBinary("tool")).toBe(true); expect(hasBinary("tool")).toBe(true);
expect(accessSpy.mock.calls.map(([candidate]) => String(candidate))).toEqual([ expect(accessSpy.mock.calls.map(([candidate]) => String(candidate))).toEqual([
"/tools/tool", plainCandidate,
"/tools/tool.EXE", exeCandidate,
"/tools/tool.CMD", cmdCandidate,
]); ]);
}); });
}); });