Skip to content

fix(sandbox): pin image version + chown /workspace + GHCR release pipeline - #72

Merged
dviejokfs merged 4 commits into
mainfrom
fix/sandbox-images-and-workspace-ownership
May 3, 2026
Merged

dviejokfs merged 4 commits into
mainfrom
fix/sandbox-images-and-workspace-ownership

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

Summary

Three related fixes that converge on one prod symptom: workspaces failing to come up behind HTTPS with frame too large: 1329809696 bytes (the ASCII bytes OCI from a Docker exec error).

  • /workspace ownership: post-start chown -R temps:temps /workspace exec, mirror of the existing /home/temps pattern. Fixes root:root ownership on bind-mounted host work_dir when the temps server runs as root.
  • Pinned sandbox image version: replace hard-coded :latest with SANDBOX_IMAGE_VERSION constant (currently 0.1.0). :latest short-circuits in ensure_image_for_runtime because inspect_image returns Ok for any cached tag — bumping the constant is now what forces hosts to re-pull. Registry switched from Docker Hub to GHCR (ghcr.io/gotempsh/).
  • GHCR release pipeline: two new jobs in release.yml, triggered on the existing v*.*.* tags — build-and-push-sandbox-images (matrix over 6 runtimes, multi-arch) and build-and-push-preview-gateway. Both read versions from source-of-truth files so tag drift is impossible.

Root cause analysis

The user reported the agent terminal failing with frame too large: 1329809696 bytes. Decoding that integer as 4 BE bytes gives b"OCI " — the start of OCI runtime exec failed: ... from Docker. The handler was reading Docker's error string as a u32 frame length header.

The proximate cause was socat missing from gotempsh/temps-sandbox-node:latest on Docker Hub. The image source has socat in its apt-get install line — but the published :latest was built from an older revision, and :latest never re-pulls once cached locally. Same root cause covers /workspace being owned by root in the Hub image (image had USER temps, but bind-mount inherits host UID, and the platform creates the host dir as root).

Fixes in detail

1. /workspace chown (crates/temps-agents/src/sandbox/docker.rs)

Mirror of the existing /home/temps chown that runs as 0:0 immediately after container start. Uses the CHOWN+FOWNER caps already added to the container.

2. Pinned image version

SANDBOX_IMAGE_VERSION constant in docker.rs is the single source of truth. The runtime pulls images at this exact tag, so bumping the constant + shipping a release that publishes the new tag is the entire upgrade flow.

3. Release workflow

Triggered only on release tags (v*.*.*). Reads SANDBOX_IMAGE_VERSION directly from docker.rs for sandbox images and from Cargo.standalone.toml for the preview gateway — no manual tag input, no drift between the runtime and what gets published.

Also included

UI fix in RepositoryList.tsx: snake_case → camelCase API field rename (preset_label → presetLabel) and updated_at → pushed_at for the recency sort.

Test plan

  • Tag a release (v0.0.X) and verify the new jobs publish all 7 images to ghcr.io/gotempsh/
  • Verify ghcr.io/gotempsh/temps-sandbox-node:0.1.0 contains socat and has temps:temps ownership when started
  • On a prod host: pull the new server build, recreate a workspace sandbox, confirm ls -la /workspace shows temps:temps and the agent terminal opens without "frame too large"
  • Existing cargo test --lib -p temps-agents -- sandbox::docker:: still passes (verified locally: 22/22)

🤖 Generated with Claude Code

dviejokfs added 4 commits May 3, 2026 13:27
…eline

Three related fixes that converge on one prod symptom: workspaces failing
to come up behind HTTPS with "frame too large: 1329809696 bytes" (the
ASCII bytes "OCI " from a Docker exec error).

1. /workspace ownership (root cause of the ownership symptom)
   The temps server runs as root in prod, so the host work_dir it creates
   for bind-mount is root-owned. Inside the container, USER temps then
   can't write anywhere in /workspace — TUIs fail, dev servers can't
   open lockfiles, and any seed performed via docker exec runs as the
   image's USER but inherits the host inode UID.
   Fix: post-start `chown -R temps:temps /workspace` exec as root, mirror
   of the existing /home/temps pattern. CHOWN+FOWNER caps are already on
   the container.

2. Pinned sandbox image version (root cause of the missing-socat symptom)
   `:latest` short-circuits in `ensure_image_for_runtime` because
   `inspect_image` returns Ok for any cached tag. Once a host has a
   stale :latest, it never re-pulls. Replace the hard-coded :latest with
   `SANDBOX_IMAGE_VERSION` (currently "0.1.0") so bumping the constant
   forces every host to pull the new tag.
   Registry switched from Docker Hub (gotempsh/) to GHCR
   (ghcr.io/gotempsh/) per ops decision.

