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:
Peter Steinberger
2026-08-10 16:50:05 -07:00
committed by GitHub
parent 6dc77a37d9
commit 87bb2e5af0
5 changed files with 130 additions and 20 deletions
+6 -6
View File
@@ -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",
+12 -6
View File
@@ -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")