diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index cba22205262..5cbb075036e 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -111,6 +111,28 @@ describe("restart-helper", () => { await cleanupScript(scriptPath); }); + it("captures macOS launchctl stderr to ~/.openclaw/logs/update-restart.log (#68486)", async () => { + // Silent failure in macOS update restart helper: previously every + // launchctl call redirected stderr to /dev/null and the final kickstart + // was chained with `|| true`, so bootstrap/kickstart failures were + // invisible and the gateway stayed offline while the updater reported + // success. The script should now route stderr to a durable log file and + // stop swallowing the final exit code. + Object.defineProperty(process, "platform", { value: "darwin" }); + process.getuid = () => 501; + + const { scriptPath, content } = await prepareAndReadScript({ + 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`) + // 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_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 f4bcb793ed3..934c2b4b9ea 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -90,20 +90,33 @@ 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 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 # 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. -if ! launchctl kickstart -k 'gui/${uid}/${escaped}' 2>/dev/null; then - launchctl enable 'gui/${uid}/${escaped}' 2>/dev/null - launchctl bootstrap 'gui/${uid}' '${escapedPlistPath}' 2>/dev/null - launchctl kickstart -k 'gui/${uid}/${escaped}' 2>/dev/null || true +# 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. +if ! launchctl kickstart -k 'gui/${uid}/${escaped}'; then + launchctl enable 'gui/${uid}/${escaped}' + launchctl bootstrap 'gui/${uid}' '${escapedPlistPath}' + launchctl kickstart -k 'gui/${uid}/${escaped}' fi -# Self-cleanup +echo "[$(date -u +%FT%TZ)] openclaw update restart done" >&2 +# Self-cleanup (log is retained under ~/.openclaw/logs/update-restart.log). rm -f "$0" `; } else if (platform === "win32") {