3. Release pipeline (long-term DX so this doesn't recur)
   Two new jobs in release.yml, triggered on the existing v*.*.* tags:
   - build-and-push-sandbox-images: matrix over the 6 runtimes, builds
     multi-arch (amd64+arm64), tags :<SANDBOX_IMAGE_VERSION>, :latest,
     and :<sha>. Reads the version directly from docker.rs.
   - build-and-push-preview-gateway: same shape, version comes from
     Cargo.standalone.toml.

Also includes UI fix in RepositoryList.tsx (snake_case→camelCase API
field rename + updated_at→pushed_at sorting).
Workspace sessions emit URLs of shape `ws-<16hex>-<port>.<domain>` because
their `public_id` is `wss_<16hex>` and `host_for()` strips the prefix.
But `parse_preview_host` is synchronous (can't hit the DB) and routes any
16-hex label to `PreviewTarget::Sandbox` — the lookup then misses in the
sandboxes table and returns 404 "Workspace preview not found", even
though the workspace exists.

Fix: add `resolve_preview_target` that runs after parsing. If the target
is `Sandbox(hex)`, query `workspace_sessions` for `public_id = wss_<hex>`
first; if found, swap to `WorkspaceSession(row.id)`. Otherwise leave it
as a sandbox lookup. One extra unique-index hit per request — negligible.

Also: the 404 body said "Workspace preview not found" for sandbox
lookups too. Genericized to "Preview not found" since both targets land
in the same branch.

_typos.toml: allow the W3C traceparent spec example span-id used in
existing test fixtures (the hex string contains "ba" which the spell
checker mistakes for "by"/"be").

Repro: hit any `https://ws-<16hex>-<port>.<domain>/` URL where the hex
matches a workspace session — pre-fix returns 404, post-fix returns the
preview login page (or proxies through if cookie is valid).
Two follow-ups on the preview-routing fix:

1. NotConfigured outcome (was indistinguishable from NotFound)
   `lookup_preview_session` previously returned `NotFound` for both
   "session row doesn't exist" and "session exists but
   `preview_password_hash` is NULL". Both manifested as the same cryptic
   404, leaving users to guess whether the workspace was deleted or the
   preview was just unconfigured.

   Split into a new `PreviewSessionLookup::NotConfigured` variant that
   propagates through `check_preview_auth` as
   `PreviewAuthOutcome::NotConfigured`. The proxy renders a small HTML
   page at 409 Conflict ("This workspace exists, but no preview password
   has been set — set one in the workspace settings") instead of the
   generic 404. The inline POST /__temps/preview/login handler also
   returns 409 with a plaintext fallback for non-browser clients.

   Sandbox targets are unaffected — sandboxes treat "no password" as
   `Allow` (the unguessable hex IS the gate), so this state only applies
   to workspace sessions.

2. Tests for resolve_preview_target
   Four `#[tokio::test]` cases using `MockDatabase` cover:
   - hex matches a workspace_sessions row → swap to WorkspaceSession(id)
   - hex doesn't match → leave as Sandbox(hex)
   - already a WorkspaceSession (digit URL) → pass through (no DB query)
   - DB error → log + degrade to Sandbox(hex), never panic

   All 4 pass; full preview_auth suite is 19/19.
Beta releases were sharing the canonical `:<version>` ref with stable, so
a beta whose Dockerfile diverged could overwrite the image a stable host
expected to find at the same SANDBOX_IMAGE_VERSION. Split the channels:

  stable (vN.N.N)        -> :<ver>, :<ver>-stable, :latest, :stable, :<sha>
  prerelease (vN.N.N-x)  -> :<ver>-beta, :beta, :<sha>   (never unsuffixed)

Server image (`temps`) gets the same `:beta` vs `:latest` split via the
manifest-merge job; per-platform intermediate tags now use the channel
name instead of a hardcoded `latest-`.

Runtime: new SandboxChannel enum, default Stable. Beta hosts opt in via
TEMPS_SANDBOX_CHANNEL=beta so the pull-cache path resolves to the beta
image stream. hub_image_for_runtime_in_channel() is the testable seam;
the env-reading wrapper stays for production callers.

scripts/build-sandbox-images.sh defaults to SANDBOX_CHANNEL=beta so an
accidental local push never poisons stable.
@dviejokfs
dviejokfs merged commit 012543c into main May 3, 2026
8 checks passed
@dviejokfs
dviejokfs deleted the fix/sandbox-images-and-workspace-ownership branch May 3, 2026 18:06
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