mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(mac): profile onboarding updates the operator's host-global managed CLI (#121651)
* fix(mac): scope managed CLI install and detection to the active app profile Under an active OPENCLAW_PROFILE the onboarding ready page detected the host-global managed CLI (~/.openclaw/bin/openclaw) and its update/install flow rewrote the operator's real managed copy. The managed install prefix now follows the profile state directory (~/.openclaw-<name>), preferred paths exclude other profiles' managed trees (including stale validated executables and inherited shell PATH entries), and external CLIs stay detectable read-only. Default-profile behavior is unchanged. * chore(mac): refresh native i18n inventory for shifted source lines
This commit is contained in:
committed by
GitHub
parent
6dc77a37d9
commit
87bb2e5af0
@@ -30251,7 +30251,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 495,
|
||||
"line": 501,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Repairing the OpenClaw Gateway update…",
|
||||
"surface": "apple",
|
||||
@@ -30259,7 +30259,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 496,
|
||||
"line": 502,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Updating the OpenClaw Gateway to \\(targetVersion)…",
|
||||
"surface": "apple",
|
||||
@@ -30267,7 +30267,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 517,
|
||||
"line": 523,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Gateway update needs attention.",
|
||||
"surface": "apple",
|
||||
@@ -30275,7 +30275,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 519,
|
||||
"line": 525,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Gateway update failed.",
|
||||
"surface": "apple",
|
||||
@@ -30283,7 +30283,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 534,
|
||||
"line": 540,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Gateway update finished, but verification failed.",
|
||||
"surface": "apple",
|
||||
@@ -30291,7 +30291,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 541,
|
||||
"line": 547,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "OpenClaw Gateway \\(installedVersion) is installed.",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -176,8 +176,11 @@ enum CLIInstaller {
|
||||
return locations
|
||||
}
|
||||
|
||||
static func managedExecutableLocation() -> String {
|
||||
URL(fileURLWithPath: self.installPrefix())
|
||||
static func managedExecutableLocation(
|
||||
homeDirectory: URL = FileManager().homeDirectoryForCurrentUser,
|
||||
profile: AppProfile = .current) -> String
|
||||
{
|
||||
URL(fileURLWithPath: self.installPrefix(homeDirectory: homeDirectory, profile: profile))
|
||||
.appendingPathComponent("bin/openclaw")
|
||||
.path
|
||||
}
|
||||
@@ -430,10 +433,13 @@ enum CLIInstaller {
|
||||
target == .channel(.dev) ? 7200 : 900
|
||||
}
|
||||
|
||||
private static func installPrefix() -> String {
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".openclaw")
|
||||
.path
|
||||
static func installPrefix(
|
||||
homeDirectory: URL = FileManager().homeDirectoryForCurrentUser,
|
||||
profile: AppProfile = .current) -> String
|
||||
{
|
||||
// Managed install identity follows only the profile; a state override must not split
|
||||
// the install used by its LaunchAgent.
|
||||
profile.stateDirectoryURL(homeDirectory: homeDirectory).path
|
||||
}
|
||||
|
||||
static func installScriptCommand(
|
||||
|
||||
@@ -97,15 +97,26 @@ enum CommandResolver {
|
||||
home: URL,
|
||||
current: [String],
|
||||
projectRoot: URL,
|
||||
validatedExecutable: String? = nil) -> [String]
|
||||
validatedExecutable: String? = nil,
|
||||
profile: AppProfile = .current) -> [String]
|
||||
{
|
||||
var preferredPaths: [String] = []
|
||||
let managedPaths = self.openclawManagedPaths(home: home)
|
||||
let managedPaths = self.openclawManagedPaths(home: home, profile: profile)
|
||||
// Other profiles' managed trees must not leak in via stale validation or the
|
||||
// inherited shell PATH (the CLI installer adds ~/.openclaw/bin to shell profiles).
|
||||
let activeManagedBase = profile.stateDirectoryURL(homeDirectory: home).path
|
||||
func isForeignManaged(_ path: String) -> Bool {
|
||||
guard path.hasPrefix(home.path + "/") else { return false }
|
||||
let name = path.dropFirst(home.path.count + 1)
|
||||
.split(separator: "/").first.map(String.init) ?? ""
|
||||
return self.isManagedDirectoryName(name)
|
||||
&& home.appendingPathComponent(name).path != activeManagedBase
|
||||
}
|
||||
if let validatedExecutable {
|
||||
let validatedBin = URL(fileURLWithPath: validatedExecutable).deletingLastPathComponent().path
|
||||
if managedPaths.contains(validatedBin) {
|
||||
preferredPaths.append(contentsOf: managedPaths)
|
||||
} else {
|
||||
} else if !isForeignManaged(validatedBin) {
|
||||
preferredPaths.append(validatedBin)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +135,8 @@ enum CommandResolver {
|
||||
var seen = Set<String>()
|
||||
let fallbackPaths = self.nodeManagerBinPaths(home: home) + externalPaths + managedPaths
|
||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||
return (preferredPaths + fallbackPaths + current).filter { seen.insert($0).inserted }
|
||||
return (preferredPaths + fallbackPaths + current)
|
||||
.filter { !isForeignManaged($0) && seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
static func validatedOpenClawExecutable(
|
||||
@@ -144,10 +156,13 @@ enum CommandResolver {
|
||||
expected: requiredVersion) ? executable : nil
|
||||
}
|
||||
|
||||
private static func openclawManagedPaths(home: URL) -> [String] {
|
||||
let bases = [
|
||||
home.appendingPathComponent(".openclaw"),
|
||||
]
|
||||
/// Exactly the AppProfile.stateDirectoryURL namespace; ~/.openclaw2 is not managed.
|
||||
private static func isManagedDirectoryName(_ name: String) -> Bool {
|
||||
name == ".openclaw" || name.hasPrefix(".openclaw-")
|
||||
}
|
||||
|
||||
private static func openclawManagedPaths(home: URL, profile: AppProfile) -> [String] {
|
||||
let bases = [profile.stateDirectoryURL(homeDirectory: home)]
|
||||
var paths: [String] = []
|
||||
for base in bases {
|
||||
let bin = base.appendingPathComponent("bin")
|
||||
|
||||
@@ -5,6 +5,33 @@ import Testing
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct CLIInstallerTests {
|
||||
@Test func `managed install locations follow the app profile`() {
|
||||
let home = URL(fileURLWithPath: "/Users/Test User", isDirectory: true)
|
||||
let cases = [
|
||||
(AppProfile(environment: [:]), "/Users/Test User/.openclaw"),
|
||||
(
|
||||
AppProfile(environment: [
|
||||
"OPENCLAW_PROFILE": "onboardtest",
|
||||
"OPENCLAW_STATE_DIR": "/tmp/ignored-state",
|
||||
]),
|
||||
"/Users/Test User/.openclaw-onboardtest"),
|
||||
]
|
||||
|
||||
for (profile, expectedPrefix) in cases {
|
||||
let prefix = CLIInstaller.installPrefix(homeDirectory: home, profile: profile)
|
||||
#expect(prefix == expectedPrefix)
|
||||
#expect(CLIInstaller.managedExecutableLocation(homeDirectory: home, profile: profile) ==
|
||||
"\(expectedPrefix)/bin/openclaw")
|
||||
|
||||
let command = CLIInstaller.installScriptCommand(
|
||||
target: .exact("2026.7.3"),
|
||||
prefix: prefix,
|
||||
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh")
|
||||
let prefixIndex = command.firstIndex(of: "--prefix")
|
||||
#expect(prefixIndex.map { command[$0 + 1] } == expectedPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `installed location finds executable`() throws {
|
||||
let fm = FileManager()
|
||||
let root = fm.temporaryDirectory.appendingPathComponent(
|
||||
|
||||
@@ -224,6 +224,68 @@ import Testing
|
||||
#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")
|
||||
|
||||
Reference in New Issue
Block a user