fix: macos wizard auth bootstrap
This commit is contained in:
@@ -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)`.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user