mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-21 17:24:47 +00:00
* refactor: remove stale file-backed shims * fix: harden sqlite state ci boundaries * refactor: store matrix idb snapshots in sqlite * fix: satisfy rebased CI guardrails * refactor: store current conversation bindings in sqlite table * refactor: store tui last sessions in sqlite table * refactor: reset sqlite schema history * refactor: drop unshipped sqlite table migration * refactor: remove plugin index file rollback * refactor: drop unshipped sqlite sidecar migrations * refactor: remove runtime commitments kv migration * refactor: preserve kysely sync result types * refactor: drop unshipped sqlite schema migration table * test: keep session usage coverage sqlite-backed * refactor: keep sqlite migration doctor-only * refactor: isolate device legacy imports * refactor: isolate push voicewake legacy imports * refactor: isolate remaining runtime legacy imports * refactor: tighten sqlite migration guardrails * test: cover sqlite persisted enum parsing * refactor: isolate legacy update and tui imports * refactor: tighten sqlite state ownership * refactor: move legacy imports behind doctor * refactor: remove legacy session row lookup * refactor: canonicalize memory transcript locators * refactor: drop transcript path scope fallbacks * refactor: drop runtime legacy session delivery pruning * refactor: store tts prefs only in sqlite * refactor: remove cron store path runtime * refactor: use cron sqlite store keys * refactor: rename telegram message cache scope * refactor: read memory dreaming status from sqlite * refactor: rename cron status store key * refactor: stop remembering transcript file paths * test: use sqlite locators in agent fixtures * refactor: remove file-shaped commitments and cron store surfaces * refactor: keep compaction transcript handles out of session rows * refactor: derive transcript handles from session identity * refactor: derive runtime transcript handles * refactor: remove gateway session locator reads * refactor: remove transcript locator from session rows * refactor: store raw stream diagnostics in sqlite * refactor: remove file-shaped transcript rotation * refactor: hide legacy trajectory paths from runtime * refactor: remove runtime transcript file bridges * refactor: repair database-first rebase fallout * refactor: align tests with database-first state * refactor: remove transcript file handoffs * refactor: sync post-compaction memory by transcript scope * refactor: run codex app-server sessions by id * refactor: bind codex runtime state by session id * refactor: pass memory transcripts by sqlite scope * refactor: remove transcript locator cleanup leftovers * test: remove stale transcript file fixtures * refactor: remove transcript locator test helper * test: make cron sqlite keys explicit * test: remove cron runtime store paths * test: remove stale session file fixtures * test: use sqlite cron keys in diagnostics * refactor: remove runtime delivery queue backfill * test: drop fake export session file mocks * refactor: rename acp session read failure flag * refactor: rename acp row session key * refactor: remove session store test seams * refactor: move legacy session parser tests to doctor * refactor: reindex managed memory in place * refactor: drop stale session store wording * refactor: rename session row helpers * refactor: rename sqlite session entry modules * refactor: remove transcript locator leftovers * refactor: trim file-era audit wording * refactor: clean managed media through sqlite * fix: prefer explicit agent for exports * fix: use prepared agent for session resets * fix: canonicalize legacy codex binding import * test: rename state cleanup helper * docs: align backup docs with sqlite state * refactor: drop legacy Pi usage auth fallback * refactor: move legacy auth profile imports to doctor * refactor: keep Pi model discovery auth in memory * refactor: remove MSTeams legacy learning key fallback * refactor: store model catalog config in sqlite * refactor: use sqlite model catalog at runtime * refactor: remove model json compatibility aliases * refactor: store auth profiles in sqlite * refactor: seed copied auth profiles in sqlite * refactor: make auth profile runtime sqlite-addressed * refactor: migrate hermes secrets into sqlite auth store * refactor: move plugin install config migration to doctor * refactor: rename plugin index audit checks * test: drop auth file assumptions * test: remove legacy transcript file assertions * refactor: drop legacy cli session aliases * refactor: store skill uploads in sqlite * refactor: keep subagent attachments in sqlite vfs * refactor: drop subagent attachment cleanup state * refactor: move legacy session aliases to doctor * refactor: require node 24 for sqlite state runtime * refactor: move provider caches into sqlite state * fix: harden virtual agent filesystem * refactor: enforce database-first runtime state * refactor: rename compaction transcript rotation setting * test: clean sqlite refactor test types * refactor: consolidate sqlite runtime state * refactor: model session conversations in sqlite * refactor: stop deriving cron delivery from session keys * refactor: stop classifying sessions from key shape * refactor: hydrate announce targets from typed delivery * refactor: route heartbeat delivery from typed sqlite context * refactor: tighten typed sqlite session routing * refactor: remove session origin routing shadow * refactor: drop session origin shadow fixtures * perf: query sqlite vfs paths by prefix * refactor: use typed conversation metadata for sessions * refactor: prefer typed session routing metadata * refactor: require typed session routing metadata * refactor: resolve group tool policy from typed sessions * refactor: delete dead session thread info bridge * Show Codex subscription reset times in channel errors (#80456) * feat(plugin-sdk): consolidate session workflow APIs * fix(agents): allow read-only agent mount reads * [codex] refresh plugin regression fixtures * fix(agents): restore compaction gateway logs * test: tighten gateway startup assertions * Redact persisted secret-shaped payloads [AI] (#79006) * test: tighten device pair notify assertions * test: tighten hermes secret assertions * test: assert matrix client error shapes * test: assert config compat warnings * fix(heartbeat): remap cron-run exec events to session keys (#80214) * fix(codex): route btw through native side threads * fix(auth): accept friendly OpenAI order for Codex profiles * fix(codex): rotate auth profiles inside harness * fix: keep browser status page probe within timeout * test: assert agents add outputs * test: pin cron read status * fix(agents): avoid Pi resource discovery stalls Co-authored-by: dataCenter430 <titan032000@gmail.com> * fix: retire timed-out codex app-server clients * test: tighten qa lab runtime assertions * test: check security fix outputs * test: verify extension runtime messages * feat(wake): expose typed sessionKey on wake protocol + system event CLI * fix(gateway): await session_end during shutdown drain and track channel + compaction lifecycle paths (#57790) * test: guard talk consult call helper * fix(codex): scale context engine projection (#80761) * fix(codex): scale context engine projection * fix: document Codex context projection scaling * fix: document Codex context projection scaling * fix: document Codex context projection scaling * fix: document Codex context projection scaling * chore: align Codex projection changelog * chore: realign Codex projection changelog * fix: isolate Codex projection patch --------- Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org> Co-authored-by: Josh Lehman <josh@martian.engineering> * refactor: move agent runtime state toward piless * refactor: remove cron session reaper * refactor: move session management to sqlite * refactor: finish database-first state migration * chore: refresh generated sqlite db types * refactor: remove stale file-backed shims * test: harden kysely type coverage # Conflicts: # .agents/skills/kysely-database-access/SKILL.md # src/infra/kysely-sync.types.test.ts # src/proxy-capture/store.sqlite.test.ts # src/state/openclaw-agent-db.test.ts # src/state/openclaw-state-db.test.ts * refactor: remove cron store path runtime * refactor: keep compaction transcript handles out of session rows * refactor: derive embedded transcripts from sqlite identity * refactor: remove embedded transcript locator handoff * refactor: remove runtime transcript file bridges * refactor: remove transcript file handoffs * refactor: remove MSTeams legacy learning key fallback * refactor: store model catalog config in sqlite * refactor: use sqlite model catalog at runtime # Conflicts: # docs/cli/secrets.md # docs/gateway/authentication.md # docs/gateway/secrets.md * fix: keep oauth sibling sync sqlite-local # Conflicts: # src/commands/onboard-auth.test.ts * refactor: remove task session store maintenance # Conflicts: # src/commands/tasks.ts * refactor: keep diagnostics in state sqlite * refactor: enforce database-first runtime state * refactor: consolidate sqlite runtime state * Show Codex subscription reset times in channel errors (#80456) * fix(codex): refresh subscription limit resets * fix(codex): format reset times for channels * Update CHANGELOG with latest changes and fixes Updated CHANGELOG with recent fixes and improvements. * fix(codex): keep command load failures on codex surface * fix(codex): format account rate limits as rows * fix(codex): summarize account limits as usage status * fix(codex): simplify account limit status * test: tighten subagent announce queue assertion * test: tighten session delete lifecycle assertions * test: tighten cron ops assertions * fix: track cron execution milestones * test: tighten hermes secret assertions * test: assert matrix sync store payloads * test: assert config compat warnings * fix(codex): align btw side thread semantics * fix(codex): honor codex fallback blocking * fix(agents): avoid Pi resource discovery stalls * test: tighten codex event assertions * test: tighten cron assertions * Fix Codex app-server OAuth harness auth * refactor: move agent runtime state toward piless * refactor: move device and push state to sqlite * refactor: move runtime json state imports to doctor * refactor: finish database-first state migration * chore: refresh generated sqlite db types * refactor: clarify cron sqlite store keys * refactor: remove stale file-backed shims * refactor: bind codex runtime state by session id * test: expect sqlite trajectory branch export * refactor: rename session row helpers * fix: keep legacy device identity import in doctor * refactor: enforce database-first runtime state * refactor: consolidate sqlite runtime state * build: align pi contract wrappers * chore: repair database-first rebase * refactor: remove session file test contracts * test: update gateway session expectations * refactor: stop routing from session compatibility shadows * refactor: stop persisting session route shadows * refactor: use typed delivery context in clients * refactor: stop echoing session route shadows * refactor: repair embedded runner rebase imports # Conflicts: # src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts * refactor: align pi contract imports * refactor: satisfy kysely sync helper guard * refactor: remove file transcript bridge remnants * refactor: remove session locator compatibility * refactor: remove session file test contracts * refactor: keep rebase database-first clean * refactor: remove session file assumptions from e2e * docs: clarify database-first goal state * test: remove legacy store markers from sqlite runtime tests * refactor: remove legacy store assumptions from runtime seams * refactor: align sqlite runtime helper seams * test: update memory recall sqlite audit mock * refactor: align database-first runtime type seams * test: clarify doctor cron legacy store names * fix: preserve sqlite session route projections * test: fix copilot token cache test syntax * docs: update database-first proof status * test: align database-first test fixtures * docs: update database-first proof status * refactor: clean extension database-first drift * test: align agent session route proof * test: clarify doctor legacy path fixtures * chore: clean database-first changed checks * chore: repair database-first rebase markers * build: allow baileys git subdependency * chore: repair exp-vfs rebase drift * chore: finish exp-vfs rebase cleanup * chore: satisfy rebase lint drift * chore: fix qqbot rebase type seam * chore: fix rebase drift leftovers * fix: keep auth profile oauth secrets out of sqlite * fix: repair rebase drift tests * test: stabilize pairing request ordering * test: use source manifests in plugin contract checks * fix: restore gateway session metadata after rebase * fix: repair database-first rebase drift * fix: clean up database-first rebase fallout * test: stabilize line quick reply receipt time * fix: repair extension rebase drift * test: keep transcript redaction tests sqlite-backed * fix: carry injected transcript redaction through sqlite * chore: clean database branch rebase residue * fix: repair database branch CI drift * fix: repair database branch CI guard drift * fix: stabilize oauth tls preflight test * test: align database branch fast guards * test: repair build artifact boundary guards * chore: clean changelog rebase markers --------- Co-authored-by: pashpashpash <nik@vault77.ai> Co-authored-by: Eva <eva@100yen.org> Co-authored-by: stainlu <stainlu@newtype-ai.org> Co-authored-by: Jason Zhou <jason.zhou.design@gmail.com> Co-authored-by: Ruben Cuevas <hi@rubencu.com> Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> Co-authored-by: Shakker <shakkerdroid@gmail.com> Co-authored-by: Kaspre <36520309+Kaspre@users.noreply.github.com> Co-authored-by: dataCenter430 <titan032000@gmail.com> Co-authored-by: Kaspre <kaspre@gmail.com> Co-authored-by: pandadev66 <nova.full.stack@outlook.com> Co-authored-by: Eva <admin@100yen.org> Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org> Co-authored-by: Josh Lehman <josh@martian.engineering> Co-authored-by: jeffjhunter <support@aipersonamethod.com>
586 lines
21 KiB
Swift
586 lines
21 KiB
Swift
import Foundation
|
|
|
|
enum CommandResolver {
|
|
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
|
|
private static let helperName = "openclaw"
|
|
static let strictHostKeyCheckingSSHOptions = [
|
|
"-o", "StrictHostKeyChecking=yes",
|
|
]
|
|
static let updateHostKeysSSHOptions = [
|
|
"-o", "UpdateHostKeys=yes",
|
|
]
|
|
|
|
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,
|
|
projectRoot: URL? = 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 root = projectRoot ?? self.projectRoot()
|
|
if let openclawPath = self.projectOpenClawExecutable(projectRoot: root) {
|
|
return [openclawPath, subcommand] + extraArgs
|
|
}
|
|
if let openclawPath = self.openclawExecutable(searchPaths: searchPaths) {
|
|
return [openclawPath, subcommand] + extraArgs
|
|
}
|
|
|
|
let runtimeResult = self.runtimeResolution(searchPaths: searchPaths)
|
|
switch runtimeResult {
|
|
case let .success(runtime):
|
|
if let entry = self.gatewayEntrypoint(in: root) {
|
|
return self.makeRuntimeCommand(
|
|
runtime: runtime,
|
|
entrypoint: entry,
|
|
subcommand: subcommand,
|
|
extraArgs: extraArgs)
|
|
}
|
|
case .failure:
|
|
break
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
switch runtimeResult {
|
|
case .success:
|
|
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,
|
|
projectRoot: URL? = nil) -> [String]
|
|
{
|
|
self.openclawNodeCommand(
|
|
subcommand: subcommand,
|
|
extraArgs: extraArgs,
|
|
defaults: defaults,
|
|
configRoot: configRoot,
|
|
searchPaths: searchPaths,
|
|
projectRoot: projectRoot)
|
|
}
|
|
|
|
// 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 >=24 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 >=24 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 >=24 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",
|
|
] + self.strictHostKeyCheckingSSHOptions + self.updateHostKeysSSHOptions
|
|
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
|
|
}
|