fix(macos): clean warnings and harden gateway/talk config parsing

This commit is contained in:
Peter Steinberger
2026-02-25 00:27:31 +00:00
parent 9cd50c51b0
commit ce1dbeb986
15 changed files with 331 additions and 284 deletions

View File

@@ -51,8 +51,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle", "location" : "https://github.com/sparkle-project/Sparkle",
"state" : { "state" : {
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", "revision" : "21d8df80440b1ca3b65fa82e40782f1e5a9e6ba2",
"version" : "2.8.1" "version" : "2.9.0"
} }
}, },
{ {
@@ -78,8 +78,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git", "location" : "https://github.com/apple/swift-log.git",
"state" : { "state" : {
"revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
"version" : "1.9.1" "version" : "1.10.1"
} }
}, },
{ {

View File

@@ -17,9 +17,14 @@ enum AgentWorkspace {
AgentWorkspace.userFilename, AgentWorkspace.userFilename,
AgentWorkspace.bootstrapFilename, AgentWorkspace.bootstrapFilename,
] ]
enum BootstrapSafety: Equatable { struct BootstrapSafety: Equatable {
case safe let unsafeReason: String?
case unsafe (reason: String)
static let safe = Self(unsafeReason: nil)
static func blocked(_ reason: String) -> Self {
Self(unsafeReason: reason)
}
} }
static func displayPath(for url: URL) -> String { static func displayPath(for url: URL) -> String {
@@ -71,9 +76,7 @@ enum AgentWorkspace {
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe return .safe
} }
if !isDir.boolValue { if !isDir.boolValue { return .blocked("Workspace path points to a file.") }
return .unsafe (reason: "Workspace path points to a file.")
}
let agentsURL = self.agentsURL(workspaceURL: workspaceURL) let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if fm.fileExists(atPath: agentsURL.path) { if fm.fileExists(atPath: agentsURL.path) {
return .safe return .safe
@@ -82,9 +85,9 @@ enum AgentWorkspace {
let entries = try self.workspaceEntries(workspaceURL: workspaceURL) let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
return entries.isEmpty return entries.isEmpty
? .safe ? .safe
: .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.") : .blocked("Folder isn't empty. Choose a new folder or add AGENTS.md first.")
} catch { } catch {
return .unsafe (reason: "Couldn't inspect the workspace folder.") return .blocked("Couldn't inspect the workspace folder.")
} }
} }

View File

@@ -356,6 +356,70 @@ final class AppState {
return trimmed return trimmed
} }
private static func updateGatewayString(
_ dictionary: inout [String: Any],
key: String,
value: String?) -> Bool
{
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
guard dictionary[key] != nil else { return false }
dictionary.removeValue(forKey: key)
return true
}
if (dictionary[key] as? String) != trimmed {
dictionary[key] = trimmed
return true
}
return false
}
private static func updatedRemoteGatewayConfig(
current: [String: Any],
transport: RemoteTransport,
remoteUrl: String,
remoteHost: String?,
remoteTarget: String,
remoteIdentity: String) -> (remote: [String: Any], changed: Bool)
{
var remote = current
var changed = false
switch transport {
case .direct:
changed = Self.updateGatewayString(
&remote,
key: "transport",
value: RemoteTransport.direct.rawValue) || changed
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
changed = Self.updateGatewayString(&remote, key: "url", value: nil) || changed
} else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) {
changed = Self.updateGatewayString(&remote, key: "url", value: normalizedUrl) || changed
}
case .ssh:
changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: remoteIdentity) || changed
}
return (remote, changed)
}
private func startConfigWatcher() { private func startConfigWatcher() {
let configUrl = OpenClawConfigFile.url() let configUrl = OpenClawConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
@@ -470,69 +534,16 @@ final class AppState {
} }
if connectionMode == .remote { if connectionMode == .remote {
var remote = gateway["remote"] as? [String: Any] ?? [:] let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false let updated = Self.updatedRemoteGatewayConfig(
current: currentRemote,
if remoteTransport == .direct { transport: remoteTransport,
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines) remoteUrl: remoteUrl,
if trimmedUrl.isEmpty { remoteHost: remoteHost,
if remote["url"] != nil { remoteTarget: remoteTarget,
remote.removeValue(forKey: "url") remoteIdentity: remoteIdentity)
remoteChanged = true if updated.changed {
} gateway["remote"] = updated.remote
} else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) {
if (remote["url"] as? String) != normalizedUrl {
remote["url"] = normalizedUrl
remoteChanged = true
}
}
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
remote["transport"] = RemoteTransport.direct.rawValue
remoteChanged = true
}
} else {
if remote["transport"] != nil {
remote.removeValue(forKey: "transport")
remoteChanged = true
}
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
}
if remoteChanged {
gateway["remote"] = remote
changed = true changed = true
} }
} }

