From 824fb141717aa4d4d3184f51434444f17dbe44bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:49:37 +0000 Subject: [PATCH 1/2] fix(sudo): keep line endings when replaying captured command output _gos_sudo_for_target captures a command's stdout and stderr with command substitution, which strips trailing newlines, then replayed them with printf '%s'. The next progress or error line was glued onto the command's last line, e.g. "mv: ...: Permission deniedError: ..." or "sudo mv ... go.gos-backup.1234Activating go from ..." (which is also why tests/install-transaction.bash failed when run as root). Replay each non-empty stream with its line ending through one helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ExmnfUngrDYjAheGvYwMnn --- CHANGELOG.md | 1 + gos.sh | 38 ++++++++++++++-------------- tests/unit-versions.bash | 53 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a41f382..b8009a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Fixed +- Output and error messages replayed after a privilege-escalation retry keep their trailing newline, so the next progress or error line is no longer glued onto the command's last line (for example `...: Permission deniedError: ...`). - Windows uninstall validates ownership, removes only known installed files, and preserves unrelated contents instead of recursively deleting the target directory. Existing installations without a receipt remain supported, with their ambiguous `LICENSE` left in place. - Windows installs stage replacements on the destination filesystem and restore previous files on publication failure. Failed fresh installs remove partial payloads; failed restoration keeps recovery files and reports their location. - Windows PATH edits refresh a stale process even when the registry already contains gos, remove process entries on uninstall, and preserve registry value types and unrelated entries. Tests use registry substitutes and a disposable native Windows key. diff --git a/gos.sh b/gos.sh index b182f9c..c762f09 100755 --- a/gos.sh +++ b/gos.sh @@ -1178,6 +1178,19 @@ _gos_sudo_for() { GOS_SUDO_TARGET="$target" _gos_sudo_for_target "$@" } +# Replay captured stdout/stderr of a command run by _gos_sudo_for_target. +# Command substitution strips trailing newlines, so restore one per non-empty +# stream: otherwise the next progress or error line is glued onto the +# command's last line (e.g. "...: Permission deniedError: ..."). +_gos_sudo_replay() { + if [ -n "$1" ]; then + printf '%s\n' "$1" + fi + if [ -n "$2" ]; then + printf '%s\n' "$2" >&2 + fi +} + _gos_sudo_for_target() { local output status err err_file sudo_output sudo_status sudo_err @@ -1202,12 +1215,7 @@ _gos_sudo_for_target() { if [ "$status" -eq 0 ]; then rm -f "$err_file" - if [ -n "$output" ]; then - printf '%s' "$output" - fi - if [ -n "$err" ]; then - printf '%s' "$err" >&2 - fi + _gos_sudo_replay "$output" "$err" return 0 fi @@ -1224,28 +1232,18 @@ _gos_sudo_for_target() { sudo_err=$(<"$err_file") rm -f "$err_file" if [ "$sudo_status" -eq 0 ]; then - if [ -n "$sudo_output" ]; then - printf '%s' "$sudo_output" - fi - if [ -n "$sudo_err" ]; then - printf '%s' "$sudo_err" >&2 - fi + _gos_sudo_replay "$sudo_output" "$sudo_err" return 0 fi - printf '%s' "$err" >&2 - if [ -n "$sudo_err" ]; then - printf '%s' "$sudo_err" >&2 - fi + _gos_sudo_replay "" "$err" + _gos_sudo_replay "" "$sudo_err" return "$sudo_status" ;; esac fi rm -f "$err_file" - if [ -n "$output" ]; then - printf '%s' "$output" - fi - printf '%s' "$err" >&2 + _gos_sudo_replay "$output" "$err" return "$status" } diff --git a/tests/unit-versions.bash b/tests/unit-versions.bash index e1cb0f5..bb470bb 100644 --- a/tests/unit-versions.bash +++ b/tests/unit-versions.bash @@ -209,3 +209,56 @@ go_comparison_output="$( [ "$go_comparison_output" = "go-version-comparison-ok" ] \ || fail "Go version comparison failed: ${go_comparison_output}" pass "Go version comparison orders beta, rc, release, and patch versions" + +# _gos_sudo_for_target captures a command's stdout/stderr with command +# substitution, which strips trailing newlines. Replaying them must restore +# the line ending, or the next progress/error line is glued onto the +# command's last line (as seen in "...gos-backup.1234Activating go from ..."). +sudo_case="${test_root}/sudo-replay" +mkdir -p "${sudo_case}/bin" "${sudo_case}/target" +cat >"${sudo_case}/bin/sudo" <<'FAKE_REPLAY_SUDO' +#!/usr/bin/env bash +GOS_FAKE_UNDER_SUDO=1 exec "$@" +FAKE_REPLAY_SUDO +cat >"${sudo_case}/bin/denied-then-ok" <<'FAKE_DENIED_THEN_OK' +#!/usr/bin/env bash +if [ "${GOS_FAKE_UNDER_SUDO:-}" != 1 ]; then + echo "denied-then-ok: Permission denied" >&2 + exit 1 +fi +echo "moved" +echo "sudo-warning" >&2 +FAKE_DENIED_THEN_OK +cat >"${sudo_case}/bin/always-denied" <<'FAKE_ALWAYS_DENIED' +#!/usr/bin/env bash +echo "always-denied: Permission denied" >&2 +exit 4 +FAKE_ALWAYS_DENIED +cat >"${sudo_case}/bin/plain-fail" <<'FAKE_PLAIN_FAIL' +#!/usr/bin/env bash +echo "partial-output" +echo "plain-fail: boom" >&2 +exit 3 +FAKE_PLAIN_FAIL +chmod +x "${sudo_case}/bin/"* +PATH="${sudo_case}/bin:${fake_bin}:${original_path}" \ + GOS_INSTALL_DIR="${sudo_case}/go" \ + GOS_CACHE_DIR="${sudo_case}/cache" \ + GOS_TEST_REAL_MV="$real_mv" \ + bash -c ' + set -euo pipefail + . "$1" + GOS_SUDO_TARGET="$2" _gos_sudo_for_target denied-then-ok + printf "after-retry\n" + printf "after-retry-err\n" >&2 + GOS_SUDO_TARGET="$2" _gos_sudo_for_target always-denied || printf "status=%s\n" "$?" + printf "after-denied-err\n" >&2 + GOS_SUDO_TARGET="$2" _gos_sudo_for_target plain-fail || printf "status=%s\n" "$?" + printf "after-fail-err\n" >&2 + ' bash "$sourceable_script" "${sudo_case}/target" \ + >"${sudo_case}/stdout" 2>"${sudo_case}/stderr" || fail "sudo replay harness failed: $(cat "${sudo_case}/stderr")" +expected_sudo_stdout="$(printf '%s\n' moved after-retry status=4 partial-output status=3)" +expected_sudo_stderr="$(printf '%s\n' sudo-warning after-retry-err 'always-denied: Permission denied' 'always-denied: Permission denied' after-denied-err 'plain-fail: boom' after-fail-err)" +[ "$(cat "${sudo_case}/stdout")" = "$expected_sudo_stdout" ] || fail "sudo replay stdout lost line endings: $(cat "${sudo_case}/stdout")" +[ "$(cat "${sudo_case}/stderr")" = "$expected_sudo_stderr" ] || fail "sudo replay stderr lost line endings: $(cat "${sudo_case}/stderr")" +pass "sudo retry replays captured output with its line endings" From 69d3891b1c5c3017d4f51f02fd0eb0a7dbdea8c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:31:58 +0000 Subject: [PATCH 2/2] test(sudo): cover first-try replay and widen the changelog entry - add a first-try success case with stdout and stderr to the replay regression - describe the fix as covering every replay path, not only retries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ExmnfUngrDYjAheGvYwMnn --- CHANGELOG.md | 2 +- tests/unit-versions.bash | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 524d654..e04590e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Fixed - Project resolution skips `.tool-versions` files that only pin other languages, preserving `go.mod` and parent Go pins. Bash/Zsh auto-switching tracks both the unrelated manifest and the selected Go manifest so edits in the same directory invalidate the prompt cache. Incomplete explicit Go entries still fail closed, and the first `go`/`golang` entry decides (a bare entry no longer lets a later line win). `.tool-versions` fields are no longer pathname-expanded, so `golang *` cannot select a version from file names in the working directory. -- Output and error messages replayed after a privilege-escalation retry keep their trailing newline, so the next progress or error line is no longer glued onto the command's last line (for example `...: Permission deniedError: ...`). +- Output and error messages that gos captures from file operations (with or without a privilege-escalation retry) keep their trailing newline when replayed, so the next progress or error line is no longer glued onto the command's last line (for example `...: Permission deniedError: ...`). - Windows uninstall validates ownership, removes only known installed files, and preserves unrelated contents instead of recursively deleting the target directory. Existing installations without a receipt remain supported, with their ambiguous `LICENSE` left in place. - Windows installs stage replacements on the destination filesystem and restore previous files on publication failure. Failed fresh installs remove partial payloads; failed restoration keeps recovery files and reports their location. - Windows PATH edits refresh a stale process even when the registry already contains gos, remove process entries on uninstall, and preserve registry value types and unrelated entries. Tests use registry substitutes and a disposable native Windows key. diff --git a/tests/unit-versions.bash b/tests/unit-versions.bash index bb470bb..c28a62b 100644 --- a/tests/unit-versions.bash +++ b/tests/unit-versions.bash @@ -229,6 +229,11 @@ fi echo "moved" echo "sudo-warning" >&2 FAKE_DENIED_THEN_OK +cat >"${sudo_case}/bin/direct-ok" <<'FAKE_DIRECT_OK' +#!/usr/bin/env bash +echo "direct-output" +echo "direct-warning" >&2 +FAKE_DIRECT_OK cat >"${sudo_case}/bin/always-denied" <<'FAKE_ALWAYS_DENIED' #!/usr/bin/env bash echo "always-denied: Permission denied" >&2 @@ -248,6 +253,9 @@ PATH="${sudo_case}/bin:${fake_bin}:${original_path}" \ bash -c ' set -euo pipefail . "$1" + GOS_SUDO_TARGET="$2" _gos_sudo_for_target direct-ok + printf "after-direct\n" + printf "after-direct-err\n" >&2 GOS_SUDO_TARGET="$2" _gos_sudo_for_target denied-then-ok printf "after-retry\n" printf "after-retry-err\n" >&2 @@ -257,8 +265,8 @@ PATH="${sudo_case}/bin:${fake_bin}:${original_path}" \ printf "after-fail-err\n" >&2 ' bash "$sourceable_script" "${sudo_case}/target" \ >"${sudo_case}/stdout" 2>"${sudo_case}/stderr" || fail "sudo replay harness failed: $(cat "${sudo_case}/stderr")" -expected_sudo_stdout="$(printf '%s\n' moved after-retry status=4 partial-output status=3)" -expected_sudo_stderr="$(printf '%s\n' sudo-warning after-retry-err 'always-denied: Permission denied' 'always-denied: Permission denied' after-denied-err 'plain-fail: boom' after-fail-err)" +expected_sudo_stdout="$(printf '%s\n' direct-output after-direct moved after-retry status=4 partial-output status=3)" +expected_sudo_stderr="$(printf '%s\n' direct-warning after-direct-err sudo-warning after-retry-err 'always-denied: Permission denied' 'always-denied: Permission denied' after-denied-err 'plain-fail: boom' after-fail-err)" [ "$(cat "${sudo_case}/stdout")" = "$expected_sudo_stdout" ] || fail "sudo replay stdout lost line endings: $(cat "${sudo_case}/stdout")" [ "$(cat "${sudo_case}/stderr")" = "$expected_sudo_stderr" ] || fail "sudo replay stderr lost line endings: $(cat "${sudo_case}/stderr")" -pass "sudo retry replays captured output with its line endings" +pass "sudo wrapper replays captured output with its line endings on every path"