diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1f13109de..77ded05fd03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4. - Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204. - Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639. +- Fix: macOS gateway stays attached across restarts (no port flapping) and status probes pick the right auth. (#982) — thanks @wes-davis. - CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands. - Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors. - Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`. diff --git a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift index d1371cdf1ad..ec281612fb4 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift @@ -178,7 +178,7 @@ enum GatewayEnvironment { let port = self.gatewayPort() if let gatewayBin { let bind = self.preferredGatewayBind() ?? "loopback" - let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] + let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind] return GatewayCommandResolution(status: status, command: cmd) } @@ -186,7 +186,7 @@ enum GatewayEnvironment { case let .success(resolvedRuntime) = runtime { let bind = self.preferredGatewayBind() ?? "loopback" - let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind] + let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind] return GatewayCommandResolution(status: status, command: cmd) } diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift index 2344181286a..8369dbb939c 100644 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift @@ -87,6 +87,14 @@ final class GatewayProcessManager { self.status = .stopped return } + // Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks). + // Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port. + switch self.status { + case .starting, .running, .attachedExisting: + return + case .stopped, .failed: + break + } self.status = .starting self.logger.debug("gateway start requested") diff --git a/apps/macos/Sources/Clawdbot/PortGuardian.swift b/apps/macos/Sources/Clawdbot/PortGuardian.swift index b0f03fa2863..4f823105198 100644 --- a/apps/macos/Sources/Clawdbot/PortGuardian.swift +++ b/apps/macos/Sources/Clawdbot/PortGuardian.swift @@ -351,10 +351,14 @@ actor PortGuardian { if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } return false case .local: - if !cmd.contains("clawdbot") { return false } - if full.contains("gateway-daemon") { return true } + // The gateway may listen as `clawdbot` or via its runtime (e.g. node). + let hasGatewayArgs = + full.contains(" gateway ") && + full.contains(" --port \(port)") + if hasGatewayArgs { return true } // If args are unavailable, treat a clawdbot listener as expected. - return full == cmd + if cmd.contains("clawdbot"), full == cmd { return true } + return false case .unconfigured: return false } diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift index ae8357b0ce6..195669ea55d 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift @@ -7,7 +7,7 @@ import Testing let url = FileManager.default.temporaryDirectory .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist") let plist: [String: Any] = [ - "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"], + "ProgramArguments": ["clawdbot", "gateway", "--port", "18789", "--bind", "loopback"], "EnvironmentVariables": [ "CLAWDBOT_GATEWAY_TOKEN": " secret ", "CLAWDBOT_GATEWAY_PASSWORD": "pw", @@ -28,7 +28,7 @@ import Testing let url = FileManager.default.temporaryDirectory .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist") let plist: [String: Any] = [ - "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"], + "ProgramArguments": ["clawdbot", "gateway", "--port", "18789"], ] let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) try data.write(to: url, options: [.atomic]) diff --git a/docs/platforms/hetzner.md b/docs/platforms/hetzner.md index 3360017bb04..0f406d56132 100644 --- a/docs/platforms/hetzner.md +++ b/docs/platforms/hetzner.md @@ -187,7 +187,7 @@ services: [ "node", "dist/index.js", - "gateway-daemon", + "gateway", "--bind", "${CLAWDBOT_GATEWAY_BIND}", "--port", diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index d75aac24d22..8187a9148b6 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -206,6 +206,31 @@ if [[ "$NO_SIGN" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}" fi +# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (before the app launches). +# This reduces noisy "could not connect" errors during app startup. +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 + GATEWAY_PORT="$( + node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + try { + const raw = fs.readFileSync(path.join(process.env.HOME, ".clawdbot", "clawdbot.json"), "utf8"); + const cfg = JSON.parse(raw); + const port = cfg && cfg.gateway && typeof cfg.gateway.port === "number" ? cfg.gateway.port : 18789; + process.stdout.write(String(port)); + } catch { + process.stdout.write("18789"); + } + ' + )" + run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" bash -lc "lsof -iTCP:${GATEWAY_PORT} -sTCP:LISTEN | head -n 5 || true" +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. @@ -226,13 +251,6 @@ else fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)." fi -# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (after the app launches). 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/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 0a4361140e1..88faa3642d0 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -53,13 +53,6 @@ export function registerGatewayCli(program: Command) { ), ); - // Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias. - addGatewayRunCommand( - program - .command("gateway-daemon", { hidden: true }) - .description("Run the WebSocket Gateway as a long-lived daemon"), - ); - gatewayCallOpts( gateway .command("call") diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index f32fbabed59..924dbf06efb 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -119,10 +119,11 @@ export async function statusAllCommand( const localFallbackAuth = resolveProbeAuth("local"); const remoteAuth = resolveProbeAuth("remote"); + const probeAuth = isRemoteMode && !remoteUrlMissing ? remoteAuth : localFallbackAuth; const gatewayProbe = await probeGateway({ url: connection.url, - auth: remoteUrlMissing ? localFallbackAuth : remoteAuth, + auth: probeAuth, timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000), }).catch(() => null); const gatewayReachable = gatewayProbe?.ok === true; @@ -291,7 +292,7 @@ export async function statusAllCommand( ? `unreachable (${gatewayProbe.error})` : "unreachable"; const gatewayAuth = gatewayReachable - ? ` · auth ${formatGatewayAuthUsed(remoteUrlMissing ? localFallbackAuth : remoteAuth)}` + ? ` · auth ${formatGatewayAuthUsed(probeAuth)}` : ""; const gatewaySelfLine = gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform