Skip to content

feat(devcontainer): support YubiKey SSH/GPG relay on Windows hosts - #755

Open
woopstar wants to merge 14 commits into
mainfrom
feat/devcontainer-windows-yubikey
Open

feat(devcontainer): support YubiKey SSH/GPG relay on Windows hosts#755
woopstar wants to merge 14 commits into
mainfrom
feat/devcontainer-windows-yubikey

Conversation

@woopstar

@woopstar woopstar commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Adds Windows host support to the existing YubiKey SSH/GPG devcontainer relay. Docker Desktop on Windows cannot forward Windows named pipes or GnuPG's emulated sockets into containers, so the same TCP-relay architecture used for macOS is implemented in pure PowerShell.

Branch

feat/devcontainer-windows-yubikey

Files changed

  • .devcontainer/scripts/start-agent-relay.ps1 — new TCP relay: SSH agent via \\.\pipe\openssh-ssh-agent named pipe (port 9999), GPG agent and scdaemon via GnuPG socket-emulation files (ports 9998/9997). Uses one runspace per listener and per connection; PipeOptions.Asynchronous + Task.WaitAny to avoid wedging gpg-agent's pipe server.
  • .devcontainer/scripts/install-agent-relay.ps1 — new installer: registers a per-user Scheduled Task (falls back to a hidden Startup-folder launcher when policy blocks task creation), stages %USERPROFILE%\.ssh and %USERPROFILE%\.gitconfig into .devcontainer/host-config/, and snapshots %APPDATA%\gnupg public key material into .devcontainer/gnupg-host/ for the container.
  • .devcontainer/scripts/devcontainer-agent-bridge.sh — falls back to the workspace gnupg snapshot when /root/.gnupg has no keyring (Windows layout).
  • .devcontainer/scripts/initialize-host.sh — Unix/WSL2 staging script: copies/symlinks host ~/.ssh and ~/.gitconfig into .devcontainer/host-config/ before the container starts. On WSL2 it resolves the Windows user profile so the Windows-side SSH config is used.
  • .devcontainer/scripts/initialize-host.ps1 — native Windows staging script: snapshots host ~/.ssh and ~/.gitconfig into .devcontainer/host-config\; now treats CI-style .gitconfig directory stubs as empty config files, falls back to HOME when USERPROFILE is unavailable, and copies SSH entries explicitly for cross-platform reliability.
  • .devcontainer/devcontainer.json — uses a single spec-compliant Unix initializeCommand and keeps mounts pointed at the staged .devcontainer/host-config/ directory. Windows host-config staging now happens through the installer instead of a lifecycle object.
  • .devcontainer/README.md — Windows setup section and updated YubiKey/GPG support description, including that the installer stages .ssh and .gitconfig.
  • .gitignore — ignores .devcontainer/gnupg-host/ and .devcontainer/host-config/.
  • .github/workflows/lint-and-test.yml — creates a file stub for ~/.gitconfig in CI instead of a directory, and adds a powershell shim on Ubuntu runners.

Why

The devcontainer previously only supported YubiKey forwarding on macOS. This enables the same workflow on Windows hosts without requiring Python or admin rights.

Testing

