diff --git a/sandy b/sandy index 07ddbe2..d427946 100755 --- a/sandy +++ b/sandy @@ -2759,6 +2759,28 @@ if [ "${SANDY_SSH:-token}" = "token" ] && command -v gh &>/dev/null; then git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/" gh auth setup-git 2>/dev/null || true fi +# Guaranteed-baseline git credential helper, independent of gh. The gh path +# above (`gh auth login --with-token` earlier + `gh auth setup-git` here) +# validates the token over the network against api.github.com and swallows all +# errors (2>/dev/null || true) — so a transient api.github.com failure (observed: +# authenticated api.github.com calls returning 503 while github.com itself is +# fine) silently leaves the container with NO working credential helper and git +# push/pull fails, even though a valid GIT_TOKEN was forwarded. Append a direct +# helper: git tries credential helpers in order, so gh's multi-account helper +# still wins when it's healthy, and this is the fallback. It supplies the +# forwarded token for github.com with NO network round-trip and reads +# $GIT_TOKEN from the container env at call time (the literal ${GIT_TOKEN} is +# stored in .gitconfig, not the value). Always emits creds (git ignores helper +# output on store/erase), sidestepping the inline-helper $1-operation gotcha. +if [ "${SANDY_SSH:-token}" = "token" ] && [ -n "${GIT_TOKEN:-}" ]; then + # ${GIT_TOKEN} is INTENTIONALLY left literal in the helper string — it is + # stored verbatim in .gitconfig and expanded by the helper's own shell at + # git-call time (so the token value never lands in the config file). That + # is exactly the pattern SC2016 warns about, so silence it here. + # shellcheck disable=SC2016 + git config --global --add credential."https://github.com".helper \ + '!f() { echo username=x-access-token; echo "password=${GIT_TOKEN}"; }; f' +fi sandy_log "Refreshing plugin marketplaces" # Refresh plugin marketplaces (once per day, or forced when channels need plugins). @@ -3512,6 +3534,23 @@ if [ "$SANDY_START" = "true" ] && [ "${SANDY_DAEMON_SUPERVISOR:-0}" != "1" ]; th _sandy_daemon_log="$SANDY_HOME/sandboxes/.daemon-start-$$.log" : > "$_sandy_daemon_log" + # Fix B — grant any passive-privileged approval with the CLIENT's TTY BEFORE + # forking the non-TTY supervisor. The supervisor's stdin is /dev/null (the + # nohup redirect below), so its own approval resolution can only fail + # closed — silently dropping workspace-config privileged keys, including + # credentials in a workspace .sandy/.secrets (the CLAUDE_CODE_OAUTH_TOKEN + # drop reported against daemon-mode). Reuse the full config-load + approval + # flow via a synchronous subprocess from the workspace in SANDY_APPROVE_ONLY + # mode: it prompts on our terminal and persists the approval file (and + # nothing else — it exits before mutex/build/launch), which the supervisor + # then honors. Only when we actually have a TTY; a non-TTY client (sandy-ui + # / scripted) still can't prompt, so there approval must be pre-granted + # (run `sandy --start` once from a terminal) or SANDY_AUTO_APPROVE_PRIVILEGED + # set. + if [ -t 0 ] && [ -t 2 ]; then + ( cd "$WORK_DIR" 2>/dev/null && SANDY_APPROVE_ONLY=1 "$0" ) /dev/tty 2>&1 || true + fi + # D1 — daemonize the supervisor with `nohup … & disown`, deliberately NOT # setsid: setsid ships with util-linux and is ABSENT on macOS, which is # daemon-mode's primary platform (sandy-ui), and failing-clean there isn't @@ -4272,9 +4311,14 @@ _resolve_passive_privileged_approval() { _src="$(printf '%s\n' "${_PASSIVE_PRIVILEGED_SOURCES[@]}" | sort -u | head -n1)" echo "[sandy]" >&2 echo "[sandy] Workspace config ($_src) wants to set privileged keys:" >&2 + # Print KEY NAMES ONLY — never the values. These are credentials + # (CLAUDE_CODE_OAUTH_TOKEN, API keys, …) and this prompt goes to stderr, + # which in daemon mode is the supervisor log on disk. `${_kv%%=*}` strips + # =VALUE. (The set_hash above still encodes the values, so any value edit + # still re-prompts.) while IFS= read -r _kv; do [ -z "$_kv" ] && continue - echo "[sandy] $_kv" >&2 + echo "[sandy] ${_kv%%=*}" >&2 done <<< "$sorted" echo "[sandy]" >&2 echo "[sandy] These affect security boundaries (SSH agent forwarding, permission" >&2 @@ -4301,11 +4345,19 @@ _resolve_passive_privileged_approval() { IFS= read -r _ans /dev/null || date)" - printf '%s\n' "$sorted" + while IFS= read -r _kv; do + [ -z "$_kv" ] && continue + printf '%s\n' "${_kv%%=*}" + done <<< "$sorted" } > "$approval_file" chmod 600 "$approval_file" 2>/dev/null || true while IFS= read -r _kv; do @@ -4327,6 +4379,20 @@ _resolve_passive_privileged_approval # has been resolved through approval and exported (or dropped on rejection). _load_sandy_extra_env +# Fix B — SANDY_APPROVE_ONLY: the internal mode the `--start` client runs as a +# synchronous TTY subprocess (before forking the non-TTY supervisor) purely to +# grant + PERSIST any passive-privileged approval interactively. Everything it +# needs (config load + _resolve_passive_privileged_approval) has run just above, +# and this exits BEFORE the workspace mutex, the bare-sandy busy-gate, image +# builds, and launch — so the subprocess touches nothing on disk but the +# approval file, then exits. The real supervisor re-loads config and honors +# that persisted approval. Env-only internal flag (like SANDY_DAEMON_SUPERVISOR +# / SANDY_APPROVE_ONLY is never a config key), so a committed config can't set +# it. +if [ "${SANDY_APPROVE_ONLY:-0}" = "1" ]; then + exit 0 +fi + # Sanity: reject world-open SANDY_ALLOW_LAN_HOSTS values. Even from a # privileged source, "0.0.0.0/0" negates the whole point of LAN isolation and # is almost certainly a mistake, not a feature request. @@ -7603,14 +7669,43 @@ if [ "${SANDY_DAEMON_SUPERVISOR:-0}" = "1" ]; then # normally. `|| true` so a sleeper killed by anything other than our own # traps (stray kill, OOM) respawns instead of tripping set -e into a # surprise teardown of a healthy session. + # #47 — also WATCH the inner tmux session and tear the daemon down when the + # AGENT exits (ctrl-d / /exit / crash), rather than leaving a zombie + # container that PID 1's `tail -f /dev/null` keeps alive forever (holding + # the proxy, networks, and the workspace lock, and making every later + # --attach return the confusing exit-5). --stop's SIGTERM still interrupts + # `wait` and runs the same teardown via the trap; this poll handles the + # OTHER end of a session's life. Guard against a transient `docker exec` + # hiccup: only break when has-session fails on TWO consecutive polls AND + # the container is still running (if the container itself is already gone, + # break immediately — cleanup reaps residual networks/lock). Falling out of + # the loop hits `exit "$_run_rc"` below → the EXIT trap → cleanup()'s + # `docker rm -f`, which removes the container outright so `--restart + # unless-stopped` does NOT resurrect it. Reboot survival (D9) is unaffected: + # a rebooted container has no supervisor watching it and is handled by + # --stop/--attach via container-as-truth. + _sandy_session_gone=0 while :; do - sleep 300 & + sleep 30 & wait "$!" || true + if docker exec -u "$(id -u)" "$CONTAINER_NAME" tmux has-session -t sandy >/dev/null 2>&1; then + _sandy_session_gone=0 # session healthy — reset the counter + continue + fi + if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then + info "Daemon container is no longer running — supervisor tearing down." + break + fi + _sandy_session_gone=$((_sandy_session_gone + 1)) + if [ "$_sandy_session_gone" -ge 2 ]; then + info "Agent session ended — stopping the daemon session." + break + fi done fi -# Preserve the interactive path's exit-status semantics (see the capture note -# above). In supervisor mode this is only reached if `wait` returns without a -# signal (unusual — --stop's SIGTERM exits via the signal trap); exiting with -# the launch status is a harmless fallback there. +# Reached in supervisor mode when the watch loop above breaks (the agent +# session ended or the container vanished) — exiting fires the EXIT trap → +# cleanup() (docker rm -f + networks + lock release), the same teardown --stop +# performs. In the interactive path this preserves docker run's exit status. exit "$_run_rc" diff --git a/templates/user-setup.sh.tmpl b/templates/user-setup.sh.tmpl index ca4de2c..9390faf 100644 --- a/templates/user-setup.sh.tmpl +++ b/templates/user-setup.sh.tmpl @@ -529,6 +529,28 @@ if [ "${SANDY_SSH:-token}" = "token" ] && command -v gh &>/dev/null; then git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/" gh auth setup-git 2>/dev/null || true fi +# Guaranteed-baseline git credential helper, independent of gh. The gh path +# above (`gh auth login --with-token` earlier + `gh auth setup-git` here) +# validates the token over the network against api.github.com and swallows all +# errors (2>/dev/null || true) — so a transient api.github.com failure (observed: +# authenticated api.github.com calls returning 503 while github.com itself is +# fine) silently leaves the container with NO working credential helper and git +# push/pull fails, even though a valid GIT_TOKEN was forwarded. Append a direct +# helper: git tries credential helpers in order, so gh's multi-account helper +# still wins when it's healthy, and this is the fallback. It supplies the +# forwarded token for github.com with NO network round-trip and reads +# $GIT_TOKEN from the container env at call time (the literal ${GIT_TOKEN} is +# stored in .gitconfig, not the value). Always emits creds (git ignores helper +# output on store/erase), sidestepping the inline-helper $1-operation gotcha. +if [ "${SANDY_SSH:-token}" = "token" ] && [ -n "${GIT_TOKEN:-}" ]; then + # ${GIT_TOKEN} is INTENTIONALLY left literal in the helper string — it is + # stored verbatim in .gitconfig and expanded by the helper's own shell at + # git-call time (so the token value never lands in the config file). That + # is exactly the pattern SC2016 warns about, so silence it here. + # shellcheck disable=SC2016 + git config --global --add credential."https://github.com".helper \ + '!f() { echo username=x-access-token; echo "password=${GIT_TOKEN}"; }; f' +fi sandy_log "Refreshing plugin marketplaces" # Refresh plugin marketplaces (once per day, or forced when channels need plugins).