Spaces:
Sleeping
Sleeping
| import Foundation | |
| enum CommandResolver { | |
| private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath" | |
| private static let helperName = "openclaw" | |
| static func gatewayEntrypoint(in root: URL) -> String? { | |
| let distEntry = root.appendingPathComponent("dist/index.js").path | |
| if FileManager().isReadableFile(atPath: distEntry) { return distEntry } | |
| let openclawEntry = root.appendingPathComponent("openclaw.mjs").path | |
| if FileManager().isReadableFile(atPath: openclawEntry) { return openclawEntry } | |
| let binEntry = root.appendingPathComponent("bin/openclaw.js").path | |
| if FileManager().isReadableFile(atPath: binEntry) { return binEntry } | |
| return nil | |
| } | |
| static func runtimeResolution() -> Result<RuntimeResolution, RuntimeResolutionError> { | |
| RuntimeLocator.resolve(searchPaths: self.preferredPaths()) | |
| } | |
| static func runtimeResolution(searchPaths: [String]?) -> Result<RuntimeResolution, RuntimeResolutionError> { | |
| RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths()) | |
| } | |
| static func makeRuntimeCommand( | |
| runtime: RuntimeResolution, | |
| entrypoint: String, | |
| subcommand: String, | |
| extraArgs: [String]) -> [String] | |
| { | |
| [runtime.path, entrypoint, subcommand] + extraArgs | |
| } | |
| static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] { | |
| let message = RuntimeLocator.describeFailure(error) | |
| return self.errorCommand(with: message) | |
| } | |
| static func errorCommand(with message: String) -> [String] { | |
| let script = """ | |
| cat <<'__OPENCLAW_ERR__' >&2 | |
| \(message) | |
| __OPENCLAW_ERR__ | |
| exit 1 | |
| """ | |
| return ["/bin/sh", "-c", script] | |
| } | |
| static func projectRoot() -> URL { | |
| if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), | |
| let url = self.expandPath(stored), | |
| FileManager().fileExists(atPath: url.path) | |
| { | |
| return url | |
| } | |
| let fallback = FileManager().homeDirectoryForCurrentUser | |
| .appendingPathComponent("Projects/openclaw") | |
| if FileManager().fileExists(atPath: fallback.path) { | |
| return fallback | |
| } | |
| return FileManager().homeDirectoryForCurrentUser | |
| } | |
| static func setProjectRoot(_ path: String) { | |
| UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey) | |
| } | |
| static func projectRootPath() -> String { | |
| self.projectRoot().path | |
| } | |
| static func preferredPaths() -> [String] { | |
| let current = ProcessInfo.processInfo.environment["PATH"]? | |
| .split(separator: ":").map(String.init) ?? [] | |
| let home = FileManager().homeDirectoryForCurrentUser | |
| let projectRoot = self.projectRoot() | |
| return self.preferredPaths(home: home, current: current, projectRoot: projectRoot) | |
| } | |
| static func preferredPaths(home: URL, current: [String], projectRoot: URL) -> [String] { | |
| var extras = [ | |
| home.appendingPathComponent("Library/pnpm").path, | |
| "/opt/homebrew/bin", | |
| "/usr/local/bin", | |
| "/usr/bin", | |
| "/bin", | |
| ] | |
| #if DEBUG | |
| // Dev-only convenience. Avoid project-local PATH hijacking in release builds. | |
| extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0) | |
| #endif | |
| let openclawPaths = self.openclawManagedPaths(home: home) | |
| if !openclawPaths.isEmpty { | |
| extras.insert(contentsOf: openclawPaths, at: 1) | |
| } | |
| extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + openclawPaths.count) | |
| var seen = Set<String>() | |
| // Preserve order while stripping duplicates so PATH lookups remain deterministic. | |
| return (extras + current).filter { seen.insert($0).inserted } | |
| } | |
| private static func openclawManagedPaths(home: URL) -> [String] { | |
| let bases = [ | |
| home.appendingPathComponent(".openclaw"), | |
| ] | |
| var paths: [String] = [] | |
| for base in bases { | |
| let bin = base.appendingPathComponent("bin") | |
| let nodeBin = base.appendingPathComponent("tools/node/bin") | |
| if FileManager().fileExists(atPath: bin.path) { | |
| paths.append(bin.path) | |
| } | |
| if FileManager().fileExists(atPath: nodeBin.path) { | |
| paths.append(nodeBin.path) | |
| } | |
| } | |
| return paths | |
| } | |
| private static func nodeManagerBinPaths(home: URL) -> [String] { | |
| var bins: [String] = [] | |
| // Volta | |
| let volta = home.appendingPathComponent(".volta/bin") | |
| if FileManager().fileExists(atPath: volta.path) { | |
| bins.append(volta.path) | |
| } | |
| // asdf | |
| let asdf = home.appendingPathComponent(".asdf/shims") | |
| if FileManager().fileExists(atPath: asdf.path) { | |
| bins.append(asdf.path) | |
| } | |
| // fnm | |
| bins.append(contentsOf: self.versionedNodeBinPaths( | |
| base: home.appendingPathComponent(".local/share/fnm/node-versions"), | |
| suffix: "installation/bin")) | |
| // nvm | |
| bins.append(contentsOf: self.versionedNodeBinPaths( | |
| base: home.appendingPathComponent(".nvm/versions/node"), | |
| suffix: "bin")) | |
| return bins | |
| } | |
| private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] { | |
| guard FileManager().fileExists(atPath: base.path) else { return [] } | |
| let entries: [String] | |
| do { | |
| entries = try FileManager().contentsOfDirectory(atPath: base.path) | |
| } catch { | |
| return [] | |
| } | |
| func parseVersion(_ name: String) -> [Int] { | |
| let trimmed = name.hasPrefix("v") ? String(name.dropFirst()) : name | |
| return trimmed.split(separator: ".").compactMap { Int($0) } | |
| } | |
| let sorted = entries.sorted { a, b in | |
| let va = parseVersion(a) | |
| let vb = parseVersion(b) | |
| let maxCount = max(va.count, vb.count) | |
| for i in 0..<maxCount { | |
| let ai = i < va.count ? va[i] : 0 | |
| let bi = i < vb.count ? vb[i] : 0 | |
| if ai != bi { return ai > bi } | |
| } | |
| // If identical numerically, keep stable ordering. | |
| return a > b | |
| } | |
| var paths: [String] = [] | |
| for entry in sorted { | |
| let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix) | |
| let node = binDir.appendingPathComponent("node") | |
| if FileManager().isExecutableFile(atPath: node.path) { | |
| paths.append(binDir.path) | |
| } | |
| } | |
| return paths | |
| } | |
| static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { | |
| for dir in searchPaths ?? self.preferredPaths() { | |
| let candidate = (dir as NSString).appendingPathComponent(name) | |
| if FileManager().isExecutableFile(atPath: candidate) { | |
| return candidate | |
| } | |
| } | |
| return nil | |
| } | |
| static func openclawExecutable(searchPaths: [String]? = nil) -> String? { | |
| self.findExecutable(named: self.helperName, searchPaths: searchPaths) | |
| } | |
| static func projectOpenClawExecutable(projectRoot: URL? = nil) -> String? { | |
| #if DEBUG | |
| let root = projectRoot ?? self.projectRoot() | |
| let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path | |
| return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil | |
| #else | |
| return nil | |
| #endif | |
| } | |
| static func nodeCliPath() -> String? { | |
| let root = self.projectRoot() | |
| let candidates = [ | |
| root.appendingPathComponent("openclaw.mjs").path, | |
| root.appendingPathComponent("bin/openclaw.js").path, | |
| ] | |
| for candidate in candidates where FileManager().isReadableFile(atPath: candidate) { | |
| return candidate | |
| } | |
| return nil | |
| } | |
| static func hasAnyOpenClawInvoker(searchPaths: [String]? = nil) -> Bool { | |
| if self.openclawExecutable(searchPaths: searchPaths) != nil { return true } | |
| if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true } | |
| if self.findExecutable(named: "node", searchPaths: searchPaths) != nil, | |
| self.nodeCliPath() != nil | |
| { | |
| return true | |
| } | |
| return false | |
| } | |
| static func openclawNodeCommand( | |
| subcommand: String, | |
| extraArgs: [String] = [], | |
| defaults: UserDefaults = .standard, | |
| configRoot: [String: Any]? = nil, | |
| searchPaths: [String]? = nil) -> [String] | |
| { | |
| let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot) | |
| if settings.mode == .remote, let ssh = self.sshNodeCommand( | |
| subcommand: subcommand, | |
| extraArgs: extraArgs, | |
| settings: settings) | |
| { | |
| return ssh | |
| } | |
| let runtimeResult = self.runtimeResolution(searchPaths: searchPaths) | |
| switch runtimeResult { | |
| case let .success(runtime): | |
| let root = self.projectRoot() | |
| if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) { | |
| return [openclawPath, subcommand] + extraArgs | |
| } | |
| if let entry = self.gatewayEntrypoint(in: root) { | |
| return self.makeRuntimeCommand( | |
| runtime: runtime, | |
| entrypoint: entry, | |
| subcommand: subcommand, | |
| extraArgs: extraArgs) | |
| } | |
| if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) { | |
| // Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs. | |
| return [pnpm, "--silent", "openclaw", subcommand] + extraArgs | |
| } | |
| if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) { | |
| return [openclawPath, subcommand] + extraArgs | |
| } | |
| let missingEntry = """ | |
| openclaw entrypoint missing (looked for dist/index.js or openclaw.mjs); run pnpm build. | |
| """ | |
| return self.errorCommand(with: missingEntry) | |
| case let .failure(error): | |
| return self.runtimeErrorCommand(error) | |
| } | |
| } | |
| static func openclawCommand( | |
| subcommand: String, | |
| extraArgs: [String] = [], | |
| defaults: UserDefaults = .standard, | |
| configRoot: [String: Any]? = nil, | |
| searchPaths: [String]? = nil) -> [String] | |
| { | |
| self.openclawNodeCommand( | |
| subcommand: subcommand, | |
| extraArgs: extraArgs, | |
| defaults: defaults, | |
| configRoot: configRoot, | |
| searchPaths: searchPaths) | |
| } | |
| // MARK: - SSH helpers | |
| private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? { | |
| guard !settings.target.isEmpty else { return nil } | |
| guard let parsed = self.parseSSHTarget(settings.target) else { return nil } | |
| // Run the real openclaw CLI on the remote host. | |
| let exportedPath = [ | |
| "/opt/homebrew/bin", | |
| "/usr/local/bin", | |
| "/usr/bin", | |
| "/bin", | |
| "/usr/sbin", | |
| "/sbin", | |
| "$HOME/Library/pnpm", | |
| "$PATH", | |
| ].joined(separator: ":") | |
| let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") | |
| let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines) | |
| let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines) | |
| let projectSection = if userPRJ.isEmpty { | |
| """ | |
| DEFAULT_PRJ="$HOME/Projects/openclaw" | |
| if [ -d "$DEFAULT_PRJ" ]; then | |
| PRJ="$DEFAULT_PRJ" | |
| cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; } | |
| fi | |
| """ | |
| } else { | |
| """ | |
| PRJ=\(self.shellQuote(userPRJ)) | |
| cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; } | |
| """ | |
| } | |
| let cliSection = if userCLI.isEmpty { | |
| "" | |
| } else { | |
| """ | |
| CLI_HINT=\(self.shellQuote(userCLI)) | |
| if [ -n "$CLI_HINT" ]; then | |
| if [ -x "$CLI_HINT" ]; then | |
| CLI="$CLI_HINT" | |
| "$CLI_HINT" \(quotedArgs); | |
| exit $?; | |
| elif [ -f "$CLI_HINT" ]; then | |
| if command -v node >/dev/null 2>&1; then | |
| CLI="node $CLI_HINT" | |
| node "$CLI_HINT" \(quotedArgs); | |
| exit $?; | |
| fi | |
| fi | |
| fi | |
| """ | |
| } | |
| let scriptBody = """ | |
| PATH=\(exportedPath); | |
| CLI=""; | |
| \(cliSection) | |
| \(projectSection) | |
| if command -v openclaw >/dev/null 2>&1; then | |
| CLI="$(command -v openclaw)" | |
| openclaw \(quotedArgs); | |
| elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/dist/index.js" ]; then | |
| if command -v node >/dev/null 2>&1; then | |
| CLI="node $PRJ/dist/index.js" | |
| node "$PRJ/dist/index.js" \(quotedArgs); | |
| else | |
| echo "Node >=22 required on remote host"; exit 127; | |
| fi | |
| elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/openclaw.mjs" ]; then | |
| if command -v node >/dev/null 2>&1; then | |
| CLI="node $PRJ/openclaw.mjs" | |
| node "$PRJ/openclaw.mjs" \(quotedArgs); | |
| else | |
| echo "Node >=22 required on remote host"; exit 127; | |
| fi | |
| elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/bin/openclaw.js" ]; then | |
| if command -v node >/dev/null 2>&1; then | |
| CLI="node $PRJ/bin/openclaw.js" | |
| node "$PRJ/bin/openclaw.js" \(quotedArgs); | |
| else | |
| echo "Node >=22 required on remote host"; exit 127; | |
| fi | |
| elif command -v pnpm >/dev/null 2>&1; then | |
| CLI="pnpm --silent openclaw" | |
| pnpm --silent openclaw \(quotedArgs); | |
| else | |
| echo "openclaw CLI missing on remote host"; exit 127; | |
| fi | |
| """ | |
| let options: [String] = [ | |
| "-o", "BatchMode=yes", | |
| "-o", "StrictHostKeyChecking=accept-new", | |
| "-o", "UpdateHostKeys=yes", | |
| ] | |
| let args = self.sshArguments( | |
| target: parsed, | |
| identity: settings.identity, | |
| options: options, | |
| remoteCommand: ["/bin/sh", "-c", scriptBody]) | |
| return ["/usr/bin/ssh"] + args | |
| } | |
| struct RemoteSettings { | |
| let mode: AppState.ConnectionMode | |
| let target: String | |
| let identity: String | |
| let projectRoot: String | |
| let cliPath: String | |
| } | |
| static func connectionSettings( | |
| defaults: UserDefaults = .standard, | |
| configRoot: [String: Any]? = nil) -> RemoteSettings | |
| { | |
| let root = configRoot ?? OpenClawConfigFile.loadDict() | |
| let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode | |
| let target = defaults.string(forKey: remoteTargetKey) ?? "" | |
| let identity = defaults.string(forKey: remoteIdentityKey) ?? "" | |
| let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" | |
| let cliPath = defaults.string(forKey: remoteCliPathKey) ?? "" | |
| return RemoteSettings( | |
| mode: mode, | |
| target: self.sanitizedTarget(target), | |
| identity: identity, | |
| projectRoot: projectRoot, | |
| cliPath: cliPath) | |
| } | |
| static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool { | |
| self.connectionSettings(defaults: defaults).mode == .remote | |
| } | |
| private static func sanitizedTarget(_ raw: String) -> String { | |
| let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) | |
| if trimmed.hasPrefix("ssh ") { | |
| return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines) | |
| } | |
| return trimmed | |
| } | |
| struct SSHParsedTarget { | |
| let user: String? | |
| let host: String | |
| let port: Int | |
| } | |
| static func parseSSHTarget(_ target: String) -> SSHParsedTarget? { | |
| let trimmed = self.normalizeSSHTargetInput(target) | |
| guard !trimmed.isEmpty else { return nil } | |
| if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil { | |
| return nil | |
| } | |
| let userHostPort: String | |
| let user: String? | |
| if let atRange = trimmed.range(of: "@") { | |
| user = String(trimmed[..<atRange.lowerBound]) | |
| userHostPort = String(trimmed[atRange.upperBound...]) | |
| } else { | |
| user = nil | |
| userHostPort = trimmed | |
| } | |
| let host: String | |
| let port: Int | |
| if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex { | |
| host = String(userHostPort[..<colon]) | |
| let portStr = String(userHostPort[userHostPort.index(after: colon)...]) | |
| guard let parsedPort = Int(portStr), parsedPort > 0, parsedPort <= 65535 else { | |
| return nil | |
| } | |
| port = parsedPort | |
| } else { | |
| host = userHostPort | |
| port = 22 | |
| } | |
| return self.makeSSHTarget(user: user, host: host, port: port) | |
| } | |
| static func sshTargetValidationMessage(_ target: String) -> String? { | |
| let trimmed = self.normalizeSSHTargetInput(target) | |
| guard !trimmed.isEmpty else { return nil } | |
| if trimmed.hasPrefix("-") { | |
| return "SSH target cannot start with '-'" | |
| } | |
| if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil { | |
| return "SSH target cannot contain spaces" | |
| } | |
| if self.parseSSHTarget(trimmed) == nil { | |
| return "SSH target must look like user@host[:port]" | |
| } | |
| return nil | |
| } | |
| private static func shellQuote(_ text: String) -> String { | |
| if text.isEmpty { return "''" } | |
| let escaped = text.replacingOccurrences(of: "'", with: "'\\''") | |
| return "'\(escaped)'" | |
| } | |
| private static func expandPath(_ path: String) -> URL? { | |
| var expanded = path | |
| if expanded.hasPrefix("~") { | |
| let home = FileManager().homeDirectoryForCurrentUser.path | |
| expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home) | |
| } | |
| return URL(fileURLWithPath: expanded) | |
| } | |
| private static func normalizeSSHTargetInput(_ target: String) -> String { | |
| var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) | |
| if trimmed.hasPrefix("ssh ") { | |
| trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "") | |
| .trimmingCharacters(in: .whitespacesAndNewlines) | |
| } | |
| return trimmed | |
| } | |
| private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool { | |
| if value.isEmpty { return false } | |
| if !allowLeadingDash, value.hasPrefix("-") { return false } | |
| let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters) | |
| return value.rangeOfCharacter(from: invalid) == nil | |
| } | |
| static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? { | |
| let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) | |
| guard self.isValidSSHComponent(trimmedHost) else { return nil } | |
| let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines) | |
| let normalizedUser: String? | |
| if let trimmedUser { | |
| guard self.isValidSSHComponent(trimmedUser) else { return nil } | |
| normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser | |
| } else { | |
| normalizedUser = nil | |
| } | |
| guard port > 0, port <= 65535 else { return nil } | |
| return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port) | |
| } | |
| private static func sshTargetString(_ target: SSHParsedTarget) -> String { | |
| target.user.map { "\($0)@\(target.host)" } ?? target.host | |
| } | |
| static func sshArguments( | |
| target: SSHParsedTarget, | |
| identity: String, | |
| options: [String], | |
| remoteCommand: [String] = []) -> [String] | |
| { | |
| var args = options | |
| if target.port > 0 { | |
| args.append(contentsOf: ["-p", String(target.port)]) | |
| } | |
| let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines) | |
| if !trimmedIdentity.isEmpty { | |
| // Only use IdentitiesOnly when an explicit identity file is provided. | |
| // This allows 1Password SSH agent and other SSH agents to provide keys. | |
| args.append(contentsOf: ["-o", "IdentitiesOnly=yes"]) | |
| args.append(contentsOf: ["-i", trimmedIdentity]) | |
| } | |
| args.append("--") | |
| args.append(self.sshTargetString(target)) | |
| args.append(contentsOf: remoteCommand) | |
| return args | |
| } | |
| #if SWIFT_PACKAGE | |
| static func _testNodeManagerBinPaths(home: URL) -> [String] { | |
| self.nodeManagerBinPaths(home: home) | |
| } | |
| #endif | |
| } | |