Skip to content

fix(gpg): agent forwarding - #790

Merged
skevetter merged 10 commits into
mainfrom
fix/gpg-forwarding
Jul 28, 2026
Merged

fix(gpg): agent forwarding#790
skevetter merged 10 commits into
mainfrom
fix/gpg-forwarding

Conversation

@skevetter

@skevetter skevetter commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Consolidates all GPG agent-forwarding fixes. Supersedes #789.

Problems

  1. Signing never worked reliablygpg -K empty, No secret key, even though the host held the keys and the socket was forwarded.
  2. Not durable while roaming / on flaky links — a single dropped connection killed forwarding until the workspace was reopened.

Summary by CodeRabbit

Summary of updates

  • New Features

    • Enhanced GPG agent forwarding to automatically restart after unexpected interruptions.
    • SSH-based GPG forwarding now validates access by verifying the expected secret key fingerprint.
  • Bug Fixes

    • Improved GPG configuration setup to reliably add missing directives while preserving existing entries and handling files without trailing newlines.
    • Forwarding shutdown is more responsive when the workspace context is cancelled.
  • Tests

    • Added coverage for supervised forwarding behavior and GPG configuration idempotency/preservation.

GPG agent forwarding into the container points gpg at the forwarded host
socket via a symlink (~/.gnupg/S.gpg-agent). Setup wrote only "use-agent"
to gpg.conf, never "no-autostart". Whenever gpg ran while the forwarded
socket was momentarily unavailable, it autostarted a local gpg-agent,
which replaced the symlink with an empty local socket and permanently
broke forwarding for the session — gpg -K showed nothing and signing
failed with "No secret key".

Write no-autostart (the GnuPG-recommended setting for agent forwarding)
so gpg only ever uses the forwarded agent. Verified end to end: with the
forwarded socket and no-autostart, gpg -K lists the secret keys and a
commit is GPG-signed and verifies.
ForwardAgent launched the background gpg-forwarding SSH process once in a
detached goroutine with no restart. A single dropped connection (e.g. a
laptop roaming between networks) killed the forward permanently until the
workspace was reopened, and interactive sessions that reuse the forwarded
socket then broke too.

Supervise the process: restart it whenever it exits, with exponential
backoff (reset after a stable run), tied to the caller's context so it
stops cleanly when the workspace is closed. Combined with no-autostart,
gpg forwarding now self-heals after transient connectivity loss.
@netlify

netlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploy Preview for images-devsy-sh canceled.

Name Link
🔨 Latest commit 39c032a
🔍 Latest deploy log https://app.netlify.com/projects/images-devsy-sh/deploys/6a6804d712b0930008634f7a

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

GPG forwarding now accepts contexts, supervises and restarts its process, expands configuration and socket setup, and uses package-level agent operations. E2E coverage imports a controlled key and verifies forwarded secret-key access by fingerprint.

Changes

GPG forwarding flow

