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

View File

@@ -17,9 +17,14 @@ enum AgentWorkspace {
AgentWorkspace.userFilename,
AgentWorkspace.bootstrapFilename,
]
enum BootstrapSafety: Equatable {
case safe
case unsafe (reason: String)
struct BootstrapSafety: Equatable {
let unsafeReason: String?
static let safe = Self(unsafeReason: nil)
static func blocked(_ reason: String) -> Self {
Self(unsafeReason: reason)
}
}
static func displayPath(for url: URL) -> String {
@@ -71,9 +76,7 @@ enum AgentWorkspace {
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe
}
if !isDir.boolValue {
return .unsafe (reason: "Workspace path points to a file.")
}
if !isDir.boolValue { return .blocked("Workspace path points to a file.") }
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if fm.fileExists(atPath: agentsURL.path) {
return .safe
@@ -82,9 +85,9 @@ enum AgentWorkspace {
let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
return entries.isEmpty
? .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 {
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
}
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() {
let configUrl = OpenClawConfigFile.url()
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
@@ -470,69 +534,16 @@ final class AppState {
}
if connectionMode == .remote {
var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false
if remoteTransport == .direct {
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
if remote["url"] != nil {
remote.removeValue(forKey: "url")
remoteChanged = true
}
} 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
let currentRemote = gateway["remote"] as? [String: Any] ?? [:]
let updated = Self.updatedRemoteGatewayConfig(
current: currentRemote,
transport: remoteTransport,
remoteUrl: remoteUrl,
remoteHost: remoteHost,
remoteTarget: remoteTarget,
remoteIdentity: remoteIdentity)
if updated.changed {
gateway["remote"] = updated.remote
changed = true
}
}

View File

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

View File

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

View File

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

View File

@@ -63,11 +63,11 @@ enum ExecShellWrapperParser {
private static func extractPayload(command: [String], spec: WrapperSpec) -> String? {
switch spec.kind {
case .posix:
return self.extractPosixInlineCommand(command)
self.extractPosixInlineCommand(command)
case .cmd:
return self.extractCmdInlineCommand(command)
self.extractCmdInlineCommand(command)
case .powershell:
return self.extractPowerShellInlineCommand(command)
self.extractPowerShellInlineCommand(command)
}
}
@@ -81,7 +81,9 @@ enum ExecShellWrapperParser {
}
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
}
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 mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let inferred: String
if let shellCommand, !mustBindDisplayToFullArgv {
inferred = shellCommand
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
inferred = ExecCommandFormatter.displayString(for: command)
ExecCommandFormatter.displayString(for: command)
}
if let raw = normalizedRaw, raw != inferred {
@@ -189,7 +188,7 @@ enum ExecSystemRunCommandValidator {
}
var appletIndex = 1
if appletIndex < argv.count && argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
appletIndex += 1
}
guard appletIndex < argv.count else {
@@ -255,14 +254,13 @@ enum ExecSystemRunCommandValidator {
return false
}
let inlineCommandIndex: Int?
if wrapper == "powershell" || wrapper == "pwsh" {
inlineCommandIndex = self.resolveInlineCommandTokenIndex(
let inlineCommandIndex: Int? = if wrapper == "powershell" || wrapper == "pwsh" {
self.resolveInlineCommandTokenIndex(
wrapperArgv,
flags: self.powershellInlineCommandFlags,
allowCombinedC: false)
} else {
inlineCommandIndex = self.resolveInlineCommandTokenIndex(
self.resolveInlineCommandTokenIndex(
wrapperArgv,
flags: self.posixInlineCommandFlags,
allowCombinedC: true)

View File

@@ -304,8 +304,7 @@ struct GeneralSettings: View {
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
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)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
@@ -549,8 +548,7 @@ extension GeneralSettings {
}
guard Self.isValidWsUrl(trimmedUrl) else {
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
}
} 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 {
var staticCode: SecStaticCode?

View File

@@ -87,19 +87,9 @@ extension OnboardingView {
self.onboardingCard(spacing: 12, padding: 14) {
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(
title: "This Mac",
subtitle: localSubtitle,
subtitle: self.localGatewaySubtitle,
selected: self.state.connectionMode == .local)
{
self.selectLocalGateway()
@@ -107,50 +97,7 @@ extension OnboardingView {
Divider().padding(.vertical, 4)
HStack(spacing: 8) {
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.gatewayDiscoverySection()
self.connectionChoiceButton(
title: "Configure later",
@@ -160,104 +107,168 @@ extension OnboardingView {
self.selectUnconfiguredGateway()
}
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)
self.advancedConnectionSection()
}
}
}
}
if self.showAdvancedConnection {
let labelWidth: CGFloat = 110
let fieldWidth: CGFloat = 320
private var localGatewaySubtitle: 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."
}
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)
}
}
}
@ViewBuilder
private func gatewayDiscoverySection() -> some View {
HStack(spacing: 8) {
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)
}
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)))
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)))
}
}
@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 }
let configured = await self.loadAgentWorkspace()
let url = AgentWorkspace.resolveWorkspaceURL(from: configured)
switch AgentWorkspace.bootstrapSafety(for: url) {
case .safe:
let safety = AgentWorkspace.bootstrapSafety(for: url)
if let reason = safety.unsafeReason {
self.workspaceStatus = "Workspace not touched: \(reason)"
} else {
do {
_ = try AgentWorkspace.bootstrap(workspaceURL: url)
if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -23,8 +25,6 @@ extension OnboardingView {
} catch {
self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)"
}
case let .unsafe (reason):
self.workspaceStatus = "Workspace not touched: \(reason)"
}
self.refreshBootstrapStatus()
}
@@ -54,7 +54,7 @@ extension OnboardingView {
do {
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)"
return
}

View File

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

View File

@@ -810,25 +810,59 @@ extension TalkModeRuntime {
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(
_ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection?
{
guard let talk else { return nil }
let rawProvider = talk["provider"]?.stringValue
let rawProviders = talk["providers"]?.dictionaryValue
let rawProviders = talk["providers"]
let hasNormalizedPayload = rawProvider != nil || rawProviders != nil
if hasNormalizedPayload {
let normalizedProviders =
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 normalizedProviders = Self.normalizedTalkProviders(rawProviders)
let providerID =
Self.normalizedTalkProviderID(rawProvider) ??
normalizedProviders.keys.sorted().first ??
normalizedProviders.keys.min() ??
Self.defaultTalkProvider
return TalkProviderConfigSelection(
provider: providerID,
@@ -877,14 +911,14 @@ extension TalkModeRuntime {
let apiKey = activeConfig?["apiKey"]?.stringValue
let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
(envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil)
(envVoice?.isEmpty == false ? envVoice : nil) ??
(sagVoice?.isEmpty == false ? sagVoice : nil)
} else {
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
}
let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider {
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
} else {
nil
}

View File

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