Skip to content
Open
6 changes: 5 additions & 1 deletion bin/gstack-codex-probe
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,16 @@ _gstack_codex_timeout_wrapper() {
# Resolve wrapper binary: prefer gtimeout (Homebrew coreutils on macOS),
# fall back to timeout (Linux), else run unwrapped. Arguments: $1 is the
# duration in seconds; rest is the command to run.
#
# --kill-after=10: timeout sends SIGTERM at $_duration, then SIGKILL 10s later
# if the child is still alive. A plain `timeout $_duration` can't reap a child
# that traps/ignores SIGTERM; the escalation guarantees the wrapper fires.
local _duration="$1"
shift
local _to
_to=$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || echo "")
if [ -n "$_to" ]; then
"$_to" "$_duration" "$@"
"$_to" --kill-after=10 "$_duration" "$@"
else
"$@"
fi
Expand Down
120 changes: 120 additions & 0 deletions bin/gstack-gemini-probe
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# gstack-gemini-probe: shared helper for /gemini and outside-voice skills.
# Sourced from template bash blocks; never execute directly.
#
# Functions (all prefixed with _gstack_gemini_ for namespace hygiene):
# _gstack_gemini_auth_probe — auth check (login state + ~/.gemini/ files)
# _gstack_gemini_version_check — warn on known-bad gemini CLI versions
# _gstack_gemini_timeout_wrapper — gtimeout -> timeout -> unwrapped fallback
# _gstack_gemini_log_event — telemetry emission to ~/.gstack/analytics/
#
# Hygiene rules (mirror gstack-codex-probe):
# - Never set -e / set -u / trap / IFS= / PATH= in this file.
# - All internal vars prefix with _GSTACK_GEMINI_.
# - All functions prefix with _gstack_gemini_.
# - No command execution at source time (only function defs).

# --- Auth probe -------------------------------------------------------------

_gstack_gemini_auth_probe() {
# Gemini CLI authenticates via Google OAuth (interactive browser flow on
# first run) and stores tokens under one of:
# ~/.gemini/ (newer CLI default)
# ~/.config/google-gemini/ (alternate location)
# ~/.config/gemini/ (older alternate)
# Or via env var GEMINI_API_KEY for headless / CI use.
#
# We probe in order: env var, then any of the credential file locations.
# Multi-signal so we don't false-negative for users authenticated via env.
local _k1
_k1=$(printf '%s' "${GEMINI_API_KEY:-}" | tr -d '[:space:]')
if [ -n "$_k1" ]; then
echo "AUTH_OK"
return 0
fi
if [ -d "$HOME/.gemini" ] && [ -n "$(ls -A "$HOME/.gemini" 2>/dev/null)" ]; then
echo "AUTH_OK"
return 0
fi
if [ -d "$HOME/.config/google-gemini" ] && [ -n "$(ls -A "$HOME/.config/google-gemini" 2>/dev/null)" ]; then
echo "AUTH_OK"
return 0
fi
if [ -d "$HOME/.config/gemini" ] && [ -n "$(ls -A "$HOME/.config/gemini" 2>/dev/null)" ]; then
echo "AUTH_OK"
return 0
fi
echo "AUTH_FAILED"
return 1
}

# --- Version check ----------------------------------------------------------

_gstack_gemini_version_check() {
# Warn on known-bad gemini CLI versions. Anchored regex prevents false
# positives. Update this list when a new gemini CLI version regresses.
#
# Currently no known-bad releases; entries should be added when issues
# are discovered (e.g., stdin deadlocks, auth regressions, breaking
# output-format changes).
local _ver
_ver=$(gemini --version 2>/dev/null | head -1)
[ -z "$_ver" ] && return 0
# Example pattern (commented out — fill in when needed):
# if echo "$_ver" | grep -Eq '(^|[^0-9.])0\.38\.(0)([^0-9.]|$)'; then
# echo "WARN: gemini CLI $_ver has known issues. Upgrade: npm install -g @google/gemini-cli@latest"
# _gstack_gemini_log_event "gemini_version_warning"
# fi
return 0
}

