refactor: remove mac attach-only setting

This commit is contained in:
Peter Steinberger
2026-01-12 04:38:42 +00:00
parent 8e1cdf3a1f
commit 51d5f16770
15 changed files with 18 additions and 126 deletions

View File

@@ -182,14 +182,6 @@ final class AppState {
} }
} }
var attachExistingGatewayOnly: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.attachExistingGatewayOnly, forKey: attachExistingGatewayOnlyKey)
}
}
}
var remoteTarget: String { var remoteTarget: String {
didSet { didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) } self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
@@ -302,8 +294,6 @@ final class AppState {
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.peekabooBridgeEnabled = UserDefaults.standard self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
self.attachExistingGatewayOnly = UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
if !self.isPreview { if !self.isPreview {
Task.detached(priority: .utility) { [weak self] in Task.detached(priority: .utility) { [weak self] in
let current = await LaunchAgentManager.status() let current = await LaunchAgentManager.status()
@@ -604,7 +594,6 @@ extension AppState {
state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdbot" state.remoteProjectRoot = "~/Projects/clawdbot"
state.remoteCliPath = "" state.remoteCliPath = ""
state.attachExistingGatewayOnly = false
return state return state
} }
} }
@@ -624,9 +613,6 @@ enum AppStateStore {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
} }
static var attachExistingGatewayOnly: Bool {
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
}
} }
@MainActor @MainActor

View File

@@ -405,10 +405,6 @@ enum CommandResolver {
cliPath: cliPath) cliPath: cliPath)
} }
static var attachExistingGatewayOnly: Bool {
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
}
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool { static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
self.connectionSettings(defaults: defaults).mode == .remote self.connectionSettings(defaults: defaults).mode == .remote
} }

View File

@@ -27,8 +27,7 @@ final class ConnectionModeCoordinator {
GatewayProcessManager.shared.setActive(true) GatewayProcessManager.shared.setActive(true)
if GatewayAutostartPolicy.shouldEnsureLaunchAgent( if GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local, mode: .local,
paused: paused, paused: paused)
attachExistingOnly: AppStateStore.attachExistingGatewayOnly)
{ {
Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() } Task { await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() }
} }

View File

@@ -32,7 +32,6 @@ let peekabooBridgeEnabledKey = "clawdbot.peekabooBridgeEnabled"
let deepLinkKeyKey = "clawdbot.deepLinkKey" let deepLinkKeyKey = "clawdbot.deepLinkKey"
let modelCatalogPathKey = "clawdbot.modelCatalogPath" let modelCatalogPathKey = "clawdbot.modelCatalogPath"
let modelCatalogReloadKey = "clawdbot.modelCatalogReload" let modelCatalogReloadKey = "clawdbot.modelCatalogReload"
let attachExistingGatewayOnlyKey = "clawdbot.gateway.attachExistingOnly"
let cliInstallPromptedVersionKey = "clawdbot.cliInstallPromptedVersion" let cliInstallPromptedVersionKey = "clawdbot.cliInstallPromptedVersion"
let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled" let heartbeatsEnabledKey = "clawdbot.heartbeatsEnabled"
let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled" let debugFileLogEnabledKey = "clawdbot.debug.fileLogEnabled"

View File

@@ -212,12 +212,6 @@ final class ControlChannel {
return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry." return "Gateway connection was closed; start the gateway (localhost:\(port)) and retry."
case .cannotFindHost, .cannotConnectToHost: case .cannotFindHost, .cannotConnectToHost:
let isRemote = CommandResolver.connectionModeIsRemote() let isRemote = CommandResolver.connectionModeIsRemote()
if AppStateStore.attachExistingGatewayOnly, !isRemote {
return """
Cannot reach gateway at localhost:\(port) and “Attach existing gateway only” is enabled.
Disable it in Debug Settings or start a gateway on that port.
"""
}
if isRemote { if isRemote {
return """ return """
Cannot reach gateway at localhost:\(port). Cannot reach gateway at localhost:\(port).

View File

@@ -772,7 +772,7 @@ struct DebugSettings: View {
} }
private var canRestartGateway: Bool { private var canRestartGateway: Bool {
self.state.connectionMode == .local && !self.state.attachExistingGatewayOnly self.state.connectionMode == .local
} }
private func configURL() -> URL { private func configURL() -> URL {

View File

@@ -7,9 +7,8 @@ enum GatewayAutostartPolicy {
static func shouldEnsureLaunchAgent( static func shouldEnsureLaunchAgent(
mode: AppState.ConnectionMode, mode: AppState.ConnectionMode,
paused: Bool, paused: Bool) -> Bool
attachExistingOnly: Bool) -> Bool
{ {
self.shouldStartGateway(mode: mode, paused: paused) && !attachExistingOnly self.shouldStartGateway(mode: mode, paused: paused)
} }
} }

View File

@@ -69,7 +69,6 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async { func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return } guard !CommandResolver.connectionModeIsRemote() else { return }
guard !AppStateStore.attachExistingGatewayOnly else { return }
let enabled = await GatewayLaunchAgentManager.isLoaded() let enabled = await GatewayLaunchAgentManager.isLoaded()
guard !enabled else { return } guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path let bundlePath = Bundle.main.bundleURL.path
@@ -97,15 +96,6 @@ final class GatewayProcessManager {
if await self.attachExistingGatewayIfAvailable() { if await self.attachExistingGatewayIfAvailable() {
return return
} }
// Respect debug toggle: only attach, never spawn, when enabled.
if AppStateStore.attachExistingGatewayOnly {
await MainActor.run {
self.status = .failed("Attach-only enabled; no gateway to attach")
self.appendLog("[gateway] attach-only enabled; not spawning local gateway\n")
self.logger.warning("gateway attach-only enabled; not spawning")
}
return
}
await self.enableLaunchdGateway() await self.enableLaunchdGateway()
} }
} }

