fix: harden macOS update restart helper (#68492) (thanks @hclsys)

This commit is contained in:
Peter Steinberger
2026-04-18 18:15:38 +01:00
parent 4a870300dd
commit 089e038dfe
3 changed files with 179 additions and 19 deletions

View File

@@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai
- Slack/threads: log failed thread starter and history fetches at verbose level while preserving best-effort fallback behavior, so missing Slack thread context is diagnosable without interrupting inbound handling. (#68594) Thanks @martingarramon.
- Gateway/restart: keep stale-gateway cleanup from terminating the current process's parent or ancestors, so plugin sidecars like WeChat no longer kill the active gateway and trigger an infinite supervisor restart loop. Fixes #68451. (#68517) Thanks @openperf.
- Gateway/auth: reject gateway auth credentials that match published example placeholders at startup and secret reload, and keep cloud install snippets from publishing copy-paste gateway/keyring secrets. (#68404) Thanks @coygeek.
- CLI/update: preserve macOS restart helper launchctl failures in the update restart log without letting log setup block the restart path. (#68492) Thanks @hclsys.
## 2026.4.15

View File

@@ -1,5 +1,7 @@
import { spawn, type ChildProcess } from "node:child_process";
import { execFile, spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { prepareRestartScript, runRestartScript } from "./restart-helper.js";
@@ -25,7 +27,45 @@ describe("restart-helper", () => {
}
async function cleanupScript(scriptPath: string) {
await fs.unlink(scriptPath);
await fs.unlink(scriptPath).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
});
}
async function makeTempDir(prefix: string) {
return await fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
async function writeFakeLaunchctl(
fakeBinDir: string,
content = `#!/bin/sh
echo "launchctl $*" >&2
case "$1" in
kickstart) exit 0 ;;
enable|bootstrap) exit 0 ;;
esac
exit 0
`,
) {
const launchctlPath = path.join(fakeBinDir, "launchctl");
await fs.writeFile(launchctlPath, content, { mode: 0o755 });
}
async function executeScript(scriptPath: string, env: Record<string, string>) {
return await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve) => {
execFile(
"/bin/sh",
[scriptPath],
{ env: { ...process.env, ...env } },
(error, stdout, stderr) => {
const execError = error as (Error & { code?: number | string }) | null;
const code = typeof execError?.code === "number" ? execError.code : null;
resolve({ code, stdout, stderr });
},
);
});
}
function expectWindowsRestartWaitOrdering(content: string, port = 18789) {
@@ -125,14 +165,127 @@ describe("restart-helper", () => {
OPENCLAW_PROFILE: "default",
HOME: "/Users/testuser",
});
expect(content).toContain("exec 2>>'/Users/testuser/.openclaw/logs/update-restart.log'");
// Every launchctl call should allow stderr through now (no `2>/dev/null`)
expect(content).toContain(
"exec >>'/Users/testuser/.openclaw/logs/update-restart.log' 2>&1 || true",
);
// Every launchctl call should allow output through now (no `2>/dev/null`)
// and the final kickstart must not swallow its exit code.
expect(content).not.toMatch(/launchctl[^\n]*2>\/dev\/null/);
expect(content).not.toMatch(/launchctl kickstart[^\n]*\|\| true/);
await cleanupScript(scriptPath);
});
it("uses OPENCLAW_STATE_DIR for the macOS update restart log", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.getuid = () => 501;
const { scriptPath, content } = await prepareAndReadScript({
OPENCLAW_PROFILE: "default",
HOME: "/Users/testuser",
OPENCLAW_STATE_DIR: "/tmp/openclaw-state",
});
expect(content).toContain("mkdir -p '/tmp/openclaw-state/logs' 2>/dev/null || true");
expect(content).toContain(
"exec >>'/tmp/openclaw-state/logs/update-restart.log' 2>&1 || true",
);
await cleanupScript(scriptPath);
});
it("returns the final macOS launchctl kickstart failure after logging cleanup", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.getuid = () => 501;
const tmpDir = await makeTempDir("openclaw-restart-helper-");
const fakeBinDir = path.join(tmpDir, "bin");
const stateDir = path.join(tmpDir, "state");
await fs.mkdir(fakeBinDir, { recursive: true });
await writeFakeLaunchctl(
fakeBinDir,
`#!/bin/sh
echo "launchctl $*" >&2
case "$1" in
kickstart) exit 42 ;;
enable|bootstrap) exit 0 ;;
esac
exit 0
`,
);
const { scriptPath } = await prepareAndReadScript({
OPENCLAW_PROFILE: "default",
HOME: path.join(tmpDir, "home"),
OPENCLAW_STATE_DIR: stateDir,
});
const result = await executeScript(scriptPath, {
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
});
const log = await fs.readFile(path.join(stateDir, "logs", "update-restart.log"), "utf-8");
expect(result.code).toBe(42);
expect(log).toContain("openclaw update restart attempt (label=ai.openclaw.gateway)");
expect(log).toContain("launchctl kickstart -k gui/501/ai.openclaw.gateway");
expect(log).toContain("openclaw update restart failed status=42");
expect(log).not.toContain("openclaw update restart done");
});
it("continues the macOS restart path when log setup fails", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.getuid = () => 501;
const tmpDir = await makeTempDir("openclaw-restart-helper-");
const fakeBinDir = path.join(tmpDir, "bin");
const stateFile = path.join(tmpDir, "state-file");
const markerPath = path.join(tmpDir, "launchctl-ran");
await fs.mkdir(fakeBinDir, { recursive: true });
await fs.writeFile(stateFile, "not a directory");
await writeFakeLaunchctl(
fakeBinDir,
`#!/bin/sh
printf ran > "$LAUNCHCTL_MARKER"
exit 0
`,
);
const { scriptPath } = await prepareAndReadScript({
OPENCLAW_PROFILE: "default",
HOME: path.join(tmpDir, "home"),
OPENCLAW_STATE_DIR: stateFile,
});
const result = await executeScript(scriptPath, {
LAUNCHCTL_MARKER: markerPath,
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
});
expect(result.code).toBeNull();
await expect(fs.readFile(markerPath, "utf-8")).resolves.toBe("ran");
});
it("logs custom macOS launchd labels without shell expansion", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.getuid = () => 501;
const tmpDir = await makeTempDir("openclaw-restart-helper-");
const fakeBinDir = path.join(tmpDir, "bin");
const stateDir = path.join(tmpDir, "state");
await fs.mkdir(fakeBinDir, { recursive: true });
await writeFakeLaunchctl(fakeBinDir);
const { scriptPath } = await prepareAndReadScript({
OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.$(echo injected)",
HOME: path.join(tmpDir, "home"),
OPENCLAW_STATE_DIR: stateDir,
});
const result = await executeScript(scriptPath, {
PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`,
});
const log = await fs.readFile(path.join(stateDir, "logs", "update-restart.log"), "utf-8");
expect(result.code).toBeNull();
expect(log).toContain("label=ai.openclaw.$(echo injected)");
expect(log).not.toContain("label=ai.openclaw.injected");
});
it("uses OPENCLAW_LAUNCHD_LABEL override on macOS", async () => {
Object.defineProperty(process, "platform", { value: "darwin" });
process.getuid = () => 501;

View File

@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js";
import { DEFAULT_GATEWAY_PORT, resolveStateDir } from "../../config/paths.js";
import { quoteCmdScriptArg } from "../../daemon/cmd-argv.js";
import {
resolveGatewayLaunchAgentLabel,
@@ -90,34 +90,40 @@ rm -f "$0"
const home = normalizeOptionalString(env.HOME) || process.env.HOME || os.homedir();
const plistPath = path.join(home, "Library", "LaunchAgents", `${label}.plist`);
const escapedPlistPath = shellEscape(plistPath);
const logPath = path.join(home, ".openclaw", "logs", "update-restart.log");
const logDir = path.join(resolveStateDir(env), "logs");
const logPath = path.join(logDir, "update-restart.log");
const escapedLogDir = shellEscape(logDir);
const escapedLogPath = shellEscape(logPath);
filename = `openclaw-restart-${timestamp}.sh`;
scriptContent = `#!/bin/sh
# Standalone restart script — survives parent process termination.
# Wait briefly to ensure file locks are released after update.
sleep 1
# Capture launchctl stderr so bootstrap/kickstart failures leave a durable
# audit trail. Without this, transient launchctl errors (plist-on-disk race,
# schema rejection, stale job) disappear into /dev/null and the updater
# reports success while the gateway is silently offline — see #68486.
mkdir -p "$(dirname '${escapedLogPath}')" 2>/dev/null || true
exec 2>>'${escapedLogPath}'
echo "[$(date -u +%FT%TZ)] openclaw update restart attempt (label=${escaped})" >&2
# Capture launchctl output so bootstrap/kickstart failures leave a durable
# audit trail. Log setup is best-effort: restart must still run if the log path
# is temporarily unavailable.
mkdir -p '${escapedLogDir}' 2>/dev/null || true
exec >>'${escapedLogPath}' 2>&1 || true
printf '[%s] openclaw update restart attempt (label=%s)\\n' "$(date -u +%FT%TZ)" '${escaped}' >&2
# Try kickstart first (works when the service is still registered).
# If it fails (e.g. after bootout), clear any persisted disabled state,
# then re-register via bootstrap and kickstart. Any step's stderr now
# lands in update-restart.log; the final kickstart no longer swallows
# its exit code with \`|| true\`, so a genuine failure exits non-zero and
# is inspectable.
# then re-register via bootstrap and kickstart. The final status is captured
# before self-cleanup so a genuine failure remains observable.
status=0
if ! launchctl kickstart -k 'gui/${uid}/${escaped}'; then
launchctl enable 'gui/${uid}/${escaped}'
launchctl bootstrap 'gui/${uid}' '${escapedPlistPath}'
launchctl kickstart -k 'gui/${uid}/${escaped}'
status=$?
fi
echo "[$(date -u +%FT%TZ)] openclaw update restart done" >&2
# Self-cleanup (log is retained under ~/.openclaw/logs/update-restart.log).
if [ "$status" -eq 0 ]; then
printf '[%s] openclaw update restart done\\n' "$(date -u +%FT%TZ)" >&2
else
printf '[%s] openclaw update restart failed status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
fi
# Self-cleanup (log is retained under the OpenClaw state logs directory).
rm -f "$0"
exit "$status"
`;
} else if (platform === "win32") {
const taskName = resolveWindowsTaskName(env);