Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f82e5a7
feat(sandbox): add kubernetes runner
pedrofrxncx Apr 24, 2026
5242f90
chore: update dependencies and versions in bun.lock
pedrofrxncx Apr 24, 2026
4c63497
feat(sandbox): background bootstrap with live SSE log streaming
pedrofrxncx Apr 25, 2026
5e9a975
Merge branch 'main' into feat/k8s-sandbox
pedrofrxncx Apr 28, 2026
c49a352
rm
pedrofrxncx Apr 28, 2026
035704c
Add .gitattributes for Helm chart and update README, reload-image.sh,…
pedrofrxncx Apr 28, 2026
ca41893
Enhance logging in Broadcaster and refactor KubernetesSandboxRunner
pedrofrxncx Apr 28, 2026
d7866ca
Implement WebSocket proxying and enhance port discovery in daemon
pedrofrxncx Apr 28, 2026
ad50a78
Enhance sandbox configuration in Helm charts
pedrofrxncx Apr 28, 2026
f7c2bd7
Merge branch 'main' of https://github.com/decocms/studio into feat/k8…
pedrofrxncx Apr 28, 2026
c498543
Enhance sandbox runner configuration for Kubernetes
pedrofrxncx Apr 28, 2026
b07193e
Implement sandbox preview proxy and enhance WebSocket handling
pedrofrxncx Apr 28, 2026
3eb7005
Refactor monitoring configurations for Kubernetes
pedrofrxncx Apr 28, 2026
3df90f7
Update dependencies and enhance sandbox monitoring configurations
pedrofrxncx Apr 28, 2026
738608a
Merge branch 'main' of https://github.com/decocms/studio into feat/k8…
pedrofrxncx Apr 28, 2026
8235065
Update Helm chart values to set default nodeSelector for amd64 archit…
pedrofrxncx Apr 28, 2026
e38bee6
Remove scheduled_tasks.lock file as it is no longer needed for task m…
pedrofrxncx Apr 28, 2026
e89dda9
Add release workflow for Studio Sandbox image and update references
pedrofrxncx Apr 28, 2026
34ada69
Refactor sandbox runner references from Kubernetes to agent-sandbox
pedrofrxncx Apr 28, 2026
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
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Vendored upstream Helm chart for kubernetes-sigs/agent-sandbox.
# Refreshed via deploy/helm/charts/agent-sandbox/vendor.sh — do not hand-edit.
# Marking as generated so GitHub collapses the diff in PRs and excludes it
# from language stats; bumps are still reviewable by reading vendor.sh's
# version arg.
deploy/helm/charts/agent-sandbox/crds/** linguist-generated=true
deploy/helm/charts/agent-sandbox/templates/** linguist-generated=true
deploy/helm/charts/agent-sandbox-*.tgz binary linguist-generated=true
78 changes: 78 additions & 0 deletions .github/workflows/release-studio-sandbox.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Release Studio Sandbox Image

on:
push:
branches: [main]
paths:
- "packages/sandbox/**"
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/mesh-sandbox

jobs:
build-push:
name: Build & Push mesh-sandbox image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.5"

- name: Install dependencies
run: bun install

# The Dockerfile copies daemon/dist/daemon.js into the image, so the
# bundle has to exist before `docker build` runs.
- name: Build daemon bundle
run: bun run --cwd=packages/sandbox build

- name: Read sandbox version
id: version
run: |
VERSION=$(bun -e "console.log(require('./packages/sandbox/package.json').version)")
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ steps.version.outputs.version }}
type=raw,value=latest
type=sha,format=short

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./packages/sandbox
file: ./packages/sandbox/image/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
2 changes: 1 addition & 1 deletion apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ async function buildAllTools(
// VM file tools — same six LLM-visible tools across runners (schemas in
// vm-tools/schemas.ts). Dispatch resolves through `getRunnerByKind` so
// the entry's recorded runnerKind drives the routing, regardless of the
// current MESH_SANDBOX_RUNNER env value. When no entry exists, fall back
// current STUDIO_SANDBOX_RUNNER env value. When no entry exists, fall back
// to the QuickJS `sandbox` tool — VM_START must run first for file tools.
const vmNeedsApproval =
toolNeedsApproval(toolApprovalLevel, false, approvalOpts) !== false;
Expand Down
5 changes: 3 additions & 2 deletions apps/mesh/src/api/routes/decopilot/stream-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ async function streamCoreInner(
{
vmId: string;
previewUrl: string;
runnerKind?: "docker" | "freestyle";
runnerKind?: "docker" | "freestyle" | "agent-sandbox";
}
>
>;
Expand All @@ -490,7 +490,8 @@ async function streamCoreInner(
? {
runnerKind: (activeVmEntry.runnerKind ?? "freestyle") as
| "docker"
| "freestyle",
| "freestyle"
| "agent-sandbox",
vmId: activeVmEntry.vmId,
}
: null;
Expand Down
2 changes: 1 addition & 1 deletion apps/mesh/src/cli/build-child-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function buildChildEnv(
FIRECRAWL_API_KEY: settings.firecrawlApiKey,

// Sandbox runner: read from env by resolveRunnerKindFromEnv() in workers
MESH_SANDBOX_RUNNER: process.env.MESH_SANDBOX_RUNNER,
STUDIO_SANDBOX_RUNNER: process.env.STUDIO_SANDBOX_RUNNER,
FREESTYLE_API_KEY: process.env.FREESTYLE_API_KEY,

// Browserless
Expand Down
65 changes: 65 additions & 0 deletions apps/mesh/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ function withSecurityHeaders(res: Response): Response {
// Closed early in gracefulShutdown so the port frees before the Hono drain.
let ingressServers: import("node:net").Server[] = [];

// Sandbox preview reverse-proxy (agent-sandbox only). The base domain is parsed at
// boot from STUDIO_SANDBOX_PREVIEW_URL_PATTERN; null disables the proxy and
// preview-host requests fall through to the normal mesh routing (which 404s
// because nothing matches). The Bun-level WS handler is registered
// unconditionally — when previewBaseDomain is null, no upgrade path runs it.
const {
parsePreviewBaseDomain,
tryHandlePreviewHttp,
tryUpgradePreviewWs,
previewWebSocketHandler,
isPreviewWsData,
} = await import("./sandbox/preview-proxy");
const { getOrInitSharedRunner: getOrInitRunnerForPreview } = await import(
"./sandbox/lifecycle"
);
const previewBaseDomain = parsePreviewBaseDomain(
process.env.STUDIO_SANDBOX_PREVIEW_URL_PATTERN,
);
const previewProxyDeps = {
baseDomain: previewBaseDomain ?? "",
getRunner: async () => {
const runner = await getOrInitRunnerForPreview();
if (!runner || runner.kind !== "agent-sandbox") return null;
// The agent-sandbox runner is the only one that exposes proxyPreviewRequest /
// resolvePreviewUpstreamUrl; cast is safe after the kind check.
return runner as unknown as import("@decocms/sandbox/runner/agent-sandbox").AgentSandboxRunner;
},
};

// Docker-only boot/dev wiring. Both hooks (boot sweep + local ingress) are
// intimate with Docker-specific primitives (labels, host-port mappings);
// other runners manage their own VM/ingress lifecycle.
Expand Down Expand Up @@ -140,12 +169,48 @@ const server = Bun.serve({
hostname: "0.0.0.0", // Listen on all network interfaces (required for K8s)
reusePort,
fetch: async (request, server) => {
// Sandbox preview proxy: matched by Host header. Runs *before* assets
// and the Hono app so a `<handle>.preview.<base>` request never hits
// mesh's static-file handler (which would 404 on the dev server's
// bundle paths). WS upgrades short-circuit Bun.serve's fetch by
// returning undefined; HTTP returns a Response.
if (previewBaseDomain) {
// Bun's Server type defaults T=undefined for upgrade<T>(); cast widens
// to our PreviewWsData carrier so the WS handler can stash it. Bun
// doesn't enforce data-type consistency at runtime, only via generics.
const upgradeRes = await tryUpgradePreviewWs(
request,
server as unknown as Parameters<typeof tryUpgradePreviewWs>[1],
previewProxyDeps,
);
if (upgradeRes === undefined) return; // upgraded
if (upgradeRes) return upgradeRes; // pre-upgrade error
const httpRes = await tryHandlePreviewHttp(request, previewProxyDeps);
if (httpRes) return httpRes;
}

// Try assets first (static files or dev proxy), then API
// Pass server as env so Hono's getConnInfo can access requestIP
const assetRes = await handleAssets(request);
if (assetRes) return withSecurityHeaders(assetRes);
return app.fetch(request, { server });
},
// Multiplexed WebSocket handler. `ws.data.kind` discriminates which
// upgrader stashed the payload — preview is the only producer today; new
// upgraders should add a tagged `kind` and a branch here.
websocket: {
open(ws) {
if (isPreviewWsData(ws.data)) previewWebSocketHandler.open(ws);
},
message(ws, message) {
if (isPreviewWsData(ws.data)) {
previewWebSocketHandler.message(ws, message);
}
},
close(ws) {
if (isPreviewWsData(ws.data)) previewWebSocketHandler.close(ws);
},
},
development: settings.nodeEnv !== "production",
});

Expand Down
84 changes: 74 additions & 10 deletions apps/mesh/src/sandbox/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Runner singletons, one per kind. VM_DELETE dispatches on the entry's
* recorded runnerKind (not env), so a pod that flipped MESH_SANDBOX_RUNNER
* recorded runnerKind (not env), so a pod that flipped STUDIO_SANDBOX_RUNNER
* between start and stop still tears down the right kind of VM.
* Boot/shutdown sweeps are Docker-only — other runners' sandboxes outlive
* mesh by design, so a generic sweep would nuke active user VMs.
Expand All @@ -14,18 +14,57 @@ import {
type RunnerKind,
type SandboxRunner,
} from "@decocms/sandbox/runner";
import { getDb } from "@/database";
import type { Kysely } from "kysely";
import { meter } from "@/observability";
import type { Database as DatabaseSchema } from "@/storage/types";
import { KyselySandboxRunnerStateStore } from "@/storage/sandbox-runner-state";

const runners: Partial<Record<RunnerKind, SandboxRunner>> = {};
// In-flight instantiate() promises, memoized per kind. Two concurrent
// callers on a cold mesh would otherwise both miss the resolved-runner
// cache and both call instantiate(); memoizing the promise (and only
// promoting to `runners` once it resolves) collapses them to a single
// build. Cleared on failure so a retry can take a fresh swing.
const inflight: Partial<Record<RunnerKind, Promise<SandboxRunner>>> = {};

function resolveOnce(
kind: RunnerKind,
build: () => Promise<SandboxRunner>,
): Promise<SandboxRunner> {
const cached = runners[kind];
if (cached) return Promise.resolve(cached);
const pending = inflight[kind];
if (pending) return pending;
const promise = build()
.then((runner) => {
runners[kind] = runner;
return runner;
})
.finally(() => {
delete inflight[kind];
});
inflight[kind] = promise;
return promise;
}

// Set in prod (k8s/docker behind ingress) so the runner skips the local
// 127.0.0.1 port-forward path and emits a URL the user's browser can
// actually reach. Empty/unset = local forwarder fallback (dev).
function readPreviewUrlPattern(): string | undefined {
const raw = process.env.STUDIO_SANDBOX_PREVIEW_URL_PATTERN;
return raw && raw.trim() !== "" ? raw : undefined;
}

async function instantiate(
kind: RunnerKind,
ctx: MeshContext,
db: Kysely<DatabaseSchema>,
): Promise<SandboxRunner> {
const stateStore = new KyselySandboxRunnerStateStore(ctx.db);
const stateStore = new KyselySandboxRunnerStateStore(db);
const previewUrlPattern = readPreviewUrlPattern();
switch (kind) {
case "docker":
return new DockerSandboxRunner({ stateStore });
return new DockerSandboxRunner({ stateStore, previewUrlPattern });
case "freestyle": {
// Dynamic import — freestyle SDK is an optionalDependency so
// docker-only deploys don't need it installed.
Expand All @@ -34,6 +73,22 @@ async function instantiate(
);
return new FreestyleSandboxRunner({ stateStore });
}
case "agent-sandbox": {
// Dynamic import — @kubernetes/client-node is heavy and only needed
// when STUDIO_SANDBOX_RUNNER=agent-sandbox. Docker/Freestyle deploys never
// load it.
const { AgentSandboxRunner } = await import(
"@decocms/sandbox/runner/agent-sandbox"
);
// `meter` is reassigned by initObservability() after sdk.start(); read
// it at runner construction (post-init) so we get the real instruments
// not the no-op evaluated at module load.
return new AgentSandboxRunner({
stateStore,
previewUrlPattern,
meter,
});
}
default: {
const exhaustive: never = kind;
throw new Error(`Unknown runner kind: ${String(exhaustive)}`);
Expand All @@ -46,15 +101,24 @@ export function getSharedRunner(ctx: MeshContext): Promise<SandboxRunner> {
}

/** VM_DELETE uses this so teardown follows the entry's recorded runnerKind. */
export async function getRunnerByKind(
export function getRunnerByKind(
ctx: MeshContext,
kind: RunnerKind,
): Promise<SandboxRunner> {
const cached = runners[kind];
if (cached) return cached;
const runner = await instantiate(kind, ctx);
runners[kind] = runner;
return runner;
return resolveOnce(kind, () => instantiate(kind, ctx.db));
}

/**
* Eager runner accessor for paths that need the runner before any user
* request — preview-host proxying at the Bun.serve layer is the only caller
* today. Reads the runner kind from env and constructs without a
* MeshContext (the state store only needs a Kysely instance). Returns null
* when no runner kind is configured.
*/
export async function getOrInitSharedRunner(): Promise<SandboxRunner | null> {
const kind = tryResolveRunnerKindFromEnv();
if (!kind) return null;
return resolveOnce(kind, () => instantiate(kind, getDb().db));
}

/**
Expand Down
Loading
Loading