View File

@@ -204,11 +204,6 @@ struct GeneralSettings: View {
if !self.isNixMode { if !self.isNixMode {
self.gatewayInstallerCard self.gatewayInstallerCard
} }
SettingsToggleRow(
title: "Attach only",
subtitle: "Use this when the gateway runs externally; the mac app will only attach " +
"to an already-running gateway and won't start one locally.",
binding: self.$state.attachExistingGatewayOnly)
TailscaleIntegrationSection( TailscaleIntegrationSection(
connectionMode: self.state.connectionMode, connectionMode: self.state.connectionMode,
isPaused: self.state.isPaused) isPaused: self.state.isPaused)

View File

@@ -279,7 +279,7 @@ struct MenuContent: View {
Label("Send Test Notification", systemImage: "bell") Label("Send Test Notification", systemImage: "bell")
} }
Divider() Divider()
if self.state.connectionMode == .local, !AppStateStore.attachExistingGatewayOnly { if self.state.connectionMode == .local {
Button { Button {
DebugActions.restartGateway() DebugActions.restartGateway()
} label: { } label: {

View File

@@ -13,19 +13,12 @@ struct GatewayAutostartPolicyTests {
@Test func ensuresLaunchAgentWhenLocalAndNotAttachOnly() { @Test func ensuresLaunchAgentWhenLocalAndNotAttachOnly() {
#expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent( #expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local, mode: .local,
paused: false, paused: false))
attachExistingOnly: false))
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local, mode: .local,
paused: false, paused: true))
attachExistingOnly: true))
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .local,
paused: true,
attachExistingOnly: false))
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent( #expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
mode: .remote, mode: .remote,
paused: false, paused: false))
attachExistingOnly: false))
} }
} }

View File

@@ -1,5 +1,5 @@
--- ---
summary: "Gateway lifecycle on macOS (launchd + attach-only)" summary: "Gateway lifecycle on macOS (launchd)"
read_when: read_when:
- Integrating the mac app with the gateway lifecycle - Integrating the mac app with the gateway lifecycle
--- ---
@@ -10,8 +10,7 @@ uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
autostart at login and restart on crashes. autostart at login and restart on crashes.
Childprocess mode (Gateway spawned directly by the app) is **not in use** today. Childprocess mode (Gateway spawned directly by the app) is **not in use** today.
If you need tighter coupling to the UI, use **Attachonly** and run the Gateway If you need tighter coupling to the UI, run the Gateway manually in a terminal.
manually in a terminal.
## Default behavior (launchd) ## Default behavior (launchd)
@@ -30,35 +29,18 @@ launchctl bootout gui/$UID/com.clawdbot.gateway
Replace the label with `com.clawdbot.<profile>` when running a named profile. Replace the label with `com.clawdbot.<profile>` when running a named profile.
## Attachonly (developer mode)
Attachonly tells the app to **connect to an existing Gateway** without spawning
one. This is ideal for local dev (hotreload, custom flags).
Steps:
1) Start the Gateway yourself:
```bash
pnpm gateway:watch
```
2) In the macOS app: Debug Settings → Gateway → **Attach only**.
The UI should show “Using existing gateway …” once connected.
## Unsigned dev builds ## Unsigned dev builds
`scripts/restart-mac.sh --no-sign` is for fast local builds when you dont have `scripts/restart-mac.sh --no-sign` is for fast local builds when you dont have
signing keys. To prevent launchd from pointing at an unsigned relay binary, it: signing keys. To prevent launchd from pointing at an unsigned relay binary, it:
- Writes `~/.clawdbot/disable-launchagent`. - 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 Signed runs of `scripts/restart-mac.sh` clear this override if the marker is
present. To reset manually: present. To reset manually:
```bash ```bash
rm ~/.clawdbot/disable-launchagent rm ~/.clawdbot/disable-launchagent
defaults write com.clawdbot.mac clawdbot.gateway.attachExistingOnly -bool NO
``` ```
## Remote mode ## Remote mode

View File

