diff --git a/.config/shell/README.md b/.config/shell/README.md index 2ccf584f..934bd226 100644 --- a/.config/shell/README.md +++ b/.config/shell/README.md @@ -26,6 +26,13 @@ empty inherited value through the environment ownership helper. Missing or empty files leave the environment unchanged. The file is local credential state and must never be committed. +Cron jobs get the same value: the managed crontab block sets `SHELL` to bash +and `BASH_ENV` to `env-noninteractive.sh` (see Environment Ownership), so every +job loads `env.d` like other non-interactive shells. Each account reads its own +file, so root's crontab needs `/root/.config/gh/github-pat`. Without a token, +scheduled `dot update` runs make anonymous GitHub API calls, which share one +60-per-hour quota per public IP. + The base `gh-token` merge hook preserves an existing token file and repairs its mode to `0600` during `dot update`. When the file is missing, it can seed it from GitHub CLI's local `hosts.yml` if Mike Farah's `yq` is available. Missing @@ -56,6 +63,13 @@ zsh as the login shell, `ssh host cmd` reads only `~/.zshenv`. Shell-local state such as functions, `shopt`/`setopt`, and system rc bootstraps loads in every shell regardless of mode. +Cron jobs are fill-only children too. The cron merge hook writes `SHELL` (an +absolute bash) and `BASH_ENV` (this loader) into the managed crontab block +ahead of its `PATH`, so jobs see the same `env.d` values as agent and script +shells instead of cron's bare `/bin/sh` environment. The crontab's `PATH` +arrives as an inherited value. When bash or the loader is missing, the hook +leaves cron on its default shell. + Because nested shells load `env.d/` too, the loader suspends `set -u` while it runs and restores the caller's setting afterwards: a `bash -u` or `bash -euo pipefail` script would otherwise print "unbound variable" from diff --git a/.local/lib/dotfiles/merge-hooks.d/cron.sh b/.local/lib/dotfiles/merge-hooks.d/cron.sh index dc924d95..7767fc25 100644 --- a/.local/lib/dotfiles/merge-hooks.d/cron.sh +++ b/.local/lib/dotfiles/merge-hooks.d/cron.sh @@ -10,7 +10,8 @@ dot_hook_source merge-hooks.d/lib/cron-list.sh || return # choice. `cron.local` remains the last, untracked escape hatch. # Replaces all dot-managed entries (between marker lines) on each run. # Expands $HOME in cron lines. Sets PATH as a standalone cron variable -# so tools like git, curl, jq are found in cron's minimal environment. +# so tools like git, curl, jq are found in cron's minimal environment, and +# SHELL plus BASH_ENV so jobs load env.d like other non-interactive shells. # Idempotent — skips if the installed block already matches. # Build a clean PATH for cron from declarative path fragments. @@ -59,6 +60,41 @@ _cron_path() { echo "$result" } +# Emit the crontab variable lines that make cron jobs load env.d the way +# agent and script shells do: bash as the job shell, with BASH_ENV pointing at +# the shared non-interactive loader. +# +# Cron otherwise runs every job with a bare /bin/sh environment (dash on Debian, +# bash in sh mode elsewhere) that never loads env.d, so jobs miss dotfiles +# values such as GH_TOKEN; Shdeps then makes anonymous GitHub API calls that +# share one 60-per-hour quota across every host behind the same public IP. +# Crontab variable lines are literal (no expansion), so both values are +# resolved to absolute paths here. The job shell only has to source BASH_ENV, +# so prefer the system bash (Termux keeps it under $PREFIX): a Homebrew, mise, +# or Shdeps bash earlier on PATH could later be removed, and a missing SHELL +# fails every job, including the `dot update` that would repair the crontab. +# Fall back to a PATH lookup only when no system bash exists. When bash or the +# loader is missing, emit nothing and leave cron on its default shell rather +# than break every job. +# +# Args: $1 cron PATH (may be empty) +_cron_shell_env() { + local cron_path="$1" bash_bin="" candidate + local loader="$HOME/.config/shell/env-noninteractive.sh" + [[ -r "$loader" ]] || return 0 + for candidate in /bin/bash /usr/bin/bash ${PREFIX:+"$PREFIX/bin/bash"}; do + if [[ -x "$candidate" ]]; then + bash_bin=$candidate + break + fi + done + if [[ -z "$bash_bin" ]]; then + bash_bin=$(PATH="${cron_path:+$cron_path:}$PATH" command -v bash) || return 0 + fi + [[ "$bash_bin" == /* ]] || return 0 + printf 'SHELL=%s\nBASH_ENV=%s\n' "$bash_bin" "$loader" +} + # Parse a `# filter:` directive line into the active cron filter state. # Resets each key to empty (match-all) first. # Syntax: # filter: hosts=a,b platforms=linux users=chris or # filter: * @@ -191,7 +227,7 @@ _CRON_MARKER="# dot-managed-cron" # installs exactly this text and `dot doctor` compares the live crontab with # it, so the two can never disagree about what "current" means. _cron_render_block() { - local source sources="" cron_path body + local source sources="" cron_path body shell_env _cron_parsed="" for source in "${_cron_sources[@]}"; do @@ -211,6 +247,14 @@ _cron_render_block() { if [[ -n "$cron_path" ]]; then body="PATH=$cron_path"$'\n\n'"$body" fi + shell_env=$(_cron_shell_env "$cron_path") + if [[ -n "$shell_env" ]]; then + if [[ -n "$cron_path" ]]; then + body="$shell_env"$'\n'"$body" + else + body="$shell_env"$'\n\n'"$body" + fi + fi REPLY=$(dot_managed_block_build "$_CRON_MARKER" "$sources" "$body") } diff --git a/.local/lib/dotfiles/tests/core/cron.sh b/.local/lib/dotfiles/tests/core/cron.sh index bf371949..2c478d09 100644 --- a/.local/lib/dotfiles/tests/core/cron.sh +++ b/.local/lib/dotfiles/tests/core/cron.sh @@ -123,6 +123,66 @@ EOF _assert_contains "has end marker" "dot-managed-cron end" "$result" _assert_contains "preserves comments" "comment line" "$result" + # Without the shared env loader, cron keeps its default shell. Earlier + # shards in the same TEST_HOME may have installed a shell tree, so remove + # it explicitly rather than assume a clean fixture. + rm -rf "$TEST_HOME/.config/shell" + _run_cron_merge 2>/dev/null + result=$(crontab -l 2>/dev/null) + _assert_not_contains "no env loader: cron keeps its default shell" \ + "BASH_ENV=" "$result" + + # With the shared env loader present, the block sets SHELL and BASH_ENV so + # every job loads env.d like agent and script shells (for GH_TOKEN among + # others) instead of running in cron's bare /bin/sh environment. + mkdir -p "$TEST_HOME/.local/lib/dotfiles" "$TEST_HOME/.config/shell" + cp -R "$REAL_HOME/.config/shell/." "$TEST_HOME/.config/shell/" + cp "$REAL_HOME/.local/lib/dotfiles/shell-loader.sh" "$TEST_HOME/.local/lib/dotfiles/" + _run_cron_merge 2>/dev/null + result=$(crontab -l 2>/dev/null) + cron_shell=$(printf '%s\n' "$result" | sed -n 's/^SHELL=//p') + cron_bash_env=$(printf '%s\n' "$result" | sed -n 's/^BASH_ENV=//p') + cron_job_path=$(printf '%s\n' "$result" | sed -n 's/^PATH=//p') + _assert_eq "env loader: BASH_ENV is the shared non-interactive loader" \ + "$TEST_HOME/.config/shell/env-noninteractive.sh" "$cron_bash_env" + case "$cron_shell" in + /*bash) _pass "env loader: SHELL is an absolute bash" ;; + *) _fail "env loader: SHELL is an absolute bash (got '$cron_shell')" ;; + esac + _assert_eq "env loader: variables precede the jobs" \ + "SHELL= BASH_ENV= PATH=" \ + "$(printf '%s\n' "$result" | grep -oE '^(SHELL|BASH_ENV|PATH)=' | tr '\n' ' ' | sed 's/ $//')" + + # Run a job the way cron does with that block: the rendered SHELL with -c, + # in a clean environment carrying only the crontab variables. The job must + # see GH_TOKEN from the token file and nothing may print, since any output + # becomes cron mail. + # Most accounts start without a token file (root on a fresh host, say); + # the job must still run cleanly, with GH_TOKEN simply unset. + rm -rf "$TEST_HOME/.config/gh" + # shellcheck disable=SC2016 # The job shell expands GH_TOKEN, not this one. + _assert_eq "env loader: no token file leaves GH_TOKEN unset with no output" \ + "rc=0 token=[] set=" \ + "$("${DOT_TEST_ENV_CLEAN[@]}" HOME="$TEST_HOME" PATH="$cron_job_path:$DOT_TEST_SYSTEM_PATH" \ + SHELL="$cron_shell" BASH_ENV="$cron_bash_env" \ + "$cron_shell" -c 'set -eu; printf "rc=0 token=[%s] set=%s\n" "${GH_TOKEN-}" "${GH_TOKEN+yes}"' 2>&1)" + mkdir -p "$TEST_HOME/.config/gh" + printf '%s\n' 'ghp_fixture' >"$TEST_HOME/.config/gh/github-pat" + # shellcheck disable=SC2016 # The job shell expands GH_TOKEN, not this one. + _assert_eq "env loader: a cron job gets GH_TOKEN with no extra output" \ + "token=[ghp_fixture]" \ + "$("${DOT_TEST_ENV_CLEAN[@]}" HOME="$TEST_HOME" PATH="$cron_job_path:$DOT_TEST_SYSTEM_PATH" \ + SHELL="$cron_shell" BASH_ENV="$cron_bash_env" \ + "$cron_shell" -c 'printf "token=[%s]\n" "${GH_TOKEN-}"' 2>&1)" + # shellcheck disable=SC2016 # The job shell expands GH_TOKEN, not this one. + _assert_eq "env loader: a GH_TOKEN the job sets itself wins" \ + "token=[ghp_job]" \ + "$("${DOT_TEST_ENV_CLEAN[@]}" HOME="$TEST_HOME" PATH="$cron_job_path:$DOT_TEST_SYSTEM_PATH" \ + SHELL="$cron_shell" BASH_ENV="$cron_bash_env" GH_TOKEN=ghp_job \ + "$cron_shell" -c 'printf "token=[%s]\n" "${GH_TOKEN-}"' 2>&1)" + rm -rf "$TEST_HOME/.config/shell" "$TEST_HOME/.config/gh" + unset cron_shell cron_bash_env cron_job_path + # Idempotent — running again doesn't duplicate _run_cron_merge 2>/dev/null count=$(crontab -l 2>/dev/null | grep -c "dot update --cron" || true)