Skip to content

feat(preview): optional hosted sandboxes, off by default - #6212

Merged
nicacioliveira merged 3 commits into
mainfrom
feat/preview-sandbox
Aug 19, 2026
Merged

feat(preview): optional hosted sandboxes, off by default#6212
nicacioliveira merged 3 commits into
mainfrom
feat/preview-sandbox

Conversation

@nicacioliveira

@nicacioliveira nicacioliveira commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

A preview cannot drive agents today. SANDBOX_START creates a SandboxClaim through the Kubernetes API, and the preview's ServiceAccount has no rights in the sandbox namespace:

KubeHttpError: sandboxclaims.extensions.agents.x-k8s.io "..." is forbidden
  status: 403, reason: "Forbidden"
[lifecycle-watcher] pod/... stopped: watch denied

Observed on #6205.

What this grants, plainly

The Role being bound lives in agent-sandbox-system, which holds the sandboxes of every environment on the cluster, production included:

studio-sandbox-runner-prod → ServiceAccount/deco-studio/deco-studio
studio-sandbox-runner-stg  → ServiceAccount/deco-studio-stg/deco-studio-stg

Kubernetes RBAC has no label scoping. A preview bound there gets pods/portforward, and delete/patch on HTTPRoutes, across all sandboxes in that namespace — not only the ones it creates. The housekeeper's CLAIM_SELECTOR separates what it sweeps; it does not separate what a ServiceAccount can touch.

A preview runs un-merged code from any PR carrying the preview label. So enabling this makes a label on a PR sufficient to reach production sandboxes. That is the trade, and it is stated at the top of the template rather than buried in a values comment.

Default is false. Nothing changes unless someone turns it on.

Why it cannot be narrowed today

The sandbox namespace is hardcoded — in the application, and across 16 files of the sandbox-env chart — on the premise recorded in lifecycle.ts:

template name with envName so multiple envs share agent-sandbox-system

A sandbox-env installed into its own namespace would need application changes, not just a different helm install. The move to a controller API instead of the Kubernetes API removes the need entirely, by replacing cluster RBAC with a tenant-scoped token.

Two guards

Both cover a render that would look healthy and grant the wrong thing:

guard why
serviceAccount.create must be true otherwise the binding lands on the namespace's default SA, giving sandbox access to every pod in the preview — Postgres and MinIO included
roleName must be set an empty roleRef binds nothing, silently

Asserted in helm-test.yml on the message text, not just the exit code.

Permission and configuration are separate

Enabling preview.sandbox.enabled grants rights and changes no behaviour. The preview still needs the STUDIO_SANDBOX_* block pointed at an existing environment — values-preview.yaml now spells out exactly which settings, and notes that without STUDIO_SANDBOX_SENTINEL_TOKEN the claim takes the cold-start path rather than borrowing the shared warm pool.

Side effect worth having

Previews now run under a named ServiceAccount instead of default. Postgres and MinIO stay on default, so even with sandboxes enabled they hold no sandbox rights — only the app, the worker and the migrate Job do.

Verification

  • Default render outside preview: 0 lines of difference against main.
  • Preview render with sandboxes off: no RoleBinding, ServiceAccount present.
  • Preview render with sandboxes on: binding targets the release SA in the release namespace.
  • Both guards fail with the intended message.
  • helm lint clean.

Summary by cubic

Adds optional hosted agent sandboxes for previews. Previously SANDBOX_START failed with 403; now, when enabled, previews can claim and drive sandboxes. Default stays off to avoid giving preview code reach into production sandboxes.

  • Binds the preview ServiceAccount to an existing Role in agent-sandbox-system, granting pods/portforward and HTTPRoute delete/patch across that namespace.
  • Validations require serviceAccount.create=true and a non-empty preview.sandbox.roleName; CI asserts both failure paths.
  • Permission is separate from app config: preview.sandbox.enabled=true only grants rights; the app must still set STUDIO_SANDBOX_* (provider, runner, template, gateway) to a real environment.
  • Previews now run under a named ServiceAccount; Postgres and MinIO remain on default.

Rollout

  • Off by default; no change unless you opt in.
  • To enable: set preview.sandbox.enabled=true, preview.sandbox.namespace (defaults to agent-sandbox-system), and preview.sandbox.roleName to a Role from the sandbox-env chart; also configure the STUDIO_SANDBOX_* block.

Written for commit 4cdbe21. Summary will update on new commits.

Review in cubic

A preview cannot drive agents today: SANDBOX_START creates a SandboxClaim
through the Kubernetes API and the preview's ServiceAccount has no rights in the
sandbox namespace, so it fails with a 403 that surfaces as an unhandled
rejection.

This adds the binding, and keeps it off by default, because what it grants is
not narrow. The Role lives in a namespace shared by every environment on the
cluster, production included, and Kubernetes RBAC has no label scoping — so a
preview bound there reaches production sandboxes too. A preview runs un-merged
code from any PR carrying a label. That is the whole trade, and it is written at
the top of the template rather than buried in a values comment.

It is shaped this way because the sandbox namespace is hardcoded in the
application and across the sandbox-env chart, on the premise that multiple envs
share it. A namespace-per-environment, or the move to a controller API with a
scoped token, removes the need for this entirely.

Two guards, because both failures render something that looks correct:
serviceAccount.create must be on (otherwise the binding lands on  and
every pod in the preview gets sandbox access, Postgres and MinIO included), and
roleName must be set (an empty roleRef binds nothing).

Permission and configuration stay separate: enabling this grants rights but
changes no behaviour until the STUDIO_SANDBOX_* block is pointed at an existing
environment, which values-preview.yaml now spells out.
…ables

values-preview.yaml now sets serviceAccount.create=true, so the negative case
rendered successfully and the assertion reported a missing guard.
@nicacioliveira
nicacioliveira merged commit 963fa42 into main Aug 19, 2026
31 checks passed
@nicacioliveira
nicacioliveira deleted the feat/preview-sandbox branch August 19, 2026 01:31
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