diff --git a/CHANGELOG.md b/CHANGELOG.md index 190d71e73..f57be7dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - CLI/Status: make `status --all` scan progress determinate (OSC progress + spinner). - Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage. - CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter. +- macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior. ## 2026.1.11-4 diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index a7b2c6003..bcb9f49de 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -4,6 +4,7 @@ enum GatewayLaunchAgentManager { private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd") private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway" + private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" private static var plistURL: URL { FileManager.default.homeDirectoryForCurrentUser @@ -50,6 +51,10 @@ enum GatewayLaunchAgentManager { } static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { + if enabled, self.isLaunchAgentWriteDisabled() { + self.logger.info("launchd enable skipped (attach-only or disable marker set)") + return nil + } if enabled { _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"]) try? FileManager.default.removeItem(at: self.legacyPlistURL) @@ -301,6 +306,17 @@ enum GatewayLaunchAgentManager { } } +extension GatewayLaunchAgentManager { + private static func isLaunchAgentWriteDisabled() -> Bool { + if UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey) { + return true + } + let marker = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(self.disableLaunchAgentMarker) + return FileManager.default.fileExists(atPath: marker.path) + } +} + #if DEBUG extension GatewayLaunchAgentManager { static func _testGatewayExecutablePath(bundlePath: String) -> String { diff --git a/docs/platforms/mac/child-process.md b/docs/platforms/mac/child-process.md index 142147c53..36219b636 100644 --- a/docs/platforms/mac/child-process.md +++ b/docs/platforms/mac/child-process.md @@ -44,6 +44,22 @@ Steps: The UI should show “Using existing gateway …” once connected. +## Unsigned dev builds + +`scripts/restart-mac.sh --no-sign` is for fast local builds when you don’t have +signing keys. To prevent launchd from pointing at an unsigned relay binary, it: + +- Writes `~/.clawdbot/disable-launchagent`. +- Sets `clawdbot.gateway.attachExistingOnly=true` in the macOS app defaults. + +Signed runs of `scripts/restart-mac.sh` clear these overrides if the marker is +present. To reset manually: + +```bash +rm ~/.clawdbot/disable-launchagent +defaults write com.clawdbot.mac clawdbot.gateway.attachExistingOnly -bool NO +``` + ## Remote mode Remote mode never starts a local Gateway. The app uses an SSH tunnel to the diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index dc21d1699..9359a715e 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -18,6 +18,8 @@ LOG_PATH="${CLAWDBOT_RESTART_LOG:-/tmp/clawdbot-restart.log}" NO_SIGN=0 SIGN=0 AUTO_DETECT_SIGNING=1 +GATEWAY_WAIT_SECONDS="${CLAWDBOT_GATEWAY_WAIT_SECONDS:-0}" +LAUNCHAGENT_DISABLE_MARKER="${HOME}/.clawdbot/disable-launchagent" log() { printf '%s\n' "$*"; } fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } @@ -85,6 +87,18 @@ for arg in "$@"; do log " --no-sign Force no code signing (fastest for development)" log " --sign Force code signing (will fail if no signing key available)" log "" + log "Env:" + log " CLAWDBOT_GATEWAY_WAIT_SECONDS=0 Wait time before gateway port check (unsigned only)" + log "" + log "Unsigned recovery:" + log " defaults write clawdbot.gateway.attachExistingOnly -bool YES" + log " node dist/entry.js daemon install --force --runtime node" + log " node dist/entry.js daemon restart" + log "" + log "Reset unsigned overrides:" + log " rm ~/.clawdbot/disable-launchagent" + log " defaults write clawdbot.gateway.attachExistingOnly -bool NO" + log "" log "Default behavior: Auto-detect signing keys, fallback to --no-sign if none found" exit 0 ;; @@ -100,6 +114,9 @@ mkdir -p "$(dirname "$LOG_PATH")" rm -f "$LOG_PATH" exec > >(tee "$LOG_PATH") 2>&1 log "==> Log: ${LOG_PATH}" +if [[ "$NO_SIGN" -eq 1 ]]; then + log "==> Using --no-sign (unsigned flow enabled)" +fi acquire_lock @@ -150,6 +167,8 @@ fi if [ "$NO_SIGN" -eq 1 ]; then export ALLOW_ADHOC_SIGNING=1 export SIGN_IDENTITY="-" + mkdir -p "${HOME}/.clawdbot" + run_step "disable launchagent writes" /usr/bin/touch "${LAUNCHAGENT_DISABLE_MARKER}" elif [ "$SIGN" -eq 1 ]; then if ! check_signing_keys; then fail "No signing identity found. Use --no-sign or install a signing key." @@ -184,6 +203,22 @@ choose_app_bundle() { choose_app_bundle +APP_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "${APP_BUNDLE}/Contents/Info.plist" 2>/dev/null || true)" + +# When unsigned, avoid the app overwriting the LaunchAgent with the relay binary. +if [ "$NO_SIGN" -eq 1 ]; then + if [[ -n "${APP_BUNDLE_ID}" ]]; then + run_step "set attach-existing-only" \ + /usr/bin/defaults write "${APP_BUNDLE_ID}" clawdbot.gateway.attachExistingOnly -bool YES + fi +elif [[ -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then + run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}" + if [[ -n "${APP_BUNDLE_ID}" ]]; then + run_step "unset attach-existing-only" \ + /usr/bin/defaults write "${APP_BUNDLE_ID}" clawdbot.gateway.attachExistingOnly -bool NO + fi +fi + # 4) Launch the installed app in the foreground so the menu bar extra appears. # LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.). # That can cause launchd spawn failures and is undesirable for a GUI app anyway. @@ -203,3 +238,15 @@ if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then else fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)." fi + +# When unsigned, launchd cannot exec the app relay binary. Ensure the gateway +# LaunchAgent targets the repo CLI instead (after the app has launched). +if [ "$NO_SIGN" -eq 1 ]; then + run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node" + run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart" + if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then + run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}" + fi + run_step "verify gateway port 18789 (unsigned)" bash -lc "lsof -iTCP:18789 -sTCP:LISTEN | head -n 5 || true" + run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true" +fi diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index dc2e7581d..c4d195126 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -774,7 +774,10 @@ describe("doctor", () => { const docker = sandbox.docker as Record; expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim"); - expect(runCommandWithTimeout).not.toHaveBeenCalled(); + const defaultsCalls = runCommandWithTimeout.mock.calls.filter( + ([args]) => Array.isArray(args) && args[0] === "/usr/bin/defaults", + ); + expect(defaultsCalls.length).toBe(runCommandWithTimeout.mock.calls.length); }); it("runs legacy state migrations in non-interactive mode without prompting", async () => { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 460691ba1..614901ed8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { intro as clackIntro, @@ -135,6 +137,53 @@ function noteOpencodeProviderOverrides(cfg: ClawdbotConfig) { note(lines.join("\n"), "OpenCode Zen"); } +const MAC_APP_BUNDLE_ID = "com.clawdbot.mac"; +const MAC_ATTACH_EXISTING_ONLY_KEY = "clawdbot.gateway.attachExistingOnly"; + +function resolveHomeDir(): string { + return process.env.HOME ?? os.homedir(); +} + +async function readMacAttachExistingOnly(): Promise { + const result = await runCommandWithTimeout( + [ + "/usr/bin/defaults", + "read", + MAC_APP_BUNDLE_ID, + MAC_ATTACH_EXISTING_ONLY_KEY, + ], + { timeoutMs: 2000 }, + ).catch(() => null); + if (!result || result.code !== 0) return null; + const raw = result.stdout.trim().toLowerCase(); + if (["1", "true", "yes"].includes(raw)) return true; + if (["0", "false", "no"].includes(raw)) return false; + return null; +} + +async function noteMacLaunchAgentOverrides() { + if (process.platform !== "darwin") return; + const markerPath = path.join( + resolveHomeDir(), + ".clawdbot", + "disable-launchagent", + ); + const hasMarker = fs.existsSync(markerPath); + const attachOnly = await readMacAttachExistingOnly(); + if (!hasMarker && attachOnly !== true) return; + + const lines = [ + hasMarker ? `- LaunchAgent writes are disabled via ${markerPath}.` : null, + attachOnly === true + ? `- macOS app is set to Attach-only (${MAC_APP_BUNDLE_ID}:${MAC_ATTACH_EXISTING_ONLY_KEY}=true).` + : null, + "- To restore default behavior:", + ` rm ${markerPath}`, + ` defaults write ${MAC_APP_BUNDLE_ID} ${MAC_ATTACH_EXISTING_ONLY_KEY} -bool NO`, + ].filter((line): line is string => Boolean(line)); + note(lines.join("\n"), "Gateway (macOS)"); +} + async function detectClawdbotGitCheckout( root: string, ): Promise<"git" | "not-git" | "unknown"> { @@ -368,6 +417,7 @@ export async function doctorCommand( runtime, prompter, ); + await noteMacLaunchAgentOverrides(); await noteSecurityWarnings(cfg);