Skip to content

fix(sshproxy): run docker exec as the devcontainer's remoteUser - #45

Merged
clintberry merged 1 commit into
mainfrom
fix/vscode-exec-remote-user
Jul 28, 2026
Merged

fix(sshproxy): run docker exec as the devcontainer's remoteUser#45
clintberry merged 1 commit into
mainfrom
fix/vscode-exec-remote-user

Conversation

@clintberry

Copy link
Copy Markdown
Contributor

Problem

Running any git command in a VS Code terminal opened against a session fails:

fatal: detected dubious ownership in repository at '/workspaces/<repo>'
To add an exception for this directory, call:

	git config --global --add safe.directory /workspaces/<repo>

The same command in the in-browser Terminal panel works fine.

Root cause

A UID mismatch between the two paths into the container.

Devcontainer images routinely ship USER root and declare a separate remoteUser. DevPod chowns the workspace tree to that remoteUser. Verified against a live container:

docker inspect --format '{{.Config.User}}' root
devcontainer.metadata label {"remoteUser":"vscode"}
ls -ld /workspaces/<repo> drwxr-xr-x vscode vscode

The browser Terminal panel shells out to devpod ssh, which honors remoteUser and lands as uid 1000. The SSH proxy shells out to docker exec, and dockerArgs built every invocation without --user — so VS Code channels fell back to the image's USER and landed as uid 0.

Git ≥2.35.2's safe.directory check compares the repository directory's owner against the process euid. 1000 ≠ 0, so it refuses.

Reproduced in the same container, with only the user differing:

docker exec -i <c> /bin/bash -l        → root   → fatal: detected dubious ownership
docker exec --user vscode -i <c> ...   → vscode → On branch main

Fix

Resolve the exec user from the container's devcontainer.metadata label and pass --user on every exec shape — PTY shell, non-PTY shell, exec, SFTP, and TCP forwards.

SFTP matters as much as the interactive shell: writing files as root into a remoteUser-owned tree is the same bug with a slower fuse.

Why --user rather than safe.directory

Adding a safe.directory exception would silence the error, but it patches one symptom of the UID mismatch. Running as root also means files created in VS Code land as root:root in a vscode-owned tree, $HOME resolves to /root (so git config, SSH keys, and tool caches all miss), and the HOME/USER the proxy forwards from the client no longer describe the actual process user. --user fixes the class.

Design notes

Precedence follows the devcontainer spec: remoteUser, then containerUser, then leave the image's USER alone. Metadata entries merge with later ones overriding earlier, and an entry that omits the key does not clear a value an earlier entry set.

Resolution fails open. No declared user, a docker inspect error, or a proxy built without a workspace manager all fall back to the previous behaviour rather than killing the channel. Guessing wrong costs a git ownership warning; erroring out costs the whole editor session.

The label value is validated. It is attacker-influenceable — a session member controls the devcontainer.json in their own repository — so it is checked against a strict user[:group] / uid pattern before reaching argv. It lands in an argv slot rather than a shell, so this is defence in depth; the leading-character rule is what stops a value from reading as a flag.

Lookups are memoized per container with a 5-minute TTL. VS Code Remote-SSH opens many channels per connection and each resolves the user, so an uncached docker inspect per channel would add real latency to every terminal and port forward. The cache lives on the Manager rather than in package globals so instances and tests don't share it.

devpod.user is not this value. It reports root on images that declare a remoteUser, so keying off it would reproduce the bug. Called out in the code and in CLAUDE.md so the next person doesn't reach for it.

Test plan

Full backend suite passes, including under -race. go vet and gofmt clean.

20 new cases:

  • sshproxy/docker_test.go--user is present in all five exec modes and precedes the container name (after it, docker treats it as part of the in-container command); absent when no user resolves, with no empty argv slot; threaded through buildExecCmd, buildSFTPCmd, and buildTCPForwardCmd, the last also asserting the forwarding script survives intact.
  • workspace/container_user_test.go — the real multi-entry DevPod label shape, remoteUser outranking containerUser, later-entry override, empty values not clobbering earlier ones, bare-object tolerance, numeric uid and user:group forms, and rejection of malformed JSON, wrong value types, leading dashes, whitespace, shell metacharacters, and path traversal.

Existing argv tests were updated to pass an empty user, preserving their original assertions as the "no declared user" case.

Why existing tests missed this: they assert dockerArgs argv exactly as written, and a shape assertion cannot detect that the shape produces the wrong UID. Nothing related exec identity to workspace ownership.

End-to-end verification: workspace.ContainerUser was run against the live container through the real code path and resolved vscode, and the resulting argv was confirmed to fix git in that container.

Post-Deploy Monitoring & Validation

No schema, auth, or data impact. The change is confined to the argv the SSH proxy builds.

Validate after deploy:

  • Open a session in VS Code, run git status in the integrated terminal — it succeeds.
  • id in that terminal reports the devcontainer's remoteUser, not root.
  • Create a file from VS Code and check it is owned by that user, not root.
  • Port forwarding and file save (SFTP) still work — both exec paths changed.
  • Failure signal: docker: Error response from daemon: unable to find user ... in server logs would mean a container declared a user that does not exist inside it. Look for the could not resolve container exec user warning, which indicates resolution fell back to the image default. Rollback is a straight revert.

Known residuals / follow-ups

  • Containers declaring neither remoteUser nor containerUser still exec as the image's USER, so they keep the original behaviour — including this bug if such an image also chowns the workspace elsewhere. Fixing that would mean stat-ing the workspace directory, which diverges from what devpod ssh does.
  • A declared user that does not exist in the container will make docker exec fail outright rather than fall back. That is a broken devcontainer, and devpod ssh would fail on it too, but the error surfaces later than a validation-time check would.
  • Only the UID half of the documented Terminal-vs-VS-Code divergence is closed. The environment and login-shell differences described in CLAUDE.md remain.
  • Not verified inside a real VS Code Remote-SSH session — verification was at the docker exec layer, which is where the bug lives, but the full editor round trip has not been exercised.

Compound Engineering
Claude Code

Running any git command in a VS Code terminal opened against a session
failed with:

    fatal: detected dubious ownership in repository at '/workspaces/<repo>'

Devcontainer images routinely declare `USER root` and a separate
`remoteUser`, and DevPod chowns the workspace tree to that remoteUser.
`devpod ssh` — the browser terminal path — honors it, but the SSH proxy
built every `docker exec` without `--user`, so VS Code channels landed
as uid 0 in a uid 1000 tree. Git's safe.directory check compares the
repo owner against the process euid, sees the mismatch, and refuses.

Resolve the user from the container's devcontainer.metadata label
(remoteUser, then containerUser, per the spec's precedence) and pass
`--user` on every exec shape: shells, exec, SFTP, and TCP forwards.
SFTP matters as much as the shell — writing files as root into a
remoteUser-owned tree is the same bug with a slower fuse.

Resolution fails open. No declared user, a docker inspect error, or a
proxy without a workspace manager all fall back to the image's USER
rather than killing the channel: guessing wrong costs a git warning,
erroring out costs the whole editor session.

The label value is attacker-influenceable — a session member controls
the devcontainer.json in their own repo — so it is validated against a
strict user/uid[:group] pattern before reaching argv. Lookups are
memoized per container with a TTL, since VS Code opens many channels
per connection and each resolves the user.

Note `devpod.user` is not this value: it reports root on images that
declare a remoteUser, so keying off it would reproduce the bug.
@clintberry
clintberry merged commit 65ffb05 into main Jul 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant