Commit eb0002c

Eric Bower  ·  2026-08-15 13:31:36 -0400 EDT
parent f7542d3
fix: debug bridge
4 files changed,  +110, -32
+13, -2
 1@@ -10,6 +10,10 @@ inputs:
 2     description: "SSH private key registered with pico.sh for authenticated pipe.pico.sh debug relay access"
 3     required: false
 4     default: ""
 5+  pico_ssh_cert:
 6+    description: "SSH public certificate key for authenticating with pico.sh debug relay"
 7+    required: false
 8+    default: ""
 9   debug_on_fail:
10     description: "Automatically open an interactive pipe.pico.sh debug bridge if a job fails (true, false, auto)"
11     required: false
12@@ -34,7 +38,7 @@ inputs:
13 runs:
14   using: "composite"
15   steps:
16-    - name: Set up SSH key for pico.sh debug relay
17+    - name: Set up SSH key and certificate for pico.sh debug relay
18       if: inputs.pico_ssh_key != ''
19       shell: bash
20       run: |
21@@ -42,6 +46,10 @@ runs:
22         chmod 700 ~/.ssh
23         echo "${{ inputs.pico_ssh_key }}" > ~/.ssh/id_pici_debug
24         chmod 600 ~/.ssh/id_pici_debug
25+        if [ -n "${{ inputs.pico_ssh_cert }}" ]; then
26+          echo "${{ inputs.pico_ssh_cert }}" > ~/.ssh/id_pici_debug-cert.pub
27+          chmod 644 ~/.ssh/id_pici_debug-cert.pub
28+        fi
29 
30     - name: Install zmx
31       shell: bash
32@@ -82,10 +90,13 @@ runs:
33           FLAGS="$FLAGS --summary-file $GITHUB_STEP_SUMMARY"
34         fi
35 
36-        # Pass SSH key if configured
37+        # Pass SSH key & certificate if configured
38         if [ -f "$HOME/.ssh/id_pici_debug" ]; then
39           FLAGS="$FLAGS -pk $HOME/.ssh/id_pici_debug"
40         fi
41+        if [ -f "$HOME/.ssh/id_pici_debug-cert.pub" ]; then
42+          FLAGS="$FLAGS -ck $HOME/.ssh/id_pici_debug-cert.pub"
43+        fi
44 
45         echo "==> Executing: pici run $FLAGS"
46         pici run $FLAGS \
+64, -24
  1@@ -8,8 +8,10 @@ import (
  2 	"net"
  3 	"os"
  4 	"os/exec"
  5+	"os/signal"
  6 	"path/filepath"
  7 	"strings"
  8+	"syscall"
  9 	"time"
 10 
 11 	"github.com/creack/pty"
 12@@ -75,13 +77,34 @@ func scrubEnvironment(environ []string) []string {
 13 	return cleaned
 14 }
 15 
 16+// loadCertSigner parses the certificate file at certPath and wraps the key signer.
 17+func loadCertSigner(signer ssh.Signer, certPath string) (ssh.Signer, error) {
 18+	certBytes, err := os.ReadFile(certPath)
 19+	if err != nil {
 20+		return nil, fmt.Errorf("read cert file (%s): %w", certPath, err)
 21+	}
 22+	pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certBytes)
 23+	if err != nil {
 24+		return nil, fmt.Errorf("parse cert (%s): %w", certPath, err)
 25+	}
 26+	cert, ok := pubKey.(*ssh.Certificate)
 27+	if !ok {
 28+		return nil, fmt.Errorf("public key at %s is not an SSH certificate", certPath)
 29+	}
 30+	certSigner, err := ssh.NewCertSigner(cert, signer)
 31+	if err != nil {
 32+		return nil, fmt.Errorf("create cert signer: %w", err)
 33+	}
 34+	return certSigner, nil
 35+}
 36+
 37 // findSSHAuthMethod discovers an available SSH authentication method from:
 38-// 1. Specified keyLocation (and optional certificateLocation)
 39-// 2. PICO_SSH_KEY environment variable (file path or raw key content)
 40+// 1. Explicit keyLocation (and optional explicit certificateLocation)
 41+// 2. Explicit PICO_SSH_KEY environment variable (file path or raw key content)
 42 // 3. Active SSH agent ($SSH_AUTH_SOCK)
 43 // 4. Default user SSH keys (~/.ssh/id_ed25519, ~/.ssh/id_rsa)
 44 func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.AuthMethod, error) {
 45-	// 1. Specified key path
 46+	// 1. Specified key path (-pk)
 47 	if keyLocation != "" {
 48 		keyBytes, err := os.ReadFile(keyLocation)
 49 		if err != nil {
 50@@ -92,21 +115,9 @@ func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.Auth
 51 			return nil, fmt.Errorf("parse private key: %w", err)
 52 		}
 53 		if certificateLocation != "" {
 54-			certBytes, err := os.ReadFile(certificateLocation)
 55-			if err != nil {
 56-				return nil, fmt.Errorf("read cert file: %w", err)
 57-			}
 58-			pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certBytes)
 59+			certSigner, err := loadCertSigner(signer, certificateLocation)
 60 			if err != nil {
 61-				return nil, fmt.Errorf("parse cert: %w", err)
 62-			}
 63-			cert, ok := pubKey.(*ssh.Certificate)
 64-			if !ok {
 65-				return nil, fmt.Errorf("public key is not a certificate")
 66-			}
 67-			certSigner, err := ssh.NewCertSigner(cert, signer)
 68-			if err != nil {
 69-				return nil, fmt.Errorf("new cert signer: %w", err)
 70+				return nil, err
 71 			}
 72 			return ssh.PublicKeys(certSigner), nil
 73 		}
 74@@ -123,6 +134,13 @@ func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.Auth
 75 		}
 76 		signer, err := ssh.ParsePrivateKey(keyBytes)
 77 		if err == nil {
 78+			if certificateLocation != "" {
 79+				certSigner, err := loadCertSigner(signer, certificateLocation)
 80+				if err != nil {
 81+					return nil, err
 82+				}
 83+				return ssh.PublicKeys(certSigner), nil
 84+			}
 85 			return ssh.PublicKeys(signer), nil
 86 		}
 87 	}
 88@@ -135,12 +153,19 @@ func findSSHAuthMethod(keyLocation string, certificateLocation string) (ssh.Auth
 89 		}
 90 	}
 91 
 92-	// 4. Standard ~/.ssh/ files
 93+	// 4. Standard ~/.ssh/ files (default fallback when no key is explicitly passed)
 94 	if homeDir, err := os.UserHomeDir(); err == nil {
 95 		for _, keyName := range []string{"id_ed25519", "id_rsa"} {
 96-			p := filepath.Join(homeDir, ".ssh", keyName)
 97-			if keyBytes, err := os.ReadFile(p); err == nil {
 98+			keyPath := filepath.Join(homeDir, ".ssh", keyName)
 99+			if keyBytes, err := os.ReadFile(keyPath); err == nil {
100 				if signer, err := ssh.ParsePrivateKey(keyBytes); err == nil {
101+					if certificateLocation != "" {
102+						certSigner, err := loadCertSigner(signer, certificateLocation)
103+						if err != nil {
104+							return nil, err
105+						}
106+						return ssh.PublicKeys(certSigner), nil
107+					}
108 					return ssh.PublicKeys(signer), nil
109 				}
110 			}
111@@ -182,21 +207,30 @@ func startDebugBridge(ctx context.Context, cfg DebugBridgeConfig) error {
112 		User:            "pici",
113 		Auth:            []ssh.AuthMethod{auth},
114 		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
115-		Timeout:         10 * time.Second,
116+		Timeout:         5 * time.Second,
117 	}
118 
119 	fmt.Fprintf(cfg.Stdout, "šŸ”Œ connecting debug bridge to %s (topic: %s)...\n", cfg.Host, cfg.Topic) //nolint:errcheck
120-	client, err := ssh.Dial("tcp", cfg.Host, sshConfig)
121+
122+	dialer := net.Dialer{Timeout: 5 * time.Second}
123+	conn, err := dialer.DialContext(ctx, "tcp", cfg.Host)
124 	if err != nil {
125 		return fmt.Errorf("dial ssh (%s): %w", cfg.Host, err)
126 	}
127-	defer client.Close()
128+	defer func() { _ = conn.Close() }()
129+
130+	sshConn, chans, reqs, err := ssh.NewClientConn(conn, cfg.Host, sshConfig)
131+	if err != nil {
132+		return fmt.Errorf("ssh handshake (%s): %w", cfg.Host, err)
133+	}
134+	client := ssh.NewClient(sshConn, chans, reqs)
135+	defer func() { _ = client.Close() }()
136 
137 	session, err := client.NewSession()
138 	if err != nil {
139 		return fmt.Errorf("ssh session: %w", err)
140 	}
141-	defer session.Close()
142+	defer func() { _ = session.Close() }()
143 
144 	sshStdin, err := session.StdinPipe()
145 	if err != nil {
146@@ -243,6 +277,10 @@ func startDebugBridge(ctx context.Context, cfg DebugBridgeConfig) error {
147 	ctxWithTimeout, cancel := context.WithTimeout(ctx, cfg.Timeout)
148 	defer cancel()
149 
150+	sigCh := make(chan os.Signal, 1)
151+	signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
152+	defer signal.Stop(sigCh)
153+
154 	errCh := make(chan error, 3)
155 
156 	go func() {
157@@ -262,6 +300,8 @@ func startDebugBridge(ctx context.Context, cfg DebugBridgeConfig) error {
158 	select {
159 	case <-ctxWithTimeout.Done():
160 		fmt.Fprintf(cfg.Stdout, "\nā° debug session timed out after %s\n", cfg.Timeout) //nolint:errcheck
161+	case <-sigCh:
162+		fmt.Fprintf(cfg.Stdout, "\nā¹ debug session aborted by signal\n") //nolint:errcheck
163 	case <-errCh:
164 		fmt.Fprintf(cfg.Stdout, "\nšŸ”Œ debug session ended\n") //nolint:errcheck
165 	}
+6, -6
 1@@ -1259,13 +1259,13 @@ func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions [
 2 	statusIcon := map[string]string{"success": "āœ…", "failed": "āŒ"}[status]
 3 
 4 	var sb strings.Builder
 5-	sb.WriteString(fmt.Sprintf("### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID))
 6-	sb.WriteString(fmt.Sprintf("**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration))
 7+	fmt.Fprintf(&sb, "### Pici CI Summary: `%s` (Job `%s`)\n\n", repoName, jobID)
 8+	fmt.Fprintf(&sb, "**Status:** %s %s | **Exit Code:** `%d` | **Duration:** `%s`\n\n", statusIcon, strings.ToUpper(status), exitCode, duration)
 9 
10 	if debugActive && debugTopic != "" {
11 		sb.WriteString("### šŸ”§ Remote Debug Active\n\n")
12 		sb.WriteString("To attach to the interactive shell session for this job:\n\n")
13-		sb.WriteString(fmt.Sprintf("```bash\nssh -t pipe.pico.sh pipe %s\n```\n\n", debugTopic))
14+		fmt.Fprintf(&sb, "```bash\nssh -t pipe.pico.sh pipe %s\n```\n\n", debugTopic)
15 	}
16 
17 	sb.WriteString("| Session | Status | Exit Code | Duration |\n")
18@@ -1289,7 +1289,7 @@ func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions [
19 			}
20 			sIcon = map[string]string{"running": "šŸš€", "success": "āœ…", "failed": "āŒ"}[sStatus]
21 		}
22-		sb.WriteString(fmt.Sprintf("| `%s` | %s %s | `%s` | %s |\n", s.Short, sIcon, sStatus, sExit, sDur))
23+		fmt.Fprintf(&sb, "| `%s` | %s %s | `%s` | %s |\n", s.Short, sIcon, sStatus, sExit, sDur)
24 	}
25 	sb.WriteString("\n")
26 
27@@ -1308,14 +1308,14 @@ func writeSummaryMarkdown(filePath string, repoName, jobID string, jobSessions [
28 		if err != nil {
29 			history = fmt.Sprintf("(history unavailable: %v)", err)
30 		}
31-		sb.WriteString(fmt.Sprintf("<details><summary><b>%s</b> (exit %s)</summary>\n\n```\n%s\n```\n\n</details>\n\n", s.Short, state.exitCode, strings.TrimSpace(history)))
32+		fmt.Fprintf(&sb, "<details><summary><b>%s</b> (exit %s)</summary>\n\n```\n%s\n```\n\n</details>\n\n", s.Short, state.exitCode, strings.TrimSpace(history))
33 	}
34 
35 	f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
36 	if err != nil {
37 		return fmt.Errorf("open summary file: %w", err)
38 	}
39-	defer f.Close()
40+	defer func() { _ = f.Close() }()
41 
42 	if _, err := f.WriteString(sb.String()); err != nil {
43 		return fmt.Errorf("write summary file: %w", err)
+27, -0
 1@@ -1469,3 +1469,30 @@ func TestScrubEnvironment(t *testing.T) {
 2 		}
 3 	}
 4 }
 5+
 6+func TestFindSSHAuthMethod_ExplicitKeyAndCert(t *testing.T) {
 7+	tempDir := t.TempDir()
 8+	keyPath := filepath.Join(tempDir, "id_ed25519")
 9+
10+	// Generate a temporary ED25519 key
11+	cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", keyPath)
12+	if err := cmd.Run(); err != nil {
13+		t.Skipf("ssh-keygen not available or failed: %v", err)
14+	}
15+
16+	// Test 1: Explicit key path
17+	auth, err := findSSHAuthMethod(keyPath, "")
18+	if err != nil {
19+		t.Fatalf("findSSHAuthMethod failed: %v", err)
20+	}
21+	if auth == nil {
22+		t.Fatal("expected non-nil auth method")
23+	}
24+
25+	// Test 2: Non-existent cert path explicitly specified returns error
26+	_, err = findSSHAuthMethod(keyPath, filepath.Join(tempDir, "nonexistent-cert.pub"))
27+	if err == nil {
28+		t.Fatal("expected error when non-existent certificate is explicitly specified")
29+	}
30+}
31+