diff --git a/libs/code/scripts/install.sh b/libs/code/scripts/install.sh index 4aa330c997..9982818426 100755 --- a/libs/code/scripts/install.sh +++ b/libs/code/scripts/install.sh @@ -407,15 +407,25 @@ if [ "$(id -u)" -eq 0 ]; then log_warn "Could not determine non-root target user. Files under ${HOME} may remain owned by root." log_warn " After install, run: sudo chown -R YOUR_USERNAME ~/.local" fix_owner() { :; } + fix_file_owner() { :; } else fix_owner() { if ! chown -R "$TARGET_USER" "$@" 2>&1; then log_warn "Could not fix ownership of $* for user ${TARGET_USER}." fi } + fix_file_owner() { + local path + for path in "$@"; do + if { [ -e "$path" ] || [ -L "$path" ]; } && ! chown -h "$TARGET_USER" "$path" 2>&1; then + log_warn "Could not fix ownership of $path for user ${TARGET_USER}." + fi + done + } fi else fix_owner() { :; } + fix_file_owner() { :; } fi # --------------------------------------------------------------------------- @@ -895,13 +905,31 @@ is_snap_curl() { # working downloader is available. download_to_stdout() { local url="$1" ua="${2:-deepagents-code-install}" - if command -v curl >/dev/null 2>&1 && ! is_snap_curl; then - curl -fsSL -H "User-Agent: ${ua}" "$url" 2>/dev/null || return $? - elif command -v wget >/dev/null 2>&1; then - wget -qO- --header="User-Agent: ${ua}" "$url" 2>/dev/null || return $? - else - return 1 - fi + local attempt=1 body="" download_rc=1 + while [ "$attempt" -le 3 ]; do + if command -v curl >/dev/null 2>&1 && ! is_snap_curl; then + if body=$(curl -fsSL -H "User-Agent: ${ua}" "$url" 2>/dev/null); then + printf '%s' "$body" + return 0 + else + download_rc=$? + fi + elif command -v wget >/dev/null 2>&1; then + if body=$(wget -qO- --header="User-Agent: ${ua}" "$url" 2>/dev/null); then + printf '%s' "$body" + return 0 + else + download_rc=$? + fi + else + return 1 + fi + if [ "$attempt" -lt 3 ]; then + sleep "$attempt" + fi + attempt=$((attempt + 1)) + done + return "$download_rc" } install_uv() { @@ -934,10 +962,35 @@ install_uv() { # it: on failure it holds the actionable cause (curl: (6) Could not resolve # host, SSL errors, HTTP status), which the failure branch below surfaces. # curl -sS and wget -nv stay quiet on success, so this adds no noise then. + local attempt if command -v curl >/dev/null 2>&1 && ! is_snap_curl; then - curl -fsSL https://astral.sh/uv/install.sh -o "$uv_script" 2>"$uv_install_out" || uv_install_rc=$? + uv_install_rc=1 + for attempt in 1 2 3; do + : >"$uv_install_out" + if curl -fsSL https://astral.sh/uv/install.sh -o "$uv_script" 2>"$uv_install_out"; then + uv_install_rc=0 + break + else + uv_install_rc=$? + fi + if [ "$attempt" -lt 3 ]; then + sleep "$attempt" + fi + done elif command -v wget >/dev/null 2>&1; then - wget -nv -O "$uv_script" https://astral.sh/uv/install.sh 2>"$uv_install_out" || uv_install_rc=$? + uv_install_rc=1 + for attempt in 1 2 3; do + : >"$uv_install_out" + if wget -nv -O "$uv_script" https://astral.sh/uv/install.sh 2>"$uv_install_out"; then + uv_install_rc=0 + break + else + uv_install_rc=$? + fi + if [ "$attempt" -lt 3 ]; then + sleep "$attempt" + fi + done elif is_snap_curl; then rm -f "$uv_install_out" "$uv_script" log_error "curl is installed as a snap and cannot download files due to sandbox permissions." @@ -1039,15 +1092,50 @@ if ! resolve_uv_bin; then exit 1 fi acquire_install_lock + UV_BIN_DIR_PREEXISTED=false + if [ -d "${HOME}/.local/bin" ]; then + UV_BIN_DIR_PREEXISTED=true + fi log_info "uv not found — installing..." install_uv - fix_owner "${HOME}/.local/bin" # root installs: restore user ownership + if [ "$UV_BIN_DIR_PREEXISTED" = false ]; then + fix_file_owner "${HOME}/.local/bin" + fi + fix_file_owner "${HOME}/.local/bin/uv" "${HOME}/.local/bin/uvx" "${HOME}/.local/bin/env" if ! resolve_uv_bin; then log_error "uv not found after installation. Restart your shell or add ~/.local/bin to PATH." exit 1 fi fi +resolve_tool_bin_dir() { + local dir="" + if dir=$("$UV_BIN" tool dir --bin 2>/dev/null) && [ -n "$dir" ]; then + : + elif [ -n "${XDG_BIN_HOME:-}" ]; then + dir="$XDG_BIN_HOME" + elif [ -n "${XDG_DATA_HOME:-}" ]; then + dir="${XDG_DATA_HOME}/../bin" + else + dir="${HOME}/.local/bin" + fi + case "$dir" in + /*) ;; + *) dir="$(pwd -P)/${dir}" ;; + esac + printf '%s\n' "$dir" +} + +TOOL_BIN_DIR="$(resolve_tool_bin_dir)" +TOOL_BIN_DIR_DISPLAY="$TOOL_BIN_DIR" +case "$TOOL_BIN_DIR" in + "$HOME"/*) TOOL_BIN_DIR_DISPLAY="~${TOOL_BIN_DIR#"$HOME"}" ;; +esac +TOOL_BIN_DIR_PREEXISTED=false +if [ -d "$TOOL_BIN_DIR" ]; then + TOOL_BIN_DIR_PREEXISTED=true +fi + # --------------------------------------------------------------------------- # Latest-version lookup # --------------------------------------------------------------------------- @@ -1081,15 +1169,30 @@ PACKAGE="deepagents-code${EXTRAS}${VERSION_SPEC}" # Capture pre-install version (if any) for messaging PRE_VERSION="" -for candidate in dcode deepagents-code; do - if command -v "$candidate" >/dev/null 2>&1; then - PRE_VERSION=$("$candidate" -v 2>/dev/null | head -1 | awk '{print $NF}') || PRE_VERSION="" - break - elif [ -x "${HOME}/.local/bin/${candidate}" ]; then - PRE_VERSION=$("${HOME}/.local/bin/${candidate}" -v 2>/dev/null | head -1 | awk '{print $NF}') || PRE_VERSION="" - break +PRE_INSTALL_IS_TOOL=false +PRE_INSTALL_ON_PATH=false +if [ "$(id -u)" -ne 0 ]; then + for candidate in dcode deepagents-code; do + if [ -x "${TOOL_BIN_DIR}/${candidate}" ]; then + PRE_VERSION=$("${TOOL_BIN_DIR}/${candidate}" -v 2>/dev/null | head -1 | awk '{print $NF}') || PRE_VERSION="" + PRE_INSTALL_IS_TOOL=true + original=$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true) + if [ -n "$original" ] && \ + { [ "$original" = "${TOOL_BIN_DIR}/${candidate}" ] || [ "$original" -ef "${TOOL_BIN_DIR}/${candidate}" ]; }; then + PRE_INSTALL_ON_PATH=true + fi + break + fi + done + if [ "$PRE_INSTALL_IS_TOOL" = false ]; then + for candidate in dcode deepagents-code; do + if command -v "$candidate" >/dev/null 2>&1; then + PRE_VERSION=$("$candidate" -v 2>/dev/null | head -1 | awk '{print $NF}') || PRE_VERSION="" + break + fi + done fi -done +fi # Detect editable installs (uv tool install -e ) so we can tell the user # why the environment will be rebuilt instead of upgraded in place. @@ -1144,9 +1247,13 @@ elif [ -n "$PRE_VERSION" ] && [ -z "$VERSION" ] && [ -z "$PRERELEASE_REQUESTED" else log_info "Updating deepagents-code ${PRE_VERSION} → ${LATEST_VERSION} with requested options..." fi - elif [ "$LATEST_VERSION" = "$PRE_VERSION" ]; then + elif [ "$LATEST_VERSION" = "$PRE_VERSION" ] && [ "$PRE_INSTALL_ON_PATH" = true ]; then log_success "Already up to date!" exit 0 + elif [ "$LATEST_VERSION" = "$PRE_VERSION" ] && [ "$PRE_INSTALL_IS_TOOL" = true ]; then + log_info "deepagents-code ${PRE_VERSION} is current but is not selected on PATH — repairing its install." + elif [ "$LATEST_VERSION" = "$PRE_VERSION" ]; then + log_info "deepagents-code ${PRE_VERSION} is current but is outside uv's configured tool bin — installing it there." elif [ "$ASSUME_YES" = "1" ]; then log_info "Updating deepagents-code ${PRE_VERSION} → ${LATEST_VERSION}..." elif can_prompt; then @@ -1189,7 +1296,10 @@ fi # Using a tempfile (vs. process substitution) ensures we see uv's full exit # status, don't race the warning past later log lines, and can re-scan the # raw output for (4) after the awk pass above has already reformatted it. -uv_stderr=$(mktemp 2>/dev/null) || uv_stderr="/tmp/deepagents-install.$$.err" +uv_stderr=$(mktemp 2>/dev/null) || { + log_error "mktemp is required to create a secure temp file." + exit 1 +} register_temp "$uv_stderr" uv_rc=0 UV_REPORTED_PACKAGE_CHANGES=false @@ -1324,7 +1434,17 @@ if [ "$uv_rc" -ne 0 ]; then log_error "Common fixes: check your network, try a different Python version (DEEPAGENTS_CODE_PYTHON=3.12), or install manually." exit "$uv_rc" fi -fix_owner "${HOME}/.local/bin" "${HOME}/.local/share/uv" # uv binaries + tool data +if path_is_under_home "$TOOL_BIN_DIR"; then + if [ "$TOOL_BIN_DIR_PREEXISTED" = false ]; then + fix_file_owner "$TOOL_BIN_DIR" + fi + fix_file_owner "${TOOL_BIN_DIR}/dcode" "${TOOL_BIN_DIR}/deepagents-code" +fi +if [ -n "$UV_TOOL_DIR" ] && path_is_under_home "${UV_TOOL_DIR}/deepagents-code"; then + fix_owner "${UV_TOOL_DIR}/deepagents-code" +elif [ -d "${HOME}/.local/share/uv" ]; then + fix_owner "${HOME}/.local/share/uv" +fi if [ "$OS" = "macos" ] && [ -d "${HOME}/Library/Caches/uv" ]; then fix_owner "${HOME}/Library/Caches/uv" elif [ -d "${HOME}/.cache/uv" ]; then @@ -1337,10 +1457,9 @@ fix_install_log_owner # --------------------------------------------------------------------------- # PATH setup — make dcode immediately findable in a new shell # --------------------------------------------------------------------------- -# After `uv tool install`, dcode lands in ~/.local/bin. If that directory is -# already in the user's PATH (via ~/.local/bin/env or a shell profile), dcode -# just works after a shell restart. If it isn't, the user is stuck with a -# successful install but no callable binary. +# After `uv tool install`, dcode lands in uv's configured tool bin directory. +# If that directory is not already in PATH, expose the installed binary through +# one of the user's conventional bin directories. # # Strategy (adapted from Amp's installer, https://ampcode.com/install.sh): # 1. If a common bin dir (~/.local/bin, ~/bin, ~/.bin) is already in PATH, @@ -1349,20 +1468,24 @@ fix_install_log_owner # ~/.local/bin to the user's shell profile (.zshrc, .bashrc, # .bash_profile, or config.fish). Prompt interactively before writing; # auto-add in non-interactive mode (CI, cron, piped install). -# 3. Skip the whole thing if the binary is already on PATH or uv's env file -# exists (uv's installer already handles PATH setup in that case). +# 3. Skip the whole thing if the binary was already on PATH or uv's env file +# exposes a binary installed under ~/.local/bin. -# Check if a directory is in PATH. -dir_in_path() { +# Check if a directory was in PATH before the installer sourced any env files. +dir_in_original_path() { local check_dir="$1" [ -d "$check_dir" ] || return 1 check_dir=$(cd "$check_dir" 2>/dev/null && pwd) || return 1 - case ":${PATH:-}:" in + case ":${ORIGINAL_PATH:-}:" in *":$check_dir:"*) return 0 ;; *) return 1 ;; esac } +paths_are_same_file() { + [ "$1" = "$2" ] || [ "$1" -ef "$2" ] +} + # Try to symlink the dcode binary into a directory already in PATH. Tries # ~/.local/bin, ~/bin, and ~/.bin in order. Returns 0 on success. try_symlink_in_path() { @@ -1371,18 +1494,21 @@ try_symlink_in_path() { local preferred_dirs=("$HOME/.local/bin" "$HOME/bin" "$HOME/.bin") local dir symlink_path for dir in "${preferred_dirs[@]}"; do - if dir_in_path "$dir"; then + if dir_in_original_path "$dir"; then mkdir -p "$dir" 2>/dev/null || continue symlink_path="$dir/$binary_name" - if [ "$binary_path" = "$symlink_path" ]; then + if paths_are_same_file "$binary_path" "$symlink_path"; then return 0 fi + if [ -e "$symlink_path" ] && [ ! -L "$symlink_path" ]; then + continue + fi # Remove existing symlink if it points elsewhere or is stale if [ -L "$symlink_path" ]; then rm -f "$symlink_path" fi - if ln -sf "$binary_path" "$symlink_path" 2>/dev/null; then - fix_owner "$symlink_path" 2>/dev/null || true + if ln -s "$binary_path" "$symlink_path" 2>/dev/null; then + fix_file_owner "$symlink_path" 2>/dev/null || true return 0 fi fi @@ -1530,17 +1656,24 @@ rewrite_managed_path_block() { # Returns: 0 = PATH is fixed for the current shell (symlink in an on-PATH dir), # 1 = failure (a specific warning was already printed), # 2 = no changes needed, but the current shell still must be reloaded -# or sourced before dcode will resolve. +# or sourced before dcode will resolve, +# 3 = root install to a custom bin; PATH changes are left to MDM policy. ensure_path_setup() { local binary_name="$1" local binary_path="$2" - # uv's env file already handles PATH setup for new shells — no profile - # change needed. But the current shell still lacks ~/.local/bin on PATH, so - # return 2 to let the caller emit a reload/source hint. - if [ -f "$HOME/.local/bin/env" ]; then + # uv's env file only exposes binaries that are actually under ~/.local/bin. + # A custom uv tool bin still needs a symlink or its own PATH entry. + local binary_dir="${binary_path%/*}" + if [ -f "$HOME/.local/bin/env" ] && \ + paths_are_same_file "$binary_dir" "$HOME/.local/bin"; then return 2 fi + if [ "$(id -u)" -eq 0 ] && ! paths_are_same_file "$binary_dir" "$HOME/.local/bin"; then + log_warn "${binary_name} installed to ${TOOL_BIN_DIR_DISPLAY}, which is not on the target user's PATH." + log_warn " Add that directory through the user's shell configuration or MDM policy." + return 3 + fi # Step 1: try symlinking into a dir already in PATH (no profile change). if try_symlink_in_path "$binary_name" "$binary_path"; then @@ -1551,21 +1684,31 @@ ensure_path_setup() { fi # Step 2: create ~/.local/bin, symlink there, then add to shell profile. + local local_bin_preexisted=false + if [ -d "$HOME/.local/bin" ]; then + local_bin_preexisted=true + fi mkdir -p "$HOME/.local/bin" 2>/dev/null || { log_warn "Could not create ~/.local/bin." return 1 } - fix_owner "$HOME/.local/bin" + if [ "$local_bin_preexisted" = false ]; then + fix_file_owner "$HOME/.local/bin" + fi local symlink_path="$HOME/.local/bin/$binary_name" - if [ "$binary_path" != "$symlink_path" ]; then + if ! paths_are_same_file "$binary_path" "$symlink_path"; then + if [ -e "$symlink_path" ] && [ ! -L "$symlink_path" ]; then + log_warn "Refusing to replace existing file at ${symlink_path}." + return 1 + fi if [ -L "$symlink_path" ]; then rm -f "$symlink_path" fi - if ! ln -sf "$binary_path" "$symlink_path" 2>/dev/null; then + if ! ln -s "$binary_path" "$symlink_path" 2>/dev/null; then log_warn "Could not create symlink at ${symlink_path}." return 1 fi - fix_owner "$symlink_path" + fix_file_owner "$symlink_path" fi # Step 3: detect shell and add ~/.local/bin to profile if needed. @@ -1656,7 +1799,7 @@ classify_shadowing_command() { detect_shadowing_install() { local candidate expected original manager for candidate in dcode deepagents-code; do - expected="${HOME}/.local/bin/${candidate}" + expected="${TOOL_BIN_DIR}/${candidate}" [ -x "$expected" ] || continue original=$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true) [ -n "$original" ] || continue @@ -1677,16 +1820,15 @@ detect_shadowing_install() { DCODE_BIN="" DCODE_NAME="" # Tracks whether the binary would have resolved via the user's original PATH, -# not the installer-mutated PATH. A fresh `uv tool install` drops the binary in -# ~/.local/bin, and this script may have sourced ~/.local/bin/env earlier to -# find uv; the parent shell still won't have dcode on PATH until it is -# restarted or the env file is sourced. +# not the installer-mutated PATH. DCODE_ON_PATH=false for candidate in dcode deepagents-code; do - if [ -x "${HOME}/.local/bin/${candidate}" ]; then - DCODE_BIN="${HOME}/.local/bin/${candidate}" + if [ -x "${TOOL_BIN_DIR}/${candidate}" ]; then + DCODE_BIN="${TOOL_BIN_DIR}/${candidate}" DCODE_NAME="$candidate" - if [ "$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true)" = "$DCODE_BIN" ]; then + original=$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true) + if [ -n "$original" ] && \ + { [ "$original" = "$DCODE_BIN" ] || [ "$original" -ef "$DCODE_BIN" ]; }; then DCODE_ON_PATH=true fi break @@ -1697,7 +1839,9 @@ if [ -z "$DCODE_BIN" ]; then if resolved=$(command -v "$candidate" 2>/dev/null) && [ -n "$resolved" ]; then DCODE_BIN="$resolved" DCODE_NAME="$candidate" - if [ "$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true)" = "$DCODE_BIN" ]; then + original=$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true) + if [ -n "$original" ] && \ + { [ "$original" = "$DCODE_BIN" ] || [ "$original" -ef "$DCODE_BIN" ]; }; then DCODE_ON_PATH=true fi break @@ -1771,7 +1915,7 @@ elif [ -n "$DCODE_BIN" ]; then log_warn " ${VERIFY_OUTPUT}" log_warn "The installation may be broken. Try running: ${DCODE_NAME} -v" else - log_warn "dcode (or deepagents-code) command not found in PATH. Restart your shell or run:" + log_warn "dcode (or deepagents-code) command not found in ${TOOL_BIN_DIR_DISPLAY} or PATH. Restart your shell or run:" log_warn " source ~/.zshrc # (or ~/.bashrc)" fi @@ -1784,7 +1928,7 @@ fi if [ "$VERIFY_OK" = true ] && [ "$DCODE_ON_PATH" = false ] && [ -n "$DCODE_BIN" ]; then path_setup_rc=0 ensure_path_setup "$DCODE_NAME" "$DCODE_BIN" || path_setup_rc=$? - if [ "$path_setup_rc" -ne 0 ]; then + if [ "$path_setup_rc" -ne 0 ] && [ "$path_setup_rc" -ne 3 ]; then # rc=1: ensure_path_setup printed a specific warning; add the fallback. # rc=2: no profile change needed, but the current shell still lacks # ~/.local/bin on PATH — emit the same reload/source hint. @@ -1917,16 +2061,20 @@ if [ "$SKIP_OPTIONAL" != "1" ]; then else # Quiet path: capture setup output and surface it only on failure, so a # broken install stays debuggable without noise in the common case. - ripgrep_setup_out=$(mktemp 2>/dev/null) || ripgrep_setup_out="/tmp/deepagents-ripgrep-setup.$$.out" - register_temp "$ripgrep_setup_out" - if "$DCODE_BIN" tools install >"$ripgrep_setup_out" 2>&1; then - fix_owner "${HOME}/.deepagents/bin" + if ripgrep_setup_out=$(mktemp 2>/dev/null); then + register_temp "$ripgrep_setup_out" + if "$DCODE_BIN" tools install >"$ripgrep_setup_out" 2>&1; then + fix_owner "${HOME}/.deepagents/bin" + else + echo "" + cat "$ripgrep_setup_out" >&2 2>/dev/null || true + ripgrep_managed_failed + fi + rm -f "$ripgrep_setup_out" else - echo "" - cat "$ripgrep_setup_out" >&2 2>/dev/null || true + log_warn "Could not create a secure temp file; skipping managed ripgrep setup." ripgrep_managed_failed fi - rm -f "$ripgrep_setup_out" fi elif command -v rg >/dev/null 2>&1; then if [ "$VERBOSE" = "1" ]; then diff --git a/libs/code/tests/unit_tests/test_install_script.py b/libs/code/tests/unit_tests/test_install_script.py index d7be05d88c..e072b453b4 100644 --- a/libs/code/tests/unit_tests/test_install_script.py +++ b/libs/code/tests/unit_tests/test_install_script.py @@ -32,7 +32,9 @@ def _write_fake_tools( installed_version: str | None = "0.0.1", latest_version: str | None = None, curl_fails: bool = False, + curl_failures_before_success: int = 0, dcode_verify_fails: bool = False, + mktemp_fails: bool = False, ) -> tuple[Path, Path, Path]: """Stage fake `uv`, `curl`, and (optionally) `dcode` binaries on `PATH`. @@ -53,19 +55,29 @@ def _write_fake_tools( # Raw f-string: the embedded bash must keep `\n` as the two literal # characters (an f-string would otherwise turn `\n` into a newline). `{{ }}` # still escape to literal braces; the `{...!r}` slots interpolate paths. + default_tool_bin = bin_dir if installed_version is not None else home / ".local/bin" uv = bin_dir / "uv" uv.write_text( rf"""#!/usr/bin/env bash set -euo pipefail +default_tool_bin={str(default_tool_bin)!r} if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "dir" ]; then - printf '%s\n' {str(tools)!r} + if [ "${{3:-}}" = "--bin" ]; then + if [ "${{FAKE_UV_TOOL_DIR_BIN_UNSUPPORTED:-}}" = "1" ]; then + exit 2 + fi + printf '%s\n' "${{FAKE_UV_TOOL_BIN_DIR:-$default_tool_bin}}" + else + printf '%s\n' {str(tools)!r} + fi exit 0 fi if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "install" ]; then printf '%s\n' "$@" > {str(tmp_path / "uv-args.txt")!r} if [ "${{FAKE_UV_CREATE_LOCAL_DCODE:-}}" = "1" ]; then - mkdir -p "$HOME/.local/bin" - cat > "$HOME/.local/bin/dcode" <<'DCODE' + tool_bin="${{FAKE_UV_TOOL_BIN_DIR:-$default_tool_bin}}" + mkdir -p "$tool_bin" + cat > "$tool_bin/dcode" <<'DCODE' #!/usr/bin/env bash if [ "${{1:-}}" = "-v" ]; then printf 'deepagents-code %s\n' "${{FAKE_LOCAL_DCODE_VERSION:-0.2.0}}" @@ -73,7 +85,7 @@ def _write_fake_tools( fi exit 0 DCODE - chmod +x "$HOME/.local/bin/dcode" + chmod +x "$tool_bin/dcode" fi if [ -n "${{FAKE_UV_INSTALL_STDERR:-}}" ]; then printf '%s\n' "$FAKE_UV_INSTALL_STDERR" >&2 @@ -90,11 +102,37 @@ def _write_fake_tools( curl = bin_dir / "curl" if curl_fails or latest_version is None: curl.write_text("#!/usr/bin/env bash\nexit 1\n") + elif curl_failures_before_success: + payload = f'{{"info":{{"version":"{latest_version}"}}}}' + attempts = tmp_path / "curl-attempts.txt" + curl.write_text( + f"""#!/usr/bin/env bash +count=0 +if [ -f {str(attempts)!r} ]; then + read -r count < {str(attempts)!r} +fi +count=$((count + 1)) +printf '%s\n' "$count" > {str(attempts)!r} +if [ "$count" -le {curl_failures_before_success} ]; then + exit 7 +fi +printf '%s' '{payload}' +""" + ) else: payload = f'{{"info":{{"version":"{latest_version}"}}}}' curl.write_text(f"#!/usr/bin/env bash\nprintf '%s' '{payload}'\n") _make_executable(curl) + sleep = bin_dir / "sleep" + sleep.write_text("#!/usr/bin/env bash\nexit 0\n") + _make_executable(sleep) + + if mktemp_fails: + mktemp = bin_dir / "mktemp" + mktemp.write_text("#!/usr/bin/env bash\nexit 1\n") + _make_executable(mktemp) + if installed_version is not None: dcode = bin_dir / "dcode" tools_log = tmp_path / "dcode-tools.txt" @@ -124,14 +162,18 @@ def _env( installed_version: str | None = "0.0.1", latest_version: str | None = None, curl_fails: bool = False, + curl_failures_before_success: int = 0, dcode_verify_fails: bool = False, + mktemp_fails: bool = False, ) -> dict[str, str]: bin_dir, home, uv = _write_fake_tools( tmp_path, installed_version=installed_version, latest_version=latest_version, curl_fails=curl_fails, + curl_failures_before_success=curl_failures_before_success, dcode_verify_fails=dcode_verify_fails, + mktemp_fails=mktemp_fails, ) return { **os.environ, @@ -151,7 +193,9 @@ def _invoke( installed_version: str | None = "0.0.1", latest_version: str | None = None, curl_fails: bool = False, + curl_failures_before_success: int = 0, dcode_verify_fails: bool = False, + mktemp_fails: bool = False, ) -> tuple[subprocess.CompletedProcess[str], Path]: """Run `install.sh` non-interactively with the fake tools on `PATH`. @@ -166,7 +210,9 @@ def _invoke( installed_version=installed_version, latest_version=latest_version, curl_fails=curl_fails, + curl_failures_before_success=curl_failures_before_success, dcode_verify_fails=dcode_verify_fails, + mktemp_fails=mktemp_fails, ) proc = subprocess.run( ["bash", str(SCRIPT)], @@ -635,6 +681,42 @@ def test_install_script_unreachable_pypi_falls_back_to_upgrade(tmp_path: Path) - assert args[-1] == "deepagents-code" +def test_install_script_retries_transient_pypi_failure(tmp_path: Path) -> None: + """Two transient metadata failures are retried before updating.""" + proc, args_path = _invoke( + tmp_path, + {}, + installed_version="0.1.0", + latest_version="0.2.0", + curl_failures_before_success=2, + ) + + assert proc.returncode == 0 + assert (tmp_path / "curl-attempts.txt").read_text().strip() == "3" + assert "Could not determine the latest version" not in proc.stderr + assert args_path.read_text().splitlines()[:3] == ["tool", "install", "-U"] + + +def test_install_script_requires_secure_temp_file_for_uv_output( + tmp_path: Path, +) -> None: + """The main install fails closed instead of using a predictable `/tmp` file.""" + proc, args_path = _invoke( + tmp_path, + {}, + installed_version="0.1.0", + latest_version="0.2.0", + mktemp_fails=True, + ) + + assert proc.returncode != 0 + assert "mktemp is required to create a secure temp file" in proc.stderr + assert not args_path.exists() + script = SCRIPT.read_text(encoding="utf-8") + assert "/tmp/deepagents-install.$$" not in script + assert "/tmp/deepagents-ripgrep-setup.$$" not in script + + def test_install_script_interactive_decline_keeps_current(tmp_path: Path) -> None: """Answering 'n' to the update prompt keeps the current version (no uv).""" code, output, args_path = _invoke_interactive( @@ -1841,6 +1923,7 @@ def _run_install_uv( mktemp_fails: bool = False, no_shebang: bool = False, download_fails: bool = False, + download_failures_before_success: int = 0, use_wget: bool = False, ) -> subprocess.CompletedProcess[str]: """Run the real `install_uv` from `install.sh` against a fake uv installer. @@ -1875,6 +1958,19 @@ def _run_install_uv( write_body = ( "printf 'DOWNLOADER_ERROR: could not resolve host\\n' >&2\nexit 7\n" ) + elif download_failures_before_success: + attempts = tmp_path / "uv-download-attempts.txt" + write_body = ( + "count=0\n" + f"if [ -f {str(attempts)!r} ]; then read -r count < {str(attempts)!r}; fi\n" + "count=$((count + 1))\n" + f"printf '%s\\n' \"$count\" > {str(attempts)!r}\n" + f'if [ "$count" -le {download_failures_before_success} ]; then\n' + " printf 'DOWNLOADER_ERROR: transient failure\\n' >&2\n" + " exit 7\n" + "fi\n" + f"printf '%s\\n' {installer} >\"${{out:-/dev/stdout}}\"\n" + ) else: write_body = f"printf '%s\\n' {installer} >\"${{out:-/dev/stdout}}\"\n" downloader = bin_dir / downloader_name @@ -1889,6 +1985,9 @@ def _run_install_uv( "done\n" + write_body ) _make_executable(downloader) + sleep = bin_dir / "sleep" + sleep.write_text("#!/usr/bin/env bash\nexit 0\n") + _make_executable(sleep) if mktemp_fails: mktemp = bin_dir / "mktemp" mktemp.write_text("#!/usr/bin/env bash\nexit 1\n") @@ -1996,6 +2095,22 @@ def test_install_uv_surfaces_download_failure(tmp_path: Path) -> None: assert "UV_INSTALLER_NOISE" not in proc.stdout +@pytest.mark.parametrize("use_wget", [False, True]) +def test_install_uv_retries_transient_download_failure( + tmp_path: Path, *, use_wget: bool +) -> None: + """The uv bootstrap retries two transient failures before succeeding.""" + proc = _run_install_uv( + tmp_path, + verbose=False, + download_failures_before_success=2, + use_wget=use_wget, + ) + + assert proc.returncode == 0, proc.stderr + assert (tmp_path / "uv-download-attempts.txt").read_text().strip() == "3" + + def test_install_uv_downloads_via_wget(tmp_path: Path) -> None: """The wget branch downloads to `-O ` and the script then runs it. @@ -2173,7 +2288,10 @@ def test_install_script_linux_skips_clt_check(tmp_path: Path) -> None: def _invoke_with_local_uv_not_on_path( - tmp_path: Path, *, env_file_content: str | None = None + tmp_path: Path, + *, + env_file_content: str | None = None, + extra_env: dict[str, str] | None = None, ) -> tuple[subprocess.CompletedProcess[str], Path]: """Run with uv present only in ~/.local/bin, absent from PATH.""" bin_dir, home, uv = _write_fake_tools( @@ -2200,6 +2318,7 @@ def _invoke_with_local_uv_not_on_path( "XDG_CACHE_HOME": str(home / ".cache"), "PATH": f"{bin_dir}{os.pathsep}{path_without_uv}", "DEEPAGENTS_CODE_SKIP_OPTIONAL": "1", + **(extra_env or {}), } proc = subprocess.run( ["bash", str(SCRIPT)], @@ -2236,6 +2355,33 @@ def test_install_script_sources_uv_env_file_defensively(tmp_path: Path) -> None: assert uv_args.read_text().splitlines()[:3] == ["tool", "install", "-U"] +def test_install_script_custom_bin_from_sourced_uv_persists_path( + tmp_path: Path, +) -> None: + """Sourcing uv's env cannot hide that its custom tool bin needs PATH setup.""" + tool_bin = tmp_path / "home/custom-bin" + proc, uv_args = _invoke_with_local_uv_not_on_path( + tmp_path, + env_file_content='export PATH="$HOME/.local/bin:$PATH"\n', + extra_env={ + "FAKE_UV_CREATE_LOCAL_DCODE": "1", + "FAKE_UV_TOOL_BIN_DIR": str(tool_bin), + "SHELL": "/bin/zsh", + }, + ) + + assert proc.returncode == 0, proc.stderr + assert uv_args.exists() + installed = tool_bin / "dcode" + exposed = tmp_path / "home/.local/bin/dcode" + assert installed.is_file() + assert exposed.is_symlink() + assert exposed.resolve() == installed.resolve() + profile = tmp_path / "home/.zshrc" + assert 'export PATH="$HOME/.local/bin:$PATH"' in profile.read_text() + assert "Added ~/.local/bin to PATH" in proc.stdout + + def test_install_script_rejects_invalid_uv_bin_without_installing( tmp_path: Path, ) -> None: @@ -2258,6 +2404,161 @@ def test_install_script_rejects_invalid_uv_bin_without_installing( ) +def test_install_script_honors_uv_tool_bin_dir(tmp_path: Path) -> None: + """A custom uv tool bin is found, verified, and exposed on `PATH`.""" + tool_bin = tmp_path / "home" / "custom-bin" + extra_env = { + "UV_TOOL_BIN_DIR": str(tool_bin), + "FAKE_UV_TOOL_BIN_DIR": str(tool_bin), + "FAKE_UV_CREATE_LOCAL_DCODE": "1", + "PATH": f"{tmp_path / 'bin'}{os.pathsep}{_path_without_dcode()}", + "SHELL": "/bin/zsh", + } + + proc, uv_args = _invoke(tmp_path, extra_env, installed_version=None) + + assert proc.returncode == 0, proc.stderr + assert uv_args.exists() + installed = tool_bin / "dcode" + exposed = tmp_path / "home/.local/bin/dcode" + assert installed.is_file() + assert exposed.is_symlink() + assert exposed.resolve() == installed.resolve() + assert "deepagents-code 0.2.0 installed" in proc.stdout + assert "command not found in PATH" not in proc.stderr + + +def test_install_script_old_uv_ignores_unsupported_tool_bin_override( + tmp_path: Path, +) -> None: + """An old uv falls back to its legacy bin instead of a newer-only override.""" + custom_bin = tmp_path / "home" / "custom-bin" + legacy_bin = tmp_path / "home/.local/bin" + proc, uv_args = _invoke( + tmp_path, + { + "UV_TOOL_BIN_DIR": str(custom_bin), + "XDG_BIN_HOME": "", + "XDG_DATA_HOME": "", + "FAKE_UV_TOOL_BIN_DIR": str(legacy_bin), + "FAKE_UV_TOOL_DIR_BIN_UNSUPPORTED": "1", + "FAKE_UV_CREATE_LOCAL_DCODE": "1", + "PATH": f"{tmp_path / 'bin'}{os.pathsep}{_path_without_dcode()}", + "SHELL": "/bin/zsh", + }, + installed_version=None, + ) + + assert proc.returncode == 0, proc.stderr + assert uv_args.exists() + assert (legacy_bin / "dcode").is_file() + assert not (legacy_bin / "dcode").is_symlink() + assert not custom_bin.exists() + + +def test_install_script_does_not_replace_tool_bin_path_alias_with_symlink( + tmp_path: Path, +) -> None: + """Equivalent uv bin spellings cannot turn `dcode` into a symlink loop.""" + home = tmp_path / "home" + alias_bin = home / ".local/share/../bin" + proc, _ = _invoke( + tmp_path, + { + "FAKE_UV_TOOL_BIN_DIR": str(alias_bin), + "FAKE_UV_CREATE_LOCAL_DCODE": "1", + "PATH": f"{tmp_path / 'bin'}{os.pathsep}{_path_without_dcode()}", + "SHELL": "/bin/zsh", + }, + installed_version=None, + ) + + installed = home / ".local/bin/dcode" + assert proc.returncode == 0, proc.stderr + assert installed.is_file() + assert not installed.is_symlink() + assert "deepagents-code 0.2.0 installed" in proc.stdout + + +def test_install_script_root_custom_bin_leaves_path_to_mdm(tmp_path: Path) -> None: + """A root custom-bin install does not write through user-controlled PATH files.""" + home = tmp_path / "home" + tool_bin = home / "custom-bin" + tool_bin.mkdir(parents=True) + dcode = tool_bin / "dcode" + dcode.write_text("#!/usr/bin/env bash\nexit 0\n") + _make_executable(dcode) + harness = tmp_path / "root_path_setup.sh" + harness.write_text( + f"HOME={str(home)!r}\n" + f"TOOL_BIN_DIR_DISPLAY={str(tool_bin)!r}\n" + "VERBOSE=0\n" + "id() { printf '0\\n'; }\n" + "log_warn() { printf '%s\\n' \"$*\" >&2; }\n" + f"{_extract_shell_function('paths_are_same_file')}\n" + f"{_extract_shell_function('ensure_path_setup')}\n" + "set +e\n" + f"ensure_path_setup dcode {str(dcode)!r}\n" + "rc=$?\n" + "printf '%s\\n' \"$rc\"\n", + encoding="utf-8", + ) + + proc = subprocess.run( + ["bash", str(harness)], + check=False, + capture_output=True, + text=True, + ) + + assert proc.returncode == 0 + assert proc.stdout.strip() == "3" + assert "MDM policy" in proc.stderr + assert not (home / ".local").exists() + assert not (home / ".zshrc").exists() + + +def test_install_script_root_does_not_execute_existing_dcode_before_install( + tmp_path: Path, +) -> None: + """A root install does not run a user-controlled pre-install executable.""" + env = _env( + tmp_path, + {"FAKE_UV_CREATE_LOCAL_DCODE": "1", "SUDO_USER": "target"}, + installed_version="0.1.0", + latest_version="0.2.0", + ) + bin_dir = tmp_path / "bin" + marker = tmp_path / "pre-install-dcode-ran" + dcode = bin_dir / "dcode" + dcode.write_text( + f"#!/usr/bin/env bash\nprintf 'ran\\n' > {str(marker)!r}\nexit 0\n" + ) + _make_executable(dcode) + for name, body in { + "id": "printf '0\\n'\n", + "uname": "printf 'Linux\\n'\n", + "chown": "exit 0\n", + }.items(): + tool = bin_dir / name + tool.write_text(f"#!/usr/bin/env bash\n{body}") + _make_executable(tool) + + proc = subprocess.run( + ["bash", str(SCRIPT)], + env=env, + check=False, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + + assert proc.returncode == 0, proc.stderr + assert (tmp_path / "uv-args.txt").exists() + assert not marker.exists() + + def _invoke_with_local_dcode_not_on_path( tmp_path: Path, *, create_env_file: bool = False ) -> subprocess.CompletedProcess[str]: @@ -2467,6 +2768,7 @@ def test_install_script_warns_when_original_path_shadows_uv_tool( proc, _ = _invoke( tmp_path, { + "FAKE_UV_TOOL_BIN_DIR": str(tmp_path / "home/.local/bin"), "FAKE_UV_CREATE_LOCAL_DCODE": "1", "FAKE_LOCAL_DCODE_VERSION": "0.2.0", }, @@ -2480,6 +2782,58 @@ def test_install_script_warns_when_original_path_shadows_uv_tool( assert "PATH order may run that binary instead of the uv tool" in proc.stderr +def test_install_script_current_shadow_does_not_skip_uv_install(tmp_path: Path) -> None: + """A current non-uv `dcode` cannot suppress installation into uv's bin.""" + proc, uv_args = _invoke( + tmp_path, + { + "FAKE_UV_TOOL_BIN_DIR": str(tmp_path / "home/.local/bin"), + "FAKE_UV_CREATE_LOCAL_DCODE": "1", + "FAKE_LOCAL_DCODE_VERSION": "0.2.0", + }, + installed_version="0.2.0", + latest_version="0.2.0", + ) + + assert proc.returncode == 0, proc.stderr + assert uv_args.exists() + assert "outside uv's configured tool bin" in proc.stdout + assert "Already up to date" not in proc.stdout + + +def test_install_script_current_uv_tool_repairs_shadowed_path(tmp_path: Path) -> None: + """A current uv tool still continues when another binary wins on `PATH`.""" + tool_bin = tmp_path / "home/.local/bin" + env = _env( + tmp_path, + {"FAKE_UV_TOOL_BIN_DIR": str(tool_bin)}, + installed_version="0.1.0", + latest_version="0.2.0", + ) + tool_bin.mkdir(parents=True) + dcode = tool_bin / "dcode" + dcode.write_text( + "#!/usr/bin/env bash\n" + 'if [ "${1:-}" = "-v" ]; then printf "deepagents-code 0.2.0\\n"; fi\n' + ) + _make_executable(dcode) + + proc = subprocess.run( + ["bash", str(SCRIPT)], + env=env, + check=False, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + + assert proc.returncode == 0, proc.stderr + assert (tmp_path / "uv-args.txt").exists() + assert "not selected on PATH" in proc.stdout + assert "Detected existing dcode" in proc.stderr + + def _run_detect_shadowing_install( tmp_path: Path, *, @@ -2517,6 +2871,7 @@ def _run_detect_shadowing_install( 'log_warn() { printf "%s\\n" "$*" >&2; }\n' 'OS="linux"\n' f"HOME={str(home)!r}\n" + f"TOOL_BIN_DIR={str(local_bin)!r}\n" f"ORIGINAL_PATH={original_path!r}\n" f"{_extract_shell_function('classify_shadowing_command')}\n" f"{_extract_shell_function('detect_shadowing_install')}\n"