diff --git a/Sources/RepoPrompt/Infrastructure/Process/CLI/CLIPathInstaller.swift b/Sources/RepoPrompt/Infrastructure/Process/CLI/CLIPathInstaller.swift index 721a9a514..27b496676 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/CLI/CLIPathInstaller.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/CLI/CLIPathInstaller.swift @@ -201,7 +201,8 @@ enum CLIPathInstaller { case noBundledCLI case directoryMissing case permissionDenied - case pathExistsNotOurs + case pathExistsNotOurs(path: String) + case userSymlinkSetupFailed(path: String) case scriptFailed(String) case unknown(Error) @@ -213,8 +214,10 @@ enum CLIPathInstaller { "\(installDirectory) does not exist. Create it first with: sudo mkdir -p \(installDirectory)" case .permissionDenied: "Permission denied. Administrator access is required." - case .pathExistsNotOurs: - "A file already exists at \(installPath) that wasn't created by RepoPrompt" + case let .pathExistsNotOurs(path): + "A file already exists at \(path) that wasn't created by RepoPrompt" + case let .userSymlinkSetupFailed(path): + "Could not set up the CLI link at \(path). Check permissions for its parent directory and try again." case let .scriptFailed(message): "Installation failed: \(message)" case let .unknown(error): @@ -223,6 +226,21 @@ enum CLIPathInstaller { } } + /// Distinguishes an unmanaged occupant (a real conflict) from other + /// user-space symlink setup failures (e.g. directory creation or atomic + /// swap), so `install()` reports an accurate message instead of always + /// claiming a conflicting file exists. + static func userSymlinkInstallError(userLink: String, bundledPath: String) -> InstallError { + if case .unmanaged = ManagedCLIPathPolicy.classifySymlink( + at: userLink, + desiredDestination: bundledPath, + managedDestinations: ManagedCLIPathPolicy.managedDestinations(currentBundledCLIPath: bundledPath) + ) { + return .pathExistsNotOurs(path: userLink) + } + return .userSymlinkSetupFailed(path: userLink) + } + /// Install the CLI to PATH using AppleScript for admin privileges @MainActor static func install() async throws { @@ -236,7 +254,7 @@ enum CLIPathInstaller { case .directoryMissing: throw InstallError.directoryMissing case .installedByOther: - throw InstallError.pathExistsNotOurs + throw InstallError.pathExistsNotOurs(path: installPath) case .installed: logger.info("CLI already installed at \(installPath)") return @@ -249,7 +267,7 @@ enum CLIPathInstaller { userSymlinkURL: URL(fileURLWithPath: userLink), bundledCLIURL: URL(fileURLWithPath: bundledPath) ) else { - throw InstallError.pathExistsNotOurs + throw Self.userSymlinkInstallError(userLink: userLink, bundledPath: bundledPath) } let managedDestinations = ManagedCLIPathPolicy.managedDestinations( @@ -291,7 +309,7 @@ enum CLIPathInstaller { logger.info("CLI not installed, nothing to uninstall") return case .installedByOther: - throw InstallError.pathExistsNotOurs + throw InstallError.pathExistsNotOurs(path: installPath) case .installed, .installedButStale: break } @@ -389,7 +407,7 @@ enum CLIPathInstaller { case .directoryMissing: throw InstallError.directoryMissing case .installedByOther: - throw InstallError.pathExistsNotOurs + throw InstallError.pathExistsNotOurs(path: claudeRPInstallPath) case .installed: // Wrapper is already installed, but still refresh the MCP config file logger.info("claude-rp already installed at \(claudeRPInstallPath)") @@ -455,7 +473,7 @@ enum CLIPathInstaller { logger.info("claude-rp not installed, nothing to uninstall") return case .installedByOther: - throw InstallError.pathExistsNotOurs + throw InstallError.pathExistsNotOurs(path: claudeRPInstallPath) case .installed, .installedButOutdated: break } diff --git a/Sources/RepoPrompt/Infrastructure/Process/CLI/ManagedCLIPathPolicy.swift b/Sources/RepoPrompt/Infrastructure/Process/CLI/ManagedCLIPathPolicy.swift index 5dbba33ba..7949eb2c5 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/CLI/ManagedCLIPathPolicy.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/CLI/ManagedCLIPathPolicy.swift @@ -40,7 +40,10 @@ enum ManagedCLIPathPolicy { let destination = resolvedDestination(rawDestination, linkPath: path) let desired = standardized(desiredDestination) let allowlist = Set(managedDestinations.map(standardized)) - guard destination == desired || allowlist.contains(destination) else { + guard destination == desired + || allowlist.contains(destination) + || isTranslocatedManagedDestination(destination, allowlist: allowlist) + else { return .unmanaged } if destination == desired, fileManager.isExecutableFile(atPath: destination) { @@ -127,4 +130,35 @@ enum ManagedCLIPathPolicy { private static func standardized(_ path: String) -> String { URL(fileURLWithPath: (path as NSString).expandingTildeInPath).standardizedFileURL.path } + + /// App Translocation relocates a quarantined app bundle to a randomized, + /// read-only mount such as + /// `/private/var/folders/.../AppTranslocation//d/.app/...`. + /// A user-space CLI link created during a translocated launch therefore + /// points at a path that is neither the desired destination nor on the + /// static allowlist, and that path usually vanishes once the app is moved + /// to a stable location. Treat such a link as a managed (stale) entry when + /// its app-bundle-relative suffix matches a known managed destination, so it + /// can be repaired instead of being rejected as unmanaged. + private static func isTranslocatedManagedDestination( + _ destination: String, + allowlist: Set + ) -> Bool { + let components = (destination as NSString).pathComponents + guard components.contains("AppTranslocation"), + let suffix = appBundleRelativeSuffix(destination) + else { return false } + return allowlist.contains { appBundleRelativeSuffix($0) == suffix } + } + + /// Returns the portion of a path from its last `*.app` component to the end + /// (e.g. `RepoPrompt CE.app/Contents/MacOS/repoprompt-mcp`), or nil when the + /// path has no app-bundle component. + private static func appBundleRelativeSuffix(_ path: String) -> String? { + let components = (path as NSString).pathComponents + guard let appIndex = components.lastIndex(where: { $0.hasSuffix(".app") }) else { + return nil + } + return components[appIndex...].joined(separator: "/") + } } diff --git a/Tests/RepoPromptTests/MCP/CLIPathInstallerTests.swift b/Tests/RepoPromptTests/MCP/CLIPathInstallerTests.swift index eb428a13f..0343ac005 100644 --- a/Tests/RepoPromptTests/MCP/CLIPathInstallerTests.swift +++ b/Tests/RepoPromptTests/MCP/CLIPathInstallerTests.swift @@ -124,6 +124,62 @@ final class CLIPathInstallerTests: XCTestCase { XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: linkURL.path), bundledCLI.path) } + func testClassifierReclaimsStaleTranslocatedManagedTarget() throws { + // Managed destination lives inside an app bundle. + let allowlist: Set = [bundledCLI.path] + + // Simulate a user-space link created while the app ran translocated: + // it targets the same app bundle CLI, but under a now-removed App + // Translocation mount (dangling; the path does not exist). + let staleTranslocated = "/private/var/folders/xx/T/AppTranslocation/" + + UUID().uuidString + + "/d/RepoPrompt.app/Contents/MacOS/repoprompt-mcp" + try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: staleTranslocated) + XCTAssertEqual( + ManagedCLIPathPolicy.classifySymlink( + at: linkURL.path, + desiredDestination: bundledCLI.path, + managedDestinations: allowlist + ), + .managedStale(destination: staleTranslocated) + ) + + // A translocated path whose app-bundle suffix does NOT match any + // managed destination must stay unmanaged. + try FileManager.default.removeItem(at: linkURL) + let foreignTranslocated = "/private/var/folders/xx/T/AppTranslocation/" + + UUID().uuidString + + "/d/Evil.app/Contents/MacOS/repoprompt-mcp" + try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: foreignTranslocated) + XCTAssertEqual( + ManagedCLIPathPolicy.classifySymlink( + at: linkURL.path, + desiredDestination: bundledCLI.path, + managedDestinations: allowlist + ), + .unmanaged + ) + } + + func testUserSpaceManagerRepairsStaleTranslocatedSymlink() throws { + // Reproduces the reported bug: the user-space link points at a CLI + // inside a now-removed App Translocation mount (dangling). Because the + // app has since moved to a stable location, the link must be reclaimed + // and repaired rather than rejected as unmanaged. + let staleTranslocated = "/private/var/folders/xx/T/AppTranslocation/" + + UUID().uuidString + + "/d/RepoPrompt.app/Contents/MacOS/repoprompt-mcp" + try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: staleTranslocated) + + XCTAssertTrue( + CLISymlinkManagerUserSpace.ensureLocalSymlink( + userSymlinkURL: linkURL, + bundledCLIURL: bundledCLI + ) + ) + XCTAssertEqual(try FileManager.default.destinationOfSymbolicLink(atPath: linkURL.path), bundledCLI.path) + } + func testUserSpaceManagerRollsBackRacedUnmanagedReplacement() throws { let recognizedOldPath = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/RepoPrompt CE/DebugApps/RepoPrompt.app/Contents/MacOS/repoprompt-mcp") @@ -235,4 +291,58 @@ final class CLIPathInstallerTests: XCTestCase { XCTAssertFalse(ManagedCLIPathPolicy.isManagedWrapper("#!/bin/bash\necho '\(ManagedCLIPathPolicy.currentClaudeWrapperMarker)'\n")) XCTAssertFalse(ManagedCLIPathPolicy.isManagedWrapper("#!/bin/bash\necho unrelated\n")) } + + func testPathExistsNotOursErrorReportsProvidedPath() { + // The message must name the actual offending path, not a hardcoded one. + let path = "/Users/example/Library/Application Support/RepoPrompt CE/repoprompt_ce_cli" + let description = CLIPathInstaller.InstallError.pathExistsNotOurs(path: path).errorDescription + XCTAssertEqual(description, "A file already exists at \(path) that wasn't created by RepoPrompt") + } + + func testUserSymlinkInstallErrorDistinguishesConflictFromSetupFailure() throws { + // A genuinely foreign occupant is reported as a real conflict. + try Data("foreign".utf8).write(to: linkURL) + let conflict = CLIPathInstaller.userSymlinkInstallError(userLink: linkURL.path, bundledPath: bundledCLI.path) + guard case let .pathExistsNotOurs(path) = conflict else { + return XCTFail("expected pathExistsNotOurs, got \(conflict)") + } + XCTAssertEqual(path, linkURL.path) + + // No occupant (e.g. a directory-creation or atomic-swap failure) is + // reported as a setup failure, not a spurious conflict. + try FileManager.default.removeItem(at: linkURL) + let setupFailure = CLIPathInstaller.userSymlinkInstallError(userLink: linkURL.path, bundledPath: bundledCLI.path) + guard case let .userSymlinkSetupFailed(path) = setupFailure else { + return XCTFail("expected userSymlinkSetupFailed, got \(setupFailure)") + } + XCTAssertEqual(path, linkURL.path) + } + + func testUserSymlinkSetupFailedErrorReportsProvidedPath() { + // The setup-failure message must name the path and avoid claiming a conflict. + let path = "/Users/example/Library/Application Support/RepoPrompt CE/repoprompt_ce_cli" + let description = CLIPathInstaller.InstallError.userSymlinkSetupFailed(path: path).errorDescription + XCTAssertEqual( + description, + "Could not set up the CLI link at \(path). Check permissions for its parent directory and try again." + ) + } + + func testClassifierRejectsTranslocatedPathWithoutAppBundleComponent() throws { + // A path under /AppTranslocation/ but with no *.app component has no + // app-bundle suffix to match, so it must remain unmanaged. + let allowlist: Set = [bundledCLI.path] + let noAppBundle = "/private/var/folders/xx/T/AppTranslocation/" + + UUID().uuidString + + "/d/repoprompt-mcp" + try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: noAppBundle) + XCTAssertEqual( + ManagedCLIPathPolicy.classifySymlink( + at: linkURL.path, + desiredDestination: bundledCLI.path, + managedDestinations: allowlist + ), + .unmanaged + ) + } }