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
......@@ -0,0 +1,96 @@
1+name: "Pici CI"
2+description: "Run parallel CI workflows with pico.sh, zmx, and interactive pipe.pico.sh remote debugging"
3+author: "pico.sh"
4+branding:
5+ icon: "terminal"
6+ color: "blue"
7+
8+inputs:
9+ pico_ssh_key:
10+ description: "SSH private key registered with pico.sh for authenticated pipe.pico.sh debug relay access"
11+ required: false
12+ default: ""
13+ debug_on_fail:
14+ description: "Automatically open an interactive pipe.pico.sh debug bridge if a job fails (true, false, auto)"
15+ required: false
16+ default: "auto"
17+ summary:
18+ description: "Write Markdown summary table to GITHUB_STEP_SUMMARY"
19+ required: false
20+ default: "true"
21+ working_directory:
22+ description: "Directory containing pico.sh"
23+ required: false
24+ default: "."
25+ zmx_version:
26+ description: "Pinned zmx release version to install"
27+ required: false
28+ default: "v0.1.0"
29+ pici_version:
30+ description: "Pinned pici release version to install"
31+ required: false
32+ default: "latest"
33+
34+runs:
35+ using: "composite"
36+ steps:
37+ - name: Set up SSH key for pico.sh debug relay
38+ if: inputs.pico_ssh_key != ''
39+ shell: bash
40+ run: |
41+ mkdir -p ~/.ssh
42+ chmod 700 ~/.ssh
43+ echo "${{ inputs.pico_ssh_key }}" > ~/.ssh/id_pici_debug
44+ chmod 600 ~/.ssh/id_pici_debug
45+
46+ - name: Install zmx
47+ shell: bash
48+ run: |
49+ if ! command -v zmx >/dev/null 2>&1; then
50+ echo "==> Installing zmx..."
51+ curl -fsSL https://github.com/picosh/zmx/releases/latest/download/zmx-linux-amd64 -o /usr/local/bin/zmx || \
52+ curl -fsSL https://pkg.pico.sh/zmx -o /usr/local/bin/zmx
53+ chmod +x /usr/local/bin/zmx
54+ fi
55+
56+ - name: Install pici
57+ shell: bash
58+ run: |
59+ if ! command -v pici >/dev/null 2>&1; then
60+ echo "==> Installing pici..."
61+ curl -fsSL https://pkg.pico.sh/pici -o /usr/local/bin/pici || true
62+ if [ ! -f /usr/local/bin/pici ]; then
63+ go install github.com/picosh/pici@latest
64+ else
65+ chmod +x /usr/local/bin/pici
66+ fi
67+ fi
68+
69+ - name: Run Pici CI
70+ shell: bash
71+ working-directory: ${{ inputs.working_directory }}
72+ run: |
73+ FLAGS="--in-place"
74+
75+ # Determine debug-on-fail flag
76+ if [ "${{ inputs.debug_on_fail }}" = "true" ] || { [ "${{ inputs.debug_on_fail }}" = "auto" ] && [ "${RUNNER_DEBUG:-0}" = "1" ]; }; then
77+ FLAGS="$FLAGS --debug-on-fail"
78+ fi
79+
80+ # Pass summary file flag if enabled
81+ if [ "${{ inputs.summary }}" = "true" ] && [ -n "$GITHUB_STEP_SUMMARY" ]; then
82+ FLAGS="$FLAGS --summary-file $GITHUB_STEP_SUMMARY"
83+ fi
84+
85+ # Pass SSH key if configured
86+ if [ -f "$HOME/.ssh/id_pici_debug" ]; then
87+ FLAGS="$FLAGS -pk $HOME/.ssh/id_pici_debug"
88+ fi
89+
90+ echo "==> Executing: pici run $FLAGS"
91+ pici run $FLAGS \
92+ -e PICI_JOB="${GITHUB_RUN_ID:-local}" \
93+ -e PICI_EVENT="git.${GITHUB_EVENT_NAME:-push}" \
94+ -e PICI_BRANCH="${GITHUB_REF_NAME:-main}" \
95+ -e PICI_COMMIT="${GITHUB_SHA:-dev}" \
96+ -e PICI_REPO="${GITHUB_REPOSITORY:-local}"
+310, -0
......@@ -0,0 +1,310 @@
1+package main
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "log/slog"
8+ "net"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "time"
14+
15+ "github.com/creack/pty"
16+ "golang.org/x/crypto/ssh"
17+ "golang.org/x/crypto/ssh/agent"
18+)
19+
20+// scrubEnvironment filters out sensitive environment variables (tokens, keys, secrets, credentials)
21+// while retaining essential system, terminal, and pici variables so interactive debugging works.
22+func scrubEnvironment(environ []string) []string {
23+ safePrefixes := []string{
24+ "PATH=", "USER=", "HOME=", "SHELL=", "TERM=", "LANG=", "LC_",
25+ "ZMX_", "PICI_", "PWD=", "TMPDIR=", "HOSTNAME=", "EDITOR=", "COLORTERM=",
26+ "SHLVL=", "LOGNAME=", "_=",
27+ }
28+
29+ sensitiveSubstrings := []string{
30+ "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "AUTH",
31+ "PRIVATE", "API_KEY", "ACCESS_KEY",
32+ }
33+
34+ var cleaned []string
35+ for _, env := range environ {
36+ parts := strings.SplitN(env, "=", 2)
37+ if len(parts) == 0 {
38+ continue
39+ }
40+ key := parts[0]
41+ keyUpper := strings.ToUpper(key)
42+
43+ // Explicitly scrub SSH private keys or pico auth keys
44+ if keyUpper == "PICO_SSH_KEY" || keyUpper == "SSH_PRIVATE_KEY" || keyUpper == "SSH_KEY" {
45+ continue
46+ }
47+
48+ // Check if it matches an explicitly preserved safe prefix
49+ isSafe := false
50+ for _, sp := range safePrefixes {
51+ if strings.HasPrefix(env, sp) {
52+ isSafe = true
53+ break
54+ }
55+ }
56+
57+ if isSafe {
58+ cleaned = append(cleaned, env)
59+ continue
60+ }
61+
62+ // Check if key contains sensitive words
63+ isSensitive := false
64+ for _, substr := range sensitiveSubstrings {
65+ if strings.Contains(keyUpper, substr) {
66+ isSensitive = true
67+ break
68+ }
69+ }
70+
71+ if !isSensitive {
72+ cleaned = append(cleaned, env)
73+ }
74+ }
75+ return cleaned
76+}
77+
78+// findSSHAuthMethod discovers an available SSH authentication method from:
79+// 1. Specified keyLocation (and optional certificateLocation)
80+// 2. PICO_SSH_KEY environment variable (file path or raw key content)
81+// 3. Active SSH agent ($SSH_AUTH_SOCK)
82+// 4. Default user SSH keys (~/.ssh/id_ed25519, ~/.ssh/id_rsa)
83+func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.AuthMethod, error) {
84+ // 1. Specified key path
85+ if keyLocation != "" {
86+ keyBytes, err := os.ReadFile(keyLocation)
87+ if err != nil {
88+ return nil, fmt.Errorf("read key file: %w", err)
89+ }
90+ signer, err := ssh.ParsePrivateKey(keyBytes)
91+ if err != nil {
92+ return nil, fmt.Errorf("parse private key: %w", err)
93+ }
94+ if certificateLocation != "" {
95+ certBytes, err := os.ReadFile(certificateLocation)
96+ if err != nil {
97+ return nil, fmt.Errorf("read cert file: %w", err)
98+ }
99+ pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certBytes)
100+ if err != nil {
101+ return nil, fmt.Errorf("parse cert: %w", err)
102+ }
103+ cert, ok := pubKey.(*ssh.Certificate)
104+ if !ok {
105+ return nil, fmt.Errorf("public key is not a certificate")
106+ }
107+ certSigner, err := ssh.NewCertSigner(cert, signer)
108+ if err != nil {
109+ return nil, fmt.Errorf("new cert signer: %w", err)
110+ }
111+ return ssh.PublicKeys(certSigner), nil
112+ }
113+ return ssh.PublicKeys(signer), nil
114+ }
115+
116+ // 2. PICO_SSH_KEY environment variable (path or raw text)
117+ if envKey := os.Getenv("PICO_SSH_KEY"); envKey != "" {
118+ var keyBytes []byte
119+ if _, err := os.Stat(envKey); err == nil {
120+ keyBytes, _ = os.ReadFile(envKey)
121+ } else {
122+ keyBytes = []byte(envKey)
123+ }
124+ signer, err := ssh.ParsePrivateKey(keyBytes)
125+ if err == nil {
126+ return ssh.PublicKeys(signer), nil
127+ }
128+ }
129+
130+ // 3. SSH Agent
131+ if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
132+ if agentConn, err := net.Dial("unix", sock); err == nil {
133+ ag := agent.NewClient(agentConn)
134+ return ssh.PublicKeysCallback(ag.Signers), nil
135+ }
136+ }
137+
138+ // 4. Standard ~/.ssh/ files
139+ if homeDir, err := os.UserHomeDir(); err == nil {
140+ for _, keyName := range []string{"id_ed25519", "id_rsa"} {
141+ p := filepath.Join(homeDir, ".ssh", keyName)
142+ if keyBytes, err := os.ReadFile(p); err == nil {
143+ if signer, err := ssh.ParsePrivateKey(keyBytes); err == nil {
144+ return ssh.PublicKeys(signer), nil
145+ }
146+ }
147+ }
148+ }
149+
150+ return nil, fmt.Errorf("no SSH authentication method found (provide -pk or set PICO_SSH_KEY)")
151+}
152+
153+type DebugBridgeConfig struct {
154+ Host string
155+ Topic string
156+ KeyLocation string
157+ CertLocation string
158+ WorkDir string
159+ Timeout time.Duration
160+ Logger *slog.Logger
161+ Stdout io.Writer
162+}
163+
164+// startDebugBridge establishes a PTY relay over pipe.pico.sh
165+func startDebugBridge(ctx context.Context, cfg DebugBridgeConfig) error {
166+ if cfg.Host == "" {
167+ cfg.Host = "pipe.pico.sh:22"
168+ }
169+ if cfg.Timeout <= 0 {
170+ cfg.Timeout = 15 * time.Minute
171+ }
172+ if cfg.Stdout == nil {
173+ cfg.Stdout = os.Stdout
174+ }
175+
176+ auth, err := findSSHAuthMethod(cfg.KeyLocation, cfg.CertLocation)
177+ if err != nil {
178+ return fmt.Errorf("ssh auth: %w", err)
179+ }
180+
181+ sshConfig := &ssh.ClientConfig{
182+ User: "pici",
183+ Auth: []ssh.AuthMethod{auth},
184+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
185+ Timeout: 10 * time.Second,
186+ }
187+
188+ fmt.Fprintf(cfg.Stdout, "🔌 connecting debug bridge to %s (topic: %s)...\n", cfg.Host, cfg.Topic) //nolint:errcheck
189+ client, err := ssh.Dial("tcp", cfg.Host, sshConfig)
190+ if err != nil {
191+ return fmt.Errorf("dial ssh (%s): %w", cfg.Host, err)
192+ }
193+ defer client.Close()
194+
195+ session, err := client.NewSession()
196+ if err != nil {
197+ return fmt.Errorf("ssh session: %w", err)
198+ }
199+ defer session.Close()
200+
201+ sshStdin, err := session.StdinPipe()
202+ if err != nil {
203+ return fmt.Errorf("stdin pipe: %w", err)
204+ }
205+ sshStdout, err := session.StdoutPipe()
206+ if err != nil {
207+ return fmt.Errorf("stdout pipe: %w", err)
208+ }
209+ session.Stderr = io.Discard
210+
211+ if err := session.Start(fmt.Sprintf("pipe %s", cfg.Topic)); err != nil {
212+ return fmt.Errorf("start pipe command: %w", err)
213+ }
214+
215+ shell := os.Getenv("SHELL")
216+ if shell == "" {
217+ shell = "/bin/bash"
218+ }
219+ cmd := exec.Command(shell, "-i")
220+ if cfg.WorkDir != "" {
221+ cmd.Dir = cfg.WorkDir
222+ }
223+ cmd.Env = scrubEnvironment(os.Environ())
224+
225+ ptmx, err := pty.Start(cmd)
226+ if err != nil {
227+ return fmt.Errorf("start pty: %w", err)
228+ }
229+ defer func() {
230+ _ = ptmx.Close()
231+ if cmd.Process != nil {
232+ _ = cmd.Process.Kill()
233+ }
234+ }()
235+
236+ fmt.Fprintf(cfg.Stdout, "\n🔧 ========================================================\n") //nolint:errcheck
237+ fmt.Fprintf(cfg.Stdout, "🔧 PICI REMOTE DEBUG BRIDGE ACTIVE\n") //nolint:errcheck
238+ fmt.Fprintf(cfg.Stdout, "🔧 Run this command in your terminal to attach:\n") //nolint:errcheck
239+ fmt.Fprintf(cfg.Stdout, "🔧 ssh -t pipe.pico.sh pipe %s\n", cfg.Topic) //nolint:errcheck
240+ fmt.Fprintf(cfg.Stdout, "🔧 Session will close when shell exits or after %s of idle time.\n", cfg.Timeout) //nolint:errcheck
241+ fmt.Fprintf(cfg.Stdout, "🔧 ========================================================\n\n") //nolint:errcheck
242+
243+ ctxWithTimeout, cancel := context.WithTimeout(ctx, cfg.Timeout)
244+ defer cancel()
245+
246+ errCh := make(chan error, 3)
247+
248+ go func() {
249+ _, err := io.Copy(ptmx, sshStdout)
250+ errCh <- err
251+ }()
252+
253+ go func() {
254+ _, err := io.Copy(sshStdin, ptmx)
255+ errCh <- err
256+ }()
257+
258+ go func() {
259+ errCh <- cmd.Wait()
260+ }()
261+
262+ select {
263+ case <-ctxWithTimeout.Done():
264+ fmt.Fprintf(cfg.Stdout, "\n⏰ debug session timed out after %s\n", cfg.Timeout) //nolint:errcheck
265+ case <-errCh:
266+ fmt.Fprintf(cfg.Stdout, "\n🔌 debug session ended\n") //nolint:errcheck
267+ }
268+
269+ return nil
270+}
271+
272+func runDebug(cfg *Cfg, target string) error {
273+ if target == "" {
274+ return fmt.Errorf("target session or repo/job_id required (e.g. pici debug myrepo/12345 or pici debug ci.myrepo.12345.runner)")
275+ }
276+
277+ // Check if target is a full session name or short session prefix
278+ if strings.Contains(target, "/") {
279+ parts := strings.SplitN(target, "/", 2)
280+ repo, jobID := parts[0], parts[1]
281+ prefix := fmt.Sprintf("ci.%s.%s.", repo, jobID)
282+ localPrefix := fmt.Sprintf("local.%s.%s.", repo, jobID)
283+ listOutput, err := exec.Command("zmx", "list").CombinedOutput()
284+ if err != nil {
285+ return fmt.Errorf("list zmx sessions: %w", err)
286+ }
287+ sessions := parseZMXList(string(listOutput))
288+ var matches []SessionInfo
289+ for _, s := range sessions {
290+ if strings.HasPrefix(s.Name, prefix) || strings.HasPrefix(s.Name, localPrefix) {
291+ matches = append(matches, s)
292+ }
293+ }
294+ if len(matches) == 0 {
295+ return fmt.Errorf("no active zmx sessions found for %s", target)
296+ }
297+ fmt.Fprintf(os.Stdout, "Found sessions for %s:\n", target) //nolint:errcheck
298+ for _, m := range matches {
299+ fmt.Fprintf(os.Stdout, " • %s (pid %s, exit: %s)\n", m.Name, m.PID, m.ExitCode) //nolint:errcheck
300+ }
301+ target = matches[0].Name
302+ fmt.Fprintf(os.Stdout, "\nAttaching to %s...\n", target) //nolint:errcheck
303+ }
304+
305+ cmd := exec.Command("zmx", "attach", target)
306+ cmd.Stdin = os.Stdin
307+ cmd.Stdout = os.Stdout
308+ cmd.Stderr = os.Stderr
309+ return cmd.Run()
310+}
M go.mod
+6, -0
......@@ -1,3 +1,9 @@
11 module github.com/picosh/pici
22
33 go 1.26.2
4+
5+require (
6+ github.com/creack/pty v1.1.24 // indirect
7+ golang.org/x/crypto v0.55.0 // indirect
8+ golang.org/x/sys v0.47.0 // indirect
9+)
M go.sum
+6, -0
......@@ -0,0 +1,6 @@
1+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
2+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
3+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
4+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
5+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
6+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+68, -13
......@@ -76,14 +76,16 @@ type Cfg struct {
7676 MonitorInterval time.Duration
7777 GCInterval time.Duration
7878 NewWorkspace WorkspaceFactory
79- StatusOutput io.Writer // where status JSONL is written (default: os.Stdout)
80- IncludeRunning bool // emit running status updates in addition to terminal
81- HumanOutput bool // human-readable output instead of JSONL / slog
82- Wait bool // block until job completes, print history and summary
83- InPlace bool // execute workspace in-place without rsyncing to /tmp
84- SummaryFile string // write markdown summary to this file path
85- EnvVars envList // custom environment variables passed via -e / -env
86- SessionPrefix string // session prefix filter for monitor (default: "ci.")
79+ StatusOutput io.Writer // where status JSONL is written (default: os.Stdout)
80+ IncludeRunning bool // emit running status updates in addition to terminal
81+ HumanOutput bool // human-readable output instead of JSONL / slog
82+ Wait bool // block until job completes, print history and summary
83+ InPlace bool // execute workspace in-place without rsyncing to /tmp
84+ SummaryFile string // write markdown summary to this file path
85+ DebugOnFail bool // open interactive pipe.pico.sh debug bridge on failure
86+ DebugTimeout time.Duration // idle timeout for debug bridge (default: 15m)
87+ EnvVars envList // custom environment variables passed via -e / -env
88+ SessionPrefix string // session prefix filter for monitor (default: "ci.")
8789 }
8890
8991 // JobFailedError indicates that one or more sessions within a job failed,
......@@ -115,6 +117,7 @@ func NewCfg() (*Cfg, string, bool) {
115117 var logLevel string
116118 var envVars envList
117119 var sessionPrefix string
120+ var debugTimeout time.Duration
118121 flag.StringVar(&keyLoc, "pk", "", "ssh private key used to authenticate with pico services")
119122 flag.StringVar(&certLoc, "ck", "", "ssh certificate public key used to authenticate with pico services (only required if using ssh certificates)")
120123 flag.StringVar(&artifactDir, "artifact-dir", "/tmp/pici-artifacts", "local directory to stage artifacts")
......@@ -130,11 +133,14 @@ func NewCfg() (*Cfg, string, bool) {
130133 var wait bool
131134 var inPlace bool
132135 var summaryFile string
136+ var debugOnFail bool
133137 flag.BoolVar(&includeRunning, "include-running", false, "emit running status updates in addition to terminal (default: terminal only)")
134138 flag.BoolVar(&human, "human", false, "human-readable output (default: JSONL / slog)")
135139 flag.BoolVar(&wait, "wait", false, "block until job completes, printing session history and summary")
136140 flag.BoolVar(&inPlace, "in-place", false, "execute workspace in-place without rsyncing to /tmp")
137141 flag.StringVar(&summaryFile, "summary-file", "", "file path to write markdown job summary")
142+ flag.BoolVar(&debugOnFail, "debug-on-fail", false, "open interactive pipe.pico.sh debug bridge on failure")
143+ flag.DurationVar(&debugTimeout, "debug-timeout", 15*time.Minute, "idle timeout for interactive debug bridge")
138144
139145 // Split args so the subcommand (first non-flag arg) doesn't block
140146 // flags that appear after it: "pici runner --wait" works.
......@@ -144,6 +150,16 @@ func NewCfg() (*Cfg, string, bool) {
144150 os.Exit(1)
145151 }
146152
153+ for _, envPair := range envVars {
154+ parts := strings.SplitN(envPair, "=", 2)
155+ if len(parts) == 2 && parts[0] == "PICI_DEBUG" && (parts[1] == "1" || parts[1] == "true") {
156+ debugOnFail = true
157+ }
158+ }
159+ if envVal := os.Getenv("PICI_DEBUG"); envVal == "1" || envVal == "true" {
160+ debugOnFail = true
161+ }
162+
147163 logger := newLogger("ci", logLevel)
148164 ctx, cancel := context.WithCancel(context.Background())
149165 return &Cfg{
......@@ -162,6 +178,8 @@ func NewCfg() (*Cfg, string, bool) {
162178 Wait: wait,
163179 InPlace: inPlace,
164180 SummaryFile: summaryFile,
181+ DebugOnFail: debugOnFail,
182+ DebugTimeout: debugTimeout,
165183 EnvVars: envVars,
166184 SessionPrefix: sessionPrefix,
167185 }, cmd, wantHelp
......@@ -169,7 +187,7 @@ func NewCfg() (*Cfg, string, bool) {
169187
170188 func isKnownSubcommand(s string) bool {
171189 switch s {
172- case "runner", "monitor", "cancel", "gc", "status", "orca", "run", "help":
190+ case "runner", "monitor", "cancel", "gc", "status", "orca", "run", "debug", "help":
173191 return true
174192 default:
175193 return false
......@@ -273,6 +291,7 @@ LOCAL DEVELOPER USAGE
273291 pici [destination] [flags] Run ./pico.sh locally in /tmp & render HTML logs
274292 pici pgs.sh:/my-site Run locally and rsync HTML logs to destination
275293 pici run [destination] Explicit alias for local run
294+ pici debug <repo/job_id | session> Attach to active zmx session for debugging
276295
277296 DAEMON & SERVICE COMMANDS
278297 pici runner Execute CI job from event JSON payload (stdin/flag)
......@@ -287,6 +306,8 @@ FLAGS
287306 -e, -env <KEY=VAL> Set or override environment variable for pico.sh
288307 -in-place Execute workspace in-place without copying to /tmp
289308 -summary-file <path> Write markdown job summary table to file
309+ -debug-on-fail Open interactive pipe.pico.sh debug bridge on failure
310+ -debug-timeout <dur> Idle timeout for interactive debug bridge (default: 15m)
290311 -pk <path> SSH private key
291312 -ck <path> SSH public certificate key
292313 -artifact-dir <path> Artifact staging directory (default: /tmp/pici-artifacts)
......@@ -377,8 +398,16 @@ func main() {
377398 }
378399 case "status":
379400 cfg.Logger.Debug("starting status updater")
380- case "orca":
381- cfg.Logger.Debug("starting orchestrator")
401+ case "debug":
402+ cfg.Logger.Debug("starting debug session")
403+ target := ""
404+ if flag.NArg() > 0 {
405+ target = flag.Arg(0)
406+ }
407+ if err := runDebug(cfg, target); err != nil {
408+ cfg.Logger.Error("debug failed", "err", err)
409+ os.Exit(1)
410+ }
382411 case "help":
383412 printMainHelp()
384413 case "run", "":
......@@ -1190,8 +1219,25 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
11901219 fmt.Fprintf(os.Stdout, "%s job failed: exit %d (%s)\n", icon, exitCode, duration) //nolint:errcheck
11911220 }
11921221
1222+ topic := fmt.Sprintf("%s.%s.%s", domain, name, jobID)
1223+ if exitCode != 0 && cfg.DebugOnFail {
1224+ cwd, _ := os.Getwd()
1225+ debugCfg := DebugBridgeConfig{
1226+ Topic: topic,
1227+ KeyLocation: cfg.KeyLocation,
1228+ CertLocation: cfg.CertificateLocation,
1229+ WorkDir: cwd,
1230+ Timeout: cfg.DebugTimeout,
1231+ Logger: log,
1232+ Stdout: os.Stdout,
1233+ }
1234+ if err := startDebugBridge(cfg.Ctx, debugCfg); err != nil {
1235+ log.Error("debug bridge failed", "err", err)
1236+ }
1237+ }
1238+
11931239 if cfg.SummaryFile != "" {
1194- if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known); err != nil {
1240+ if err := writeSummaryMarkdown(cfg.SummaryFile, name, jobID, jobSessions, known, cfg.DebugOnFail && exitCode != 0, topic); err != nil {
11951241 log.Error("write summary markdown", "err", err, "file", cfg.SummaryFile)
11961242 }
11971243 }
......@@ -1203,7 +1249,7 @@ func waitAndReport(cfg *Cfg, log *slog.Logger, name, jobID, eventType string) er
12031249 return nil
12041250 }
12051251
1206-func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState) error {
1252+func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions []SessionInfo, known map[string]*sessionState, debugActive bool, debugTopic string) error {
12071253 if filePath == "" {
12081254 return nil
12091255 }
......@@ -1216,6 +1262,12 @@ func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions [
12161262 sb.WriteString(fmt.Sprintf("### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID))
12171263 sb.WriteString(fmt.Sprintf("**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration))
12181264
1265+ if debugActive && debugTopic != "" {
1266+ sb.WriteString("### 🔧 Remote Debug Active\n\n")
1267+ sb.WriteString("To attach to the interactive shell session for this job:\n\n")
1268+ sb.WriteString(fmt.Sprintf("```bash\nssh -t pipe.pico.sh pipe %s\n```\n\n", debugTopic))
1269+ }
1270+
12191271 sb.WriteString("| Session | Status | Exit Code | Duration |\n")
12201272 sb.WriteString("| :--- | :--- | :--- | :--- |\n")
12211273
......@@ -2543,6 +2595,9 @@ func runLocal(cfg *Cfg, dest string) error {
25432595 }
25442596 log := logger.With("repo", eventData.Name, "job_id", jobID, "mode", "local")
25452597
2598+ // Cancel any existing running job for this repo
2599+ cancelRunningJobs(cfg, log, eventData.Name)
2600+
25462601 // Set up workspace
25472602 var wk Workspace
25482603 if cfg.InPlace {
+92, -5
......@@ -1271,9 +1271,13 @@ func TestRunLocal_InPlace(t *testing.T) {
12711271 }
12721272
12731273 tempDir := t.TempDir()
1274+ workDir := filepath.Join(tempDir, "inplace-repo")
1275+ if err := os.MkdirAll(workDir, 0755); err != nil {
1276+ t.Fatal(err)
1277+ }
12741278 origWd, _ := os.Getwd()
12751279 defer func() { _ = os.Chdir(origWd) }()
1276- _ = os.Chdir(tempDir)
1280+ _ = os.Chdir(workDir)
12771281
12781282 picoContent := `#!/usr/bin/env bash
12791283 set -e
......@@ -1295,7 +1299,7 @@ echo "inplace marker" > marker.txt
12951299 t.Fatalf("expected runLocal in-place to succeed, got %v", err)
12961300 }
12971301
1298- markerPath := filepath.Join(tempDir, "marker.txt")
1302+ markerPath := filepath.Join(workDir, "marker.txt")
12991303 data, err := os.ReadFile(markerPath)
13001304 if err != nil {
13011305 t.Fatalf("expected marker.txt to be created in working directory: %v", err)
......@@ -1311,9 +1315,13 @@ func TestRunLocal_SummaryFile(t *testing.T) {
13111315 }
13121316
13131317 tempDir := t.TempDir()
1318+ workDir := filepath.Join(tempDir, "summary-repo")
1319+ if err := os.MkdirAll(workDir, 0755); err != nil {
1320+ t.Fatal(err)
1321+ }
13141322 origWd, _ := os.Getwd()
13151323 defer func() { _ = os.Chdir(origWd) }()
1316- _ = os.Chdir(tempDir)
1324+ _ = os.Chdir(workDir)
13171325
13181326 picoContent := `#!/usr/bin/env bash
13191327 echo "hello from step"
......@@ -1323,7 +1331,7 @@ echo "hello from step"
13231331 }
13241332
13251333 artifactDir := t.TempDir()
1326- summaryFilePath := filepath.Join(tempDir, "summary.md")
1334+ summaryFilePath := filepath.Join(workDir, "summary.md")
13271335 cfg := &Cfg{
13281336 ArtifactDir: artifactDir,
13291337 MonitorInterval: 100 * time.Millisecond,
......@@ -1361,7 +1369,7 @@ func TestWriteSummaryMarkdown_WithFailure(t *testing.T) {
13611369 "failing-step": {status: "failed", exitCode: "42", duration: "3.4s"},
13621370 }
13631371
1364- err := writeSummaryMarkdown(tempFile, "test-repo", "job-123", sessions, known)
1372+ err := writeSummaryMarkdown(tempFile, "test-repo", "job-123", sessions, known, false, "")
13651373 if err != nil {
13661374 t.Fatalf("writeSummaryMarkdown failed: %v", err)
13671375 }
......@@ -1382,3 +1390,82 @@ func TestWriteSummaryMarkdown_WithFailure(t *testing.T) {
13821390 t.Errorf("missing failed details in content:\n%s", content)
13831391 }
13841392 }
1393+
1394+func TestWriteSummaryMarkdown_WithDebugActive(t *testing.T) {
1395+ tempFile := filepath.Join(t.TempDir(), "summary.md")
1396+ sessions := []SessionInfo{
1397+ {Short: "failing-step", Name: "local.repo.job.failing-step", ExitCode: "42"},
1398+ }
1399+ known := map[string]*sessionState{
1400+ "failing-step": {status: "failed", exitCode: "42", duration: "1.0s"},
1401+ }
1402+
1403+ err := writeSummaryMarkdown(tempFile, "my-repo", "job-456", sessions, known, true, "ci.my-repo.job-456")
1404+ if err != nil {
1405+ t.Fatalf("writeSummaryMarkdown failed: %v", err)
1406+ }
1407+
1408+ data, err := os.ReadFile(tempFile)
1409+ if err != nil {
1410+ t.Fatalf("read summary file: %v", err)
1411+ }
1412+ content := string(data)
1413+
1414+ if !strings.Contains(content, "### 🔧 Remote Debug Active") {
1415+ t.Errorf("expected debug header in summary markdown, got:\n%s", content)
1416+ }
1417+ if !strings.Contains(content, "ssh -t pipe.pico.sh pipe ci.my-repo.job-456") {
1418+ t.Errorf("expected ssh command in summary markdown, got:\n%s", content)
1419+ }
1420+}
1421+
1422+func TestScrubEnvironment(t *testing.T) {
1423+ envIn := []string{
1424+ "PATH=/usr/bin:/bin",
1425+ "USER=runner",
1426+ "HOME=/home/runner",
1427+ "SHELL=/bin/bash",
1428+ "TERM=xterm-256color",
1429+ "ZMX_SESSION_PREFIX=ci.repo.123.",
1430+ "PICI_JOB=123",
1431+ "GITHUB_TOKEN=ghp_secret_token_12345",
1432+ "ACTIONS_RUNTIME_TOKEN=actions_token_67890",
1433+ "MY_APP_SECRET=super_secret_val",
1434+ "API_KEY=abc123xyz",
1435+ "DATABASE_PASSWORD=secret_db_pass",
1436+ "PICO_SSH_KEY=-----BEGIN OPENSSH PRIVATE KEY-----",
1437+ "CUSTOM_SAFE_FLAG=1",
1438+ }
1439+
1440+ cleaned := scrubEnvironment(envIn)
1441+ joined := strings.Join(cleaned, "\n")
1442+
1443+ // Verify safe vars remain
1444+ for _, expected := range []string{
1445+ "PATH=/usr/bin:/bin",
1446+ "USER=runner",
1447+ "HOME=/home/runner",
1448+ "SHELL=/bin/bash",
1449+ "ZMX_SESSION_PREFIX=ci.repo.123.",
1450+ "PICI_JOB=123",
1451+ "CUSTOM_SAFE_FLAG=1",
1452+ } {
1453+ if !strings.Contains(joined, expected) {
1454+ t.Errorf("expected %q to be preserved in environment, got:\n%s", expected, joined)
1455+ }
1456+ }
1457+
1458+ // Verify secrets are scrubbed
1459+ for _, forbidden := range []string{
1460+ "GITHUB_TOKEN",
1461+ "ACTIONS_RUNTIME_TOKEN",
1462+ "MY_APP_SECRET",
1463+ "API_KEY",
1464+ "DATABASE_PASSWORD",
1465+ "PICO_SSH_KEY",
1466+ } {
1467+ if strings.Contains(joined, forbidden) {
1468+ t.Errorf("expected %q to be SCRUBBED from environment, but found it in:\n%s", forbidden, joined)
1469+ }
1470+ }
1471+}