fix: macos wizard auth bootstrap

This commit is contained in:
Peter Steinberger
2026-01-15 08:47:45 +00:00
parent 1afdb850f3
commit 5f87f7bbf5
54 changed files with 467 additions and 377 deletions

View File

@@ -67,6 +67,7 @@
- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani. - Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani.
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. - macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. - UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. - TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`. - TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.

View File

@@ -33,14 +33,16 @@ actor GatewayEndpointStore {
return GatewayEndpointStore.resolveGatewayToken( return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(), isRemote: CommandResolver.connectionModeIsRemote(),
root: root, root: root,
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
}, },
password: { password: {
let root = ClawdbotConfigFile.loadDict() let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword( return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(), isRemote: CommandResolver.connectionModeIsRemote(),
root: root, root: root,
env: ProcessInfo.processInfo.environment) env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
}, },
localPort: { GatewayEnvironment.gatewayPort() }, localPort: { GatewayEnvironment.gatewayPort() },
localHost: { localHost: {
@@ -60,7 +62,8 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword( private static func resolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{ {
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -88,13 +91,19 @@ actor GatewayEndpointStore {
return pw return pw
} }
} }
if let password = launchdSnapshot?.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
return password
}
return nil return nil
} }
private static func resolveGatewayToken( private static func resolveGatewayToken(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?) -> String?
{ {
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -122,6 +131,11 @@ actor GatewayEndpointStore {
return value return value
} }
} }
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil return nil
} }
@@ -430,9 +444,19 @@ extension GatewayEndpointStore {
static func _testResolveGatewayPassword( static func _testResolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String]) -> String? env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{ {
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env) self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
}
static func _testResolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil) -> String?
{
self.resolveGatewayToken(isRemote: isRemote, root: root, env: env, launchdSnapshot: launchdSnapshot)
} }
static func _testResolveGatewayBindMode( static func _testResolveGatewayBindMode(

View File

@@ -306,6 +306,10 @@ enum GatewayLaunchAgentManager {
password: snapshot.password) password: snapshot.password)
} }
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
LaunchAgentPlist.snapshot(url: self.plistURL)
}
private static func ensureEnabled() async { private static func ensureEnabled() async {
let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return } guard result.status != 0 else { return }

View File

@@ -58,6 +58,13 @@ final class OnboardingWizardModel {
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
guard self.sessionId == nil, !self.isStarting else { return } guard self.sessionId == nil, !self.isStarting else { return }
guard mode == .local else { return } guard mode == .local else { return }
if self.shouldSkipWizard() {
self.sessionId = nil
self.currentStep = nil
self.status = "done"
self.errorMessage = nil
return
}
self.isStarting = true self.isStarting = true
self.errorMessage = nil self.errorMessage = nil
self.lastStartMode = mode self.lastStartMode = mode
@@ -177,6 +184,33 @@ final class OnboardingWizardModel {
Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) }
return true return true
} }
private func shouldSkipWizard() -> Bool {
let root = ClawdbotConfigFile.loadDict()
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
return true
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any]
{
if let mode = auth["mode"] as? String,
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let token = auth["token"] as? String,
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let password = auth["password"] as? String,
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
}
return false
}
} }
struct OnboardingWizardStepView: View { struct OnboardingWizardStepView: View {

View File

@@ -1,36 +1,63 @@
import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized) @Suite struct GatewayEndpointStoreTests {
struct GatewayEndpointStoreTests { @Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
@Test func resolvesLocalHostFromBindModes() { let snapshot = LaunchAgentPlistSnapshot(
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( programArguments: [],
bindMode: "loopback", environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
tailscaleIP: "100.64.0.10") == "127.0.0.1") port: nil,
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( bind: nil,
bindMode: "lan", token: "launchd-token",
tailscaleIP: "100.64.0.10") == "127.0.0.1") password: nil)
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet", let envToken = GatewayEndpointStore._testResolveGatewayToken(
tailscaleIP: "100.64.0.10") == "100.64.0.10") isRemote: false,
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( root: [:],
bindMode: "tailnet", env: ["CLAWDBOT_GATEWAY_TOKEN": "env-token"],
tailscaleIP: nil) == "127.0.0.1") launchdSnapshot: snapshot)
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( #expect(envToken == "env-token")
bindMode: "auto",
tailscaleIP: "100.64.0.10") == "100.64.0.10") let fallbackToken = GatewayEndpointStore._testResolveGatewayToken(
#expect(GatewayEndpointStore._testResolveLocalGatewayHost( isRemote: false,
bindMode: "auto", root: [:],
tailscaleIP: nil) == "127.0.0.1") env: [:],
launchdSnapshot: snapshot)
#expect(fallbackToken == "launchd-token")
} }
@Test func resolvesBindModeFromEnvOrConfig() { @Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() {
let root: [String: Any] = ["gateway": ["bind": "tailnet"]] let snapshot = LaunchAgentPlistSnapshot(
#expect(GatewayEndpointStore._testResolveGatewayBindMode( programArguments: [],
root: root, environment: ["CLAWDBOT_GATEWAY_TOKEN": "launchd-token"],
env: [:]) == "tailnet") port: nil,
#expect(GatewayEndpointStore._testResolveGatewayBindMode( bind: nil,
root: root, token: "launchd-token",
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan") password: nil)
let token = GatewayEndpointStore._testResolveGatewayToken(
isRemote: true,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(token == nil)
}
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
let snapshot = LaunchAgentPlistSnapshot(
programArguments: [],
environment: ["CLAWDBOT_GATEWAY_PASSWORD": "launchd-pass"],
port: nil,
bind: nil,
token: nil,
password: "launchd-pass")
let password = GatewayEndpointStore._testResolveGatewayPassword(
isRemote: false,
root: [:],
env: [:],
launchdSnapshot: snapshot)
#expect(password == "launchd-pass")
} }
} }