Skip to content

Commit a71c02b

Browse files
integration: the Rust profile work plus the ssh agent socket fix
Not for merging upstream. This is the branch dl-next is built from and the one the gateway's workspaces are cloned from, so that one checkout carries both halves at once: - the Rust side (profile mount, probe verification, ClaudeConfig::Bound), which decides what dl does at launch, and - the .devcontainer side (blooop#582's $SSH_AUTH_SOCK mount), which decides whether a container can be created on a host whose agent is not at the path the manifest used to guess. blooop#583 needs no merge here: f3adbaa is already an ancestor, since the profile work was built on top of it. # Conflicts: # test/unit/test_devcontainer_manifest.py
2 parents 8f96b94 + abf6d9b commit a71c02b

3 files changed

Lines changed: 81 additions & 5 deletions

File tree

.devcontainer/claude-code/init-host.sh

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,20 @@ heal_stale_file_mount() {
120120
rm -f "$saved"
121121
}
122122

123+
# The agent socket is named by $SSH_AUTH_SOCK and not as a path under ~/.ssh,
124+
# because that is the mount's source now: the consuming manifest binds
125+
# ${localEnv:SSH_AUTH_SOCK}, since ~/.ssh/agent.sock is where almost no agent
126+
# actually listens (gpg-agent answers under $XDG_RUNTIME_DIR, ssh-agent(1) on a
127+
# /tmp/ssh-XXXX path). The fallback keeps the old path in the list rather than
128+
# dropping it, so a container created before that change still has its stale
129+
# socket mount healed. Run *inside* a container this repo built, $SSH_AUTH_SOCK
130+
# is /home/vscode/.ssh/agent.sock, which is exactly the mount that can be stale
131+
# there -- so the variable is the right name to heal at both ends, which the
132+
# hardcoded path was only ever by coincidence.
123133
for mounted_file in "$HOME/.claude/CLAUDE.md" "$HOME/.claude/settings.json" \
124134
"$HOME/.claude/.credentials.json" "$HOME/.claude/.claude.json" \
125-
"$HOME/.ssh/known_hosts" "$HOME/.ssh/agent.sock"; do
135+
"$HOME/.ssh/known_hosts" "$HOME/.ssh/agent.sock" \
136+
"${SSH_AUTH_SOCK:-$HOME/.ssh/agent.sock}"; do
126137
heal_stale_file_mount "$mounted_file"
127138
done
128139
#

.devcontainer/devcontainer.json

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,33 @@
120120
// known_hosts is read-only because nothing in here has any business
121121
// adding hosts to the developer's list.
122122
//
123-
// The HOME-relative socket path is an assumption -- stock OpenSSH puts
124-
// its agent socket in /tmp -- but a pre-existing one, since
125-
// containerEnv already hardcodes the same path.
126-
"source=${localEnv:HOME}/.ssh/agent.sock,target=/home/vscode/.ssh/agent.sock,type=bind",
123+
// The source is $SSH_AUTH_SOCK, because the agent's path is not
124+
// something this file can know. It used to be
125+
// ${localEnv:HOME}/.ssh/agent.sock, described here as "an assumption
126+
// -- stock OpenSSH puts its agent socket in /tmp -- but a
127+
// pre-existing one, since containerEnv already hardcodes the same
128+
// path". The assumption is false for every agent that is not a
129+
// hand-rolled symlink: gpg-agent (a YubiKey OpenPGP setup) answers on
130+
// $XDG_RUNTIME_DIR/gnupg/S.gpg-agent.ssh, ssh-agent(1) on a
131+
// /tmp/ssh-XXXX mktemp path, and 1Password and gnome-keyring on paths
132+
// of their own. None of them is under ~/.ssh, so `devpod up` failed
133+
// before the container existed with "bind mount source path does not
134+
// exist", which reads as a broken workspace rather than a
135+
// configuration this repository declared.
136+
//
137+
// The reverse argument, which is what the old comment was reaching
138+
// for: containerEnv hardcodes the *target*, and that stays hardcoded.
139+
// Inside the container SSH_AUTH_SOCK is /home/vscode/.ssh/agent.sock
140+
// whatever the host called it, so the source is the only half that has
141+
// to vary and the container end of the contract is unchanged.
142+
//
143+
// Costs, both real. An unset SSH_AUTH_SOCK expands to nothing and the
144+
// mount is malformed rather than skipped, since devcontainer.json has
145+
// no conditional mounts -- a host with no agent at all was already
146+
// broken here and is now broken differently. And this is a change
147+
// under .devcontainer/, so the prebuild tag moves and every launch
148+
// builds locally until devcontainer-prebuild.yml republishes on main.
149+
"source=${localEnv:SSH_AUTH_SOCK},target=/home/vscode/.ssh/agent.sock,type=bind",
127150
"source=${localEnv:HOME}/.ssh/known_hosts,target=/home/vscode/.ssh/known_hosts,type=bind,readonly",
128151
"source=${localEnv:HOME}/.config/gh,target=/home/vscode/.config/gh,type=bind"
129152
],

