From 878e284d39c1337b21e8a762f6f3550407636bcb Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Mon, 13 Jul 2026 18:59:03 +0000 Subject: [PATCH 1/5] feat(setup-repo): repo-aware summaries + gh-login GH_TOKEN, DRY token storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on #308's GH_TOKEN support with the operator-UX improvements: * Open with two bulleted lists — what we know about the repo (name + local-checkout vs GitHub-remote, classified from the git URL) and what its setup entails (Claude credential always; GH_TOKEN for GitHub repos), each marked needed / already-configured / not-needed. * GH_TOKEN acquisition now combines both paths: reuse a GH_TOKEN already in the environment (#308's fast path) when present, else authenticate interactively via `gh auth login` + `gh auth token`. Offers to replace an existing GH_TOKEN, and guides the operator when `gh` isn't installed. * DRY the env-file write: generalize `store_oauth_token` into `store_env_token `, the single comment-out/replace/append implementation both tokens share (retires the append-only `append_env_var`); `store_oauth_token` stays as a thin wrapper. * Turn the closing summary into a bullet-per-step list. * Fold in #309's hint wording (detach reassurance + actionable drop). The opening summary needs the repo's name, so the ShellRunner now also exports PANOPTICON_REPO_NAME (alongside #308's PANOPTICON_GIT_URL), passed through by the spawner from the repo record. Co-Authored-By: Claude Opus 4.8 --- src/panopticon/sessionservice/shell_runner.py | 5 +- src/panopticon/sessionservice/spawner.py | 1 + src/panopticon/workflows/setup_repo.sh | 213 +++++++++++++----- src/panopticon/workflows/setup_repo_lib.sh | 100 ++++---- tests/sessionservice/test_shell_runner.py | 15 ++ tests/sessionservice/test_spawner.py | 6 +- tests/workflows/test_setup_repo.py | 99 +++++--- 7 files changed, 311 insertions(+), 128 deletions(-) diff --git a/src/panopticon/sessionservice/shell_runner.py b/src/panopticon/sessionservice/shell_runner.py index 75db9a6e..ef899eff 100644 --- a/src/panopticon/sessionservice/shell_runner.py +++ b/src/panopticon/sessionservice/shell_runner.py @@ -68,6 +68,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, @@ -82,7 +83,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 @@ -113,6 +115,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", diff --git a/src/panopticon/sessionservice/spawner.py b/src/panopticon/sessionservice/spawner.py index 7a638318..b2562f3e 100644 --- a/src/panopticon/sessionservice/spawner.py +++ b/src/panopticon/sessionservice/spawner.py @@ -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 diff --git a/src/panopticon/workflows/setup_repo.sh b/src/panopticon/workflows/setup_repo.sh index 68946ab1..8d8a914d 100644 --- a/src/panopticon/workflows/setup_repo.sh +++ b/src/panopticon/workflows/setup_repo.sh @@ -1,12 +1,17 @@ -# 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). +# Guide the operator through a repo's host-side setup: mint a Claude auth token (`claude +# setup-token`) and — for a GitHub repo — record a `GH_TOKEN`, writing each 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 an already-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. +# 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. @@ -14,28 +19,63 @@ 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. echo "$dashboard_hint" echo -summary="" +# Work out what's already configured and what setting this repo up entails. 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 already carries it. +claude_configured=0 +if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; 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 -# 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. +# 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 configured" +else + echo " • Claude credential — needed" +fi +if [ "$gh_needed" -eq 1 ]; then + if [ "$gh_configured" -eq 1 ]; then + echo " • GH_TOKEN — already configured" + else + echo " • GH_TOKEN — needed (GitHub repo)" + fi +else + echo " • GH_TOKEN — not needed (not a GitHub repo)" +fi +echo + +# 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="$1" + summary="$summary + • $1" 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 +# Mint a Claude token and record the outcome. On success, capture the minted token and write it +# straight into the repo's env-file (commenting out any previous one — see store_env_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()). +# extract_oauth_token / store_env_token come from setup_repo_lib.sh (prepended by shell_script()). collect_token() { echo echo "Running 'claude setup-token' — follow the prompts to mint a token." @@ -60,66 +100,108 @@ 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 collected." 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. echo echo "Token minted. Copy the token shown above into $env_file as:" echo " CLAUDE_CODE_OAUTH_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 +} + +# Write a GH token into the env-file, or record why we couldn't — shared by both the reuse-from-env +# and `gh auth login` paths (mirrors the Claude token's store step). $1 ok flag, $2 token, $3 the +# source label for the summary. Goes through store_env_token, so an existing GH_TOKEN is commented +# out and replaced just like the Claude token. +store_gh_token() { + if [ "$1" -eq 0 ]; then + add_summary "GH_TOKEN: $3 failed or was cancelled — nothing collected." + elif [ -n "$2" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \ + && store_env_token GH_TOKEN "$2" "$PANOPTICON_ENV_FILE"; then + echo + echo "Wrote GH_TOKEN to $env_file (any previous one was commented out)." + add_summary "GH_TOKEN: wrote it to $env_file from $3 (any previous one was commented out)." + else + echo + echo "Couldn't write GH_TOKEN. Add it to $env_file yourself:" + echo " GH_TOKEN=" + add_summary "GH_TOKEN: couldn't write it — add GH_TOKEN to $env_file yourself." 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 +# Authenticate to GitHub with `gh auth login`, then capture the token with `gh auth token` and store +# it — the fallback when there's no GH_TOKEN in the environment to reuse. Guarded on `gh` being +# installed; otherwise guides the operator to add one by hand. +collect_gh_token() { + if ! command -v gh >/dev/null 2>&1; then + echo + echo "The 'gh' CLI isn't installed on this host, so I can't run 'gh auth login'." + echo "Add a token to $env_file yourself instead:" + echo " GH_TOKEN=" + add_summary "GH_TOKEN: 'gh' not installed — add GH_TOKEN to $env_file yourself." + return + 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 "Running 'gh auth login' — follow the prompts to authenticate to GitHub." 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 + _gt_ok=1 + gh auth login || _gt_ok=0 + _gt_token="" + [ "$_gt_ok" -eq 1 ] && _gt_token=$(gh auth token 2>/dev/null) + store_gh_token "$_gt_ok" "$_gt_token" "'gh auth login'" } -if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; then +# Get a GH_TOKEN into the env-file for a GitHub repo: reuse one already in the environment if the +# operator has it (the shell runner inherits the host env — the fast path), else authenticate with +# `gh auth login`. +setup_gh_token() { + if [ -n "${GH_TOKEN:-}" ]; then + echo "A GH_TOKEN is set in your environment. Adding it to $env_file lets task containers use" + echo "'gh' and push over HTTPS." + echo + printf 'Add the GH_TOKEN from your environment to %s? [Y/n] ' "$env_file" + read gh_answer + case "$gh_answer" in + [Nn]*) add_summary "GH_TOKEN: skipped — add GH_TOKEN to $env_file yourself." ;; + *) store_gh_token 1 "$GH_TOKEN" "your environment" ;; + esac + else + echo "No GH_TOKEN in your environment — a GitHub repo needs one to push and open PRs." + echo + echo "Prefer to use your own? Press $prefix then $detach to go to the dashboard, drop this task using 'x' and add" + echo " GH_TOKEN=" + echo "to $env_file yourself." + echo + printf 'Authenticate to GitHub with gh now? [Y/n] ' + read gh_answer + case "$gh_answer" in + [Nn]*) add_summary "GH_TOKEN: skipped — add GH_TOKEN to $env_file yourself." ;; + *) collect_gh_token ;; + esac + fi +} + +# --- Claude credential ------------------------------------------------------------------------- +if [ "$claude_configured" -eq 1 ]; 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)." echo printf 'Collect a new token anyway? [y/N] ' read answer case "$answer" in [Yy]*) collect_token ;; - *) add_summary "Kept the existing credential in $env_file — nothing collected." ;; + *) add_summary "Claude credential: kept the existing one in $env_file — nothing collected." ;; 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 "Prefer to use your own? Press $prefix then $detach to go to the dashboard, drop this task using 'x' and add one of" echo "these to $env_file yourself:" echo " CLAUDE_CODE_OAUTH_TOKEN=" echo " ANTHROPIC_API_KEY=" @@ -129,14 +211,33 @@ else collect_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 configured 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 — nothing collected." ;; + 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 configured." +fi echo printf 'Press Enter to complete this task and return to the dashboard. ' read _ diff --git a/src/panopticon/workflows/setup_repo_lib.sh b/src/panopticon/workflows/setup_repo_lib.sh index 57228122..b237bee7 100644 --- a/src/panopticon/workflows/setup_repo_lib.sh +++ b/src/panopticon/workflows/setup_repo_lib.sh @@ -10,36 +10,53 @@ extract_oauth_token() { grep -oaE 'sk-ant-oat01-[A-Za-z0-9_-]+' "$1" 2>/dev/null | tail -n 1 } -# Store a freshly minted token $1 into env-file $2, preserving history: -# * comment out any existing *active* `CLAUDE_CODE_OAUTH_TOKEN=…` line (kept as a record, not lost), -# * drop any placeholder *comment* stub (`# CLAUDE_CODE_OAUTH_TOKEN =`, or a `<…>` placeholder), -# * append the new active line. -# Other lines (ANTHROPIC_API_KEY, GH_TOKEN, blanks, unrelated comments) are left untouched. Atomic -# replace, private perms (the file holds a live credential). Returns nonzero if it can't be written. -store_oauth_token() { - _sot_token=$1 - _sot_file=$2 - [ -n "$_sot_file" ] || return 1 +# Store a freshly minted value $2 for env var $1 into env-file $3, preserving history: +# * comment out any existing *active* `=…` line (kept as a record, not lost), +# * drop any placeholder *comment* stub (`# =`, or a `<…>` placeholder), +# * append the new active line (on its own line even if the file lacked a trailing newline). +# Other lines (a different var, blanks, unrelated comments) are left untouched. Atomic replace, +# private perms (the file holds a live credential). Returns nonzero if it can't be written. Shared by +# every token the setup flow writes (CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN, …). $1 must be a plain env +# var name (`[A-Za-z_][A-Za-z0-9_]*`) — it's interpolated verbatim into the sed/grep patterns, where +# it carries no regex metacharacters. +store_env_token() { + _set_var=$1 + _set_token=$2 + _set_file=$3 + [ -n "$_set_file" ] || return 1 umask 077 - mkdir -p "$(dirname "$_sot_file")" || return 1 - _sot_tmp=$(mktemp "$_sot_file.XXXXXX") || return 1 - if [ -f "$_sot_file" ]; then + mkdir -p "$(dirname "$_set_file")" || return 1 + _set_tmp=$(mktemp "$_set_file.XXXXXX") || return 1 + if [ -f "$_set_file" ]; then # 1) comment out an active assignment — a leading '#' means the line no longer starts with - # the bare var name, so an already-commented real token (with an sk-ant-… value) is left - # as-is; then 2) drop placeholder comment stubs (an empty or `<…>` value). - sed -E 's/^([[:space:]]*)CLAUDE_CODE_OAUTH_TOKEN=/\1# CLAUDE_CODE_OAUTH_TOKEN=/' "$_sot_file" \ - | grep -vE '^[[:space:]]*#[[:space:]]*CLAUDE_CODE_OAUTH_TOKEN[[:space:]]*=[[:space:]]*(<[^>]*>)?[[:space:]]*$' \ - > "$_sot_tmp" || true # grep exits 1 when it filters every line — that's fine + # the bare var name, so an already-commented real value is left as-is; then 2) drop + # placeholder comment stubs (an empty or `<…>` value). + sed -E "s/^([[:space:]]*)${_set_var}=/\\1# ${_set_var}=/" "$_set_file" \ + | grep -vE "^[[:space:]]*#[[:space:]]*${_set_var}[[:space:]]*=[[:space:]]*(<[^>]*>)?[[:space:]]*\$" \ + > "$_set_tmp" || true # grep exits 1 when it filters every line — that's fine + fi + # Ensure the appended line stands alone even if the kept content didn't end in a newline. + if [ -s "$_set_tmp" ] && [ -n "$(tail -c 1 "$_set_tmp")" ]; then + printf '\n' >> "$_set_tmp" || { + rm -f "$_set_tmp" + return 1 + } fi - printf 'CLAUDE_CODE_OAUTH_TOKEN=%s\n' "$_sot_token" >> "$_sot_tmp" || { - rm -f "$_sot_tmp" + printf '%s=%s\n' "$_set_var" "$_set_token" >> "$_set_tmp" || { + rm -f "$_set_tmp" return 1 } - mv "$_sot_tmp" "$_sot_file" || { - rm -f "$_sot_tmp" + mv "$_set_tmp" "$_set_file" || { + rm -f "$_set_tmp" return 1 } - chmod 600 "$_sot_file" 2>/dev/null || true + chmod 600 "$_set_file" 2>/dev/null || true +} + +# Back-compat wrapper: store a Claude OAuth token. The shared implementation lives in +# store_env_token; this keeps the Claude call site (and its tests) reading clearly. +store_oauth_token() { + store_env_token CLAUDE_CODE_OAUTH_TOKEN "$1" "$2" } # True when URL $1 names github.com as its host, in either form the repo's `git_url` is stored: @@ -52,27 +69,24 @@ is_github_url() { esac } +# A human label for the repo's source, from its git URL $1 — drives the setup flow's opening summary. +# A GitHub remote (the case that wants a GH_TOKEN); a filesystem path / file:// URL, or a bare ref +# with neither a scheme nor an scp-style host, is a local checkout; anything else is a generic remote. +repo_source_label() { + if [ -z "$1" ]; then + printf 'unknown' + elif is_github_url "$1"; then + printf 'GitHub remote' + elif printf '%s' "$1" | grep -qE '^(/|\./|\.\./|~|file://)' \ + || ! printf '%s' "$1" | grep -qE '://|@'; then + printf 'local checkout' + else + printf 'remote' + fi +} + # True when env-file $2 exists and holds an *active* (uncommented) `$1=` assignment. A commented -# (`# $1=…`) line or a missing file returns nonzero — matching store_oauth_token's notion of active. +# (`# $1=…`) line or a missing file returns nonzero — matching store_env_token's notion of active. env_file_has_var() { [ -f "$2" ] && grep -qE "^[[:space:]]*$1=" "$2" } - -# Append `$1=$2` to env-file $3, creating it (and its parent dir) private (0600) if needed. Adds a -# separating newline first when the file is non-empty and its last byte isn't one, so the appended -# line always stands alone. Only ever called when the var is absent (see env_file_has_var), so it -# needs no comment-out logic. Returns nonzero if it can't be written. -append_env_var() { - _aev_var=$1 - _aev_val=$2 - _aev_file=$3 - [ -n "$_aev_file" ] || return 1 - umask 077 - mkdir -p "$(dirname "$_aev_file")" || return 1 - # `$(…)` strips trailing newlines, so a non-empty result means the last byte isn't a newline. - if [ -s "$_aev_file" ] && [ -n "$(tail -c 1 "$_aev_file")" ]; then - printf '\n' >> "$_aev_file" || return 1 - fi - printf '%s=%s\n' "$_aev_var" "$_aev_val" >> "$_aev_file" || return 1 - chmod 600 "$_aev_file" 2>/dev/null || true -} diff --git a/tests/sessionservice/test_shell_runner.py b/tests/sessionservice/test_shell_runner.py index c3e699d6..1eedff8c 100644 --- a/tests/sessionservice/test_shell_runner.py +++ b/tests/sessionservice/test_shell_runner.py @@ -149,6 +149,21 @@ def test_spawn_omits_the_git_url_export_without_one() -> None: assert "PANOPTICON_GIT_URL" not in command +def test_spawn_exports_the_repo_name_when_given() -> None: + # A script uses PANOPTICON_REPO_NAME to label the repo in its summary. + rec = _Recorder() + ShellRunner("http://svc:8000", run=rec).spawn("t1", script="echo hi", repo_name="acme/widget") + command = rec.calls[-1][-1] + assert "export PANOPTICON_REPO_NAME=acme/widget" in command + + +def test_spawn_omits_the_repo_name_export_without_one() -> None: + rec = _Recorder() + ShellRunner("http://svc:8000", run=rec).spawn("t1", script="echo hi") + command = rec.calls[-1][-1] + assert "PANOPTICON_REPO_NAME" not in command + + def test_spawn_reports_starting_then_awaiting() -> None: phases: list[LifecyclePhase] = [] ShellRunner("http://svc:8000", run=_Recorder()).spawn( diff --git a/tests/sessionservice/test_spawner.py b/tests/sessionservice/test_spawner.py index 5964c2bc..a0b16e65 100644 --- a/tests/sessionservice/test_spawner.py +++ b/tests/sessionservice/test_spawner.py @@ -251,6 +251,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, @@ -260,6 +261,7 @@ def spawn( "task_id": task_id, "env_file": env_file, "git_url": git_url, + "repo_name": repo_name, "script": script, "workdir": workdir, } @@ -306,7 +308,8 @@ def _shell_spawner( def test_spawn_one_shell_workflow_runs_the_script_and_skips_docker() -> None: - client = _FakeClient(repo=_REPO, runner_type="shell", shell_script="claude setup-token") + repo = {**_REPO, "name": "acme/widget"} + client = _FakeClient(repo=repo, runner_type="shell", shell_script="claude setup-token") runner, shell = _FakeRunner(), _FakeShellRunner() made: list[str] = [] cid = _shell_spawner(client, runner, shell, made).spawn_one(dict(_SHELL_TASK)) @@ -316,6 +319,7 @@ def test_spawn_one_shell_workflow_runs_the_script_and_skips_docker() -> None: assert shell.spawned[0]["script"] == "claude setup-token" # fetched from the workflow over REST assert shell.spawned[0]["env_file"] == "r1.env" # the repo's secrets name is passed through assert shell.spawned[0]["git_url"] == "https://forge/r1.git" # the repo's forge, for detection + assert shell.spawned[0]["repo_name"] == "acme/widget" # the repo's label, for the summary # by default the script runs in the task's own directory, created empty (no clone) assert shell.spawned[0]["workdir"] == "/tasks/t1" assert made == ["/tasks/t1"] diff --git a/tests/workflows/test_setup_repo.py b/tests/workflows/test_setup_repo.py index 7f8fef67..a6fa7955 100644 --- a/tests/workflows/test_setup_repo.py +++ b/tests/workflows/test_setup_repo.py @@ -15,8 +15,9 @@ WF = SetupRepo() -# The sourceable helpers (extract_oauth_token / store_oauth_token) the functional tests exercise in a -# real `sh`, no LLM — the token is a literal fixture, `claude`/`script` are never invoked. +# The sourceable helpers (extract_oauth_token / store_env_token / repo_source_label / …) the +# functional tests exercise in a real `sh`, no LLM — the token is a literal fixture, `claude`/`gh`/ +# `script` are never invoked. _LIB = (importlib.resources.files("panopticon.workflows") / "setup_repo_lib.sh").read_text() @@ -97,11 +98,11 @@ def test_shell_script_captures_and_writes_the_minted_token() -> None: script = WF.shell_script() # captures the interactive `claude setup-token` in a pty so its output can be read back assert "script -q -e -c 'claude setup-token'" in script - # extracts the minted token and stores it in the repo's env-file via the helpers + # extracts the minted token and stores it via the shared, var-parameterized helper (the DRY + # primitive both the Claude token and GH_TOKEN write through) assert "extract_oauth_token" in script - assert "store_oauth_token" in script and "PANOPTICON_ENV_FILE" in script - # comments out an existing active token, and drops a placeholder comment stub - assert "# CLAUDE_CODE_OAUTH_TOKEN=" in script # the sed replacement that comments it out + assert "store_env_token CLAUDE_CODE_OAUTH_TOKEN" in script and "PANOPTICON_ENV_FILE" in script + # the helper comments out an existing active line and drops a placeholder comment stub assert "grep -vE" in script # the filter that removes the placeholder stub # still falls back to on-screen copy guidance when it can't capture/write assert "Copy the token shown above into" in script @@ -172,21 +173,38 @@ def test_store_oauth_token_keeps_an_already_commented_out_token(tmp_path: Path) assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-NEW" in lines -def test_shell_script_offers_the_github_token_when_missing() -> None: +def test_shell_script_summarizes_the_repo_and_setup_up_front() -> None: script = WF.shell_script() - # the offer step is defined and run before the final summary converges - assert "maybe_offer_github_token" in script - # gated on: repo on GitHub (PANOPTICON_GIT_URL), a GH_TOKEN in the env, and it not already in file - assert "PANOPTICON_GIT_URL" in script - assert "is_github_url" in script + # opens with two bulleted lists: what we know about the repo (name + source), and what setting it + # up entails, driven by the repo env vars the shell runner injects and the source classification. + assert "This repo:" in script and "To set up:" in script + assert "PANOPTICON_REPO_NAME" in script and "PANOPTICON_GIT_URL" in script + assert "repo_source_label" in script + assert "Claude credential" in script and "GH_TOKEN" in script + + +def test_shell_script_sets_up_the_github_token_for_github_repos() -> None: + script = WF.shell_script() + # gated on the repo being a GitHub remote (local checkouts skip the whole GH step) + assert "is_github_url" in script and "PANOPTICON_GIT_URL" in script + # reuses a GH_TOKEN from the environment when present, else authenticates with `gh auth login` assert "GH_TOKEN" in script + assert "gh auth login" in script and "gh auth token" in script + assert "command -v gh" in script # graceful when `gh` isn't installed + # writes it via the shared helper (so an existing GH_TOKEN is commented out + replaced), and + # offers to replace one already in the env-file + assert "store_env_token GH_TOKEN" in script assert "env_file_has_var GH_TOKEN" in script - # writes it via the helper - assert "append_env_var GH_TOKEN" in script - # the offer runs after the Claude credential step (the call — last occurrence of the name — - # comes after the credential check) but before the final summary - assert script.rindex("maybe_offer_github_token") > script.index("${CLAUDE_CODE_OAUTH_TOKEN:-}") - assert script.rindex("maybe_offer_github_token") < script.rindex('echo "Summary: $summary"') + # the GH step runs after the Claude credential step but before the final summary + assert script.rindex("setup_gh_token") > script.index("${CLAUDE_CODE_OAUTH_TOKEN:-}") + assert script.rindex("setup_gh_token") < script.rindex('echo "Summary:"') + + +def test_shell_script_closing_summary_is_bulleted() -> None: + script = WF.shell_script() + # each step records its outcome as a bullet; the closing summary prints them + assert "add_summary" in script + assert 'summary=" • ' in script # bullet-prefixed accumulation def test_is_github_url_matches_https_and_ssh_remotes() -> None: @@ -223,23 +241,50 @@ def test_env_file_has_var_detects_only_active_lines(tmp_path: Path) -> None: ) -def test_append_env_var_appends_privately(tmp_path: Path) -> None: - # A file whose last line has no trailing newline: the helper adds a separator so the line stands - # alone, keeps the prior content, and leaves the file private (0600). +def test_store_env_token_is_generic_over_the_var_name(tmp_path: Path) -> None: + # The shared primitive works for any var (here GH_TOKEN), mirroring the Claude-token behaviour: + # comment out the active line, drop the placeholder stub, append the new one, leave others alone. env_file = tmp_path / "repo.env" - env_file.write_text("ANTHROPIC_API_KEY=key-123") # no trailing newline - _sh(f"append_env_var GH_TOKEN ghp_tok {shlex.quote(str(env_file))}") - assert env_file.read_text() == "ANTHROPIC_API_KEY=key-123\nGH_TOKEN=ghp_tok\n" + env_file.write_text( + "GH_TOKEN=ghp_OLD\n" + "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-KEEP\n" + "# GH_TOKEN =\n" # a placeholder stub to be removed + "# a note we keep\n" + ) + _sh(f"store_env_token GH_TOKEN ghp_NEW {shlex.quote(str(env_file))}") + lines = env_file.read_text().splitlines() + + assert "# GH_TOKEN=ghp_OLD" in lines # previous active token preserved but commented out + assert "# GH_TOKEN =" not in lines # placeholder stub gone + # an unrelated var (the Claude token) and comments are untouched + assert "CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-KEEP" in lines + assert "# a note we keep" in lines + # exactly one *active* GH_TOKEN line, and it's the new one + assert [ln for ln in lines if ln.startswith("GH_TOKEN=")] == ["GH_TOKEN=ghp_NEW"] + # holds a live credential — kept private (0600) assert stat.S_IMODE(env_file.stat().st_mode) == 0o600 -def test_append_env_var_creates_a_private_file(tmp_path: Path) -> None: +def test_store_env_token_separates_the_new_line_without_a_trailing_newline(tmp_path: Path) -> None: + # A file whose last line lacks a trailing newline still gets the appended line on its own line. env_file = tmp_path / "secrets" / "repo.env" # parent dir does not exist yet - _sh(f"append_env_var GH_TOKEN ghp_tok {shlex.quote(str(env_file))}") - assert env_file.read_text() == "GH_TOKEN=ghp_tok\n" + env_file.parent.mkdir() + env_file.write_text("ANTHROPIC_API_KEY=key-123") # no trailing newline + _sh(f"store_env_token GH_TOKEN ghp_tok {shlex.quote(str(env_file))}") + assert env_file.read_text() == "ANTHROPIC_API_KEY=key-123\nGH_TOKEN=ghp_tok\n" assert stat.S_IMODE(env_file.stat().st_mode) == 0o600 +def test_repo_source_label_classifies_local_github_and_other() -> None: + # Drives the opening summary's "Source:" line and the GH_TOKEN gating. + assert _sh("repo_source_label https://github.com/acme/widget.git").strip() == "GitHub remote" + assert _sh("repo_source_label git@github.com:acme/widget.git").strip() == "GitHub remote" + assert _sh("repo_source_label /home/me/src/widget").strip() == "local checkout" + assert _sh("repo_source_label file:///srv/widget").strip() == "local checkout" + assert _sh("repo_source_label https://gitlab.com/x/y.git").strip() == "remote" + assert _sh("repo_source_label ''").strip() == "unknown" + + def test_docker_workflows_have_no_shell_script_and_default_knobs() -> None: from panopticon.workflows import Spike From 940b6aab048229f320c7236313619e8b491ed6e1 Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Mon, 13 Jul 2026 20:06:22 +0000 Subject: [PATCH 2/5] fix(shell-runner): strip comments so the spawn command fits tmux's imsg cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup-repo UX changes grew the assembled shell command to ~18 KB. tmux sends a whole `new-session` command (pane shell command included) to its server over imsg, which caps a single message at MAX_IMSGSIZE (16 KiB) — so the spawn failed with `new-session ... returned non-zero exit status 1` before the script ever ran. Strip whole-line comments and blank lines from the assembled command at spawn time (they're pure bulk at runtime; the source files keep their comments). This brings setup-repo's command from ~18 KB to ~10 KB, well under the cap, and gives every shell workflow headroom. Only lines that are entirely a comment or blank are dropped — inline/trailing comments and code are untouched. Adds a tmux-free regression test asserting the assembled setup-repo command has no whole-line comments and stays under 16 KiB (the pytest CI job has no tmux, so the integration spawn test is skipped there — this guards it without one). Co-Authored-By: Claude Opus 4.8 --- src/panopticon/sessionservice/shell_runner.py | 20 ++++++++- tests/sessionservice/test_shell_runner.py | 43 ++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/panopticon/sessionservice/shell_runner.py b/src/panopticon/sessionservice/shell_runner.py index ef899eff..f58e349c 100644 --- a/src/panopticon/sessionservice/shell_runner.py +++ b/src/panopticon/sessionservice/shell_runner.py @@ -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).""" @@ -130,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) diff --git a/tests/sessionservice/test_shell_runner.py b/tests/sessionservice/test_shell_runner.py index 1eedff8c..682eb7c4 100644 --- a/tests/sessionservice/test_shell_runner.py +++ b/tests/sessionservice/test_shell_runner.py @@ -13,7 +13,7 @@ from panopticon.core.models import LifecyclePhase from panopticon.sessionservice.runner import Runner -from panopticon.sessionservice.shell_runner import ShellRunner +from panopticon.sessionservice.shell_runner import ShellRunner, _minify_shell class _Recorder: @@ -164,6 +164,47 @@ def test_spawn_omits_the_repo_name_export_without_one() -> None: assert "PANOPTICON_REPO_NAME" not in command +def test_minify_shell_drops_full_line_comments_and_blanks_only() -> None: + src = "\n".join( + [ + "# a full-line comment", + " # indented comment", + "", + " ", + "export FOO=bar", + "run --flag # trailing comment stays (mid-line #)", + 'echo "# not a comment inside a string"', + ] + ) + out = _minify_shell(src) + assert out.splitlines() == [ + "export FOO=bar", + "run --flag # trailing comment stays (mid-line #)", + 'echo "# not a comment inside a string"', + ] + + +def test_spawn_command_strips_comments_and_stays_under_the_imsg_cap() -> None: + # tmux sends the whole new-session command to its server over imsg (16 KiB cap); a heavily + # commented workflow script + the task lib can exceed it and fail the spawn, so the assembled + # command drops whole-line comments/blanks. The real setup-repo script is the motivating case. + from panopticon.workflows import SetupRepo + + rec = _Recorder() + ShellRunner("http://svc:8000", run=rec).spawn( + "t1", + script=SetupRepo().shell_script(), + git_url="https://github.com/o/r.git", + repo_name="o/r", + ) + command = rec.calls[-1][-1] + # no whole-line comments survive, but the code (functions, exports) does + assert not [ln for ln in command.splitlines() if ln.lstrip().startswith("#")] + assert "store_env_token" in command and "panopticon_advance()" in command + # comfortably under tmux's MAX_IMSGSIZE, so new-session won't reject it + assert len(command.encode()) < 16384 + + def test_spawn_reports_starting_then_awaiting() -> None: phases: list[LifecyclePhase] = [] ShellRunner("http://svc:8000", run=_Recorder()).spawn( From cceeca7807f5f2829388d3e7b41028c446f33726 Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Mon, 13 Jul 2026 21:01:30 +0000 Subject: [PATCH 3/5] feat(setup-repo): drop the gh auth login fallback for GH_TOKEN Keep only the reuse-a-GH_TOKEN-from-the-environment path: when the operator's env has a GH_TOKEN, offer to write it into the env-file (through the shared store_env_token, so an existing one is replaced); when it doesn't, guide them to add one by hand rather than minting one interactively. Removes collect_gh_token and the `gh auth login`/`gh auth token` flow. Co-Authored-By: Claude Opus 4.8 --- src/panopticon/workflows/setup_repo.sh | 58 ++++++-------------------- tests/workflows/test_setup_repo.py | 5 +-- 2 files changed, 15 insertions(+), 48 deletions(-) diff --git a/src/panopticon/workflows/setup_repo.sh b/src/panopticon/workflows/setup_repo.sh index 8d8a914d..79b898b7 100644 --- a/src/panopticon/workflows/setup_repo.sh +++ b/src/panopticon/workflows/setup_repo.sh @@ -115,18 +115,15 @@ collect_token() { fi } -# Write a GH token into the env-file, or record why we couldn't — shared by both the reuse-from-env -# and `gh auth login` paths (mirrors the Claude token's store step). $1 ok flag, $2 token, $3 the -# source label for the summary. Goes through store_env_token, so an existing GH_TOKEN is commented -# out and replaced just like the Claude token. +# Write a GH token $1 into the env-file, or record why we couldn't (mirrors the Claude token's store +# step). $2 is the source label for the summary. Goes through store_env_token, so an existing +# GH_TOKEN is commented out and replaced just like the Claude token. store_gh_token() { - if [ "$1" -eq 0 ]; then - add_summary "GH_TOKEN: $3 failed or was cancelled — nothing collected." - elif [ -n "$2" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \ - && store_env_token GH_TOKEN "$2" "$PANOPTICON_ENV_FILE"; then + if [ -n "$1" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \ + && store_env_token GH_TOKEN "$1" "$PANOPTICON_ENV_FILE"; then echo echo "Wrote GH_TOKEN to $env_file (any previous one was commented out)." - add_summary "GH_TOKEN: wrote it to $env_file from $3 (any previous one was commented out)." + add_summary "GH_TOKEN: wrote it to $env_file from $2 (any previous one was commented out)." else echo echo "Couldn't write GH_TOKEN. Add it to $env_file yourself:" @@ -135,31 +132,9 @@ store_gh_token() { fi } -# Authenticate to GitHub with `gh auth login`, then capture the token with `gh auth token` and store -# it — the fallback when there's no GH_TOKEN in the environment to reuse. Guarded on `gh` being -# installed; otherwise guides the operator to add one by hand. -collect_gh_token() { - if ! command -v gh >/dev/null 2>&1; then - echo - echo "The 'gh' CLI isn't installed on this host, so I can't run 'gh auth login'." - echo "Add a token to $env_file yourself instead:" - echo " GH_TOKEN=" - add_summary "GH_TOKEN: 'gh' not installed — add GH_TOKEN to $env_file yourself." - return - fi - echo - echo "Running 'gh auth login' — follow the prompts to authenticate to GitHub." - echo - _gt_ok=1 - gh auth login || _gt_ok=0 - _gt_token="" - [ "$_gt_ok" -eq 1 ] && _gt_token=$(gh auth token 2>/dev/null) - store_gh_token "$_gt_ok" "$_gt_token" "'gh auth login'" -} - -# Get a GH_TOKEN into the env-file for a GitHub repo: reuse one already in the environment if the -# operator has it (the shell runner inherits the host env — the fast path), else authenticate with -# `gh auth login`. +# Get a GH_TOKEN into the env-file for a GitHub repo by reusing one already in the environment (the +# shell runner inherits the host env). When there's none to reuse, guide the operator to add one by +# hand — we don't mint one here. setup_gh_token() { if [ -n "${GH_TOKEN:-}" ]; then echo "A GH_TOKEN is set in your environment. Adding it to $env_file lets task containers use" @@ -169,21 +144,14 @@ setup_gh_token() { read gh_answer case "$gh_answer" in [Nn]*) add_summary "GH_TOKEN: skipped — add GH_TOKEN to $env_file yourself." ;; - *) store_gh_token 1 "$GH_TOKEN" "your environment" ;; + *) store_gh_token "$GH_TOKEN" "your environment" ;; esac else echo "No GH_TOKEN in your environment — a GitHub repo needs one to push and open PRs." - echo - echo "Prefer to use your own? Press $prefix then $detach to go to the dashboard, drop this task using 'x' and add" + echo "Add one to $env_file yourself (press $prefix then $detach to go to the dashboard, drop" + echo "this task using 'x', and add):" echo " GH_TOKEN=" - echo "to $env_file yourself." - echo - printf 'Authenticate to GitHub with gh now? [Y/n] ' - read gh_answer - case "$gh_answer" in - [Nn]*) add_summary "GH_TOKEN: skipped — add GH_TOKEN to $env_file yourself." ;; - *) collect_gh_token ;; - esac + add_summary "GH_TOKEN: none in your environment — add GH_TOKEN to $env_file yourself." fi } diff --git a/tests/workflows/test_setup_repo.py b/tests/workflows/test_setup_repo.py index a6fa7955..8e441768 100644 --- a/tests/workflows/test_setup_repo.py +++ b/tests/workflows/test_setup_repo.py @@ -187,10 +187,9 @@ def test_shell_script_sets_up_the_github_token_for_github_repos() -> None: script = WF.shell_script() # gated on the repo being a GitHub remote (local checkouts skip the whole GH step) assert "is_github_url" in script and "PANOPTICON_GIT_URL" in script - # reuses a GH_TOKEN from the environment when present, else authenticates with `gh auth login` + # reuses a GH_TOKEN already in the environment — it does not mint one (no interactive gh auth) assert "GH_TOKEN" in script - assert "gh auth login" in script and "gh auth token" in script - assert "command -v gh" in script # graceful when `gh` isn't installed + assert "gh auth login" not in script and "gh auth token" not in script # writes it via the shared helper (so an existing GH_TOKEN is commented out + replaced), and # offers to replace one already in the env-file assert "store_env_token GH_TOKEN" in script From 504e428e76bc86f3ea9ab231d8ed2854c5be1113 Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Tue, 14 Jul 2026 14:13:39 +0000 Subject: [PATCH 4/5] feat(setup-repo): open with the credentials goal + why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin the flow with a short intro stating the goal — collect persistent credentials (a Claude token, and a GH_TOKEN for GitHub repos) stored in the repo's env-file so Claude can use them inside task containers — and the reason: the agent runs on its own dedicated credentials instead of hijacking the operator's. Co-Authored-By: Claude Opus 4.8 --- src/panopticon/workflows/setup_repo.sh | 9 ++++++++- tests/workflows/test_setup_repo.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/panopticon/workflows/setup_repo.sh b/src/panopticon/workflows/setup_repo.sh index 79b898b7..38a21c3a 100644 --- a/src/panopticon/workflows/setup_repo.sh +++ b/src/panopticon/workflows/setup_repo.sh @@ -21,7 +21,14 @@ detach=$(tmux list-keys -T prefix 2>/dev/null | awk '$NF == "detach-client" { pr [ -n "$detach" ] || detach="d" 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 with the goal: collect persistent, container-scoped credentials so the agent runs on its own, +# not the operator's. +echo "Setting up '$repo_name'. The goal is to collect persistent credentials — a Claude token, and a" +echo "GH_TOKEN for GitHub repos — and store them in the repo's env-file, so Claude can use them inside" +echo "task containers. This gives the agent its own dedicated credentials instead of hijacking yours." +echo + +# Show how to get back to the dashboard, up front before any prompts. echo "$dashboard_hint" echo diff --git a/tests/workflows/test_setup_repo.py b/tests/workflows/test_setup_repo.py index 8e441768..84aa6805 100644 --- a/tests/workflows/test_setup_repo.py +++ b/tests/workflows/test_setup_repo.py @@ -84,6 +84,17 @@ def test_shell_script_checks_for_an_existing_credential_and_guides_the_operator( assert "detach-client" in script and "show-options -gv prefix" in script +def test_shell_script_opens_with_the_credentials_goal_intro() -> None: + script = WF.shell_script() + # begins by stating the goal: persistent credentials for Claude to use inside containers, so the + # agent runs on its own dedicated credentials instead of the operator's. + assert "persistent credentials" in script + assert "task containers" in script + assert "instead of hijacking yours" in script + # the intro comes before the dashboard hint and any prompts + assert script.index("persistent credentials") < script.index('echo "$dashboard_hint"') + + def test_shell_script_shows_the_dashboard_hint_first() -> None: script = WF.shell_script() # the return-to-dashboard hint is echoed up front, before the credential check / any prompts. From e70fac1f9cace817fb618b83b5f2a94adea73fe1 Mon Sep 17 00:00:00 2001 From: Panopticon Agent Date: Tue, 14 Jul 2026 14:43:05 +0000 Subject: [PATCH 5/5] =?UTF-8?q?feat(setup-repo):=20trustworthy=20credentia?= =?UTF-8?q?l=20flow=20=E2=80=94=20adopt/paste/mint,=20env-file=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the self-review of the onboarding flow: Correctness * "Configured" is now checked against the env-file (env_file_has_var), not the sourced env. A Claude token that lives only in the operator's shell is no longer mis-reported as configured (the container gets --env-file, not the host env), which previously meant "done" but a container that 401s. Consent / control * Every adopt prompt defaults to No ([y/N]); flipped GH adoption off [Y/n]. * Adopting a token from the environment shows a masked tail (last 4) so the operator confirms which token — new mask_last4 helper. * Only offers to adopt a var that isn't already the env-file's own. Fast path for an already-authenticated operator * Each credential can be adopted from the environment or pasted inline; Claude falls back to `claude setup-token`. Symmetric Claude/GH via a shared store_token. No more detach → drop → hand-edit → re-run to bring your own. Copy * New opener: what happens + that you stay in control (per-repo tokens, "not your personal session", opt out by editing the env-file) — drops the "collect persistent credentials"/"hijacking" register. * "collect" → "mint / set up" throughout; agent-neutral wording. Co-Authored-By: Claude Opus 4.8 --- src/panopticon/workflows/setup_repo.sh | 181 +++++++++++++-------- src/panopticon/workflows/setup_repo_lib.sh | 11 ++ tests/workflows/test_setup_repo.py | 49 ++++-- 3 files changed, 155 insertions(+), 86 deletions(-) diff --git a/src/panopticon/workflows/setup_repo.sh b/src/panopticon/workflows/setup_repo.sh index 38a21c3a..bfc2dfc4 100644 --- a/src/panopticon/workflows/setup_repo.sh +++ b/src/panopticon/workflows/setup_repo.sh @@ -1,12 +1,12 @@ -# Guide the operator through a repo's host-side setup: mint a Claude auth token (`claude -# setup-token`) and — for a GitHub repo — record a `GH_TOKEN`, writing each 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 an already-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). +# 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 bulleted 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}" @@ -21,22 +21,25 @@ detach=$(tmux list-keys -T prefix 2>/dev/null | awk '$NF == "detach-client" { pr [ -n "$detach" ] || detach="d" dashboard_hint="To return to the dashboard without finishing, detach: press $prefix then $detach (you can resume this task any time from the dashboard)." -# Open with the goal: collect persistent, container-scoped credentials so the agent runs on its own, -# not the operator's. -echo "Setting up '$repo_name'. The goal is to collect persistent credentials — a Claude token, and a" -echo "GH_TOKEN for GitHub repos — and store them in the repo's env-file, so Claude can use them inside" -echo "task containers. This gives the agent its own dedicated credentials instead of hijacking yours." +# 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 -# Work out what's already configured and what setting this repo up entails. 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 already carries it. +# 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 [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; then +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 @@ -53,13 +56,13 @@ echo " • Source: $repo_label" echo echo "To set up:" if [ "$claude_configured" -eq 1 ]; then - echo " • Claude credential — already configured" + 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 configured" + echo " • GH_TOKEN — already in $env_file" else echo " • GH_TOKEN — needed (GitHub repo)" fi @@ -79,11 +82,27 @@ add_summary() { fi } -# Mint a Claude token and record the outcome. On success, capture the minted token and write it -# straight into the repo's env-file (commenting out any previous one — see store_env_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_env_token come from setup_repo_lib.sh (prepended by shell_script()). -collect_token() { +# 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 + 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 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 @@ -107,14 +126,14 @@ collect_token() { fi if [ "$_ct_ok" -eq 0 ]; then - add_summary "Claude credential: 'claude setup-token' failed or was cancelled — nothing collected." + add_summary "Claude credential: 'claude setup-token' failed or was cancelled — nothing set up." elif [ -n "$_ct_token" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \ && 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 "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=" @@ -122,81 +141,99 @@ collect_token() { fi } -# Write a GH token $1 into the env-file, or record why we couldn't (mirrors the Claude token's store -# step). $2 is the source label for the summary. Goes through store_env_token, so an existing -# GH_TOKEN is commented out and replaced just like the Claude token. -store_gh_token() { - if [ -n "$1" ] && [ -n "${PANOPTICON_ENV_FILE:-}" ] \ - && store_env_token GH_TOKEN "$1" "$PANOPTICON_ENV_FILE"; then - echo - echo "Wrote GH_TOKEN to $env_file (any previous one was commented out)." - add_summary "GH_TOKEN: wrote it to $env_file from $2 (any previous one was commented out)." +# 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 "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 - echo - echo "Couldn't write GH_TOKEN. Add it to $env_file yourself:" - echo " GH_TOKEN=" - add_summary "GH_TOKEN: couldn't write it — add GH_TOKEN to $env_file yourself." + mint_claude_token fi } -# Get a GH_TOKEN into the env-file for a GitHub repo by reusing one already in the environment (the -# shell runner inherits the host env). When there's none to reuse, guide the operator to add one by -# hand — we don't mint one here. +# 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:-}" ]; then - echo "A GH_TOKEN is set in your environment. Adding it to $env_file lets task containers use" - echo "'gh' and push over HTTPS." - echo - printf 'Add the GH_TOKEN from your environment to %s? [Y/n] ' "$env_file" + 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 - [Nn]*) add_summary "GH_TOKEN: skipped — add GH_TOKEN to $env_file yourself." ;; - *) store_gh_token "$GH_TOKEN" "your environment" ;; + [Yy]*) + store_token GH_TOKEN "$GH_TOKEN" "your environment" "GH_TOKEN" + return + ;; esac + fi + echo + 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 - echo "No GH_TOKEN in your environment — a GitHub repo needs one to push and open PRs." - echo "Add one to $env_file yourself (press $prefix then $detach to go to the dashboard, drop" - echo "this task using 'x', and add):" - echo " GH_TOKEN=" - add_summary "GH_TOKEN: none in your environment — add GH_TOKEN to $env_file yourself." + add_summary "GH_TOKEN: none set up — add GH_TOKEN to $env_file yourself." fi } # --- Claude credential ------------------------------------------------------------------------- if [ "$claude_configured" -eq 1 ]; then - echo "A Claude credential is already configured in $env_file." + 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 "Claude credential: kept the existing one 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? Press $prefix then $detach to go to the dashboard, drop this task using 'x' and add one of" - echo "these to $env_file yourself:" - echo " CLAUDE_CODE_OAUTH_TOKEN=" - echo " ANTHROPIC_API_KEY=" - echo - printf 'Press Enter to collect a token now. ' - read _ - collect_token + setup_claude_token fi # --- GH_TOKEN (GitHub repos only) -------------------------------------------------------------- if [ "$gh_needed" -eq 1 ]; then echo if [ "$gh_configured" -eq 1 ]; then - echo "A GH_TOKEN is already configured in $env_file." + 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 — nothing collected." ;; + *) add_summary "GH_TOKEN: kept the existing one in $env_file." ;; esac else setup_gh_token @@ -211,7 +248,7 @@ echo "Summary:" if [ -n "$summary" ]; then echo "$summary" else - echo " • Nothing to do — everything was already configured." + echo " • Nothing to do — everything was already set up." fi echo printf 'Press Enter to complete this task and return to the dashboard. ' diff --git a/src/panopticon/workflows/setup_repo_lib.sh b/src/panopticon/workflows/setup_repo_lib.sh index b237bee7..088b8fd8 100644 --- a/src/panopticon/workflows/setup_repo_lib.sh +++ b/src/panopticon/workflows/setup_repo_lib.sh @@ -90,3 +90,14 @@ repo_source_label() { env_file_has_var() { [ -f "$2" ] && grep -qE "^[[:space:]]*$1=" "$2" } + +# Print a masked tail of secret $1 for a consent prompt — the last 4 characters, e.g. `...a1b2`, so +# the operator can confirm *which* token without it being shown in full. A value of 4 chars or fewer +# collapses to just `...` (nothing safe to reveal). +mask_last4() { + if [ "${#1}" -gt 4 ]; then + printf '...%s' "$(printf '%s' "$1" | tail -c 4)" + else + printf '...' + fi +} diff --git a/tests/workflows/test_setup_repo.py b/tests/workflows/test_setup_repo.py index 84aa6805..9231e976 100644 --- a/tests/workflows/test_setup_repo.py +++ b/tests/workflows/test_setup_repo.py @@ -75,24 +75,24 @@ def test_shell_script_runs_setup_repo_and_advances() -> None: def test_shell_script_checks_for_an_existing_credential_and_guides_the_operator() -> None: script = WF.shell_script() - # branches on an already-configured credential (env-file sourced by the shell runner) + # branches on an already-configured credential, checked against the **env-file** (not the sourced + # env) so a host-only token isn't mis-reported as configured assert "CLAUDE_CODE_OAUTH_TOKEN" in script and "ANTHROPIC_API_KEY" in script + assert "env_file_has_var CLAUDE_CODE_OAUTH_TOKEN" in script assert "$PANOPTICON_ENV_FILE" in script or "PANOPTICON_ENV_FILE" in script # names the env-file - # tells the operator they can drop the task instead (dashboard 'x') - assert "'x'" in script and "drop" in script.lower() # detects/falls back to the tmux detach binding to get back to the dashboard assert "detach-client" in script and "show-options -gv prefix" in script def test_shell_script_opens_with_the_credentials_goal_intro() -> None: script = WF.shell_script() - # begins by stating the goal: persistent credentials for Claude to use inside containers, so the - # agent runs on its own dedicated credentials instead of the operator's. - assert "persistent credentials" in script - assert "task containers" in script - assert "instead of hijacking yours" in script + # begins by explaining what happens and that the operator stays in control: task containers use + # per-repo credentials (not the operator's own session), and they can opt out. + assert "per-repo credentials" in script + assert "not your" in script and "personal session" in script + assert "set up your own secrets by editing" in script # the intro comes before the dashboard hint and any prompts - assert script.index("persistent credentials") < script.index('echo "$dashboard_hint"') + assert script.index("per-repo credentials") < script.index('echo "$dashboard_hint"') def test_shell_script_shows_the_dashboard_hint_first() -> None: @@ -198,18 +198,30 @@ def test_shell_script_sets_up_the_github_token_for_github_repos() -> None: script = WF.shell_script() # gated on the repo being a GitHub remote (local checkouts skip the whole GH step) assert "is_github_url" in script and "PANOPTICON_GIT_URL" in script - # reuses a GH_TOKEN already in the environment — it does not mint one (no interactive gh auth) + # adopts a GH_TOKEN from the environment or lets the operator paste one — it does not mint one assert "GH_TOKEN" in script assert "gh auth login" not in script and "gh auth token" not in script - # writes it via the shared helper (so an existing GH_TOKEN is commented out + replaced), and - # offers to replace one already in the env-file - assert "store_env_token GH_TOKEN" in script + # writes it via the shared store_token → store_env_token (existing GH_TOKEN commented out + + # replaced), and only offers to adopt one that isn't already the env-file's own + assert "store_token GH_TOKEN" in script assert "env_file_has_var GH_TOKEN" in script # the GH step runs after the Claude credential step but before the final summary - assert script.rindex("setup_gh_token") > script.index("${CLAUDE_CODE_OAUTH_TOKEN:-}") + assert script.rindex("setup_gh_token") > script.index("claude_configured") assert script.rindex("setup_gh_token") < script.rindex('echo "Summary:"') +def test_shell_script_offers_adopt_paste_and_default_no_consent() -> None: + script = WF.shell_script() + # each credential can be adopted from the operator's env or pasted inline (fast path for an + # already-authenticated operator — no cancel-and-restart) + assert "Paste a Claude token" in script and "Paste a GitHub token" in script + # adoption confirms *which* token via a masked tail, and every prompt is default-No (no [Y/n]) + assert "mask_last4" in script + assert "[Y/n]" not in script + # the Claude token can be adopted from a host-env var too (symmetric with GH) + assert "setup_claude_token" in script + + def test_shell_script_closing_summary_is_bulleted() -> None: script = WF.shell_script() # each step records its outcome as a bullet; the closing summary prints them @@ -295,6 +307,15 @@ def test_repo_source_label_classifies_local_github_and_other() -> None: assert _sh("repo_source_label ''").strip() == "unknown" +def test_mask_last4_reveals_only_the_tail() -> None: + # Consent prompts show which token without exposing it: the last 4 chars, or nothing when short. + assert _sh("mask_last4 sk-ant-oat01-abcdWXYZ").strip() == "...WXYZ" + assert _sh("mask_last4 ghp_1234").strip() == "...1234" # 8 chars → last 4 + assert _sh("mask_last4 abcd").strip() == "..." # exactly 4 → nothing safe to reveal + assert _sh("mask_last4 ab").strip() == "..." + assert _sh("mask_last4 ''").strip() == "..." + + def test_docker_workflows_have_no_shell_script_and_default_knobs() -> None: from panopticon.workflows import Spike