Skip to content

fix: resolve SSH signature key path for container-to-host forwarding (#645)#714

Merged
skevetter merged 17 commits intomainfrom
investigate/issue-645-missing
Apr 12, 2026
Merged

fix: resolve SSH signature key path for container-to-host forwarding (#645)#714
skevetter merged 17 commits intomainfrom
investigate/issue-645-missing

Conversation

@skevetter
Copy link
Copy Markdown
Owner

@skevetter skevetter commented Apr 11, 2026

Summary

Fixes #645 — Git SSH signature forwarding fails because the container-local temp key path (/tmp/.git_signing_key_tmpXXXXXX) is forwarded verbatim to the host's Sign() function, where ssh-keygen can't find it.

Core fix: public key content forwarding

  • Container client (client.go): reads the public key file content and includes it as PublicKey in the signature request
  • Host server (server.go): Sign() calls resolveKeyFile() which writes the PublicKey content to a host-local temp file before invoking ssh-keygen
  • Backward compatible: when PublicKey is empty, falls back to KeyPath

Non-fatal SSH agent forwarding

  • SSH tunnel (sshtunnel.go): agent forwarding failure is now logged as a warning instead of fatally aborting devpod up
  • Matches OpenSSH behavior: clientloop.c returns NULL on agent failure without terminating the session; ExitOnForwardFailure in ssh_config(5) explicitly excludes agent forwarding
  • Stale SSH_AUTH_SOCK is common in practice (tmux, screen, reconnected terminals)

Test coverage

  • Unit tests for Sign() with PublicKey content and non-existent key paths
  • Client tests for public key reading and request body construction
  • Integration test verifying PublicKey flows through the tunnel
  • Tunnel server test for PublicKey deserialization
  • E2e test for file-path-based signing key scenario (the exact issue Git SSH signature forwarding doesn't work #645 case)

Summary by CodeRabbit

  • New Features

    • Signature requests now include public key content when available, and signing accepts an inline public key.
  • Bug Fixes

    • SSH agent forwarding failures in dev containers are non-fatal; workflows continue without agent forwarding.
  • Tests

    • Added end-to-end and unit tests validating local agent usage, inclusion of public key content in requests, and signing error handling.

When the signing request includes the public key content, Sign() writes
it to a host-local temp file instead of using the container-local KeyPath
which does not exist on the host.
The container-side client now reads the cert file and includes its
content in the request body, so the host can create a temp file
rather than relying on the container-local path.
Adds a second commit in the e2e test that configures user.signingkey
as a file path (not inline key content), which exercises the code path
that was broken for real users.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 11, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6d36e31c-080d-406e-9f4d-ff6687c39f5e

📥 Commits

Reviewing files that changed from the base of the PR and between f36a6b8 and 9c1fb23.

📒 Files selected for processing (2)
  • e2e/tests/ssh/ssh.go
  • pkg/credentials/integration_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • e2e/tests/ssh/ssh.go

📝 Walkthrough

Walkthrough

Client-side signing requests now include the public key contents; the server can write that content to a temp key file for ssh-keygen. SSH agent forwarding failures are logged and treated non-fatal. Tests and e2e flows updated to exercise public-key-in-request and agent behavior.

Changes

Cohort / File(s) Summary
Git SSH Signing Client
pkg/gitsshsigning/client.go, pkg/gitsshsigning/client_test.go
Client builds GitSSHSignatureRequest.PublicKey by reading the cert/public key file when present; tests cover presence and absence of the file.
Git SSH Signing Server
pkg/gitsshsigning/server.go, pkg/gitsshsigning/server_test.go
Added PublicKey field to GitSSHSignatureRequest; new resolveKeyFile() writes PublicKey to a temp file (with cleanup) or uses KeyPath; Sign() uses resolved path and wraps resolution errors.
Tunnel Server Tests
pkg/agent/tunnelserver/tunnelserver_gitssh_test.go
New unit test exercising GitSSHSignature deserialization and error-message expectations when signing fails.
Credentials Integration
pkg/credentials/integration_test.go
Integration test captures outgoing signature request payload and asserts PublicKey and buffer Content are forwarded.
SSH Tunnel Agent Forwarding
pkg/devcontainer/sshtunnel/sshtunnel.go
setupSSHAgentForwarding no longer propagates agent-forwarding errors; it logs failure and returns nil, allowing the tunnel to continue without agent forwarding.
E2E SSH Test
e2e/tests/ssh/ssh.go
E2E test now starts a local ssh-agent, injects SSH_AUTH_SOCK/SSH_AGENT_PID, adds the signing key with ssh-add, exports the public key in-container, and performs a second signed commit to validate signing flow.

Sequence Diagram(s)

sequenceDiagram
  participant Dev as Developer / Git Client
  participant Helper as git-ssh-signing (client)
  participant Tunnel as Tunnel Server
  participant Signer as Local Signer / ssh-keygen
  participant Agent as ssh-agent

  Dev->>Helper: invokes git-ssh program with buffer & cert path
  Helper->>Helper: read cert file -> include PublicKey in request
  Helper->>Tunnel: POST GitSSHSignatureRequest (Content, KeyPath, PublicKey)
  Tunnel->>Tunnel: deserialize request
  Tunnel->>Signer: resolveKeyFile(): if PublicKey -> write temp key file
  Tunnel->>Agent: (optional) use SSH agent if available
  Tunnel->>Signer: run `ssh-keygen -Y sign -f <resolvedKey> -n git` with Content
  Signer-->>Tunnel: signed payload or stderr error
  Tunnel-->>Helper: response (signed blob or error)
  Helper-->>Dev: pass signed result to Git
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested labels

size/xl

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the core fix: resolving SSH signature key paths for container-to-host forwarding, which matches the main change across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch investigate/issue-645-missing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

ssh-keygen -Y sign -f <path> reads the public key directly from the
given path. The previous .pub suffix stripping was incorrect — ssh-keygen
does not auto-append .pub for agent-based signing.
user.signingkey inside the container is a host path, not key content.
Use ssh-add -L to get the actual public key from the forwarded agent.
The e2e test generates a key with ssh-keygen but never adds it to
the SSH agent. This means ssh-add -L inside the container returns
nothing, the public key file is empty, and the host falls back to
the container path which doesn't exist.
CI runners don't have an SSH agent running, so ssh-add fails.
Start a dedicated agent, parse its output for SSH_AUTH_SOCK and
SSH_AGENT_PID, and clean up on test completion.
The SSH agent cleanup kills the agent process but leaves SSH_AUTH_SOCK
set in the process env. Subsequent ordered tests inherit the stale
socket path, causing devpod up to fatally fail when it tries to
forward the dead agent.
Replace manual os.Setenv + os.Unsetenv with GinkgoT().Setenv which
automatically restores the original value when the spec completes,
preventing stale SSH_AUTH_SOCK from leaking into subsequent tests.
SSH_AUTH_SOCK commonly points to stale sockets (tmux, screen,
reconnected terminals). Following OpenSSH's behavior, log a warning
and continue rather than fatally aborting devpod up. Agent forwarding
is optional — nothing in the tunnel lifecycle depends on it.
Reference the specific OpenSSH source (clientloop.c, ssh_config(5))
that establishes agent forwarding failures as non-fatal, and explain
why stale SSH_AUTH_SOCK is common in practice.
Remove redundant assertion messages, obvious comments, and
over-explained test descriptions. Keep only comments that add
context not evident from the code itself.
@skevetter skevetter marked this pull request as ready for review April 12, 2026 00:56
@skevetter
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
pkg/credentials/integration_test.go (1)

106-107: Don’t discard marshal errors in the test helper path.

Even in tests, checking this error gives clearer failures if response shape changes later.

Proposed test-hygiene fix
-			jsonBytes, _ := json.Marshal(sig)
+			jsonBytes, err := json.Marshal(sig)
+			require.NoError(t, err)
 			return &tunnel.Message{Message: string(jsonBytes)}, nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/credentials/integration_test.go` around lines 106 - 107, The test helper
currently discards the error from json.Marshal(sig); change it to capture the
error (jsonBytes, err := json.Marshal(sig)) and if err != nil return an error
instead of returning a malformed Message (e.g., return nil, fmt.Errorf("marshal
sig: %w", err)); update the surrounding function (the helper that returns
(*tunnel.Message, error)) to propagate that error and add the necessary import
(fmt) if missing so test failures show marshal problems instead of silently
producing bad JSON in tunnel.Message.
e2e/tests/ssh/ssh.go (1)

158-163: Use the parsed agent PID directly in cleanup.

Reading SSH_AGENT_PID from env during cleanup is less robust than capturing PID once and reusing it.

Proposed cleanup hardening
 			t := ginkgo.GinkgoT()
+			var agentPID string
 			for line := range strings.SplitSeq(string(agentOut), "\n") {
 				for _, prefix := range []string{"SSH_AUTH_SOCK=", "SSH_AGENT_PID="} {
 					if _, after, ok := strings.Cut(line, prefix); ok {
 						val := after
 						if semi := strings.Index(val, ";"); semi >= 0 {
 							val = val[:semi]
 						}
 						key := prefix[:len(prefix)-1]
+						if key == "SSH_AGENT_PID" {
+							agentPID = val
+						}
 						t.Setenv(key, val)
 					}
 				}
 			}
 			ginkgo.DeferCleanup(func(_ context.Context) {
-				if pid := os.Getenv("SSH_AGENT_PID"); pid != "" {
+				if agentPID != "" {
 					// `#nosec` G204 -- controlled pid from ssh-agent we started
-					_ = exec.Command("kill", pid).Run()
+					_ = exec.Command("kill", agentPID).Run()
 				}
 			})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/tests/ssh/ssh.go` around lines 158 - 163, Capture the ssh-agent PID once
when you start the agent (e.g., store it in a local variable like sshAgentPID or
parsedAgentPID) and use that stored value inside the ginkgo.DeferCleanup closure
instead of calling os.Getenv("SSH_AGENT_PID") at cleanup time; parse to an int
(strconv.Atoi) when you capture it to validate it and then call
exec.Command("kill", strconv.Itoa(parsedAgentPID)).Run() (or syscall.Kill) in
the existing ginkgo.DeferCleanup anonymous function to ensure you always kill
the same agent you started and avoid reading the environment at teardown.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@e2e/tests/ssh/ssh.go`:
- Around line 158-163: Capture the ssh-agent PID once when you start the agent
(e.g., store it in a local variable like sshAgentPID or parsedAgentPID) and use
that stored value inside the ginkgo.DeferCleanup closure instead of calling
os.Getenv("SSH_AGENT_PID") at cleanup time; parse to an int (strconv.Atoi) when
you capture it to validate it and then call exec.Command("kill",
strconv.Itoa(parsedAgentPID)).Run() (or syscall.Kill) in the existing
ginkgo.DeferCleanup anonymous function to ensure you always kill the same agent
you started and avoid reading the environment at teardown.

