Merge branch 'main' into feat/mattermost-channel
This commit is contained in:
@@ -284,13 +284,16 @@ enum CommandResolver {
|
||||
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args.append(contentsOf: ["-i", settings.identity])
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
@@ -6,15 +6,20 @@ final class ConnectionModeCoordinator {
|
||||
static let shared = ConnectionModeCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
|
||||
private var lastMode: AppState.ConnectionMode?
|
||||
|
||||
/// Apply the requested connection mode by starting/stopping local gateway,
|
||||
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
|
||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||
if let lastMode = self.lastMode, lastMode != mode {
|
||||
GatewayProcessManager.shared.clearLastFailure()
|
||||
NodesStore.shared.lastError = nil
|
||||
}
|
||||
self.lastMode = mode
|
||||
switch mode {
|
||||
case .unconfigured:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
_ = await NodeServiceManager.stop()
|
||||
NodesStore.shared.lastError = nil
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
GatewayProcessManager.shared.stop()
|
||||
@@ -23,9 +28,8 @@ final class ConnectionModeCoordinator {
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||
|
||||
case .local:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
_ = await NodeServiceManager.stop()
|
||||
NodesStore.shared.lastError = nil
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||
@@ -56,6 +60,7 @@ final class ConnectionModeCoordinator {
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
do {
|
||||
NodesStore.shared.lastError = nil
|
||||
if let error = await NodeServiceManager.start() {
|
||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
private(set) var lastPingMs: Double?
|
||||
private(set) var authSourceLabel: String?
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "control")
|
||||
|
||||
@@ -128,6 +129,7 @@ final class ControlChannel {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
self.lastPingMs = nil
|
||||
self.authSourceLabel = nil
|
||||
}
|
||||
|
||||
func health(timeout: TimeInterval? = nil) async throws -> Data {
|
||||
@@ -188,8 +190,11 @@ final class ControlChannel {
|
||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||
{
|
||||
let reason = urlErr.failureURLString ?? urlErr.localizedDescription
|
||||
let tokenKey = CommandResolver.connectionModeIsRemote()
|
||||
? "gateway.remote.token"
|
||||
: "gateway.auth.token"
|
||||
return
|
||||
"Gateway rejected token; set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||
"Gateway rejected token; set \(tokenKey) (or CLAWDBOT_GATEWAY_TOKEN) " +
|
||||
"or clear it on the gateway. " +
|
||||
"Reason: \(reason)"
|
||||
}
|
||||
@@ -300,6 +305,27 @@ final class ControlChannel {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||
}
|
||||
await self.refreshAuthSourceLabel()
|
||||
}
|
||||
|
||||
private func refreshAuthSourceLabel() async {
|
||||
let isRemote = CommandResolver.connectionModeIsRemote()
|
||||
let authSource = await GatewayConnection.shared.authSource()
|
||||
self.authSourceLabel = Self.formatAuthSource(authSource, isRemote: isRemote)
|
||||
}
|
||||
|
||||
private static func formatAuthSource(_ source: GatewayAuthSource?, isRemote: Bool) -> String? {
|
||||
guard let source else { return nil }
|
||||
switch source {
|
||||
case .deviceToken:
|
||||
return "Auth: device token (paired device)"
|
||||
case .sharedToken:
|
||||
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
||||
case .password:
|
||||
return "Auth: password (\(isRemote ? "gateway.remote.password" : "gateway.auth.password"))"
|
||||
case .none:
|
||||
return "Auth: none"
|
||||
}
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
|
||||
@@ -484,6 +484,22 @@ struct DebugSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(
|
||||
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
LaunchdManager.startClawdbot()
|
||||
} label: {
|
||||
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Restart app") { DebugActions.restartApp() }
|
||||
Button("Restart onboarding") { DebugActions.restartOnboarding() }
|
||||
|
||||
@@ -149,6 +149,7 @@ struct ExecApprovalsResolvedDefaults {
|
||||
|
||||
enum ExecApprovalsStore {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||
private static let defaultAgentId = "main"
|
||||
private static let defaultSecurity: ExecSecurity = .deny
|
||||
private static let defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
@@ -165,13 +166,22 @@ enum ExecApprovalsStore {
|
||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
var agents = file.agents ?? [:]
|
||||
if let legacyDefault = agents["default"] {
|
||||
if let main = agents[self.defaultAgentId] {
|
||||
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
|
||||
} else {
|
||||
agents[self.defaultAgentId] = legacyDefault
|
||||
}
|
||||
agents.removeValue(forKey: "default")
|
||||
}
|
||||
return ExecApprovalsFile(
|
||||
version: 1,
|
||||
socket: ExecApprovalsSocketConfig(
|
||||
path: socketPath.isEmpty ? nil : socketPath,
|
||||
token: token.isEmpty ? nil : token),
|
||||
defaults: file.defaults,
|
||||
agents: file.agents)
|
||||
agents: agents)
|
||||
}
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
@@ -272,16 +282,16 @@ enum ExecApprovalsStore {
|
||||
ask: defaults.ask ?? self.defaultAsk,
|
||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "default"
|
||||
let key = self.agentKey(agentId)
|
||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
|
||||
?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
||||
?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||
.map { entry in
|
||||
ExecAllowlistEntry(
|
||||
@@ -455,7 +465,36 @@ enum ExecApprovalsStore {
|
||||
|
||||
private static func agentKey(_ agentId: String?) -> String {
|
||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "default" : trimmed
|
||||
return trimmed.isEmpty ? self.defaultAgentId : trimmed
|
||||
}
|
||||
|
||||
private static func normalizedPattern(_ pattern: String?) -> String? {
|
||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
||||
}
|
||||
|
||||
private static func mergeAgents(
|
||||
current: ExecApprovalsAgent,
|
||||
legacy: ExecApprovalsAgent
|
||||
) -> ExecApprovalsAgent {
|
||||
var seen = Set<String>()
|
||||
var allowlist: [ExecAllowlistEntry] = []
|
||||
func append(_ entry: ExecAllowlistEntry) {
|
||||
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
|
||||
return
|
||||
}
|
||||
seen.insert(key)
|
||||
allowlist.append(entry)
|
||||
}
|
||||
for entry in current.allowlist ?? [] { append(entry) }
|
||||
for entry in legacy.allowlist ?? [] { append(entry) }
|
||||
|
||||
return ExecApprovalsAgent(
|
||||
security: current.security ?? legacy.security,
|
||||
ask: current.ask ?? legacy.ask,
|
||||
askFallback: current.askFallback ?? legacy.askFallback,
|
||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
||||
allowlist: allowlist.isEmpty ? nil : allowlist)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +593,30 @@ enum ExecCommandFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalHelpers {
|
||||
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return ExecApprovalDecision(rawValue: trimmed)
|
||||
}
|
||||
|
||||
static func requiresAsk(
|
||||
ask: ExecAsk,
|
||||
security: ExecSecurity,
|
||||
allowlistMatch: ExecAllowlistEntry?,
|
||||
skillAllow: Bool) -> Bool
|
||||
{
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
return pattern.isEmpty ? nil : pattern
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecAllowlistMatcher {
|
||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||
guard let resolution, !entries.isEmpty else { return nil }
|
||||
|
||||
@@ -314,7 +314,7 @@ private enum ExecHostExecutor {
|
||||
}
|
||||
|
||||
var approvedByAsk = approvalDecision != nil
|
||||
if self.requiresAsk(
|
||||
if ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
@@ -417,36 +417,20 @@ private enum ExecHostExecutor {
|
||||
skillAllow: skillAllow)
|
||||
}
|
||||
|
||||
private static func requiresAsk(
|
||||
ask: ExecAsk,
|
||||
security: ExecSecurity,
|
||||
allowlistMatch: ExecAllowlistEntry?,
|
||||
skillAllow: Bool) -> Bool
|
||||
{
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static func persistAllowlistEntry(
|
||||
decision: ExecApprovalDecision?,
|
||||
context: ExecApprovalContext)
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: context.resolution)
|
||||
else {
|
||||
return
|
||||
}
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||
}
|
||||
|
||||
private static func allowlistPattern(
|
||||
command: [String],
|
||||
resolution: ExecCommandResolution?) -> String?
|
||||
{
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
return pattern.isEmpty ? nil : pattern
|
||||
}
|
||||
|
||||
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||
guard needsScreenRecording == true else { return nil }
|
||||
let authorized = await PermissionManager
|
||||
|
||||
@@ -250,6 +250,11 @@ actor GatewayConnection {
|
||||
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
|
||||
}
|
||||
|
||||
func authSource() async -> GatewayAuthSource? {
|
||||
guard let client else { return nil }
|
||||
return await client.authSource()
|
||||
}
|
||||
|
||||
func shutdown() async {
|
||||
if let client {
|
||||
await client.shutdown()
|
||||
|
||||
@@ -482,7 +482,7 @@ actor GatewayEndpointStore {
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
guard bind == "auto" else { return nil }
|
||||
guard bind == "tailnet" else { return nil }
|
||||
|
||||
let currentHost = currentURL.host?.lowercased() ?? ""
|
||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||
@@ -562,9 +562,6 @@ actor GatewayEndpointStore {
|
||||
case "tailnet":
|
||||
return tailscaleIP ?? "127.0.0.1"
|
||||
case "auto":
|
||||
if let tailscaleIP, !tailscaleIP.isEmpty {
|
||||
return tailscaleIP
|
||||
}
|
||||
return "127.0.0.1"
|
||||
case "custom":
|
||||
return customBindHost ?? "127.0.0.1"
|
||||
|
||||
@@ -115,7 +115,7 @@ extension GatewayLaunchAgentManager {
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
subcommand: "gateway",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Launchd management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
|
||||
@@ -42,10 +42,20 @@ final class GatewayProcessManager {
|
||||
private var environmentRefreshTask: Task<Void, Never>?
|
||||
private var lastEnvironmentRefresh: Date?
|
||||
private var logRefreshTask: Task<Void, Never>?
|
||||
#if DEBUG
|
||||
private var testingConnection: GatewayConnection?
|
||||
#endif
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
|
||||
|
||||
private let logLimit = 20000 // characters to keep in-memory
|
||||
private let environmentRefreshMinInterval: TimeInterval = 30
|
||||
private var connection: GatewayConnection {
|
||||
#if DEBUG
|
||||
return self.testingConnection ?? .shared
|
||||
#else
|
||||
return .shared
|
||||
#endif
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
// Remote mode should never spawn a local gateway; treat as stopped.
|
||||
@@ -126,6 +136,10 @@ final class GatewayProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
func clearLastFailure() {
|
||||
self.lastFailureReason = nil
|
||||
}
|
||||
|
||||
func refreshEnvironmentStatus(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force {
|
||||
@@ -178,7 +192,7 @@ final class GatewayProcessManager {
|
||||
let hasListener = instance != nil
|
||||
|
||||
let attemptAttach = {
|
||||
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
|
||||
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
|
||||
}
|
||||
|
||||
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||
@@ -187,6 +201,7 @@ final class GatewayProcessManager {
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||
self.existingGatewayDetails = details
|
||||
self.clearLastFailure()
|
||||
self.status = .attachedExisting(details: details)
|
||||
self.appendLog("[gateway] using existing instance: \(details)\n")
|
||||
self.logger.info("gateway using existing instance details=\(details)")
|
||||
@@ -310,9 +325,10 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return }
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let details = instance.map { "pid \($0.pid)" }
|
||||
self.clearLastFailure()
|
||||
self.status = .running(details: details)
|
||||
self.logger.info("gateway started details=\(details ?? "ok")")
|
||||
self.refreshControlChannelIfNeeded(reason: "gateway started")
|
||||
@@ -352,7 +368,8 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return false }
|
||||
do {
|
||||
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
self.clearLastFailure()
|
||||
return true
|
||||
} catch {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
@@ -385,3 +402,19 @@ final class GatewayProcessManager {
|
||||
return String(text.suffix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension GatewayProcessManager {
|
||||
func setTestingConnection(_ connection: GatewayConnection?) {
|
||||
self.testingConnection = connection
|
||||
}
|
||||
|
||||
func setTestingDesiredActive(_ active: Bool) {
|
||||
self.desiredActive = active
|
||||
}
|
||||
|
||||
func setTestingLastFailureReason(_ reason: String?) {
|
||||
self.lastFailureReason = reason
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2,52 +2,25 @@ import AppKit
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettings: View {
|
||||
@Bindable var state: AppState
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
private let healthStore = HealthStore.shared
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||
localDisplayName: InstanceIdentity.displayName)
|
||||
@State private var isInstallingCLI = false
|
||||
@State private var cliStatus: String?
|
||||
@State private var cliInstalled = false
|
||||
@State private var cliInstallLocation: String?
|
||||
@State private var gatewayStatus: GatewayEnvironmentStatus = .checking
|
||||
@State private var remoteStatus: RemoteStatus = .idle
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !self.state.onboardingSeen {
|
||||
Button {
|
||||
DebugActions.restartOnboarding()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Label("Complete onboarding to finish setup", systemImage: "arrow.counterclockwise")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SettingsToggleRow(
|
||||
title: "Clawdbot active",
|
||||
@@ -83,29 +56,6 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
SystemRunSettingsView()
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$locationModeRaw) {
|
||||
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always may require System Settings to approve background location.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Enable Peekaboo Bridge",
|
||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||
@@ -130,29 +80,13 @@ struct GeneralSettings: View {
|
||||
}
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
self.refreshCLIStatus()
|
||||
self.refreshGatewayStatus()
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
CanvasManager.shared.hideAll()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeBinding: Binding<Bool> {
|
||||
@@ -161,39 +95,20 @@ struct GeneralSettings: View {
|
||||
set: { self.state.isPaused = !$0 })
|
||||
}
|
||||
|
||||
private var locationMode: ClawdbotLocationMode {
|
||||
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||
return false
|
||||
}
|
||||
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let requireAlways = mode == .always
|
||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Clawdbot runs")
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Picker("", selection: self.$state.connectionMode) {
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 380, alignment: .leading)
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.frame(width: 260, alignment: .leading)
|
||||
|
||||
if self.state.connectionMode == .unconfigured {
|
||||
Text("Pick Local or Remote to start the Gateway.")
|
||||
@@ -216,8 +131,6 @@ struct GeneralSettings: View {
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
}
|
||||
|
||||
self.cliInstaller
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +212,11 @@ struct GeneralSettings: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let authLabel = ControlChannel.shared.authSourceLabel {
|
||||
Text(authLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
@@ -346,59 +264,6 @@ struct GeneralSettings: View {
|
||||
return message == self.controlStatusLine
|
||||
}
|
||||
|
||||
private var cliInstaller: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.isInstallingCLI ? 0 : 1)
|
||||
if self.isInstallingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 150)
|
||||
}
|
||||
.disabled(self.isInstallingCLI)
|
||||
|
||||
if self.isInstallingCLI {
|
||||
Text("Working...")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.cliInstalled {
|
||||
Label("Installed", systemImage: "checkmark.circle.fill")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Not installed")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let status = cliStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let installLocation = self.cliInstallLocation {
|
||||
Text("Found at \(installLocation)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayInstallerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
@@ -454,22 +319,6 @@ struct GeneralSettings: View {
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
guard !self.isInstallingCLI else { return }
|
||||
self.isInstallingCLI = true
|
||||
defer { isInstallingCLI = false }
|
||||
await CLIInstaller.install { status in
|
||||
self.cliStatus = status
|
||||
self.refreshCLIStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshCLIStatus() {
|
||||
let installLocation = CLIInstaller.installedLocation()
|
||||
self.cliInstallLocation = installLocation
|
||||
self.cliInstalled = installLocation != nil
|
||||
}
|
||||
|
||||
private func refreshGatewayStatus() {
|
||||
Task {
|
||||
let status = await Task.detached(priority: .utility) {
|
||||
@@ -763,9 +612,6 @@ extension GeneralSettings {
|
||||
message: "Gateway ready")
|
||||
view.remoteStatus = .failed("SSH failed")
|
||||
view.showRemoteAdvanced = true
|
||||
view.cliInstalled = true
|
||||
view.cliInstallLocation = "/usr/local/bin/clawdbot"
|
||||
view.cliStatus = "Installed"
|
||||
_ = view.body
|
||||
|
||||
state.connectionMode = .unconfigured
|
||||
|
||||
@@ -145,10 +145,11 @@ extension MenuSessionsInjector {
|
||||
let headerItem = NSMenuItem()
|
||||
headerItem.tag = self.tag
|
||||
headerItem.isEnabled = false
|
||||
let statusText = self.cachedErrorText ?? (isConnected ? nil : self.controlChannelStatusText(for: channelState))
|
||||
let hosted = self.makeHostedView(
|
||||
rootView: AnyView(MenuSessionsHeaderView(
|
||||
count: rows.count,
|
||||
statusText: isConnected ? nil : self.controlChannelStatusText(for: channelState))),
|
||||
statusText: statusText)),
|
||||
width: width,
|
||||
highlighted: false)
|
||||
headerItem.view = hosted
|
||||
@@ -598,8 +599,11 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedSnapshot = nil
|
||||
self.cachedErrorText = nil
|
||||
if self.cachedSnapshot != nil {
|
||||
self.cachedErrorText = "Gateway disconnected (showing cached)"
|
||||
} else {
|
||||
self.cachedErrorText = nil
|
||||
}
|
||||
self.cacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
@@ -624,8 +628,6 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedUsageSummary = nil
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
@@ -648,8 +650,6 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
|
||||
guard self.isControlChannelConnected else {
|
||||
self.cachedCostSummary = nil
|
||||
self.cachedCostErrorText = nil
|
||||
self.costCacheUpdatedAt = Date()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,14 +2,28 @@ import Foundation
|
||||
import JavaScriptCore
|
||||
|
||||
enum ModelCatalogLoader {
|
||||
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
||||
static var defaultPath: String { self.resolveDefaultPath() }
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
||||
private nonisolated static let appSupportDir: URL = {
|
||||
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
}()
|
||||
|
||||
private static var cachePath: URL {
|
||||
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
|
||||
}
|
||||
|
||||
static func load(from path: String) async throws -> [ModelChoice] {
|
||||
let expanded = (path as NSString).expandingTildeInPath
|
||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: expanded).lastPathComponent)")
|
||||
let source = try String(contentsOfFile: expanded, encoding: .utf8)
|
||||
guard let resolved = self.resolvePath(preferred: expanded) else {
|
||||
self.logger.error("model catalog load failed: file not found")
|
||||
throw NSError(
|
||||
domain: "ModelCatalogLoader",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
|
||||
}
|
||||
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
|
||||
let source = try String(contentsOfFile: resolved.path, encoding: .utf8)
|
||||
let sanitized = self.sanitize(source: source)
|
||||
|
||||
let ctx = JSContext()
|
||||
@@ -45,9 +59,82 @@ enum ModelCatalogLoader {
|
||||
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
|
||||
}
|
||||
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
|
||||
if resolved.shouldCache {
|
||||
self.cacheCatalog(sourcePath: resolved.path)
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
private static func resolveDefaultPath() -> String {
|
||||
let cache = self.cachePath.path
|
||||
if FileManager().isReadableFile(atPath: cache) { return cache }
|
||||
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
|
||||
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
|
||||
return cache
|
||||
}
|
||||
|
||||
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
|
||||
if FileManager().isReadableFile(atPath: preferred) {
|
||||
return (preferred, preferred != self.cachePath.path)
|
||||
}
|
||||
|
||||
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
|
||||
self.logger.warning("model catalog path missing; falling back to bundled catalog")
|
||||
return (bundlePath, true)
|
||||
}
|
||||
|
||||
let cache = self.cachePath.path
|
||||
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
|
||||
self.logger.warning("model catalog path missing; falling back to cached catalog")
|
||||
return (cache, false)
|
||||
}
|
||||
|
||||
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
|
||||
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
|
||||
return (nodePath, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func bundleCatalogPath() -> String? {
|
||||
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
|
||||
return nil
|
||||
}
|
||||
return url.path
|
||||
}
|
||||
|
||||
private static func nodeModulesCatalogPath() -> String? {
|
||||
let roots = [
|
||||
URL(fileURLWithPath: CommandResolver.projectRootPath()),
|
||||
URL(fileURLWithPath: FileManager().currentDirectoryPath),
|
||||
]
|
||||
for root in roots {
|
||||
let candidate = root
|
||||
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
|
||||
if FileManager().isReadableFile(atPath: candidate.path) {
|
||||
return candidate.path
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func cacheCatalog(sourcePath: String) {
|
||||
let destination = self.cachePath
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: destination.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if FileManager().fileExists(atPath: destination.path) {
|
||||
try FileManager().removeItem(at: destination)
|
||||
}
|
||||
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
|
||||
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
|
||||
} catch {
|
||||
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func sanitize(source: String) -> String {
|
||||
guard let exportRange = source.range(of: "export const MODELS"),
|
||||
let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"),
|
||||
|
||||
@@ -480,26 +480,26 @@ actor MacNodeRuntime {
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||
}
|
||||
|
||||
let requiresAsk: Bool = {
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
let approvedByAsk = params.approved == true
|
||||
if requiresAsk, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
let approval = await self.resolveSystemRunApproval(
|
||||
req: req,
|
||||
params: params,
|
||||
context: ExecRunContext(
|
||||
displayCommand: displayCommand,
|
||||
security: security,
|
||||
ask: ask,
|
||||
agentId: agentId,
|
||||
resolution: resolution,
|
||||
allowlistMatch: allowlistMatch,
|
||||
skillAllow: skillAllow,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId))
|
||||
if let response = approval.response { return response }
|
||||
let approvedByAsk = approval.approvedByAsk
|
||||
let persistAllowlist = approval.persistAllowlist
|
||||
if persistAllowlist, security == .allowlist,
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
||||
{
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
@@ -619,6 +619,99 @@ actor MacNodeRuntime {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private struct ExecApprovalOutcome {
|
||||
var approvedByAsk: Bool
|
||||
var persistAllowlist: Bool
|
||||
var response: BridgeInvokeResponse?
|
||||
}
|
||||
|
||||
private struct ExecRunContext {
|
||||
var displayCommand: String
|
||||
var security: ExecSecurity
|
||||
var ask: ExecAsk
|
||||
var agentId: String?
|
||||
var resolution: ExecCommandResolution?
|
||||
var allowlistMatch: ExecAllowlistEntry?
|
||||
var skillAllow: Bool
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
}
|
||||
|
||||
private func resolveSystemRunApproval(
|
||||
req: BridgeInvokeRequest,
|
||||
params: ClawdbotSystemRunParams,
|
||||
context: ExecRunContext) async -> ExecApprovalOutcome
|
||||
{
|
||||
let requiresAsk = ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
skillAllow: context.skillAllow)
|
||||
|
||||
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
||||
var approvedByAsk = params.approved == true || decisionFromParams != nil
|
||||
var persistAllowlist = decisionFromParams == .allowAlways
|
||||
if decisionFromParams == .deny {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "user-denied"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||
}
|
||||
|
||||
if requiresAsk, !approvedByAsk {
|
||||
let decision = await MainActor.run {
|
||||
ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: context.displayCommand,
|
||||
cwd: params.cwd,
|
||||
host: "node",
|
||||
security: context.security.rawValue,
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.agentId,
|
||||
resolvedPath: context.resolution?.resolvedPath))
|
||||
}
|
||||
switch decision {
|
||||
case .deny:
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "user-denied"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
persistAllowlist = true
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: nil)
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ClawdbotIPC
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import SwiftUI
|
||||
|
||||
struct PermissionsSettings: View {
|
||||
@@ -8,6 +10,8 @@ struct PermissionsSettings: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
SystemRunSettingsView()
|
||||
|
||||
Text("Allow these so Clawdbot can notify and capture when needed.")
|
||||
.padding(.top, 4)
|
||||
|
||||
@@ -15,6 +19,8 @@ struct PermissionsSettings: View {
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
LocationAccessSettings()
|
||||
|
||||
Button("Restart onboarding") { self.showOnboarding() }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
@@ -24,6 +30,72 @@ struct PermissionsSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocationAccessSettings: View {
|
||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$locationModeRaw) {
|
||||
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always may require System Settings to approve background location.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.onAppear {
|
||||
self.lastLocationModeRaw = self.locationModeRaw
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var locationMode: ClawdbotLocationMode {
|
||||
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
}
|
||||
|
||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||
return false
|
||||
}
|
||||
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let requireAlways = mode == .always
|
||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionStatusList: View {
|
||||
let status: [Capability: Bool]
|
||||
let refresh: () async -> Void
|
||||
@@ -45,25 +117,6 @@ struct PermissionStatusList: View {
|
||||
.font(.footnote)
|
||||
.padding(.top, 2)
|
||||
.help("Refresh status")
|
||||
|
||||
if (self.status[.accessibility] ?? false) == false || (self.status[.screenRecording] ?? false) == false {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(
|
||||
"Note: macOS may require restarting Clawdbot after enabling Accessibility or Screen Recording.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
LaunchdManager.startClawdbot()
|
||||
} label: {
|
||||
Label("Restart Clawdbot", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,14 @@ actor PortGuardian {
|
||||
}
|
||||
}
|
||||
|
||||
func isListening(port: Int, pid: Int32? = nil) async -> Bool {
|
||||
let listeners = await self.listeners(on: port)
|
||||
if let pid {
|
||||
return listeners.contains(where: { $0.pid == pid })
|
||||
}
|
||||
return !listeners.isEmpty
|
||||
}
|
||||
|
||||
private func listeners(on port: Int) async -> [Listener] {
|
||||
let res = await ShellExecutor.run(
|
||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||
|
||||
@@ -72,7 +72,6 @@ final class RemotePortTunnel {
|
||||
}
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
@@ -84,7 +83,12 @@ final class RemotePortTunnel {
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identity.isEmpty { args.append(contentsOf: ["-i", identity]) }
|
||||
if !identity.isEmpty {
|
||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||
args.append(contentsOf: ["-i", identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
|
||||
@@ -20,11 +20,13 @@ actor RemoteTunnelManager {
|
||||
tunnel.process.isRunning,
|
||||
let local = tunnel.localPort
|
||||
{
|
||||
if await self.isTunnelHealthy(port: local) {
|
||||
let pid = tunnel.process.processIdentifier
|
||||
if await PortGuardian.shared.isListening(port: Int(local), pid: pid) {
|
||||
self.logger.info("reusing active SSH tunnel localPort=\(local, privacy: .public)")
|
||||
return local
|
||||
}
|
||||
self.logger.error("active SSH tunnel on port \(local, privacy: .public) is unhealthy; restarting")
|
||||
self.logger.error(
|
||||
"active SSH tunnel on port \(local, privacy: .public) is not listening; restarting")
|
||||
await self.beginRestart()
|
||||
tunnel.terminate()
|
||||
self.controlTunnel = nil
|
||||
@@ -35,19 +37,11 @@ actor RemoteTunnelManager {
|
||||
if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)),
|
||||
self.isSshProcess(desc)
|
||||
{
|
||||
if await self.isTunnelHealthy(port: desiredPort) {
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener " +
|
||||
"localPort=\(desiredPort, privacy: .public) " +
|
||||
"pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
}
|
||||
if self.restartInFlight {
|
||||
self.logger.info("control tunnel restart in flight; skip stale tunnel cleanup")
|
||||
return nil
|
||||
}
|
||||
await self.beginRestart()
|
||||
await self.cleanupStaleTunnel(desc: desc, port: desiredPort)
|
||||
self.logger.info(
|
||||
"reusing existing SSH tunnel listener " +
|
||||
"localPort=\(desiredPort, privacy: .public) " +
|
||||
"pid=\(desc.pid, privacy: .public)")
|
||||
return desiredPort
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -88,10 +82,6 @@ actor RemoteTunnelManager {
|
||||
self.controlTunnel = nil
|
||||
}
|
||||
|
||||
private func isTunnelHealthy(port: UInt16) async -> Bool {
|
||||
await PortGuardian.shared.probeGatewayHealth(port: Int(port))
|
||||
}
|
||||
|
||||
private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool {
|
||||
let cmd = desc.command.lowercased()
|
||||
if cmd.contains("ssh") { return true }
|
||||
@@ -128,21 +118,5 @@ actor RemoteTunnelManager {
|
||||
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
|
||||
}
|
||||
|
||||
private func cleanupStaleTunnel(desc: PortGuardian.Descriptor, port: UInt16) async {
|
||||
let pid = desc.pid
|
||||
self.logger.error(
|
||||
"stale SSH tunnel detected on port \(port, privacy: .public) pid \(pid, privacy: .public)")
|
||||
let killed = await self.kill(pid: pid)
|
||||
if !killed {
|
||||
self.logger.error("failed to terminate stale SSH tunnel pid \(pid, privacy: .public)")
|
||||
}
|
||||
await PortGuardian.shared.removeRecord(pid: pid)
|
||||
}
|
||||
|
||||
private func kill(pid: Int32) async -> Bool {
|
||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
if term.ok { return true }
|
||||
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
return sigkill.ok
|
||||
}
|
||||
// Keep tunnel reuse lightweight; restart only when the listener disappears.
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.20</string>
|
||||
<string>2026.1.21</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601200</string>
|
||||
<string>202601210</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -52,6 +52,51 @@ actor SessionPreviewCache {
|
||||
}
|
||||
}
|
||||
|
||||
actor SessionPreviewLimiter {
|
||||
static let shared = SessionPreviewLimiter(maxConcurrent: 2)
|
||||
|
||||
private let maxConcurrent: Int
|
||||
private var available: Int
|
||||
private var waitQueue: [UUID] = []
|
||||
private var waiters: [UUID: CheckedContinuation<Void, Never>] = [:]
|
||||
|
||||
init(maxConcurrent: Int) {
|
||||
let normalized = max(1, maxConcurrent)
|
||||
self.maxConcurrent = normalized
|
||||
self.available = normalized
|
||||
}
|
||||
|
||||
func withPermit<T>(_ operation: () async throws -> T) async throws -> T {
|
||||
await self.acquire()
|
||||
defer { self.release() }
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
private func acquire() async {
|
||||
if self.available > 0 {
|
||||
self.available -= 1
|
||||
return
|
||||
}
|
||||
let id = UUID()
|
||||
await withCheckedContinuation { cont in
|
||||
self.waitQueue.append(id)
|
||||
self.waiters[id] = cont
|
||||
}
|
||||
}
|
||||
|
||||
private func release() {
|
||||
if let id = self.waitQueue.first {
|
||||
self.waitQueue.removeFirst()
|
||||
if let cont = self.waiters.removeValue(forKey: id) {
|
||||
cont.resume()
|
||||
}
|
||||
return
|
||||
}
|
||||
self.available = min(self.available + 1, self.maxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
@@ -184,17 +229,31 @@ enum SessionMenuPreviewLoader {
|
||||
return self.snapshot(from: cached)
|
||||
}
|
||||
|
||||
let isConnected = await MainActor.run {
|
||||
if case .connected = ControlChannel.shared.state { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
guard isConnected else {
|
||||
if let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey) {
|
||||
return Self.snapshot(from: fallback)
|
||||
}
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Gateway disconnected"))
|
||||
}
|
||||
|
||||
do {
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
let payload = try await SessionPreviewLimiter.shared.withPermit {
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
}
|
||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
|
||||
@@ -221,6 +221,6 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
Self.detectTailnetIPv4()
|
||||
self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user