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
25 changes: 23 additions & 2 deletions src/panopticon/sessionservice/shell_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@
_TASK_LIB = (importlib.resources.files("panopticon.sessionservice") / "task_lib.sh").read_text()


def _minify_shell(script: str) -> str:
"""Drop whole-line comments and blank lines from an assembled shell command.

tmux sends a whole ``new-session`` command (including the pane's shell command) to its server
over ``imsg``, which caps a single message at ``MAX_IMSGSIZE`` (16 KiB). A richly commented
workflow script plus the task lib and env-file can push the assembled command past that, and
then ``new-session`` fails outright (before the script even runs) — so we strip the parts that
are pure bulk at runtime. Only lines that are **entirely** a comment (optional leading whitespace
then ``#``) or blank are removed, so trailing/inline comments and every line of code are left
untouched; the source files keep their comments. Panopticon's shell assets don't put ``#`` lines
inside heredocs (where this would be wrong) — keep it that way.
"""
return "\n".join(
line for line in script.splitlines() if line.strip() and not line.lstrip().startswith("#")
)


class ShellRunner(Runner):
"""Runs a shell workflow's script in a host tmux session (one host, no container)."""

Expand Down Expand Up @@ -68,6 +85,7 @@ def spawn(
*,
env_file: str | None = None,
git_url: str | None = None,
repo_name: str | None = None,
script: str = "",
workdir: str | None = None,
progress: Callable[[LifecyclePhase], None] | None = None,
Expand All @@ -82,7 +100,8 @@ def spawn(
exported — so the script can drive its own lifecycle over REST (e.g. advance to COMPLETE on
success) — and the repo's ``env_file`` secrets sourced first when given. ``git_url``, when
given, is exported as ``PANOPTICON_GIT_URL`` so a script can tell what forge the repo lives
on (e.g. offer to record a GitHub token). ``env_file`` is a
on (e.g. offer to record a GitHub token); ``repo_name`` is exported as ``PANOPTICON_REPO_NAME``
so a script can name the repo in its summary. ``env_file`` is a
**name relative to this runner's secrets dir** (ADR 0007), resolved host-locally (like
``LocalRunner``) so a remote runner uses its own host's secrets. The panopticon shell lib
(``panopticon_advance``/``_drop``/…) is loaded into the shell so the script can drive its task
Expand Down Expand Up @@ -113,6 +132,7 @@ def _report(phase: LifecyclePhase) -> None:
f"export PANOPTICON_TASK_ID={shlex.quote(task_id)}",
f"export PANOPTICON_RUNNER_ID={shlex.quote(self._runner_id)}",
*([f"export PANOPTICON_GIT_URL={shlex.quote(git_url)}"] if git_url else []),
*([f"export PANOPTICON_REPO_NAME={shlex.quote(repo_name)}"] if repo_name else []),
f"curl --silent --no-buffer {shlex.quote(live_url)} >/dev/null 2>&1 &",
"_panopticon_live_pid=$!",
"trap 'kill $_panopticon_live_pid 2>/dev/null' EXIT",
Expand All @@ -127,7 +147,8 @@ def _report(phase: LifecyclePhase) -> None:
lines.append(f"export PANOPTICON_ENV_FILE={quoted}")
lines.append(f"[ -f {quoted} ] && {{ set -a; . {quoted}; set +a; }}")
lines.append(script)
command = "\n".join(lines)
# Strip comments/blank lines so the assembled command stays under tmux's 16 KiB imsg cap.
command = _minify_shell("\n".join(lines))
# Clear any stale session first so a respawn is idempotent (no-op when none exists).
self._run(self._tmux("kill-session", "-t", session), check=False)
_report(LifecyclePhase.STARTING)
Expand Down
1 change: 1 addition & 0 deletions src/panopticon/sessionservice/spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ def _spawn_shell(self, task: JsonObj, repo: JsonObj) -> str:
task_id,
env_file=repo.get("env_file"), # per-repo secrets, sourced into the shell (ADR 0007)
git_url=repo.get("git_url"), # the repo's forge — lets a script detect a GitHub remote
repo_name=repo.get("name"), # so a script can name the repo in its summary
script=spec["script"],
workdir=workdir,
progress=lambda phase: self._report(task_id, phase), # STARTING then AWAITING
Expand Down
259 changes: 186 additions & 73 deletions src/panopticon/workflows/setup_repo.sh
Original file line number Diff line number Diff line change
@@ -1,42 +1,108 @@
# Collect a Claude auth token (`claude setup-token`) for the repo's env-file. Run by the session
# service in a host tmux session (no container); ShellRunner sources the repo's env-file first, so an
# already-configured credential shows up as an env var, and exports PANOPTICON_ENV_FILE (its path)
# and PANOPTICON_GIT_URL (the repo's remote, used to detect a GitHub forge below).
# Set up a repo's per-repo credentials for task containers: a Claude token, and — for a GitHub repo —
# a GH_TOKEN, written into the repo's env-file. Run by the session service in a host tmux session (no
# container); ShellRunner sources the repo's env-file first (so a configured credential shows up as
# an env var) and exports PANOPTICON_ENV_FILE (its path), PANOPTICON_GIT_URL (the repo's remote, used
# to detect a GitHub forge below), and PANOPTICON_REPO_NAME (the repo's label, for the summary).
#
# Whatever route the operator takes, the script converges on a summary + a prompt to press Enter,
# which completes the task and returns them to the dashboard.
# Each credential can be adopted from the operator's own environment, pasted, or (for Claude) minted
# with `claude setup-token`. Whatever route the operator takes, the script converges on a bulleted
# summary + a prompt to press Enter, which completes the task and returns them to the dashboard.

env_file="${PANOPTICON_ENV_FILE:-the repo's env-file}"
repo_name="${PANOPTICON_REPO_NAME:-this repo}"
repo_url="${PANOPTICON_GIT_URL:-}"
repo_label=$(repo_source_label "$repo_url")

# How to get back to the dashboard: detach from this tmux session. Detect the prefix + detach key
# from the running server (the operator may have rebound them), falling back to the tmux defaults.
prefix=$(tmux show-options -gv prefix 2>/dev/null)
[ -n "$prefix" ] || prefix="C-b"
detach=$(tmux list-keys -T prefix 2>/dev/null | awk '$NF == "detach-client" { print $(NF - 1); exit }')
[ -n "$detach" ] || detach="d"
dashboard_hint="To return to the dashboard without finishing, detach: press $prefix then $detach (the task stays running)."
dashboard_hint="To return to the dashboard without finishing, detach: press $prefix then $detach (you can resume this task any time from the dashboard)."

# Show how to get back to the dashboard up front, before anything else.
# Open by explaining what this does and that the operator stays in control — task containers get
# per-repo tokens from the env-file (not the operator's own session), and they can opt out entirely.
echo "Setting up '$repo_name'. Task containers use per-repo credentials from this repo's env-file — a"
echo "Claude token, and a GH_TOKEN for GitHub repos — so the agent runs on its own tokens, not your"
echo "personal session. You can skip this and set up your own secrets by editing $env_file yourself."
echo

# Show how to get back to the dashboard, up front before any prompts.
echo "$dashboard_hint"
echo

summary=""
# Work out what's already set up and what this repo needs. The Claude credential is always needed
# (the agent runs `claude` regardless); a GH_TOKEN is only needed for a GitHub remote (a local
# checkout has nothing to push). "Configured" means the **env-file** carries it — the only thing the
# container sees (it's launched with `--env-file`, not the host env), so a token that lives only in
# the operator's shell doesn't count as done.
claude_configured=0
if env_file_has_var CLAUDE_CODE_OAUTH_TOKEN "${PANOPTICON_ENV_FILE:-}" \
|| env_file_has_var ANTHROPIC_API_KEY "${PANOPTICON_ENV_FILE:-}"; then
claude_configured=1
fi
gh_needed=0
gh_configured=0
if is_github_url "$repo_url"; then
gh_needed=1
env_file_has_var GH_TOKEN "${PANOPTICON_ENV_FILE:-}" && gh_configured=1
fi

# What we know about the repo, and what its setup entails — two bulleted lists up front.
echo "This repo:"
echo " • Name: $repo_name"
echo " • Source: $repo_label"
echo
echo "To set up:"
if [ "$claude_configured" -eq 1 ]; then
echo " • Claude credential — already in $env_file"
else
echo " • Claude credential — needed"
fi
if [ "$gh_needed" -eq 1 ]; then
if [ "$gh_configured" -eq 1 ]; then
echo " • GH_TOKEN — already in $env_file"
else
echo " • GH_TOKEN — needed (GitHub repo)"
fi
else
echo " • GH_TOKEN — not needed (not a GitHub repo)"
fi
echo

# Append clause $1 to the running $summary, space-separating it from anything already there. Each
# step records its own outcome this way, so the order the steps run in doesn't clobber the summary.
# The closing summary is a bullet per step; each step appends its outcome here.
summary=""
add_summary() {
if [ -n "$summary" ]; then
summary="$summary $1"
if [ -z "$summary" ]; then
summary=" • $1"
else
summary="$summary
• $1"
fi
}

# Write token $2 for var $1 into the env-file, echoing + summarizing the outcome. $3 is the source
# label, $4 the credential label for the summary. Goes through store_env_token, so any existing value
# is commented out and replaced. The shared write step for every path (adopt / paste / mint).
store_token() {
if [ -n "$2" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \
&& store_env_token "$1" "$2" "$PANOPTICON_ENV_FILE"; then
echo
echo "Wrote $1 to $env_file (any previous one was commented out)."
add_summary "$4: wrote $1 to $env_file from $3 (any previous one was commented out)."
else
summary="$1"
echo
echo "Couldn't write $1. Add it to $env_file yourself."
add_summary "$4: couldn't write it — add $1 to $env_file yourself."
fi
}

# Mint a token and record the outcome in $summary. On success, capture the minted token and write it
# straight into the repo's env-file (commenting out any previous one — see store_oauth_token); fall
# back to on-screen copy instructions when it can't be captured or there's no env-file to write to.
# extract_oauth_token / store_oauth_token come from setup_repo_lib.sh (prepended by shell_script()).
collect_token() {
# Mint a Claude token with `claude setup-token` and store it — the leaf used when the operator has no
# token to adopt or paste. On success, capture the minted token and write it into the env-file; fall
# back to on-screen copy instructions when it can't be captured. extract_oauth_token / store_env_token
# come from setup_repo_lib.sh (prepended by shell_script()).
mint_claude_token() {
echo
echo "Running 'claude setup-token' — follow the prompts to mint a token."
echo
Expand All @@ -60,83 +126,130 @@ collect_token() {
fi

if [ "$_ct_ok" -eq 0 ]; then
add_summary "'claude setup-token' failed or was cancelled — no token was collected."
add_summary "Claude credential: 'claude setup-token' failed or was cancelled — nothing set up."
elif [ -n "$_ct_token" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \
&& store_oauth_token "$_ct_token" "$PANOPTICON_ENV_FILE"; then
&& store_env_token CLAUDE_CODE_OAUTH_TOKEN "$_ct_token" "$PANOPTICON_ENV_FILE"; then
echo
echo "Wrote the new token to $env_file as CLAUDE_CODE_OAUTH_TOKEN (any previous one was commented out)."
add_summary "Minted a new token and wrote it to $env_file (any previous token was commented out)."
add_summary "Claude credential: minted a new token and wrote it to $env_file (any previous one was commented out)."
else
# Minted, but we couldn't capture/extract it or there's no env-file configured — guide the copy.
# Minted, but we couldn't capture/extract it or there's no env-file — guide the copy.
echo
echo "Token minted. Copy the token shown above into $env_file as:"
echo " CLAUDE_CODE_OAUTH_TOKEN=<token>"
add_summary "Minted a new token — copy it into $env_file as CLAUDE_CODE_OAUTH_TOKEN."
add_summary "Claude credential: minted a new token — copy it into $env_file as CLAUDE_CODE_OAUTH_TOKEN."
fi
}

# Offer to record a GitHub token in the repo's env-file, but only when it's both wanted and missing:
# the repo is hosted on GitHub (PANOPTICON_GIT_URL), a GH_TOKEN is present in the environment (e.g.
# the operator's own shell — the shell runner inherits the host env), and the env-file doesn't
# already carry an active GH_TOKEN line. Records the outcome in $summary. is_github_url /
# env_file_has_var / append_env_var come from setup_repo_lib.sh (prepended by shell_script()).
maybe_offer_github_token() {
is_github_url "${PANOPTICON_GIT_URL:-}" || return 0
[ -n "${GH_TOKEN:-}" ] || return 0
[ -n "${PANOPTICON_ENV_FILE:-}" ] || return 0
! env_file_has_var GH_TOKEN "$PANOPTICON_ENV_FILE" || return 0
# Set up the Claude credential: offer to adopt a token from the operator's own environment (fast path
# for an already-authenticated operator — masked for consent, default-No), else let them paste one,
# else mint a fresh one with `claude setup-token`. Only offers to adopt a var that isn't already the
# env-file's own (so replacing a configured token goes straight to paste/mint).
setup_claude_token() {
_sct_var=""
_sct_val=""
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] \
&& ! env_file_has_var CLAUDE_CODE_OAUTH_TOKEN "${PANOPTICON_ENV_FILE:-}"; then
_sct_var=CLAUDE_CODE_OAUTH_TOKEN
_sct_val=$CLAUDE_CODE_OAUTH_TOKEN
elif [ -n "${ANTHROPIC_API_KEY:-}" ] \
&& ! env_file_has_var ANTHROPIC_API_KEY "${PANOPTICON_ENV_FILE:-}"; then
_sct_var=ANTHROPIC_API_KEY
_sct_val=$ANTHROPIC_API_KEY
fi
if [ -n "$_sct_val" ]; then
echo "A $_sct_var is set in your environment (ending $(mask_last4 "$_sct_val"))."
printf 'Add it to %s for task containers to use? [y/N] ' "$env_file"
read answer
case "$answer" in
[Yy]*)
store_token "$_sct_var" "$_sct_val" "your environment" "Claude credential"
return
;;
esac
fi
echo
echo "This repo is hosted on GitHub and a GH_TOKEN is set in your environment, but $env_file"
echo "has no GH_TOKEN. Adding it lets task containers use 'gh' and push over HTTPS."
echo "Paste a Claude token to store it (a CLAUDE_CODE_OAUTH_TOKEN or an ANTHROPIC_API_KEY),"
echo "or press Enter to mint one with 'claude setup-token'."
printf '> '
read pasted
if [ -n "$pasted" ]; then
_pv=CLAUDE_CODE_OAUTH_TOKEN
case "$pasted" in sk-ant-api*) _pv=ANTHROPIC_API_KEY ;; esac
store_token "$_pv" "$pasted" "the token you pasted" "Claude credential"
else
mint_claude_token
fi
}

# Set up the GH_TOKEN for a GitHub repo: adopt one from the operator's environment (masked, default-No)
# if present and not already the env-file's own, else let them paste one, else skip (guide them to add
# it themselves). We don't mint a GitHub token here.
setup_gh_token() {
if [ -n "${GH_TOKEN:-}" ] && ! env_file_has_var GH_TOKEN "${PANOPTICON_ENV_FILE:-}"; then
echo "A GH_TOKEN is set in your environment (ending $(mask_last4 "$GH_TOKEN"))."
echo "Adding it to $env_file lets task containers use 'gh' and push over HTTPS."
printf 'Add it to %s? [y/N] ' "$env_file"
read gh_answer
case "$gh_answer" in
[Yy]*)
store_token GH_TOKEN "$GH_TOKEN" "your environment" "GH_TOKEN"
return
;;
esac
fi
echo
printf 'Add GH_TOKEN to %s? [y/N] ' "$env_file"
read gh_answer
case "$gh_answer" in
[Yy]*)
if append_env_var GH_TOKEN "$GH_TOKEN" "$PANOPTICON_ENV_FILE"; then
echo "Wrote GH_TOKEN to $env_file."
add_summary "Added GH_TOKEN to $env_file."
else
echo "Could not write GH_TOKEN to $env_file."
add_summary "Could not add GH_TOKEN to $env_file."
fi
;;
*) add_summary "Left GH_TOKEN out of $env_file." ;;
esac
echo "Paste a GitHub token to store it, or press Enter to skip (add GH_TOKEN to $env_file yourself)."
printf '> '
read pasted
if [ -n "$pasted" ]; then
store_token GH_TOKEN "$pasted" "the token you pasted" "GH_TOKEN"
else
add_summary "GH_TOKEN: none set up — add GH_TOKEN to $env_file yourself."
fi
}