Layer / File(s) Summary
Context-aware forwarding supervision
pkg/gpg/forward.go, pkg/gpg/forward_test.go, pkg/ide/opener/opener.go
Forwarding uses caller cancellation, bounded restart backoff, and tests for restart and prompt shutdown.
GPG configuration and agent operations
pkg/gpg/gpg_forwarding.go, cmd/internal/agentworkspace/setup_gpg.go, pkg/gpg/gpg_forwarding_test.go
Agent operations become package-level, GPG commands share stdin/error handling, configuration directives are added idempotently, and forwarding detection is tightened.
Forwarded socket wiring
pkg/gpg/gpg_forwarding.go, cmd/workspace/ssh.go
Remote socket links claim the forwarded socket, while SSH reverse forwarding uses the container socket path contract.
SSH GPG forwarding verification
e2e/framework/command.go, e2e/tests/ssh/ssh.go, e2e/tests/ssh/testdata/gpg-forwarding/*
E2E helpers import a private key and verify its fingerprint through SSH GPG agent forwarding with explicit container users and image configuration.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • devsy-org/devsy#20: Overlaps with the ForwardAgent signature changes in pkg/gpg/forward.go.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and clearly matches the main change: fixing GPG agent forwarding.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@netlify

netlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploy Preview for devsydev canceled.

Name Link
🔨 Latest commit 39c032a
🔍 Latest deploy log https://app.netlify.com/projects/devsydev/deploys/6a6804d7f21ff8000818a06d

The existing gpg-forwarding e2e only asserted the workspace boots with the
flag set; it never ran gpg, so a broken forward passed. Add a functional
check: import the test key on the host, up with --ssh-gpg-forwarding, then
assert the container's forwarded agent exposes the secret key (the exact
symptom that regressed). Replaces the unused, incorrect DevsySSHGpgTestKey.
- pass backoff bounds into superviseForward via a struct instead of
  test-mutable package globals
- dedupe the gpg stdin-import logic (runGpgWithStdin) and wrap both
  import errors consistently
- simplify SetupGpgConf (rely on O_CREATE instead of stat+create)
- clarify IsGpgTunnelRunning (TrimSpace != "" instead of len > 1)
- drop the pointless slice-spread on gpgconf --kill
- correct the stale "hardlinks" comment (symlinks)
- convert StopGpgAgent/SetupGpgConf/gpgConfigPath (which never used the
  GPGConf receiver) to package functions; update call sites
- build the su -c wrapper with shellescape.QuoteCommand instead of
  hand-rolled fmt.Sprintf quoting
- extract appendMissingDirectives to keep SetupGpgConf within complexity
@skevetter skevetter changed the title fix(gpg): make agent forwarding durable (no-autostart + supervised, self-healing forward) fix(gpg): agent forwarding Jul 28, 2026
@skevetter
skevetter marked this pull request as ready for review July 28, 2026 00:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
e2e/framework/command.go (1)

483-507: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remote stderr is discarded, hiding forwarding failure details.

2>/dev/null drops gpg's own error output on the remote side, and the local stderr return value is discarded (_). If the fingerprint check fails, the resulting error only says "not reachable" with no clue why (agent not found vs. socket permission vs. no secret keys imported), making CI failures harder to diagnose.

♻️ Suggested fix: capture stderr for debugging
-	stdout, _, err := f.ExecCommandCapture(ctx, []string{
+	stdout, stderr, err := f.ExecCommandCapture(ctx, []string{
 		cmdWorkspace,
 		cmdSSH,
 		names.Flag(names.AgentForwarding),
 		names.Flag(names.SSHGPGForwarding),
 		flagCommand,
-		"gpg --list-secret-keys --with-colons 2>/dev/null",
+		"gpg --list-secret-keys --with-colons",
 		workspace,
 	})
 	if err != nil {
-		return fmt.Errorf("list secret keys in container: %w", err)
+		return fmt.Errorf("list secret keys in container: %s: %w", stderr, err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/framework/command.go` around lines 483 - 507, Update
DevsySSHGpgSecretKeyForwarded to preserve remote gpg diagnostics by removing the
2>/dev/null redirection and capturing the stderr returned by ExecCommandCapture.
Include that stderr in the fingerprint-mismatch error alongside stdout, while
retaining the existing command-error handling and successful fingerprint check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@e2e/tests/ssh/ssh.go`:
- Around line 25-26: Remove the committed GPG private-key fixture from the
testdata used by the SSH e2e flow. Update the setup around gpgTestKeyFingerprint
and the GPG forwarding test to generate a disposable key at runtime, or
otherwise exclude the private fixture from committed testdata while preserving
the test’s behavior.

---

Nitpick comments:
In `@e2e/framework/command.go`:
- Around line 483-507: Update DevsySSHGpgSecretKeyForwarded to preserve remote
gpg diagnostics by removing the 2>/dev/null redirection and capturing the stderr
returned by ExecCommandCapture. Include that stderr in the fingerprint-mismatch
error alongside stdout, while retaining the existing command-error handling and
successful fingerprint check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ba8c8b73-1942-4426-8ee8-49c8822c3441

📥 Commits

Reviewing files that changed from the base of the PR and between 4437ea4 and 806cc89.

📒 Files selected for processing (8)
  • cmd/internal/agentworkspace/setup_gpg.go
  • e2e/framework/command.go
  • e2e/tests/ssh/ssh.go
  • pkg/gpg/forward.go
  • pkg/gpg/forward_test.go
  • pkg/gpg/gpg_forwarding.go
  • pkg/gpg/gpg_forwarding_test.go
  • pkg/ide/opener/opener.go

Comment thread e2e/tests/ssh/ssh.go
Comment on lines +25 to +26
gpgTestKeyFingerprint = "07F681B9FD6C3411F679BFD1F51769DB572DDD3F"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether a static private key fixture is committed to the repo.
fd -i 'gpg-private' tests/ssh/testdata

Repository: devsy-org/devsy

Length of output: 259


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Repo files count: '
git ls-files | wc -l

printf '\nCandidate ssh.go files:\n'
fd -i '^ssh\.go$|testdata$|gpg-private' . || true

printf '\nMatch gpg-private/fingerprint/gpg-forwarding:\n'
rg -n "gpg-private|gpg-forwarding|gpgTestKeyFingerprint|ImportGpgKey" . || true

Repository: devsy-org/devsy

Length of output: 2428


Don’t commit the GPG test private key fixture.

e2e/tests/ssh/testdata/gpg-forwarding/gpg-private.key is checked into the repo and imported by the e2e flow, so this shouldn’t be categorized as a secrets leak at line 25. Instead, flag this test fixture under the key fixture location as a cleanup: generate the disposable GPG key at setup time or exclude the private fixture from committed testdata.

🧰 Tools
🪛 Betterleaks (1.6.1)

[high] 25-25: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/ssh/ssh.go` around lines 25 - 26, Remove the committed GPG
private-key fixture from the testdata used by the SSH e2e flow. Update the setup
around gpgTestKeyFingerprint and the GPG forwarding test to generate a
disposable key at runtime, or otherwise exclude the private fixture from
committed testdata while preserving the test’s behavior.

Source: Linters/SAST tools

Forwarding mirrored the host's socket path inside the container (e.g.
/Users/<user>/.gnupg/... on a macOS host) and then symlinked to it. Those
mirrored ancestor dirs are created root-owned, so a non-root workspace
user cannot traverse them to reach the socket, and GPG forwarding
silently failed (gpg -K empty, "No secret key"). The previous attempt to
chmod the mirrored host dirs treated the symptom.

Do what standard GPG agent forwarding does instead: forward the host's
extra socket to a fixed container-local path the workspace user can reach,
symlink gpg's own socket locations (~/.gnupg, /run/user/<uid>/gnupg) to
it, and take ownership of the socket (the ssh server binds it as root).
No host-path mirroring, no permission changes to OS directories.

Run the gpg-forwarding e2e as a non-root user so this failure mode is
covered (the container's home is likewise root-owned, reproducing it).
@skevetter
skevetter marked this pull request as draft July 28, 2026 01:18
@skevetter
skevetter marked this pull request as ready for review July 28, 2026 01:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/workspace/ssh.go`:
- Around line 748-751: Update the ReverseForwardPorts mapping in the GPG
forwarding setup to use gpgExtraSocketPath as the host side and
gpg.ContainerSocketPath as the container side, preserving the A:B ordering
expected by ParsePortSpec.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7e62fa29-8fe0-49d3-ac31-649d0c55f1be

📥 Commits

Reviewing files that changed from the base of the PR and between 806cc89 and 39c032a.

📒 Files selected for processing (4)
  • cmd/workspace/ssh.go
  • e2e/tests/ssh/testdata/gpg-forwarding/.devcontainer.json
  • e2e/tests/ssh/testdata/gpg-forwarding/devcontainer.json
  • pkg/gpg/gpg_forwarding.go

Comment thread cmd/workspace/ssh.go
Comment on lines +748 to +751
cmd.ReverseForwardPorts = append(
cmd.ReverseForwardPorts,
gpg.ContainerSocketPath+":"+gpgExtraSocketPath,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm ParsePortSpec's field-assignment order for "A:B" strings.
rg -n "func ParsePortSpec" -A 40 --type=go
rg -n "type Mapping struct" -A 15 --type=go
rg -n "ReverseForwardPorts" -B2 -A5 --type=go -g '!*_test.go'

Repository: devsy-org/devsy

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -u

echo "Tracked files around ssh/devssh/gpg/port:"
git ls-files | rg 'cmd/workspace/ssh\.go|devssh|gpg|port|setup_gpg|ssh\.go$' | sed -n '1,200p'

echo
echo "ssh.go outline around relevant functions:"
if [ -f cmd/workspace/ssh.go ]; then
  wc -l cmd/workspace/ssh.go
  rg -n "ReverseForwardPorts|ParsePortSpec|Mapping|setup-gpg|ReversePortForward|Reverse forwarding" cmd/workspace/ssh.go
fi

Repository: devsy-org/devsy

Length of output: 3381


🏁 Script executed:

#!/bin/bash
set -u

echo "pkg/port/parse.go:"
sed -n '1,220p' pkg/port/parse.go

echo
echo "pkg/port/port.go:"
sed -n '1,180p' pkg/port/port.go

echo
echo "cmd/workspace/ssh.go relevant sections:"
sed -n '1,130p' cmd/workspace/ssh.go
sed -n '430,470p' cmd/workspace/ssh.go
sed -n '730,770p' cmd/workspace/ssh.go

echo
echo "cmd/internal/agentworkspace/setup_gpg.go:"
sed -n '1,220p' cmd/internal/agentworkspace/setup_gpg.go

echo
echo "pkg/gpg/gpg_forwarding.go relevant socket symlink section:"
sed -n '130,200p' pkg/gpg/gpg_forwarding.go

echo
echo "Behavioral check of ParsePortSpec assignments for simple A:B strings:"
python3 - <<'PY'
from pathlib import Path
src = Path("pkg/port/parse.go").read_text()
print("ParsePortSpec source excerpt:")
start = src.index("func ParsePortSpec(s string)")
print(src[start:].split("func ")[0][:2200])
PY

Repository: devsy-org/devsy

Length of output: 16493


Reverse-forward mapping order is correct for GPG forwarding.

ParsePortSpec assigns a 2-part string A:B to mapping.Host.Address = A and mapping.Container.Address = B, so the current gpg.ContainerSocketPath+":"+gpgExtraSocketPath would bind Host = /tmp/S.gpg-agent and Container = detected host socket path, which is the wrong direction. Keep the suggested ordering gpgExtraSocketPath+":"+gpg.ContainerSocketPath if this is still present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/workspace/ssh.go` around lines 748 - 751, Update the ReverseForwardPorts
mapping in the GPG forwarding setup to use gpgExtraSocketPath as the host side
and gpg.ContainerSocketPath as the container side, preserving the A:B ordering
expected by ParsePortSpec.

@skevetter
skevetter merged commit dd2001f into main Jul 28, 2026
66 checks passed
@skevetter
skevetter deleted the fix/gpg-forwarding branch July 28, 2026 01:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant