Commit f7542d3
Eric Bower
·
2026-08-15 12:49:33 -0400 EDT
parent a869da2
feat: debug mode and gha
6 files changed,
+578,
-18
+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=
M
main.go
+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+}