test/unit/test_devcontainer_manifest.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,3 +712,45 @@ def test_the_feature_seeds_no_empty_credential():
712712
and (">" in line or "tee" in line or "cp " in line)
713713
]
714714
assert not seeding, f"{name} is written by install.sh: {seeding}"
715+
716+
717+
def test_the_agent_socket_is_bound_from_the_variable_that_names_it(devcontainer, mounts):
718+
"""The agent socket mount reads $SSH_AUTH_SOCK, not a guess at where it is.
719+
720+
This was ``${localEnv:HOME}/.ssh/agent.sock``, and the manifest said so with
721+
a comment calling it "an assumption ... but a pre-existing one". It is not a
722+
path any agent picks by itself: gpg-agent (a YubiKey OpenPGP setup) listens
723+
on ``$XDG_RUNTIME_DIR/gnupg/S.gpg-agent.ssh`` and ``ssh-agent(1)`` on a
724+
``/tmp/ssh-XXXX`` mktemp path, so on such a host ``devpod up`` refused the
725+
create outright with ``bind mount source path does not exist`` -- before the
726+
container existed, which reads as a broken tool rather than as this file
727+
naming a path the host never had.
728+
729+
``init-host.sh`` cannot paper over it the way it does for ``known_hosts``:
730+
that one is ``touch``ed into existence, and there is no touching a socket
731+
into being an agent.
732+
"""
733+
agent_socket = devcontainer["containerEnv"]["SSH_AUTH_SOCK"]
734+
sources = {mount.get("source") for mount in mounts if mount.get("target") == agent_socket}
735+
assert sources == {"${localEnv:SSH_AUTH_SOCK}"}, (
736+
f"the agent socket must be bound from ${{localEnv:SSH_AUTH_SOCK}}; found {sources}"
737+
)
738+
739+
740+
def test_the_host_hook_heals_the_socket_it_is_mounted_from(devcontainer, mounts):
741+
"""The heal list names the path the manifest actually binds.
742+
743+
Two hand-maintained copies of one fact: the mount source here, and the loop
744+
in ``init-host.sh`` that detaches a stale one. They disagreed the moment the
745+
mount moved, and a heal aimed at a path nothing mounts is a heal that never
746+
fires -- silently, since a stale mount only bites a *nested* create.
747+
"""
748+
hook = (REPO_ROOT / ".devcontainer" / "claude-code" / "init-host.sh").read_text()
749+
agent_socket = devcontainer["containerEnv"]["SSH_AUTH_SOCK"]
750+
source = next(mount["source"] for mount in mounts if mount.get("target") == agent_socket)
751+
# "${localEnv:SSH_AUTH_SOCK}" in the manifest is "$SSH_AUTH_SOCK" in the shell.
752+
variable = source.removeprefix("${localEnv:").removesuffix("}")
753+
assert f"${{{variable}:-" in hook or f"${variable}" in hook, (
754+
f"init-host.sh does not heal a stale mount at ${variable}, which is what "
755+
"the manifest binds the agent socket from"
756+
)

0 commit comments

Comments
 (0)