Merge pull request #695 from jeffersonwarrior/jeff/no-sign-launchagent
macOS: stabilize launchagent in --no-sign
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- CLI/Status: make `status --all` scan progress determinate (OSC progress + spinner).
|
- 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.
|
- 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.
|
- 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
|
## 2026.1.11-4
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
||||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||||
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway"
|
||||||
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
@@ -50,6 +51,10 @@ enum GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
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 {
|
if enabled {
|
||||||
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
_ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
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
|
#if DEBUG
|
||||||
extension GatewayLaunchAgentManager {
|
extension GatewayLaunchAgentManager {
|
||||||
static func _testGatewayExecutablePath(bundlePath: String) -> String {
|
static func _testGatewayExecutablePath(bundlePath: String) -> String {
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ Steps:
|
|||||||
|
|
||||||
The UI should show “Using existing gateway …” once connected.
|
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
|
||||||
|
|
||||||
Remote mode never starts a local Gateway. The app uses an SSH tunnel to the
|
Remote mode never starts a local Gateway. The app uses an SSH tunnel to the
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ LOG_PATH="${CLAWDBOT_RESTART_LOG:-/tmp/clawdbot-restart.log}"
|
|||||||
NO_SIGN=0
|
NO_SIGN=0
|
||||||
SIGN=0
|
SIGN=0
|
||||||
AUTO_DETECT_SIGNING=1
|
AUTO_DETECT_SIGNING=1
|
||||||
|
GATEWAY_WAIT_SECONDS="${CLAWDBOT_GATEWAY_WAIT_SECONDS:-0}"
|
||||||
|
LAUNCHAGENT_DISABLE_MARKER="${HOME}/.clawdbot/disable-launchagent"
|
||||||
|
|
||||||
log() { printf '%s\n' "$*"; }
|
log() { printf '%s\n' "$*"; }
|
||||||
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
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 " --no-sign Force no code signing (fastest for development)"
|
||||||
log " --sign Force code signing (will fail if no signing key available)"
|
log " --sign Force code signing (will fail if no signing key available)"
|
||||||
log ""
|
log ""
|
||||||
|
log "Env:"
|
||||||
|
log " CLAWDBOT_GATEWAY_WAIT_SECONDS=0 Wait time before gateway port check (unsigned only)"
|
||||||
|
log ""
|
||||||
|
log "Unsigned recovery:"
|
||||||
|
log " defaults write <bundle-id> 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 <bundle-id> clawdbot.gateway.attachExistingOnly -bool NO"
|
||||||
|
log ""
|
||||||
log "Default behavior: Auto-detect signing keys, fallback to --no-sign if none found"
|
log "Default behavior: Auto-detect signing keys, fallback to --no-sign if none found"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
@@ -100,6 +114,9 @@ mkdir -p "$(dirname "$LOG_PATH")"
|
|||||||
rm -f "$LOG_PATH"
|
rm -f "$LOG_PATH"
|
||||||
exec > >(tee "$LOG_PATH") 2>&1
|
exec > >(tee "$LOG_PATH") 2>&1
|
||||||
log "==> Log: ${LOG_PATH}"
|
log "==> Log: ${LOG_PATH}"
|
||||||
|
if [[ "$NO_SIGN" -eq 1 ]]; then
|
||||||
|
log "==> Using --no-sign (unsigned flow enabled)"
|
||||||
|
fi
|
||||||
|
|
||||||
acquire_lock
|
acquire_lock
|
||||||
|
|
||||||
@@ -150,6 +167,8 @@ fi
|
|||||||
if [ "$NO_SIGN" -eq 1 ]; then
|
if [ "$NO_SIGN" -eq 1 ]; then
|
||||||
export ALLOW_ADHOC_SIGNING=1
|
export ALLOW_ADHOC_SIGNING=1
|
||||||
export SIGN_IDENTITY="-"
|
export SIGN_IDENTITY="-"
|
||||||
|
mkdir -p "${HOME}/.clawdbot"
|
||||||
|
run_step "disable launchagent writes" /usr/bin/touch "${LAUNCHAGENT_DISABLE_MARKER}"
|
||||||
elif [ "$SIGN" -eq 1 ]; then
|
elif [ "$SIGN" -eq 1 ]; then
|
||||||
if ! check_signing_keys; then
|
if ! check_signing_keys; then
|
||||||
fail "No signing identity found. Use --no-sign or install a signing key."
|
fail "No signing identity found. Use --no-sign or install a signing key."
|
||||||
@@ -184,6 +203,22 @@ choose_app_bundle() {
|
|||||||
|
|
||||||
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.
|
# 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.).
|
# 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.
|
# 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
|
else
|
||||||
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
||||||
fi
|
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
|
||||||
|
|||||||
@@ -774,7 +774,10 @@ describe("doctor", () => {
|
|||||||
const docker = sandbox.docker as Record<string, unknown>;
|
const docker = sandbox.docker as Record<string, unknown>;
|
||||||
|
|
||||||
expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim");
|
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 () => {
|
it("runs legacy state migrations in non-interactive mode without prompting", async () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
intro as clackIntro,
|
intro as clackIntro,
|
||||||
@@ -135,6 +137,53 @@ function noteOpencodeProviderOverrides(cfg: ClawdbotConfig) {
|
|||||||
note(lines.join("\n"), "OpenCode Zen");
|
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<boolean | null> {
|
||||||
|
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(
|
async function detectClawdbotGitCheckout(
|
||||||
root: string,
|
root: string,
|
||||||
): Promise<"git" | "not-git" | "unknown"> {
|
): Promise<"git" | "not-git" | "unknown"> {
|
||||||
@@ -368,6 +417,7 @@ export async function doctorCommand(
|
|||||||
runtime,
|
runtime,
|
||||||
prompter,
|
prompter,
|
||||||
);
|
);
|
||||||
|
await noteMacLaunchAgentOverrides();
|
||||||
|
|
||||||
await noteSecurityWarnings(cfg);
|
await noteSecurityWarnings(cfg);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user