Compare commits
4 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8293292c5d | ||
|
|
1a256b8670 | ||
|
|
360dffbe9d | ||
|
|
221d66c825 |
@ -88,6 +88,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin.
|
||||
- Skills/native command deduplication: centralize skill command dedupe by canonical `skillName` in `listSkillCommandsForAgents` so duplicate suffixed variants (for example `_2`) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.
|
||||
- Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (`&`, `"`, `<`, `>`, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.
|
||||
- Linux/WSL2 daemon install hardening: add regression coverage for WSL environment detection, WSL-specific systemd guidance, and `systemctl --user is-enabled` failure paths so WSL2/headless onboarding keeps treating bus-unavailable probes as non-fatal while preserving real permission errors. Related: #36495. Thanks @vincentkoc.
|
||||
- Agents/thinking-tag promotion hardening: guard `promoteThinkingTagsToBlocks` against malformed assistant content entries (`null`/`undefined`) before `block.type` reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin.
|
||||
- Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid `dev` placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap `serverVersion` to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI.
|
||||
- Control UI/markdown parser crash fallback: catch `marked.parse()` failures and fall back to escaped plain-text `<pre>` rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.
|
||||
|
||||
@ -256,4 +256,27 @@ describe("runDaemonInstall", () => {
|
||||
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
|
||||
expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true);
|
||||
});
|
||||
|
||||
it("continues Linux install when service probe hits a non-fatal systemd bus failure", async () => {
|
||||
service.isLoaded.mockRejectedValueOnce(
|
||||
new Error("systemctl is-enabled unavailable: Failed to connect to bus"),
|
||||
);
|
||||
|
||||
await runDaemonInstall({ json: true });
|
||||
|
||||
expect(actionState.failed).toEqual([]);
|
||||
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails install when service probe reports an unrelated error", async () => {
|
||||
service.isLoaded.mockRejectedValueOnce(
|
||||
new Error("systemctl is-enabled unavailable: read-only file system"),
|
||||
);
|
||||
|
||||
await runDaemonInstall({ json: true });
|
||||
|
||||
expect(actionState.failed[0]?.message).toContain("Gateway service check failed");
|
||||
expect(actionState.failed[0]?.message).toContain("read-only file system");
|
||||
expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@ import { resolveGatewayInstallToken } from "../../commands/gateway-install-token
|
||||
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import {
|
||||
@ -48,8 +49,12 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch (err) {
|
||||
fail(`Gateway service check failed: ${String(err)}`);
|
||||
return;
|
||||
if (isNonFatalSystemdInstallProbeError(err)) {
|
||||
loaded = false;
|
||||
} else {
|
||||
fail(`Gateway service check failed: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (loaded) {
|
||||
if (!opts.force) {
|
||||
|
||||
@ -122,4 +122,34 @@ describe("maybeInstallDaemon", () => {
|
||||
|
||||
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rethrows install probe failures that are not the known non-fatal Linux systemd cases", async () => {
|
||||
serviceIsLoaded.mockRejectedValueOnce(
|
||||
new Error("systemctl is-enabled unavailable: read-only file system"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
maybeInstallDaemon({
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
port: 18789,
|
||||
}),
|
||||
).rejects.toThrow("systemctl is-enabled unavailable: read-only file system");
|
||||
|
||||
expect(serviceInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues the WSL2 daemon install flow when service status probe reports systemd unavailability", async () => {
|
||||
serviceIsLoaded.mockRejectedValueOnce(
|
||||
new Error("systemctl --user unavailable: Failed to connect to bus: No medium found"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
maybeInstallDaemon({
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
port: 18789,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { isNonFatalSystemdInstallProbeError } from "../daemon/systemd.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { confirm, select } from "./configure.shared.js";
|
||||
@ -23,7 +24,10 @@ export async function maybeInstallDaemon(params: {
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ env: process.env });
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (!isNonFatalSystemdInstallProbeError(error)) {
|
||||
throw error;
|
||||
}
|
||||
loaded = false;
|
||||
}
|
||||
let shouldCheckLinger = false;
|
||||
|
||||
33
src/daemon/systemd-hints.test.ts
Normal file
33
src/daemon/systemd-hints.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSystemdUnavailableDetail, renderSystemdUnavailableHints } from "./systemd-hints.js";
|
||||
|
||||
describe("isSystemdUnavailableDetail", () => {
|
||||
it("matches systemd unavailable error details", () => {
|
||||
expect(
|
||||
isSystemdUnavailableDetail("systemctl --user unavailable: Failed to connect to bus"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSystemdUnavailableDetail(
|
||||
"systemctl not available; systemd user services are required on Linux.",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isSystemdUnavailableDetail("permission denied")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderSystemdUnavailableHints", () => {
|
||||
it("renders WSL2-specific recovery hints", () => {
|
||||
expect(renderSystemdUnavailableHints({ wsl: true })).toEqual([
|
||||
"WSL2 needs systemd enabled: edit /etc/wsl.conf with [boot]\\nsystemd=true",
|
||||
"Then run: wsl --shutdown (from PowerShell) and reopen your distro.",
|
||||
"Verify: systemctl --user status",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders generic Linux recovery hints outside WSL", () => {
|
||||
expect(renderSystemdUnavailableHints()).toEqual([
|
||||
"systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.",
|
||||
"If you're in a container, run the gateway in the foreground instead of `openclaw gateway`.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn());
|
||||
@ -10,6 +11,7 @@ vi.mock("node:child_process", () => ({
|
||||
import { splitArgsPreservingQuotes } from "./arg-split.js";
|
||||
import { parseSystemdExecStart } from "./systemd-unit.js";
|
||||
import {
|
||||
isNonFatalSystemdInstallProbeError,
|
||||
isSystemdUserServiceAvailable,
|
||||
parseSystemdShow,
|
||||
restartSystemdService,
|
||||
@ -150,6 +152,101 @@ describe("isSystemdServiceEnabled", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for the WSL2 Ubuntu 24.04 wrapper-only is-enabled failure", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
mockManagedUnitPresent();
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
|
||||
const err = new Error(
|
||||
"Command failed: systemctl --user is-enabled openclaw-gateway.service",
|
||||
) as Error & { code?: number };
|
||||
err.code = 1;
|
||||
cb(err, "", "");
|
||||
});
|
||||
|
||||
await expect(
|
||||
isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }),
|
||||
).rejects.toThrow(
|
||||
"systemctl is-enabled unavailable: Command failed: systemctl --user is-enabled openclaw-gateway.service",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when is-enabled cannot connect to the user bus without machine fallback", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
mockManagedUnitPresent();
|
||||
vi.spyOn(os, "userInfo").mockImplementationOnce(() => {
|
||||
throw new Error("no user info");
|
||||
});
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
|
||||
cb(
|
||||
createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }),
|
||||
"",
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
isSystemdServiceEnabled({
|
||||
env: { HOME: "/tmp/openclaw-test-home", USER: "", LOGNAME: "" },
|
||||
}),
|
||||
).rejects.toThrow("systemctl is-enabled unavailable: Failed to connect to bus");
|
||||
});
|
||||
|
||||
it("returns false when both direct and machine-scope is-enabled checks report bus unavailability", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
mockManagedUnitPresent();
|
||||
execFileMock
|
||||
.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
|
||||
cb(
|
||||
createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }),
|
||||
"",
|
||||
"",
|
||||
);
|
||||
})
|
||||
.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual([
|
||||
"--machine",
|
||||
"debian@",
|
||||
"--user",
|
||||
"is-enabled",
|
||||
"openclaw-gateway.service",
|
||||
]);
|
||||
cb(
|
||||
createExecFileError("Failed to connect to user scope bus via local transport", {
|
||||
stderr:
|
||||
"Failed to connect to user scope bus via local transport: $DBUS_SESSION_BUS_ADDRESS and $XDG_RUNTIME_DIR not defined",
|
||||
}),
|
||||
"",
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
isSystemdServiceEnabled({
|
||||
env: { HOME: "/tmp/openclaw-test-home", USER: "debian" },
|
||||
}),
|
||||
).rejects.toThrow("systemctl is-enabled unavailable: Failed to connect to user scope bus");
|
||||
});
|
||||
|
||||
it("throws when generic wrapper errors report infrastructure failures", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
mockManagedUnitPresent();
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
|
||||
const err = new Error(
|
||||
"Command failed: systemctl --user is-enabled openclaw-gateway.service",
|
||||
) as Error & { code?: number };
|
||||
err.code = 1;
|
||||
cb(err, "", "read-only file system");
|
||||
});
|
||||
|
||||
await expect(
|
||||
isSystemdServiceEnabled({ env: { HOME: "/tmp/openclaw-test-home" } }),
|
||||
).rejects.toThrow("systemctl is-enabled unavailable: read-only file system");
|
||||
});
|
||||
|
||||
it("throws when systemctl is-enabled fails for non-state errors", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
mockManagedUnitPresent();
|
||||
@ -190,6 +287,32 @@ describe("isSystemdServiceEnabled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNonFatalSystemdInstallProbeError", () => {
|
||||
it("matches wrapper-only WSL install probe failures", () => {
|
||||
expect(
|
||||
isNonFatalSystemdInstallProbeError(
|
||||
new Error("Command failed: systemctl --user is-enabled openclaw-gateway.service"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches bus-unavailable install probe failures", () => {
|
||||
expect(
|
||||
isNonFatalSystemdInstallProbeError(
|
||||
new Error("systemctl is-enabled unavailable: Failed to connect to bus"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match real infrastructure failures", () => {
|
||||
expect(
|
||||
isNonFatalSystemdInstallProbeError(
|
||||
new Error("systemctl is-enabled unavailable: read-only file system"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd runtime parsing", () => {
|
||||
it("parses active state details", () => {
|
||||
const output = [
|
||||
|
||||
@ -179,6 +179,46 @@ function isSystemdUnitNotEnabled(detail: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isSystemctlBusUnavailable(detail: string): boolean {
|
||||
if (!detail) {
|
||||
return false;
|
||||
}
|
||||
const normalized = detail.toLowerCase();
|
||||
return (
|
||||
normalized.includes("failed to connect to bus") ||
|
||||
normalized.includes("failed to connect to user scope bus") ||
|
||||
normalized.includes("dbus_session_bus_address") ||
|
||||
normalized.includes("xdg_runtime_dir") ||
|
||||
normalized.includes("no medium found")
|
||||
);
|
||||
}
|
||||
|
||||
function isGenericSystemctlIsEnabledFailure(detail: string): boolean {
|
||||
if (!detail) {
|
||||
return false;
|
||||
}
|
||||
const normalized = detail.toLowerCase().trim();
|
||||
return (
|
||||
normalized.startsWith("command failed: systemctl") &&
|
||||
normalized.includes(" is-enabled ") &&
|
||||
!normalized.includes("permission denied") &&
|
||||
!normalized.includes("access denied") &&
|
||||
!normalized.includes("no space left") &&
|
||||
!normalized.includes("read-only file system") &&
|
||||
!normalized.includes("out of memory") &&
|
||||
!normalized.includes("cannot allocate memory")
|
||||
);
|
||||
}
|
||||
|
||||
export function isNonFatalSystemdInstallProbeError(error: unknown): boolean {
|
||||
const detail = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
||||
if (!detail) {
|
||||
return false;
|
||||
}
|
||||
const normalized = detail.toLowerCase();
|
||||
return isSystemctlBusUnavailable(normalized) || isGenericSystemctlIsEnabledFailure(normalized);
|
||||
}
|
||||
|
||||
function resolveSystemctlDirectUserScopeArgs(): string[] {
|
||||
return ["--user"];
|
||||
}
|
||||
|
||||
101
src/infra/wsl.test.ts
Normal file
101
src/infra/wsl.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
|
||||
const readFileSyncMock = vi.hoisted(() => vi.fn());
|
||||
const readFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
readFileSync: readFileSyncMock,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
default: {
|
||||
readFile: readFileMock,
|
||||
},
|
||||
}));
|
||||
|
||||
const { isWSLEnv, isWSLSync, isWSL2Sync, isWSL, resetWSLStateForTests } = await import("./wsl.js");
|
||||
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: platform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("wsl detection", () => {
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
|
||||
beforeEach(() => {
|
||||
envSnapshot = captureEnv(["WSL_INTEROP", "WSL_DISTRO_NAME", "WSLENV"]);
|
||||
readFileSyncMock.mockReset();
|
||||
readFileMock.mockReset();
|
||||
resetWSLStateForTests();
|
||||
setPlatform("linux");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
envSnapshot.restore();
|
||||
resetWSLStateForTests();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, "platform", originalPlatformDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["WSL_DISTRO_NAME", "Ubuntu"],
|
||||
["WSL_INTEROP", "/run/WSL/123_interop"],
|
||||
["WSLENV", "PATH/l"],
|
||||
])("detects WSL from %s", (key, value) => {
|
||||
process.env[key] = value;
|
||||
expect(isWSLEnv()).toBe(true);
|
||||
});
|
||||
|
||||
it("reads /proc/version for sync WSL detection when env vars are absent", () => {
|
||||
readFileSyncMock.mockReturnValueOnce("Linux version 6.6.0-1-microsoft-standard-WSL2");
|
||||
expect(isWSLSync()).toBe(true);
|
||||
expect(readFileSyncMock).toHaveBeenCalledWith("/proc/version", "utf8");
|
||||
});
|
||||
|
||||
it.each(["Linux version 6.6.0-1-microsoft-standard-WSL2", "Linux version 6.6.0-1-wsl2"])(
|
||||
"detects WSL2 sync from kernel version: %s",
|
||||
(kernelVersion) => {
|
||||
readFileSyncMock.mockReturnValueOnce(kernelVersion);
|
||||
readFileSyncMock.mockReturnValueOnce(kernelVersion);
|
||||
expect(isWSL2Sync()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns false for sync detection on non-linux platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(isWSLSync()).toBe(false);
|
||||
expect(isWSL2Sync()).toBe(false);
|
||||
expect(readFileSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches async WSL detection until reset", async () => {
|
||||
readFileMock.mockResolvedValue("6.6.0-1-microsoft-standard-WSL2");
|
||||
|
||||
await expect(isWSL()).resolves.toBe(true);
|
||||
await expect(isWSL()).resolves.toBe(true);
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
resetWSLStateForTests();
|
||||
await expect(isWSL()).resolves.toBe(true);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns false when async WSL detection cannot read osrelease", async () => {
|
||||
readFileMock.mockRejectedValueOnce(new Error("ENOENT"));
|
||||
await expect(isWSL()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for async detection on non-linux platforms without reading osrelease", async () => {
|
||||
setPlatform("win32");
|
||||
await expect(isWSL()).resolves.toBe(false);
|
||||
expect(readFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,10 @@ import fs from "node:fs/promises";
|
||||
|
||||
let wslCached: boolean | null = null;
|
||||
|
||||
export function resetWSLStateForTests(): void {
|
||||
wslCached = null;
|
||||
}
|
||||
|
||||
export function isWSLEnv(): boolean {
|
||||
if (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
|
||||
return true;
|
||||
@ -48,6 +52,10 @@ export async function isWSL(): Promise<boolean> {
|
||||
if (wslCached !== null) {
|
||||
return wslCached;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
wslCached = false;
|
||||
return wslCached;
|
||||
}
|
||||
if (isWSLEnv()) {
|
||||
wslCached = true;
|
||||
return wslCached;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user