From 04c9924c45b3c9eba338b151fe65d1c8df7c8a22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 17 Aug 2026 17:03:04 -0700 Subject: [PATCH] fix(macos): keep elevation host CUA-free (#125408) * fix(macos): isolate elevation host from CUA * fix(macos): fail closed on unsafe elevation rollback * fix(macos): quarantine unsafe elevation state before recovery * fix(macos): bind elevation recovery ownership --- .../AppLaunchPresentationPolicy.swift | 4 + apps/macos/Sources/OpenClaw/AppState.swift | 5 +- .../OpenClaw/ComputerControlProvider.swift | 4 +- .../OpenClaw/CuaDriverHostCoordinator.swift | 11 +- apps/macos/Sources/OpenClaw/MenuBar.swift | 4 +- .../AppLaunchPresentationPolicyTests.swift | 3 + .../ComputerControlSettingsTests.swift | 18 + .../CuaDriverHostCoordinatorTests.swift | 37 + .../MacNodeHostWorkerTests.swift | 30 + scripts/codesign-mac-app.sh | 11 + scripts/mac-elevation-host.sh | 371 +++++++++- scripts/package-mac-app.sh | 16 +- test/scripts/codesign-mac-app.test.ts | 34 + test/scripts/mac-elevation-host.test.ts | 633 +++++++++++++++++- test/scripts/package-mac-app.test.ts | 21 + 15 files changed, 1158 insertions(+), 44 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/AppLaunchPresentationPolicy.swift b/apps/macos/Sources/OpenClaw/AppLaunchPresentationPolicy.swift index 5048d2a6b011..d7d610f7e30f 100644 --- a/apps/macos/Sources/OpenClaw/AppLaunchPresentationPolicy.swift +++ b/apps/macos/Sources/OpenClaw/AppLaunchPresentationPolicy.swift @@ -190,6 +190,10 @@ struct AppLaunchRuntimePlan: Equatable { !self.isElevationHost } + var allowsCuaComputerControl: Bool { + !self.isElevationHost + } + func shouldAutoOpenChat(arguments: [String]) -> Bool { self.allowsAutomaticPresentation && (arguments.contains("--chat") || arguments.contains("--webchat")) diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 03788178178b..4bf2a944bc1e 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -376,6 +376,7 @@ final class AppState { self.ifNotPreview { let computerControlEnabled = isComputerControlEnabled() let provider = ComputerControlProvider.current() + let launchPlan = AppLaunchRuntimePlan.current let peekabooBridgeEnabled = self.peekabooBridgeEnabled self.computerControlHostGeneration &+= 1 let generation = self.computerControlHostGeneration @@ -389,7 +390,9 @@ final class AppState { guard generation == self.computerControlHostGeneration else { return } await CuaDriverHostCoordinator.shared.setEnabled(true) case .peekaboo: - await CuaDriverHostCoordinator.shared.setEnabled(false) + if launchPlan.allowsCuaComputerControl { + await CuaDriverHostCoordinator.shared.setEnabled(false) + } guard generation == self.computerControlHostGeneration else { return } await PeekabooBridgeHostCoordinator.shared.setEnabled( peekabooBridgeEnabled && computerControlEnabled) diff --git a/apps/macos/Sources/OpenClaw/ComputerControlProvider.swift b/apps/macos/Sources/OpenClaw/ComputerControlProvider.swift index def46ba0819c..6f68828a397a 100644 --- a/apps/macos/Sources/OpenClaw/ComputerControlProvider.swift +++ b/apps/macos/Sources/OpenClaw/ComputerControlProvider.swift @@ -52,8 +52,10 @@ enum ComputerControlProvider: String, CaseIterable, Sendable { static func current( defaults: UserDefaults = AppDefaults.standard, - cuaAvailable: Bool = CuaDriverArtifact.bundledExecutableURL != nil) -> Self + cuaAvailable: Bool = CuaDriverArtifact.bundledExecutableURL != nil, + launchPlan: AppLaunchRuntimePlan = .current) -> Self { + guard launchPlan.allowsCuaComputerControl else { return .peekaboo } guard let rawValue = defaults.string(forKey: computerControlProviderKey), let provider = Self(rawValue: rawValue) else { return .peekaboo } diff --git a/apps/macos/Sources/OpenClaw/CuaDriverHostCoordinator.swift b/apps/macos/Sources/OpenClaw/CuaDriverHostCoordinator.swift index 11fe56f5eb7d..603f30ed96db 100644 --- a/apps/macos/Sources/OpenClaw/CuaDriverHostCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/CuaDriverHostCoordinator.swift @@ -200,6 +200,7 @@ final class CuaDriverHostCoordinator { static let shared = CuaDriverHostCoordinator( observeNotifications: true, + enablementAllowed: { AppLaunchRuntimePlan.current.allowsCuaComputerControl }, beforeDaemonStop: { await MacNodeModeCoordinator.shared.prepareForCuaDaemonStop() }) @@ -229,6 +230,7 @@ final class CuaDriverHostCoordinator { private let readinessProbe: ReadinessProbe private let restartSleep: @Sendable (Duration) async -> Void private let permissionSnapshot: @MainActor () async -> [Capability: CapabilityAuthorizationStatus] + private let enablementAllowed: @MainActor () -> Bool private let beforeDaemonStop: @MainActor () async -> Void private var desiredEnabled = false @@ -261,6 +263,7 @@ final class CuaDriverHostCoordinator { permissionSnapshot: @escaping @MainActor () async -> [Capability: CapabilityAuthorizationStatus] = { await PermissionManager.authorizationStatus([.accessibility, .screenRecording]) }, + enablementAllowed: @escaping @MainActor () -> Bool = { true }, beforeDaemonStop: @escaping @MainActor () async -> Void = {}) { self.notificationCenter = notificationCenter @@ -271,6 +274,7 @@ final class CuaDriverHostCoordinator { self.readinessProbe = readinessProbe self.restartSleep = restartSleep self.permissionSnapshot = permissionSnapshot + self.enablementAllowed = enablementAllowed self.beforeDaemonStop = beforeDaemonStop guard observeNotifications else { return } @@ -297,12 +301,13 @@ final class CuaDriverHostCoordinator { } func setEnabled(_ enabled: Bool) async { + let effectiveEnabled = enabled && self.enablementAllowed() let wasEnabled = self.desiredEnabled - self.desiredEnabled = enabled - if enabled, !wasEnabled { + self.desiredEnabled = effectiveEnabled + if effectiveEnabled, !wasEnabled { self.restartAttempt = 0 } - if !enabled { + if !effectiveEnabled { self.restartTask?.cancel() self.restartTask = nil self.restartAttempt = 0 diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 004cbdf682ea..0803cc9ac415 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -432,7 +432,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { var nodeTerminationCleanup: @MainActor () async -> Void = { // CUA shutdown drains the worker before closing the daemon socket; run it // first so other cleanup cannot consume the app termination deadline. - await CuaDriverHostCoordinator.shared.shutdown() + if AppLaunchRuntimePlan.current.allowsCuaComputerControl { + await CuaDriverHostCoordinator.shared.shutdown() + } await TalkMLXSpeechSynthesizer.shared.shutdown() await MacNodeModeCoordinator.shared.stopAndWait() } diff --git a/apps/macos/Tests/OpenClawIPCTests/AppLaunchPresentationPolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppLaunchPresentationPolicyTests.swift index 87a3570940d6..37279bb36348 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppLaunchPresentationPolicyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppLaunchPresentationPolicyTests.swift @@ -84,6 +84,7 @@ struct AppLaunchRuntimePlanTests { #expect(policy.allowsUpdater) #expect(policy.allowsDockIcon) #expect(policy.allowsInteractiveServices) + #expect(policy.allowsCuaComputerControl) #expect(policy.shouldAutoOpenChat(arguments: ["OpenClaw", "--chat"])) #expect(policy.shouldAutoOpenDashboard(arguments: ["OpenClaw", "--dashboard"])) } @@ -99,6 +100,7 @@ struct AppLaunchRuntimePlanTests { #expect(policy.allowsUpdater) #expect(policy.allowsDockIcon) #expect(policy.allowsInteractiveServices) + #expect(policy.allowsCuaComputerControl) #expect(!policy.shouldAutoOpenChat(arguments: arguments)) #expect(!policy.shouldAutoOpenDashboard(arguments: arguments)) } @@ -115,6 +117,7 @@ struct AppLaunchRuntimePlanTests { #expect(!policy.allowsUpdater) #expect(!policy.allowsDockIcon) #expect(!policy.allowsInteractiveServices) + #expect(!policy.allowsCuaComputerControl) #expect(!policy.shouldAutoOpenChat(arguments: arguments)) #expect(!policy.shouldAutoOpenDashboard(arguments: arguments)) #expect(DockIconManager.activationPolicy( diff --git a/apps/macos/Tests/OpenClawIPCTests/ComputerControlSettingsTests.swift b/apps/macos/Tests/OpenClawIPCTests/ComputerControlSettingsTests.swift index b6d176babf00..8553d9d0c922 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ComputerControlSettingsTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ComputerControlSettingsTests.swift @@ -31,6 +31,24 @@ struct ComputerControlSettingsTests { #expect(ComputerControlProvider.current(defaults: defaults, cuaAvailable: true) == .peekaboo) } + @Test func `elevation host ignores enabled CUA defaults while normal launches preserve them`() throws { + let suiteName = "ComputerControlElevationHostTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: computerControlEnabledKey) + defaults.set(ComputerControlProvider.cua.rawValue, forKey: computerControlProviderKey) + + #expect(isComputerControlEnabled(defaults: defaults)) + #expect(ComputerControlProvider.current( + defaults: defaults, + cuaAvailable: true, + launchPlan: AppLaunchRuntimePlan(arguments: ["OpenClaw"])) == .cua) + #expect(ComputerControlProvider.current( + defaults: defaults, + cuaAvailable: true, + launchPlan: AppLaunchRuntimePlan(arguments: ["OpenClaw", "--elevation-host"])) == .peekaboo) + } + @Test func `bundled CUA locator accepts only a regular executable and never follows a symlink`() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("openclaw-cua-artifact-\(UUID().uuidString)", isDirectory: true) diff --git a/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift index db2fa1eb714a..fe23638c0862 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift @@ -58,6 +58,43 @@ struct CuaDriverHostCoordinatorTests { #expect(launcher.processes.allSatisfy { !$0.isRunning }) } + @Test func `elevation host refuses CUA enablement before spawning a child`() async throws { + let root = self.shortTemporaryDirectory("elevation-host") + defer { try? FileManager.default.removeItem(at: root) } + let launcher = CuaProcessLauncherProbe() + let coordinator = CuaDriverHostCoordinator( + artifactURL: { root.appendingPathComponent("cua-driver") }, + applicationSupportURL: { root }, + bundleIdentifier: { "ai.openclaw.test" }, + processLauncher: { launch, onTermination in + launcher.launch(launch, onTermination: onTermination) + }, + readinessProbe: { _ in true }, + enablementAllowed: { + AppLaunchRuntimePlan(arguments: ["OpenClaw", "--elevation-host"]).allowsCuaComputerControl + }) + + await coordinator.setEnabled(true) + + #expect(launcher.launches.isEmpty) + #expect(coordinator.workerEndpoint == nil) + + let normalCoordinator = CuaDriverHostCoordinator( + artifactURL: { root.appendingPathComponent("cua-driver") }, + applicationSupportURL: { root }, + bundleIdentifier: { "ai.openclaw.test" }, + processLauncher: { launch, onTermination in + launcher.launch(launch, onTermination: onTermination) + }, + readinessProbe: { _ in true }, + enablementAllowed: { + AppLaunchRuntimePlan(arguments: ["OpenClaw"]).allowsCuaComputerControl + }) + await normalCoordinator.setEnabled(true) + #expect(await self.waitForReadyLaunch(1, launcher: launcher, coordinator: normalCoordinator)) + await normalCoordinator.setEnabled(false) + } + @Test func `socket directory is random owner-only and cleanup removes only its owned leaf`() throws { let root = self.shortTemporaryDirectory("socket") defer { try? FileManager.default.removeItem(at: root) } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift index 5bcea508a76b..9e3a9c4a9628 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift @@ -311,6 +311,36 @@ struct MacNodeHostWorkerTests { workerManifest: cua) == descriptor) } + @Test func `elevation host never advertises a persisted CUA provider`() throws { + let suiteName = "MacNodeElevationHostProviderTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: computerControlEnabledKey) + defaults.set(ComputerControlProvider.cua.rawValue, forKey: computerControlProviderKey) + let provider = ComputerControlProvider.current( + defaults: defaults, + cuaAvailable: true, + launchPlan: AppLaunchRuntimePlan(arguments: ["OpenClaw", "--elevation-host"])) + #expect(provider == .peekaboo) + + let cuaDescriptor = OpenClawProtocol.AnyCodable(["provider": "cua"]) + let manifest = MacNodeHostManifest( + version: "test", + caps: ["screen", "computer"], + commands: [MacNodeScreenCommand.snapshot.rawValue, OpenClawComputerCommand.act.rawValue], + computerUse: cuaDescriptor, + pathEnv: "/usr/bin:/bin") + let workerManifest = try #require(MacNodeModeCoordinator.workerManifest(manifest, for: provider)) + #expect(workerManifest.computerUse == nil) + let advertised = try #require(MacNodeModeCoordinator.computerUseDescriptor( + provider: provider, + commands: manifest.commands, + workerManifest: workerManifest)) + let data = try JSONEncoder().encode(advertised) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect((object["provider"] as? [String: Any])?["id"] as? String == "peekaboo") + } + @Test func `stale route updates cannot replace newer worker authority`() { #expect(MacNodeHostWorker.routeUpdateIsCurrent(candidateGeneration: 4, currentGeneration: 4)) #expect(MacNodeHostWorker.routeUpdateIsCurrent(candidateGeneration: 5, currentGeneration: 4)) diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index b182e1cdd489..c01b709542bd 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -329,11 +329,21 @@ verify_team_ids() { fi } +assert_no_elevation_cua_driver() { + [[ "$SIGNING_VARIANT" == "elevation-host" ]] || return 0 + local cua_driver="$APP_BUNDLE/Contents/Resources/cua-driver" + if [[ -e "$cua_driver" || -L "$cua_driver" ]]; then + echo "ERROR: Elevation host must not contain bundled CUA driver: $cua_driver" >&2 + exit 1 + fi +} + # Sign-time twin of verify_elevation_app in mac-elevation-host.sh, which asserts the same identity # invariants but requires an already notarized and stapled bundle. Dropping this check defers every # elevation identity failure until after an Apple notarization submission has been spent. verify_elevation_signature() { [[ "$SIGNING_VARIANT" == "elevation-host" ]] || return 0 + assert_no_elevation_cua_driver local actual_team actual_team="$(team_id_for "$APP_BUNDLE" || true)" @@ -368,6 +378,7 @@ verify_elevation_signature() { } # Sign bundled helper binaries before signing the app bundle. +assert_no_elevation_cua_driver MLX_TTS_HELPER="$APP_BUNDLE/Contents/MacOS/openclaw-mlx-tts" if [ -f "$MLX_TTS_HELPER" ]; then echo "Signing MLX TTS helper"; sign_plain_item "$MLX_TTS_HELPER" diff --git a/scripts/mac-elevation-host.sh b/scripts/mac-elevation-host.sh index 702b714da753..5e0ddc31f91d 100755 --- a/scripts/mac-elevation-host.sh +++ b/scripts/mac-elevation-host.sh @@ -105,6 +105,9 @@ RECOVERY_CURRENT_RECEIPT_SHA="" RECOVERY_RESTORED_MIGRATION_IDENTITY="" RECOVERY_RELAUNCHED_ADOPTED_PID="" RECOVERY_RESUMED=0 +UNSAFE_ELEVATION_APP_QUARANTINE="" +UNSAFE_ELEVATION_APP_WAS_QUARANTINED=0 +ELEVATION_APP_OWNER_WAS_EVIDENCED=0 OPENCLAW_CLI=() fail() { @@ -360,12 +363,189 @@ verify_universal_machos() { done < <(find "$app" -type f -perm -111 -print0) } +elevation_app_is_cua_free() { + local app="$1" + local cua_driver="$app/Contents/Resources/cua-driver" + [[ ! -e "$cua_driver" && ! -L "$cua_driver" ]] +} + +elevation_plist_binds_app() { + local plist="$1" args executable label program + [[ -n "$plist" && -f "$plist" && ! -L "$plist" ]] || return 1 + label="$(plist_file_value "$plist" Label)" + [[ "$label" == "$ELEVATION_LABEL" ]] || return 1 + executable="$APP_PATH/Contents/MacOS/OpenClaw" + if program="$(plutil -extract Program raw -o - "$plist" 2>/dev/null)"; then + [[ "$program" == "$executable" ]] || return 1 + elif plutil -extract Program xml1 -o /dev/null "$plist" 2>/dev/null; then + return 1 + fi + args="$(plutil -extract ProgramArguments json -o - "$plist" 2>/dev/null)" || return 1 + [[ "$(jq -c . <<<"$args")" == \ + "$(jq -cn --arg executable "$executable" '[$executable,"--elevation-host"]')" ]] +} + +elevation_receipt_binds_app() { + local receipt="$1" + [[ -n "$receipt" && -f "$receipt" && ! -L "$receipt" ]] || return 1 + jq -e \ + --arg appPath "$APP_PATH" \ + --arg plistPath "$PLIST_PATH" ' + type == "object" and + .appPath == $appPath and + .plistPath == $plistPath and + ( + (.schemaVersion == 3 and .kind == "openclaw-elevation-install") or + ( + (has("schemaVersion") | not) and (has("kind") | not) and + keys == ["appPath","archiveSha256","backupPath","peekabooCommit","plistPath","previousPlist","sourceCommit"] + ) + ) + ' "$receipt" >/dev/null 2>&1 +} + +loaded_elevation_job_binds_app() { + local snapshot program + snapshot="$(job_snapshot "$job_domain")" + [[ -n "$snapshot" ]] || return 1 + program="$(awk -F' = ' '/^[[:space:]]*program = / {print $2; exit}' <<<"$snapshot")" + [[ "$program" == "$APP_PATH/Contents/MacOS/OpenClaw" ]] || return 1 + grep -Eq '^[[:space:]]*--elevation-host[[:space:]]*$' <<<"$snapshot" +} + +elevation_ownership_is_evidenced() { + local candidate + loaded_elevation_job_binds_app && return 0 + for candidate in "$PLIST_PATH" "$ROLLBACK_ELEVATION_PLIST" "$RECOVERY_CURRENT_PLIST"; do + elevation_plist_binds_app "$candidate" && return 0 + done + for candidate in \ + "$RECEIPT_PATH" \ + "$FINAL_RECEIPT_PATH" \ + "$PENDING_RECEIPT_PATH" \ + "$ROLLBACK_INSTALL_RECEIPT" \ + "$RECOVERY_CURRENT_RECEIPT" + do + elevation_receipt_binds_app "$candidate" && return 0 + done + return 1 +} + +quarantine_elevation_plist() { + local description="$1" quarantine_path source_identity source_sha="" source_kind + [[ -e "$PLIST_PATH" || -L "$PLIST_PATH" ]] || return 0 + source_identity="$(path_identity "$PLIST_PATH")" || return 1 + if [[ -f "$PLIST_PATH" && ! -L "$PLIST_PATH" ]]; then + source_kind="file" + source_sha="$(shasum -a 256 "$PLIST_PATH" | awk '{print $1}')" || return 1 + [[ "$source_sha" =~ ^[0-9a-f]{64}$ ]] || return 1 + elif [[ -L "$PLIST_PATH" ]]; then + source_kind="symlink" + else + return 1 + fi + quarantine_path="$(mktemp -u "$STATE_DIR/elevation-host.quarantined-launch-agent.XXXXXX")" || return 1 + [[ ! -e "$quarantine_path" && ! -L "$quarantine_path" ]] || return 1 + rename_app_exclusively "$PLIST_PATH" "$quarantine_path" || return 1 + [[ ! -e "$PLIST_PATH" && ! -L "$PLIST_PATH" ]] || return 1 + [[ "$(path_identity "$quarantine_path")" == "$source_identity" ]] || return 1 + if [[ "$source_kind" == "file" ]]; then + backup_file_matches "$quarantine_path" "$source_sha" || return 1 + else + [[ -L "$quarantine_path" ]] || return 1 + fi + fsync_parent "$PLIST_PATH" || return 1 + printf 'Quarantined %s elevation LaunchAgent outside launchd discovery at %s\n' \ + "$description" "$quarantine_path" >&2 +} + +ensure_elevation_job_absent() { + local state + state="$(job_loaded_state "$job_domain")" + if [[ "$state" != "absent" ]]; then + launchctl bootout "$job_domain" >/dev/null 2>&1 || true + state="$(job_loaded_state "$job_domain")" + fi + [[ "$state" == "absent" ]] +} + +neutralize_unsafe_elevation_launch_agent() { + local description="$1" evidence_path="$2" evidence_sha="$3" neutralization_failed=0 + if ! quarantine_elevation_plist "$description"; then + if [[ -e "$PLIST_PATH" || -L "$PLIST_PATH" ]]; then + rm -f -- "$PLIST_PATH" || neutralization_failed=1 + fi + fsync_parent "$PLIST_PATH" || neutralization_failed=1 + printf 'Removed unquarantinable %s elevation LaunchAgent; exact evidence remains at %s\n' \ + "$description" "$evidence_path" >&2 + fi + [[ ! -e "$PLIST_PATH" && ! -L "$PLIST_PATH" ]] || neutralization_failed=1 + ensure_elevation_job_absent || neutralization_failed=1 + if [[ -n "$evidence_path" ]]; then + backup_file_matches "$evidence_path" "$evidence_sha" || neutralization_failed=1 + fi + [[ "$neutralization_failed" == "0" ]] +} + +quarantine_entry_unsafe_elevation_app() { + local app_identity app_kind quarantine_container quarantine_app quarantine_failed=0 + ELEVATION_APP_OWNER_WAS_EVIDENCED=0 + elevation_ownership_is_evidenced || return 0 + # Keep this fact across neutralization: launchd and its plist may be gone before + # rollback decides whether the displaced CUA-bearing app can return to APP_PATH. + ELEVATION_APP_OWNER_WAS_EVIDENCED=1 + [[ -e "$APP_PATH" || -L "$APP_PATH" ]] || return 0 + elevation_app_is_cua_free "$APP_PATH" && return 0 + + neutralize_unsafe_elevation_launch_agent 'entry for unsafe elevation app' '' '' || + quarantine_failed=1 + if [[ -L "$APP_PATH" ]]; then + app_kind="symlink" + app_identity="$(path_identity "$APP_PATH")" || quarantine_failed=1 + elif [[ -d "$APP_PATH" ]]; then + app_kind="bundle" + app_identity="$(durable_path_identity "$APP_PATH")" || quarantine_failed=1 + else + quarantine_failed=1 + fi + if [[ "$quarantine_failed" == "0" ]]; then + if quarantine_container="$(mktemp -d "$STATE_DIR/elevation-host.quarantined-app.XXXXXX")"; then + quarantine_app="$quarantine_container/OpenClaw.app" + rename_app_exclusively "$APP_PATH" "$quarantine_app" || quarantine_failed=1 + if path_matches_identity "$quarantine_app" "$app_identity" && + { [[ "$app_kind" == "symlink" && -L "$quarantine_app" ]] || + { [[ "$app_kind" == "bundle" && -d "$quarantine_app" && ! -L "$quarantine_app" ]] && + ! elevation_app_is_cua_free "$quarantine_app"; }; } && + [[ ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] + then + UNSAFE_ELEVATION_APP_QUARANTINE="$quarantine_app" + UNSAFE_ELEVATION_APP_WAS_QUARANTINED=1 + fsync_parent "$APP_PATH" || quarantine_failed=1 + else + quarantine_failed=1 + fi + else + quarantine_failed=1 + fi + fi + [[ ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] || quarantine_failed=1 + [[ ! -e "$PLIST_PATH" && ! -L "$PLIST_PATH" ]] || quarantine_failed=1 + if [[ "$UNSAFE_ELEVATION_APP_WAS_QUARANTINED" == "1" ]]; then + printf 'Quarantined CUA-bearing elevation app outside its launchd path at %s\n' \ + "$UNSAFE_ELEVATION_APP_QUARANTINE" >&2 + fi + [[ "$quarantine_failed" == "0" ]] +} + # Canonical elevation identity check: a strict superset of verify_elevation_signature in # codesign-mac-app.sh, and the only one that runs post-notarization and on the target Mac at install # time. Dropping it lets the portable installer accept an archive nobody re-verified after signing. verify_elevation_app() { local app="$1" [[ -d "$app" && ! -L "$app" ]] || fail "elevation app not found or symlinked: $app" + local cua_driver="$app/Contents/Resources/cua-driver" + elevation_app_is_cua_free "$app" || + fail "elevation app must not contain bundled CUA driver: $cua_driver" [[ "$(plist_value "$app" CFBundleIdentifier)" == "$EXPECTED_BUNDLE_ID" ]] || fail "elevation app bundle id must be $EXPECTED_BUNDLE_ID" local source_commit peekaboo_commit @@ -531,6 +711,7 @@ restore_install_receipt_after_rollback() { "$ROLLBACK_INSTALL_RECEIPT_SHA" \ 600 elif [[ -e "$RECEIPT_PATH" || -L "$RECEIPT_PATH" ]]; then + [[ "$ELEVATION_APP_OWNER_WAS_EVIDENCED" == "1" ]] || return 1 [[ -f "$RECEIPT_PATH" && ! -L "$RECEIPT_PATH" ]] || return 1 rm "$RECEIPT_PATH" fi @@ -2163,12 +2344,28 @@ install_host() { recover_install() { local recovery_failed=0 elevation_state restored_state prior_owner_state + local unsafe_previous_elevation=0 rollback_app_candidate="" + quarantine_entry_unsafe_elevation_app || return 1 + [[ "$UNSAFE_ELEVATION_APP_WAS_QUARANTINED" == "0" ]] || recovery_failed=1 if [[ "$CUTOVER_APP_MUTATED" == "1" && -n "$ROLLBACK_APP_PATH" ]]; then verify_recorded_rollback_app "$APP_PATH" || verify_recorded_rollback_app "$ROLLBACK_APP_PATH" || return 1 fi - [[ -z "$ROLLBACK_ELEVATION_PLIST" ]] || - backup_file_matches "$ROLLBACK_ELEVATION_PLIST" "$ROLLBACK_ELEVATION_PLIST_SHA" || return 1 + if [[ "$ELEVATION_APP_OWNER_WAS_EVIDENCED" == "1" && -n "$ROLLBACK_APP_PATH" ]] + then + if [[ -d "$ROLLBACK_APP_PATH" && ! -L "$ROLLBACK_APP_PATH" ]] && + ! elevation_app_is_cua_free "$ROLLBACK_APP_PATH" + then + rollback_app_candidate="$ROLLBACK_APP_PATH" + elif [[ -d "$APP_PATH" && ! -L "$APP_PATH" ]] && ! elevation_app_is_cua_free "$APP_PATH"; then + rollback_app_candidate="$APP_PATH" + fi + [[ -z "$rollback_app_candidate" ]] || unsafe_previous_elevation=1 + fi + if [[ "$unsafe_previous_elevation" == "0" ]]; then + [[ -z "$ROLLBACK_ELEVATION_PLIST" ]] || + backup_file_matches "$ROLLBACK_ELEVATION_PLIST" "$ROLLBACK_ELEVATION_PLIST_SHA" || return 1 + fi [[ -z "$ROLLBACK_MIGRATION_PLIST" ]] || backup_file_matches "$ROLLBACK_MIGRATION_PLIST" "$ROLLBACK_MIGRATION_PLIST_SHA" || return 1 [[ -z "$ROLLBACK_INSTALL_RECEIPT" ]] || @@ -2221,7 +2418,21 @@ recover_install() { [[ -n "$ROLLBACK_APP_PATH" ]] && verify_recorded_rollback_app "$APP_PATH" || return 1 fi - if [[ -n "$ROLLBACK_APP_PATH" && -d "$ROLLBACK_APP_PATH" ]]; then + if [[ "$unsafe_previous_elevation" == "1" ]]; then + if [[ -d "$ROLLBACK_APP_PATH" && ! -L "$ROLLBACK_APP_PATH" ]]; then + verify_recorded_rollback_app "$ROLLBACK_APP_PATH" || recovery_failed=1 + [[ ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] || recovery_failed=1 + elif verify_recorded_rollback_app "$APP_PATH"; then + [[ ! -e "$ROLLBACK_APP_PATH" && ! -L "$ROLLBACK_APP_PATH" ]] || recovery_failed=1 + if [[ "$recovery_failed" == "0" ]]; then + rename_app_exclusively "$APP_PATH" "$ROLLBACK_APP_PATH" || true + verify_recorded_rollback_app "$ROLLBACK_APP_PATH" || recovery_failed=1 + [[ ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] || recovery_failed=1 + fi + else + recovery_failed=1 + fi + elif [[ -n "$ROLLBACK_APP_PATH" && -d "$ROLLBACK_APP_PATH" ]]; then [[ ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] || return 1 rename_app_exclusively "$ROLLBACK_APP_PATH" "$APP_PATH" || true verify_recorded_rollback_app "$APP_PATH" || recovery_failed=1 @@ -2229,23 +2440,42 @@ recover_install() { elif [[ -n "$ROLLBACK_APP_PATH" ]]; then verify_recorded_rollback_app "$APP_PATH" || recovery_failed=1 fi - if [[ -n "$ROLLBACK_ELEVATION_PLIST" && -f "$ROLLBACK_ELEVATION_PLIST" ]]; then - restore_file_atomically \ - "$ROLLBACK_ELEVATION_PLIST" \ - "$PLIST_PATH" \ - "$ROLLBACK_ELEVATION_PLIST_SHA" \ - 644 || recovery_failed=1 - restored_state="$(job_loaded_state "$job_domain")" - [[ "$restored_state" != 'unknown' ]] || recovery_failed=1 - if [[ "$recovery_failed" == "0" && - "$ROLLBACK_ELEVATION_WAS_LOADED" == "1" && "$restored_state" == 'absent' ]] - then - launchctl bootstrap "$launch_domain" "$PLIST_PATH" >/dev/null 2>&1 || recovery_failed=1 - elif [[ "$ROLLBACK_ELEVATION_WAS_LOADED" == "0" && "$restored_state" != 'absent' ]]; then - recovery_failed=1 + if [[ "$unsafe_previous_elevation" == "1" ]]; then + if [[ -n "$ROLLBACK_ELEVATION_PLIST" ]]; then + neutralize_unsafe_elevation_launch_agent \ + 'replacement for unsafe previous' \ + "$ROLLBACK_ELEVATION_PLIST" \ + "$ROLLBACK_ELEVATION_PLIST_SHA" || recovery_failed=1 + printf 'Preserved previous elevation app with bundled CUA driver at %s and LaunchAgent evidence at %s; refusing to restore it as elevation host\n' \ + "$ROLLBACK_APP_PATH" "$ROLLBACK_ELEVATION_PLIST" >&2 + else + neutralize_unsafe_elevation_launch_agent \ + 'replacement for unsafe previous' \ + '' \ + '' || recovery_failed=1 + printf 'Preserved previous elevation app with bundled CUA driver at %s; no prior elevation LaunchAgent was recorded, and it will not be restored as elevation host\n' \ + "$ROLLBACK_APP_PATH" >&2 fi - else + recovery_failed=1 + elif [[ -n "$ROLLBACK_ELEVATION_PLIST" && -f "$ROLLBACK_ELEVATION_PLIST" ]]; then + restore_file_atomically \ + "$ROLLBACK_ELEVATION_PLIST" \ + "$PLIST_PATH" \ + "$ROLLBACK_ELEVATION_PLIST_SHA" \ + 644 || recovery_failed=1 + restored_state="$(job_loaded_state "$job_domain")" + [[ "$restored_state" != 'unknown' ]] || recovery_failed=1 + if [[ "$recovery_failed" == "0" && + "$ROLLBACK_ELEVATION_WAS_LOADED" == "1" && "$restored_state" == 'absent' ]] + then + launchctl bootstrap "$launch_domain" "$PLIST_PATH" >/dev/null 2>&1 || recovery_failed=1 + elif [[ "$ROLLBACK_ELEVATION_WAS_LOADED" == "0" && "$restored_state" != 'absent' ]]; then + recovery_failed=1 + fi + elif [[ "$ELEVATION_APP_OWNER_WAS_EVIDENCED" == "1" ]]; then [[ ! -f "$PLIST_PATH" ]] || rm -f "$PLIST_PATH" || recovery_failed=1 + elif [[ -e "$PLIST_PATH" || -L "$PLIST_PATH" ]]; then + recovery_failed=1 fi if [[ -n "$ROLLBACK_MIGRATION_SOURCE" && -f "$ROLLBACK_MIGRATION_PLIST" ]]; then if [[ "$CUTOVER_MIGRATION_REMOVED" != "1" && @@ -2365,6 +2595,13 @@ recover_install() { restore_current_generation_after_recovery_failure() { local restore_failed=0 app_restore_failed=0 state + local unsafe_current_elevation=0 current_app_evidence_path="" + quarantine_entry_unsafe_elevation_app || return 1 + if [[ "$UNSAFE_ELEVATION_APP_WAS_QUARANTINED" == "1" ]]; then + unsafe_current_elevation=1 + current_app_evidence_path="$UNSAFE_ELEVATION_APP_QUARANTINE" + app_restore_failed=1 + fi if [[ "$RECOVERY_RELAUNCHED_ADOPTED_PID" =~ ^[0-9]+$ ]]; then ADOPTION_PID="$RECOVERY_RELAUNCHED_ADOPTED_PID" @@ -2414,7 +2651,35 @@ restore_current_generation_after_recovery_failure() { [[ "$(job_loaded_state "$launch_domain/$ROLLBACK_MIGRATION_LABEL")" == "absent" ]] || return 1 fi - if [[ "$RECOVERY_CURRENT_APP_STATE" == "absent" ]]; then + if [[ "$unsafe_current_elevation" == "0" && + ( "$RECOVERY_CURRENT_APP_STATE" == "valid" || + "$RECOVERY_CURRENT_APP_STATE" == "damaged" ) ]] + then + if [[ -n "$RECOVERED_FAILED_APP_PATH" && -d "$RECOVERED_FAILED_APP_PATH" && + ! -L "$RECOVERED_FAILED_APP_PATH" ]] && + ! elevation_app_is_cua_free "$RECOVERED_FAILED_APP_PATH" + then + unsafe_current_elevation=1 + current_app_evidence_path="$RECOVERED_FAILED_APP_PATH" + elif [[ -d "$APP_PATH" && ! -L "$APP_PATH" ]] && ! elevation_app_is_cua_free "$APP_PATH"; then + unsafe_current_elevation=1 + if preserve_current_app_for_recovery 'unsafe current elevation app'; then + current_app_evidence_path="$RECOVERED_FAILED_APP_PATH" + elif [[ -n "$RECOVERED_FAILED_APP_PATH" ]] && + verify_recorded_current_app "$RECOVERED_FAILED_APP_PATH" + then + current_app_evidence_path="$RECOVERED_FAILED_APP_PATH" + app_restore_failed=1 + else + current_app_evidence_path="$APP_PATH" + app_restore_failed=1 + fi + fi + fi + + if [[ "$unsafe_current_elevation" == "1" && "$app_restore_failed" != "0" ]]; then + : # Preserve the unsafe classification and skip every normal restoration path. + elif [[ "$RECOVERY_CURRENT_APP_STATE" == "absent" ]]; then if [[ ! -e "$APP_PATH" && ! -L "$APP_PATH" ]]; then [[ -z "$ROLLBACK_APP_PATH" ]] || verify_recorded_rollback_app "$ROLLBACK_APP_PATH" || app_restore_failed=1 @@ -2439,14 +2704,19 @@ restore_current_generation_after_recovery_failure() { ! -L "$RECOVERED_FAILED_APP_PATH" ]] && path_matches_identity "$RECOVERED_FAILED_APP_PATH" "$RECOVERY_CURRENT_APP_IDENTITY" then - rename_app_exclusively "$RECOVERED_FAILED_APP_PATH" "$APP_PATH" || true - path_matches_identity "$APP_PATH" "$RECOVERY_CURRENT_APP_IDENTITY" || app_restore_failed=1 - if [[ "$app_restore_failed" == "0" && "$RECOVERY_CURRENT_APP_STATE" == "valid" ]]; then - verify_recorded_current_app "$APP_PATH" || app_restore_failed=1 + if [[ "$unsafe_current_elevation" == "1" ]]; then + verify_recorded_current_app "$RECOVERED_FAILED_APP_PATH" || app_restore_failed=1 + [[ ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] || app_restore_failed=1 + else + rename_app_exclusively "$RECOVERED_FAILED_APP_PATH" "$APP_PATH" || true + path_matches_identity "$APP_PATH" "$RECOVERY_CURRENT_APP_IDENTITY" || app_restore_failed=1 + if [[ "$app_restore_failed" == "0" && "$RECOVERY_CURRENT_APP_STATE" == "valid" ]]; then + verify_recorded_current_app "$APP_PATH" || app_restore_failed=1 + fi + [[ ! -e "$RECOVERED_FAILED_APP_PATH" && ! -L "$RECOVERED_FAILED_APP_PATH" ]] || + app_restore_failed=1 + rmdir "$(dirname "$RECOVERED_FAILED_APP_PATH")" 2>/dev/null || true fi - [[ ! -e "$RECOVERED_FAILED_APP_PATH" && ! -L "$RECOVERED_FAILED_APP_PATH" ]] || - app_restore_failed=1 - rmdir "$(dirname "$RECOVERED_FAILED_APP_PATH")" 2>/dev/null || true else app_restore_failed=1 fi @@ -2454,18 +2724,49 @@ restore_current_generation_after_recovery_failure() { case "$RECOVERY_CURRENT_APP_STATE" in absent) [[ ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] || app_restore_failed=1 ;; damaged) - [[ -d "$APP_PATH" && ! -L "$APP_PATH" ]] && - path_matches_identity "$APP_PATH" "$RECOVERY_CURRENT_APP_IDENTITY" || app_restore_failed=1 + if [[ "$unsafe_current_elevation" == "1" ]]; then + [[ -n "$current_app_evidence_path" && ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] && + path_matches_identity "$current_app_evidence_path" "$RECOVERY_CURRENT_APP_IDENTITY" && + verify_recorded_current_app "$current_app_evidence_path" || app_restore_failed=1 + else + [[ -d "$APP_PATH" && ! -L "$APP_PATH" ]] && + path_matches_identity "$APP_PATH" "$RECOVERY_CURRENT_APP_IDENTITY" || app_restore_failed=1 + fi ;; valid) - path_matches_identity "$APP_PATH" "$RECOVERY_CURRENT_APP_IDENTITY" && - verify_recorded_current_app "$APP_PATH" || app_restore_failed=1 + if [[ "$unsafe_current_elevation" == "1" ]]; then + [[ -n "$current_app_evidence_path" && ! -e "$APP_PATH" && ! -L "$APP_PATH" ]] && + path_matches_identity "$current_app_evidence_path" "$RECOVERY_CURRENT_APP_IDENTITY" && + verify_recorded_current_app "$current_app_evidence_path" || app_restore_failed=1 + else + path_matches_identity "$APP_PATH" "$RECOVERY_CURRENT_APP_IDENTITY" && + verify_recorded_current_app "$APP_PATH" || app_restore_failed=1 + fi ;; *) app_restore_failed=1 ;; esac - [[ "$app_restore_failed" == "0" ]] || return 1 + if [[ "$app_restore_failed" != "0" && "$unsafe_current_elevation" == "0" ]]; then + return 1 + fi - if [[ -n "$RECOVERY_CURRENT_PLIST" ]]; then + if [[ "$unsafe_current_elevation" == "1" ]]; then + if [[ -n "$RECOVERY_CURRENT_PLIST" ]]; then + neutralize_unsafe_elevation_launch_agent \ + 'replacement for unsafe current' \ + "$RECOVERY_CURRENT_PLIST" \ + "$RECOVERY_CURRENT_PLIST_SHA" || restore_failed=1 + printf 'Preserved current elevation app with bundled CUA driver at %s and LaunchAgent evidence at %s; refusing to restore it as elevation host\n' \ + "$current_app_evidence_path" "$RECOVERY_CURRENT_PLIST" >&2 + else + neutralize_unsafe_elevation_launch_agent \ + 'replacement for unsafe current' \ + '' \ + '' || restore_failed=1 + printf 'Preserved current elevation app with bundled CUA driver at %s; no current elevation LaunchAgent was recorded, and it will not be restored as elevation host\n' \ + "$current_app_evidence_path" >&2 + fi + restore_failed=1 + elif [[ -n "$RECOVERY_CURRENT_PLIST" ]]; then restore_file_atomically \ "$RECOVERY_CURRENT_PLIST" \ "$PLIST_PATH" \ @@ -2686,6 +2987,10 @@ recover_host() { fail 'recovery migration transaction binding is invalid' RECOVERY_RESTORED_MIGRATION_IDENTITY="$migration_identity" fi + if [[ "$INSTALL_RECEIPT_SCHEMA" != "legacy" ]]; then + RECOVERY_CURRENT_APP_CDHASH_ARM64="$(jq -r '.cdhashes.arm64' "$RECEIPT_PATH")" + RECOVERY_CURRENT_APP_CDHASH_X86_64="$(jq -r '.cdhashes.x86_64' "$RECEIPT_PATH")" + fi if [[ "$INSTALL_RECEIPT_SCHEMA" == 'legacy' || "$RECOVERY_CURRENT_APP_STATE" != "valid" || ("$RECOVERY_PENDING_INSTALL" == "1" && "$current_app_matches_receipt" != "1") ]] then @@ -2713,8 +3018,6 @@ recover_host() { [[ -z "$ARCHIVE" && -z "$ARTIFACT_RECEIPT" && -z "$EXPECTED_ARTIFACT_RECEIPT_SHA256" ]] || fail 'artifact helper inputs are valid only for legacy recovery' CONFIG_PATH="$(jq -r '.configPath' "$RECEIPT_PATH")" - RECOVERY_CURRENT_APP_CDHASH_ARM64="$(jq -r '.cdhashes.arm64' "$RECEIPT_PATH")" - RECOVERY_CURRENT_APP_CDHASH_X86_64="$(jq -r '.cdhashes.x86_64' "$RECEIPT_PATH")" prepare_current_app_rename_helper fi if [[ "$pending_migration_identity_needs_record" == "1" ]]; then diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 92bcb0b3ddec..8915a63a90c2 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -25,6 +25,14 @@ MLX_TTS_HELPER_BUILD_ROOT="$MLX_TTS_HELPER_ROOT/.build" BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac.debug}" PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")" BUILD_CONFIG="${BUILD_CONFIG:-debug}" +SIGNING_VARIANT="${OPENCLAW_MAC_SIGNING_VARIANT:-standard}" +case "$SIGNING_VARIANT" in + standard | elevation-host) ;; + *) + echo "ERROR: Unknown OPENCLAW_MAC_SIGNING_VARIANT value: $SIGNING_VARIANT (use standard|elevation-host)" >&2 + exit 1 + ;; +esac # OPENCLAW_SKIP_MLX_TTS=1 packages the app without the local MLX voice helper. # The helper pulls in the full mlx-swift Metal shader stack, which some beta # Xcode toolchains cannot compile (flaky `metal` diagnostics), needlessly @@ -886,8 +894,12 @@ fi rm -rf "$APP_ROOT/Contents/Resources/ProviderIcons" cp -R "$PROVIDER_ICONS_SRC" "$APP_ROOT/Contents/Resources/ProviderIcons" -echo "🖥 Staging embedded CUA driver" -"$ROOT_DIR/scripts/stage-cua-driver-macos.sh" "$APP_ROOT/Contents/Resources/cua-driver" +if [[ "$SIGNING_VARIANT" == "elevation-host" ]]; then + echo "🖥 Omitting embedded CUA driver from elevation-host package" +else + echo "🖥 Staging embedded CUA driver" + "$ROOT_DIR/scripts/stage-cua-driver-macos.sh" "$APP_ROOT/Contents/Resources/cua-driver" +fi echo "📦 Copying CLI installer" INSTALL_CLI_SRC="$ROOT_DIR/scripts/install-cli.sh" diff --git a/test/scripts/codesign-mac-app.test.ts b/test/scripts/codesign-mac-app.test.ts index 5acd48e8cf64..8a1b3fcd1454 100644 --- a/test/scripts/codesign-mac-app.test.ts +++ b/test/scripts/codesign-mac-app.test.ts @@ -6,6 +6,7 @@ import { mkdirSync, readFileSync, readdirSync, + symlinkSync, writeFileSync, } from "node:fs"; import path from "node:path"; @@ -294,6 +295,39 @@ describe("codesign-mac-app temp file hygiene", () => { expect(script).toContain('assert_no_apple_events_entitlement "$APP_BUNDLE"'); }); + it.each(["file", "symlink"])("rejects an elevation-host CUA driver %s before signing", (kind) => { + const tempRoot = tempDirs.make(`openclaw-codesign-elevation-cua-${kind}-`); + const app = path.join(tempRoot, "Fake.app"); + const binDir = path.join(tempRoot, "bin"); + const resources = path.join(app, "Contents", "Resources"); + mkdirSync(path.join(app, "Contents", "MacOS"), { recursive: true }); + mkdirSync(resources, { recursive: true }); + mkdirSync(binDir); + writeFileSync(path.join(app, "Contents", "MacOS", "OpenClaw"), "#!/bin/sh\n"); + const cuaDriver = path.join(resources, "cua-driver"); + if (kind === "file") { + writeFileSync(cuaDriver, "driver\n"); + } else { + symlinkSync("/missing/cua-driver", cuaDriver); + } + installElevationFakeCodesign(binDir); + + const result = spawnSync("bash", [scriptPath, app], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_MAC_SIGNING_VARIANT: "elevation-host", + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + SIGN_IDENTITY: "Developer ID Application: OpenClaw Foundation (FWJYW4S8P8)", + TMPDIR: tempRoot, + }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must not contain bundled CUA driver"); + }); + it("consumes complete codesign metadata under pipefail before validating authority", () => { const tempRoot = tempDirs.make("openclaw-codesign-elevation-metadata-"); const app = path.join(tempRoot, "Fake.app"); diff --git a/test/scripts/mac-elevation-host.test.ts b/test/scripts/mac-elevation-host.test.ts index fb016a26b7b7..79a06cb99564 100644 --- a/test/scripts/mac-elevation-host.test.ts +++ b/test/scripts/mac-elevation-host.test.ts @@ -57,6 +57,45 @@ function receiptDigestArgs(receiptPath: string): string[] { return ["--receipt-sha256", sha256(readFileSync(receiptPath))]; } +function quarantinedElevationAppPath(stateDir: string): string | undefined { + const container = readdirSync(stateDir).find((name) => + name.startsWith("elevation-host.quarantined-app."), + ); + return container ? path.join(stateDir, container, "OpenClaw.app") : undefined; +} + +function preservedCuaAppPath(harness: { + appPath: string; + env: NodeJS.ProcessEnv; + stateDir: string; +}): string | undefined { + const quarantined = quarantinedElevationAppPath(harness.stateDir); + if (quarantined) { + const driver = path.join(quarantined, "Contents", "Resources", "cua-driver"); + if (existsSync(driver) || lstatSync(driver, { throwIfNoEntry: false })?.isSymbolicLink()) { + return quarantined; + } + } + const home = harness.env.HOME!; + for (const entry of readdirSync(home)) { + if ( + !entry.startsWith(`${path.basename(harness.appPath)}.rollback-elevation-host-`) && + !entry.startsWith(`${path.basename(harness.appPath)}.failed-elevation-host-`) + ) { + continue; + } + const container = path.join(home, entry); + const candidate = entry.includes(".failed-elevation-host-") + ? path.join(container, "OpenClaw.app") + : container; + const driver = path.join(candidate, "Contents", "Resources", "cua-driver"); + if (existsSync(driver) || lstatSync(driver, { throwIfNoEntry: false })?.isSymbolicLink()) { + return candidate; + } + } + return undefined; +} + function runInstaller( installerPath: string, args: string[], @@ -536,7 +575,13 @@ function createArtifactVerificationHarness() { ' : >"$TEST_PENDING_KILL_MARKER"', ' kill -KILL "$PPID"', "fi", + 'if [ "${1:-}" = "--elevation-sync-file" ] && [ "${TEST_CORRUPT_ROLLBACK_PLIST_BACKUP_ON_SYNC:-0}" = "1" ] && echo "${2:-}" | grep -q \'elevation-host[.]previous-plist[.]\'; then', + " printf '%s\\n' corrupt >\"$2\"", + "fi", 'if [ "${1:-}" = "--elevation-rename-exclusive" ]; then', + ' if [ "${TEST_FAIL_ELEVATION_QUARANTINE_RENAME:-0}" = "1" ] && echo "$3" | grep -q \'elevation-host[.]quarantined-launch-agent[.]\'; then', + " exit 7", + " fi", ' if [ "${TEST_DANGLING_ROLLBACK_DURING_MOVE:-0}" = "1" ] && echo "$3" | grep -q \'[.]rollback-elevation-host-\'; then', ' ln -s /missing/openclaw-rollback-target "$3"', " fi", @@ -570,6 +615,12 @@ function createArtifactVerificationHarness() { " fi", ' if [ -e "$3" ] || [ -L "$3" ]; then exit 1; fi', ' /bin/mv "$2" "$3" || exit $?', + ' if [ "${TEST_REMOVE_CUA_DRIVER_AFTER_UNSAFE_ENTRY_MOVE:-0}" = "1" ] && echo "$3" | grep -q \'elevation-host[.]quarantined-app[.].*[/]OpenClaw[.]app$\' && [ -L "$3" ]; then', + ' /bin/rm -f -- "$(readlink "$3")/Contents/Resources/cua-driver"', + " fi", + ' if [ "${TEST_RELOAD_ELEVATION_AFTER_QUARANTINE:-0}" = "1" ] && echo "$3" | grep -q \'elevation-host[.]quarantined-launch-agent[.]\'; then', + " printf '%s\\n' elevation-loaded >\"$TEST_LAUNCH_STATE_FILE\"", + " fi", ' if [ "${TEST_KILL_AFTER_INITIAL_MIGRATION_CUSTODY:-0}" = "1" ] && echo "$3" | grep -q \'[.]custody[.]\'; then', ' kill -KILL "$PPID"', " fi", @@ -595,6 +646,12 @@ function createArtifactVerificationHarness() { "APP_HELPER", 'printf helper >"$app/Contents/MacOS/openclaw-mlx-tts"', 'chmod 755 "$app/Contents/MacOS/OpenClaw" "$app/Contents/MacOS/openclaw-mlx-tts"', + 'case "${TEST_CUA_DRIVER_KIND:-none}" in', + ' file) mkdir -p "$app/Contents/Resources"; printf driver >"$app/Contents/Resources/cua-driver"; chmod 755 "$app/Contents/Resources/cua-driver" ;;', + ' symlink) mkdir -p "$app/Contents/Resources"; ln -s /missing/cua-driver "$app/Contents/Resources/cua-driver" ;;', + " none) ;;", + " *) exit 64 ;;", + "esac", "", ].join("\n"), ); @@ -613,6 +670,9 @@ function createArtifactVerificationHarness() { 'if [[ "$*" == *"--verify"* && "${TEST_FINAL_SIGNATURE_INVALID:-0}" == "1" && "$target" == "${TEST_INSTALLED_APP_PATH:-}" && -f "${TEST_LAUNCH_STATE_FILE:-}" && "$(tr -d \'\\n\' <"$TEST_LAUNCH_STATE_FILE")" == "elevation-loaded" ]]; then', " exit 1", "fi", + 'if [[ "$*" == *"--verify"* && "${TEST_CURRENT_CUA_SIGNATURE_INVALID:-0}" == "1" && "$target" == "${TEST_INSTALLED_APP_PATH:-}" && ( -e "$target/Contents/Resources/cua-driver" || -L "$target/Contents/Resources/cua-driver" ) ]]; then', + " exit 1", + "fi", 'if [[ "$*" == *"--verify"* && -e "$target/Contents/invalid-signature" ]]; then', " exit 1", "fi", @@ -693,6 +753,8 @@ function createArtifactVerificationHarness() { function createInstallRollbackHarness( options: { danglingRollbackDuringMove?: boolean; + corruptRollbackPlistBackupOnSync?: boolean; + currentCuaSignatureInvalid?: boolean; failCurrentReceiptRestoreCopy?: boolean; failAfterReceiptCommitMove?: boolean; failRecoveryXattrRead?: boolean; @@ -700,8 +762,11 @@ function createInstallRollbackHarness( finalSignatureInvalid?: boolean; failLsofInspection?: boolean; failPgrepInspection?: boolean; + failUnsafeEntryIdentity?: boolean; + failUnsafeEntryMktemp?: boolean; hupDuringCustody?: boolean; launchdBootstrapFails?: boolean; + failElevationQuarantineRename?: boolean; killDuringMigrationRestoreBootstrapOnce?: boolean; killAfterMigrationRestoreBootstrapOnce?: boolean; killAfterInitialMigrationCustody?: boolean; @@ -709,7 +774,9 @@ function createInstallRollbackHarness( killAfterRollbackAppCustody?: boolean; migrationRestoreBootstrapFails?: boolean; raceMigrationCustodyDestination?: boolean; + reloadElevationAfterQuarantine?: boolean; removeInstalledExecutableAfterReadiness?: boolean; + existingElevationLoaded?: boolean; recreateAppDuringDamagedCustody?: boolean; recreateSourceDuringBootout?: boolean; recreateSourceOnFailure?: boolean; @@ -720,6 +787,8 @@ function createInstallRollbackHarness( replaceMigrationSourceSameContentBeforeInitialCustody?: boolean; restartAppDuringBootout?: boolean; rollbackNonNativeSignatureInvalid?: boolean; + rollbackCuaDriverKind?: "file" | "symlink"; + removeCuaDriverAfterUnsafeEntryMove?: boolean; signalDuringCustody?: boolean; signalDuringRecoveryAppMove?: boolean; signalDuringReceiptCommit?: boolean; @@ -728,6 +797,14 @@ function createInstallRollbackHarness( symlinkDamagedAppBeforeCustody?: boolean; sameSourceExistingApp?: boolean; transientAppRestartReloadsJob?: boolean; + unsafeEntryEvidence?: + | "job" + | "plist" + | "plist-program" + | "receipt" + | "unrelated-plist" + | "unrelated-program" + | "unrelated-receipt"; } = {}, ) { const artifact = createArtifactVerificationHarness(); @@ -741,6 +818,7 @@ function createInstallRollbackHarness( const label = "ai.openclaw.mac.node-fixture"; const launchAgentsDir = path.join(tempRoot, "Library", "LaunchAgents"); const sourcePlist = path.join(launchAgentsDir, `${label}.plist`); + const elevationPlist = path.join(launchAgentsDir, "ai.openclaw.mac.elevation-host.plist"); const launchStateFile = path.join(tempRoot, "launch-state"); const nodeGenerationFile = path.join(tempRoot, "node-generation"); mkdirSync(path.join(stateDir, "state"), { recursive: true }); @@ -750,6 +828,16 @@ function createInstallRollbackHarness( writeAppInfoPlist(appPath, oldSourceCommit, oldPeekabooCommit); writeExecutable(path.join(appPath, "Contents", "MacOS", "OpenClaw"), "#!/bin/sh\nexit 0\n"); writeFileSync(path.join(appPath, "Contents", "old-fixture"), "old\n", "utf8"); + if (options.rollbackCuaDriverKind) { + const resources = path.join(appPath, "Contents", "Resources"); + const cuaDriver = path.join(resources, "cua-driver"); + mkdirSync(resources, { recursive: true }); + if (options.rollbackCuaDriverKind === "file") { + writeExecutable(cuaDriver, "#!/bin/sh\nexit 0\n"); + } else { + symlinkSync("/missing/cua-driver", cuaDriver); + } + } const sourceContents = [ '', '', @@ -764,10 +852,48 @@ function createInstallRollbackHarness( "", "", ].join("\n"); - writeFileSync(sourcePlist, sourceContents, "utf8"); - writeFileSync(launchStateFile, "source-loaded\n", "utf8"); + const elevationPlistContents = [ + '', + '', + "Labelai.openclaw.mac.elevation-host", + `ProgramArguments${appPath}/Contents/MacOS/OpenClaw--elevation-host`, + "EnvironmentVariables", + `OPENCLAW_STATE_DIR${stateDir}`, + `OPENCLAW_CONFIG_PATH${configPath}`, + "", + "", + ].join("\n"); + if (options.existingElevationLoaded) { + writeFileSync(elevationPlist, elevationPlistContents, "utf8"); + writeFileSync(launchStateFile, "elevation-loaded\n", "utf8"); + } else { + writeFileSync(sourcePlist, sourceContents, "utf8"); + writeFileSync(launchStateFile, "source-loaded\n", "utf8"); + } writeFileSync(nodeGenerationFile, "0\n", "utf8"); writeExecutable(path.join(binDir, "defaults"), "#!/bin/sh\nprintf '%s\\n' primary\n"); + writeExecutable( + path.join(binDir, "df"), + [ + "#!/usr/bin/env bash", + 'if [[ "${TEST_FAIL_UNSAFE_ENTRY_IDENTITY:-0}" == "1" && "${!#}" == "${TEST_INSTALLED_APP_PATH:-}" ]]; then', + " exit 7", + "fi", + 'exec /bin/df "$@"', + "", + ].join("\n"), + ); + writeExecutable( + path.join(binDir, "mktemp"), + [ + "#!/usr/bin/env bash", + 'if [[ "${TEST_FAIL_UNSAFE_ENTRY_MKTEMP:-0}" == "1" && "$*" == *"elevation-host.quarantined-app."* ]]; then', + " exit 7", + "fi", + 'exec /usr/bin/mktemp "$@"', + "", + ].join("\n"), + ); if (options.replaceAuthenticatedRenameHelperBeforeUse) { writeExecutable( path.join(binDir, "shasum"), @@ -912,6 +1038,8 @@ function createInstallRollbackHarness( " fi", ' if [[ "$target" == */ai.openclaw.mac.elevation-host && "$state" == "elevation-loaded" ]]; then', " printf '%s\\n' ' pid = 555555'", + " printf ' program = %s/Contents/MacOS/OpenClaw\\n' \"$TEST_INSTALLED_APP_PATH\"", + " printf '%s\\n' ' arguments = {' ' --elevation-host' ' }'", " exit 0", " fi", " printf '%s\\n' 'Could not find service in domain' >&2", @@ -932,6 +1060,27 @@ function createInstallRollbackHarness( ' plist="${3:-}"', ' if [[ "$plist" == *ai.openclaw.mac.elevation-host.plist ]]; then', ' if [[ "$TEST_LAUNCHD_BOOTSTRAP_FAILS" == "1" ]]; then', + ' if [[ -n "$TEST_UNSAFE_ENTRY_EVIDENCE" ]]; then', + ' rollback_app="$(find "$(dirname "$TEST_INSTALLED_APP_PATH")" -maxdepth 1 -type d -name "$(basename "$TEST_INSTALLED_APP_PATH").rollback-elevation-host-*" -print -quit)"', + ' [[ -n "$rollback_app" ]] || exit 71', + ' /bin/rm -rf -- "$TEST_INSTALLED_APP_PATH"', + ' if [[ "$TEST_UNSAFE_ENTRY_EVIDENCE" == unrelated-* ]]; then', + ' /bin/mv "$rollback_app" "$TEST_INSTALLED_APP_PATH"', + " else", + ' ln -s "$rollback_app" "$TEST_INSTALLED_APP_PATH"', + " fi", + ' pending_receipt="$TEST_STATE_DIR/elevation-host-install.pending.json"', + ' case "$TEST_UNSAFE_ENTRY_EVIDENCE" in', + ' job) /bin/rm -f -- "$TEST_ELEVATION_PLIST" "$pending_receipt"; printf \'%s\\n\' elevation-loaded >"$TEST_LAUNCH_STATE_FILE" ;;', + ' plist) /bin/rm -f -- "$pending_receipt"; printf \'%s\\n\' elevation-absent >"$TEST_LAUNCH_STATE_FILE" ;;', + ' plist-program) /bin/rm -f -- "$pending_receipt"; /usr/bin/plutil -insert Program -string "$TEST_INSTALLED_APP_PATH/Contents/MacOS/OpenClaw" "$TEST_ELEVATION_PLIST"; printf \'%s\\n\' elevation-absent >"$TEST_LAUNCH_STATE_FILE" ;;', + ' receipt) /bin/rm -f -- "$TEST_ELEVATION_PLIST"; printf \'%s\\n\' elevation-absent >"$TEST_LAUNCH_STATE_FILE" ;;', + ' unrelated-plist) /bin/rm -f -- "$pending_receipt"; /usr/bin/plutil -replace ProgramArguments.0 -string "$TEST_UNRELATED_APP_PATH/Contents/MacOS/OpenClaw" "$TEST_ELEVATION_PLIST"; printf \'%s\\n\' elevation-absent >"$TEST_LAUNCH_STATE_FILE" ;;', + ' unrelated-program) /bin/rm -f -- "$pending_receipt"; /usr/bin/plutil -insert Program -string "$TEST_UNRELATED_APP_PATH/Contents/MacOS/OpenClaw" "$TEST_ELEVATION_PLIST"; printf \'%s\\n\' elevation-absent >"$TEST_LAUNCH_STATE_FILE" ;;', + ' unrelated-receipt) /bin/rm -f -- "$TEST_ELEVATION_PLIST"; jq --arg appPath "$TEST_UNRELATED_APP_PATH" \'.appPath = $appPath\' "$pending_receipt" >"$pending_receipt.tmp"; /bin/mv "$pending_receipt.tmp" "$TEST_STATE_DIR/elevation-host-install.json"; /bin/rm -f -- "$pending_receipt"; printf \'%s\\n\' elevation-absent >"$TEST_LAUNCH_STATE_FILE" ;;', + " *) exit 72 ;;", + " esac", + " fi", ' if [[ "$TEST_RECREATE_SOURCE_ON_FAILURE" == "1" ]]; then', " printf '%s\\n' replacement-owner >\"$TEST_SOURCE_PLIST\"", " fi", @@ -1000,6 +1149,8 @@ function createInstallRollbackHarness( ...artifact, appPath, configPath, + elevationPlist, + elevationPlistContents, label, launchStateFile, sourceContents, @@ -1008,15 +1159,26 @@ function createInstallRollbackHarness( env: { ...artifact.env, TEST_DANGLING_ROLLBACK_DURING_MOVE: options.danglingRollbackDuringMove ? "1" : "0", + TEST_CORRUPT_ROLLBACK_PLIST_BACKUP_ON_SYNC: options.corruptRollbackPlistBackupOnSync + ? "1" + : "0", TEST_FAIL_CURRENT_RECEIPT_RESTORE_COPY: options.failCurrentReceiptRestoreCopy ? "1" : "0", TEST_FAIL_AFTER_RECEIPT_COMMIT_MOVE: options.failAfterReceiptCommitMove ? "1" : "0", + TEST_FAIL_ELEVATION_QUARANTINE_RENAME: options.failElevationQuarantineRename ? "1" : "0", TEST_FAIL_LSOF_INSPECTION: options.failLsofInspection ? "1" : "0", TEST_FAIL_PGREP_INSPECTION: options.failPgrepInspection ? "1" : "0", TEST_FAIL_RECOVERY_XATTR_READ: options.failRecoveryXattrRead ? "1" : "0", + TEST_FAIL_UNSAFE_ENTRY_IDENTITY: options.failUnsafeEntryIdentity ? "1" : "0", + TEST_FAIL_UNSAFE_ENTRY_MKTEMP: options.failUnsafeEntryMktemp ? "1" : "0", TEST_FINAL_CDHASH_MISMATCH: options.finalCDHashMismatch ? "1" : "0", TEST_FINAL_SIGNATURE_INVALID: options.finalSignatureInvalid ? "1" : "0", TEST_INSTALLED_APP_PATH: appPath, + TEST_ELEVATION_PLIST: elevationPlist, + TEST_STATE_DIR: stateDir, + TEST_UNRELATED_APP_PATH: path.join(tempRoot, "UnrelatedOpenClaw.app"), + TEST_UNSAFE_ENTRY_EVIDENCE: options.unsafeEntryEvidence ?? "", TEST_CUSTODY_SIGNAL: options.hupDuringCustody ? "HUP" : "TERM", + TEST_CURRENT_CUA_SIGNATURE_INVALID: options.currentCuaSignatureInvalid ? "1" : "0", TEST_LAUNCHD_BOOTSTRAP_FAILS: options.launchdBootstrapFails === false ? "0" : "1", TEST_LIVE_PID: String(process.pid), TEST_LAUNCH_STATE_FILE: launchStateFile, @@ -1037,6 +1199,7 @@ function createInstallRollbackHarness( TEST_RECREATE_APP_DURING_DAMAGED_CUSTODY: options.recreateAppDuringDamagedCustody ? "1" : "0", TEST_RECREATE_SOURCE_DURING_BOOTOUT: options.recreateSourceDuringBootout ? "1" : "0", TEST_RECREATE_SOURCE_ON_FAILURE: options.recreateSourceOnFailure ? "1" : "0", + TEST_RELOAD_ELEVATION_AFTER_QUARANTINE: options.reloadElevationAfterQuarantine ? "1" : "0", TEST_RENAME_HELPER_HASH_MARKER: path.join(tempRoot, "rename-helper-hash-marker"), TEST_REPLACE_DAMAGED_APP_DIRECTORY_BEFORE_CUSTODY: options.replaceDamagedAppDirectoryBeforeCustody ? "1" : "0", @@ -1048,6 +1211,9 @@ function createInstallRollbackHarness( options.replaceMigrationSourceSameContentBeforeInitialCustody ? "1" : "0", TEST_REMOVE_INSTALLED_EXECUTABLE_AFTER_READINESS: options.removeInstalledExecutableAfterReadiness ? "1" : "0", + TEST_REMOVE_CUA_DRIVER_AFTER_UNSAFE_ENTRY_MOVE: options.removeCuaDriverAfterUnsafeEntryMove + ? "1" + : "0", TEST_RESTART_APP_DURING_BOOTOUT: options.restartAppDuringBootout ? "1" : "0", TEST_ROLLBACK_NON_NATIVE_SIGNATURE_INVALID: options.rollbackNonNativeSignatureInvalid ? "1" @@ -1299,6 +1465,47 @@ describe("mac elevation host command contract", () => { }, ); + it.skipIf(process.platform !== "darwin")( + "rejects CUA-bearing elevation artifacts before verify or install", + () => { + const verification = createArtifactVerificationHarness(); + const rejectedVerify = runInstaller( + verification.installerPath, + [ + "verify", + "--archive", + verification.archivePath, + "--receipt", + verification.receiptPath, + ...receiptDigestArgs(verification.receiptPath), + ], + { ...verification.env, TEST_CUA_DRIVER_KIND: "file" }, + ); + expect(rejectedVerify.status).toBe(1); + expect(rejectedVerify.stderr).toContain("must not contain bundled CUA driver"); + + const installation = createInstallRollbackHarness(); + const rejectedInstall = runInstaller( + installation.installerPath, + [ + "install", + "--archive", + installation.archivePath, + "--receipt", + installation.receiptPath, + ...receiptDigestArgs(installation.receiptPath), + "--app", + installation.appPath, + "--migrate-launch-agent", + installation.sourcePlist, + ], + { ...installation.env, TEST_CUA_DRIVER_KIND: "symlink" }, + ); + expect(rejectedInstall.status).toBe(1); + expect(rejectedInstall.stderr).toContain("must not contain bundled CUA driver"); + }, + ); + it.skipIf(process.platform !== "darwin")( "plans canonical node conversion without reading or copying its token", () => { @@ -1643,6 +1850,428 @@ describe("mac elevation host command contract", () => { }, ); + for (const rollbackCuaDriverKind of ["file", "symlink"] as const) { + it.skipIf(process.platform !== "darwin")( + `preserves but never restarts a ${rollbackCuaDriverKind} CUA-bearing elevation rollback`, + () => { + const harness = createInstallRollbackHarness({ + existingElevationLoaded: true, + reloadElevationAfterQuarantine: true, + rollbackCuaDriverKind, + }); + const result = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--state-dir", + harness.stateDir, + "--config-path", + harness.configPath, + ], + harness.env, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("could not bootstrap elevation host"); + expect(result.stderr).toContain("Preserved previous elevation app with bundled CUA driver"); + expect(result.stderr).toContain( + "Quarantined replacement for unsafe previous elevation LaunchAgent", + ); + expect(result.stderr).toContain("automatic elevation-host rollback was incomplete"); + expect(readFileSync(harness.launchStateFile, "utf8").trim()).toBe("elevation-absent"); + const quarantinedApp = preservedCuaAppPath(harness); + expect(quarantinedApp).toBeDefined(); + const rollbackDriver = lstatSync( + path.join(quarantinedApp!, "Contents", "Resources", "cua-driver"), + ); + expect(rollbackDriver.isSymbolicLink()).toBe(rollbackCuaDriverKind === "symlink"); + expect(existsSync(harness.elevationPlist)).toBe(false); + expect( + readdirSync(path.dirname(harness.elevationPlist)).filter((name) => + name.startsWith("ai.openclaw.mac.elevation-host"), + ), + ).toEqual([]); + const previousPlist = readdirSync(harness.stateDir).find((name) => + name.startsWith("elevation-host.previous-plist."), + ); + expect(previousPlist).toBeDefined(); + expect(readFileSync(path.join(harness.stateDir, previousPlist!), "utf8")).toBe( + harness.elevationPlistContents, + ); + const quarantinedPlist = readdirSync(harness.stateDir).find((name) => + name.startsWith("elevation-host.quarantined-launch-agent."), + ); + expect(quarantinedPlist).toBeDefined(); + expect(readFileSync(path.join(harness.stateDir, quarantinedPlist!), "utf8")).toContain( + "--elevation-host", + ); + }, + ); + } + + it.skipIf(process.platform !== "darwin")( + "removes the discoverable elevation plist when unsafe rollback quarantine fails", + () => { + const harness = createInstallRollbackHarness({ + existingElevationLoaded: true, + failElevationQuarantineRename: true, + rollbackCuaDriverKind: "file", + }); + const result = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--state-dir", + harness.stateDir, + "--config-path", + harness.configPath, + ], + harness.env, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Removed unquarantinable replacement for unsafe previous elevation LaunchAgent", + ); + expect(readFileSync(harness.launchStateFile, "utf8").trim()).toBe("elevation-absent"); + expect(existsSync(harness.elevationPlist)).toBe(false); + const previousPlist = readdirSync(harness.stateDir).find((name) => + name.startsWith("elevation-host.previous-plist."), + ); + expect(previousPlist).toBeDefined(); + expect(readFileSync(path.join(harness.stateDir, previousPlist!), "utf8")).toBe( + harness.elevationPlistContents, + ); + }, + ); + + it.skipIf(process.platform !== "darwin")( + "quarantines a CUA-bearing elevation app when the recorded rollback destination is blocked", + () => { + const harness = createInstallRollbackHarness({ + danglingRollbackDuringMove: true, + existingElevationLoaded: true, + rollbackCuaDriverKind: "file", + }); + const result = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--state-dir", + harness.stateDir, + "--config-path", + harness.configPath, + ], + harness.env, + ); + + expect(result.status).toBe(1); + expect(existsSync(path.join(harness.appPath, "Contents", "Resources", "cua-driver"))).toBe( + false, + ); + expect(existsSync(harness.elevationPlist)).toBe(false); + expect(readFileSync(harness.launchStateFile, "utf8").trim()).toBe("elevation-absent"); + const quarantinedApp = quarantinedElevationAppPath(harness.stateDir); + expect(quarantinedApp).toBeDefined(); + expect(existsSync(path.join(quarantinedApp!, "Contents", "Resources", "cua-driver"))).toBe( + true, + ); + }, + ); + + for (const evidence of ["job", "plist", "plist-program", "receipt"] as const) { + it.skipIf(process.platform !== "darwin")( + `quarantines a symlinked CUA app from exact ${evidence} ownership evidence`, + () => { + const harness = createInstallRollbackHarness({ + rollbackCuaDriverKind: "file", + unsafeEntryEvidence: evidence, + }); + const result = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--migrate-launch-agent", + harness.sourcePlist, + ], + harness.env, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Quarantined CUA-bearing elevation app"); + expect(lstatSync(harness.appPath, { throwIfNoEntry: false })).toBeUndefined(); + expect(readFileSync(harness.launchStateFile, "utf8").trim()).toBe("elevation-absent"); + const quarantinedApp = quarantinedElevationAppPath(harness.stateDir); + expect(quarantinedApp).toBeDefined(); + expect(lstatSync(quarantinedApp!).isSymbolicLink()).toBe(true); + expect(existsSync(path.join(quarantinedApp!, "Contents", "Resources", "cua-driver"))).toBe( + true, + ); + }, + ); + } + + it.skipIf(process.platform !== "darwin")( + "keeps a symlink quarantined when its target changes after the move", + () => { + const harness = createInstallRollbackHarness({ + removeCuaDriverAfterUnsafeEntryMove: true, + rollbackCuaDriverKind: "file", + unsafeEntryEvidence: "job", + }); + const result = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--migrate-launch-agent", + harness.sourcePlist, + ], + harness.env, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Quarantined CUA-bearing elevation app"); + const quarantinedApp = quarantinedElevationAppPath(harness.stateDir); + expect(quarantinedApp).toBeDefined(); + expect(lstatSync(quarantinedApp!).isSymbolicLink()).toBe(true); + }, + ); + + for (const evidence of ["unrelated-plist", "unrelated-program", "unrelated-receipt"] as const) { + it.skipIf(process.platform !== "darwin")( + `preserves an app when only an ${evidence} remains`, + () => { + const harness = createInstallRollbackHarness({ + rollbackCuaDriverKind: "file", + unsafeEntryEvidence: evidence, + }); + const result = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--migrate-launch-agent", + harness.sourcePlist, + ], + harness.env, + ); + + expect(result.status).toBe(1); + expect(result.stderr).not.toContain("Quarantined CUA-bearing elevation app"); + expect(lstatSync(harness.appPath).isDirectory()).toBe(true); + expect(quarantinedElevationAppPath(harness.stateDir)).toBeUndefined(); + const stalePath = + evidence !== "unrelated-receipt" + ? harness.elevationPlist + : path.join(harness.stateDir, "elevation-host-install.json"); + expect(existsSync(stalePath)).toBe(true); + }, + ); + } + + for (const setupFailure of ["identity", "mktemp"] as const) { + it.skipIf(process.platform !== "darwin")( + `neutralizes launchd before unsafe app quarantine ${setupFailure} setup fails`, + () => { + const harness = createInstallRollbackHarness({ + danglingRollbackDuringMove: true, + existingElevationLoaded: true, + failUnsafeEntryIdentity: setupFailure === "identity", + failUnsafeEntryMktemp: setupFailure === "mktemp", + rollbackCuaDriverKind: "file", + }); + const result = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--state-dir", + harness.stateDir, + "--config-path", + harness.configPath, + ], + harness.env, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("automatic elevation-host rollback was incomplete"); + expect(existsSync(harness.elevationPlist)).toBe(false); + expect(readFileSync(harness.launchStateFile, "utf8").trim()).toBe("elevation-absent"); + expect(existsSync(path.join(harness.appPath, "Contents", "Resources", "cua-driver"))).toBe( + true, + ); + }, + ); + } + + it.skipIf(process.platform !== "darwin")( + "neutralizes unsafe rollback even when its plist evidence becomes corrupt", + () => { + const harness = createInstallRollbackHarness({ + corruptRollbackPlistBackupOnSync: true, + existingElevationLoaded: true, + rollbackCuaDriverKind: "file", + }); + const result = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--state-dir", + harness.stateDir, + "--config-path", + harness.configPath, + ], + harness.env, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("automatic elevation-host rollback was incomplete"); + expect(readFileSync(harness.launchStateFile, "utf8").trim()).toBe("elevation-absent"); + expect(existsSync(harness.appPath)).toBe(false); + expect(existsSync(harness.elevationPlist)).toBe(false); + expect( + readdirSync(harness.stateDir).some((name) => + name.startsWith("elevation-host.quarantined-launch-agent."), + ), + ).toBe(true); + expect( + existsSync(path.join(preservedCuaAppPath(harness)!, "Contents", "Resources", "cua-driver")), + ).toBe(true); + }, + ); + + it.skipIf(process.platform !== "darwin")( + "never restores a CUA-bearing current elevation job after explicit recovery fails", + () => { + const harness = createInstallRollbackHarness({ + currentCuaSignatureInvalid: true, + launchdBootstrapFails: false, + migrationRestoreBootstrapFails: true, + }); + const installed = runInstaller( + harness.installerPath, + [ + "install", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--migrate-launch-agent", + harness.sourcePlist, + ], + harness.env, + ); + expect(installed.status, installed.stderr).toBe(0); + const currentReceipt = readFileSync( + path.join(harness.stateDir, "elevation-host-install.json"), + "utf8", + ); + const resources = path.join(harness.appPath, "Contents", "Resources"); + mkdirSync(resources, { recursive: true }); + writeExecutable(path.join(resources, "cua-driver"), "#!/bin/sh\nexit 0\n"); + + const recovered = runInstaller( + harness.installerPath, + [ + "recover", + "--archive", + harness.archivePath, + "--receipt", + harness.receiptPath, + ...receiptDigestArgs(harness.receiptPath), + "--app", + harness.appPath, + "--state-dir", + harness.stateDir, + ], + harness.env, + ); + + expect(recovered.status).toBe(1); + expect(recovered.stderr).toContain("Preserved current elevation app with bundled CUA driver"); + expect(recovered.stderr).toContain( + "recovery failed and the current OpenClaw installation could not be restored completely", + ); + expect(readFileSync(harness.launchStateFile, "utf8").trim()).toBe("elevation-absent"); + expect(existsSync(harness.elevationPlist)).toBe(false); + expect(existsSync(path.join(harness.appPath, "Contents", "Resources", "cua-driver"))).toBe( + false, + ); + const preservedApp = preservedCuaAppPath(harness); + expect(preservedApp).toBeDefined(); + expect(existsSync(path.join(preservedApp!, "Contents", "Resources", "cua-driver"))).toBe( + true, + ); + expect(readFileSync(path.join(harness.stateDir, "elevation-host-install.json"), "utf8")).toBe( + currentReceipt, + ); + const preservedCurrentPlist = readdirSync(harness.stateDir).find((name) => + name.startsWith("elevation-host.recovery-current-plist."), + ); + expect(preservedCurrentPlist).toBeDefined(); + expect(readFileSync(path.join(harness.stateDir, preservedCurrentPlist!), "utf8")).toContain( + "--elevation-host", + ); + }, + ); + it.skipIf(process.platform !== "darwin")( "refuses to record an invalid existing app as rollback state", () => { diff --git a/test/scripts/package-mac-app.test.ts b/test/scripts/package-mac-app.test.ts index 07bc7ab4eae6..24f4e55cda35 100644 --- a/test/scripts/package-mac-app.test.ts +++ b/test/scripts/package-mac-app.test.ts @@ -1680,6 +1680,27 @@ describe("package-mac-app plist stamping", () => { ); }); + it("omits the CUA driver only from elevation-host packages", () => { + const packageScript = readFileSync(scriptPath, "utf8"); + const variantBlock = packageScript.slice( + packageScript.indexOf('SIGNING_VARIANT="${OPENCLAW_MAC_SIGNING_VARIANT:-standard}"'), + packageScript.indexOf("# OPENCLAW_SKIP_MLX_TTS"), + ); + const cuaBlock = packageScript.slice( + packageScript.indexOf('if [[ "$SIGNING_VARIANT" == "elevation-host" ]]'), + packageScript.indexOf('echo "📦 Copying CLI installer"'), + ); + + expect(variantBlock).toContain("standard | elevation-host"); + expect(variantBlock).toContain("Unknown OPENCLAW_MAC_SIGNING_VARIANT value"); + expect(cuaBlock).toContain("Omitting embedded CUA driver from elevation-host package"); + expect(cuaBlock).toContain("else"); + expect(cuaBlock).toContain("Staging embedded CUA driver"); + expect(cuaBlock).toContain( + '"$ROOT_DIR/scripts/stage-cua-driver-macos.sh" "$APP_ROOT/Contents/Resources/cua-driver"', + ); + }); + it("does not mask required Info.plist stamp failures", () => { const script = readFileSync(scriptPath, "utf8"); const stampBlock = script.slice(