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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ Decouples a session's lifetime from the launching client, so a session survives
**Decisions (documented for the `sandy-ui` consumer contract):**
- **DEC-A — concurrent attach = last-wins** via `tmux attach -d` (a second client cleanly displaces the first; the displaced client exits `3`). Never plain `tmux attach` (that mirrors — the one banned outcome).
- **DEC-B — bare `sandy` over a live daemon session = error-with-hint + exit `1`** (points at `--attach` / `--stop`); keyed off the container label so a supervisor-less rebooted session is respected, not clobbered.
- **DEC-C — exit codes.** `--attach`: `0` = session ended while attached, `3` = clean detach (session lives), `4` = no such session, `5` = attach failed. `--stop`: `0` = stopped, `4` = no such session, `5` = teardown failed. A client attached when `--stop` runs elsewhere sees the container vanish → exits `0`.
- **DEC-C — exit codes.** `--attach`: `0` = session ended while attached, `3` = clean detach (session lives), `4` = no such session, `5` = attach failed. `--stop`: `0` = stopped, `4` = no such session, `5` = teardown failed. A client attached when `--stop` runs elsewhere sees the container vanish → exits `0`. **Post-attach, `5` is reserved for a failure to *establish* the attach** — once `tmux attach` returns, the outcome is only `3` (session still up) or `0` (session gone). The session-gone case includes the brief window where the container is *still up* but the inner session has ended (the agent exited and the #47 supervisor watch-loop is mid-teardown): that maps to `0`, not `5`, so sandy-ui stops sticky-reconnecting a dying session. A single transient `docker exec` probe failure is absorbed by a one-shot re-probe before the `0` verdict.

**`--stop` interplay with the #14 lock:** if the supervisor PID is alive, `--stop` signals it (`kill -TERM`) so the supervisor's *own* trap releases the lock (nothing else ever removes a live-owned lock). If the supervisor is dead (D9 reboot case), `--stop` tears the container/networks down directly and reaps the now-stale lock (whose holder PID is provably dead). This is the only unavoidable cleanup duplication, bounded to container+network+lock.

Expand Down
4 changes: 3 additions & 1 deletion SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1041,9 +1041,11 @@ For each configured channel:
`$SANDY_HOME/channel-relay.sh` is a generated bash script that long-polls the Telegram Bot API (`getUpdates`), filters messages by `TELEGRAM_ALLOWED_SENDERS`, and injects them into the container tmux session via:

```
docker exec <CONTAINER_NAME> tmux send-keys -t sandy.<PANE> "<text>" Enter
docker exec -u <host-uid> <CONTAINER_NAME> tmux send-keys -t sandy.<PANE> "<text>" Enter
```

The `-u "$(id -u)"` is required: `docker exec` defaults to the image user (root, since sandy sets no `USER`), but the in-container tmux server runs as the gosu-dropped host uid, so its socket lives at `/tmp/tmux-<uid>/`. Without `-u`, root can't see that socket and both `has-session` and `send-keys` silently fail — the host-side relay (gemini/codex/opencode channels) never delivers. (Claude channels use an in-container plugin and are unaffected.)

Launched as a background process before `docker run`, tracked via `CHANNEL_RELAY_PID`, and killed in the cleanup trap. The target pane is `SANDY_CHANNEL_TARGET_PANE` (default `0` = the first agent listed in `SANDY_AGENT`, or the sole pane in single-agent mode).

**Scope**: Telegram only in v0.9.0; Discord via relay is deferred. The relay is stateless — no chat threading, no attachment support, no edit-message reactions. For rich features, use the claude plugin path.
Expand Down
52 changes: 33 additions & 19 deletions sandy
Original file line number Diff line number Diff line change
Expand Up @@ -2458,30 +2458,32 @@ allowed-tools: Bash, Read
argument-hint: "[N] [action]"
---

!`set -- $ARGUMENTS; if [ $# -ge 1 ] && expr "$1" : '^[0-9][0-9]*$' >/dev/null; then sandy-ss-paths "$1"; else sandy-ss-paths 1; fi`
!`sandy-ss-paths 5 2>&1 || true`

User action: $ARGUMENTS

Read each image listed above (use the Read tool on each path). The first arg
to /ss may be a count of screenshots to grab — strip it from the user action
if so. Then respond to whatever the user asked: "huh"/"explain" → describe
what you see; "fix" → diagnose the error/bug shown and edit the relevant
code; "do this" → identify the technique and apply it to our work; "make
infographic" → synthesize the screenshots into one. Use the screenshots as
the user's visual input to you.
The list above shows up to the 5 newest screenshots (newest first). If the
user's action begins with a number N, that's how many they want — read only
the first N paths (default 1 when there's no leading number). Use the Read
tool on each chosen path. Then respond to whatever the user asked:
"huh"/"explain" → describe what you see; "fix" → diagnose the error/bug shown
and edit the relevant code; "do this" → identify the technique and apply it
to our work; "make infographic" → synthesize the screenshots into one. Use
the screenshots as the user's visual input to you.
SSMD
fi
if _sandy_has_gemini; then
_sandy_write_file "$HOME/.gemini/commands/ss.toml" <<'SSTOML'
description = "Look at recent screenshot(s) from the host"
prompt = """Recent screenshot(s) (newest first):
!{ if echo "{{args}}" | awk '{exit !($1 ~ /^[0-9]+$/)}'; then sandy-ss-paths $(echo "{{args}}" | awk '{print $1}'); else sandy-ss-paths 1; fi }
prompt = """Recent screenshot(s), up to the 5 newest (newest first):
!{ sandy-ss-paths 5 2>&1 || true }

User action: {{args}}

Read each image path listed above. The first arg may be a count — strip it
from the user action if so. Respond to what the user asked: explain, fix
the visible error, do/remix the technique shown, make an infographic, etc.
If the user's action begins with a number N, read only the first N paths
above (default 1 when there's no leading number). Read each chosen image
path. Respond to what the user asked: explain, fix the visible error,
do/remix the technique shown, make an infographic, etc.
"""
SSTOML
fi
Expand Down Expand Up @@ -3243,13 +3245,13 @@ _inject() {
# Wait for container tmux session to exist (up to 30s)
local tries=0
while [ $tries -lt 60 ]; do
if docker exec "$SANDY_CONTAINER_NAME" tmux has-session -t sandy 2>/dev/null; then
if docker exec -u "$(id -u)" "$SANDY_CONTAINER_NAME" tmux has-session -t sandy 2>/dev/null; then
break
fi
sleep 0.5
tries=$((tries+1))
done
docker exec "$SANDY_CONTAINER_NAME" tmux send-keys -t "sandy.${TARGET_PANE}" "$text" Enter 2>/dev/null || true
docker exec -u "$(id -u)" "$SANDY_CONTAINER_NAME" tmux send-keys -t "sandy.${TARGET_PANE}" "$text" Enter 2>/dev/null || true
}

# Drain stale updates once at startup
Expand Down Expand Up @@ -3701,17 +3703,29 @@ if [ "$SANDY_ATTACH" = "true" ]; then
# - session still up (has-session succeeds) → 3, clean detach
# - container is gone/stopped (e.g. --stop ran
# elsewhere while we were attached) → 0, session ended
# - container still up but the has-session probe
# itself failed for some other reason → 5, unexpected failure
# - container still up but the session is gone → 0, session ended
# (the agent exited; the #47 supervisor watch-
# loop is about to tear the container down —
# that is a normal end, not an "attach failed")
if docker exec -u "$(id -u)" "$_sandy_attach_container" tmux has-session -t sandy >/dev/null 2>&1; then
exit 3
fi
_sandy_attach_still_running="$(docker inspect -f '{{.State.Running}}' "$_sandy_attach_container" 2>/dev/null || echo false)"
if [ "$_sandy_attach_still_running" != "true" ]; then
exit 0
fi
error "Attach ended unexpectedly (container still running, but tmux session probe failed)."
exit 5
# Container is briefly still up but our session probe failed. Re-probe once
# after a short settle to rule out a transient `docker exec` hiccup that would
# otherwise misreport a live session as ended (→ spurious sticky-reconnect loss).
sleep 1
if docker exec -u "$(id -u)" "$_sandy_attach_container" tmux has-session -t sandy >/dev/null 2>&1; then
exit 3
fi
# Session is genuinely gone while the daemon container is on its way down.
# This is the normal "agent exited while a client was attached" path — report
# it as ended (0), not "attach failed" (5), so sandy-ui stops reconnecting it.
info "Session ended (agent exited); the daemon is shutting down."
exit 0
fi

# --- Daemon-mode dispatcher (--stop; milestone 1.1.0, #17 Batch 2) ---
Expand Down
28 changes: 15 additions & 13 deletions templates/user-setup.sh.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -228,30 +228,32 @@ allowed-tools: Bash, Read
argument-hint: "[N] [action]"
---

!`set -- $ARGUMENTS; if [ $# -ge 1 ] && expr "$1" : '^[0-9][0-9]*$' >/dev/null; then sandy-ss-paths "$1"; else sandy-ss-paths 1; fi`
!`sandy-ss-paths 5 2>&1 || true`

User action: $ARGUMENTS

Read each image listed above (use the Read tool on each path). The first arg
to /ss may be a count of screenshots to grab — strip it from the user action
if so. Then respond to whatever the user asked: "huh"/"explain" → describe
what you see; "fix" → diagnose the error/bug shown and edit the relevant
code; "do this" → identify the technique and apply it to our work; "make
infographic" → synthesize the screenshots into one. Use the screenshots as
the user's visual input to you.
The list above shows up to the 5 newest screenshots (newest first). If the
user's action begins with a number N, that's how many they want — read only
the first N paths (default 1 when there's no leading number). Use the Read
tool on each chosen path. Then respond to whatever the user asked:
"huh"/"explain" → describe what you see; "fix" → diagnose the error/bug shown
and edit the relevant code; "do this" → identify the technique and apply it
to our work; "make infographic" → synthesize the screenshots into one. Use
the screenshots as the user's visual input to you.
SSMD
fi
if _sandy_has_gemini; then
_sandy_write_file "$HOME/.gemini/commands/ss.toml" <<'SSTOML'
description = "Look at recent screenshot(s) from the host"
prompt = """Recent screenshot(s) (newest first):
!{ if echo "{{args}}" | awk '{exit !($1 ~ /^[0-9]+$/)}'; then sandy-ss-paths $(echo "{{args}}" | awk '{print $1}'); else sandy-ss-paths 1; fi }
prompt = """Recent screenshot(s), up to the 5 newest (newest first):
!{ sandy-ss-paths 5 2>&1 || true }

User action: {{args}}

Read each image path listed above. The first arg may be a count — strip it
from the user action if so. Respond to what the user asked: explain, fix
the visible error, do/remix the technique shown, make an infographic, etc.
If the user's action begins with a number N, read only the first N paths
above (default 1 when there's no leading number). Read each chosen image
path. Respond to what the user asked: explain, fix the visible error,
do/remix the technique shown, make an infographic, etc.
"""
SSTOML
fi
Expand Down
30 changes: 30 additions & 0 deletions test/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3679,6 +3679,36 @@ check "screenshot mount gated on SANDY_SCREENSHOT_DIR being set" \
| grep -qF "if [ -n \"\${SANDY_SCREENSHOT_DIR:-}\" ]"
' -- "$_SS_SCRIPT"

# 45j. /ss injection hardening (#43). The shell-exec lines in the generated
# /ss commands must run a FIXED `sandy-ss-paths` with NO user-argument splicing
# — the count argument is now handled in model prose. A quoted path in
# $ARGUMENTS / {{args}} textually spliced into the `!`…`` (claude) or `!{ }`
# (gemini) shell line would break the parse or inject a command.
check "claude /ss shell line runs a fixed sandy-ss-paths (no \$ARGUMENTS splice)" \
grep -qF '!`sandy-ss-paths 5 2>&1 || true`' "$_SS_SCRIPT"
check "gemini /ss shell block runs a fixed sandy-ss-paths (no {{args}} splice)" \
grep -qF '!{ sandy-ss-paths 5 2>&1 || true }' "$_SS_SCRIPT"
check "no user-arg splicing into a /ss shell-exec line" \
bash -c '! grep -qF "set -- \$ARGUMENTS" "$1"' -- "$_SS_SCRIPT"

# ============================================================
# SECTION 46: Channel relay (host-side tmux inject)
# ============================================================
# The agent-agnostic host-side relay (gemini/codex/opencode channels) injects
# messages via `docker exec … tmux`. docker exec defaults to the image user
# (root — sandy sets no USER), but the in-container tmux server runs as the
# gosu-dropped host uid, so its socket lives at /tmp/tmux-<uid>/. Without
# `-u "$(id -u)"` root can't see that socket and both has-session and send-keys
# silently fail — the relay never delivers (#48).
info "46. Channel relay uid-scoping (#48)"
_CR_SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/sandy"
check "channel-relay has-session exec is uid-scoped" \
grep -qF 'docker exec -u "$(id -u)" "$SANDY_CONTAINER_NAME" tmux has-session' "$_CR_SCRIPT"
check "channel-relay send-keys exec is uid-scoped" \
grep -qF 'docker exec -u "$(id -u)" "$SANDY_CONTAINER_NAME" tmux send-keys' "$_CR_SCRIPT"
check "no bare (root) channel-relay tmux exec remains" \
bash -c '! grep -qF '\''docker exec "$SANDY_CONTAINER_NAME" tmux'\'' "$1"' -- "$_CR_SCRIPT"

# ============================================================
# SECTION 47: User-defined env passthrough (SANDY_EXTRA_ENV)
# ============================================================
Expand Down