diff --git a/CHANGELOG.md b/CHANGELOG.md index 592fa8c5a55..8679be724b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 5cbb075036e..f00b513edba 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -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) { + 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; diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index 934c2b4b9ea..c2c200ceef2 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -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);