mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
* Daemon: handle missing systemctl in containers * Daemon: harden missing-systemctl detection * Daemon tests: cover systemctl spawn failure path * Changelog: note container systemctl service-check fix * Update CHANGELOG.md * Daemon: fail closed on unknown systemctl is-enabled errors * Daemon tests: cover is-enabled unknown-error path --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
|
||||
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
|
||||
|
||||
@@ -42,6 +42,56 @@ describe("systemd availability", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSystemdServiceEnabled", () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockClear();
|
||||
});
|
||||
|
||||
it("returns false when systemctl is not present", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
execFileMock.mockImplementation((_cmd, _args, _opts, cb) => {
|
||||
const err = new Error("spawn systemctl EACCES") as Error & { code?: string };
|
||||
err.code = "EACCES";
|
||||
cb(err, "", "");
|
||||
});
|
||||
const result = await isSystemdServiceEnabled({ env: {} });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("calls systemctl is-enabled when systemctl is present", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
execFileMock.mockImplementationOnce((_cmd, args, _opts, cb) => {
|
||||
expect(args).toEqual(["--user", "is-enabled", "openclaw-gateway.service"]);
|
||||
cb(null, "enabled", "");
|
||||
});
|
||||
const result = await isSystemdServiceEnabled({ env: {} });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when systemctl reports disabled", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
||||
const err = new Error("disabled") as Error & { code?: number };
|
||||
err.code = 1;
|
||||
cb(err, "disabled", "");
|
||||
});
|
||||
const result = await isSystemdServiceEnabled({ env: {} });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when systemctl is-enabled fails for non-state errors", async () => {
|
||||
const { isSystemdServiceEnabled } = await import("./systemd.js");
|
||||
execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
||||
const err = new Error("Failed to connect to bus") as Error & { code?: number };
|
||||
err.code = 1;
|
||||
cb(err, "", "Failed to connect to bus");
|
||||
});
|
||||
await expect(isSystemdServiceEnabled({ env: {} })).rejects.toThrow(
|
||||
"systemctl is-enabled unavailable: Failed to connect to bus",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemd runtime parsing", () => {
|
||||
it("parses active state details", () => {
|
||||
const output = [
|
||||
|
||||
@@ -142,6 +142,39 @@ async function execSystemctl(
|
||||
return await execFileUtf8("systemctl", args);
|
||||
}
|
||||
|
||||
function readSystemctlDetail(result: { stdout: string; stderr: string }): string {
|
||||
return (result.stderr || result.stdout || "").trim();
|
||||
}
|
||||
|
||||
function isSystemctlMissing(detail: string): boolean {
|
||||
if (!detail) {
|
||||
return false;
|
||||
}
|
||||
const normalized = detail.toLowerCase();
|
||||
return (
|
||||
normalized.includes("not found") ||
|
||||
normalized.includes("no such file or directory") ||
|
||||
normalized.includes("spawn systemctl enoent") ||
|
||||
normalized.includes("spawn systemctl eacces")
|
||||
);
|
||||
}
|
||||
|
||||
function isSystemdUnitNotEnabled(detail: string): boolean {
|
||||
if (!detail) {
|
||||
return false;
|
||||
}
|
||||
const normalized = detail.toLowerCase();
|
||||
return (
|
||||
normalized.includes("disabled") ||
|
||||
normalized.includes("static") ||
|
||||
normalized.includes("indirect") ||
|
||||
normalized.includes("masked") ||
|
||||
normalized.includes("not-found") ||
|
||||
normalized.includes("could not be found") ||
|
||||
normalized.includes("failed to get unit file state")
|
||||
);
|
||||
}
|
||||
|
||||
export async function isSystemdUserServiceAvailable(): Promise<boolean> {
|
||||
const res = await execSystemctl(["--user", "status"]);
|
||||
if (res.code === 0) {
|
||||
@@ -174,8 +207,8 @@ async function assertSystemdAvailable() {
|
||||
if (res.code === 0) {
|
||||
return;
|
||||
}
|
||||
const detail = res.stderr || res.stdout;
|
||||
if (detail.toLowerCase().includes("not found")) {
|
||||
const detail = readSystemctlDetail(res);
|
||||
if (isSystemctlMissing(detail)) {
|
||||
throw new Error("systemctl not available; systemd user services are required on Linux.");
|
||||
}
|
||||
throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim());
|
||||
@@ -312,11 +345,17 @@ export async function restartSystemdService({
|
||||
}
|
||||
|
||||
export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Promise<boolean> {
|
||||
await assertSystemdAvailable();
|
||||
const serviceName = resolveSystemdServiceName(args.env ?? {});
|
||||
const unitName = `${serviceName}.service`;
|
||||
const res = await execSystemctl(["--user", "is-enabled", unitName]);
|
||||
return res.code === 0;
|
||||
if (res.code === 0) {
|
||||
return true;
|
||||
}
|
||||
const detail = readSystemctlDetail(res);
|
||||
if (isSystemctlMissing(detail) || isSystemdUnitNotEnabled(detail)) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`systemctl is-enabled unavailable: ${detail || "unknown error"}`.trim());
|
||||
}
|
||||
|
||||
export async function readSystemdServiceRuntime(
|
||||
@@ -327,7 +366,7 @@ export async function readSystemdServiceRuntime(
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unknown",
|
||||
detail: String(err),
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
const serviceName = resolveSystemdServiceName(env);
|
||||
@@ -373,8 +412,7 @@ async function isSystemctlAvailable(): Promise<boolean> {
|
||||
if (res.code === 0) {
|
||||
return true;
|
||||
}
|
||||
const detail = (res.stderr || res.stdout).toLowerCase();
|
||||
return !detail.includes("not found");
|
||||
return !isSystemctlMissing(readSystemctlDetail(res));
|
||||
}
|
||||
|
||||
export async function findLegacySystemdUnits(env: GatewayServiceEnv): Promise<LegacySystemdUnit[]> {
|
||||
|
||||
Reference in New Issue
Block a user