# --- Timeout wrapper --------------------------------------------------------

_gstack_gemini_timeout_wrapper() {
# Resolve wrapper binary: prefer gtimeout (Homebrew coreutils on macOS),
# fall back to timeout (Linux), else run unwrapped. Arguments: $1 is the
# duration in seconds; rest is the command to run.
#
# --kill-after=10: at $_duration, timeout sends SIGTERM; if the child is still
# alive 10s later it sends SIGKILL. The gemini node CLI IGNORES SIGTERM, so a
# plain `timeout $_duration` never reaps a hung review (observed: ran 553s past
# a 330s cap). The escalation to SIGKILL guarantees the wrapper actually fires.
local _duration="$1"
shift
local _to
_to=$(command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || echo "")
if [ -n "$_to" ]; then
"$_to" --kill-after=10 "$_duration" "$@"
else
"$@"
fi
}

# --- Telemetry event --------------------------------------------------------

_gstack_gemini_log_event() {
# Emit a telemetry event to ~/.gstack/analytics/skill-usage.jsonl.
# Gated on $_TEL != "off" (caller sets this from gstack-config).
# Event types: gemini_timeout, gemini_auth_failed, gemini_cli_missing,
# gemini_version_warning.
# Payload schema: {skill, event, duration_s, ts}. NEVER includes prompt
# content, env var values, or auth tokens.
local _event="$1"
local _duration="${2:-0}"
[ "${_TEL:-off}" = "off" ] && return 0
local _ts
_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
mkdir -p "$HOME/.gstack/analytics" 2>/dev/null
printf '{"skill":"gemini","event":"%s","duration_s":"%s","ts":"%s"}\n' \
"$_event" "$_duration" "$_ts" \
>> "$HOME/.gstack/analytics/skill-usage.jsonl" 2>/dev/null || true
}

# --- Hang detection helper --------------------------------------------------

_gstack_gemini_log_hang() {
# Called when timeout fires. Emits a structured event for postmortem.
local _mode="$1" # review | challenge | consult
local _stderr_bytes="$2" # bytes captured before timeout
_gstack_gemini_log_event "gemini_hang_${_mode}" "$_stderr_bytes"
}
196 changes: 196 additions & 0 deletions bin/gstack-review-sandbox
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
#!/usr/bin/env bash
# gstack-review-sandbox — sourceable helpers for full-context, sandboxed,
# timeout-robust cross-model reviews (codex / gemini).
#
# WHY: a diff is too small a lens — reviewers must read the whole repo (and the
# web) or they manufacture confident-wrong "breaking change / field missing"
# findings (the proving context is in unchanged code, absent from the diff).
# Codex gets this via `-s read-only`; gemini has no read-only-with-tools mode
# (its `--approval-mode plan` disables tools entirely), so gemini must run
# tool-enabled inside a throwaway git worktree off a clean, pushed branch — the
# worktree + hard timeout + guaranteed cleanup is what makes the unsafe mode
# safe. The headless tool path is also flaky, so every call is timeout-bounded
# with a stall watchdog and one bounded retry.
#
# Public API:
# gstack_review_setup_worktree <repo_root> <branch> -> sets GR_WT GR_TIP GR_OUTDIR GR_PGIDFILE
# gstack_run_reviewer <name> <hard_s> <stall_s> <retries> -- <cmd...>
# -> prints "<name>:<STATUS>"
# findings in $GR_OUTDIR/<name>.out
# gstack_review_cleanup -> trap handler (idempotent)
#
# STATUS: OK (output) | CLEAN (exit 0, no output) | TIMEOUT (hung/stalled/killed)
# | CRASH (nonzero exit / OOM)
#
# Robustness: each reviewer runs in its OWN session (setsid) so the whole
# process tree is killable; `timeout --kill-after` converts a lockup into a
# bounded exit (TERM, then KILL); a stall watchdog kills early when output
# stops growing. PGIDs are tracked in a FILE (flock-appended) so tracking
# survives command-substitution, backgrounding and concurrency — cleanup reads
# that file and kills every surviving group on EVERY exit path, then removes
# the worktree. A reviewer that deliberately `setsid`-escapes its own group is
# an accepted edge (codex/gemini don't in normal use); the worktree removal is
# the file-level backstop.
#
# USAGE (self-contained orchestrator — run both reviewers concurrently in ONE
# process so the EXIT trap reaps everything):
# source gstack-review-sandbox
# trap gstack_review_cleanup EXIT INT TERM
# gstack_review_setup_worktree "$REPO" "$BRANCH" || exit 1
# gstack_run_reviewer codex 480 180 1 -- codex exec "$P" -C "$GR_WT" -s read-only ... >"$GR_OUTDIR/codex.status" &
# gstack_run_reviewer gemini 480 180 1 -- gemini -y -p "$P" ...(cwd=$GR_WT)... >"$GR_OUTDIR/gemini.status" &
# wait
# # read $GR_OUTDIR/{codex,gemini}.{status,out}; synthesize. Trap cleans up.

