Repository navigation
Nested devpod writes broken ProxyCommand entries into the host's real ~/.ssh/config #112
Description
Question
Nested devpod up writes Host <ws>.devpod blocks into the host's real
~/.ssh/config, with a ProxyCommand pointing at a path that only exists inside
the container. What should the devcontainer do about it?
The one hole the in-container prototype
found in an otherwise clean isolation result. Everything else was invisible to the
host — devpod list, docker ps, the daemon, the images. This is not.
The route: .devcontainer/devcontainer.json bind-mounts ${localEnv:HOME}/.ssh
into the container. devpod inside then writes its config block through that mount,
onto the developer's real file. The entry it leaves is not merely noise — it is
broken by construction, naming
/workspaces/…/.pixi/envs/default/bin/devpod as its ProxyCommand, a path with
no meaning on the host.
DEVPOD_HOME does not scope this, and that is established on metal rather
than assumed: the prototype's wf96-dind block landed in the host's config with
its own ProxyCommand carrying --devpod-home "/tmp/…/wf96/devpod". devpod knew
about the scratch home and wrote relative to $HOME anyway. That closes the map's
long-running "not yet specified" item about where devpod writes these — it is
$HOME, full stop, with only devpod up's DEVPOD_SSH_CONFIG able to redirect
it (subcommand-scoped, per PR #106's review).
The tension, which is the whole decision. The ~/.ssh mount is not there for
the config file. It is there for the agent socket (SSH_AUTH_SOCK is
/home/vscode/.ssh/agent.sock) and the signing key — both load-bearing, both
verified working. Any fix trades against them. Candidates:
- Mount the agent socket alone rather than the whole directory, and let the
container keep its own~/.ssh/config. Smallest boundary; check what else in
~/.sshis actually used (known_hosts? the signing key? git'sgpg.sshconfig?). - Set
DEVPOD_SSH_CONFIGin the container's environment so devpod writes to a
container-local file. One line, but only coversdevpod up. - Mount
~/.sshread-only. Cheapest to state, but breaks any legitimate write and
may break devpod outright rather than harmlessly.
Judge against principle 2 — the smallest boundary that keeps agent forwarding and
signing working — and against principle 1: a developer who runs several of these
containers should not slowly accumulate dead ProxyCommand entries in the file
their non-devlaunch ssh depends on.
Establish, before deciding: exactly which paths under ~/.ssh the container reads
and writes, and whether devpod delete from inside removes its own block again
(the prototype's cleanup left zero wf96 lines, which suggests it does — confirm,
because "leaks only when the container dies badly" is a different problem from
"leaks always").
How the outcome gets tested. The decision lands on this ticket; whatever it
implies for devcontainer.json lands through a build ticket whose test is that
the host's ~/.ssh/config is byte-identical across a full nested workspace
lifecycle.
Activity
blooop commentedon Aug 8, 2026
breadcrumb: starting. Snapshot of the real host file before any work:
sha256sum ~/.ssh/config=cd9519fbe1fac1a2279f2414349e366cf63e7f1a0a0a346e7833718e463ad205. Two facts established by reading, before any metal:--ssh-configisup-only, confirmed against the installed binary (v0.26.1), not just against PR Give the test suite a devpod namespace of its own #106's review:devpod up --helphas both--configure-ssh(default true,DEVPOD_CONFIGURE_SSH) and--ssh-config(DEVPOD_SSH_CONFIG, "if empty will use ~/.ssh/config");devpod ssh|delete|stop --helphave neither. So candidate 2 (setDEVPOD_SSH_CONFIGincontainerEnv) redirects the write but leavesdelete's cleanup pointed at$HOME/.ssh/config— it would split write and unwrite across two different files. That is worse than either endpoint: blocks would be written to the container-local file and never removed from it, whiledeleterewrites the host's real file looking for a block that was never there.Nothing in this repo reads
~/.sshexcept the mount itself. Only hit outside.devcontainer/isgh_auth.ssh_args_and_env(), which is about--send-env GH_TOKEN, not ssh keys. And git signing is not configured on this host at all — nogpg.format, nouser.signingkeyin~/.gitconfigor its~/.gitconfig.localinclude — and~/.gitconfigis not mounted into the devcontainer anyway. So the "signing key" half of the ticket's tension is thinner than stated; what is actually load-bearing isagent.sock(SSH_AUTH_SOCK=/home/vscode/.ssh/agent.sock, 1 identity in the host agent) andknown_hosts.Next: confirm on metal whether
devpod deleteremoves its own block, usingHOME=a scratch dir so the real file is never a candidate target.blooop commentedon Aug 8, 2026
breadcrumb: live corroborating evidence from PR #106's retry session, directly relevant to this ticket's core question.
Two DevPod
Hostblocks disappeared from the host's real~/.ssh/configmid-session, for workspaces already absent from~/.devpod(whose hash never changed). The agent that observed it had run onlyversion,--help,listand a scopedprovider add, andgrepoverdevlaunch/ test/forssh/config|DevPod Startfinds zero hits outside the scoping module — so devlaunch did not do it.Most likely cause, and it is this ticket's subject seen from the other side:
devpod deletedoes not accept--ssh-config. A concurrent agent running with a scopedDEVPOD_HOMEdeleted its own workspaces, and devpod pruned the corresponding blocks from the real~/.ssh/configbecause that is the only ssh configdeleteknows about.Two consequences for your decision:
devpod upleaves the delete path pointed at the developer's file.Worth confirming rather than taking on trust — the attribution is inferred from timing and elimination, not observed directly.
blooop commentedon Aug 8, 2026
breadcrumb (correction — I was wrong in the last one, and it matters): I read the actual source of the devpod build we ship (
~/.pixi/envs/devpod/bin/devpod, v0.26.1 — note it is theskevetter/devpodfork, notloft-sh, per its symbol table).DEVPOD_SSH_CONFIGis not write-only-with-no-cleanup. It is persisted per workspace and honoured at delete:pkg/provider/workspace.go:73—SSHConfigPath string+""+json:"sshConfigPath,omitempty"+""+, i.e.up's--ssh-configvalue is stored in the workspace'sworkspace.json`.pkg/client/clientimplementation/workspace_client.go:441— delete callsDeleteWorkspaceFolder{SSHConfigPath: s.workspace.SSHConfigPath}, which resolves that path and callsssh.RemoveFromConfig.pkg/ssh.ResolveSSHConfigPath("")→$HOME/.ssh/config. It reads no env var; the env var only ever reaches it viaup's flag.Verified against the real state on this host: the three live
workspace.jsonfiles have nosshConfigPathkey (omitempty, never passed), so their deletes resolve to$HOME/.ssh/config. So "subcommand-scoped" is true of the flag and misleading about the behaviour — candidate 2 is symmetric after all, and is a stronger candidate than the ticket assumed.A second thing the source changes.
pkg/ssh/config.go:351—writeSSHConfigisos.WriteFile(path, content, 0o600): everyupand everydeleterewrites the whole file, serialised only by a process-local mutex. The container is therefore not appending a line to the host's~/.ssh/config; it is rewriting the entire file, my personalHost homeassistantblock included, with no cross-process locking against the host's owndl. That is a lost-update hazard on top of the dead-entry one, and it is an argument for a boundary that does not depend on devpod's good behaviour.Next: metal test of what candidate 1 actually costs — socket-only mount,
known_hosts, and whethergit@github.comstill authenticates.blooop commentedon Aug 8, 2026
breadcrumb (on metal — what candidate 1 actually costs, measured): no devpod, no nested workspace, no container image built. Three
docker run --rminvocations against theubuntu:24.04that was already on this host, reproducing candidate 1's mount set exactly.A. Socket-file bind + read-only
known_hosts, noconfig, no private key — everything load-bearing still works:So
id_ed25519is not load-bearing: with no key file in the container at all, the forwarded agent carries the identity and git-over-ssh authenticates. Docker binds a unix socket file fine — this is the docker.sock pattern.B.
known_hostsis load-bearing — drop it and the container fails closed, non-interactively:(
StrictHostKeyChecking=accept-newrecovers it, andssh-keyscanat image-build time would too — but mounting the host's file read-only is the option that keeps today's verified behaviour and still cannot write back.)C. A
--mountwith a missing source aborts the container and creates nothing —invalid mount config for type "bind": bind source path does not exist. Relevant because narrowing the mount narrows what must exist on the host.Host files unchanged throughout:
~/.ssh/configcd9519fb…ad205before and after all three,~/.ssh/known_hosts679e58fc…0a030before and after.And the delete question is settled — the leak is on abandonment, not always.
cmd delete→clientimplementation.DeleteWorkspaceFolder→ssh.RemoveFromConfigruns unconditionally on every delete (workspace_client.go:441), which matches #96's observation that cleanup left zerowf96lines. Two riders that stop this being reassuring:$HOME/.ssh/configwheneversshConfigPathwas never recorded. So a nesteddeleterewrites the host's whole real file even when it has nothing to remove.transformHostSectionre-reads the file through a line scanner and re-joins it, so a lifecycle is no-net-change modulo normalisation, not a no-op. The happy path races; only the abandoned path leaks.blooop commentedon Aug 8, 2026
agent-decided (principle 1, long-term maintainability — with principle 2 picking the shape): narrow the mount. Stop bind-mounting the host's
~/.sshdirectory into the container; mount only the two files that were ever load-bearing, and let/home/vscode/.sshbe container-local so devpod's blocks land in a file that dies with the container.containerEnv.SSH_AUTH_SOCKis unchanged (/home/vscode/.ssh/agent.sock— the same path it already names), and the Dockerfile already creates/home/vscode/.sshmode 700, so nothing else moves.initializeCommandshouldtouch ~/.ssh/known_hostson the host, because a--mountwith a missing source aborts the container.Why this and not the other three
vs. mounting
~/.sshread-only. Fails closed in the wrong place:writeSSHConfigis a plainos.WriteFile, so nesteddevpod uperrors out rather than degrading,sshcan no longer appendknown_hosts, and the host's private key stays exposed anyway. Cheapest to state, most expensive to live with.vs.
DEVPOD_SSH_CONFIGincontainerEnv. This one is better than the ticket credited it — I was wrong in my first breadcrumb and corrected it in the second: the path is persisted (sshConfigPathinworkspace.json) anddeletedoes honour it, so it is symmetric, not write-only. It is still the wrong fix, for two reasons. (a) Its correctness rests on three undocumented internals of a fork (skevetter/devpod) — the flag being env-bound onup, the value surviving asomitemptyintoworkspace.json, anddeleteresolving from there. Any one of them changing silently re-points a whole-fileos.WriteFileat the developer's real~/.ssh/config; that is a bad thing to hang a safety property on, and PR #106's review already read this API wrong once in the other direction. (b) It fixes devpod and only devpod. The directory stays mounted and writable, sosshappendingknown_hosts, and anything else the container grows, still write to the host's files. Narrowing the mount is a docker-level fact that holds no matter what runs inside.vs.
DEVPOD_CONFIGURE_SSH=false(a fourth candidate, worth recording because it looks right and is not).dlnever uses the<ws>.devpodaliases —workspace_sshshells out todevpod ssh <id>(devlaunch/dl.py:989) — so suppressing the write costs nothing. But it does not close the hole:deletecallsRemoveFromConfigunconditionally, so it still rewrites the host's whole~/.ssh/confighunting a block that was never written. Fine as belt-and-braces on top; useless alone.What it costs, stated plainly
ssh-add -lreturns the agent's identity andgit ls-remote git@github.com:…exits 0. The ticket's "signing key" leg turned out not to exist yet — there is nogpg.formatoruser.signingkeyin~/.gitconfigor its~/.gitconfig.local, and~/.gitconfigis not mounted into the devcontainer regardless. If signing is turned on later the answer is a third read-only file bind, not the directory back.known_hostsbecomes read-only, so the container cannot learn new hosts. Forgithub.comthat is the behaviour you want; for a repo on an unknown host,gitfails closed withHost key verification failedinstead of prompting. Escape hatch if it bites:StrictHostKeyChecking=accept-newin a container-local~/.ssh/config— which is now genuinely container-local and therefore safe to write.agent.sockwas already assumed by the existingSSH_AUTH_SOCKvalue, so that is not new;known_hostsis, hence thetouchininitializeCommand.How it gets tested
Structural, in CI, no docker: assert no mount in
devcontainer.jsonhas target exactly/home/vscode/.ssh, and that whateverSSH_AUTH_SOCKnames is itself a mount target.e2e, opt-in marker, the real assertion:
sha256sum ~/.ssh/configon the host → start the devcontainer → inside it run a nested lifecycle → kill the container with the nested workspace still alive → re-hash and assert byte-identical, plusgrep -c "DevPod Start"unchanged. The abandonment step is the whole test: the happy path already cleaned up before this fix, so an up-then-delete test would pass against the broken config. Pair it with an in-container liveness check —ssh-add -lnon-empty andgit ls-remote git@github.com:…exit 0 — because the fix is only correct if agent forwarding survives it. The test is safe to run against the developer's real file by construction: a passing run writes nothing to it.