In `@pkg/credentials/integration_test.go`:
- Around line 106-107: The test helper currently discards the error from
json.Marshal(sig); change it to capture the error (jsonBytes, err :=
json.Marshal(sig)) and if err != nil return an error instead of returning a
malformed Message (e.g., return nil, fmt.Errorf("marshal sig: %w", err)); update
the surrounding function (the helper that returns (*tunnel.Message, error)) to
propagate that error and add the necessary import (fmt) if missing so test
failures show marshal problems instead of silently producing bad JSON in
tunnel.Message.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5d206d36-9a94-4233-92f6-c53500c9b099

📥 Commits

Reviewing files that changed from the base of the PR and between 36c10c9 and f36a6b8.

📒 Files selected for processing (8)
  • e2e/tests/ssh/ssh.go
  • pkg/agent/tunnelserver/tunnelserver_gitssh_test.go
  • pkg/credentials/integration_test.go
  • pkg/devcontainer/sshtunnel/sshtunnel.go
  • pkg/gitsshsigning/client.go
  • pkg/gitsshsigning/client_test.go
  • pkg/gitsshsigning/server.go
  • pkg/gitsshsigning/server_test.go

Capture SSH agent PID at parse time instead of reading env at cleanup.
Check json.Marshal error in test helper instead of discarding it.
@coderabbitai coderabbitai bot added the size/xl label Apr 12, 2026
@skevetter skevetter merged commit 696b944 into main Apr 12, 2026
41 checks passed
@skevetter skevetter deleted the investigate/issue-645-missing branch April 12, 2026 03:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Git SSH signature forwarding doesn't work

1 participant