Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ cd server && make migrate # Run migrations + seed data
- **Auth**: Two modes selected by `DEUCE_AUTH_MODE`. `dev` (default) injects a fixed user ID from `DEUCE_USER_ID` — localhost-only, do not bind on a non-loopback interface. `forge-proxy` trusts `X-Forge-*` headers from the [forge-proxy](https://github.com/forgeutah/forge-proxy) reverse proxy (validates the shared secret in constant time, checks a single required role from CSV, auto-provisions users by `forge_user_id`). See `server/internal/auth/forge_proxy.go`.
- **Agent runtime**: One persistent Pi (`pi --mode rpc`) process per session inside its DevPod container, driven over JSONL (`server/internal/agent/`). `@deuce` mentions are detected server-side (`server/internal/handler/messages.go`) and enqueue tasks on the session's serial queue; `GET/PUT /api/agent` reads/edits deuce's global system prompt. The agent's fixed UUID is `agent.DeuceAgentID` (mirrored by `DEUCE` in `src/lib/deuce.ts`); the nil UUID is the system-notice author sentinel.
- **DevPod**: Workspace manager shells out to `devpod` CLI via os/exec (`server/internal/workspace/manager.go`)
- **Devcontainer prebuild cache** (opt-in via `DEUCE_PREBUILD_REPOSITORY`, `server/internal/workspace/prebuild.go`): `devpod build --repository R --skip-push` builds the repo's devcontainer once and tags it `R:devpod-<hash>`, where `<hash>` is devpod's hash of the devcontainer definition. A thin Deuce layer then bakes Pi, pi-subagents and the ask-user extension on top as `R:deuce-<hash>`, and `devpod up --devcontainer-image R:deuce-<hash>` starts sessions from it with no build and no over-ssh provisioning. Because the tag carries the definition hash, a devcontainer change invalidates the cache while ordinary code pushes reuse it.

### Adding a New API Endpoint

Expand Down Expand Up @@ -90,6 +91,22 @@ GITHUB_TOKEN= # GitHub PAT for repo listing (optional)
DEVPOD_BIN=devpod # DevPod binary path
DEVPOD_PROVIDER= # DevPod provider (empty = default)

# Devcontainer prebuild cache. Empty (default) = off: every session runs a
# from-scratch `devpod up` and installs Pi over `devpod ssh`. When set to a
# Docker repository name (no tag — Deuce appends the devcontainer hash), each
# repo's devcontainer image is built once per devcontainer-definition hash,
# has Pi + pi-subagents + the ask-user extension baked on top, and later
# sessions start straight from that image with no build and no over-ssh
# install. Tags stay local to the Docker daemon; no registry is required.
DEUCE_PREBUILD_REPOSITORY=

# Carries a workspace's ~/.vscode-server tree across container recreates so
# VS Code Remote-SSH does not re-download its ~120MB server payload. Empty
# (default) = off. Must be an absolute path. Budget ~120MB per workspace that
# has been opened in VS Code; an entry is removed when its workspace is
# deleted. Keyed by workspace, not by user — see the VS Code section below.
DEUCE_VSCODE_SERVER_CACHE_DIR=

# Agent backend. Pi (pi.dev) runs in --mode rpc inside each session's DevPod
# container, driven over a persistent JSONL channel — one process per session.
# PiProvider/PiModel select the Pi backend (v1 runs Claude models through Pi).
Expand Down Expand Up @@ -239,7 +256,11 @@ Devcontainers used with "Open in VS Code" must include:
- `openssh-sftp-server` (Debian: `/usr/lib/openssh/sftp-server`) — required for SFTP-based file operations
- A **glibc-compatible base image** — VS Code Remote requires glibc ≥ 2.17. Alpine + musl needs `apk add gcompat`.

`~/.vscode-server` lives in the container's own filesystem; a fresh ~120MB download fires on every container recreate. Per-user named volume caching is a v2 follow-up.
`~/.vscode-server` lives in the container's own filesystem, so a fresh ~120MB download would fire on every container recreate. Setting `DEUCE_VSCODE_SERVER_CACHE_DIR` avoids that: Deuce copies the tree out to a host cache before stop/rebuild and copies it back in after the container is recreated (`server/internal/workspace/vscode_cache.go`).

A named volume would be the obvious mechanism, but DevPod exposes no way to add one — `devpod up` has no mount flag and the docker provider's options are only `DOCKER_BUILDER`/`DOCKER_HOST`/`DOCKER_PATH`/`INACTIVITY_TIMEOUT`. Mounts can only come from the repo's own `devcontainer.json`, which Deuce does not control, hence `docker cp`.

The cache is keyed by **workspace**, not by user. A workspace's container is already shared by every member of its session, so a per-workspace cache adds no new reach; keying it per user would copy one user's extension state — including credentials their extensions have stored — into a container other session members hold a shell on.

## Documented Solutions

Expand Down
14 changes: 14 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,19 @@ GITHUB_TOKEN=
DEVPOD_BIN=devpod
DEVPOD_PROVIDER=docker

# Devcontainer prebuild cache. Empty (default) = off: every session builds the
# devcontainer from scratch and installs Pi over `devpod ssh`. Set it to a
# Docker repository name (no tag — the devcontainer hash is appended) to build
# each repo's image once with Pi and the agent tooling baked in, so later
# sessions start with no build and no install. Stays local to the Docker
# daemon; no registry or push is involved.
DEUCE_PREBUILD_REPOSITORY=

# Carries a workspace's ~/.vscode-server tree across container recreates so
# VS Code Remote-SSH does not re-download its ~120MB server payload each time.
# Empty (default) = off. Must be an absolute path. Budget ~120MB per workspace
# opened in VS Code; entries are removed when their workspace is deleted.
DEUCE_VSCODE_SERVER_CACHE_DIR=

# Anthropic API key for real agent execution
ANTHROPIC_API_KEY=
47 changes: 47 additions & 0 deletions server/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"slices"
"strings"
Expand Down Expand Up @@ -42,6 +43,27 @@ type Config struct {
DevPodProvider string `env:"DEVPOD_PROVIDER" envDefault:"docker"`
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY" envDefault:""`

// PrebuildRepository turns on the devcontainer prebuild cache. Empty
// (the default) keeps the original behaviour exactly: every session
// runs a from-scratch `devpod up` and installs Pi over `devpod ssh`.
// When set, each repo's devcontainer image is built once per
// devcontainer-definition hash, has Pi and the agent tooling baked on
// top, and later sessions start from that cached image.
//
// The value is a Docker repository name. It does not have to resolve
// to a registry — tags stay local to the Docker daemon, because the
// image is consumed via `devpod up --devcontainer-image` rather than
// `--prebuild-repository` (which only ever does a registry lookup).
PrebuildRepository string `env:"DEUCE_PREBUILD_REPOSITORY" envDefault:""`

// VSCodeCacheDir enables carrying a workspace's ~/.vscode-server tree
// across container recreates, so VS Code Remote-SSH does not
// re-download its ~120MB server payload every time. Empty (the
// default) disables it. Budget roughly 120MB of disk per workspace
// that has been opened in VS Code; entries are removed when their
// workspace is deleted.
VSCodeCacheDir string `env:"DEUCE_VSCODE_SERVER_CACHE_DIR" envDefault:""`

// PiProvider/PiModel configure the Pi agent backend (the sole harness);
// v1 runs Claude models through Pi.
PiProvider string `env:"DEUCE_PI_PROVIDER" envDefault:"anthropic"`
Expand Down Expand Up @@ -124,6 +146,19 @@ func (c *Config) ProxyRoleCheckEnabled() bool {
return c.ProxyHeaderRoles != ""
}

// prebuildRepoRE matches a Docker repository name without a tag: optional
// registry host (with optional port), then one or more lowercase path
// components. Deliberately strict — the value reaches `docker build -t` and
// `devpod up --devcontainer-image` argv, and a tag separator here would
// collide with the hash tag Deuce appends.
var prebuildRepoRE = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)*(:[0-9]+)?(/[a-z0-9]+([._-][a-z0-9]+)*)*$`)

// PrebuildEnabled reports whether the devcontainer prebuild cache is turned
// on. When false, workspace creation keeps its original from-scratch path.
func (c *Config) PrebuildEnabled() bool {
return c.PrebuildRepository != ""
}

// Validate checks the config for self-consistency before the server binds.
// In proxy mode it refuses to start when the optional-check env-var pairs
// are asymmetric (a header without its value, or vice versa), when the
Expand All @@ -145,6 +180,18 @@ func (c *Config) Validate() error {
return errors.New("DEUCE_WS_ALLOWED_ORIGINS cannot contain '*' — wildcard origins re-open cross-site WebSocket hijacking")
}

// Caught at startup rather than at first session create, where the
// failure would surface as a confusing mid-provisioning docker error.
if c.PrebuildRepository != "" && !prebuildRepoRE.MatchString(c.PrebuildRepository) {
return fmt.Errorf("DEUCE_PREBUILD_REPOSITORY=%q is not a valid Docker repository name (lowercase, no tag — Deuce appends the devcontainer hash as the tag)", c.PrebuildRepository)
}

// A relative cache root would resolve against the server's working
// directory, which differs between `make dev` and a deployed unit.
if c.VSCodeCacheDir != "" && !filepath.IsAbs(c.VSCodeCacheDir) {
return fmt.Errorf("DEUCE_VSCODE_SERVER_CACHE_DIR=%q must be an absolute path", c.VSCodeCacheDir)
}

if c.AuthMode == AuthModeProxy {
return c.validateProxyMode(origins)
}
Expand Down
91 changes: 91 additions & 0 deletions server/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,94 @@ func TestWSAllowedOriginList_TrimsAndDropsEmpty(t *testing.T) {
}
}
}

func TestPrebuildEnabled(t *testing.T) {
cfg := &Config{}
if cfg.PrebuildEnabled() {
t.Error("prebuild should be off by default — an empty repository keeps the original from-scratch path")
}
cfg.PrebuildRepository = "deuce-prebuild"
if !cfg.PrebuildEnabled() {
t.Error("prebuild should be on when a repository is configured")
}
}

func TestValidate_PrebuildRepositoryAccepted(t *testing.T) {
valid := []string{
"", // off
"deuce-prebuild", // bare local name
"ghcr.io/forgeutah/deuce-prebuild", // registry path
"localhost:5000/deuce-prebuild", // registry with port
"deuce_prebuild", // underscore separator
}
for _, repo := range valid {
cfg := &Config{
AuthMode: AuthModeDev,
WSAllowedOrigins: "localhost:4000",
PrebuildRepository: repo,
}
if err := cfg.Validate(); err != nil {
t.Errorf("PrebuildRepository=%q should validate: %v", repo, err)
}
}
}

func TestValidate_PrebuildRepositoryRejected(t *testing.T) {
// A tag here would collide with the devcontainer hash Deuce appends,
// and the flag- and shell-shaped values must never reach docker argv.
invalid := []string{
"deuce-prebuild:latest",
"Deuce-Prebuild",
"--build-arg=evil",
"repo with space",
"repo;rm -rf /",
"repo$(hostile)",
}
for _, repo := range invalid {
cfg := &Config{
AuthMode: AuthModeDev,
WSAllowedOrigins: "localhost:4000",
PrebuildRepository: repo,
}
err := cfg.Validate()
if err == nil {
t.Errorf("PrebuildRepository=%q should be rejected", repo)
continue
}
if !strings.Contains(err.Error(), "DEUCE_PREBUILD_REPOSITORY") {
t.Errorf("error for %q should name the env var: %v", repo, err)
}
}
}

func TestValidate_VSCodeCacheDirMustBeAbsolute(t *testing.T) {
// A relative root would resolve against the server's working directory,
// which differs between `make dev` and a deployed unit — the cache would
// silently land somewhere different depending on how deuce was started.
for _, dir := range []string{"relative/path", "./cache", "cache"} {
cfg := &Config{
AuthMode: AuthModeDev,
WSAllowedOrigins: "localhost:4000",
VSCodeCacheDir: dir,
}
err := cfg.Validate()
if err == nil {
t.Errorf("VSCodeCacheDir=%q should be rejected", dir)
continue
}
if !strings.Contains(err.Error(), "DEUCE_VSCODE_SERVER_CACHE_DIR") {
t.Errorf("error for %q should name the env var: %v", dir, err)
}
}

for _, dir := range []string{"", "/var/lib/deuce/vscode"} {
cfg := &Config{
AuthMode: AuthModeDev,
WSAllowedOrigins: "localhost:4000",
VSCodeCacheDir: dir,
}
if err := cfg.Validate(); err != nil {
t.Errorf("VSCodeCacheDir=%q should validate: %v", dir, err)
}
}
}
12 changes: 7 additions & 5 deletions server/internal/handler/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -585,17 +585,19 @@ func (h *Handler) startWorkspace(sessionID uuid.UUID, workspaceID, repoURL strin
h.hub.BroadcastToSession(sessionID.String(), msg, nil)
}

err := h.workspaces.Create(ctx, workspaceID, repoURL, logFn)
prebuilt, err := h.workspaces.Create(ctx, workspaceID, repoURL, logFn)

var newStatus string
if err != nil {
slog.Error("workspace creation failed", "sessionID", sessionID, "error", err)
newStatus = "failed"
} else {
// Install the agent harnesses (Claude fallback + Pi + ask-user
// extension) after workspace creation. Idempotent and non-fatal; the
// same helper runs on every start/rebuild so older containers migrate.
h.provisionAgentTools(ctx, workspaceID, logFn)
// Install the agent harnesses (Pi + subagents + ask-user extension)
// after workspace creation. Idempotent and non-fatal; the same helper
// runs on every start/rebuild so older containers migrate. Skipped
// when the workspace image already carries them.
h.provisionAgentToolsIfNeeded(ctx, workspaceID, prebuilt, logFn)
h.restoreVSCodeServer(ctx, workspaceID, logFn)
newStatus = "ready"
}

Expand Down
57 changes: 53 additions & 4 deletions server/internal/handler/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,44 @@ func (h *Handler) provisionAgentTools(ctx context.Context, workspaceID string, l
}
}

// provisionAgentToolsIfNeeded runs the over-ssh install only when the
// container did not start from a Deuce-baked image. When the tooling is
// baked in, the installers would be a no-op that still costs several ssh
// round-trips on the session-open path — which is the cost the prebuild
// cache exists to remove.
func (h *Handler) provisionAgentToolsIfNeeded(ctx context.Context, workspaceID string, res workspace.PrebuildResult, logFn workspace.LogFunc) {
if res.ToolsBaked {
slog.Info("agent tooling baked into workspace image; skipping over-ssh provisioning", "workspace", workspaceID)
if logFn != nil {
logFn("Agent tooling already present in the workspace image")
}
return
}
h.provisionAgentTools(ctx, workspaceID, logFn)
}

// saveVSCodeServer and restoreVSCodeServer carry the VS Code Remote server
// payload across container recreates. Both are best-effort: the cache is an
// optimisation, and losing it costs a re-download, not a session. Both are
// no-ops when DEUCE_VSCODE_SERVER_CACHE_DIR is unset.
func (h *Handler) saveVSCodeServer(ctx context.Context, workspaceID string, logFn workspace.LogFunc) {
if !h.workspaces.VSCodeCacheEnabled() {
return
}
if err := h.workspaces.SaveVSCodeServer(ctx, workspaceID, logFn); err != nil {
slog.Warn("failed to cache vscode-server payload", "workspace", workspaceID, "error", err)
}
}

func (h *Handler) restoreVSCodeServer(ctx context.Context, workspaceID string, logFn workspace.LogFunc) {
if !h.workspaces.VSCodeCacheEnabled() {
return
}
if err := h.workspaces.RestoreVSCodeServer(ctx, workspaceID, logFn); err != nil {
slog.Warn("failed to restore vscode-server payload", "workspace", workspaceID, "error", err)
}
}

// workspaceAction names the four lifecycle operations the user can trigger.
// Each one maps to a transitional workspace_status that the handler writes
// synchronously, then a devpod CLI call in a tracked background goroutine,
Expand Down Expand Up @@ -223,18 +261,22 @@ func (h *Handler) runWorkspaceAction(sessionID uuid.UUID, workspaceID, repoURL s
// devpod up is idempotent against existing workspaces: it resumes
// a stopped container without rebuilding (verified — `--recreate`
// is the explicit opt-in for a fresh container).
actErr = h.workspaces.Create(ctx, workspaceID, repoURL, logFn)
var prebuilt workspace.PrebuildResult
prebuilt, actErr = h.workspaces.Create(ctx, workspaceID, repoURL, logFn)
if actErr == nil {
// (Re)provision agent tooling on every start so containers created
// before agent support — or where a prior install failed — pick up
// Pi + the ask-user extension. The installers are idempotent.
h.provisionAgentTools(ctx, workspaceID, logFn)
h.provisionAgentToolsIfNeeded(ctx, workspaceID, prebuilt, logFn)
h.restoreVSCodeServer(ctx, workspaceID, logFn)
newStatus = "ready"
} else {
newStatus = "failed"
}

case actionStop:
// Save before the container goes away, not after.
h.saveVSCodeServer(ctx, workspaceID, logFn)
actErr = h.workspaces.Stop(ctx, workspaceID)
if actErr == nil {
newStatus = "stopped"
Expand All @@ -246,13 +288,16 @@ func (h *Handler) runWorkspaceAction(sessionID uuid.UUID, workspaceID, repoURL s
// Delete + Create. Note: Manager.Delete uses CombinedOutput so its
// output is not streamed via logFn — the user will see logs once
// Create begins. Documented in the plan's Rebuild risk note.
var rebuilt workspace.PrebuildResult
h.saveVSCodeServer(ctx, workspaceID, logFn)
if delErr := h.workspaces.Delete(ctx, workspaceID); delErr != nil {
actErr = fmt.Errorf("rebuild delete: %w", delErr)
} else {
actErr = h.workspaces.Create(ctx, workspaceID, repoURL, logFn)
rebuilt, actErr = h.workspaces.Create(ctx, workspaceID, repoURL, logFn)
}
if actErr == nil {
h.provisionAgentTools(ctx, workspaceID, logFn)
h.provisionAgentToolsIfNeeded(ctx, workspaceID, rebuilt, logFn)
h.restoreVSCodeServer(ctx, workspaceID, logFn)
newStatus = "ready"
} else {
newStatus = "failed"
Expand All @@ -261,6 +306,10 @@ func (h *Handler) runWorkspaceAction(sessionID uuid.UUID, workspaceID, repoURL s
case actionDelete:
actErr = h.workspaces.Delete(ctx, workspaceID)
if actErr == nil {
// The cache belongs to the workspace; it must not outlive it.
if err := h.workspaces.PurgeVSCodeServer(workspaceID); err != nil {
slog.Warn("failed to purge vscode-server cache", "workspace", workspaceID, "error", err)
}
newStatus = "missing"
} else {
newStatus = "failed"
Expand Down
7 changes: 7 additions & 0 deletions server/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ func (s *Server) Router() http.Handler {
}

wm := workspace.NewManager(s.cfg.DevPodBin, s.cfg.DevPodProvider, s.cfg.GitHubToken)
// Only this manager creates workspaces; the sshproxy and reconciler
// managers in main.go never call Create, so they need no prebuild repo.
wm.SetPrebuildRepository(s.cfg.PrebuildRepository)
wm.SetVSCodeCacheDir(s.cfg.VSCodeCacheDir)
if s.cfg.PrebuildEnabled() {
slog.Info("devcontainer prebuild cache enabled", "repository", s.cfg.PrebuildRepository)
}
if !wm.Available() {
slog.Warn("devpod binary not found, workspace creation will be skipped")
} else {
Expand Down
Loading
Loading