Skip to content

Feat/vm deploy topology spike - #47

Merged
clintberry merged 7 commits into
mainfrom
feat/vm-deploy-topology-spike
Aug 3, 2026
Merged

Feat/vm deploy topology spike#47
clintberry merged 7 commits into
mainfrom
feat/vm-deploy-topology-spike

Conversation

@clintberry

Copy link
Copy Markdown
Contributor

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 to devpod, docker, and git. 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.md

The 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 HOME pointing 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):

  • Workspace container bind-mounted the exact host path Deuce reads from
  • Writes made inside the workspace container appeared in Deuce's listing immediately, with correct git status
  • Upgrades left workspaces running untouched — the same container sat at Up 24 minutes across four image rolls
  • Stopping a workspace produced stopped, not missing; restart returned ready with content preserved and no re-clone
  • SSH proxy authenticated a registered key and landed as the devcontainer's remoteUser, not root

The 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's remoteUser (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 executes core.fsmonitor as 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

  • New server/internal/handler/files_test.go plants a malicious core.fsmonitor the way an attacker would and asserts it never runs. Confirmed it fails when the override is removed, rather than passing regardless.
  • New TestDockerArgs_ExecUsesBashNotDash guards the dash regression by name.
  • Full Go suite, ESLint, and tsc -b all pass.
  • Every claim above was verified against the live deployment, not inferred.

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.

  1. The devcontainer 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 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 a devcontainer.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.
  2. A fresh deployment has no user row and no team membership. Reads return FORBIDDEN, session listing returns empty despite seeded data, new sessions get members: [], 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.
  3. Configuration cannot express "off". The loader falls back to a field's built-in default when a variable is set-but-empty, so 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:

  • Startup: migrations applied followed by server starting. A container that exits non-zero before binding means migrations failed — expected behavior, not a regression.
  • Workspace health: docker provider added successfully at boot. Absence means the devpod binary isn't resolving, which would have been a hard failure on the old image.
  • The signal that matters most: create a session and confirm the files tab shows both real content and per-file git status. Content without status means the safe.directory declaration was lost; an empty tree means path parity is broken. Neither logs an error — this has to be checked by looking.
  • Failure signal / rollback trigger: any session reporting missing where its workspace should have survived, or prebuild failed; falling back appearing for a deployment that expects the cache to work.
  • Rollback: change DEUCE_IMAGE_TAG back and restart. Workspace containers are siblings and keep running through it.
  • Window/owner: first session create after deploy, by whoever deploys.

🤖 Generated with Claude Code

clintberry and others added 7 commits July 31, 2026 03:46
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

gitguardian Bot commented Aug 3, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 2 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
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
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. 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


🦉 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.

@clintberry
clintberry merged commit 8840363 into main Aug 3, 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