if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; then
echo "A Claude credential is already configured in $env_file."
echo "To keep using it, drop this task instead (press 'x' in the dashboard)."
# --- Claude credential -------------------------------------------------------------------------
if [ "$claude_configured" -eq 1 ]; then
echo "A Claude credential is already set in $env_file."
echo
printf 'Collect a new token anyway? [y/N] '
printf 'Replace it? [y/N] '
read answer
case "$answer" in
[Yy]*) collect_token ;;
*) add_summary "Kept the existing credential in $env_file — nothing collected." ;;
[Yy]*) setup_claude_token ;;
*) add_summary "Claude credential: kept the existing one in $env_file." ;;
esac
else
echo "No Claude credential found in $env_file."
echo "About to collect one with 'claude setup-token'."
echo
echo "Prefer to use your own? Drop this task (press 'x' in the dashboard) and add one of"
echo "these to $env_file yourself:"
echo " CLAUDE_CODE_OAUTH_TOKEN=<token from 'claude setup-token'>"
echo " ANTHROPIC_API_KEY=<your Anthropic API key>"
echo
printf 'Press Enter to collect a token now. '
read _
collect_token
setup_claude_token
fi

# With the Claude credential settled, offer to record a GitHub token too (no-op unless it applies).
maybe_offer_github_token
# --- GH_TOKEN (GitHub repos only) --------------------------------------------------------------
if [ "$gh_needed" -eq 1 ]; then
echo
if [ "$gh_configured" -eq 1 ]; then
echo "A GH_TOKEN is already set in $env_file."
echo
printf 'Replace it? [y/N] '
read answer
case "$answer" in
[Yy]*) setup_gh_token ;;
*) add_summary "GH_TOKEN: kept the existing one in $env_file." ;;
esac
else
setup_gh_token
fi
fi

# Every route converges here: summarize what happened, then complete the task on Enter (which ends
# the session and returns the operator to the dashboard; detaching instead — see the hint above —
# leaves it running).
# Every route converges here: summarize what happened (a bullet per step), then complete the task on
# Enter (which ends the session and returns the operator to the dashboard; detaching instead — see
# the hint above — leaves it running).
echo
echo "Summary: $summary"
echo "Summary:"
if [ -n "$summary" ]; then
echo "$summary"
else
echo " • Nothing to do — everything was already set up."
fi
echo
printf 'Press Enter to complete this task and return to the dashboard. '
read _
Expand Down
Loading
Loading