@@ -12,7 +12,7 @@ Last updated: 2026-01-01
## TL;DR ## TL;DR
- **Tailoring lives outside the repo:** `~/clawd` (workspace) + `~/.clawdbot/clawdbot.json` (config). - **Tailoring lives outside the repo:** `~/clawd` (workspace) + `~/.clawdbot/clawdbot.json` (config).
- **Stable workflow:** install the macOS app; let it run the bundled Gateway. - **Stable workflow:** install the macOS app; let it run the bundled Gateway.
- **Bleeding edge workflow:** run the Gateway yourself via `pnpm gateway:watch`, then point the macOS app at it using **Debug Settings → Gateway → Attach only**. - **Bleeding edge workflow:** run the Gateway yourself via `pnpm gateway:watch`, then let the macOS app attach in Local mode.
## Prereqs (from source) ## Prereqs (from source)
- Node `>=22` - Node `>=22`
@@ -84,9 +84,7 @@ pnpm gateway:watch
In **Clawdbot.app**: In **Clawdbot.app**:
- Connection Mode: **Local** - Connection Mode: **Local**
- Settings → **Debug Settings****Gateway** → enable **Attach only** The app will attach to the running gateway on the configured port.
This makes the app **only connect to an already-running gateway** and **never spawn** its own.
### 3) Verify ### 3) Verify
@@ -98,7 +96,6 @@ pnpm clawdbot health
``` ```
### Common footguns ### Common footguns
- **Attach only enabled, but nothing is running:** app shows “Attach-only enabled; no gateway to attach”.
- **Wrong port:** Gateway WS defaults to `ws://127.0.0.1:18789`; keep app + CLI on the same port. - **Wrong port:** Gateway WS defaults to `ws://127.0.0.1:18789`; keep app + CLI on the same port.
- **Where state lives:** - **Where state lives:**
- Credentials: `~/.clawdbot/credentials/` - Credentials: `~/.clawdbot/credentials/`
@@ -129,4 +126,4 @@ user service (no lingering needed). See [Gateway runbook](/gateway) for the syst
- [Gateway configuration](/gateway/configuration) (config schema + examples) - [Gateway configuration](/gateway/configuration) (config schema + examples)
- [Discord](/providers/discord) and [Telegram](/providers/telegram) (reply tags + replyToMode settings) - [Discord](/providers/discord) and [Telegram](/providers/telegram) (reply tags + replyToMode settings)
- [Clawdbot assistant setup](/start/clawd) - [Clawdbot assistant setup](/start/clawd)
- [macOS app](/platforms/macos) (gateway lifecycle + “Attach only”) - [macOS app](/platforms/macos) (gateway lifecycle)

View File

@@ -91,13 +91,11 @@ for arg in "$@"; do
log " CLAWDBOT_GATEWAY_WAIT_SECONDS=0 Wait time before gateway port check (unsigned only)" log " CLAWDBOT_GATEWAY_WAIT_SECONDS=0 Wait time before gateway port check (unsigned only)"
log "" log ""
log "Unsigned recovery:" 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 install --force --runtime node"
log " node dist/entry.js daemon restart" log " node dist/entry.js daemon restart"
log "" log ""
log "Reset unsigned overrides:" log "Reset unsigned overrides:"
log " rm ~/.clawdbot/disable-launchagent" log " rm ~/.clawdbot/disable-launchagent"
log " defaults write <bundle-id> clawdbot.gateway.attachExistingOnly -bool NO"
log "" 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
@@ -203,20 +201,9 @@ 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 signed, clear any previous launchagent override marker.
if [[ "$NO_SIGN" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
# When unsigned, avoid the app overwriting the LaunchAgent while iterating.
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}" 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 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.

View File

@@ -133,30 +133,10 @@ 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 { function resolveHomeDir(): string {
return process.env.HOME ?? os.homedir(); 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() { async function noteMacLaunchAgentOverrides() {
if (process.platform !== "darwin") return; if (process.platform !== "darwin") return;
const markerPath = path.join( const markerPath = path.join(
@@ -165,17 +145,12 @@ async function noteMacLaunchAgentOverrides() {
"disable-launchagent", "disable-launchagent",
); );
const hasMarker = fs.existsSync(markerPath); const hasMarker = fs.existsSync(markerPath);
const attachOnly = await readMacAttachExistingOnly(); if (!hasMarker) return;
if (!hasMarker && attachOnly !== true) return;
const lines = [ const lines = [
hasMarker ? `- LaunchAgent writes are disabled via ${markerPath}.` : null, `- LaunchAgent writes are disabled via ${markerPath}.`,
attachOnly === true
? `- macOS app is set to Attach-only (${MAC_APP_BUNDLE_ID}:${MAC_ATTACH_EXISTING_ONLY_KEY}=true).`
: null,
"- To restore default behavior:", "- To restore default behavior:",
` rm ${markerPath}`, ` rm ${markerPath}`,
` defaults write ${MAC_APP_BUNDLE_ID} ${MAC_ATTACH_EXISTING_ONLY_KEY} -bool NO`,
].filter((line): line is string => Boolean(line)); ].filter((line): line is string => Boolean(line));
note(lines.join("\n"), "Gateway (macOS)"); note(lines.join("\n"), "Gateway (macOS)");
} }