mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(computer-use): resolve exact app bundle paths
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user