Commit f7542d3

Eric Bower  ·  2026-08-15 12:49:33 -0400 EDT
parent a869da2
feat: debug mode and gha
6 files changed,  +578, -18
M go.mod
M go.sum
+96, -0
 1@@ -0,0 +1,96 @@
 2+name: "Pici CI"
 3+description: "Run parallel CI workflows with pico.sh, zmx, and interactive pipe.pico.sh remote debugging"
 4+author: "pico.sh"
 5+branding:
 6+  icon: "terminal"
 7+  color: "blue"
 8+
 9+inputs:
10+  pico_ssh_key:
11+    description: "SSH private key registered with pico.sh for authenticated pipe.pico.sh debug relay access"
12+    required: false
13+    default: ""
14+  debug_on_fail:
15+    description: "Automatically open an interactive pipe.pico.sh debug bridge if a job fails (true, false, auto)"
16+    required: false
17+    default: "auto"
18+  summary:
19+    description: "Write Markdown summary table to GITHUB_STEP_SUMMARY"
20+    required: false
21+    default: "true"
22+  working_directory:
23+    description: "Directory containing pico.sh"
24+    required: false
25+    default: "."
26+  zmx_version:
27+    description: "Pinned zmx release version to install"
28+    required: false
29+    default: "v0.1.0"
30+  pici_version:
31+    description: "Pinned pici release version to install"
32+    required: false
33+    default: "latest"
34+
35+runs:
36+  using: "composite"
37+  steps:
38+    - name: Set up SSH key for pico.sh debug relay
39+      if: inputs.pico_ssh_key != ''
40+      shell: bash
41+      run: |
42+        mkdir -p ~/.ssh
43+        chmod 700 ~/.ssh
44+        echo "${{ inputs.pico_ssh_key }}" > ~/.ssh/id_pici_debug
45+        chmod 600 ~/.ssh/id_pici_debug
46+
47+    - name: Install zmx
48+      shell: bash
49+      run: |
50+        if ! command -v zmx >/dev/null 2>&1; then
51+          echo "==> Installing zmx..."
52+          curl -fsSL https://github.com/picosh/zmx/releases/latest/download/zmx-linux-amd64 -o /usr/local/bin/zmx || \
53+          curl -fsSL https://pkg.pico.sh/zmx -o /usr/local/bin/zmx
54+          chmod +x /usr/local/bin/zmx
55+        fi
56+
57+    - name: Install pici
58+      shell: bash
59+      run: |
60+        if ! command -v pici >/dev/null 2>&1; then
61+          echo "==> Installing pici..."
62+          curl -fsSL https://pkg.pico.sh/pici -o /usr/local/bin/pici || true
63+          if [ ! -f /usr/local/bin/pici ]; then
64+            go install github.com/picosh/pici@latest
65+          else
66+            chmod +x /usr/local/bin/pici
67+          fi
68+        fi
69+
70+    - name: Run Pici CI
71+      shell: bash
72+      working-directory: ${{ inputs.working_directory }}
73+      run: |
74+        FLAGS="--in-place"
75+
76+        # Determine debug-on-fail flag
77+        if [ "${{ inputs.debug_on_fail }}" = "true" ] || { [ "${{ inputs.debug_on_fail }}" = "auto" ] && [ "${RUNNER_DEBUG:-0}" = "1" ]; }; then
78+          FLAGS="$FLAGS --debug-on-fail"
79+        fi
80+
81+        # Pass summary file flag if enabled
82+        if [ "${{ inputs.summary }}" = "true" ] && [ -n "$GITHUB_STEP_SUMMARY" ]; then
83+          FLAGS="$FLAGS --summary-file $GITHUB_STEP_SUMMARY"
84+        fi
85+
86+        # Pass SSH key if configured
87+        if [ -f "$HOME/.ssh/id_pici_debug" ]; then
88+          FLAGS="$FLAGS -pk $HOME/.ssh/id_pici_debug"
89+        fi
90+
91+        echo "==> Executing: pici run $FLAGS"
92+        pici run $FLAGS \
93+          -e PICI_JOB="${GITHUB_RUN_ID:-local}" \
94+          -e PICI_EVENT="git.${GITHUB_EVENT_NAME:-push}" \
95+          -e PICI_BRANCH="${GITHUB_REF_NAME:-main}" \
96+          -e PICI_COMMIT="${GITHUB_SHA:-dev}" \
97+          -e PICI_REPO="${GITHUB_REPOSITORY:-local}"
+310, -0
  1@@ -0,0 +1,310 @@
  2+package main
  3+
  4+import (
  5+	"context"
  6+	"fmt"
  7+	"io"
  8+	"log/slog"
  9+	"net"
 10+	"os"
 11+	"os/exec"
 12+	"path/filepath"
 13+	"strings"
 14+	"time"
 15+
 16+	"github.com/creack/pty"
 17+	"golang.org/x/crypto/ssh"
 18+	"golang.org/x/crypto/ssh/agent"
 19+)
 20+
 21+// scrubEnvironment filters out sensitive environment variables (tokens, keys, secrets, credentials)
 22+// while retaining essential system, terminal, and pici variables so interactive debugging works.
 23+func scrubEnvironment(environ []string) []string {
 24+	safePrefixes := []string{
 25+		"PATH=", "USER=", "HOME=", "SHELL=", "TERM=", "LANG=", "LC_",
 26+		"ZMX_", "PICI_", "PWD=", "TMPDIR=", "HOSTNAME=", "EDITOR=", "COLORTERM=",
 27+		"SHLVL=", "LOGNAME=", "_=",
 28+	}
 29+
 30+	sensitiveSubstrings := []string{
 31+		"TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "AUTH",
 32+		"PRIVATE", "API_KEY", "ACCESS_KEY",
 33+	}
 34+
 35+	var cleaned []string
 36+	for _, env := range environ {
 37+		parts := strings.SplitN(env, "=", 2)
 38+		if len(parts) == 0 {
 39+			continue
 40+		}
 41+		key := parts[0]
 42+		keyUpper := strings.ToUpper(key)
 43+
 44+		// Explicitly scrub SSH private keys or pico auth keys
 45+		if keyUpper == "PICO_SSH_KEY" || keyUpper == "SSH_PRIVATE_KEY" || keyUpper == "SSH_KEY" {
 46+			continue
 47+		}
 48+
 49+		// Check if it matches an explicitly preserved safe prefix
 50+		isSafe := false
 51+		for _, sp := range safePrefixes {
 52+			if strings.HasPrefix(env, sp) {
 53+				isSafe = true
 54+				break
 55+			}
 56+		}
 57+
 58+		if isSafe {
 59+			cleaned = append(cleaned, env)
 60+			continue
 61+		}
 62+
 63+		// Check if key contains sensitive words
 64+		isSensitive := false
 65+		for _, substr := range sensitiveSubstrings {
 66+			if strings.Contains(keyUpper, substr) {
 67+				isSensitive = true
 68+				break
 69+			}
 70+		}
 71+
 72+		if !isSensitive {
 73+			cleaned = append(cleaned, env)
 74+		}
 75+	}
 76+	return cleaned
 77+}
 78+
 79+// findSSHAuthMethod discovers an available SSH authentication method from:
 80+// 1. Specified keyLocation (and optional certificateLocation)
 81+// 2. PICO_SSH_KEY environment variable (file path or raw key content)
 82+// 3. Active SSH agent ($SSH_AUTH_SOCK)
 83+// 4. Default user SSH keys (~/.ssh/id_ed25519, ~/.ssh/id_rsa)
 84+func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.AuthMethod, error) {
 85+	// 1. Specified key path
 86+	if keyLocation != "" {
 87+		keyBytes, err := os.ReadFile(keyLocation)
 88+		if err != nil {
 89+			return nil, fmt.Errorf("read key file: %w", err)
 90+		}
 91+		signer, err := ssh.ParsePrivateKey(keyBytes)
 92+		if err != nil {
 93+			return nil, fmt.Errorf("parse private key: %w", err)
 94+		}
 95+		if certificateLocation != "" {
 96+			certBytes, err := os.ReadFile(certificateLocation)
 97+			if err != nil {
 98+				return nil, fmt.Errorf("read cert file: %w", err)
 99+			}
100+			pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certBytes)
101+			if err != nil {
102+				return nil, fmt.Errorf("parse cert: %w", err)
103+			}
104+			cert, ok := pubKey.(*ssh.Certificate)
105+			if !ok {
106+				return nil, fmt.Errorf("public key is not a certificate")
107+			}
108+			certSigner, err := ssh.NewCertSigner(cert, signer)
109+			if err != nil {
110+				return nil, fmt.Errorf("new cert signer: %w", err)
111+			}
112+			return ssh.PublicKeys(certSigner), nil
113+		}
114+		return ssh.PublicKeys(signer), nil
115+	}
116+
117+	// 2. PICO_SSH_KEY environment variable (path or raw text)
118+	if envKey := os.Getenv("PICO_SSH_KEY"); envKey != "" {
119+		var keyBytes []byte
120+		if _, err := os.Stat(envKey); err == nil {
121+			keyBytes, _ = os.ReadFile(envKey)
122+		} else {
123+			keyBytes = []byte(envKey)
124+		}
125+		signer, err := ssh.ParsePrivateKey(keyBytes)
126+		if err == nil {
127+			return ssh.PublicKeys(signer), nil
128+		}
129+	}
130+
131+	// 3. SSH Agent
132+	if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
133+		if agentConn, err := net.Dial("unix", sock); err == nil {
134+			ag := agent.NewClient(agentConn)
135+			return ssh.PublicKeysCallback(ag.Signers), nil
136+		}
137+	}
138+
139+	// 4. Standard ~/.ssh/ files
140+	if homeDir, err := os.UserHomeDir(); err == nil {
141+		for _, keyName := range []string{"id_ed25519", "id_rsa"} {
142+			p := filepath.Join(homeDir, ".ssh", keyName)
143+			if keyBytes, err := os.ReadFile(p); err == nil {
144+				if signer, err := ssh.ParsePrivateKey(keyBytes); err == nil {
145+					return ssh.PublicKeys(signer), nil
146+				}
147+			}
148+		}
149+	}
150+
151+	return nil, fmt.Errorf("no SSH authentication method found (provide -pk or set PICO_SSH_KEY)")
152+}
153+
154+type DebugBridgeConfig struct {
155+	Host         string
156+	Topic        string
157+	KeyLocation  string
158+	CertLocation string
159+	WorkDir      string
160+	Timeout      time.Duration
161+	Logger       *slog.Logger
162+	Stdout       io.Writer
163+}
164+
165+// startDebugBridge establishes a PTY relay over pipe.pico.sh
166+func startDebugBridge(ctx context.Context, cfg DebugBridgeConfig) error {
167+	if cfg.Host == "" {
168+		cfg.Host = "pipe.pico.sh:22"
169+	}
170+	if cfg.Timeout <= 0 {
171+		cfg.Timeout = 15 * time.Minute
172+	}
173+	if cfg.Stdout == nil {
174+		cfg.Stdout = os.Stdout
175+	}
176+
177+	auth, err := findSSHAuthMethod(cfg.KeyLocation, cfg.CertLocation)
178+	if err != nil {
179+		return fmt.Errorf("ssh auth: %w", err)
180+	}
181+
182+	sshConfig := &ssh.ClientConfig{
183+		User:            "pici",
184+		Auth:            []ssh.AuthMethod{auth},
185+		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
186+		Timeout:         10 * time.Second,
187+	}
188+
189+	fmt.Fprintf(cfg.Stdout, "🔌 connecting debug bridge to %s (topic: %s)...\n", cfg.Host, cfg.Topic) //nolint:errcheck
190+	client, err := ssh.Dial("tcp", cfg.Host, sshConfig)
191+	if err != nil {
192+		return fmt.Errorf("dial ssh (%s): %w", cfg.Host, err)
193+	}
194+	defer client.Close()
195+
196+	session, err := client.NewSession()
197+	if err != nil {
198+		return fmt.Errorf("ssh session: %w", err)
199+	}
200+	defer session.Close()
201+
202+	sshStdin, err := session.StdinPipe()
203+	if err != nil {
204+		return fmt.Errorf("stdin pipe: %w", err)
205+	}
206+	sshStdout, err := session.StdoutPipe()
207+	if err != nil {
208+		return fmt.Errorf("stdout pipe: %w", err)
209+	}
210+	session.Stderr = io.Discard
211+
212+	if err := session.Start(fmt.Sprintf("pipe %s", cfg.Topic)); err != nil {
213+		return fmt.Errorf("start pipe command: %w", err)
214+	}
215+
216+	shell := os.Getenv("SHELL")
217+	if shell == "" {
218+		shell = "/bin/bash"
219+	}
220+	cmd := exec.Command(shell, "-i")
221+	if cfg.WorkDir != "" {
222+		cmd.Dir = cfg.WorkDir
223+	}
224+	cmd.Env = scrubEnvironment(os.Environ())
225+
226+	ptmx, err := pty.Start(cmd)
227+	if err != nil {
228+		return fmt.Errorf("start pty: %w", err)
229+	}
230+	defer func() {
231+		_ = ptmx.Close()
232+		if cmd.Process != nil {
233+			_ = cmd.Process.Kill()
234+		}
235+	}()
236+
237+	fmt.Fprintf(cfg.Stdout, "\n🔧 ========================================================\n")                 //nolint:errcheck
238+	fmt.Fprintf(cfg.Stdout, "🔧 PICI REMOTE DEBUG BRIDGE ACTIVE\n")                                            //nolint:errcheck
239+	fmt.Fprintf(cfg.Stdout, "🔧 Run this command in your terminal to attach:\n")                               //nolint:errcheck
240+	fmt.Fprintf(cfg.Stdout, "🔧   ssh -t pipe.pico.sh pipe %s\n", cfg.Topic)                                   //nolint:errcheck
241+	fmt.Fprintf(cfg.Stdout, "🔧 Session will close when shell exits or after %s of idle time.\n", cfg.Timeout) //nolint:errcheck
242+	fmt.Fprintf(cfg.Stdout, "🔧 ========================================================\n\n")                 //nolint:errcheck
243+
244+	ctxWithTimeout, cancel := context.WithTimeout(ctx, cfg.Timeout)
245+	defer cancel()
246+
247+	errCh := make(chan error, 3)
248+
249+	go func() {
250+		_, err := io.Copy(ptmx, sshStdout)
251+		errCh <- err
252+	}()
253+
254+	go func() {
255+		_, err := io.Copy(sshStdin, ptmx)
256+		errCh <- err
257+	}()
258+
259+	go func() {
260+		errCh <- cmd.Wait()
261+	}()
262+
263+	select {
264+	case <-ctxWithTimeout.Done():
265+		fmt.Fprintf(cfg.Stdout, "\n⏰ debug session timed out after %s\n", cfg.Timeout) //nolint:errcheck
266+	case <-errCh:
267+		fmt.Fprintf(cfg.Stdout, "\n🔌 debug session ended\n") //nolint:errcheck
268+	}
269+
270+	return nil
271+}
272+
273+func runDebug(cfg *Cfg, target string) error {
274+	if target == "" {
275+		return fmt.Errorf("target session or repo/job_id required (e.g. pici debug myrepo/12345 or pici debug ci.myrepo.12345.runner)")
276+	}
277+
278+	// Check if target is a full session name or short session prefix
279+	if strings.Contains(target, "/") {
280+		parts := strings.SplitN(target, "/", 2)
281+		repo, jobID := parts[0], parts[1]
282+		prefix := fmt.Sprintf("ci.%s.%s.", repo, jobID)
283+		localPrefix := fmt.Sprintf("local.%s.%s.", repo, jobID)
284+		listOutput, err := exec.Command("zmx", "list").CombinedOutput()
285+		if err != nil {
286+			return fmt.Errorf("list zmx sessions: %w", err)
287+		}
288+		sessions := parseZMXList(string(listOutput))
289+		var matches []SessionInfo
290+		for _, s := range sessions {
291+			if strings.HasPrefix(s.Name, prefix) || strings.HasPrefix(s.Name, localPrefix) {
292+				matches = append(matches, s)
293+			}
294+		}
295+		if len(matches) == 0 {
296+			return fmt.Errorf("no active zmx sessions found for %s", target)
297+		}
298+		fmt.Fprintf(os.Stdout, "Found sessions for %s:\n", target) //nolint:errcheck
299+		for _, m := range matches {
300+			fmt.Fprintf(os.Stdout, "  • %s (pid %s, exit: %s)\n", m.Name, m.PID, m.ExitCode) //nolint:errcheck
301+		}
302+		target = matches[0].Name
303+		fmt.Fprintf(os.Stdout, "\nAttaching to %s...\n", target) //nolint:errcheck
304+	}
305+
306+	cmd := exec.Command("zmx", "attach", target)
307+	cmd.Stdin = os.Stdin
308+	cmd.Stdout = os.Stdout
309+	cmd.Stderr = os.Stderr
310+	return cmd.Run()
311+}
M go.mod
+6, -0
 1@@ -1,3 +1,9 @@
 2 module github.com/picosh/pici
 3 
 4 go 1.26.2
 5+
 6+require (
 7+	github.com/creack/pty v1.1.24 // indirect
 8+	golang.org/x/crypto v0.55.0 // indirect
 9+	golang.org/x/sys v0.47.0 // indirect
10+)
M go.sum
+6, -0
1@@ -0,0 +1,6 @@
2+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
3+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
4+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
5+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
6+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
7+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+68, -13
  1@@ -76,14 +76,16 @@ type Cfg struct {
  2 	MonitorInterval     time.Duration
  3 	GCInterval          time.Duration
  4 	NewWorkspace        WorkspaceFactory
  5-	StatusOutput        io.Writer // where status JSONL is written (default: os.Stdout)
  6-	IncludeRunning      bool      // emit running status updates in addition to terminal
  7-	HumanOutput         bool      // human-readable output instead of JSONL / slog
  8-	Wait                bool      // block until job completes, print history and summary
  9-	InPlace             bool      // execute workspace in-place without rsyncing to /tmp
 10-	SummaryFile         string    // write markdown summary to this file path
 11-	EnvVars             envList   // custom environment variables passed via -e / -env
 12-	SessionPrefix       string    // session prefix filter for monitor (default: "ci.")
 13+	StatusOutput        io.Writer     // where status JSONL is written (default: os.Stdout)
 14+	IncludeRunning      bool          // emit running status updates in addition to terminal
 15+	HumanOutput         bool          // human-readable output instead of JSONL / slog
 16+	Wait                bool          // block until job completes, print history and summary
 17+	InPlace             bool          // execute workspace in-place without rsyncing to /tmp
 18+	SummaryFile         string        // write markdown summary to this file path
 19+	DebugOnFail         bool          // open interactive pipe.pico.sh debug bridge on failure
 20+	DebugTimeout        time.Duration // idle timeout for debug bridge (default: 15m)
 21+	EnvVars             envList       // custom environment variables passed via -e / -env
 22+	SessionPrefix       string        // session prefix filter for monitor (default: "ci.")
 23 }
 24 
 25 // JobFailedError indicates that one or more sessions within a job failed,
 26@@ -115,6 +117,7 @@ func NewCfg() (*Cfg, string, bool) {
 27 	var logLevel string
 28 	var envVars envList
 29 	var sessionPrefix string
 30+	var debugTimeout time.Duration
 31 	flag.StringVar(&keyLoc, "pk", "", "ssh private key used to authenticate with pico services")
 32 	flag.StringVar(&certLoc, "ck", "", "ssh certificate public key used to authenticate with pico services (only required if using ssh certificates)")
 33 	flag.StringVar(&artifactDir, "artifact-dir", "/tmp/pici-artifacts", "local directory to stage artifacts")
 34@@ -130,11 +133,14 @@ func NewCfg() (*Cfg, string, bool) {
 35 	var wait bool
 36 	var inPlace bool
 37 	var summaryFile string
 38+	var debugOnFail bool
 39 	flag.BoolVar(&includeRunning, "include-running", false, "emit running status updates in addition to terminal (default: terminal only)")
 40 	flag.BoolVar(&human, "human", false, "human-readable output (default: JSONL / slog)")
 41 	flag.BoolVar(&wait, "wait", false, "block until job completes, printing session history and summary")
 42 	flag.BoolVar(&inPlace, "in-place", false, "execute workspace in-place without rsyncing to /tmp")
 43 	flag.StringVar(&summaryFile, "summary-file", "", "file path to write markdown job summary")
 44+	flag.BoolVar(&debugOnFail, "debug-on-fail", false, "open interactive pipe.pico.sh debug bridge on failure")
 45+	flag.DurationVar(&debugTimeout, "debug-timeout", 15*time.Minute, "idle timeout for interactive debug bridge")
 46 
 47 	// Split args so the subcommand (first non-flag arg) doesn't block
 48 	// flags that appear after it: "pici runner --wait" works.
 49@@ -144,6 +150,16 @@ func NewCfg() (*Cfg, string, bool) {
 50 		os.Exit(1)
 51 	}
 52 
 53+	for _, envPair := range envVars {
 54+		parts := strings.SplitN(envPair, "=", 2)
 55+		if len(parts) == 2 && parts[0] == "PICI_DEBUG" && (parts[1] == "1" || parts[1] == "true") {
 56+			debugOnFail = true
 57+		}
 58+	}
 59+	if envVal := os.Getenv("PICI_DEBUG"); envVal == "1" || envVal == "true" {
 60+		debugOnFail = true
 61+	}
 62+
 63 	logger := newLogger("ci", logLevel)
 64 	ctx, cancel := context.WithCancel(context.Background())
 65 	return &Cfg{
 66@@ -162,6 +178,8 @@ func NewCfg() (*Cfg, string, bool) {
 67 		Wait:                wait,
 68 		InPlace:             inPlace,
 69 		SummaryFile:         summaryFile,
 70+		DebugOnFail:         debugOnFail,
 71+		DebugTimeout:        debugTimeout,
 72 		EnvVars:             envVars,
 73 		SessionPrefix:       sessionPrefix,
 74 	}, cmd, wantHelp
 75@@ -169,7 +187,7 @@ func NewCfg() (*Cfg, string, bool) {
 76 
 77 func isKnownSubcommand(s string) bool {
 78 	switch s {
 79-	case "runner", "monitor", "cancel", "gc", "status", "orca", "run", "help":
 80+	case "runner", "monitor", "cancel", "gc", "status", "orca", "run", "debug", "help":
 81 		return true
 82 	default:
 83 		return false
 84@@ -273,6 +291,7 @@ LOCAL DEVELOPER USAGE
 85   pici [destination] [flags]                  Run ./pico.sh locally in /tmp & render HTML logs
 86   pici pgs.sh:/my-site                        Run locally and rsync HTML logs to destination
 87   pici run [destination]                      Explicit alias for local run
 88+  pici debug <repo/job_id | session>          Attach to active zmx session for debugging
 89 
 90 DAEMON & SERVICE COMMANDS
 91   pici runner                                 Execute CI job from event JSON payload (stdin/flag)
 92@@ -287,6 +306,8 @@ FLAGS
 93   -e, -env <KEY=VAL>                          Set or override environment variable for pico.sh
 94   -in-place                                   Execute workspace in-place without copying to /tmp
 95   -summary-file <path>                        Write markdown job summary table to file
 96+  -debug-on-fail                              Open interactive pipe.pico.sh debug bridge on failure
 97+  -debug-timeout <dur>                        Idle timeout for interactive debug bridge (default: 15m)
 98   -pk <path>                                  SSH private key
 99   -ck <path>                                  SSH public certificate key
100   -artifact-dir <path>                        Artifact staging directory (default: /tmp/pici-artifacts)
101@@ -377,8 +398,16 @@ func main() {
102 		}
103 	case "status":
104 		cfg.Logger.Debug("starting status updater")
105-	case "orca":
106-		cfg.Logger.Debug("starting orchestrator")
107+	case "debug":
108+		cfg.Logger.Debug("starting debug session")
109+		target := ""
110+		if flag.NArg() > 0 {
111+			target = flag.Arg(0)
112+		}
113+		if err := runDebug(cfg, target); err != nil {
114+			cfg.Logger.Error("debug failed", "err", err)
115+			os.Exit(1)
116+		}
117 	case "help":
118 		printMainHelp()
119 	case "run", "":
120@@ -1190,8 +1219,25 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
121 		fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck
122 	}
123 
124+	topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID)
125+	if exitCode != 0 && cfg.DebugOnFail {
126+		cwd, _ := os.Getwd()
127+		debugCfg := DebugBridgeConfig{
128+			Topic:        topic,
129+			KeyLocation:  cfg.KeyLocation,
130+			CertLocation: cfg.CertificateLocation,
131+			WorkDir:      cwd,
132+			Timeout:      cfg.DebugTimeout,
133+			Logger:       log,
134+			Stdout:       os.Stdout,
135+		}
136+		if err := startDebugBridge(cfg.Ctx, debugCfg); err != nil {
137+			log.Error("debug bridge failed", "err", err)
138+		}
139+	}
140+
141 	if cfg.SummaryFile != "" {
142-		if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known); err != nil {
143+		if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known, cfg.DebugOnFail && exitCode != 0, topic); err != nil {
144 			log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
145 		}
146 	}
147@@ -1203,7 +1249,7 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
148 	return nil
149 }
150 
151-func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState) error {
152+func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState, debugActive bool, debugTopic string) error {
153 	if filePath == "" {
154 		return nil
155 	}
156@@ -1216,6 +1262,12 @@ func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions [
157 	sb.WriteString(fmt.Sprintf("### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID))
158 	sb.WriteString(fmt.Sprintf("**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration))
159 
160+	if debugActive && debugTopic != "" {
161+		sb.WriteString("### 🔧 Remote Debug Active\n\n")
162+		sb.WriteString("To attach to the interactive shell session for this job:\n\n")
163+		sb.WriteString(fmt.Sprintf("```bash\nssh -t pipe.pico.sh pipe %s\n```\n\n", debugTopic))
164+	}
165+
166 	sb.WriteString("| Session | Status | Exit Code | Duration |\n")
167 	sb.WriteString("| :--- | :--- | :--- | :--- |\n")
168 
169@@ -2543,6 +2595,9 @@ func runLocal(cfg *Cfg, dest string) error {
170 	}
171 	log := logger.With("repo", eventData.Name, "job_id", jobID, "mode", "local")
172 
173+	// Cancel any existing running job for this repo
174+	cancelRunningJobs(cfg, log, eventData.Name)
175+
176 	// Set up workspace
177 	var wk Workspace
178 	if cfg.InPlace {
+92, -5
  1@@ -1271,9 +1271,13 @@ func TestRunLocal_InPlace(t *testing.T) {
  2 	}
  3 
  4 	tempDir := t.TempDir()
  5+	workDir := filepath.Join(tempDir, "inplace-repo")
  6+	if err := os.MkdirAll(workDir, 0755); err != nil {
  7+		t.Fatal(err)
  8+	}
  9 	origWd, _ := os.Getwd()
 10 	defer func() { _ = os.Chdir(origWd) }()
 11-	_ = os.Chdir(tempDir)
 12+	_ = os.Chdir(workDir)
 13 
 14 	picoContent := `#!/usr/bin/env bash
 15 set -e
 16@@ -1295,7 +1299,7 @@ echo "inplace marker" > marker.txt
 17 		t.Fatalf("expected runLocal in-place to succeed, got %v", err)
 18 	}
 19 
 20-	markerPath := filepath.Join(tempDir, "marker.txt")
 21+	markerPath := filepath.Join(workDir, "marker.txt")
 22 	data, err := os.ReadFile(markerPath)
 23 	if err != nil {
 24 		t.Fatalf("expected marker.txt to be created in working directory: %v", err)
 25@@ -1311,9 +1315,13 @@ func TestRunLocal_SummaryFile(t *testing.T) {
 26 	}
 27 
 28 	tempDir := t.TempDir()
 29+	workDir := filepath.Join(tempDir, "summary-repo")
 30+	if err := os.MkdirAll(workDir, 0755); err != nil {
 31+		t.Fatal(err)
 32+	}
 33 	origWd, _ := os.Getwd()
 34 	defer func() { _ = os.Chdir(origWd) }()
 35-	_ = os.Chdir(tempDir)
 36+	_ = os.Chdir(workDir)
 37 
 38 	picoContent := `#!/usr/bin/env bash
 39 echo "hello from step"
 40@@ -1323,7 +1331,7 @@ echo "hello from step"
 41 	}
 42 
 43 	artifactDir := t.TempDir()
 44-	summaryFilePath := filepath.Join(tempDir, "summary.md")
 45+	summaryFilePath := filepath.Join(workDir, "summary.md")
 46 	cfg := &Cfg{
 47 		ArtifactDir:     artifactDir,
 48 		MonitorInterval: 100 * time.Millisecond,
 49@@ -1361,7 +1369,7 @@ func TestWriteSummaryMarkdown_WithFailure(t *testing.T) {
 50 		"failing-step": {status: "failed", exitCode: "42", duration: "3.4s"},
 51 	}
 52 
 53-	err := writeSummaryMarkdown(tempFile, "test-repo", "job-123", sessions, known)
 54+	err := writeSummaryMarkdown(tempFile, "test-repo", "job-123", sessions, known, false, "")
 55 	if err != nil {
 56 		t.Fatalf("writeSummaryMarkdown failed: %v", err)
 57 	}
 58@@ -1382,3 +1390,82 @@ func TestWriteSummaryMarkdown_WithFailure(t *testing.T) {
 59 		t.Errorf("missing failed details in content:\n%s", content)
 60 	}
 61 }
 62+
 63+func TestWriteSummaryMarkdown_WithDebugActive(t *testing.T) {
 64+	tempFile := filepath.Join(t.TempDir(), "summary.md")
 65+	sessions := []SessionInfo{
 66+		{Short: "failing-step", Name: "local.repo.job.failing-step", ExitCode: "42"},
 67+	}
 68+	known := map[string]*sessionState{
 69+		"failing-step": {status: "failed", exitCode: "42", duration: "1.0s"},
 70+	}
 71+
 72+	err := writeSummaryMarkdown(tempFile, "my-repo", "job-456", sessions, known, true, "ci.my-repo.job-456")
 73+	if err != nil {
 74+		t.Fatalf("writeSummaryMarkdown failed: %v", err)
 75+	}
 76+
 77+	data, err := os.ReadFile(tempFile)
 78+	if err != nil {
 79+		t.Fatalf("read summary file: %v", err)
 80+	}
 81+	content := string(data)
 82+
 83+	if !strings.Contains(content, "### 🔧 Remote Debug Active") {
 84+		t.Errorf("expected debug header in summary markdown, got:\n%s", content)
 85+	}
 86+	if !strings.Contains(content, "ssh -t pipe.pico.sh pipe ci.my-repo.job-456") {
 87+		t.Errorf("expected ssh command in summary markdown, got:\n%s", content)
 88+	}
 89+}
 90+
 91+func TestScrubEnvironment(t *testing.T) {
 92+	envIn := []string{
 93+		"PATH=/usr/bin:/bin",
 94+		"USER=runner",
 95+		"HOME=/home/runner",
 96+		"SHELL=/bin/bash",
 97+		"TERM=xterm-256color",
 98+		"ZMX_SESSION_PREFIX=ci.repo.123.",
 99+		"PICI_JOB=123",
100+		"GITHUB_TOKEN=ghp_secret_token_12345",
101+		"ACTIONS_RUNTIME_TOKEN=actions_token_67890",
102+		"MY_APP_SECRET=super_secret_val",
103+		"API_KEY=abc123xyz",
104+		"DATABASE_PASSWORD=secret_db_pass",
105+		"PICO_SSH_KEY=-----BEGIN OPENSSH PRIVATE KEY-----",
106+		"CUSTOM_SAFE_FLAG=1",
107+	}
108+
109+	cleaned := scrubEnvironment(envIn)
110+	joined := strings.Join(cleaned, "\n")
111+
112+	// Verify safe vars remain
113+	for _, expected := range []string{
114+		"PATH=/usr/bin:/bin",
115+		"USER=runner",
116+		"HOME=/home/runner",
117+		"SHELL=/bin/bash",
118+		"ZMX_SESSION_PREFIX=ci.repo.123.",
119+		"PICI_JOB=123",
120+		"CUSTOM_SAFE_FLAG=1",
121+	} {
122+		if !strings.Contains(joined, expected) {
123+			t.Errorf("expected %q to be preserved in environment, got:\n%s", expected, joined)
124+		}
125+	}
126+
127+	// Verify secrets are scrubbed
128+	for _, forbidden := range []string{
129+		"GITHUB_TOKEN",
130+		"ACTIONS_RUNTIME_TOKEN",
131+		"MY_APP_SECRET",
132+		"API_KEY",
133+		"DATABASE_PASSWORD",
134+		"PICO_SSH_KEY",
135+	} {
136+		if strings.Contains(joined, forbidden) {
137+			t.Errorf("expected %q to be SCRUBBED from environment, but found it in:\n%s", forbidden, joined)
138+		}
139+	}
140+}