GR_WT=""
GR_TIP=""
GR_REPO=""
GR_OUTDIR=""
GR_PGIDFILE=""

_gstack_review_timeout_bin() {
command -v gtimeout 2>/dev/null || command -v timeout 2>/dev/null || true
}

_gstack_review_track_pgid() {
# Append a PGID to the shared file under an flock so concurrent reviewers
# don't interleave. The file (not a shell array) is the source of truth so
# tracking survives $(...) subshells and backgrounding.
local pgid="$1"
[ -n "$pgid" ] && [ -n "$GR_PGIDFILE" ] || return 0
( flock 9; printf '%s\n' "$pgid" >>"$GR_PGIDFILE" ) 9>"${GR_PGIDFILE}.lock" 2>/dev/null || \
printf '%s\n' "$pgid" >>"$GR_PGIDFILE" 2>/dev/null || true
}

gstack_review_setup_worktree() {
local repo="$1" branch="$2"
if ! git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "gstack-review-sandbox: '$repo' is not a git repo" >&2; return 1
fi
if [ -n "$(git -C "$repo" status --porcelain 2>/dev/null)" ]; then
echo "gstack-review-sandbox: '$repo' has uncommitted changes — commit/push first" >&2; return 1
fi
GR_TIP="$(git -C "$repo" rev-parse "$branch" 2>/dev/null)" || {
echo "gstack-review-sandbox: cannot resolve branch '$branch'" >&2; return 1; }
if git -C "$repo" rev-parse "$branch@{upstream}" >/dev/null 2>&1; then
local ahead; ahead=$(git -C "$repo" rev-list --count "$branch@{upstream}..$branch" 2>/dev/null || echo 0)
[ "${ahead:-0}" != "0" ] && echo "gstack-review-sandbox: WARN '$branch' is $ahead commit(s) ahead of upstream (not pushed)" >&2
else
echo "gstack-review-sandbox: WARN '$branch' has no upstream (not pushed)" >&2
fi
GR_REPO="$repo"
GR_OUTDIR="$(mktemp -d "${TMPDIR:-/tmp}/gstack-review-out-XXXXXX")"
GR_PGIDFILE="$GR_OUTDIR/.pgids"; : > "$GR_PGIDFILE"
GR_WT="$(mktemp -d "${TMPDIR:-/tmp}/gstack-review-wt-XXXXXX")/wt"
if ! git -C "$repo" worktree add --detach "$GR_WT" "$GR_TIP" >/dev/null 2>&1; then
echo "gstack-review-sandbox: failed to create review worktree" >&2; GR_WT=""; return 1
fi
printf '%s\n' "$GR_WT"
}

