From 1440f0acd8a2ef90eb06b2f721af0e38f4c99d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 2 Sep 2026 08:46:50 +0800 Subject: [PATCH] fix(computer-use): resolve exact app bundle paths --- .../Sources/cu-helper/AppTargetResolver.swift | 149 +++++++++++++----- .../AppTargetResolverTests.swift | 112 +++++++++++++ .../ResolvedTargetAuthorizationTests.swift | 40 +++++ 3 files changed, 265 insertions(+), 36 deletions(-) diff --git a/native/cu-helper/Sources/cu-helper/AppTargetResolver.swift b/native/cu-helper/Sources/cu-helper/AppTargetResolver.swift index 454f89a0..f26e564a 100644 --- a/native/cu-helper/Sources/cu-helper/AppTargetResolver.swift +++ b/native/cu-helper/Sources/cu-helper/AppTargetResolver.swift @@ -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 { diff --git a/native/cu-helper/Tests/CuHelperTests/AppTargetResolverTests.swift b/native/cu-helper/Tests/CuHelperTests/AppTargetResolverTests.swift index 56dfbf28..0b667466 100644 --- a/native/cu-helper/Tests/CuHelperTests/AppTargetResolverTests.swift +++ b/native/cu-helper/Tests/CuHelperTests/AppTargetResolverTests.swift @@ -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( diff --git a/native/cu-helper/Tests/CuHelperTests/ResolvedTargetAuthorizationTests.swift b/native/cu-helper/Tests/CuHelperTests/ResolvedTargetAuthorizationTests.swift index 5eeba801..a8a18581 100644 --- a/native/cu-helper/Tests/CuHelperTests/ResolvedTargetAuthorizationTests.swift +++ b/native/cu-helper/Tests/CuHelperTests/ResolvedTargetAuthorizationTests.swift @@ -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.