mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(macos): clean warnings and harden gateway/talk config parsing
This commit is contained in:
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: " ")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user