Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import Darwin
import Foundation

/// Removes macOS file quarantine from a copied helper bundle tree.
///
/// Gatekeeper decides whether to show the "downloaded from the Internet"
/// dialog from the raw `com.apple.quarantine` extended attribute, so this type
/// probes and removes that attribute directly with `getxattr(2)` and
/// `removexattr(2)`, the same way Sparkle releases an installed update. It
/// never assigns `URLResourceValues.quarantineProperties`: that setter writes
/// a quarantine record rather than removing one, and what `nil` produces
/// depends on the OS release. macOS 26.4.1 stores an empty record
/// (`0200;<time>;;`) that still triggers the dialog, macOS 15.7.4 fails with
/// an I/O error on an entry that carries no record, and macOS 27.0 removes it
/// (https://github.com/manaflow-ai/cmux/issues/13803).
///
/// Symbolic links are neither followed nor modified, so a link inside the
/// bundle cannot reach an item outside it. Entries without the attribute are
/// left untouched.
///
/// ```swift
/// let report = try ComputerUseHelperQuarantineRelease().release(treeAt: copiedHelperURL)
/// guard report.failures.isEmpty else { /* log and keep the copy */ }
/// ```
struct ComputerUseHelperQuarantineRelease {
/// The extended attribute LaunchServices and Gatekeeper consult.
static let attributeName = "com.apple.quarantine"

/// One entry whose attribute could not be removed.
struct Failure: Equatable, Sendable {
/// The entry that still carries the attribute.
let url: URL
/// The `errno` reported by `removexattr(2)`.
let code: Int32
}

/// The outcome of one pass over a tree.
struct Report: Equatable, Sendable {
/// Entries whose attribute was removed, in traversal order.
var released: [URL] = []
/// Entries whose attribute could not be removed.
var failures: [Failure] = []
}

private enum EntryKind {
case directory
case symbolicLink
case other
}

private let fileManager: FileManager

/// Creates a release pass.
/// - Parameter fileManager: Lists directory contents during traversal.
init(fileManager: FileManager = .default) {
self.fileManager = fileManager
}

/// Returns every entry under `root`, including `root`, that carries the attribute.
/// - Throws: `CancellationError` when the surrounding task is cancelled, or
/// the error that stopped a directory listing.
func quarantinedEntries(treeAt root: URL) throws -> [URL] {
var entries: [URL] = []
try walk(root) { url in
if Self.carriesAttribute(url) {
entries.append(url)
}
}
return entries
}

/// Removes the attribute from every entry under `root` that carries it.
///
/// The pass continues after a failed removal so one bad entry does not
/// leave the rest of the tree quarantined; each failure is reported.
/// - Throws: `CancellationError` when the surrounding task is cancelled, or
/// the error that stopped a directory listing.
func release(treeAt root: URL) throws -> Report {
var report = Report()
try walk(root) { url in
guard Self.carriesAttribute(url) else { return }
if let code = Self.removeAttribute(url) {
report.failures.append(Failure(url: url, code: code))
} else {
report.released.append(url)
}
}
return report
}

/// Visits `url` and, for a directory, everything below it. Symbolic links
/// are skipped without being followed; an entry that vanished is skipped.
private func walk(_ url: URL, visit: (URL) throws -> Void) throws {
guard !Task.isCancelled else { throw CancellationError() }
guard let kind = Self.entryKind(url) else { return }
switch kind {
case .symbolicLink:
return
case .other:
try visit(url)
case .directory:
try visit(url)
// Child URLs are built from the caller's root so a report names
// entries in the caller's path space rather than a resolved one.
for name in try fileManager.contentsOfDirectory(atPath: url.path) {
try walk(url.appendingPathComponent(name), visit: visit)
}
}
}

private static func entryKind(_ url: URL) -> EntryKind? {
url.withUnsafeFileSystemRepresentation { path -> EntryKind? in
guard let path else { return nil }
var status = stat()
guard lstat(path, &status) == 0 else { return nil }
switch status.st_mode & mode_t(S_IFMT) {
case mode_t(S_IFLNK):
return .symbolicLink
case mode_t(S_IFDIR):
return .directory
default:
return .other
}
}
}

private static func carriesAttribute(_ url: URL) -> Bool {
url.withUnsafeFileSystemRepresentation { path in
guard let path else { return false }
return getxattr(path, attributeName, nil, 0, 0, XATTR_NOFOLLOW) >= 0
}
}

/// Returns nil once the attribute is gone, or the `errno` of the failed call.
private static func removeAttribute(_ url: URL) -> Int32? {
url.withUnsafeFileSystemRepresentation { path in
guard let path else { return EINVAL }
guard removexattr(path, attributeName, XATTR_NOFOLLOW) != 0 else { return nil }
return errno
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import Darwin
import Foundation
import Security
import CmuxFoundation
import os

nonisolated private let logger = Logger(subsystem: "com.cmuxterm.app", category: "ComputerUseRuntime")

/// The computer use direct screen capture verification exposed to the host application.
public enum ComputerUseDirectScreenCaptureVerification: Equatable, Sendable {
Expand Down Expand Up @@ -925,7 +928,13 @@ public final class ComputerUseRuntimeService {
guard let bundledHelperAppURL else { return nil }
let destination = paths.installedHelperAppURL
let currentCheckTask = Task.detached(priority: .userInitiated) {
Self.helperIsCurrent(nested: bundledHelperAppURL, destination: destination)
let isCurrent = Self.helperIsCurrent(nested: bundledHelperAppURL, destination: destination)
if isCurrent {
// A copy staged by an earlier build can still carry the empty
// record #13602 wrote; release it in place instead of restaging.
_ = try? Self.releaseCopiedHelperFromQuarantine(at: destination)
}
return isCurrent
}
let isCurrent = await withTaskCancellationHandler {
await currentCheckTask.value
Expand Down Expand Up @@ -1838,7 +1847,7 @@ public final class ComputerUseRuntimeService {
return paths
}

nonisolated private static func installHelper(
nonisolated static func installHelper(
nested: URL,
destination: URL,
directory: URL
Expand Down Expand Up @@ -1872,33 +1881,23 @@ public final class ComputerUseRuntimeService {
}
}

/// Strips `com.apple.quarantine` from a helper copy so LaunchServices
/// launches it without the first-open dialog (#13430, #13803). An entry
/// that cannot be released is logged and kept: a quarantined helper still
/// launches once approved, while a missing helper disables Computer Use.
@discardableResult
nonisolated static func releaseCopiedHelperFromQuarantine(
at url: URL,
fileManager: FileManager = .default
) throws {
guard !Task.isCancelled else { throw CancellationError() }
let values = try url.resourceValues(
forKeys: [.isDirectoryKey, .isSymbolicLinkKey]
)
guard values.isSymbolicLink != true else { return }

var quarantineValues = URLResourceValues()
quarantineValues.quarantineProperties = nil
var mutableURL = url
try mutableURL.setResourceValues(quarantineValues)

guard values.isDirectory == true else { return }
let children = try fileManager.contentsOfDirectory(
at: url,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: []
)
for child in children {
try releaseCopiedHelperFromQuarantine(
at: child,
fileManager: fileManager
) throws -> ComputerUseHelperQuarantineRelease.Report {
let report = try ComputerUseHelperQuarantineRelease(fileManager: fileManager)
.release(treeAt: url)
for failure in report.failures {
logger.error(
"Computer Use helper quarantine release failed for \(failure.url.lastPathComponent, privacy: .public) (errno \(failure.code))"
)
}
return report
}

nonisolated private static func makeStateAuthenticationKey() -> Data {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import Darwin
import Foundation
import Testing
@testable import CmuxComputerUse

struct ComputerUseHelperQuarantineReleaseTests {
@Test func quarantinedEntriesListsOnlyEntriesCarryingTheAttribute() throws {
let fixture = try HelperBundleFixture()
defer { fixture.remove() }
let record = TestQuarantineAttribute.webDownloadRecord()
try TestQuarantineAttribute.apply(record, to: fixture.bundle)
try TestQuarantineAttribute.apply(record, to: fixture.executable)

let entries = try ComputerUseHelperQuarantineRelease()
.quarantinedEntries(treeAt: fixture.bundle)

#expect(Set(entries.map(\.path)) == [fixture.bundle.path, fixture.executable.path])
}

@Test func releaseRemovesTheAttributeFromEveryEntryAndReportsEach() throws {
let fixture = try HelperBundleFixture()
defer { fixture.remove() }
let record = TestQuarantineAttribute.webDownloadRecord()
let entries = try fixture.bundleEntries()
for entry in entries {
try TestQuarantineAttribute.apply(record, to: entry)
}

let report = try ComputerUseHelperQuarantineRelease().release(treeAt: fixture.bundle)

#expect(report.failures.isEmpty)
#expect(Set(report.released.map(\.path)) == Set(entries.map(\.path)))
for entry in entries {
#expect(try TestQuarantineAttribute.record(at: entry) == nil, "\(entry.path)")
}
#expect(try ComputerUseHelperQuarantineRelease().quarantinedEntries(treeAt: fixture.bundle).isEmpty)
}

@Test func releaseLeavesACleanTreeUntouched() throws {
let fixture = try HelperBundleFixture()
defer { fixture.remove() }

let report = try ComputerUseHelperQuarantineRelease().release(treeAt: fixture.bundle)

#expect(report == ComputerUseHelperQuarantineRelease.Report())
for entry in try fixture.bundleEntries() {
#expect(try TestQuarantineAttribute.record(at: entry) == nil, "\(entry.path)")
}
}

@Test func releaseSkipsSymbolicLinksWithoutFollowingThem() throws {
let fixture = try HelperBundleFixture()
defer { fixture.remove() }
let fileManager = FileManager.default
let record = TestQuarantineAttribute.webDownloadRecord()
let outsideFile = fixture.root.appendingPathComponent("outside-file", isDirectory: false)
try Data("outside".utf8).write(to: outsideFile)
let outsideDirectory = fixture.root.appendingPathComponent("outside-dir", isDirectory: true)
try fileManager.createDirectory(at: outsideDirectory, withIntermediateDirectories: true)
let nestedOutsideFile = outsideDirectory.appendingPathComponent("nested", isDirectory: false)
try Data("nested".utf8).write(to: nestedOutsideFile)
let resources = fixture.hiddenMarker.deletingLastPathComponent()
let fileLink = resources.appendingPathComponent("file-link", isDirectory: false)
let directoryLink = resources.appendingPathComponent("dir-link", isDirectory: false)
try fileManager.createSymbolicLink(at: fileLink, withDestinationURL: outsideFile)
try fileManager.createSymbolicLink(at: directoryLink, withDestinationURL: outsideDirectory)
for url in [outsideFile, outsideDirectory, nestedOutsideFile, fixture.executable] {
try TestQuarantineAttribute.apply(record, to: url)
}

let report = try ComputerUseHelperQuarantineRelease().release(treeAt: fixture.bundle)

#expect(report.failures.isEmpty)
#expect(report.released.map(\.path) == [fixture.executable.path])
#expect(try TestQuarantineAttribute.record(at: fixture.executable) == nil)
for url in [outsideFile, outsideDirectory, nestedOutsideFile] {
#expect(try TestQuarantineAttribute.record(at: url) == record, "\(url.path)")
}
}

@Test func releaseContinuesPastAnEntryItCannotChangeAndReportsIt() throws {
let fixture = try HelperBundleFixture()
defer { fixture.remove() }
let fileManager = FileManager.default
let record = TestQuarantineAttribute.webDownloadRecord()
let entries = try fixture.bundleEntries()
for entry in entries {
try TestQuarantineAttribute.apply(record, to: entry)
}
try fileManager.setAttributes([.immutable: true], ofItemAtPath: fixture.infoPlist.path)
defer {
try? fileManager.setAttributes([.immutable: false], ofItemAtPath: fixture.infoPlist.path)
}

let report = try ComputerUseHelperQuarantineRelease().release(treeAt: fixture.bundle)

#expect(report.failures == [
ComputerUseHelperQuarantineRelease.Failure(url: fixture.infoPlist, code: EPERM)
])
#expect(
Set(report.released.map(\.path))
== Set(entries.map(\.path)).subtracting([fixture.infoPlist.path])
)
#expect(try TestQuarantineAttribute.record(at: fixture.infoPlist) == record)
#expect(try TestQuarantineAttribute.record(at: fixture.executable) == nil)
}

@Test func releaseStopsWhenTheTaskIsCancelled() async throws {
let fixture = try HelperBundleFixture()
defer { fixture.remove() }
let record = TestQuarantineAttribute.webDownloadRecord()
try TestQuarantineAttribute.apply(record, to: fixture.executable)

let bundle = fixture.bundle
let outcome = await Task {
withUnsafeCurrentTask { $0?.cancel() }
return Result {
try ComputerUseHelperQuarantineRelease().release(treeAt: bundle)
}
}.value

#expect(throws: CancellationError.self) { try outcome.get() }
#expect(try TestQuarantineAttribute.record(at: fixture.executable) == record)
}
}
Loading
Loading