# Internal: run the command once, fully bounded. Echoes the STATUS token.
_gstack_review_run_once() {
local name="$1" hard="$2" stall="$3" out="$4" err="$5"; shift 5
local pidf rcf to launch pgid rc
pidf="$(mktemp)"; rcf="$(mktemp)"
: > "$out"; : > "$err"
to="$(_gstack_review_timeout_bin)"

# Launch in a NEW session (setsid) so the whole tree shares one PGID. The
# bash wrapper records its own PID (== PGID after setsid) and the real exit
# code, so classification is correct whether setsid forks or execs.
if [ -n "$to" ]; then
setsid bash -c 'echo "$$" >"$1"; "$4" --kill-after=10 "$3" "${@:5}"; echo "$?" >"$2"' \
_ "$pidf" "$rcf" "$hard" "$to" "$@" >"$out" 2>"$err" &
else
setsid bash -c 'echo "$$" >"$1"; "${@:3}"; echo "$?" >"$2"' \
_ "$pidf" "$rcf" "$@" >"$out" 2>"$err" &
fi
launch=$!

pgid=""; local i=0
while [ -z "$pgid" ] && [ "$i" -lt 40 ]; do
pgid="$(cat "$pidf" 2>/dev/null)"; [ -n "$pgid" ] && break
sleep 0.05; i=$((i+1))
done
_gstack_review_track_pgid "$pgid"

local killed_stall=0 last_size=-1 stall_acc=0 tick=3
while :; do
[ -s "$rcf" ] && break
if [ -n "$pgid" ]; then
kill -0 "-$pgid" 2>/dev/null || break
else
kill -0 "$launch" 2>/dev/null || break
fi
sleep "$tick"
if [ "$stall" -gt 0 ]; then
local sz; sz="$(stat -c %s "$out" 2>/dev/null || wc -c <"$out" 2>/dev/null || echo 0)"
if [ "$sz" = "$last_size" ]; then
stall_acc=$((stall_acc + tick))
if [ "$stall_acc" -ge "$stall" ]; then
[ -n "$pgid" ] && kill -KILL "-$pgid" 2>/dev/null
killed_stall=1; break
fi
else
last_size="$sz"; stall_acc=0
fi
fi
done
wait "$launch" 2>/dev/null || true
rc="$(cat "$rcf" 2>/dev/null || echo "")"
rm -f "$pidf" "$rcf"

if [ "$killed_stall" = 1 ]; then echo "TIMEOUT"; return 0; fi
case "$rc" in
0) if [ -s "$out" ]; then echo "OK"; else echo "CLEAN"; fi ;;
124|137) echo "TIMEOUT" ;;
"") echo "TIMEOUT" ;;
*) echo "CRASH" ;;
esac
}

gstack_run_reviewer() {
local name="$1" hard="$2" stall="$3" retries="$4"; shift 4
[ "$1" = "--" ] && shift
local out="$GR_OUTDIR/$name.out" err="$GR_OUTDIR/$name.err"
local attempt=0 status=""
while :; do
attempt=$((attempt + 1))
status="$(_gstack_review_run_once "$name" "$hard" "$stall" "$out" "$err" "$@")"
case "$status" in
OK|CLEAN) break ;;
TIMEOUT|CRASH)
[ "$attempt" -gt "$retries" ] && break
echo "gstack-review-sandbox: $name $status (attempt $attempt) — retrying" >&2
;;
esac
done
printf '%s:%s\n' "$name" "$status"
}