Live-tested on Windows 11 with Gpg4win 2.5.21 and a YubiKey:

  • SSH relay returns the YubiKey SSH key (ssh-rsa cardno:...).
  • GPG relay returns assuan greeting (OK Pleased to meet you).
  • scdaemon relay returns greeting (OK GNU Privacy Guard's Smartcard server ready).
  • Sequential connections are stable; pipe server remains healthy.
  • Install/status/uninstall cycle verified.
  • The initialize-host.ps1 staging script was run locally and produced the expected .devcontainer/host-config/.ssh and .gitconfig entries.
  • Verified the Windows initializer still succeeds when .gitconfig is a directory stub, matching the CI mount-stub behavior.
  • Verified the Windows initializer also succeeds when only HOME is set, matching the Linux CI environment.
  • Updated the CI workflow stub so ~/.gitconfig is created as a file, preventing devcontainer copy failures in GitHub Actions.
  • The Windows installer now stages host SSH/Git config explicitly, so Windows support no longer depends on a lifecycle object that CI interprets incorrectly.

Notes / limitations

  • PIN/touch prompts appear on the Windows host, not in the container.
  • On Windows, re-run powershell -NoProfile -ExecutionPolicy Bypass -File .devcontainer\scripts\install-agent-relay.ps1 install after changing SSH/Git config or adding new GPG keys.
  • The relay binds to 127.0.0.1 only; the container reaches it via host.docker.internal.
  • The gnupg snapshot contains only public keys and stubs — no private key material leaves the host.
  • .devcontainer/host-config/ is git-ignored and regenerated by the Unix initialize command or the Windows installer.

Docker Desktop on Windows cannot forward named pipes or GnuPG emulated sockets into containers. Add a pure-PowerShell TCP relay (start-agent-relay.ps1) that bridges the Windows OpenSSH named pipe and GnuPG emulation sockets (TCP+nonce) to localhost ports 9999/9998/9997, and an installer (install-agent-relay.ps1) that registers a per-user Scheduled Task or Startup-folder launcher.

The container-side bridge (devcontainer-agent-bridge.sh) falls back to a workspace snapshot of the host GnuPG public key material when /root/.gnupg has no keyring (Windows layout), and devcontainer.json gains USERPROFILE fallback for .ssh/.gitconfig mounts.

Verified end-to-end on a Windows 11 host with Gpg4win 2.5.21 and a YubiKey: SSH key listing, GPG assuan greeting, and scdaemon greeting all pass through the relays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the enhancement New feature or request label Aug 12, 2026
Andreas Krüger and others added 12 commits August 12, 2026 14:43
CI creates ~/.gitconfig and ~/.gnupg as empty directories via 'mkdir -p'. The bridge script now guards the find/copy with a directory check and starts the git config from scratch when the stub is a directory instead of a file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The devcontainer mounts for ~/.ssh and ~/.gitconfig used
`${localEnv:HOME}${localEnv:USERPROFILE}`, which resolves to the WSL home
on Windows/WSL2 even when the user's actual SSH config lives under their
Windows profile. This caused container startup to fail with:

  invalid mount config for type "bind": bind source path does not exist

Add a platform-specific initializeCommand that snapshots the host's .ssh
and .gitconfig into .devcontainer/host-config/ before the container is
started. The devcontainer.json mounts then source from this known,
guaranteed-to-exist location. Empty stubs are created when the host files
are missing so the bind mount never blocks container startup.

- .devcontainer/scripts/initialize-host.sh: Unix/WSL2 staging script
- .devcontainer/scripts/initialize-host.ps1: native Windows staging script
- .devcontainer/devcontainer.json: add initializeCommand and update mounts
- .devcontainer/README.md: document staging behavior
- .gitignore: ignore .devcontainer/host-config/

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds Windows-host YubiKey SSH/GPG forwarding for the devcontainer.

Changes:

  • Adds PowerShell relay and installer scripts.
  • Introduces cross-platform host configuration staging.
  • Updates container bridging, CI, documentation, and ignored snapshots.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
.gitignore Ignores generated host snapshots.
.github/workflows/lint-and-test.yml Adjusts CI mount stubs.
.devcontainer/devcontainer.json Configures staging and mounts.
.devcontainer/README.md Documents Windows setup.
.devcontainer/scripts/start-agent-relay.ps1 Implements Windows TCP relays.
.devcontainer/scripts/install-agent-relay.ps1 Installs and provisions the relay.
.devcontainer/scripts/initialize-host.sh Stages Unix/WSL host configuration.
.devcontainer/scripts/initialize-host.ps1 Stages Windows host configuration.
.devcontainer/scripts/devcontainer-agent-bridge.sh Supports Windows GnuPG snapshots.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

},
"postCreateCommand": "/usr/local/bin/setup-python-deps.sh; if [ -z \"$CI\" ]; then /usr/local/bin/devcontainer-agent-bridge.sh /bin/sh -c 'echo SSH agent ready in container || true'; fi",
"postStartCommand": "if [ -z \"$CI\" ]; then nohup /usr/local/bin/devcontainer-agent-bridge.sh /bin/true >/tmp/agent-bridge.log 2>&1 & fi",
"initializeCommand": "sh .devcontainer/scripts/initialize-host.sh",
Comment on lines +21 to +22
"type=bind,source=${localWorkspaceFolder}/.devcontainer/host-config/.ssh,target=/root/.ssh,readonly",
"type=bind,source=${localWorkspaceFolder}/.devcontainer/host-config/.gitconfig,target=/root/.gitconfig",
Comment on lines +107 to +111
# Flatten the keyboxd store: container GnuPG 2.4 (non-keyboxd)
# expects a classic keyring at pubring.kbx.
$kbx = Join-Path $GpgSnapshot 'public-keys.d\pubring.db'
if ((Test-Path $kbx) -and -not (Test-Path (Join-Path $GpgSnapshot 'pubring.kbx'))) {
Copy-Item $kbx (Join-Path $GpgSnapshot 'pubring.kbx')
-Description 'TCP relays for YubiKey SSH/GPG agents into Docker devcontainers' `
-ErrorAction Stop | Out-Null
Start-ScheduledTask -TaskName $TaskName
$installed = $true
Comment on lines +35 to +42
if (Test-Path $SshSource) {
New-Item -ItemType Directory -Force -Path $SshTarget | Out-Null
Get-ChildItem -LiteralPath $SshSource -Force | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $SshTarget -Recurse -Force
}
} else {
New-Item -ItemType Directory -Force -Path $SshTarget | Out-Null
}
Comment on lines +52 to +57
if [ -d "${HOST_CONFIG_DIR}/.ssh" ]; then
rm -rf "${STAGE_DIR}/.ssh"
cp -R "${HOST_CONFIG_DIR}/.ssh" "${STAGE_DIR}/.ssh"
else
mkdir -p "${STAGE_DIR}/.ssh"
fi
# certificates. Sockets, locks, sshcontrol and host-specific agent config
# are excluded — the container never runs its own agent.
$GpgIncludes = @('pubring.kbx', 'pubring.gpg', 'trustdb.gpg', 'common.conf')
$GpgIncludeDirs = @('openpgp-revocs.d', 'private-keys-v1.d', 'public-keys.d')
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the bug Something isn't working label Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants