gitbutlerapp/gitbutler · error

CLI destination must be absolute

Error message

CLI destination must be absolute

What it means

install_cli_link_escalated (macOS) creates a symlink to the CLI binary, possibly via an escalated privilege helper. Before doing anything it ensures the destination path is absolute; a relative destination cannot be safely resolved by the elevated helper, so it aborts with 'CLI destination must be absolute'.

Solutions

  1. Canonicalize the destination (std::fs::canonicalize or join onto '/') before calling the install API
  2. Pass a fully-qualified path like /usr/local/bin/but
  3. If building custom install tooling, resolve relative paths against the intended base directory first

Example fix

// before
install_cli_link_escalated(&cli, Path::new("bin/but"), policy)
// after
let dest = std::fs::canonicalize("bin/but").unwrap_or_else(|_| base.join("bin/but"));
install_cli_link_escalated(&cli, &dest, policy)
Defensive patterns

Strategy: validation

Validate before calling

if !destination.is_absolute() {
    return Err(anyhow!("destination must be absolute"));
}
// or: let destination = std::fs::canonicalize(destination_parent)?.join(file_name);

Prevention

When it happens

Trigger: Calling the CLI install v2 flow with a destination path that is relative (e.g. 'bin/but' instead of '/usr/local/bin/but'), reaching the escalated link installer.

Common situations: Passing a user-supplied destination from config or a shell variable without canonicalizing it; running under a helper process whose CWD differs from the caller's.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


AI-assisted analysis of gitbutlerapp/gitbutler@58e5313667 (2026-09-18). Data as JSON: /api/errors/73e0c9adc85d535b. Report an issue: GitHub.

Appendix: source

Thrown at crates/but-action/src/cli.rs:70

        Err(InstallError::InstallationRequiresElevatedPrivileges(err)) => match mode {
            InstallMode::AllowPrivilegeElevation => {
                install_cli_link_escalated(cli_path, destination, symlink_policy)
            }
            InstallMode::CurrentUserOnly => Err(err)
                .context("Privilege escalation required but not allowed under user install mode"),
        },
        Err(InstallError::Other(err)) => Err(err),
    }
}

/// Installs the CLI link with escalated privileges.
#[cfg(any(target_os = "macos", all(test, unix)))]
fn install_cli_link_escalated(
    cli_path: &std::path::Path,
    destination: &std::path::Path,
    symlink_policy: ExistingSymlinkPolicy,
) -> anyhow::Result<()> {
    anyhow::ensure!(
        destination.is_absolute(),
        "CLI destination must be absolute"
    );
    let directory = destination
        .parent()
        .context("CLI destination has no parent")?;
    // osascript accepts text arguments, so reject non-UTF-8 instead of changing paths.
    let source = cli_path.to_str().context("CLI path is not valid UTF-8")?;
    let target = destination
        .to_str()
        .context("CLI destination is not valid UTF-8")?;
    let directory = directory
        .to_str()
        .context("CLI destination directory is not valid UTF-8")?;
    // The script's destination check and `ln` are not atomic: if a directory appears at the
    // destination between them, `ln` can create a link inside it. Verification below rejects that
    // result, but the stray link remains. Avoiding this race would require an elevated helper
    // calling symlink(2) directly instead of `ln`. That seems a bit overkill for now.

View on GitHub (pinned to 58e5313667)