diff --git a/install.sh b/install.sh index bb5b4c92..0a5fa2a5 100755 --- a/install.sh +++ b/install.sh @@ -65,6 +65,14 @@ set -euo pipefail # ============================================================================ INSTALL_DIR="${CODEMAN_INSTALL_DIR:-$HOME/.codeman/app}" +# "$INSTALL_DIR.new" and ".old" must sit beside the install: with a trailing +# slash they land inside it, and every archive update fails at the final swap. +while [[ "$INSTALL_DIR" == */ && "$INSTALL_DIR" != "/" ]]; do + INSTALL_DIR="${INSTALL_DIR%/}" +done +# `docker run -u` gives a process no USER, and set -u turns every message that +# prints it into a crash. +USER="${USER:-$(id -un 2>/dev/null || true)}" REPO_URL="${CODEMAN_REPO_URL:-https://github.com/Ark0N/Codeman.git}" BRANCH="${CODEMAN_BRANCH:-master}" MIN_NODE_VERSION=18 @@ -77,6 +85,9 @@ SKIP_SYSTEMD="${CODEMAN_SKIP_SYSTEMD:-0}" # (resolve_admin_mode). NO_ADMIN_REASON is the one-line "why" shown to the user. NO_ADMIN="${CODEMAN_NO_ADMIN:-}" NO_ADMIN_REASON="" +# The one reason that can be wrong, so the messages name a way back for it +# (no_admin_override_hint). +NO_ADMIN_UNCONFIRMED="could not confirm that this account may use sudo" # Where the no-admin route keeps what it downloads (Node.js, tmux). Inside the # data directory, so one folder holds everything Codeman put on the machine. RUNTIME_DIR="$HOME/.codeman/runtime" @@ -945,7 +956,11 @@ user_in_admin_group() { # CODEMAN_NO_ADMIN=0/1 wins. Otherwise root has them; a Mac account outside the # admin group does not (unless a Homebrew it can write to is already there); # on Linux an account has none without sudo, or when it is outside the admin -# groups AND sudo will not run without a password. +# groups AND sudo will not run without a password. That last one is a judgment, +# not a fact: an account sudoers lists by name, with a password, lands there +# too, because nothing can read sudoers without a probe that reports to the +# administrator. So it says "could not confirm", and every message that ends or +# sums up the route names CODEMAN_NO_ADMIN=0 for it. # # Never decided by trying `sudo -v`: that asks for a password, and a wrong # answer from an account that may not use sudo is reported to the machine's @@ -979,7 +994,28 @@ resolve_admin_mode() { return 0 fi NO_ADMIN="1" - NO_ADMIN_REASON="this account may not use sudo" + NO_ADMIN_REASON="$NO_ADMIN_UNCONFIRMED" + return 0 +} + +# The way back to sudo, with a leading space so it can end a sentence, for a +# route chosen because the rights could not be confirmed. Nothing for any other +# reason: --no-admin asked for this route, and a sudo that is missing or refused +# has no way back to offer. +no_admin_override_hint() { + if [[ "$NO_ADMIN_REASON" == "$NO_ADMIN_UNCONFIRMED" ]]; then + printf '%s' " If this account can use sudo, run this again with CODEMAN_NO_ADMIN=0 set (for the install line: curl -fsSL https://getcodeman.com/install | CODEMAN_NO_ADMIN=0 bash)." + fi + return 0 +} + +# The same hint on a line of its own, for a message that does not end the run. +print_no_admin_override_hint() { + local hint="" + hint=$(no_admin_override_hint) + if [[ -n "$hint" ]]; then + echo -e " ${DIM}${hint# }${NC}" >&2 + fi return 0 } @@ -988,7 +1024,7 @@ ensure_sudo() { return 0 fi if [[ "$NO_ADMIN" == "1" ]]; then - die "This step needs administrator rights, and this run installs without them${NO_ADMIN_REASON:+ ($NO_ADMIN_REASON)}." + die "This step needs administrator rights, and this run installs without them${NO_ADMIN_REASON:+ ($NO_ADMIN_REASON)}.$(no_admin_override_hint)" fi if ! command -v sudo &>/dev/null; then die "sudo is required but not installed. Please install packages manually or run as root." @@ -1637,7 +1673,11 @@ install_tmux_userspace() { sha=$(tmux_build_sha256 "$platform") || die "No pinned checksum for the $platform tmux build." name="tmux-$TMUX_BUILD_VERSION-$platform.tar.gz" - tmp=$(mktemp -d "${TMPDIR:-/tmp}/codeman-tmux.XXXXXX") || die "Could not create a temporary folder." + # Staged in the runtime folder, as Node.js is: /tmp is often mounted noexec + # on the shared hosts this route is for, and the proof run below would then + # call a working build one that does not run. + mkdir -p "$bin_dir" + tmp=$(mktemp -d "$RUNTIME_DIR/codeman-tmux.XXXXXX") || die "Could not create a temporary folder." if ! run_step "Downloading tmux $TMUX_BUILD_VERSION" download \ "https://github.com/tmux/tmux-builds/releases/download/v$TMUX_BUILD_VERSION/$name" "$tmp/$name"; then rm -rf "$tmp" @@ -1656,7 +1696,6 @@ install_tmux_userspace() { rm -rf "$tmp" die "The downloaded tmux does not run on this system. Ask an administrator to install tmux, then re-run this installer." fi - mkdir -p "$bin_dir" mv -f "$tmp/tmux" "$bin_dir/tmux" rm -rf "$tmp" @@ -1685,7 +1724,9 @@ install_cloudflared_userspace() { macos-arm64) asset="cloudflared-darwin-arm64.tgz" ;; *) die "Cloudflare publishes no cloudflared build for $os $arch." ;; esac - tmp=$(mktemp -d "${TMPDIR:-/tmp}/codeman-cloudflared.XXXXXX") || die "Could not create a temporary folder." + # Staged in the home folder for the reason in install_tmux_userspace. + mkdir -p "$HOME/.local/bin" + tmp=$(mktemp -d "$HOME/.local/codeman-cloudflared.XXXXXX") || die "Could not create a temporary folder." if ! run_step "Downloading cloudflared" download \ "https://github.com/cloudflare/cloudflared/releases/latest/download/$asset" "$tmp/$asset"; then rm -rf "$tmp" @@ -1704,7 +1745,6 @@ install_cloudflared_userspace() { rm -rf "$tmp" die "The downloaded cloudflared does not run on this system." fi - mkdir -p "$HOME/.local/bin" mv -f "$src" "$dest" rm -rf "$tmp" return 0 @@ -1725,7 +1765,7 @@ ask_dependencies_no_admin() { blocker=$(no_admin_blocker "$os" "$arch" "$need_node" "$need_tmux") if [[ -n "$blocker" ]]; then - die "$blocker" + die "$blocker$(no_admin_override_hint)" fi if [[ "$need_git" == "1" ]]; then if [[ -d "$INSTALL_DIR/.git" ]]; then @@ -1749,7 +1789,7 @@ ask_dependencies_no_admin() { if [[ -n "$what" ]]; then headless_guard "download $what into $RUNTIME_DIR" if [[ "$consented" != "consented" ]] && ! prompt_yes_no "Download $what into your home folder ($RUNTIME_DIR)?" "y"; then - die "Codeman needs $what. Install them and re-run this installer." + die "Codeman needs $what. Install them and re-run this installer.$(no_admin_override_hint)" fi if [[ "$need_node" == "1" ]]; then CURRENT_STEP="installing Node.js $TARGET_NODE_VERSION into $RUNTIME_DIR" @@ -2650,6 +2690,7 @@ offer_install_tailscale() { if [[ "$NO_ADMIN" == "1" && "$(uname -s)" != "Darwin" ]]; then info "Tailscale is not installed, and installing it takes an administrator." echo -e " ${DIM}Once one has installed it and run${NC} ${CYAN}sudo tailscale set --operator=$USER${NC}${DIM}, finish here with:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}" >&2 + print_no_admin_override_hint return 1 fi headless_guard "install Tailscale (curl | sh from tailscale.com)" @@ -2736,6 +2777,7 @@ ensure_tailscale_login() { info "If the CLI cannot log in, open the Tailscale app, log in there, then run:" info " bash $INSTALL_DIR/install.sh tailscale" fi + print_no_admin_override_hint return 1 fi if [[ "$(ts_backend_state)" == "Running" ]]; then @@ -2758,6 +2800,7 @@ ensure_tailscale_operator() { if [[ "$NO_ADMIN" == "1" ]]; then warn "tailscale serve needs root or Tailscale 'operator' rights, and this account has neither." echo -e " ${DIM}An administrator can grant them once with:${NC} ${CYAN}sudo tailscale set --operator=$USER${NC}" >&2 + print_no_admin_override_hint return 1 fi if ! command -v sudo &>/dev/null; then @@ -3830,6 +3873,7 @@ print_preflight_summary() { echo -e " ${BOLD}Existing${NC} $existing" if [[ "$NO_ADMIN" == "1" ]]; then echo -e " ${BOLD}Admin${NC} not used${NO_ADMIN_REASON:+ ($NO_ADMIN_REASON)}: Node.js and tmux, where missing, go into your home folder" + print_no_admin_override_hint fi echo "" return 0 @@ -4455,7 +4499,12 @@ uninstall() { local data_dir="$HOME/.codeman" if [[ -d "$data_dir" ]]; then warn "Data directory exists at $data_dir (contains sessions, settings, state)" - if prompt_yes_no "Remove data directory ($data_dir)?" "n"; then + # Still there means it was kept just above, and it lives in this folder. + local data_note="" + if [[ -d "$RUNTIME_DIR" ]]; then + data_note=" That includes the Node.js and tmux you just kept ($RUNTIME_DIR), and whatever npm -g put there." + fi + if prompt_yes_no "Remove data directory ($data_dir)?$data_note" "n"; then rm -rf "$data_dir" success "Removed $data_dir" else @@ -4500,7 +4549,7 @@ Subcommands Environment: CODEMAN_NONINTERACTIVE=1, CODEMAN_INSTALL_DIR, CODEMAN_HOST, CODEMAN_PASSWORD, CODEMAN_PORT, CODEMAN_TAILSCALE=1, CODEMAN_TAILSCALE_NAME, CODEMAN_SKIP_SYSTEMD=1, CODEMAN_NODE_VERSION, CODEMAN_REPO_URL, CODEMAN_BRANCH, -CODEMAN_NO_ADMIN=1, CODEMAN_TARBALL_URL. +CODEMAN_NO_ADMIN=1 or 0, CODEMAN_TARBALL_URL. EOF } diff --git a/test/install-sh-invariants.test.ts b/test/install-sh-invariants.test.ts index 384937dd..3edfcdd7 100644 --- a/test/install-sh-invariants.test.ts +++ b/test/install-sh-invariants.test.ts @@ -806,6 +806,18 @@ describe('install.sh no-admin route', () => { expect(existsSync(`${app}.new`)).toBe(false); expect(existsSync(`${app}.old`)).toBe(false); + // A trailing slash on the setting must not put the staging folder inside the install. + const slashed = drive(script, { + CODEMAN_INSTALL_DIR: `${app}/`, + CODEMAN_TARBALL_URL: `file://${first}`, + HOME: scratch, + }); + expect(slashed.status, slashed.stderr).toBe(0); + expect(slashed.stdout).toContain(`staging=[${app}.new] cwd=${app}.new`); + expect(readFileSync(join(app, 'package.json'), 'utf-8')).toContain('1.0.0'); + expect(existsSync(`${app}.new`)).toBe(false); + expect(existsSync(`${app}.old`)).toBe(false); + // A folder that is neither a checkout nor one of ours is never written into. const foreign = join(scratch, 'foreign'); mkdirSync(foreign); @@ -945,4 +957,156 @@ describe('install.sh no-admin route', () => { expect(rm).toBeGreaterThan(-1); expect(body.slice(body.lastIndexOf('if [[', rm), rm)).toContain('== "$RUNTIME_DIR/"*'); }); + + it('says the rights were not confirmed, and names the way back to sudo, when it chose this route itself', () => { + // A sudoers entry cannot be read without a probe that reports to the administrator, so an + // account listed by name, with a password, lands on this route too. It must not be told it + // may not use sudo, and every place that ends or sums up the route must name the override. + const stops = ` + detect_arch() { echo x64; } + download() { echo "DOWNLOAD $1"; }; download_to_stdout() { echo "DOWNLOAD $1"; } + check_node() { return 1; }; check_tmux() { return 1; }; check_git() { return 0; } + missing_build_tools() { printf 'make'; }; is_musl_libc() { return 1; } + ts_cmd() { return 1; }; check_tailscale() { return 1; } + CLI_IDS=(); TS_STATE=""; MISSING_PKGS=""; EXISTING_FOUND=""; EXISTING_VERSION="" + section() { echo "== $1"; } + NONINTERACTIVE=1 + section blocker; check_build_tools() { return 1; }; (ask_dependencies_no_admin linux) 2>&1 || true + check_build_tools() { return 0; } + section declined; prompt_yes_no() { return 1; }; (ask_dependencies_no_admin linux) 2>&1 || true + section ensure_sudo; (ensure_sudo) 2>&1 || true + section operator; (ensure_tailscale_operator) 2>&1 || true + section summary; (print_preflight_summary linux x64 ubuntu) 2>&1 + NONINTERACTIVE=0 + section tailscale; (offer_install_tailscale) 2>&1 || true + `; + const sections = (stdout: string) => + Object.fromEntries( + stdout + .split(/^== /m) + .slice(1) + .map((part) => [part.slice(0, part.indexOf('\n')), part]) + ); + const detected = drive(` + [[ $EUID -ne 0 ]] || { echo "skipped=root"; exit 0; } + uname() { echo Linux; } + command() { if [[ "$1" == "-v" && "$2" == "sudo" ]]; then return 0; else builtin command "$@"; fi; } + id() { echo users; } + sudo() { return 1; } + NO_ADMIN=""; resolve_admin_mode + echo "route=$NO_ADMIN reason=$NO_ADMIN_REASON" + ${stops} + `); + expect(detected.status, detected.stderr).toBe(0); + if (detected.stdout.includes('skipped=root')) return; + expect(detected.stdout).toMatch(/route=1 reason=.*could not confirm/); + expect(detected.stdout).not.toContain('may not use sudo'); + const parts = sections(detected.stdout); + for (const name of ['blocker', 'declined', 'ensure_sudo', 'operator', 'summary', 'tailscale']) { + expect(parts[name], `${name}: ${detected.stdout}`).toContain('CODEMAN_NO_ADMIN=0'); + } + + // Asked for, or sudo really tried: there is no way back to name. + for (const reason of ['--no-admin', 'sudo is not installed', 'sudo was refused']) { + const other = drive(` + uname() { echo Linux; } + NO_ADMIN=1; NO_ADMIN_REASON="${reason}" + ${stops} + `); + expect(other.status, other.stderr).toBe(0); + expect(other.stdout, reason).not.toContain('CODEMAN_NO_ADMIN=0'); + } + }); + + it('proves a downloaded tmux and cloudflared in the home folder, not in the temp folder', () => { + // /tmp is commonly mounted noexec on the shared hosts this route is for. The stand-in + // below refuses to run from TMPDIR the way such a mount does (126, not a shell error). + const home = mkdtempSync(join(tmpdir(), 'codeman-noexec-')); + try { + const noexec = join(home, 'noexec-tmp'); + const fake = join(home, 'fake'); + mkdirSync(noexec); + mkdirSync(fake); + for (const tool of ['tmux', 'cloudflared']) { + writeFileSync(join(fake, tool), `#!/bin/sh\ncase "$0" in "${noexec}"/*) exit 126 ;; esac\necho ok\n`, { + mode: 0o755, + }); + } + const tarball = join(home, 'tmux.tar.gz'); + const tar = spawnSync('tar', ['-czf', tarball, '-C', fake, 'tmux']); + expect(tar.status, String(tar.stderr)).toBe(0); + const result = drive( + ` + run_step() { shift; "$@"; } + ensure_on_path() { :; } + tmux_build_sha256() { sha256_file "$DRIVE_FAKE/../tmux.tar.gz"; } + download() { case "$1" in *tmux*) cp "$DRIVE_FAKE/../tmux.tar.gz" "$2" ;; *) cp "$DRIVE_FAKE/cloudflared" "$2" ;; esac; } + (install_tmux_userspace linux x64) && echo "tmux=installed" + (install_cloudflared_userspace linux x64) && echo "cloudflared=installed" + ls -A "$RUNTIME_DIR" | tr '\n' ' '; echo + ls -A "$HOME/.local" | tr '\n' ' '; echo + ls -A "$HOME/.local/bin" | tr '\n' ' '; echo + `, + { HOME: home, TMPDIR: noexec, DRIVE_FAKE: fake } + ); + expect(result.stderr).not.toContain('does not run'); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('tmux=installed'); + expect(result.stdout).toContain('cloudflared=installed'); + expect(existsSync(join(home, '.codeman/runtime/bin/tmux'))).toBe(true); + expect(existsSync(join(home, '.local/bin/cloudflared'))).toBe(true); + // Nothing left beside them: the listings hold the final names and nothing else. + const [runtime, local, bin] = result.stdout.trim().split('\n').slice(-3); + expect(runtime.trim()).toBe('bin'); + expect(local.trim()).toBe('bin'); + expect(bin.trim().split(' ').sort()).toEqual(['cloudflared', 'tmux']); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it('survives an environment with no USER', () => { + // docker run -u gives a process no USER, and set -u turns a message that prints it into a + // crash. The no-admin route reaches several such lines, and such a container is its first home. + const result = drive( + ` + uname() { echo Linux; } + NO_ADMIN=1; NO_ADMIN_REASON=test; NONINTERACTIVE=0 + ts_cmd() { return 1; }; check_tailscale() { return 1; } + (ensure_tailscale_operator) 2>&1 || true + (offer_install_tailscale) 2>&1 || true + BIND_HOST=0.0.0.0; BIND_PASSWORD="" + (print_security_notice) 2>&1 + `, + { USER: undefined } + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout + result.stderr).not.toContain('unbound variable'); + expect(result.stdout.match(/--operator=\S/g)?.length).toBe(2); + }); + + it('drops a trailing slash from CODEMAN_INSTALL_DIR', () => { + // "$INSTALL_DIR.new" must sit beside the install, and app/.new sits inside it. + const dir = (value: string) => drive('echo "$INSTALL_DIR"', { CODEMAN_INSTALL_DIR: value }).stdout.trim(); + expect(dir('/x/app/')).toBe('/x/app'); + expect(dir('/x/app//')).toBe('/x/app'); + expect(dir('/x/app')).toBe('/x/app'); + expect(dir('/')).toBe('/'); + }); + + it('warns that removing the data directory takes a kept runtime with it', () => { + // The runtime lives inside ~/.codeman, so the yes to the data directory removes what the + // user chose to keep one prompt earlier. Read from the source, never driven: uninstall() + // calls `systemctl --user`, which HOME does not redirect, so a drive stops and disables + // the real codeman-web of whoever runs this test. + const body = fn('uninstall', '\nusage() {'); + const prompt = body.indexOf('Remove data directory ($data_dir)?'); + expect(prompt).toBeGreaterThan(-1); + const note = body.lastIndexOf('[[ -d "$RUNTIME_DIR" ]]', prompt); + expect(note, 'the kept runtime is not looked at before that prompt').toBeGreaterThan( + body.indexOf('Kept $RUNTIME_DIR') + ); + expect(body.slice(note, prompt)).toMatch(/data_note=" .*\$RUNTIME_DIR/); + expect(body.slice(prompt)).toMatch(/^Remove data directory \(\$data_dir\)\?\$data_note"/); + }); });