Feat/vm deploy topology spike - #47
Merged
Merged
Conversation
The runtime stage was gcr.io/distroless/static-debian12, which holds exactly one file. But the server execs `devpod` for every workspace operation, `docker` for the SSH proxy, SFTP, container-user lookup and devcontainer prebuild, and `git` for the files tab. The published image booted, migrated, served the SPA, and then failed on the first session create with an exec-not-found. Replace the runtime base with debian:bookworm-slim carrying git plus pinned Docker CLI (client only — the daemon is the host's) and DevPod, fetched in a separate stage so curl and the tarball never reach the runtime layer. Distroless is not kept as a second variant: there is no working use case for an image that cannot run the product's core feature, and publishing both invites deploying the wrong one. HOME is set explicitly because it is load-bearing. Both DevPod state trees resolve through os.UserHomeDir() — the CLI's workspace records that supply the reconciler's container label, and the agent's cloned content that the files tab reads — as does the SSH host key. DevPod's pin is duplicated from .devcontainer/tool-versions.env rather than shared: .dockerignore excludes .devcontainer from the build context, so the file is not readable at build time. Image goes from ~40MB to ~103MB. Verified: all three binaries resolve, container runs as UID 65532 with a writable HOME, migrations apply, the SPA and version endpoint serve, and a missing hashed asset still 404s rather than falling back to the SPA shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deuce and the Docker daemon it drives must agree on what a path string means. Deuce reads workspace files off its own filesystem and runs git against them, and it locates workspace containers by a label it reads from DevPod's on-disk records — both resolve through $HOME. So the state directory is mounted at the same absolute path on both sides and HOME points at it. Breaking that parity produces no error: the bind mount silently resolves to an empty host directory while the agent works normally somewhere nobody is looking. Required variables use compose's `:?` form rather than defaults. Every one of them (pinned image tag, state dir, docker gid, db password) is something that misbehaves quietly when guessed, so the stack refuses to start instead of starting wrong. Ports bind loopback by default. Dev auth mode admits any reachable client as any user, and that mode is deliberately set here for the spike, so the default must not be broadly published. The env template documents a verified loader behavior worth knowing: a set-but-empty variable falls back to its built-in default rather than meaning "empty". `DEUCE_WS_ALLOWED_ORIGINS=` silently becomes the localhost dev default. There is no way to express "empty" through the env file at all. Verified locally against the devcontainer's nested daemon: required-var enforcement fails loudly, the stack serves, the container's $HOME writes appear on the host at the same path with matching ownership, and a down/up cycle preserves both the SSH host key and the database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found on the spike VM. DevPod clones workspace content as the devcontainer's remoteUser — uid 1000 on most images — while the server process runs as 65532. Git refuses to operate across that mismatch and exits with "detected dubious ownership". The failure is silent in the worst way. Listing a directory needs no ownership match, so the files tab still renders the full tree; only the per-file git status disappears. Nothing errors and nothing logs, so the tab looks fine while quietly showing no modification state at all. This never surfaced in the devcontainer because Deuce runs there as vscode (uid 1000), which happens to match what DevPod writes as. It appears the moment the two uids differ, which is every containerized deployment. Matching the uids is not available as a fix: remoteUser varies per devcontainer image, one deployment serves many repos at once, and the value isn't known until the workspace is built. Declaring the trees safe describes the actual situation — this process's job is reading repositories owned by other uids, which is not what git's ownership check was built to guard. Verified on the VM: before the change the files API returned the tree with no gitStatus on any entry; after it, a modified file reports M and untracked files report U. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopts the socket-mounted container with path parity. It was the shape with the quiet failure mode, which is why it was validated before being committed to: parity held, writes crossed the boundary both ways, and rolling the image left workspace containers running untouched across three restarts while sessions reported stopped rather than missing. The solutions doc leads with the two degradation symptoms rather than the architecture, because that is the knowledge most likely to be needed later and least likely to be re-derived. An empty file tree means path parity broke; a full tree with no git status means the file-ownership declaration is missing. Neither logs an error. Also records three defects the spike surfaced that the origin did not anticipate: The prebuild cache is non-functional against DevPod v0.6.15. `devpod build --repository R --skip-push` reports success and exits zero, but tags the image :latest with no devpod- prefix to parse AND leaves no image on the daemon. Every session silently falls back to a from-scratch build with an over-SSH tooling install. This inverts the origin's requirement: the cache-key staleness bug it identified is unreachable, because a cache that never populates cannot go stale. A fresh deployment has no user row and no team membership, and the consequences cascade into unreadable sessions, empty member lists, and an SSH proxy that rejects every key. The config loader cannot express "off". A set-but-empty variable falls back to its built-in default, so DEUCE_SSH_LISTEN_ADDR= does not disable the SSH proxy the way the deployment checklist instructs. The superseded exe.dev plan is marked as such — its publish half shipped separately and its deploy half is replaced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Git executes core.fsmonitor as a command and honours it from the repository's own .git/config. Workspace repositories are not trusted — anyone with terminal access to a session, or the agent itself, can write into .git/config. Git's defence against exactly this is the dubious-ownership check, which fires here because DevPod clones as the devcontainer's remoteUser while the server runs as its own uid. The previous commit suppressed that check so the files tab could report git status at all, which also removed the protection it was providing. On a deployment that mounts the Docker socket, the result is a path from workspace container to server process to host root. Pin core.fsmonitor off on the command line, where it takes precedence over repository config. Verified by hand first: a planted value runs under a plain `git status` and does not run with the override, and status output is unchanged either way. The regression test plants the config the way an attacker would and asserts the command never ran. Confirmed it fails when the override is removed rather than passing regardless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from a real client: connecting to a session printed
`/bin/sh: 29: Syntax error: "(" unexpected` and dropped the connection
before anything ran.
The exec path handed client-supplied commands to /bin/sh, which is dash
on Debian-derived images. Clients write commands for a login shell,
because that is what a real sshd gives them — VS Code Remote-SSH's
bootstrap and the shell-integration payloads terminals inject on connect
are multi-line bash scripts. Dash rejects the bash-only syntax in them
at parse time, so the whole command dies. The reporter's script was 29
lines; a one-line `cat <(echo hi)` reproduces it at line 1.
This was on track to break Open-in-VS-Code, which is the reason the
proxy exists. The interactive shell modes have always used /bin/bash, so
this aligns the exec path with them rather than introducing a new
dependency, and bash is already required of devcontainers used with this
proxy because VS Code's own install probe needs it.
Verified against the live deployment: the failing syntax now runs,
multi-line scripts with functions and [[ ]] work, exec still lands as
the devcontainer's remoteUser, and the interactive shell is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 35683146 | Triggered | Generic Password | 8b05242 | deploy/docker-compose.yml | View secret |
| 35683146 | Triggered | Generic Password | 8b05242 | deploy/docker-compose.yml | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secrets safely. Learn here the best practices.
- Revoke and rotate these secrets.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Validates the container topology for self-hosted deployment, and fixes three defects the validation turned up.
Deuce had no deployment story and the published image couldn't run its core feature: the runtime stage was
distroless/static, holding exactly one file, while the server shells out todevpod,docker, andgit. It booted, migrated, served the SPA, then failed on the first session create.Origin:
docs/brainstorms/2026-07-30-vm-deploy-and-upgrade-requirements.md· Plan:docs/plans/2026-07-31-001-feat-vm-deploy-topology-spike-plan.mdThe decision
Adopt the socket-mounted container with path parity: an unprivileged Deuce container driving the host Docker daemon, with state bound at an identical absolute path on both sides and
HOMEpointing at it. Workspace containers are siblings rather than children.This shape needed validating rather than assuming because its failure mode is silent. Deuce reads workspace files off its own filesystem and finds containers via a label in DevPod's on-disk records — both resolve through
$HOME. If Deuce and the daemon disagree about what a path string means, DevPod still succeeds and the container still starts, but the bind mount resolves to an empty host directory: the team sees an empty file tree while the agent works normally somewhere nobody is looking.Verified on an Ubuntu 24.04 VM (Docker 29.1.3):
Up 24 minutesacross four image rollsstopped, notmissing; restart returnedreadywith content preserved and no re-cloneremoteUser, not rootThe alternatives each cost something concrete: a nested-daemon container stops every workspace on every upgrade and needs
privileged: true; host-native under systemd gives up containerized packaging and needs a raw binary the release pipeline doesn't publish.Decision recorded in
docs/solutions/architecture-patterns/deploy-deuce-as-a-container-sharing-the-host-daemon.md, which leads with the two degradation symptoms rather than the architecture — that's what someone will actually need later.Fixes
d34d8ad— git status was silently broken in any containerized deployment. DevPod clones as the devcontainer'sremoteUser(uid 1000) while the server runs as 65532, and git refuses across that mismatch. It fails quietly: walking a directory needs no ownership match, so the tree still rendered and only the per-file status vanished. Never visible in the devcontainer, where Deuce happens to run as uid 1000.9794980— closes an escalation path the above opened. Git executescore.fsmonitoras a command and honors it from a repository's own.git/config. Workspace repos are writable by the agent and anyone with terminal access, so suppressing the ownership check meant a planted value would run in the server process — which on this topology reaches the host Docker daemon. Pinned off at the call site, where the command line takes precedence over repository config.2b9e444— client exec commands ran under dash, breaking real clients. Reported from a live client as/bin/sh: 29: Syntax error: "(" unexpected, connection dropped before anything ran. Clients write commands for a login shell because that's what a real sshd gives them, and VS Code Remote-SSH's bootstrap plus the shell-integration payloads terminals inject are multi-line bash scripts. This was on track to break Open-in-VS-Code, the reason the proxy exists.Testing
server/internal/handler/files_test.goplants a maliciouscore.fsmonitorthe way an attacker would and asserts it never runs. Confirmed it fails when the override is removed, rather than passing regardless.TestDockerArgs_ExecUsesBashNotDashguards the dash regression by name.tsc -ball pass.Known residuals
Three findings surfaced by the spike that are not fixed here. All are recorded as requirements in the origin brainstorm for the follow-up deploy work.
devpod build --repository R --skip-pushreports success and exits zero, but tags the image:latestwith no parseable hash and leaves no image on the daemon. Every session silently falls back to a from-scratch build with an over-SSH tooling install, logging one WARN. Reproduced with and without adevcontainer.json. This inverts the brainstorm's original requirement: the cache-key staleness bug it identified is unreachable, because a cache that never populates cannot go stale.FORBIDDEN, session listing returns empty despite seeded data, new sessions getmembers: [], and the SSH proxy then rejects every key because it authorizes on session membership. The spike inserted a user and membership by hand to proceed.DEUCE_SSH_LISTEN_ADDR=does not disable the SSH proxy — verified across unset, empty, and explicit values. The SSH deployment checklist instructs operators to disable it exactly that way.DEUCE_WS_ALLOWED_ORIGINS=silently becomes the localhost dev default, the same trap on a security-relevant setting.Also unreviewed: this branch did not go through the persona code-review pass, which is worth running given it touches security-relevant configuration.
Post-Deploy Monitoring & Validation
deploy/is explicitly marked a spike candidate and is not a supported deployment yet — it ships with dev auth mode and loopback binds, and the follow-up plan hardens it before anyone is pointed at it. The image change, however, affects anyone pulling a released tag.Watch after any deploy from this image:
migrations appliedfollowed byserver starting. A container that exits non-zero before binding means migrations failed — expected behavior, not a regression.docker provider added successfullyat boot. Absence means thedevpodbinary isn't resolving, which would have been a hard failure on the old image.safe.directorydeclaration was lost; an empty tree means path parity is broken. Neither logs an error — this has to be checked by looking.missingwhere its workspace should have survived, orprebuild failed; falling backappearing for a deployment that expects the cache to work.DEUCE_IMAGE_TAGback and restart. Workspace containers are siblings and keep running through it.🤖 Generated with Claude Code