mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix: harden macOS update restart helper (#68492) (thanks @hclsys)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user