feat(sandbox): sandbox controller in Go, studio talks to it over HTTP - #6187
Open
pedrofrxncx wants to merge 5 commits into
Open
feat(sandbox): sandbox controller in Go, studio talks to it over HTTP#6187pedrofrxncx wants to merge 5 commits into
pedrofrxncx wants to merge 5 commits into
Conversation
pedrofrxncx
force-pushed
the
feat/sandbox-controller-go
branch
from
August 18, 2026 17:18
e36928c to
e5b8228
Compare
added 3 commits
August 18, 2026 15:03
…ction
Review fixes on the controller split.
Blockers:
- Both auth checks failed open. An unset SANDBOX_CONTROLLER_TOKEN left the
clone-url callback unauthenticated on the public API (it mints live GitHub
App tokens) and the controller serving sandbox CRUD to anything that could
reach the port. Both now refuse to serve unless mTLS or a bearer is
configured; SANDBOX_CONTROLLER_INSECURE=1 is the explicit dev opt-out.
- proxyDaemonRequest retried a 401 with a consumed stream. Ported the
canRetryBody guard the in-process runner already carries.
- Preview lost resurrection under `remote`: the operator's idle TTL reaps a
claim under a still-open iframe and the provider 404'd it. Adds
Runner.Resurrect behind GET /sandboxes/{handle}?resurrect=1, opt-in so only
preview takes the side effect.
- controller-go was in no CI at all. Adds a vet/test/build job.
Also: a real contract test between protocol.go and its TS mirror — which found
that the mirror only covered the envelopes, leaving EnsureOptions/Repo/Tenant/
Workload hand-transcribed and unguarded; the three casts at
KyselySandboxProviderStateStore call sites replaced by one guarded by
`DB extends SandboxRunnerStateDatabase`; markTenantPoolsDirty behind a type
predicate; crypto.timingSafeEqual; buildRuntimes returns an error instead of
os.Exit; unused @types/pg dropped.
Removes three spec docs that belonged to other work (claim-identity-and-cost,
tenant-warm-pools, run-failure-recovery-findings).
…ds, no pool deadlock Three bugs found running the controller against a real k3s cluster with the agent-sandbox operator. SANDBOX_CONTROLLER_INSECURE=1 booted, logged that it was serving anonymously, then 401'd every request: `authorized` fell through to a bearer comparison against the empty string. The documented local-dev escape hatch was unusable. Covered by a table test. A port-forward outlives the pod behind it — client-go keeps the local listener up, so every later dial is connection-refused — and the cache key still matched after a reprovision, because a rebuilt Sandbox keeps its claim's name. Preview resurrection therefore burned the full 2m daemon timeout on a corpse and then failed, rolling back the sandbox it had just built. `provision` now drops the entry, and a forwarder whose stream dies evicts itself. `WithLock` pinned a pool connection for the whole callback while the callback's own reads and writes each took another, so `MaxConns` concurrent ensures — 4 on a small pod — would deadlock until their contexts expired. Queries now run on the lock's transaction when there is one, which is what the TypeScript store already did. Verified on k3s: 14/14 controller e2e, resurrection now returns a live daemon.
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.
Implements
packages/sandbox/sandbox-controller-spec.md: sandbox provisioningmoves out of the Studio API process into a separate Go service, so Studio's
ServiceAccount stops holding claim CRUD,
pods get/list/watch,pods/portforward, HTTPRoute CRUD and Service patch inagent-sandbox-system.What's here
packages/sandbox/controller-go/— its own Go module,net/http+encoding/json, no framework. Sibling todaemon-go/, same shape.protocol/provider/remote/protocol.tsruntime/agentsandbox/store/sandbox_runner_stateover pgx, incl. the advisory lockStudio keeps implementing
SandboxProvider:RemoteSandboxProvider(
STUDIO_SANDBOX_PROVIDER=remote) speaks HTTP to the controller. No caller inapps/apichanged.Control plane only
The controller answers where a sandbox's daemon is and what token opens
it; Studio's existing
proxyDaemonRequestdials the pod. Nothing is relayed,so streaming dispatch bodies, preview SSE and websocket upgrades are the same
traffic they were before the split, and the controller never reimplements any
of it.
With a preview gateway that address is in-cluster Service DNS. Without one it
is a controller-held port-forward returning
http://127.0.0.1:<port>— correctexactly when controller and Studio share a host, which is the local-dev case.
One definition of the daemon's boot contract
daemon-go/pkg/protocolis the daemon's first exported package;internal/confignow aliases those types instead of owning them, so a shapechange is a compile error on both ends. That is the concrete payoff of both
services being Go, and it retires the hand-written duplicate in
server/daemon-client.ts+shared/build-config-payload.tsfor the configpath.
Deviation from the spec
The clone-credential callback names
{connectionId, cloneUrl}, not a handle,and Studio verifies the pair against a live sandbox row or a configured warm
pool before minting. A handle could not be threaded through
withFreshCloneUrl's five call sites — two are warm-pool paths with no handleat all. The verification closes the same oracle the spec was guarding against:
you can only refresh credentials for repos a sandbox already has, which is what
the caller could read off the persisted clone URL anyway.
Testing
packages/sandbox/controller-e2e/is the black-box suite: spawn the binary,drive it over HTTP through the same client Studio uses, assert only on
responses. 13/13 against a k3s cluster running the agent-sandbox operator —
real sandbox provisioned through the contract, lifecycle streamed to
ready,daemon reached directly at the returned address (and rejecting a wrong token),
TTL patched both directions, deleted with a real drain.
Skipped when the env var is unset, so
bun teststays green without a cluster.Placement gets ordinary Go table tests (
runtime/runtime_test.go).The Service-DNS path was verified separately, because a controller running
outside the cluster cannot resolve the name it correctly emits: with
previewUrlPatternset the controller returnedhttp://<sandbox>.agent-sandbox-system.svc.cluster.local:9000, and afetchfrom inside the Studio pod got
200off the daemon's/health. That is thepath the spec called an unproven future hardening pass.
That exercise found a real bug.
ensureServicePortwas gated on thepreview gateway, but a ports-less Service routes nothing at all — not through
Envoy and not through kube-proxy — so Service DNS to the daemon needs the port
whether or not a gateway exists. The Go runtime gates it on
previewUrlPatternalone. The TypeScript runner could conflate the two because it only ever
reached the daemon by port-forward.
bun run check,bun run lint(0 errors),bun test packages/sandbox/,go vet ./...andgo test ./...in both Go modules all pass.Not in this PR
than done: the controller reaches the daemon over Service DNS itself, and
Studio under
remotehas no port-forward to kill. Hardeningrunner.tstoowould harden an implementation step 5 deletes.
rotation to a per-claim token — is ported and exercised; per-org pools are
not, so
STUDIO_SANDBOX_TENANT_POOLSis ignored by the Go runtime today.dockerruntime. The registry, probing,/runtimesand placementall landed with
agent-sandboxas the only member, so adding it is oneimplementation behind an interface that already has tests.
sandbox-rbac.yamlRole. That deletion is rollout step 5 and must land inthe same PR that flips the flag, or this has made things worse (two
implementations) rather than better.
Default stays
agent-sandboxin-process; nothing in production changes untilSTUDIO_SANDBOX_PROVIDER=remoteis set, and that flip is a drained cutover,not a rolling deploy (see the spec's State section).
Summary by cubic
Moves sandbox provisioning out of Studio into a standalone Go controller and adds a
remoteSandboxProviderthat talks to it over HTTP, removing Studio’s Kubernetes/CRD and port‑forward privileges. Behavior change: Studio no longer provisions directly (old); it asks the controller for the daemon address and token and talks to the daemon itself (new). Side effects: both controller and Studio callback fail closed unless mTLS or a bearer token is configured; preview resurrection is restored.New
packages/sandbox/controller-go: HTTP server withagent-sandboxruntime (placement and capacity probe, lifecycle phases stream, TTL renew, termination reason); fixes Service‑DNS port routing; port‑forward fallback for local dev now evicts dead forwards and drops stale entries on reprovision; Postgres store forsandbox_runner_statewithWithLockqueries running on the transaction to avoid pool deadlocks; CI vet/test/build job added; black‑box e2e inpackages/sandbox/controller-e2e(opt‑in viaCONTROLLER_E2E_URL), green on k3s.One boot contract: daemon config/health types exported at
packages/sandbox/daemon-go/pkg/protocol, mirrored inpackages/sandbox/server/provider/remote/protocol.ts; a Go↔TS contract test now covers envelopes and payload types.RemoteSandboxProvider(packages/sandbox/server/provider/remote): ensure/delete/status/capacity/lifecycle phases; preserves direct daemon proxying; adds opt‑in resurrection viaGET /sandboxes/{handle}?resurrect=1; guards 401 retries withcanRetryBody.API mounts controller routes at
apps/api/src/api/routes/sandbox-controller.tsfor clone‑credential re‑mint usingtimingSafeEqual; both controller and callback now refuse requests unless mTLS or a bearer is configured (dev opt‑outSANDBOX_CONTROLLER_INSECURE=1).Store reuse and types:
KyselySandboxProviderStateStoremoved to@decocms/sandbox/provider/kysely-state-storewith a guarded DB shape; adds"remote"toSandboxProviderKindacross settings, tools, and SDK; GitHub webhook tenant‑pool refresh behind a type predicate; handle slugification avoids ReDoS by replacing trailing‑quantifier regexes with a linear scan; connection create test stubs MCP tool fetch to avoid network timeouts.Rollout
STUDIO_SANDBOX_PROVIDER=remoteis set.http://127.0.0.1:<port>.Written for commit 08cae04. Summary will update on new commits.