View File

@@ -8,7 +8,7 @@ enum ExecAllowlistMatcher {
for entry in entries { for entry in entries {
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
case .valid(let pattern): case let .valid(pattern):
let target = resolvedPath ?? rawExecutable let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry } if self.matches(pattern: pattern, target: target) { return entry }
case .invalid: case .invalid:

View File

@@ -439,9 +439,9 @@ enum ExecApprovalsStore {
static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? { static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? {
let normalizedPattern: String let normalizedPattern: String
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let validPattern): case let .valid(validPattern):
normalizedPattern = validPattern normalizedPattern = validPattern
case .invalid(let reason): case let .invalid(reason):
return reason return reason
} }
@@ -571,7 +571,7 @@ enum ExecApprovalsStore {
private static func normalizedPattern(_ pattern: String?) -> String? { private static func normalizedPattern(_ pattern: String?) -> String? {
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let normalized): case let .valid(normalized):
return normalized.lowercased() return normalized.lowercased()
case .invalid(.empty): case .invalid(.empty):
return nil return nil
@@ -587,7 +587,7 @@ enum ExecApprovalsStore {
let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
case .valid(let pattern): case let .valid(pattern):
return ExecAllowlistEntry( return ExecAllowlistEntry(
id: entry.id, id: entry.id,
pattern: pattern, pattern: pattern,
@@ -596,7 +596,7 @@ enum ExecApprovalsStore {
lastResolvedPath: normalizedResolved) lastResolvedPath: normalizedResolved)
case .invalid: case .invalid:
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) { switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) {
case .valid(let migratedPattern): case let .valid(migratedPattern):
return ExecAllowlistEntry( return ExecAllowlistEntry(
id: entry.id, id: entry.id,
pattern: migratedPattern, pattern: migratedPattern,
@@ -629,7 +629,7 @@ enum ExecApprovalsStore {
let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
case .valid(let pattern): case let .valid(pattern):
normalized.append( normalized.append(
ExecAllowlistEntry( ExecAllowlistEntry(
id: migrated.id, id: migrated.id,
@@ -637,7 +637,7 @@ enum ExecApprovalsStore {
lastUsedAt: migrated.lastUsedAt, lastUsedAt: migrated.lastUsedAt,
lastUsedCommand: migrated.lastUsedCommand, lastUsedCommand: migrated.lastUsedCommand,
lastResolvedPath: normalizedResolvedPath)) lastResolvedPath: normalizedResolvedPath))
case .invalid(let reason): case let .invalid(reason):
if dropInvalid { if dropInvalid {
rejected.append( rejected.append(
ExecAllowlistRejectedEntry( ExecAllowlistRejectedEntry(

View File

@@ -366,9 +366,9 @@ private enum ExecHostExecutor {
rawCommand: request.rawCommand) rawCommand: request.rawCommand)
let displayCommand: String let displayCommand: String
switch validatedCommand { switch validatedCommand {
case .ok(let resolved): case let .ok(resolved):
displayCommand = resolved.displayCommand displayCommand = resolved.displayCommand
case .invalid(let message): case let .invalid(message):
return self.errorResponse( return self.errorResponse(
code: "INVALID_REQUEST", code: "INVALID_REQUEST",
message: message, message: message,

View File

@@ -63,11 +63,11 @@ enum ExecShellWrapperParser {
private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { private static func extractPayload(command: [String], spec: WrapperSpec) -> String? {
switch spec.kind { switch spec.kind {
case .posix: case .posix:
return self.extractPosixInlineCommand(command) self.extractPosixInlineCommand(command)
case .cmd: case .cmd:
return self.extractCmdInlineCommand(command) self.extractCmdInlineCommand(command)
case .powershell: case .powershell:
return self.extractPowerShellInlineCommand(command) self.extractPowerShellInlineCommand(command)
} }
} }
@@ -81,7 +81,9 @@ enum ExecShellWrapperParser {
} }
private static func extractCmdInlineCommand(_ command: [String]) -> String? { private static func extractCmdInlineCommand(_ command: [String]) -> String? {
guard let idx = command.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) else { guard let idx = command
.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" })
else {
return nil return nil
} }
let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ")

View File

@@ -77,11 +77,10 @@ enum ExecSystemRunCommandValidator {
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command) let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let inferred: String let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
if let shellCommand, !mustBindDisplayToFullArgv { shellCommand
inferred = shellCommand
} else { } else {
inferred = ExecCommandFormatter.displayString(for: command) ExecCommandFormatter.displayString(for: command)
} }
if let raw = normalizedRaw, raw != inferred { if let raw = normalizedRaw, raw != inferred {
@@ -189,7 +188,7 @@ enum ExecSystemRunCommandValidator {
} }
var appletIndex = 1 var appletIndex = 1
if appletIndex < argv.count && argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
appletIndex += 1 appletIndex += 1
} }
guard appletIndex < argv.count else { guard appletIndex < argv.count else {
@@ -255,14 +254,13 @@ enum ExecSystemRunCommandValidator {
return false return false
} }
let inlineCommandIndex: Int? let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" {
if wrapper == "powershell" || wrapper == "pwsh" { self.resolveInlineCommandTokenIndex(
inlineCommandIndex = self.resolveInlineCommandTokenIndex(
wrapperArgv, wrapperArgv,
flags: self.powershellInlineCommandFlags, flags: self.powershellInlineCommandFlags,
allowCombinedC: false) allowCombinedC: false)
} else { } else {
inlineCommandIndex = self.resolveInlineCommandTokenIndex( self.resolveInlineCommandTokenIndex(
wrapperArgv, wrapperArgv,
flags: self.posixInlineCommandFlags, flags: self.posixInlineCommandFlags,
allowCombinedC: true) allowCombinedC: true)

View File

@@ -304,8 +304,7 @@ struct GeneralSettings: View {
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
} }
Text( Text(
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1." "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.")
)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10) .padding(.leading, self.remoteLabelWidth + 10)
@@ -549,8 +548,7 @@ extension GeneralSettings {
} }
guard Self.isValidWsUrl(trimmedUrl) else { guard Self.isValidWsUrl(trimmedUrl) else {
self.remoteStatus = .failed( self.remoteStatus = .failed(
"Gateway URL must use wss:// for remote hosts (ws:// only for localhost)" "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
)
return return
} }
} else { } else {

View File

@@ -431,7 +431,7 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding {
} }
} }
extension SparkleUpdaterController: @preconcurrency SPUUpdaterDelegate {} extension SparkleUpdaterController: SPUUpdaterDelegate {}
private func isDeveloperIDSigned(bundleURL: URL) -> Bool { private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
var staticCode: SecStaticCode? var staticCode: SecStaticCode?

View File

@@ -87,19 +87,9 @@ extension OnboardingView {
self.onboardingCard(spacing: 12, padding: 14) { self.onboardingCard(spacing: 12, padding: 14) {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
let localSubtitle: String = {
guard let probe = self.localGatewayProbe else {
return "Gateway starts automatically on this Mac."
}
let base = probe.expected
? "Existing gateway detected"
: "Port \(probe.port) already in use"
let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))"
return "\(base)\(command). Will attach."
}()
self.connectionChoiceButton( self.connectionChoiceButton(
title: "This Mac", title: "This Mac",
subtitle: localSubtitle, subtitle: self.localGatewaySubtitle,
selected: self.state.connectionMode == .local) selected: self.state.connectionMode == .local)
{ {
self.selectLocalGateway() self.selectLocalGateway()
@@ -107,50 +97,7 @@ extension OnboardingView {
Divider().padding(.vertical, 4) Divider().padding(.vertical, 4)
HStack(spacing: 8) { self.gatewayDiscoverySection()
Image(systemName: "dot.radiowaves.left.and.right")
.font(.caption)
.foregroundStyle(.secondary)
Text(self.gatewayDiscovery.statusText)
.font(.caption)
.foregroundStyle(.secondary)
if self.gatewayDiscovery.gateways.isEmpty {
ProgressView().controlSize(.small)
Button("Refresh") {
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
}
.buttonStyle(.link)
.help("Retry Tailscale discovery (DNS-SD).")
}
Spacer(minLength: 0)
}
if self.gatewayDiscovery.gateways.isEmpty {
Text("Searching for nearby gateways…")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("Nearby gateways")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in
self.connectionChoiceButton(
title: gateway.displayName,
subtitle: self.gatewaySubtitle(for: gateway),
selected: self.isSelectedGateway(gateway))
{
self.selectRemoteGateway(gateway)
}
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
}
self.connectionChoiceButton( self.connectionChoiceButton(
title: "Configure later", title: "Configure later",
@@ -160,104 +107,168 @@ extension OnboardingView {
self.selectUnconfiguredGateway() self.selectUnconfiguredGateway()
} }
Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { self.advancedConnectionSection()
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { }
self.showAdvancedConnection.toggle() }
} }
if self.showAdvancedConnection, self.state.connectionMode != .remote { }
self.state.connectionMode = .remote
}
}
.buttonStyle(.link)
if self.showAdvancedConnection { private var localGatewaySubtitle: String {
let labelWidth: CGFloat = 110 guard let probe = self.localGatewayProbe else {
let fieldWidth: CGFloat = 320 return "Gateway starts automatically on this Mac."
}
let base = probe.expected
? "Existing gateway detected"
: "Port \(probe.port) already in use"
let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))"
return "\(base)\(command). Will attach."
}
VStack(alignment: .leading, spacing: 10) { @ViewBuilder
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { private func gatewayDiscoverySection() -> some View {
GridRow { HStack(spacing: 8) {
Text("Transport") Image(systemName: "dot.radiowaves.left.and.right")
.font(.callout.weight(.semibold)) .font(.caption)
.frame(width: labelWidth, alignment: .leading) .foregroundStyle(.secondary)
Picker("Transport", selection: self.$state.remoteTransport) { Text(self.gatewayDiscovery.statusText)
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh) .font(.caption)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct) .foregroundStyle(.secondary)
} if self.gatewayDiscovery.gateways.isEmpty {
.pickerStyle(.segmented) ProgressView().controlSize(.small)
.frame(width: fieldWidth) Button("Refresh") {
} self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
if self.state.remoteTransport == .direct { }
GridRow { .buttonStyle(.link)
Text("Gateway URL") .help("Retry Tailscale discovery (DNS-SD).")
.font(.callout.weight(.semibold)) }
.frame(width: labelWidth, alignment: .leading) Spacer(minLength: 0)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl) }
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
if self.state.remoteTransport == .ssh {
GridRow {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
if let message = CommandResolver
.sshTargetValidationMessage(self.state.remoteTarget)
{
GridRow {
Text("")
.frame(width: labelWidth, alignment: .leading)
Text(message)
.font(.caption)
.foregroundStyle(.red)
.frame(width: fieldWidth, alignment: .leading)
}
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/OpenClaw.app/.../openclaw",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
}
Text(self.state.remoteTransport == .direct if self.gatewayDiscovery.gateways.isEmpty {
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert." Text("Searching for nearby gateways…")
: "Tip: keep Tailscale enabled so your gateway stays reachable.") .font(.caption)
.font(.footnote) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) .padding(.leading, 4)
.lineLimit(1) } else {
} VStack(alignment: .leading, spacing: 6) {
.transition(.opacity.combined(with: .move(edge: .top))) Text("Nearby gateways")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in
self.connectionChoiceButton(
title: gateway.displayName,
subtitle: self.gatewaySubtitle(for: gateway),
selected: self.isSelectedGateway(gateway))
{
self.selectRemoteGateway(gateway)
} }
} }
} }
.padding(8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
}
}
@ViewBuilder
private func advancedConnectionSection() -> some View {
Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.showAdvancedConnection.toggle()
}
if self.showAdvancedConnection, self.state.connectionMode != .remote {
self.state.connectionMode = .remote
}
}
.buttonStyle(.link)
if self.showAdvancedConnection {
let labelWidth: CGFloat = 110
let fieldWidth: CGFloat = 320
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(width: fieldWidth)
}
if self.state.remoteTransport == .direct {
GridRow {
Text("Gateway URL")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
if self.state.remoteTransport == .ssh {
GridRow {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
if let message = CommandResolver
.sshTargetValidationMessage(self.state.remoteTarget)
{
GridRow {
Text("")
.frame(width: labelWidth, alignment: .leading)
Text(message)
.font(.caption)
.foregroundStyle(.red)
.frame(width: fieldWidth, alignment: .leading)
}
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/openclaw", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/OpenClaw.app/.../openclaw",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
}
Text(self.state.remoteTransport == .direct
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.transition(.opacity.combined(with: .move(edge: .top)))
} }
} }

View File

@@ -13,8 +13,10 @@ extension OnboardingView {
guard self.state.connectionMode == .local else { return } guard self.state.connectionMode == .local else { return }
let configured = await self.loadAgentWorkspace() let configured = await self.loadAgentWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured) let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
switch AgentWorkspace.bootstrapSafety(for: url) { let safety = AgentWorkspace.bootstrapSafety(for: url)
case .safe: if let reason = safety.unsafeReason {
self.workspaceStatus = "Workspace not touched: \(reason)"
} else {
do { do {
_ = try AgentWorkspace.bootstrap(workspaceURL: url) _ = try AgentWorkspace.bootstrap(workspaceURL: url)
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -23,8 +25,6 @@ extension OnboardingView {
} catch { } catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
} }
case let .unsafe (reason):
self.workspaceStatus = "Workspace not touched: \(reason)"
} }
self.refreshBootstrapStatus() self.refreshBootstrapStatus()
} }
@@ -54,7 +54,7 @@ extension OnboardingView {
do { do {
let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath)
if case let .unsafe (reason) = AgentWorkspace.bootstrapSafety(for: url) { if let reason = AgentWorkspace.bootstrapSafety(for: url).unsafeReason {
self.workspaceStatus = "Workspace not created: \(reason)" self.workspaceStatus = "Workspace not created: \(reason)"
return return
} }

View File

@@ -383,12 +383,12 @@ final class ExecApprovalsSettingsModel {
func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? { func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? {
guard !self.isDefaultsScope else { return nil } guard !self.isDefaultsScope else { return nil }
switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { switch ExecApprovalHelpers.validateAllowlistPattern(pattern) {
case .valid(let normalizedPattern): case let .valid(normalizedPattern):
self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil)) self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil))
let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
self.allowlistValidationMessage = rejected.first?.reason.message self.allowlistValidationMessage = rejected.first?.reason.message
return rejected.first?.reason return rejected.first?.reason
case .invalid(let reason): case let .invalid(reason):
self.allowlistValidationMessage = reason.message self.allowlistValidationMessage = reason.message
return reason return reason
} }
@@ -400,9 +400,9 @@ final class ExecApprovalsSettingsModel {
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil } guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil }
var next = entry var next = entry
switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) { switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) {
case .valid(let normalizedPattern): case let .valid(normalizedPattern):
next.pattern = normalizedPattern next.pattern = normalizedPattern
case .invalid(let reason): case let .invalid(reason):
self.allowlistValidationMessage = reason.message self.allowlistValidationMessage = reason.message
return reason return reason
} }

View File

@@ -810,25 +810,59 @@ extension TalkModeRuntime {
return trimmed.isEmpty ? nil : trimmed return trimmed.isEmpty ? nil : trimmed
} }
private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? {
if let typed = value.value as? [String: AnyCodable] {
return typed
}
if let foundation = value.value as? [String: Any] {
return foundation.mapValues(AnyCodable.init)
}
if let nsDict = value.value as? NSDictionary {
var converted: [String: AnyCodable] = [:]
for case let (key as String, raw) in nsDict {
converted[key] = AnyCodable(raw)
}
return converted
}
return nil
}
private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] {
guard let raw else { return [:] }
var providerMap: [String: AnyCodable] = [:]
if let typed = raw.value as? [String: AnyCodable] {
providerMap = typed
} else if let foundation = raw.value as? [String: Any] {
providerMap = foundation.mapValues(AnyCodable.init)
} else if let nsDict = raw.value as? NSDictionary {
for case let (key as String, value) in nsDict {
providerMap[key] = AnyCodable(value)
}
} else {
return [:]
}
return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in
guard
let providerID = Self.normalizedTalkProviderID(entry.key),
let providerConfig = Self.normalizedTalkProviderConfig(entry.value)
else { return }
acc[providerID] = providerConfig
}
}
static func selectTalkProviderConfig( static func selectTalkProviderConfig(
_ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection? _ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection?
{ {
guard let talk else { return nil } guard let talk else { return nil }
let rawProvider = talk["provider"]?.stringValue let rawProvider = talk["provider"]?.stringValue
let rawProviders = talk["providers"]?.dictionaryValue let rawProviders = talk["providers"]
let hasNormalizedPayload = rawProvider != nil || rawProviders != nil let hasNormalizedPayload = rawProvider != nil || rawProviders != nil
if hasNormalizedPayload { if hasNormalizedPayload {
let normalizedProviders = let normalizedProviders = Self.normalizedTalkProviders(rawProviders)
rawProviders?.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in
guard
let providerID = Self.normalizedTalkProviderID(entry.key),
let providerConfig = entry.value.dictionaryValue
else { return }
acc[providerID] = providerConfig
} ?? [:]
let providerID = let providerID =
Self.normalizedTalkProviderID(rawProvider) ?? Self.normalizedTalkProviderID(rawProvider) ??
normalizedProviders.keys.sorted().first ?? normalizedProviders.keys.min() ??
Self.defaultTalkProvider Self.defaultTalkProvider
return TalkProviderConfigSelection( return TalkProviderConfigSelection(
provider: providerID, provider: providerID,
@@ -877,14 +911,14 @@ extension TalkModeRuntime {
let apiKey = activeConfig?["apiKey"]?.stringValue let apiKey = activeConfig?["apiKey"]?.stringValue
let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider { let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ?? (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
(envVoice?.isEmpty == false ? envVoice : nil) ?? (envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil) (sagVoice?.isEmpty == false ? sagVoice : nil)
} else { } else {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) (voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
} }
let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider { let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider {
(envApiKey?.isEmpty == false ? envApiKey : nil) ?? (envApiKey?.isEmpty == false ? envApiKey : nil) ??
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil) (apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
} else { } else {
nil nil
} }

View File

@@ -59,12 +59,7 @@ struct AgentWorkspaceTests {
try "hello".write(to: marker, atomically: true, encoding: .utf8) try "hello".write(to: marker, atomically: true, encoding: .utf8)
let result = AgentWorkspace.bootstrapSafety(for: tmp) let result = AgentWorkspace.bootstrapSafety(for: tmp)
switch result { #expect(result.unsafeReason != nil)
case .unsafe:
break
case .safe:
#expect(Bool(false), "Expected unsafe bootstrap safety result.")
}
} }
@Test @Test
@@ -77,12 +72,7 @@ struct AgentWorkspaceTests {
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
let result = AgentWorkspace.bootstrapSafety(for: tmp) let result = AgentWorkspace.bootstrapSafety(for: tmp)
switch result { #expect(result.unsafeReason == nil)
case .safe:
break
case .unsafe:
#expect(Bool(false), "Expected safe bootstrap safety result.")
}
} }
@Test @Test