gstack_review_cleanup() {
# Kill every tracked process group FIRST (read from the file — survives
# subshells/concurrency), so nothing holds the worktree busy, then discard +
# remove the worktree. Idempotent.
if [ -n "$GR_PGIDFILE" ] && [ -f "$GR_PGIDFILE" ]; then
local g
while IFS= read -r g; do
[ -n "$g" ] && kill -KILL "-$g" 2>/dev/null || true
done < "$GR_PGIDFILE"
: > "$GR_PGIDFILE"
fi
if [ -n "$GR_WT" ] && [ -d "$GR_WT" ]; then
local dirty; dirty="$(git -C "$GR_WT" status --porcelain 2>/dev/null)"
if [ -n "$dirty" ]; then
echo "gstack-review-sandbox: reviewer wrote to the worktree (discarding):" >&2
printf '%s\n' "$dirty" | sed 's/^/ /' >&2
fi
if [ -n "$GR_REPO" ]; then
git -C "$GR_REPO" worktree remove --force "$GR_WT" >/dev/null 2>&1 || rm -rf "$(dirname "$GR_WT")"
else
rm -rf "$(dirname "$GR_WT")"
fi
GR_WT=""
fi
}
8 changes: 4 additions & 4 deletions bin/gstack-update-check
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ CACHE_FILE="$STATE_DIR/last-update-check"
MARKER_FILE="$STATE_DIR/just-upgraded-from"
SNOOZE_FILE="$STATE_DIR/update-snoozed"
VERSION_FILE="$GSTACK_DIR/VERSION"
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"
REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}"
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/swxtchio/gstack/swxtch/VERSION}"
REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/swxtchio/gstack.git}"

# ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ──
if [ "${1:-}" = "--force" ]; then
Expand Down Expand Up @@ -194,10 +194,10 @@ if [ -z "${GSTACK_REMOTE_URL:-}" ]; then
# Disable credential prompts and apply a 5-second low-speed timeout so a
# flaky network or captive portal can't hang every skill preamble.
_LSR_LINE="$(GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \
git ls-remote "$REMOTE_REPO" refs/heads/main 2>/dev/null || true)"
git ls-remote "$REMOTE_REPO" refs/heads/swxtch 2>/dev/null || true)"
_REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')"
if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
_SHA_URL="https://raw.githubusercontent.com/garrytan/gstack/${_REMOTE_SHA}/VERSION"
_SHA_URL="https://raw.githubusercontent.com/swxtchio/gstack/${_REMOTE_SHA}/VERSION"

@augmentcode augmentcode Bot Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_SHA_URL is hard-coded to swxtchio/gstack, so if someone overrides GSTACK_REMOTE_REPO (without also setting GSTACK_REMOTE_URL) the SHA-pinned fetch can consult the wrong repo (or fail) and make the update check inconsistent with the override. Other locations where this applies: bin/gstack-update-check:197.

Severity: medium

Other Locations
  • bin/gstack-update-check:197

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

REMOTE="$(curl -sf --max-time 5 "$_SHA_URL" 2>/dev/null || true)"
fi
fi
Expand Down
11 changes: 11 additions & 0 deletions codex/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@ mode (persona prompt). Reference this section as "the filesystem boundary" below

Run Codex code review against the current branch diff.

**Note — full-context is already Codex's strength.** Unlike gemini, `codex exec`
/`codex review` run in a real read-only-but-tool-enabled sandbox (`-s read-only`),
so Codex already reads the whole repo + web, not just the diff. For
timeout-robust, clean-snapshot review (review a clean+pushed worktree instead of
a dirty working tree, with a hard timeout + stall watchdog + auto-cleanup), wrap
the invocation in the shared harness — see `~/.claude/skills/gstack/bin/gstack-review-sandbox`
and the orchestrator example in the fix-and-ship skill:
`gstack_run_reviewer codex 480 180 1 -- codex exec "$PROMPT" -C "$GR_WT" -s read-only …`.
The default path below is the standalone (non-orchestrated) invocation; it keeps
its own `_gstack_codex_timeout_wrapper`.

1. Create temp files for output capture:
```bash
TMPERR=$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt")
Expand Down
Loading
Loading