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
```
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)
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 --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 doctor` → no blocking config/service errors.
- `openclaw channels status --probe` → channels report `connected` or `ready`.

View File

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

View File

@ -1,5 +1,12 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import fs from "node:fs/promises";
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 type {
DiffInput,
@ -13,6 +20,45 @@ import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
const DEFAULT_FILE_NAME = "diff.txt";
const MAX_PATCH_FILE_COUNT = 128;
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 {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');

View File

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayProbeResult } from "../gateway/probe.js";
import type { RuntimeEnv } from "../runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
@ -33,7 +34,7 @@ const startSshPortForward = vi.fn(async (_opts?: unknown) => ({
stderr: [],
stop: sshStop,
}));
const probeGateway = vi.fn(async (opts: { url: string }) => {
const probeGateway = vi.fn(async (opts: { url: string }): Promise<GatewayProbeResult> => {
const { url } = opts;
if (url.includes("127.0.0.1")) {
return {
@ -52,7 +53,16 @@ const probeGateway = vi.fn(async (opts: { url: string }) => {
},
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: {
path: "/tmp/cfg.json",
exists: true,
@ -81,7 +91,16 @@ const probeGateway = vi.fn(async (opts: { url: string }) => {
},
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: {
path: "/tmp/remote.json",
exists: true,
@ -201,6 +220,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 () => {
@ -361,7 +428,16 @@ describe("gateway-status command", () => {
},
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: {
path: "/tmp/secretref-config.json",
exists: true,

View File

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

View File

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

View File

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

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(
extraResponses: Array<{ code: number; stdout: string; stderr: string }> = [],
) {
@ -179,7 +187,7 @@ describe("Windows startup fallback", () => {
await expect(restartScheduledTask({ env, stdout })).resolves.toEqual({
outcome: "completed",
});
expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 });
expectGatewayTermination(5151);
expectStartupFallbackSpawn(env);
});
});
@ -214,7 +222,7 @@ describe("Windows startup fallback", () => {
delete envWithoutPort.OPENCLAW_GATEWAY_PORT;
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(
run: (context: { env: Record<string, string>; stdout: PassThrough }) => Promise<void>,
) {
@ -92,7 +100,7 @@ describe("Scheduled Task stop/restart cleanup", () => {
await stopScheduledTask({ env, stdout });
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT);
expect(killProcessTree).toHaveBeenCalledWith(4242, { graceMs: 300 });
expectGatewayTermination(4242);
expect(inspectPortUsage).toHaveBeenCalledTimes(2);
});
});
@ -111,8 +119,12 @@ describe("Scheduled Task stop/restart cleanup", () => {
await stopScheduledTask({ env, stdout });
expect(killProcessTree).toHaveBeenNthCalledWith(1, 4242, { graceMs: 300 });
expect(killProcessTree).toHaveBeenNthCalledWith(2, expect.any(Number), { graceMs: 300 });
if (process.platform !== "win32") {
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);
});
});
@ -132,7 +144,7 @@ describe("Scheduled Task stop/restart cleanup", () => {
await stopScheduledTask({ env, stdout });
expect(killProcessTree).toHaveBeenCalledWith(6262, { graceMs: 300 });
expectGatewayTermination(6262);
expect(inspectPortUsage).toHaveBeenCalledTimes(2);
});
});
@ -150,7 +162,7 @@ describe("Scheduled Task stop/restart cleanup", () => {
});
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(GATEWAY_PORT);
expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 });
expectGatewayTermination(5151);
expect(inspectPortUsage).toHaveBeenCalledTimes(2);
expect(schtasksCalls.at(-1)).toEqual(["/Run", "/TN", "OpenClaw Gateway"]);
});

View File

@ -806,7 +806,7 @@ describe("monitorDiscordProvider", () => {
expect(clientFetchUserMock).toHaveBeenCalledWith("@me");
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
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 { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js";
@ -28,9 +29,11 @@ describe("matchesExecAllowlistPattern", () => {
const prevHome = process.env.HOME;
process.env.OPENCLAW_HOME = "/srv/openclaw-home";
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 {
expect(matchesExecAllowlistPattern("~/bin/tool", "/srv/openclaw-home/bin/tool")).toBe(true);
expect(matchesExecAllowlistPattern("~/bin/tool", "/home/other/bin/tool")).toBe(false);
expect(matchesExecAllowlistPattern("~/bin/tool", openClawHome)).toBe(true);
expect(matchesExecAllowlistPattern("~/bin/tool", fallbackHome)).toBe(false);
} finally {
if (prevOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME;

View File

@ -112,7 +112,7 @@ describe("exec approvals store helpers", () => {
expect(missing.exists).toBe(false);
expect(missing.raw).toBeNull();
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.writeFileSync(approvalsFilePath(dir), "{invalid", "utf8");

View File

@ -80,12 +80,13 @@ describe("exec-command-resolution", () => {
setup: () => {
const dir = makeTempDir();
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.writeFileSync(script, "");
fs.chmodSync(script, 0o755);
return {
command: "./scripts/run.sh --flag",
command: `./scripts/${scriptName} --flag`,
cwd,
envPath: undefined as NodeJS.ProcessEnv | undefined,
expectedPath: script,
@ -98,12 +99,13 @@ describe("exec-command-resolution", () => {
setup: () => {
const dir = makeTempDir();
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.writeFileSync(script, "");
fs.chmodSync(script, 0o755);
return {
command: '"./bin/tool" --version',
command: `"./bin/${scriptName}" --version`,
cwd,
envPath: undefined as NodeJS.ProcessEnv | undefined,
expectedPath: script,

View File

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

View File

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

View File

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

View File

@ -109,7 +109,7 @@ describe("expandHomePrefix", () => {
name: "expands exact ~ using explicit home",
input: "~",
opts: { home: " /srv/openclaw-home " },
expected: path.resolve("/srv/openclaw-home"),
expected: "/srv/openclaw-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 dirMode = fs.statSync(path.dirname(pathname)).mode & 0o777;
expect(fileMode).toBe(0o600);
expect(dirMode).toBe(0o700);
if (process.platform === "win32") {
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 () => {
await withTempDir(async (tmp) => {
const spawn = (cmd: string) => {
if (cmd === "pnpm") {
const spawn = (cmd: string, args: string[] = []) => {
if (cmd === "pnpm" || (cmd === "cmd.exe" && args.includes("pnpm"))) {
return createExitedProcess(23);
}
return createExitedProcess(0);

View File

@ -1,4 +1,5 @@
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)
@ -8,15 +9,18 @@ import fs from "node:fs/promises";
* - Versioned formula "node@22": <prefix>/opt/node@22/bin/node (keg-only)
*/
export async function resolveStableNodePath(nodePath: string): Promise<string> {
const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/);
const cellarMatch = nodePath.match(
/^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/,
);
if (!cellarMatch) {
return nodePath;
}
const prefix = cellarMatch[1]; // e.g. /opt/homebrew
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.
const optPath = `${prefix}/opt/${formula}/bin/node`;
const optPath = pathModule.join(prefix, "opt", formula, "bin", "node");
try {
await fs.access(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.
if (formula === "node") {
const binPath = `${prefix}/bin/node`;
const binPath = pathModule.join(prefix, "bin", "node");
try {
await fs.access(binPath);
return binPath;

View File

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

View File

@ -746,6 +746,14 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
security: "full",
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({
runCommand,
expected: fs.realpathSync(script),
@ -779,6 +787,13 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
ask: "off",
});
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, {
message: "SYSTEM_RUN_DENIED: approval cwd changed before execution",
exact: true,

View File

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