Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 102 additions & 7 deletions sandy
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 >/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
Expand Down Expand Up @@ -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
Expand All @@ -4301,11 +4345,19 @@ _resolve_passive_privileged_approval() {
IFS= read -r _ans </dev/tty || _ans=""
case "$_ans" in
y|Y|yes|YES|Yes)
# Store KEY NAMES ONLY, never values — line 1 (the set_hash) is the
# only thing ever read back (for change-detection); the name list
# below is human-audit only, so persisting the secret VALUES to this
# file would be a needless plaintext credential store. `${_kv%%=*}`
# strips =VALUE.
{
printf '%s\n' "$set_hash"
printf '# workspace: %s\n' "$WORK_DIR"
printf '# approved: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/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
Expand All @@ -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.
Expand Down Expand Up @@ -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"
22 changes: 22 additions & 0 deletions templates/user-setup.sh.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down