mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
df8fedcc00
* fix(macos): isolate named-profile development project roots * fix(macos): report actual direct gateway connection failures * fix(macos): satisfy SwiftLint remote recovery line limit * fix(macos): sync native IPv6 endpoint localization inventory * fix(macos): keep IPv6 endpoint formatting out of localization inventory
653 lines
27 KiB
Swift
653 lines
27 KiB
Swift
import Darwin
|
|
import Foundation
|
|
import Testing
|
|
@testable import OpenClaw
|
|
|
|
@Suite(.serialized) struct CommandResolverTests {
|
|
private func makeDefaults() -> UserDefaults {
|
|
// Use a unique suite to avoid cross-suite concurrency on UserDefaults.standard.
|
|
UserDefaults(suiteName: "CommandResolverTests.\(UUID().uuidString)")!
|
|
}
|
|
|
|
private func makeLocalDefaults() -> UserDefaults {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
|
|
return defaults
|
|
}
|
|
|
|
private func makeProjectRootWithPnpm() throws -> (tmp: URL, pnpmPath: URL) {
|
|
let tmp = try makeTempDirForTests()
|
|
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
|
try makeExecutableForTests(at: pnpmPath)
|
|
return (tmp, pnpmPath)
|
|
}
|
|
|
|
@Test func `named profiles do not inherit the default development checkout`() throws {
|
|
let home = try makeTempDirForTests()
|
|
let checkout = home.appendingPathComponent("Projects/openclaw")
|
|
try FileManager.default.createDirectory(at: checkout, withIntermediateDirectories: true)
|
|
let defaults = self.makeDefaults()
|
|
let defaultProfile = AppProfile(environment: [:])
|
|
let namedProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "isolated"])
|
|
|
|
#expect(CommandResolver.projectRoot(
|
|
defaults: defaults,
|
|
profile: defaultProfile,
|
|
homeDirectory: home).path == checkout.path)
|
|
#expect(CommandResolver.projectRoot(
|
|
defaults: defaults,
|
|
profile: namedProfile,
|
|
homeDirectory: home).path == home.appendingPathComponent(".openclaw-isolated").path)
|
|
|
|
defaults.set(checkout.path, forKey: "openclaw.gatewayProjectRootPath")
|
|
#expect(CommandResolver.projectRoot(
|
|
defaults: defaults,
|
|
profile: namedProfile,
|
|
homeDirectory: home).path == checkout.path)
|
|
}
|
|
|
|
@Test func `prefers open claw binary`() async throws {
|
|
let defaults = self.makeLocalDefaults()
|
|
|
|
let tmp = try makeTempDirForTests()
|
|
|
|
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
|
try makeExecutableForTests(at: openclawPath)
|
|
|
|
let searchPaths = [tmp.appendingPathComponent("node_modules/.bin").path]
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "gateway",
|
|
defaults: defaults,
|
|
configRoot: [:],
|
|
searchPaths: searchPaths,
|
|
projectRoot: tmp)
|
|
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
|
|
}
|
|
|
|
@Test func `source checkout worker uses the freshness aware runner`() async throws {
|
|
let tmp = try makeTempDirForTests()
|
|
let runner = tmp.appendingPathComponent("scripts/run-node.mjs")
|
|
let sourceEntrypoint = tmp.appendingPathComponent("openclaw.mjs")
|
|
let distEntrypoint = tmp.appendingPathComponent("dist/entry.js")
|
|
let projectExecutable = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
|
let runtimeBin = tmp.appendingPathComponent("runtime/bin")
|
|
let node = runtimeBin.appendingPathComponent("node")
|
|
try FileManager().createDirectory(
|
|
at: runner.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try "// source runner\n".write(to: runner, atomically: true, encoding: .utf8)
|
|
try makeExecutableForTests(at: sourceEntrypoint)
|
|
try makeExecutableForTests(at: distEntrypoint)
|
|
try makeExecutableForTests(at: projectExecutable)
|
|
try makeExecutableForTests(at: node)
|
|
try "#!/bin/sh\necho v22.22.3\n".write(to: node, atomically: true, encoding: .utf8)
|
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: node.path)
|
|
|
|
let launch = try #require(try await CommandResolver.projectNodeHostWorkerLaunch(
|
|
projectRoot: tmp,
|
|
searchPaths: [runtimeBin.path]))
|
|
|
|
#expect(launch.command == [node.path, runner.path, "node", "worker"])
|
|
#expect(launch.currentDirectoryURL == tmp)
|
|
#expect(!launch.command.contains(sourceEntrypoint.path))
|
|
#expect(!launch.command.contains(distEntrypoint.path))
|
|
#expect(!launch.command.contains(projectExecutable.path))
|
|
}
|
|
|
|
@Test func `falls back to node and script`() async throws {
|
|
let defaults = self.makeLocalDefaults()
|
|
|
|
let tmp = try makeTempDirForTests()
|
|
|
|
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
|
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
|
try makeExecutableForTests(at: nodePath)
|
|
try "#!/bin/sh\necho v22.22.3\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
|
try makeExecutableForTests(at: scriptPath)
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "rpc",
|
|
defaults: defaults,
|
|
configRoot: [:],
|
|
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(cmd.count >= 3)
|
|
if cmd.count >= 3 {
|
|
#expect(cmd[0] == nodePath.path)
|
|
#expect(cmd[1] == scriptPath.path)
|
|
#expect(cmd[2] == "rpc")
|
|
}
|
|
}
|
|
|
|
@Test func `prefers open claw binary over pnpm`() async throws {
|
|
let defaults = self.makeLocalDefaults()
|
|
|
|
let tmp = try makeTempDirForTests()
|
|
|
|
let binDir = tmp.appendingPathComponent("bin")
|
|
let openclawPath = binDir.appendingPathComponent("openclaw")
|
|
let pnpmPath = binDir.appendingPathComponent("pnpm")
|
|
try makeExecutableForTests(at: openclawPath)
|
|
try makeExecutableForTests(at: pnpmPath)
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "rpc",
|
|
defaults: defaults,
|
|
configRoot: [:],
|
|
searchPaths: [binDir.path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "rpc"]))
|
|
}
|
|
|
|
@Test func `uses open claw binary without node runtime`() async throws {
|
|
let defaults = self.makeLocalDefaults()
|
|
|
|
let tmp = try makeTempDirForTests()
|
|
|
|
let binDir = tmp.appendingPathComponent("bin")
|
|
let openclawPath = binDir.appendingPathComponent("openclaw")
|
|
try makeExecutableForTests(at: openclawPath)
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "gateway",
|
|
defaults: defaults,
|
|
configRoot: [:],
|
|
searchPaths: [binDir.path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
|
|
}
|
|
|
|
@Test func `falls back to pnpm`() async throws {
|
|
let defaults = self.makeLocalDefaults()
|
|
let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm()
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "rpc",
|
|
defaults: defaults,
|
|
configRoot: [:],
|
|
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"]))
|
|
}
|
|
|
|
@Test func `pnpm keeps extra args after subcommand`() async throws {
|
|
let defaults = self.makeLocalDefaults()
|
|
let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm()
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "health",
|
|
extraArgs: ["--json", "--timeout", "5"],
|
|
defaults: defaults,
|
|
configRoot: [:],
|
|
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "openclaw", "health", "--json"]))
|
|
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
|
}
|
|
|
|
@Test func `missing CLI explains install and source checkout paths`() async throws {
|
|
let defaults = self.makeLocalDefaults()
|
|
let tmp = try makeTempDirForTests()
|
|
let binDir = tmp.appendingPathComponent("bin")
|
|
let nodePath = binDir.appendingPathComponent("node")
|
|
try makeExecutableForTests(at: nodePath)
|
|
try "#!/bin/sh\necho v22.22.3\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "status",
|
|
defaults: defaults,
|
|
configRoot: [:],
|
|
searchPaths: [binDir.path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(cmd.first == "/bin/sh")
|
|
#expect(cmd.last?.contains("openclaw CLI not found") == true)
|
|
#expect(cmd.last?.contains("Install the CLI") == true)
|
|
#expect(cmd.last?.contains("run pnpm build in an OpenClaw source checkout") == true)
|
|
}
|
|
|
|
@Test func `preferred paths start with project node bins`() throws {
|
|
let tmp = try makeTempDirForTests()
|
|
|
|
let first = CommandResolver.preferredPaths(
|
|
home: FileManager().homeDirectoryForCurrentUser,
|
|
current: [],
|
|
projectRoot: tmp).first
|
|
#expect(first == tmp.appendingPathComponent("node_modules/.bin").path)
|
|
}
|
|
|
|
@Test func `managed install only precedes external installs after validation`() throws {
|
|
let home = try makeTempDirForTests()
|
|
let managedBin = home.appendingPathComponent(".openclaw/bin")
|
|
try FileManager().createDirectory(at: managedBin, withIntermediateDirectories: true)
|
|
let managedExecutable = managedBin.appendingPathComponent("openclaw")
|
|
|
|
let fallbackPaths = CommandResolver.preferredPaths(
|
|
home: home,
|
|
current: [],
|
|
projectRoot: home)
|
|
let validatedPaths = CommandResolver.preferredPaths(
|
|
home: home,
|
|
current: [],
|
|
projectRoot: home,
|
|
validatedExecutable: managedExecutable.path)
|
|
|
|
let packageManagerPath = home.appendingPathComponent("Library/pnpm").path
|
|
let fallbackManagedIndex = try #require(fallbackPaths.firstIndex(of: managedBin.path))
|
|
let fallbackPackageManagerIndex = try #require(fallbackPaths.firstIndex(of: packageManagerPath))
|
|
let validatedManagedIndex = try #require(validatedPaths.firstIndex(of: managedBin.path))
|
|
let validatedPackageManagerIndex = try #require(validatedPaths.firstIndex(of: packageManagerPath))
|
|
#expect(fallbackManagedIndex > fallbackPackageManagerIndex)
|
|
#expect(validatedManagedIndex < validatedPackageManagerIndex)
|
|
}
|
|
|
|
@Test func `managed paths follow the app profile`() throws {
|
|
let home = try makeTempDirForTests()
|
|
defer { try? FileManager().removeItem(at: home) }
|
|
let hostBase = home.appendingPathComponent(".openclaw")
|
|
let profileBase = home.appendingPathComponent(".openclaw-onboardtest")
|
|
for base in [hostBase, profileBase] {
|
|
try makeExecutableForTests(at: base.appendingPathComponent("bin/openclaw"))
|
|
try FileManager().createDirectory(
|
|
at: base.appendingPathComponent("tools/node/bin"),
|
|
withIntermediateDirectories: true)
|
|
}
|
|
|
|
let cases = [
|
|
(AppProfile(environment: [:]), hostBase, profileBase),
|
|
(AppProfile(environment: ["OPENCLAW_PROFILE": "onboardtest"]), profileBase, hostBase),
|
|
]
|
|
for (profile, expectedBase, excludedBase) in cases {
|
|
let paths = CommandResolver.preferredPaths(
|
|
home: home,
|
|
current: [],
|
|
projectRoot: home,
|
|
profile: profile)
|
|
#expect(paths.contains(expectedBase.appendingPathComponent("bin").path))
|
|
#expect(paths.contains(expectedBase.appendingPathComponent("tools/node/bin").path))
|
|
#expect(!paths.contains(excludedBase.appendingPathComponent("bin").path))
|
|
#expect(!paths.contains(excludedBase.appendingPathComponent("tools/node/bin").path))
|
|
}
|
|
|
|
let namedProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "onboardtest"])
|
|
let staleCases = [
|
|
(namedProfile, hostBase),
|
|
(AppProfile(environment: [:]), profileBase),
|
|
]
|
|
for (profile, staleBase) in staleCases {
|
|
// Stale validation and inherited shell PATH both leak foreign managed dirs.
|
|
let paths = CommandResolver.preferredPaths(
|
|
home: home,
|
|
current: [staleBase.appendingPathComponent("bin").path, "/usr/bin"],
|
|
projectRoot: home,
|
|
validatedExecutable: staleBase.appendingPathComponent("bin/openclaw").path,
|
|
profile: profile)
|
|
#expect(!paths.contains(staleBase.appendingPathComponent("bin").path))
|
|
#expect(paths.contains("/usr/bin"))
|
|
}
|
|
|
|
// ~/.openclaw2 is a lookalike, not the managed profile namespace; validation must survive.
|
|
for external in ["custom/bin/openclaw", ".openclaw2/bin/openclaw"] {
|
|
let customExecutable = home.appendingPathComponent(external)
|
|
try makeExecutableForTests(at: customExecutable)
|
|
let externalPaths = CommandResolver.preferredPaths(
|
|
home: home,
|
|
current: [],
|
|
projectRoot: home,
|
|
validatedExecutable: customExecutable.path,
|
|
profile: namedProfile)
|
|
let customBin = customExecutable.deletingLastPathComponent().path
|
|
let customIndex = try #require(externalPaths.firstIndex(of: customBin))
|
|
let homebrewIndex = try #require(externalPaths.firstIndex(of: "/opt/homebrew/bin"))
|
|
#expect(customIndex < homebrewIndex)
|
|
}
|
|
}
|
|
|
|
@Test func `node manager runtimes precede system runtimes`() throws {
|
|
let home = try makeTempDirForTests()
|
|
let nodeManagerBin = home.appendingPathComponent(".nvm/versions/node/v22.22.3/bin")
|
|
try makeExecutableForTests(at: nodeManagerBin.appendingPathComponent("node"))
|
|
|
|
let paths = CommandResolver.preferredPaths(
|
|
home: home,
|
|
current: [],
|
|
projectRoot: home)
|
|
|
|
let managerIndex = try #require(paths.firstIndex(of: nodeManagerBin.path))
|
|
let systemIndex = try #require(paths.firstIndex(of: "/opt/homebrew/bin"))
|
|
#expect(managerIndex < systemIndex)
|
|
}
|
|
|
|
@Test func `preferred paths include local user bin after system bins`() throws {
|
|
let home = try makeTempDirForTests()
|
|
let localBin = home.appendingPathComponent(".local/bin").path
|
|
let paths = CommandResolver.preferredPaths(
|
|
home: home,
|
|
current: [],
|
|
projectRoot: home)
|
|
|
|
let localIndex = try #require(paths.firstIndex(of: localBin))
|
|
let systemIndex = try #require(paths.firstIndex(of: "/bin"))
|
|
#expect(localIndex > systemIndex)
|
|
#expect(paths.count(where: { $0 == localBin }) == 1)
|
|
}
|
|
|
|
@Test func `SSH environment replaces path without dropping inherited values`() {
|
|
let paths = ["/usr/bin", "/bin", "/Users/test/.local/bin", "/opt/homebrew/bin"]
|
|
let environment = CommandResolver.sshEnvironment(
|
|
base: [
|
|
"HOME": "/Users/test",
|
|
"PATH": "/stale/path",
|
|
"SSH_AUTH_SOCK": "/tmp/ssh-agent.sock",
|
|
],
|
|
searchPaths: paths)
|
|
|
|
#expect(environment["PATH"] == paths.joined(separator: ":"))
|
|
#expect(environment["HOME"] == "/Users/test")
|
|
#expect(environment["SSH_AUTH_SOCK"] == "/tmp/ssh-agent.sock")
|
|
}
|
|
|
|
@Test func `validated CLI preference expires when the app requires a newer version`() throws {
|
|
let defaults = self.makeDefaults()
|
|
let root = try makeTempDirForTests()
|
|
let executable = root.appendingPathComponent("openclaw")
|
|
FileManager().createFile(atPath: executable.path, contents: Data())
|
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
|
defaults.set(executable.path, forKey: cliValidatedExecutableKey)
|
|
defaults.set("2026.7.3", forKey: cliValidatedVersionKey)
|
|
|
|
#expect(CommandResolver.validatedOpenClawExecutable(
|
|
defaults: defaults,
|
|
fileManager: .default,
|
|
requiredVersion: "2026.7.3") == executable.path)
|
|
#expect(CommandResolver.validatedOpenClawExecutable(
|
|
defaults: defaults,
|
|
fileManager: .default,
|
|
requiredVersion: "2026.8.0") == nil)
|
|
|
|
defaults.set("2026.7.3-beta.1", forKey: cliValidatedVersionKey)
|
|
#expect(CommandResolver.validatedOpenClawExecutable(
|
|
defaults: defaults,
|
|
fileManager: .default,
|
|
requiredVersion: "2026.7.3") == nil)
|
|
defaults.set("2026.7.3", forKey: cliValidatedVersionKey)
|
|
#expect(CommandResolver.validatedOpenClawExecutable(
|
|
defaults: defaults,
|
|
fileManager: .default,
|
|
requiredVersion: "2026.7.3-beta.1") == nil)
|
|
}
|
|
|
|
@Test func `builds SSH command for remote mode`() async {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey)
|
|
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
|
defaults.set("/srv/openclaw", forKey: remoteProjectRootKey)
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "status",
|
|
extraArgs: ["--json"],
|
|
defaults: defaults,
|
|
configRoot: [:])
|
|
|
|
#expect(cmd.first == "/usr/bin/ssh")
|
|
if let marker = cmd.firstIndex(of: "--") {
|
|
#expect(cmd[marker + 1] == "openclaw@example.com")
|
|
} else {
|
|
#expect(Bool(false))
|
|
}
|
|
#expect(cmd.contains("StrictHostKeyChecking=yes"))
|
|
#expect(!cmd.contains("StrictHostKeyChecking=accept-new"))
|
|
#expect(cmd.contains("UpdateHostKeys=yes"))
|
|
#expect(cmd.contains("ControlPath=none"))
|
|
#expect(cmd.contains("-i"))
|
|
#expect(cmd.contains("/tmp/id_ed25519"))
|
|
if let script = cmd.last {
|
|
#expect(script.contains("PRJ='/srv/openclaw'"))
|
|
#expect(script.contains("cd \"$PRJ\""))
|
|
#expect(script.contains("openclaw"))
|
|
#expect(script.contains("status"))
|
|
#expect(script.contains("--json"))
|
|
#expect(script.contains("CLI="))
|
|
}
|
|
}
|
|
|
|
@Test func `explicit SSH config host key policy omits strict override`() async {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
defaults.set("gateway-alias", forKey: remoteTargetKey)
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "status",
|
|
defaults: defaults,
|
|
configRoot: [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"sshHostKeyPolicy": "openssh",
|
|
"sshTarget": "gateway-alias",
|
|
],
|
|
],
|
|
])
|
|
|
|
#expect(cmd.first == "/usr/bin/ssh")
|
|
#expect(!cmd.contains { $0.hasPrefix("StrictHostKeyChecking=") })
|
|
#expect(cmd.contains("ControlPath=none"))
|
|
}
|
|
|
|
@Test func `explicit SSH config replaces stale defaults with its host key policy`() async {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
defaults.set("new-gateway-alias", forKey: remoteTargetKey)
|
|
defaults.set("/tmp/stale-id", forKey: remoteIdentityKey)
|
|
|
|
let configRoot: [String: Any] = [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"sshHostKeyPolicy": "openssh",
|
|
"sshTarget": "old-gateway-alias",
|
|
"sshIdentity": "/tmp/config-id",
|
|
],
|
|
],
|
|
]
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "status",
|
|
defaults: defaults,
|
|
configRoot: configRoot)
|
|
let settings = CommandResolver.connectionSettings(
|
|
defaults: defaults,
|
|
configRoot: configRoot)
|
|
|
|
#expect(settings.target == "old-gateway-alias")
|
|
#expect(settings.identity == "/tmp/config-id")
|
|
#expect(settings.sshHostKeyPolicy == .openssh)
|
|
#expect(!cmd.contains { $0.hasPrefix("StrictHostKeyChecking=") })
|
|
#expect(cmd.contains("/tmp/config-id"))
|
|
#expect(!cmd.contains("/tmp/stale-id"))
|
|
}
|
|
|
|
@Test func `explicit blank SSH config clears stale defaults`() {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
defaults.set("stale-gateway-alias", forKey: remoteTargetKey)
|
|
defaults.set("/tmp/stale-id", forKey: remoteIdentityKey)
|
|
|
|
let settings = CommandResolver.connectionSettings(
|
|
defaults: defaults,
|
|
configRoot: [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"sshTarget": " ",
|
|
"sshIdentity": " ",
|
|
],
|
|
],
|
|
])
|
|
|
|
#expect(settings.target.isEmpty)
|
|
#expect(settings.identity.isEmpty)
|
|
#expect(settings.sshHostKeyPolicy == .strict)
|
|
}
|
|
|
|
@Test func `remote SSH with explicit blank target fails closed`() async throws {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
defaults.set("stale-gateway-alias", forKey: remoteTargetKey)
|
|
let tmp = try makeTempDirForTests()
|
|
let localOpenClaw = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
|
try makeExecutableForTests(at: localOpenClaw)
|
|
|
|
let command = await CommandResolver.openclawCommand(
|
|
subcommand: "status",
|
|
defaults: defaults,
|
|
configRoot: [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"transport": "ssh",
|
|
"sshTarget": " ",
|
|
],
|
|
],
|
|
],
|
|
searchPaths: [localOpenClaw.deletingLastPathComponent().path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(command.first == "/bin/sh")
|
|
#expect(command.last?.contains("Remote SSH gateway target is missing or invalid.") == true)
|
|
#expect(!command.contains(localOpenClaw.path))
|
|
}
|
|
|
|
@Test func `direct remote route uses local CLI despite stale SSH defaults`() async throws {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
defaults.set("stale-gateway-alias", forKey: remoteTargetKey)
|
|
let tmp = try makeTempDirForTests()
|
|
let localOpenClaw = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
|
try makeExecutableForTests(at: localOpenClaw)
|
|
|
|
let command = await CommandResolver.openclawCommand(
|
|
subcommand: "status",
|
|
defaults: defaults,
|
|
configRoot: [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"transport": "direct",
|
|
"url": "wss://gateway.example.test",
|
|
],
|
|
],
|
|
],
|
|
searchPaths: [localOpenClaw.deletingLastPathComponent().path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(command == [localOpenClaw.path, "status"])
|
|
}
|
|
|
|
@Test func `invalid SSH host key policy fails closed`() {
|
|
let settings = CommandResolver.connectionSettings(configRoot: [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": ["sshHostKeyPolicy": " OPENSSH "],
|
|
],
|
|
])
|
|
|
|
#expect(settings.sshHostKeyPolicy == .strict)
|
|
}
|
|
|
|
@Test func `remote gateway probe applies SSH host key policy`() throws {
|
|
let strict = try #require(RemoteGatewayProbe._testSSHCheckCommand(
|
|
target: "gateway-alias",
|
|
hostKeyPolicy: .strict))
|
|
let openssh = try #require(RemoteGatewayProbe._testSSHCheckCommand(
|
|
target: "gateway-alias",
|
|
hostKeyPolicy: .openssh))
|
|
|
|
#expect(strict.contains("StrictHostKeyChecking=yes"))
|
|
#expect(strict.contains("UpdateHostKeys=yes"))
|
|
#expect(strict.contains("ControlPath=none"))
|
|
#expect(!openssh.contains { $0.hasPrefix("StrictHostKeyChecking=") })
|
|
#expect(!openssh.contains { $0.hasPrefix("UpdateHostKeys=") })
|
|
#expect(openssh.contains("ControlPath=none"))
|
|
}
|
|
|
|
@Test func `empty remote defaults fall back to config remote values`() {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
defaults.set(" ", forKey: remoteTargetKey)
|
|
defaults.set("", forKey: remoteIdentityKey)
|
|
|
|
let settings = CommandResolver.connectionSettings(
|
|
defaults: defaults,
|
|
configRoot: [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"sshTarget": "alice@gateway.local",
|
|
"sshIdentity": "/tmp/config-id",
|
|
],
|
|
],
|
|
])
|
|
|
|
#expect(settings.target == "alice@gateway.local")
|
|
#expect(settings.identity == "/tmp/config-id")
|
|
}
|
|
|
|
@Test func `rejects unsafe SSH targets`() {
|
|
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
|
|
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
|
|
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
|
|
}
|
|
|
|
@Test func `config root local overrides remote defaults`() async throws {
|
|
let defaults = self.makeDefaults()
|
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
|
defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey)
|
|
|
|
let tmp = try makeTempDirForTests()
|
|
|
|
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
|
|
try makeExecutableForTests(at: openclawPath)
|
|
|
|
let cmd = await CommandResolver.openclawCommand(
|
|
subcommand: "daemon",
|
|
defaults: defaults,
|
|
configRoot: ["gateway": ["mode": "local"]],
|
|
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path],
|
|
projectRoot: tmp)
|
|
|
|
#expect(cmd.first == openclawPath.path)
|
|
#expect(cmd.count >= 2)
|
|
if cmd.count >= 2 {
|
|
#expect(cmd[1] == "daemon")
|
|
}
|
|
}
|
|
|
|
@Test func `remote settings fall back to config ssh target`() {
|
|
let defaults = self.makeDefaults()
|
|
let settings = CommandResolver.connectionSettings(
|
|
defaults: defaults,
|
|
configRoot: [
|
|
"gateway": [
|
|
"mode": "remote",
|
|
"remote": [
|
|
"sshTarget": "alice@gateway.example:2222",
|
|
"sshIdentity": "/tmp/id_ed25519",
|
|
],
|
|
],
|
|
])
|
|
|
|
#expect(settings.mode == .remote)
|
|
#expect(settings.target == "alice@gateway.example:2222")
|
|
#expect(settings.identity == "/tmp/id_ed25519")
|
|
}
|
|
}
|