fix: no-admin route messages, noexec tmp, unset USER and install dir slash (release review)

An account sudoers lists by name, with a password, is routed to no-admin
because nothing can read sudoers without reporting to the administrator.
The route itself stays as chosen, but the reason no longer claims the
account may not use sudo, and every message that ends or sums up the route
names CODEMAN_NO_ADMIN=0 for that reason alone.

tmux and cloudflared are staged in the home folder, so a noexec /tmp no
longer reports a working build as one that does not run. USER gets a default
at the top of the script, which also covers the older lines this route can
reach. A trailing slash on CODEMAN_INSTALL_DIR no longer puts .new and .old
inside the install. The data directory prompt names a runtime kept one
prompt earlier.

The uninstall check reads the source and must never drive uninstall(): HOME
does not redirect systemctl --user, so a drive reaches the real service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 04:34:18 +01:00
1 parent ae28534406
commit 49b2c0e9bd
2 files changed
+224 -11

No files matched your search

+60 -11
View File
@@ -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
}
+164
View File
@@ -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"/);
});
});