fix(macos): clean warnings and harden gateway/talk config parsing
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: " ")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user