mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 17:11:42 -06:00
0dbdf994b3
* feat(macos): isolate named app profiles * refactor(macos): isolate profile launch ownership * fix(macos): avoid overlapping approvals socket access * fix(macos): declare profile defaults concurrency ownership * fix(macos): return profiled node launch arguments * chore(i18n): refresh macOS profile source inventory * fix(macos): gate profile startup before services * test(macos): evaluate profile state before assertions * fix(daemon): skip absent launchd deactivation * fix(macos): fail closed on profile port conflicts * chore(i18n): refresh profile conflict inventory * fix(macos): ignore non-gateway launch agent claims * test(macos): stabilize profile lifecycle timing * fix(macos): remove stale dashboard URL * chore(macos): refresh native source baseline
274 lines
14 KiB
Swift
274 lines
14 KiB
Swift
import Darwin
|
|
import Foundation
|
|
import Testing
|
|
@testable import OpenClaw
|
|
@testable import OpenClawKit
|
|
|
|
@Suite(.serialized)
|
|
struct AppProfileTests {
|
|
@Test func `default profile preserves historical identities`() {
|
|
if AppProfile.current.name == nil, AppProfile.current.validationError == nil {
|
|
#expect(AppDefaults.standard === UserDefaults.standard)
|
|
}
|
|
for raw in [nil, "", " default ", "Default"] {
|
|
let profile = AppProfile(environment: raw.map { ["OPENCLAW_PROFILE": $0] } ?? [:])
|
|
#expect(profile.name == nil)
|
|
#expect(profile.validationError == nil)
|
|
#expect(profile.gatewayLaunchAgentLabel == "ai.openclaw.gateway")
|
|
#expect(profile.defaultsSuiteName == nil)
|
|
#expect(profile.keychainService(base: "ai.openclaw.test") == "ai.openclaw.test")
|
|
#expect(GatewayTLSStore.resolvedKeychainService(suffix: profile.keychainServiceSuffix) ==
|
|
"ai.openclaw.tls-pinning")
|
|
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")).path ==
|
|
"/Users/test/.openclaw")
|
|
#expect(profile.cliRootArguments.isEmpty)
|
|
}
|
|
#if DEBUG
|
|
#expect(MacGatewayProfileStore.service == "ai.openclaw.gateway-profiles.debug")
|
|
#expect(GatewayActivationBindingKeyStore.service == "ai.openclaw.onboarding-route-binding.debug")
|
|
#else
|
|
#expect(MacGatewayProfileStore.service == "ai.openclaw.gateway-profiles")
|
|
#expect(GatewayActivationBindingKeyStore.service == "ai.openclaw.onboarding-route-binding")
|
|
#endif
|
|
}
|
|
|
|
@Test func `named profile owns every persistent namespace`() {
|
|
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work_2"])
|
|
#expect(profile.name == "work_2")
|
|
#expect(profile.validationError == nil)
|
|
#expect(profile.gatewayLaunchAgentLabel == "ai.openclaw.work_2")
|
|
#expect(profile.defaultsSuiteName == "ai.openclaw.mac.profile.work_2")
|
|
#expect(profile.keychainService(base: "ai.openclaw.test") == "ai.openclaw.test.profile.work_2")
|
|
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")).path ==
|
|
"/Users/test/.openclaw-work_2")
|
|
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")) !=
|
|
AppProfile(environment: ["OPENCLAW_PROFILE": "personal"])
|
|
.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")))
|
|
#expect(profile.cliRootArguments == ["--profile", "work_2"])
|
|
#expect(GatewayTLSStore.resolvedKeychainService(suffix: profile.keychainServiceSuffix) ==
|
|
"ai.openclaw.tls-pinning.profile.work_2")
|
|
#expect(CommandResolver.nodeHostWorkerCommand(
|
|
prefix: ["/usr/bin/node", "/repo/scripts/run-node.mjs"],
|
|
profile: profile) == [
|
|
"/usr/bin/node", "/repo/scripts/run-node.mjs", "--profile", "work_2", "node", "worker",
|
|
])
|
|
#expect(CommandResolver.nodeHostWorkerCommand(
|
|
prefix: ["/opt/openclaw"],
|
|
profile: profile) == [
|
|
"/opt/openclaw", "--profile", "work_2", "node", "worker",
|
|
])
|
|
}
|
|
|
|
@Test func `default worker commands preserve exact argument shapes`() {
|
|
let profile = AppProfile(environment: [:])
|
|
#expect(CommandResolver.nodeHostWorkerCommand(
|
|
prefix: ["/usr/bin/node", "/repo/scripts/run-node.mjs"],
|
|
profile: profile) == ["/usr/bin/node", "/repo/scripts/run-node.mjs", "node", "worker"])
|
|
#expect(CommandResolver.nodeHostWorkerCommand(
|
|
prefix: ["/opt/openclaw"],
|
|
profile: profile) == ["/opt/openclaw", "node", "worker"])
|
|
}
|
|
|
|
@Test func `invalid and colliding profile names fail closed`() {
|
|
let invalid = [
|
|
"_work", "work space", "work/escape", String(repeating: "a", count: 65),
|
|
"Work", "gateway", "mac", "node",
|
|
]
|
|
for raw in invalid {
|
|
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": raw])
|
|
#expect(profile.name == nil)
|
|
#expect(profile.validationError != nil)
|
|
}
|
|
#expect(AppProfile(environment: ["OPENCLAW_PROFILE": String(repeating: "a", count: 64)]).name != nil)
|
|
}
|
|
|
|
@Test func `colliding profile apps cannot reserve the same exact port`() throws {
|
|
let root = try self.makeReservationRoot()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let home = root.appendingPathComponent("home", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
|
|
let first = ProfileGatewayPortReservation.acquire(
|
|
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p1402"]),
|
|
port: 55636,
|
|
homeDirectory: home,
|
|
temporaryDirectory: root)
|
|
let second = ProfileGatewayPortReservation.acquire(
|
|
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"]),
|
|
port: 55636,
|
|
homeDirectory: home,
|
|
temporaryDirectory: root)
|
|
|
|
#expect(first.conflict == nil)
|
|
#expect(second.conflict?.contains("p2380") == true)
|
|
#expect(second.conflict?.contains("reservation reserves") == false)
|
|
}
|
|
|
|
@Test func `persisted other profile gateway reserves its exact port`() throws {
|
|
let root = try self.makeReservationRoot()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let home = root.appendingPathComponent("home", isDirectory: true)
|
|
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
|
|
let serviceEnv = home.appendingPathComponent(".openclaw-p1402/service-env", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: serviceEnv, withIntermediateDirectories: true)
|
|
let wrapper = serviceEnv.appendingPathComponent("ai.openclaw.p1402-env-wrapper.sh")
|
|
let environment = serviceEnv.appendingPathComponent("ai.openclaw.p1402.env")
|
|
try Data("#!/bin/sh\n".utf8).write(to: wrapper)
|
|
try Data("""
|
|
export OPENCLAW_SERVICE_MARKER='openclaw'
|
|
export OPENCLAW_SERVICE_KIND='gateway'
|
|
|
|
""".utf8).write(to: environment)
|
|
let data = try PropertyListSerialization.data(
|
|
fromPropertyList: [
|
|
"ProgramArguments": [
|
|
"/bin/sh", wrapper.path, environment.path,
|
|
"node", "openclaw.mjs", "gateway", "--port", "55636",
|
|
],
|
|
],
|
|
format: .xml,
|
|
options: 0)
|
|
try data.write(to: agents.appendingPathComponent("ai.openclaw.p1402.plist"))
|
|
|
|
let reservation = ProfileGatewayPortReservation.acquire(
|
|
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"]),
|
|
port: 55636,
|
|
homeDirectory: home,
|
|
temporaryDirectory: root)
|
|
|
|
#expect(reservation.conflict?.contains("profile \"p1402\"") == true)
|
|
}
|
|
|
|
private func makeReservationRoot() throws -> URL {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("app-profile-port-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
|
|
return root
|
|
}
|
|
|
|
@Test func `profile launch at login reason outranks bundle location`() {
|
|
let profiled = LaunchAtLoginPresentation.resolve(
|
|
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]),
|
|
bundleLocationAllowsPersistentIntegration: false,
|
|
isEnabled: false)
|
|
#expect(profiled.subtitle == "Launch at login is unavailable while an app profile is active.")
|
|
#expect(profiled.isDisabled)
|
|
|
|
let defaultUnavailable = LaunchAtLoginPresentation.resolve(
|
|
profile: AppProfile(environment: [:]),
|
|
bundleLocationAllowsPersistentIntegration: false,
|
|
isEnabled: false)
|
|
#expect(defaultUnavailable.subtitle == "Move OpenClaw to Applications before enabling launch at login.")
|
|
#expect(defaultUnavailable.isDisabled)
|
|
|
|
let defaultAvailable = LaunchAtLoginPresentation.resolve(
|
|
profile: AppProfile(environment: [:]),
|
|
bundleLocationAllowsPersistentIntegration: true,
|
|
isEnabled: false)
|
|
#expect(defaultAvailable.subtitle == "Automatically start OpenClaw after you sign in.")
|
|
#expect(!defaultAvailable.isDisabled)
|
|
}
|
|
|
|
@MainActor @Test func `profile lock excludes only the same profile`() throws {
|
|
#expect(AppDelegate.processExitCode(for: .busy) == 0)
|
|
#expect(AppDelegate.processExitCode(for: .failed("test")) == nil)
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("app-profile-lock-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
|
|
let defaultProfile = AppProfile(environment: [:])
|
|
let workProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
|
|
let defaultURL = defaultProfile.instanceLockURL(systemTemporaryDirectory: root)
|
|
let namedURL = workProfile.instanceLockURL(systemTemporaryDirectory: root)
|
|
#expect(defaultURL != namedURL)
|
|
#expect(workProfile.instanceLockURL(systemTemporaryDirectory: root) ==
|
|
AppProfile(environment: [
|
|
"OPENCLAW_PROFILE": "work",
|
|
"OPENCLAW_STATE_DIR": "/different/state",
|
|
]).instanceLockURL(systemTemporaryDirectory: root))
|
|
var defaultLock: AppInstanceLock?
|
|
switch AppInstanceLock.acquire(url: defaultURL) {
|
|
case let .acquired(lock): defaultLock = lock
|
|
case .busy, .failed: Issue.record("Expected default lock acquisition")
|
|
}
|
|
if case .busy = AppInstanceLock.acquire(url: defaultURL) {} else {
|
|
Issue.record("Expected same-profile lock rejection")
|
|
}
|
|
var namedLock: AppInstanceLock?
|
|
switch AppInstanceLock.acquire(url: namedURL) {
|
|
case let .acquired(lock): namedLock = lock
|
|
case .busy, .failed: Issue.record("Expected cross-profile lock acquisition")
|
|
}
|
|
#expect(defaultLock != nil)
|
|
#expect(namedLock != nil)
|
|
let defaultAttributes = try FileManager.default.attributesOfItem(
|
|
atPath: defaultURL.deletingLastPathComponent().path)
|
|
#expect((defaultAttributes[.posixPermissions] as? NSNumber)?.intValue == 0o700)
|
|
defaultLock = nil
|
|
namedLock = nil
|
|
if case .acquired = AppInstanceLock.acquire(url: defaultURL) {} else {
|
|
Issue.record("Expected released default lock to transfer")
|
|
}
|
|
var namedFirst: AppInstanceLock?
|
|
if case let .acquired(lock) = AppInstanceLock.acquire(url: namedURL) {
|
|
namedFirst = lock
|
|
} else {
|
|
Issue.record("Expected named-first lock acquisition")
|
|
}
|
|
if case .acquired = AppInstanceLock.acquire(url: defaultURL) {} else {
|
|
Issue.record("Expected named-to-default coexistence")
|
|
}
|
|
#expect(namedFirst != nil)
|
|
}
|
|
|
|
@Test func `profile lock reports unsafe ownership errors`() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("app-profile-lock-error-\(UUID().uuidString)", isDirectory: true)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
|
|
let target = root.appendingPathComponent("target")
|
|
let link = root.appendingPathComponent("app-instance.lock")
|
|
try Data().write(to: target)
|
|
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target)
|
|
if case .failed = AppInstanceLock.acquire(url: link) {} else {
|
|
Issue.record("Expected symlink lock rejection")
|
|
}
|
|
|
|
let unsafeURL = AppProfile(environment: ["OPENCLAW_PROFILE": "unsafe"])
|
|
.instanceLockURL(systemTemporaryDirectory: root)
|
|
let unsafe = unsafeURL.deletingLastPathComponent()
|
|
try FileManager.default.createDirectory(at: unsafe, withIntermediateDirectories: true)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: unsafe.path)
|
|
if case .failed = AppInstanceLock.acquire(url: unsafeURL) {} else {
|
|
Issue.record("Expected unsafe state directory rejection")
|
|
}
|
|
}
|
|
|
|
@Test func `long profile approvals socket uses deterministic private short path`() {
|
|
let longState = URL(fileURLWithPath: "/Users/" + String(repeating: "very-long/", count: 16))
|
|
let first = ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: true)
|
|
let second = ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: true)
|
|
#expect(first == second)
|
|
#expect(first.hasPrefix("/tmp/openclaw-\(geteuid())/exec-approvals-"))
|
|
#expect(first.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path))
|
|
#expect(ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: false)
|
|
.hasPrefix(longState.path))
|
|
let oldCanonical = longState.appendingPathComponent("exec-approvals.sock").path
|
|
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
|
|
existing: nil,
|
|
stateDirectoryURL: longState,
|
|
computed: first) == first)
|
|
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
|
|
existing: oldCanonical,
|
|
stateDirectoryURL: longState,
|
|
computed: first) == first)
|
|
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
|
|
existing: "/custom/approvals.sock",
|
|
stateDirectoryURL: longState,
|
|
computed: first) == "/custom/approvals.sock")
|
|
}
|
|
}
|