From dfea2991c94f8afcefc1d04df0790c049f277db7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 03:35:24 +0000 Subject: [PATCH] fix(daemon): clear launchd disabled state before bootstrap (#849) (thanks @ndraiman) --- CHANGELOG.md | 1 + src/daemon/launchd.test.ts | 96 +++++++++++++++++++++++++++++++++++++- src/daemon/launchd.ts | 2 +- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23833b6fd..1bfabffc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. +- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman. - Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06. - Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer. - Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer. diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index a996da479..e0d23fd23 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -1,6 +1,11 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; + import { describe, expect, it } from "vitest"; -import { parseLaunchctlPrint } from "./launchd.js"; +import { installLaunchAgent, parseLaunchctlPrint } from "./launchd.js"; describe("launchd runtime parsing", () => { it("parses state, pid, and exit status", () => { @@ -18,3 +23,92 @@ describe("launchd runtime parsing", () => { }); }); }); + +describe("launchd install", () => { + it("enables service before bootstrap (clears persisted disabled state)", async () => { + const originalPath = process.env.PATH; + const originalLogPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG; + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-launchctl-test-")); + try { + const binDir = path.join(tmpDir, "bin"); + const homeDir = path.join(tmpDir, "home"); + const logPath = path.join(tmpDir, "launchctl.log"); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(homeDir, { recursive: true }); + + const stubJsPath = path.join(binDir, "launchctl.js"); + await fs.writeFile( + stubJsPath, + [ + 'import fs from "node:fs";', + "const logPath = process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG;", + "if (logPath) {", + " fs.appendFileSync(logPath, JSON.stringify(process.argv.slice(2)) + \"\\n\", \"utf8\");", + "}", + "process.exit(0);", + "", + ].join("\n"), + "utf8", + ); + + if (process.platform === "win32") { + await fs.writeFile( + path.join(binDir, "launchctl.cmd"), + `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, + "utf8", + ); + } else { + const shPath = path.join(binDir, "launchctl"); + await fs.writeFile( + shPath, + `#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`, + "utf8", + ); + await fs.chmod(shPath, 0o755); + } + + process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = logPath; + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + + const env: Record = { + HOME: homeDir, + CLAWDBOT_PROFILE: "default", + }; + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: ["node", "-e", "process.exit(0)"], + }); + + const calls = (await fs.readFile(logPath, "utf8")) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as string[]); + + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const label = "com.clawdbot.gateway"; + const plistPath = path.join(homeDir, "Library", "LaunchAgents", `${label}.plist`); + const serviceId = `${domain}/${label}`; + + const enableCalls = calls.filter((c) => c[0] === "enable" && c[1] === serviceId); + expect(enableCalls).toHaveLength(1); + + const enableIndex = calls.findIndex((c) => c[0] === "enable" && c[1] === serviceId); + const bootstrapIndex = calls.findIndex( + (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, + ); + expect(enableIndex).toBeGreaterThanOrEqual(0); + expect(bootstrapIndex).toBeGreaterThanOrEqual(0); + expect(enableIndex).toBeLessThan(bootstrapIndex); + } finally { + process.env.PATH = originalPath; + if (originalLogPath === undefined) { + delete process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG; + } else { + process.env.CLAWDBOT_TEST_LAUNCHCTL_LOG = originalLogPath; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index a59c9d330..0d72e9d23 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -410,12 +410,12 @@ export async function installLaunchAgent({ await execLaunchctl(["bootout", domain, plistPath]); await execLaunchctl(["unload", plistPath]); + // launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap. await execLaunchctl(["enable", `${domain}/${label}`]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); if (boot.code !== 0) { throw new Error(`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim()); } - await execLaunchctl(["enable", `${domain}/${label}`]); await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); stdout.write(`${formatLine("Installed LaunchAgent", plistPath)}\n`);