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
Expand Up @@ -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)

Expand All @@ -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):
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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/<uuid>/d/<App>.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<String>
) -> 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: "/")
}
}
110 changes: 110 additions & 0 deletions Tests/RepoPromptTests/MCP/CLIPathInstallerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
)
}
}
Loading