fix(computer-use): resolve exact app bundle paths

This commit is contained in:
程序员阿江(Relakkes)
2026-09-02 08:46:50 +08:00
parent e1f7cc9d81
commit 1440f0acd8
3 changed files with 265 additions and 36 deletions
@@ -7,6 +7,40 @@ struct AppTargetCandidate: Sendable, Equatable {
let bundleURL: URL?
let localizedName: String?
let executableName: String?
let isMainApplicationProcess: Bool
init(
pid: pid_t,
bundleIdentifier: String,
bundleURL: URL?,
localizedName: String?,
executableName: String?,
isMainApplicationProcess: Bool? = nil
) {
self.pid = pid
self.bundleIdentifier = bundleIdentifier
self.bundleURL = bundleURL
self.localizedName = localizedName
self.executableName = executableName
self.isMainApplicationProcess = isMainApplicationProcess ?? Self.inferMainApplicationProcess(
bundleURL: bundleURL,
executableName: executableName
)
}
private static func inferMainApplicationProcess(
bundleURL: URL?,
executableName: String?
) -> Bool {
guard let bundleURL, let executableName else { return false }
let bundleName = bundleURL.deletingPathExtension().lastPathComponent
return executableName.caseInsensitiveCompare(bundleName) == .orderedSame
}
}
private enum RunningAppInstanceKey: Hashable {
case bundle(identifier: String, path: String)
case process(pid_t)
}
struct ResolvedAppTarget: Sendable, Equatable {
@@ -52,12 +86,17 @@ enum AppTargetResolver {
app.processIdentifier != ProcessInfo.processInfo.processIdentifier,
let bundleIdentifier = app.bundleIdentifier,
!bundleIdentifier.isEmpty else { return nil }
let bundleURL = app.bundleURL?.standardizedFileURL
let executableURL = app.executableURL?.standardizedFileURL
return AppTargetCandidate(
pid: app.processIdentifier,
bundleIdentifier: bundleIdentifier,
bundleURL: app.bundleURL?.standardizedFileURL,
bundleURL: bundleURL,
localizedName: app.localizedName,
executableName: app.executableURL?.deletingPathExtension().lastPathComponent
executableName: executableURL?.deletingPathExtension().lastPathComponent,
isMainApplicationProcess: bundleURL
.flatMap { Bundle(url: $0)?.executableURL?.standardizedFileURL }
.map { $0 == executableURL }
)
}
}
@@ -73,32 +112,34 @@ enum AppTargetResolver {
let normalizedPath = value.hasPrefix("/")
? URL(fileURLWithPath: value).standardizedFileURL.path
: nil
let name = URL(fileURLWithPath: value).deletingPathExtension().lastPathComponent
let matches = candidates.filter { candidate in
if candidate.bundleIdentifier.caseInsensitiveCompare(value) == .orderedSame {
return true
let matches: [AppTargetCandidate]
if let normalizedPath {
matches = candidates.filter {
$0.bundleURL?.standardizedFileURL.path == normalizedPath
}
if let normalizedPath,
candidate.bundleURL?.standardizedFileURL.path == normalizedPath {
return true
} else {
let name = URL(fileURLWithPath: value).deletingPathExtension().lastPathComponent
matches = candidates.filter { candidate in
if candidate.bundleIdentifier.caseInsensitiveCompare(value) == .orderedSame {
return true
}
return [
candidate.localizedName,
candidate.executableName,
candidate.bundleURL?.deletingPathExtension().lastPathComponent,
]
.compactMap { $0 }
.contains { $0.caseInsensitiveCompare(name) == .orderedSame }
}
return [
candidate.localizedName,
candidate.executableName,
candidate.bundleURL?.deletingPathExtension().lastPathComponent,
]
.compactMap { $0 }
.contains { $0.caseInsensitiveCompare(name) == .orderedSame }
}
guard matches.count == 1, let match = matches.first else {
if matches.count > 1 {
throw CUError(
"ambiguous_target",
"App identifier '\(raw)' matches multiple running instances; use a PID or full path"
)
}
guard !matches.isEmpty else {
throw CUError("target_not_running", "No running app matches '\(raw)'")
}
let match = try selectApplicationProcess(
from: matches,
identifier: raw,
fullPathWasProvided: normalizedPath != nil
)
return ResolvedAppTarget(
pid: match.pid,
bundleIdentifier: match.bundleIdentifier,
@@ -106,6 +147,39 @@ enum AppTargetResolver {
)
}
private nonisolated static func selectApplicationProcess(
from matches: [AppTargetCandidate],
identifier: String,
fullPathWasProvided: Bool
) throws -> AppTargetCandidate {
if matches.count == 1, let match = matches.first {
return match
}
let instances = Dictionary(grouping: matches) { candidate in
guard let path = candidate.bundleURL?.standardizedFileURL.path else {
return RunningAppInstanceKey.process(candidate.pid)
}
return RunningAppInstanceKey.bundle(
identifier: candidate.bundleIdentifier.lowercased(),
path: path
)
}
if instances.count == 1,
let processes = instances.values.first {
let mainProcesses = processes.filter(\.isMainApplicationProcess)
if mainProcesses.count == 1, let main = mainProcesses.first {
return main
}
}
let guidance = fullPathWasProvided ? "use a PID" : "use a PID or full path"
throw CUError(
"ambiguous_target",
"App identifier '\(identifier)' matches multiple running instances; \(guidance)"
)
}
nonisolated static func selector(payload: JSONValue) throws -> AppTargetSelector? {
if let rawPID = payload["pid"] {
guard case .int(let value) = rawPID,
@@ -223,21 +297,24 @@ enum AppTargetResolver {
let normalizedPath = value.hasPrefix("/")
? URL(fileURLWithPath: value).standardizedFileURL.path
: nil
let name = URL(fileURLWithPath: value)
.deletingPathExtension()
.lastPathComponent
let matches = candidates.filter { candidate in
if candidate.bundleIdentifier.caseInsensitiveCompare(value) == .orderedSame {
return true
let matches: [InstalledAppTarget]
if let normalizedPath {
matches = candidates.filter {
$0.bundleURL.standardizedFileURL.path == normalizedPath
}
if let normalizedPath,
candidate.bundleURL.standardizedFileURL.path == normalizedPath {
return true
} else {
let name = URL(fileURLWithPath: value)
.deletingPathExtension()
.lastPathComponent
matches = candidates.filter { candidate in
if candidate.bundleIdentifier.caseInsensitiveCompare(value) == .orderedSame {
return true
}
return [
candidate.displayName,
candidate.bundleURL.deletingPathExtension().lastPathComponent,
].contains { $0.caseInsensitiveCompare(name) == .orderedSame }
}
return [
candidate.displayName,
candidate.bundleURL.deletingPathExtension().lastPathComponent,
].contains { $0.caseInsensitiveCompare(name) == .orderedSame }
}
guard matches.count == 1, let match = matches.first else {
if matches.count > 1 {
@@ -26,6 +26,64 @@ final class AppTargetResolverTests: XCTestCase {
XCTAssertEqual(try AppTargetResolver.match(identifier: "/System/Applications/Calculator.app", candidates: [calculator]).pid, 42)
}
func testFullPathSelectsOnlyThatBundleWhenRunningCopiesShareAName() throws {
let installed = AppTargetCandidate(
pid: 100,
bundleIdentifier: "com.claude-code-haha.desktop",
bundleURL: URL(fileURLWithPath: "/Applications/Claude Code Haha.app"),
localizedName: "Claude Code Haha",
executableName: "Claude Code Haha"
)
let worktree = AppTargetCandidate(
pid: 200,
bundleIdentifier: "com.claude-code-haha.desktop",
bundleURL: URL(fileURLWithPath: "/Users/test/worktree/desktop/build-artifacts/macos-arm64/Claude Code Haha.app"),
localizedName: "Claude Code Haha",
executableName: "Claude Code Haha"
)
let result = try AppTargetResolver.match(
identifier: "/Users/test/worktree/desktop/build-artifacts/macos-arm64/Claude Code Haha.app",
candidates: [installed, worktree]
)
XCTAssertEqual(result.pid, 200)
XCTAssertEqual(result.bundleURL, worktree.bundleURL)
XCTAssertThrowsError(
try AppTargetResolver.match(
identifier: "Claude Code Haha",
candidates: [installed, worktree]
)
) {
XCTAssertEqual(($0 as? CUError)?.code, "ambiguous_target")
}
}
func testFullPathCollapsesHelperProcessesIntoTheirMainBundleInstance() throws {
let path = "/Users/test/worktree/desktop/build-artifacts/macos-arm64/Claude Code Haha.app"
let main = AppTargetCandidate(
pid: 200,
bundleIdentifier: "com.claude-code-haha.desktop",
bundleURL: URL(fileURLWithPath: path),
localizedName: "Claude Code Haha",
executableName: "Claude Code Haha"
)
let renderer = AppTargetCandidate(
pid: 201,
bundleIdentifier: main.bundleIdentifier,
bundleURL: main.bundleURL,
localizedName: "Claude Code Haha Helper (Renderer)",
executableName: "Claude Code Haha Helper (Renderer)"
)
let result = try AppTargetResolver.match(
identifier: path,
candidates: [renderer, main]
)
XCTAssertEqual(result.pid, main.pid)
}
func testAmbiguousNameFailsClosed() {
let duplicate = AppTargetCandidate(
pid: 43,
@@ -37,6 +95,18 @@ final class AppTargetResolverTests: XCTestCase {
XCTAssertThrowsError(try AppTargetResolver.match(identifier: "Calculator", candidates: [calculator, duplicate])) {
XCTAssertEqual(($0 as? CUError)?.code, "ambiguous_target")
}
XCTAssertThrowsError(
try AppTargetResolver.match(
identifier: calculator.bundleURL!.path,
candidates: [calculator, duplicate]
)
) {
XCTAssertEqual(($0 as? CUError)?.code, "ambiguous_target")
XCTAssertEqual(
($0 as? CUError)?.message,
"App identifier '/System/Applications/Calculator.app' matches multiple running instances; use a PID"
)
}
}
func testExplicitInvalidPIDFailsClosedInsteadOfFallingBack() {
@@ -77,6 +147,24 @@ final class AppTargetResolverTests: XCTestCase {
}
}
func testExplicitPIDSelectsOnlyThatProcessAcrossSameBundleCandidates() throws {
let sibling = AppTargetCandidate(
pid: 43,
bundleIdentifier: calculator.bundleIdentifier,
bundleURL: calculator.bundleURL,
localizedName: calculator.localizedName,
executableName: "Calculator Helper"
)
XCTAssertEqual(
try AppTargetResolver.resolve(
selector: .pid(sibling.pid),
candidates: [calculator, sibling]
)?.pid,
sibling.pid
)
}
func testSelectorPrecedenceIsPIDThenBundleIDThenApp() throws {
XCTAssertEqual(
try AppTargetResolver.selector(payload: .object([
@@ -160,6 +248,30 @@ final class AppTargetResolverTests: XCTestCase {
}
}
func testInstalledFullPathDoesNotAlsoMatchAnotherBundleWithTheSameName() throws {
let otherCopy = InstalledAppTarget(
bundleIdentifier: installedCalculator.bundleIdentifier,
displayName: installedCalculator.displayName,
bundleURL: URL(fileURLWithPath: "/Applications/Calculator.app")
)
XCTAssertEqual(
try AppTargetResolver.matchInstalled(
identifier: installedCalculator.bundleURL.path,
candidates: [otherCopy, installedCalculator]
),
installedCalculator
)
XCTAssertThrowsError(
try AppTargetResolver.matchInstalled(
identifier: installedCalculator.displayName,
candidates: [otherCopy, installedCalculator]
)
) {
XCTAssertEqual(($0 as? CUError)?.code, "ambiguous_target")
}
}
func testResolveWithoutLaunchingDoesNotTurnMissingPIDIntoInstalledApp() {
XCTAssertThrowsError(
try AppTargetResolver.resolveWithoutLaunching(
@@ -54,6 +54,46 @@ final class ResolvedTargetAuthorizationTests: XCTestCase {
}
}
func testWorktreePathResolutionStillReachesIntrinsicSelfControlDenial() throws {
let installed = AppTargetCandidate(
pid: 100,
bundleIdentifier: "com.claude-code-haha.desktop",
bundleURL: URL(fileURLWithPath: "/Applications/Claude Code Haha.app"),
localizedName: "Claude Code Haha",
executableName: "Claude Code Haha"
)
let worktree = AppTargetCandidate(
pid: 200,
bundleIdentifier: installed.bundleIdentifier,
bundleURL: URL(fileURLWithPath: "/Users/test/worktree/desktop/build-artifacts/macos-arm64/Claude Code Haha.app"),
localizedName: installed.localizedName,
executableName: installed.executableName
)
let resolved = try AppTargetResolver.match(
identifier: worktree.bundleURL!.path,
candidates: [installed, worktree]
)
let identity = AXTreeProcessIdentity(
bundleID: worktree.bundleIdentifier,
executablePath: worktree.bundleURL!.appendingPathComponent("Contents/MacOS/Claude Code Haha").path,
launchTime: 300
)
XCTAssertEqual(resolved.pid, worktree.pid)
XCTAssertThrowsError(
try ResolvedTargetAuthorization.authorize(
resolved: resolved,
currentIdentity: identity
)
) {
XCTAssertEqual(($0 as? CUError)?.code, "app_denied")
XCTAssertEqual(
($0 as? CUError)?.message,
"Computer Use is not allowed to use the app 'com.claude-code-haha.desktop' for safety reasons."
)
}
}
func testOmittedFrontmostAndLaunchedTargetsUseSameActualBundlePolicy() {
// Both paths ultimately produce this same resolved target shape. The
// authorizer intentionally has no selector-specific bypass.