From ac516405de0eb2a644177cbaff167546f46a64c2 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 21:52:59 +0200 Subject: [PATCH 01/21] feat(host-service): extract WorkspaceRuntime abstraction for PTY launches Move the inline shell/args/env computation in createTerminalSessionInternal behind a WorkspaceRuntime interface (prepare/buildPtyLaunch/getAgentHookUrl/ destroy). HostRuntime reproduces today's behavior exactly; a Docker sandbox runtime will plug in behind the same seam. No behavior change. --- .../src/runtime/sandbox/host-runtime.test.ts | 140 ++++++++++++++++++ .../src/runtime/sandbox/host-runtime.ts | 82 ++++++++++ .../src/runtime/sandbox/registry.ts | 14 ++ .../src/runtime/sandbox/workspace-runtime.ts | 53 +++++++ .../host-service/src/terminal/terminal.ts | 59 ++------ 5 files changed, 303 insertions(+), 45 deletions(-) create mode 100644 packages/host-service/src/runtime/sandbox/host-runtime.test.ts create mode 100644 packages/host-service/src/runtime/sandbox/host-runtime.ts create mode 100644 packages/host-service/src/runtime/sandbox/registry.ts create mode 100644 packages/host-service/src/runtime/sandbox/workspace-runtime.ts diff --git a/packages/host-service/src/runtime/sandbox/host-runtime.test.ts b/packages/host-service/src/runtime/sandbox/host-runtime.test.ts new file mode 100644 index 00000000000..0468b5575b2 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/host-runtime.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + buildV2TerminalEnv, + getShellLaunchArgs, + getTerminalBaseEnv, + initTerminalBaseEnv, + resetTerminalBaseEnvForTests, + resolveLaunchShell, + shellLaunchExpectsReadyMarker, +} from "../../terminal/env.ts"; +import { getHostAgentHookUrl, HostRuntime } from "./host-runtime.ts"; +import { getWorkspaceRuntime } from "./registry.ts"; + +const ENV_KEYS = [ + "SUPERSET_HOME_DIR", + "HOST_SERVICE_PORT", + "PORT", + "SUPERSET_AGENT_HOOK_PORT", + "SUPERSET_AGENT_HOOK_VERSION", +] as const; + +const BASE_ENV = { + SHELL: "/bin/bash", + HOME: "/Users/someone", + LANG: "en_US.UTF-8", + PATH: "/usr/bin:/bin", +}; + +const LAUNCH_CONTEXT = { + terminalId: "term-1", + workspaceId: "ws-1", + workspacePath: "/tmp/worktrees/ws-1", + rootPath: "/tmp/repo", + cwd: "/tmp/worktrees/ws-1/sub", + themeType: "light" as const, +}; + +describe("HostRuntime", () => { + let savedEnv: Partial>; + let homeDir: string; + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + homeDir = mkdtempSync(path.join(tmpdir(), "superset-host-runtime-")); + mkdirSync(path.join(homeDir, "bash"), { recursive: true }); + // Same literal the desktop bash wrapper writes; shell-launch checks for + // this exact marker text to report readiness support. + writeFileSync( + path.join(homeDir, "bash", "rcfile"), + `printf '\\033]133;A\\007'\n`, + ); + process.env.SUPERSET_HOME_DIR = homeDir; + process.env.HOST_SERVICE_PORT = "48123"; + process.env.SUPERSET_AGENT_HOOK_PORT = "51741"; + process.env.SUPERSET_AGENT_HOOK_VERSION = "4"; + resetTerminalBaseEnvForTests(); + initTerminalBaseEnv(BASE_ENV); + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetTerminalBaseEnvForTests(); + rmSync(homeDir, { recursive: true, force: true }); + }); + + test("buildPtyLaunch reproduces the pre-refactor daemon.open spec exactly", async () => { + const spec = await new HostRuntime().buildPtyLaunch(LAUNCH_CONTEXT); + + // Expected spec built the way createTerminalSessionInternal did inline + // before the runtime extraction, from the same primitives. + const baseEnv = getTerminalBaseEnv(); + const shell = resolveLaunchShell(baseEnv); + const expectedEnv = buildV2TerminalEnv({ + baseEnv, + shell, + supersetHomeDir: homeDir, + themeType: LAUNCH_CONTEXT.themeType, + cwd: LAUNCH_CONTEXT.cwd, + terminalId: LAUNCH_CONTEXT.terminalId, + workspaceId: LAUNCH_CONTEXT.workspaceId, + workspacePath: LAUNCH_CONTEXT.workspacePath, + rootPath: LAUNCH_CONTEXT.rootPath, + supersetEnv: + process.env.NODE_ENV === "development" ? "development" : "production", + agentHookPort: "51741", + agentHookVersion: "4", + hostAgentHookUrl: "http://127.0.0.1:48123/trpc/notifications.hook", + }); + + expect(spec.shell).toBe(shell); + expect(spec.argv).toEqual( + getShellLaunchArgs({ shell, supersetHomeDir: homeDir }), + ); + expect(spec.cwd).toBe(LAUNCH_CONTEXT.cwd); + expect(spec.env).toEqual(expectedEnv); + expect(spec.expectsReadyMarker).toBe( + shellLaunchExpectsReadyMarker({ shell, supersetHomeDir: homeDir }), + ); + }); + + test("injects the workspace context into the PTY env", async () => { + const spec = await new HostRuntime().buildPtyLaunch(LAUNCH_CONTEXT); + + expect(spec.env.SUPERSET_TERMINAL_ID).toBe("term-1"); + expect(spec.env.SUPERSET_WORKSPACE_ID).toBe("ws-1"); + expect(spec.env.SUPERSET_WORKSPACE_PATH).toBe("/tmp/worktrees/ws-1"); + expect(spec.env.SUPERSET_ROOT_PATH).toBe("/tmp/repo"); + expect(spec.env.SUPERSET_HOME_DIR).toBe(homeDir); + expect(spec.env.SUPERSET_HOST_AGENT_HOOK_URL).toBe( + "http://127.0.0.1:48123/trpc/notifications.hook", + ); + expect(spec.env.PWD).toBe(LAUNCH_CONTEXT.cwd); + }); + + test("getHostAgentHookUrl prefers HOST_SERVICE_PORT and is empty without a port", () => { + expect(getHostAgentHookUrl()).toBe( + "http://127.0.0.1:48123/trpc/notifications.hook", + ); + delete process.env.HOST_SERVICE_PORT; + process.env.PORT = "4879"; + expect(getHostAgentHookUrl()).toBe( + "http://127.0.0.1:4879/trpc/notifications.hook", + ); + delete process.env.PORT; + expect(getHostAgentHookUrl()).toBe(""); + }); + + test("registry resolves every workspace to the host runtime today", () => { + const runtime = getWorkspaceRuntime("any-workspace"); + expect(runtime.kind).toBe("host"); + expect(runtime).toBe(getWorkspaceRuntime("another-workspace")); + }); +}); diff --git a/packages/host-service/src/runtime/sandbox/host-runtime.ts b/packages/host-service/src/runtime/sandbox/host-runtime.ts new file mode 100644 index 00000000000..4fbf2475b0a --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/host-runtime.ts @@ -0,0 +1,82 @@ +import { + buildV2TerminalEnv, + getShellLaunchArgs, + getTerminalBaseEnv, + resolveLaunchShell, + shellLaunchExpectsReadyMarker, + waitForTerminalBaseEnv, +} from "../../terminal/env.ts"; +import type { + PtyLaunchSpec, + TerminalLaunchContext, + WorkspaceRuntime, +} from "./workspace-runtime.ts"; + +/** + * Build the host-service tRPC URL for the v2 agent hook. The agent shell + * script POSTs to this; host-service fans out on the event bus so the + * renderer (web or electron) can play the finish sound. + */ +export function getHostAgentHookUrl(): string { + const port = process.env.HOST_SERVICE_PORT || process.env.PORT; + if (!port) return ""; + return `http://127.0.0.1:${port}/trpc/notifications.hook`; +} + +/** + * Today's behavior: the PTY child is the user's shell running directly on + * the host, wrapped with Superset's shell bootstrap (rc files under + * SUPERSET_HOME_DIR) and the preserved login-shell env snapshot. + */ +export class HostRuntime implements WorkspaceRuntime { + readonly kind = "host" as const; + + async prepare(): Promise { + // Nothing to provision for host execution. + } + + async buildPtyLaunch(ctx: TerminalLaunchContext): Promise { + // Use the preserved shell snapshot — never live process.env. Resolution + // runs in the background at startup so the server can listen + // immediately; wait for it here before the first PTY needs it. + await waitForTerminalBaseEnv(); + const baseEnv = getTerminalBaseEnv(); + const supersetHomeDir = process.env.SUPERSET_HOME_DIR || ""; + const shell = resolveLaunchShell(baseEnv); + const argv = getShellLaunchArgs({ shell, supersetHomeDir }); + const env = buildV2TerminalEnv({ + baseEnv, + shell, + supersetHomeDir, + themeType: ctx.themeType, + cwd: ctx.cwd, + terminalId: ctx.terminalId, + workspaceId: ctx.workspaceId, + workspacePath: ctx.workspacePath, + rootPath: ctx.rootPath, + supersetEnv: + process.env.NODE_ENV === "development" ? "development" : "production", + agentHookPort: process.env.SUPERSET_AGENT_HOOK_PORT || "", + agentHookVersion: process.env.SUPERSET_AGENT_HOOK_VERSION || "", + hostAgentHookUrl: this.getAgentHookUrl(), + }); + return { + shell, + argv, + cwd: ctx.cwd, + env, + expectsReadyMarker: shellLaunchExpectsReadyMarker({ + shell, + supersetHomeDir, + }), + }; + } + + getAgentHookUrl(): string { + return getHostAgentHookUrl(); + } + + async destroy(): Promise { + // Nothing to tear down for host execution. + } +} diff --git a/packages/host-service/src/runtime/sandbox/registry.ts b/packages/host-service/src/runtime/sandbox/registry.ts new file mode 100644 index 00000000000..768c4681c6d --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/registry.ts @@ -0,0 +1,14 @@ +import { HostRuntime } from "./host-runtime.ts"; +import type { WorkspaceRuntime } from "./workspace-runtime.ts"; + +const hostRuntime = new HostRuntime(); + +/** + * Resolve the execution runtime for a workspace. + * + * Always the host runtime today. Once per-workspace sandbox config lands, + * workspaces with sandboxing enabled resolve to a Docker runtime instead. + */ +export function getWorkspaceRuntime(_workspaceId: string): WorkspaceRuntime { + return hostRuntime; +} diff --git a/packages/host-service/src/runtime/sandbox/workspace-runtime.ts b/packages/host-service/src/runtime/sandbox/workspace-runtime.ts new file mode 100644 index 00000000000..8c9f9315afd --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/workspace-runtime.ts @@ -0,0 +1,53 @@ +/** + * Workspace execution runtime abstraction. + * + * Every workspace PTY (user terminals, agent launches, setup, teardown, + * CLI --command) is spawned from one chokepoint in terminal.ts. The runtime + * decides WHERE that PTY's process tree lives: directly on the host (today's + * behavior) or inside a per-workspace sandbox container (Docker runtime). + */ + +export interface TerminalLaunchContext { + terminalId: string; + workspaceId: string; + /** + * Workspace worktree path on the host. Sandbox runtimes bind-mount the + * worktree at this exact path so host-side git/diff/search keep working. + */ + workspacePath: string; + /** Main repo path, or "" for session workspaces with no project. */ + rootPath: string; + /** Resolved terminal cwd (inside the worktree). */ + cwd: string; + themeType?: "dark" | "light"; +} + +/** Exactly what daemon.open needs, plus launch capability metadata. */ +export interface PtyLaunchSpec { + /** Host executable the pty-daemon forks (user shell, or e.g. `docker`). */ + shell: string; + argv: string[]; + /** Host-valid cwd — pty-daemon stat()s it before spawning. */ + cwd: string; + env: Record; + /** + * Whether this launch installs Superset's OSC 133;A prompt marker, so + * initial commands can safely wait for shell readiness before typing. + */ + expectsReadyMarker: boolean; +} + +export interface WorkspaceRuntime { + readonly kind: "host" | "docker"; + /** + * Ensure backing resources exist before a PTY launch. No-op for the host + * runtime; container ensure (create/start/pull) for sandbox runtimes. + */ + prepare(): Promise; + /** Build the daemon.open spec for a workspace PTY. Called after prepare(). */ + buildPtyLaunch(ctx: TerminalLaunchContext): Promise; + /** Agent-hook callback URL reachable from where the PTY's shell runs. */ + getAgentHookUrl(): string; + /** Tear down backing resources on workspace delete. No-op for host. */ + destroy(): Promise; +} diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 8f7cf951e63..d7030ac9d8a 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -29,6 +29,7 @@ import type { HostDb } from "../db/index.ts"; import { projects, terminalSessions, workspaces } from "../db/schema.ts"; import type { EventBus } from "../events/index.ts"; import { portManager } from "../ports/port-manager.ts"; +import { getWorkspaceRuntime } from "../runtime/sandbox/registry.ts"; import { sweepAgentBindingsAfterDaemonLoss } from "../terminal-agents/daemon-loss-sweep.ts"; import { markTerminalAgentBindingEnded } from "../terminal-agents/persistence.ts"; import { @@ -40,14 +41,6 @@ import { getDaemonClient, onDaemonDisconnect, } from "./daemon-client-singleton.ts"; -import { - buildV2TerminalEnv, - getShellLaunchArgs, - getTerminalBaseEnv, - resolveLaunchShell, - shellLaunchExpectsReadyMarker, - waitForTerminalBaseEnv, -} from "./env.ts"; import { listTerminalResourceSessions } from "./resource-sessions.ts"; import { getShellReadyMarkerEvidence, @@ -157,17 +150,6 @@ export function parseThemeType( return value === "dark" || value === "light" ? value : undefined; } -/** - * Build the host-service tRPC URL for the v2 agent hook. The agent shell - * script POSTs to this; host-service fans out on the event bus so the - * renderer (web or electron) can play the finish sound. - */ -function getHostAgentHookUrl(): string { - const port = process.env.HOST_SERVICE_PORT || process.env.PORT; - if (!port) return ""; - return `http://127.0.0.1:${port}/trpc/notifications.hook`; -} - type TerminalClientMessage = | { type: "input"; data: string } | { type: "resize"; cols: number; rows: number } @@ -2447,29 +2429,17 @@ export async function createTerminalSessionInternal({ DEFAULT_TERMINAL_ROWS, ); - // Use the preserved shell snapshot — never live process.env. Resolution - // runs in the background at startup so the server can listen immediately; - // wait for it here before the first PTY needs the snapshot. - await waitForTerminalBaseEnv(); - const baseEnv = getTerminalBaseEnv(); - const supersetHomeDir = process.env.SUPERSET_HOME_DIR || ""; - const shell = resolveLaunchShell(baseEnv); - const shellArgs = getShellLaunchArgs({ shell, supersetHomeDir }); - const ptyEnv = buildV2TerminalEnv({ - baseEnv, - shell, - supersetHomeDir, - themeType, - cwd, + // The workspace runtime decides where the PTY's process tree lives (host + // shell today, sandbox container when enabled) and builds the launch spec. + const runtime = getWorkspaceRuntime(workspaceId); + await runtime.prepare(); + const launch = await runtime.buildPtyLaunch({ terminalId, workspaceId, workspacePath: workspace.worktreePath, rootPath, - supersetEnv: - process.env.NODE_ENV === "development" ? "development" : "production", - agentHookPort: process.env.SUPERSET_AGENT_HOOK_PORT || "", - agentHookVersion: process.env.SUPERSET_AGENT_HOOK_VERSION || "", - hostAgentHookUrl: getHostAgentHookUrl(), + cwd, + themeType, }); let daemon: DaemonClient; @@ -2512,12 +2482,12 @@ export async function createTerminalSessionInternal({ } else { try { openResult = await daemon.open(terminalId, { - shell, - argv: shellArgs, - cwd, + shell: launch.shell, + argv: launch.argv, + cwd: launch.cwd, cols, rows, - env: ptyEnv, + env: launch.env, }); } catch (err) { // After host-service restart the daemon may already own this @@ -2584,8 +2554,7 @@ export async function createTerminalSessionInternal({ // Determine shell readiness support. Adopted sessions are already past // shell startup, so treat them as immediately ready — the OSC 133;A // marker has already flown by and we don't want to gate writes on it. - const shellSupportsReady = - !isAdopted && shellLaunchExpectsReadyMarker({ shell, supersetHomeDir }); + const shellSupportsReady = !isAdopted && launch.expectsReadyMarker; let shellReadyResolve: (() => void) | null = null; const shellReadyPromise = shellSupportsReady @@ -2631,7 +2600,7 @@ export async function createTerminalSessionInternal({ // Adopted sessions have already run their initialCommand in the prior // host-service lifetime — flag it as queued so we don't double-fire it. initialCommandQueued: isAdopted, - launchShellName: basename(shell), + launchShellName: basename(launch.shell), portHintDecoder: new StringDecoder("utf8"), modeTracker: createModeTracker(cols, rows), epoch: randomBytes(8).toString("hex"), From aae042e4fc25f82af1e6ddf53dcd18b33d71bc6d Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 22:10:04 +0200 Subject: [PATCH 02/21] =?UTF-8?q?feat(host-service):=20Docker=20sandbox=20?= =?UTF-8?q?runtime=20=E2=80=94=20container=20lifecycle,=20isolated=20git,?= =?UTF-8?q?=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .superset/config.json gains a 'sandbox' key (image, runtime, network, ports, resources, mounts, env, git.cloneDepth) with three-tier merge; mounts/env are honored only from machine-local sources so a cloned repo can't mount host paths into its own sandbox. - One persistent container per workspace (superset-ws-, sleep infinity, managed labels, restart unless-stopped), lazily ensured on first PTY with single-flight per workspace; PTYs are docker exec -it children so daemon persistence/adoption/resize work unchanged. - Isolated git metadata: per-workspace bare clone bind-mounted at /sandbox/git with a read-only file mask over the worktree's .git pointer (--separate-git-dir layout); the main repo's .git never enters the container. In-container commits stay sandbox-local. - Generated container superset-home (bash rcfile with OSC 133;A marker, notify.sh copy) mounted read-only at /opt/superset. - Agent hook URL rewritten to host.docker.internal for sandboxed PTYs. - workspaces table: sandbox_enabled (sticky at create), image digest, port map (drizzle migration 0023); WorkspaceSnapshot.sandboxed. - Startup reconcile removes orphan managed containers; workspace delete tears down container + sandbox state. --- .../drizzle/0023_busy_starjammers.sql | 3 + .../drizzle/meta/0023_snapshot.json | 967 ++++++++++++++++++ .../host-service/drizzle/meta/_journal.json | 7 + packages/host-service/src/app.ts | 6 + packages/host-service/src/db/schema.ts | 11 + packages/host-service/src/events/types.ts | 2 + .../src/runtime/sandbox/container-env.ts | 52 + .../src/runtime/sandbox/container-manager.ts | 172 ++++ .../src/runtime/sandbox/docker-args.test.ts | 165 +++ .../src/runtime/sandbox/docker-args.ts | 139 +++ .../src/runtime/sandbox/docker-cli.ts | 217 ++++ .../src/runtime/sandbox/docker-runtime.ts | 74 ++ .../src/runtime/sandbox/git-bootstrap.ts | 135 +++ .../src/runtime/sandbox/host-runtime.test.ts | 14 +- .../host-service/src/runtime/sandbox/paths.ts | 53 + .../src/runtime/sandbox/registry.ts | 80 +- .../src/runtime/sandbox/sandbox-home.ts | 81 ++ .../src/runtime/sandbox/sandbox-reconcile.ts | 38 + .../host-service/src/runtime/setup/config.ts | 44 +- .../src/runtime/setup/sandbox-config.test.ts | 117 +++ .../src/runtime/setup/sandbox-config.ts | 262 +++++ .../host-service/src/terminal/terminal.ts | 34 +- .../workspace-cleanup/workspace-cleanup.ts | 17 + .../src/trpc/router/workspaces/workspaces.ts | 6 + .../src/workspaces/local-workspace-store.ts | 4 + 25 files changed, 2677 insertions(+), 23 deletions(-) create mode 100644 packages/host-service/drizzle/0023_busy_starjammers.sql create mode 100644 packages/host-service/drizzle/meta/0023_snapshot.json create mode 100644 packages/host-service/src/runtime/sandbox/container-env.ts create mode 100644 packages/host-service/src/runtime/sandbox/container-manager.ts create mode 100644 packages/host-service/src/runtime/sandbox/docker-args.test.ts create mode 100644 packages/host-service/src/runtime/sandbox/docker-args.ts create mode 100644 packages/host-service/src/runtime/sandbox/docker-cli.ts create mode 100644 packages/host-service/src/runtime/sandbox/docker-runtime.ts create mode 100644 packages/host-service/src/runtime/sandbox/git-bootstrap.ts create mode 100644 packages/host-service/src/runtime/sandbox/paths.ts create mode 100644 packages/host-service/src/runtime/sandbox/sandbox-home.ts create mode 100644 packages/host-service/src/runtime/sandbox/sandbox-reconcile.ts create mode 100644 packages/host-service/src/runtime/setup/sandbox-config.test.ts create mode 100644 packages/host-service/src/runtime/setup/sandbox-config.ts diff --git a/packages/host-service/drizzle/0023_busy_starjammers.sql b/packages/host-service/drizzle/0023_busy_starjammers.sql new file mode 100644 index 00000000000..03b0b6ac0be --- /dev/null +++ b/packages/host-service/drizzle/0023_busy_starjammers.sql @@ -0,0 +1,3 @@ +ALTER TABLE `workspaces` ADD `sandbox_enabled` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `sandbox_image_digest` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `sandbox_port_map_json` text DEFAULT '{}' NOT NULL; \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0023_snapshot.json b/packages/host-service/drizzle/meta/0023_snapshot.json new file mode 100644 index 00000000000..8a3e90047ee --- /dev/null +++ b/packages/host-service/drizzle/meta/0023_snapshot.json @@ -0,0 +1,967 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "92d09836-d890-4688-baee-b241a6e59f9c", + "prevId": "e1255119-f9b5-406c-ac75-f450890e466d", + "tables": { + "host_agent_configs": { + "name": "host_agent_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preset_id": { + "name": "preset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_id": { + "name": "icon_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "args_json": { + "name": "args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "prompt_transport": { + "name": "prompt_transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_args_json": { + "name": "prompt_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "resume_args_json": { + "name": "resume_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "env_json": { + "name": "env_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_agent_configs_display_order_idx": { + "name": "host_agent_configs_display_order_idx", + "columns": [ + "display_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_settings": { + "name": "host_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sparse_checkout_paths": { + "name": "sparse_checkout_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "naming_instructions": { + "name": "naming_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "merged_at": { + "name": "merged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_agent_bindings": { + "name": "terminal_agent_bindings", + "columns": { + "terminal_id": { + "name": "terminal_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_type": { + "name": "last_event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_agent_bindings_workspace_id_idx": { + "name": "terminal_agent_bindings_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk": { + "name": "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk", + "tableFrom": "terminal_agent_bindings", + "tableTo": "terminal_sessions", + "columnsFrom": [ + "terminal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_workspace_id": { + "name": "origin_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispose_requested_at": { + "name": "dispose_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", + "columns": [ + "origin_workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "origin_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_cloud_deletes": { + "name": "workspace_cloud_deletes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "queued_at": { + "name": "queued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_owner": { + "name": "upstream_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_repo": { + "name": "upstream_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_branch": { + "name": "upstream_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suppressed_pull_request_id": { + "name": "suppressed_pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'worktree'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cloud_synced_at": { + "name": "cloud_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archive_reason": { + "name": "archive_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_enabled": { + "name": "sandbox_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sandbox_image_digest": { + "name": "sandbox_image_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_port_map_json": { + "name": "sandbox_port_map_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_archived_at_idx": { + "name": "workspaces_archived_at_idx", + "columns": [ + "archived_at" + ], + "isUnique": false + }, + "workspaces_upstream_ref_idx": { + "name": "workspaces_upstream_ref_idx", + "columns": [ + "upstream_owner", + "upstream_repo", + "upstream_branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + }, + "workspaces_one_main_per_project": { + "name": "workspaces_one_main_per_project", + "columns": [ + "project_id" + ], + "isUnique": true, + "where": "type = 'main'" + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_suppressed_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_suppressed_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "suppressed_pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index 1354f015ea1..3c8a7b82a8d 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1786644873924, "tag": "0022_kind_roxanne_simpson", "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1786651057099, + "tag": "0023_busy_starjammers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 4467b237b13..fd4735c0897 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -21,6 +21,7 @@ import { createGitEnvResolver, createGitFactory } from "./runtime/git"; import { runMainWorkspaceSweep } from "./runtime/main-workspace-sweep"; import { runProjectBackfill } from "./runtime/project-backfill"; import { PullRequestRuntimeManager } from "./runtime/pull-requests"; +import { runSandboxReconcile } from "./runtime/sandbox/sandbox-reconcile"; import { registerWorkspaceTerminalRoute } from "./terminal/terminal"; import { SqliteTerminalAgentBindingPersistence, @@ -230,6 +231,11 @@ export function createApp(options: CreateAppOptions): CreateAppResult { }).catch((err) => { console.warn("[host-service] archived-workspace reconcile failed:", err); }); + // Remove sandbox containers for workspaces deleted while docker was + // down. No-op when docker isn't running. + await runSandboxReconcile(db).catch((err) => { + console.warn("[host-service] sandbox reconcile failed:", err); + }); })(); const wsAuth: MiddlewareHandler = async (c, next) => { diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index e186bfc3801..a645596ffb5 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -245,6 +245,17 @@ export const workspaces = sqliteTable( archivedAt: integer("archived_at"), // "merged" when the linked PR was merged at destroy time. archiveReason: text("archive_reason").$type<"merged" | "deleted">(), + // Sticky sandbox decision snapshotted at create time from the resolved + // sandbox config — later config flips don't silently re-home a live + // workspace's terminals. Pre-existing rows stay unsandboxed. + sandboxEnabled: integer("sandbox_enabled", { mode: "boolean" }) + .notNull() + .default(false), + // Image digest the workspace's container was created from; drives + // "image updated — rebuild sandbox?" prompts. + sandboxImageDigest: text("sandbox_image_digest"), + // JSON {containerPort: hostPort} published at container create. + sandboxPortMapJson: text("sandbox_port_map_json").notNull().default("{}"), }, (table) => [ index("workspaces_project_id_idx").on(table.projectId), diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index f940ba89c63..570f83c65ee 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -70,6 +70,8 @@ export interface WorkspaceSnapshot { createdByUserId: string | null; createdAt: number; updatedAt: number; + /** True when the workspace's terminals run inside a sandbox container. */ + sandboxed: boolean; } export interface WorkspaceChangedMessage { diff --git a/packages/host-service/src/runtime/sandbox/container-env.ts b/packages/host-service/src/runtime/sandbox/container-env.ts new file mode 100644 index 00000000000..fe9da197a83 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/container-env.ts @@ -0,0 +1,52 @@ +import { + TERMINAL_TERM_PROGRAM, + TERMINAL_TERM_PROGRAM_VERSION, +} from "@superset/shared/constants"; +import { CONTAINER_SUPERSET_DIR } from "./paths.ts"; +import type { TerminalLaunchContext } from "./workspace-runtime.ts"; + +export interface ContainerTerminalEnvParams { + ctx: TerminalLaunchContext; + hostAgentHookUrl: string; + /** Host env var names forwarded verbatim (config `sandbox.env`). */ + envPassthrough: string[]; + hostEnv: Record; +} + +/** + * Env for the shell INSIDE the sandbox container, passed via `docker exec + * -e`. Deliberately minimal — the host login-shell snapshot never crosses + * the boundary; PATH/HOME come from the image. SUPERSET_HOME_DIR points at + * the read-only mounted container superset-home so the runtime-resolved + * agent hooks find notify.sh. + */ +export function buildContainerTerminalEnv( + params: ContainerTerminalEnvParams, +): Record { + const { ctx, hostAgentHookUrl, envPassthrough, hostEnv } = params; + const env: Record = { + TERM: "xterm-256color", + TERM_PROGRAM: TERMINAL_TERM_PROGRAM, + TERM_PROGRAM_VERSION: TERMINAL_TERM_PROGRAM_VERSION, + COLORTERM: "truecolor", + COLORFGBG: ctx.themeType === "light" ? "0;15" : "15;0", + TERM_THEME: ctx.themeType === "light" ? "light" : "dark", + LANG: "C.UTF-8", + SUPERSET_TERMINAL_ID: ctx.terminalId, + SUPERSET_WORKSPACE_ID: ctx.workspaceId, + SUPERSET_WORKSPACE_PATH: ctx.workspacePath, + SUPERSET_ROOT_PATH: ctx.rootPath, + SUPERSET_ENV: + process.env.NODE_ENV === "development" ? "development" : "production", + SUPERSET_AGENT_HOOK_VERSION: process.env.SUPERSET_AGENT_HOOK_VERSION || "", + SUPERSET_HOME_DIR: CONTAINER_SUPERSET_DIR, + }; + if (hostAgentHookUrl) { + env.SUPERSET_HOST_AGENT_HOOK_URL = hostAgentHookUrl; + } + for (const name of envPassthrough) { + const value = hostEnv[name]; + if (value !== undefined) env[name] = value; + } + return env; +} diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts new file mode 100644 index 00000000000..3ddb8c1f650 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -0,0 +1,172 @@ +import { createHash } from "node:crypto"; +import { rm } from "node:fs/promises"; +import { + buildContainerCreateArgs, + CONFIG_HASH_LABEL, + type MountSpec, + type ResolvedSandboxSettings, +} from "./docker-args.ts"; +import { + checkDockerAvailable, + createContainer, + imageExists, + inspectContainer, + pullImage, + removeContainer, + startContainer, +} from "./docker-cli.ts"; +import { ensureSandboxGit } from "./git-bootstrap.ts"; +import { + CONTAINER_GIT_DIR, + CONTAINER_SUPERSET_DIR, + getSandboxContainerName, + getWorkspaceSandboxPaths, +} from "./paths.ts"; +import { ensureSandboxHome } from "./sandbox-home.ts"; + +/** Thrown for user-actionable sandbox failures (docker down, pull failed). */ +export class SandboxError extends Error { + constructor(message: string) { + super(message); + this.name = "SandboxError"; + } +} + +export interface EnsureContainerParams { + workspaceId: string; + worktreePath: string; + repoPath: string; + branch: string; + settings: ResolvedSandboxSettings; +} + +export function computeConfigHash(settings: ResolvedSandboxSettings): string { + return createHash("sha256") + .update(JSON.stringify(settings)) + .digest("hex") + .slice(0, 16); +} + +function buildWorkspaceMounts(params: EnsureContainerParams): MountSpec[] { + const paths = getWorkspaceSandboxPaths(params.workspaceId); + return [ + // Worktree at its host path — path identity keeps host-side git, + // search, and diff working against the same absolute paths. + { source: params.worktreePath, target: params.worktreePath }, + { + source: ensureSandboxHome(), + target: CONTAINER_SUPERSET_DIR, + readOnly: true, + }, + { source: paths.gitDir, target: CONTAINER_GIT_DIR }, + // File-over-file mask: hides the worktree's real .git pointer (which + // targets the unmounted main repo) behind the sandbox git dir path. + { + source: paths.dotGitFile, + target: `${params.worktreePath}/.git`, + readOnly: true, + }, + ...params.settings.extraMounts, + ]; +} + +const ensureInFlight = new Map>(); + +/** + * Ensure the workspace's sandbox container exists and is running. + * Single-flight per workspace: concurrent terminal opens (agent + setup + * racing at workspace create) coalesce onto one ensure. + */ +export function ensureContainer(params: EnsureContainerParams): Promise { + const existing = ensureInFlight.get(params.workspaceId); + if (existing) return existing; + const promise = doEnsureContainer(params).finally(() => { + ensureInFlight.delete(params.workspaceId); + }); + ensureInFlight.set(params.workspaceId, promise); + return promise; +} + +async function doEnsureContainer(params: EnsureContainerParams): Promise { + const availability = await checkDockerAvailable(); + if (!availability.ok) { + throw new SandboxError( + `Workspace sandbox unavailable: ${availability.error}`, + ); + } + + await ensureSandboxGit({ + workspaceId: params.workspaceId, + repoPath: params.repoPath, + branch: params.branch, + worktreePath: params.worktreePath, + cloneDepth: params.settings.cloneDepth, + }); + + const name = getSandboxContainerName(params.workspaceId); + const configHash = computeConfigHash(params.settings); + const inspection = await inspectContainer(name); + + if (inspection.exists) { + const staleConfig = inspection.labels[CONFIG_HASH_LABEL] !== configHash; + if (inspection.running && !staleConfig) return; + if (!inspection.running && staleConfig) { + // Stopped + outdated: recreate. A RUNNING container with stale + // config keeps serving — never kill live terminals implicitly. + await removeContainer(name); + } else { + await startContainer(name); + return; + } + } + + if (!(await imageExists(params.settings.image))) { + console.log( + `[sandbox] pulling image ${params.settings.image} for workspace ${params.workspaceId}`, + ); + try { + await pullImage(params.settings.image); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new SandboxError( + `Failed to pull sandbox image ${params.settings.image}: ${message}`, + ); + } + } + + await createContainer( + buildContainerCreateArgs({ + name, + workspaceId: params.workspaceId, + configHash, + image: params.settings.image, + runtime: params.settings.runtime, + network: params.settings.network, + resources: params.settings.resources, + mounts: buildWorkspaceMounts(params), + }), + ); + await startContainer(name); + console.log( + `[sandbox] container ${name} running (image ${params.settings.image})`, + ); +} + +/** Remove the workspace's container and host-side sandbox state. */ +export async function destroyWorkspaceSandbox( + workspaceId: string, +): Promise { + const availability = await checkDockerAvailable(); + if (availability.ok) { + await removeContainer(getSandboxContainerName(workspaceId)); + } else { + console.warn( + `[sandbox] docker unavailable during destroy of ${workspaceId}; ` + + "container (if any) will be removed by the startup reconcile", + ); + } + await rm(getWorkspaceSandboxPaths(workspaceId).stateDir, { + recursive: true, + force: true, + }); +} diff --git a/packages/host-service/src/runtime/sandbox/docker-args.test.ts b/packages/host-service/src/runtime/sandbox/docker-args.test.ts new file mode 100644 index 00000000000..f82af872de6 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/docker-args.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import { + buildContainerCreateArgs, + buildExecArgs, + DEFAULT_SANDBOX_IMAGE, + parseConfigMount, + resolveSandboxSettings, +} from "./docker-args.ts"; +import { buildGitBootstrapCommands } from "./git-bootstrap.ts"; + +describe("buildContainerCreateArgs", () => { + test("composes the full create argv", () => { + const args = buildContainerCreateArgs({ + name: "superset-ws-abc", + workspaceId: "abc", + configHash: "deadbeef", + image: "img:1", + runtime: "runsc", + network: "none", + resources: { cpus: 2, memoryMb: 4096, pidsLimit: 1024 }, + mounts: [ + { source: "/wt", target: "/wt" }, + { source: "/mask", target: "/wt/.git", readOnly: true }, + ], + }); + expect(args).toEqual([ + "create", + "--name", + "superset-ws-abc", + "--hostname", + "superset-ws-abc", + "--label", + "com.superset.managed=true", + "--label", + "com.superset.workspace-id=abc", + "--label", + "com.superset.config-hash=deadbeef", + "--restart", + "unless-stopped", + "--init", + "--runtime", + "runsc", + "--network", + "none", + "--cpus", + "2", + "--memory", + "4096m", + "--pids-limit", + "1024", + "--add-host", + "host.docker.internal:host-gateway", + "--mount", + "type=bind,source=/wt,target=/wt", + "--mount", + "type=bind,source=/mask,target=/wt/.git,readonly", + "img:1", + "sleep", + "infinity", + ]); + }); + + test("omits runtime/network/cpu/memory flags when unset", () => { + const args = buildContainerCreateArgs({ + name: "n", + workspaceId: "w", + configHash: "h", + image: "img", + network: "bridge", + resources: { pidsLimit: 2048 }, + mounts: [], + }); + expect(args).not.toContain("--runtime"); + expect(args).not.toContain("--network"); + expect(args).not.toContain("--cpus"); + expect(args).not.toContain("--memory"); + expect(args).toContain("--pids-limit"); + }); +}); + +describe("buildExecArgs", () => { + test("threads cwd, env, and command through docker exec -it", () => { + expect( + buildExecArgs({ + containerName: "superset-ws-abc", + cwd: "/wt/sub", + env: { A: "1", B: "two words" }, + command: ["/bin/bash", "--rcfile", "/opt/superset/bash/rcfile"], + }), + ).toEqual([ + "exec", + "-it", + "-w", + "/wt/sub", + "-e", + "A=1", + "-e", + "B=two words", + "superset-ws-abc", + "/bin/bash", + "--rcfile", + "/opt/superset/bash/rcfile", + ]); + }); +}); + +describe("resolveSandboxSettings", () => { + test("applies defaults to an empty config", () => { + expect(resolveSandboxSettings({})).toEqual({ + image: DEFAULT_SANDBOX_IMAGE, + network: "bridge", + ports: [], + resources: { pidsLimit: 2048 }, + extraMounts: [], + envPassthrough: [], + }); + }); + + test("parses ro mounts", () => { + expect(parseConfigMount("/etc/certs:ro")).toEqual({ + source: "/etc/certs", + target: "/etc/certs", + readOnly: true, + }); + expect(parseConfigMount("/opt/cache")).toEqual({ + source: "/opt/cache", + target: "/opt/cache", + }); + }); +}); + +describe("buildGitBootstrapCommands", () => { + test("produces the isolated-git-dir bootstrap sequence", () => { + const commands = buildGitBootstrapCommands({ + workspaceId: "abc", + repoPath: "/repo", + branch: "feat/x", + worktreePath: "/wt", + headSha: "cafe123", + gitDir: "/sb/git", + cloneDepth: 25, + }); + expect(commands.map((c) => c.argv)).toEqual([ + [ + "clone", + "--bare", + "--single-branch", + "--branch", + "feat/x", + "--depth", + "25", + "file:///repo", + "/sb/git", + ], + ["--git-dir", "/sb/git", "config", "core.bare", "false"], + ["--git-dir", "/sb/git", "config", "core.worktree", "/wt"], + ["--git-dir", "/sb/git", "config", "core.logAllRefUpdates", "true"], + ["--git-dir", "/sb/git", "update-ref", "refs/heads/feat/x", "cafe123"], + ["--git-dir", "/sb/git", "symbolic-ref", "HEAD", "refs/heads/feat/x"], + ["read-tree", "HEAD"], + ]); + const readTree = commands.at(-1); + expect(readTree?.env).toEqual({ GIT_DIR: "/sb/git", GIT_WORK_TREE: "/wt" }); + }); +}); diff --git a/packages/host-service/src/runtime/sandbox/docker-args.ts b/packages/host-service/src/runtime/sandbox/docker-args.ts new file mode 100644 index 00000000000..e56a10ba9dd --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/docker-args.ts @@ -0,0 +1,139 @@ +/** + * Pure builders for docker CLI argv. Kept side-effect-free so create/exec + * specs are snapshot-testable without a docker daemon. + */ + +import type { SandboxConfig } from "../setup/sandbox-config.ts"; + +export interface MountSpec { + source: string; + target: string; + readOnly?: boolean; +} + +export const MANAGED_LABEL = "com.superset.managed=true"; +export const WORKSPACE_ID_LABEL = "com.superset.workspace-id"; +export const CONFIG_HASH_LABEL = "com.superset.config-hash"; + +export interface ContainerCreateSpec { + name: string; + workspaceId: string; + configHash: string; + image: string; + runtime?: string; + network: "bridge" | "none"; + resources: { cpus?: number; memoryMb?: number; pidsLimit: number }; + mounts: MountSpec[]; +} + +export function buildMountArgs(mounts: MountSpec[]): string[] { + return mounts.flatMap((mount) => [ + "--mount", + [ + "type=bind", + `source=${mount.source}`, + `target=${mount.target}`, + ...(mount.readOnly ? ["readonly"] : []), + ].join(","), + ]); +} + +export function buildContainerCreateArgs(spec: ContainerCreateSpec): string[] { + const { resources } = spec; + return [ + "create", + "--name", + spec.name, + "--hostname", + spec.name, + "--label", + MANAGED_LABEL, + "--label", + `${WORKSPACE_ID_LABEL}=${spec.workspaceId}`, + "--label", + `${CONFIG_HASH_LABEL}=${spec.configHash}`, + "--restart", + "unless-stopped", + "--init", + ...(spec.runtime ? ["--runtime", spec.runtime] : []), + ...(spec.network === "none" ? ["--network", "none"] : []), + ...(resources.cpus ? ["--cpus", String(resources.cpus)] : []), + ...(resources.memoryMb ? ["--memory", `${resources.memoryMb}m`] : []), + "--pids-limit", + String(resources.pidsLimit), + // Docker Desktop resolves host.docker.internal natively; host-gateway + // pre-provisions the same name for Linux engines. + "--add-host", + "host.docker.internal:host-gateway", + ...buildMountArgs(spec.mounts), + spec.image, + "sleep", + "infinity", + ]; +} + +export interface ExecSpec { + containerName: string; + cwd: string; + env: Record; + command: string[]; +} + +/** argv for `docker ` — an interactive TTY exec into the sandbox. */ +export function buildExecArgs(spec: ExecSpec): string[] { + return [ + "exec", + "-it", + "-w", + spec.cwd, + ...Object.entries(spec.env).flatMap(([key, value]) => [ + "-e", + `${key}=${value}`, + ]), + spec.containerName, + ...spec.command, + ]; +} + +/** Parse a config `mounts` entry ("path" | "path:ro") into a MountSpec. */ +export function parseConfigMount(entry: string): MountSpec { + const readOnly = entry.endsWith(":ro"); + const path = readOnly ? entry.slice(0, -3) : entry; + return { source: path, target: path, ...(readOnly && { readOnly: true }) }; +} + +export interface ResolvedSandboxSettings { + image: string; + runtime?: string; + network: "bridge" | "none"; + ports: number[]; + resources: { cpus?: number; memoryMb?: number; pidsLimit: number }; + extraMounts: MountSpec[]; + envPassthrough: string[]; + cloneDepth?: number; +} + +export const DEFAULT_SANDBOX_IMAGE = "ghcr.io/superset-sh/sandbox:latest"; +const DEFAULT_PIDS_LIMIT = 2048; + +/** Apply defaults to a parsed SandboxConfig. */ +export function resolveSandboxSettings( + config: SandboxConfig, +): ResolvedSandboxSettings { + return { + image: config.image ?? DEFAULT_SANDBOX_IMAGE, + ...(config.runtime && { runtime: config.runtime }), + network: config.network ?? "bridge", + ports: config.ports ?? [], + resources: { + ...(config.resources?.cpus && { cpus: config.resources.cpus }), + ...(config.resources?.memoryMb && { + memoryMb: config.resources.memoryMb, + }), + pidsLimit: config.resources?.pidsLimit ?? DEFAULT_PIDS_LIMIT, + }, + extraMounts: (config.mounts ?? []).map(parseConfigMount), + envPassthrough: config.env ?? [], + ...(config.git?.cloneDepth && { cloneDepth: config.git.cloneDepth }), + }; +} diff --git a/packages/host-service/src/runtime/sandbox/docker-cli.ts b/packages/host-service/src/runtime/sandbox/docker-cli.ts new file mode 100644 index 00000000000..dd1807ae80e --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/docker-cli.ts @@ -0,0 +1,217 @@ +import { execFile } from "node:child_process"; +import { homedir } from "node:os"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_TIMEOUT_MS = 15_000; +const PULL_TIMEOUT_MS = 10 * 60_000; +const AVAILABILITY_CACHE_MS = 5_000; + +/** + * Env for invoking the docker CLI. host-service may be launched by the + * desktop app with a minimal PATH that misses Docker Desktop's install + * locations, so append the common ones. + */ +export function getDockerCliEnv(): Record { + const extraPaths = ["/usr/local/bin", "/opt/homebrew/bin"]; + const basePath = process.env.PATH || "/usr/bin:/bin"; + const path = [ + basePath, + ...extraPaths.filter((p) => !basePath.includes(p)), + ].join(":"); + const env: Record = { + PATH: path, + HOME: process.env.HOME || homedir(), + }; + for (const key of ["DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG"]) { + const value = process.env[key]; + if (value) env[key] = value; + } + return env; +} + +export class DockerCliError extends Error { + constructor( + message: string, + readonly stderr: string, + ) { + super(message); + this.name = "DockerCliError"; + } +} + +async function docker( + args: string[], + options?: { timeoutMs?: number }, +): Promise { + try { + const { stdout } = await execFileAsync("docker", args, { + env: getDockerCliEnv(), + timeout: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + }); + return stdout; + } catch (error) { + const stderr = + error && typeof error === "object" && "stderr" in error + ? String((error as { stderr: unknown }).stderr) + : ""; + const message = error instanceof Error ? error.message : String(error); + throw new DockerCliError( + stderr.trim() ? `docker ${args[0]}: ${stderr.trim()}` : message, + stderr, + ); + } +} + +let availabilityCache: { at: number; result: DockerAvailability } | null = null; + +export type DockerAvailability = + | { ok: true; serverVersion: string } + | { ok: false; error: string }; + +/** Probe the docker daemon, cached briefly so PTY bursts don't stack probes. */ +export async function checkDockerAvailable(): Promise { + const now = Date.now(); + if (availabilityCache && now - availabilityCache.at < AVAILABILITY_CACHE_MS) { + return availabilityCache.result; + } + let result: DockerAvailability; + try { + const out = await docker(["version", "--format", "{{.Server.Version}}"], { + timeoutMs: 5_000, + }); + result = { ok: true, serverVersion: out.trim() }; + } catch (error) { + result = { + ok: false, + error: + error instanceof DockerCliError && error.stderr.trim() + ? error.stderr.trim() + : "Docker is not running. Start Docker Desktop (or the Docker daemon) and retry.", + }; + } + availabilityCache = { at: now, result }; + return result; +} + +export function resetDockerAvailabilityCacheForTests(): void { + availabilityCache = null; +} + +export interface ContainerInspection { + exists: boolean; + running: boolean; + labels: Record; + imageDigest: string | null; +} + +export async function inspectContainer( + name: string, +): Promise { + try { + const out = await docker([ + "inspect", + "--type", + "container", + "--format", + '{"running":{{json .State.Running}},"labels":{{json .Config.Labels}},"image":{{json .Image}}}', + name, + ]); + const parsed = JSON.parse(out.trim()) as { + running: boolean; + labels: Record | null; + image: string | null; + }; + return { + exists: true, + running: parsed.running, + labels: parsed.labels ?? {}, + imageDigest: parsed.image ?? null, + }; + } catch (error) { + if (error instanceof DockerCliError && /No such/i.test(error.stderr)) { + return { exists: false, running: false, labels: {}, imageDigest: null }; + } + throw error; + } +} + +export async function imageExists(image: string): Promise { + try { + await docker(["image", "inspect", "--format", "{{.Id}}", image]); + return true; + } catch (error) { + if (error instanceof DockerCliError && /No such/i.test(error.stderr)) { + return false; + } + throw error; + } +} + +export async function pullImage(image: string): Promise { + await docker(["pull", image], { timeoutMs: PULL_TIMEOUT_MS }); +} + +/** `docker create` with fully composed args; returns the container id. */ +export async function createContainer(createArgs: string[]): Promise { + const out = await docker(createArgs, { timeoutMs: 60_000 }); + return out.trim(); +} + +export async function startContainer(name: string): Promise { + await docker(["start", name], { timeoutMs: 60_000 }); +} + +export async function removeContainer(name: string): Promise { + try { + await docker(["rm", "-f", name], { timeoutMs: 60_000 }); + } catch (error) { + if (error instanceof DockerCliError && /No such/i.test(error.stderr)) { + return; + } + throw error; + } +} + +export interface ManagedContainer { + name: string; + workspaceId: string | null; + running: boolean; +} + +/** All containers carrying the Superset managed label, running or not. */ +export async function listManagedContainers(): Promise { + const out = await docker([ + "ps", + "-a", + "--filter", + "label=com.superset.managed=true", + "--format", + '{"name":{{json .Names}},"state":{{json .State}},"labels":{{json .Labels}}}', + ]); + const containers: ManagedContainer[] = []; + for (const line of out.split("\n")) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as { + name: string; + state: string; + labels: string; + }; + const workspaceLabel = parsed.labels + .split(",") + .map((pair) => pair.split("=")) + .find(([key]) => key === "com.superset.workspace-id"); + containers.push({ + name: parsed.name, + workspaceId: workspaceLabel?.[1] ?? null, + running: parsed.state === "running", + }); + } catch { + // Skip unparseable lines rather than failing the whole listing. + } + } + return containers; +} diff --git a/packages/host-service/src/runtime/sandbox/docker-runtime.ts b/packages/host-service/src/runtime/sandbox/docker-runtime.ts new file mode 100644 index 00000000000..4386ad8b9e8 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/docker-runtime.ts @@ -0,0 +1,74 @@ +import { buildContainerTerminalEnv } from "./container-env.ts"; +import { + destroyWorkspaceSandbox, + ensureContainer, +} from "./container-manager.ts"; +import { buildExecArgs, type ResolvedSandboxSettings } from "./docker-args.ts"; +import { getDockerCliEnv } from "./docker-cli.ts"; +import { getHostAgentHookUrl } from "./host-runtime.ts"; +import { CONTAINER_BASH_RCFILE, getSandboxContainerName } from "./paths.ts"; +import type { + PtyLaunchSpec, + TerminalLaunchContext, + WorkspaceRuntime, +} from "./workspace-runtime.ts"; + +export interface DockerRuntimeParams { + workspaceId: string; + worktreePath: string; + repoPath: string; + branch: string; + settings: ResolvedSandboxSettings; +} + +/** + * Runs every workspace PTY inside one persistent per-workspace container. + * The pty-daemon's child is the docker CLI: `docker exec -it` under a PTY + * forwards bytes, resize (SIGWINCH → exec-resize API), and the inner + * process's exit code, so terminal persistence/adoption work unchanged. + */ +export class DockerRuntime implements WorkspaceRuntime { + readonly kind = "docker" as const; + + constructor(private readonly params: DockerRuntimeParams) {} + + async prepare(): Promise { + await ensureContainer(this.params); + } + + async buildPtyLaunch(ctx: TerminalLaunchContext): Promise { + const env = buildContainerTerminalEnv({ + ctx, + hostAgentHookUrl: this.getAgentHookUrl(), + envPassthrough: this.params.settings.envPassthrough, + hostEnv: process.env, + }); + return { + shell: "docker", + argv: buildExecArgs({ + containerName: getSandboxContainerName(this.params.workspaceId), + cwd: ctx.cwd, + env, + command: ["/bin/bash", "--rcfile", CONTAINER_BASH_RCFILE], + }), + // pty-daemon stat()s the spawn cwd on the host; the container cwd + // rides in `docker exec -w` instead. + cwd: ctx.workspacePath, + env: getDockerCliEnv(), + // The generated container rcfile always installs the OSC 133;A + // prompt marker (sandbox-home.ts owns the contract). + expectsReadyMarker: true, + }; + } + + getAgentHookUrl(): string { + // host.docker.internal reaches the host's loopback from containers on + // Docker Desktop natively; the container is created with + // `--add-host host.docker.internal:host-gateway` for Linux engines. + return getHostAgentHookUrl().replace("127.0.0.1", "host.docker.internal"); + } + + async destroy(): Promise { + await destroyWorkspaceSandbox(this.params.workspaceId); + } +} diff --git a/packages/host-service/src/runtime/sandbox/git-bootstrap.ts b/packages/host-service/src/runtime/sandbox/git-bootstrap.ts new file mode 100644 index 00000000000..7dc7133e2e9 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/git-bootstrap.ts @@ -0,0 +1,135 @@ +/** + * Isolated git metadata for sandboxed workspaces. + * + * The main repo's .git is never mounted into a sandbox. Instead each + * workspace gets a host-side git dir (bind-mounted at CONTAINER_GIT_DIR) + * bootstrapped from the repo, laid out exactly like `--separate-git-dir`: + * the worktree's real `.git` pointer file is masked in-container by a + * read-only one-line file pointing at CONTAINER_GIT_DIR, so git inside the + * sandbox resolves the isolated metadata from any subdirectory with no env + * vars. In-container commits stay sandbox-local until explicitly synced. + */ + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { CONTAINER_GIT_DIR, getWorkspaceSandboxPaths } from "./paths.ts"; + +const execFileAsync = promisify(execFile); + +const GIT_TIMEOUT_MS = 5 * 60_000; + +export interface GitBootstrapParams { + workspaceId: string; + repoPath: string; + branch: string; + worktreePath: string; + cloneDepth?: number; +} + +export interface GitCommand { + argv: string[]; + env?: Record; +} + +/** + * The git command sequence that produces the sandbox git dir. Pure so tests + * can assert the plan without running git. `headSha` is the worktree's + * current HEAD — the shared repo's ref for the branch may lag behind it. + */ +export function buildGitBootstrapCommands( + params: GitBootstrapParams & { headSha: string; gitDir: string }, +): GitCommand[] { + const { repoPath, branch, worktreePath, cloneDepth, headSha, gitDir } = + params; + return [ + { + // file:// forces a real object copy — a hardlink/alternates clone + // would dangle inside the container where the main repo .git is + // not mounted. + argv: [ + "clone", + "--bare", + "--single-branch", + "--branch", + branch, + ...(cloneDepth ? ["--depth", String(cloneDepth)] : []), + `file://${repoPath}`, + gitDir, + ], + }, + { argv: ["--git-dir", gitDir, "config", "core.bare", "false"] }, + { argv: ["--git-dir", gitDir, "config", "core.worktree", worktreePath] }, + { argv: ["--git-dir", gitDir, "config", "core.logAllRefUpdates", "true"] }, + { + argv: [ + "--git-dir", + gitDir, + "update-ref", + `refs/heads/${branch}`, + headSha, + ], + }, + { + argv: [ + "--git-dir", + gitDir, + "symbolic-ref", + "HEAD", + `refs/heads/${branch}`, + ], + }, + { + // Seed the index to HEAD so the first in-container `git status` + // reports clean instead of everything-deleted. + argv: ["read-tree", "HEAD"], + env: { GIT_DIR: gitDir, GIT_WORK_TREE: worktreePath }, + }, + ]; +} + +async function git(command: GitCommand, cwd: string): Promise { + await execFileAsync("git", command.argv, { + cwd, + timeout: GIT_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, ...command.env }, + }); +} + +/** + * Idempotently create the sandbox git dir + .git mask for a workspace. + * Skips everything when the git dir already exists (container recreation, + * host-service restarts). Errors propagate — the caller surfaces them as + * terminal-create failures. + */ +export async function ensureSandboxGit( + params: GitBootstrapParams, +): Promise { + const paths = getWorkspaceSandboxPaths(params.workspaceId); + if (existsSync(join(paths.gitDir, "HEAD"))) return; + + await mkdir(paths.stateDir, { recursive: true }); + + const { stdout } = await execFileAsync( + "git", + ["-C", params.worktreePath, "rev-parse", "HEAD"], + { timeout: 30_000 }, + ); + const headSha = stdout.trim(); + + for (const command of buildGitBootstrapCommands({ + ...params, + headSha, + gitDir: paths.gitDir, + })) { + await git(command, params.repoPath); + } + + await writeFile(paths.dotGitFile, `gitdir: ${CONTAINER_GIT_DIR}\n`, { + mode: 0o444, + }); + await writeFile(paths.bootstrapShaFile, `${headSha}\n`, { mode: 0o644 }); +} diff --git a/packages/host-service/src/runtime/sandbox/host-runtime.test.ts b/packages/host-service/src/runtime/sandbox/host-runtime.test.ts index 0468b5575b2..c86672368ea 100644 --- a/packages/host-service/src/runtime/sandbox/host-runtime.test.ts +++ b/packages/host-service/src/runtime/sandbox/host-runtime.test.ts @@ -132,9 +132,17 @@ describe("HostRuntime", () => { expect(getHostAgentHookUrl()).toBe(""); }); - test("registry resolves every workspace to the host runtime today", () => { - const runtime = getWorkspaceRuntime("any-workspace"); + test("registry resolves unsandboxed workspaces to the shared host runtime", () => { + // Registry falls back to the host runtime when the workspace row is + // missing or has sandboxEnabled=false — a fake empty db covers both. + const fakeDb = { + query: { + workspaces: { findFirst: () => ({ sync: () => undefined }) }, + projects: { findFirst: () => ({ sync: () => undefined }) }, + }, + } as unknown as Parameters[0]; + const runtime = getWorkspaceRuntime(fakeDb, "any-workspace"); expect(runtime.kind).toBe("host"); - expect(runtime).toBe(getWorkspaceRuntime("another-workspace")); + expect(runtime).toBe(getWorkspaceRuntime(fakeDb, "another-workspace")); }); }); diff --git a/packages/host-service/src/runtime/sandbox/paths.ts b/packages/host-service/src/runtime/sandbox/paths.ts new file mode 100644 index 00000000000..b42b96d7829 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/paths.ts @@ -0,0 +1,53 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Mount target for the generated container superset-home (read-only). */ +export const CONTAINER_SUPERSET_DIR = "/opt/superset"; +/** Mount target for the per-workspace isolated git dir. */ +export const CONTAINER_GIT_DIR = "/sandbox/git"; +/** In-container SUPERSET_HOME_DIR contents live under CONTAINER_SUPERSET_DIR. */ +export const CONTAINER_BASH_RCFILE = `${CONTAINER_SUPERSET_DIR}/bash/rcfile`; + +export function getSupersetHomeDir(): string { + return process.env.SUPERSET_HOME_DIR || join(homedir(), ".superset"); +} + +/** Root for all host-side sandbox state. */ +export function getSandboxRootDir(): string { + return join(getSupersetHomeDir(), "sandbox"); +} + +/** + * Host-side directory generated by sandbox-home.ts and bind-mounted + * read-only at CONTAINER_SUPERSET_DIR in every sandbox container. + */ +export function getSandboxHomeDir(): string { + return join(getSandboxRootDir(), "home"); +} + +export interface WorkspaceSandboxPaths { + /** Per-workspace state root; removed on workspace delete. */ + stateDir: string; + /** Isolated git dir bind-mounted at CONTAINER_GIT_DIR. */ + gitDir: string; + /** One-line mask bind-mounted read-only over /.git. */ + dotGitFile: string; + /** HEAD sha the sandbox git dir was bootstrapped from. */ + bootstrapShaFile: string; +} + +export function getWorkspaceSandboxPaths( + workspaceId: string, +): WorkspaceSandboxPaths { + const stateDir = join(getSandboxRootDir(), "workspaces", workspaceId); + return { + stateDir, + gitDir: join(stateDir, "git"), + dotGitFile: join(stateDir, "dot-git"), + bootstrapShaFile: join(stateDir, "bootstrap-sha"), + }; +} + +export function getSandboxContainerName(workspaceId: string): string { + return `superset-ws-${workspaceId}`; +} diff --git a/packages/host-service/src/runtime/sandbox/registry.ts b/packages/host-service/src/runtime/sandbox/registry.ts index 768c4681c6d..65de18d7dc4 100644 --- a/packages/host-service/src/runtime/sandbox/registry.ts +++ b/packages/host-service/src/runtime/sandbox/registry.ts @@ -1,14 +1,86 @@ +import { eq } from "drizzle-orm"; +import type { HostDb } from "../../db/index.ts"; +import { projects, workspaces } from "../../db/schema.ts"; +import { loadSetupConfig } from "../setup/config.ts"; +import { computeConfigHash } from "./container-manager.ts"; +import { resolveSandboxSettings } from "./docker-args.ts"; +import { DockerRuntime } from "./docker-runtime.ts"; import { HostRuntime } from "./host-runtime.ts"; import type { WorkspaceRuntime } from "./workspace-runtime.ts"; const hostRuntime = new HostRuntime(); +const dockerRuntimes = new Map< + string, + { configHash: string; runtime: DockerRuntime } +>(); /** * Resolve the execution runtime for a workspace. * - * Always the host runtime today. Once per-workspace sandbox config lands, - * workspaces with sandboxing enabled resolve to a Docker runtime instead. + * The workspace row's sticky `sandboxEnabled` (snapshotted at create time) + * decides host vs docker; the sandbox details (image, resources, mounts) + * are re-read from config on every resolution so edits apply to the next + * container recreation without flipping a live workspace's mode. */ -export function getWorkspaceRuntime(_workspaceId: string): WorkspaceRuntime { - return hostRuntime; +export function getWorkspaceRuntime( + db: HostDb, + workspaceId: string, +): WorkspaceRuntime { + const workspace = db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + if (!workspace?.sandboxEnabled || !workspace.projectId) return hostRuntime; + + const project = db.query.projects + .findFirst({ where: eq(projects.id, workspace.projectId) }) + .sync(); + if (!project) return hostRuntime; + + const sandboxConfig = + loadSetupConfig({ + repoPath: project.repoPath, + projectId: project.id, + worktreePath: workspace.worktreePath, + })?.sandbox ?? {}; + const settings = resolveSandboxSettings(sandboxConfig); + const configHash = computeConfigHash(settings); + + const cached = dockerRuntimes.get(workspaceId); + if (cached && cached.configHash === configHash) return cached.runtime; + + const runtime = new DockerRuntime({ + workspaceId, + worktreePath: workspace.worktreePath, + repoPath: project.repoPath, + branch: workspace.branch, + settings, + }); + dockerRuntimes.set(workspaceId, { configHash, runtime }); + return runtime; +} + +/** Drop the cached runtime after a workspace is destroyed. */ +export function evictWorkspaceRuntime(workspaceId: string): void { + dockerRuntimes.delete(workspaceId); +} + +/** + * The sticky sandbox decision for a NEW workspace, resolved from the + * project's sandbox config at create time and persisted on the row. + */ +export function resolveSandboxEnabledForNewWorkspace( + db: HostDb, + projectId: string, + worktreePath: string, +): boolean { + const project = db.query.projects + .findFirst({ where: eq(projects.id, projectId) }) + .sync(); + if (!project) return false; + const config = loadSetupConfig({ + repoPath: project.repoPath, + projectId, + worktreePath, + }); + return config?.sandbox?.enabled === true; } diff --git a/packages/host-service/src/runtime/sandbox/sandbox-home.ts b/packages/host-service/src/runtime/sandbox/sandbox-home.ts new file mode 100644 index 00000000000..8472f192cd2 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/sandbox-home.ts @@ -0,0 +1,81 @@ +/** + * Generator for the container superset-home: a host-side directory + * bind-mounted read-only at CONTAINER_SUPERSET_DIR in every sandbox + * container, with SUPERSET_HOME_DIR pointing at it in-container. + * + * Contents mirror the host's desktop-generated ~/.superset wrappers where + * container-relevant: a bash rcfile that emits the OSC 133;A readiness + * marker (same contract terminal.ts scans for) and the notify.sh agent + * hook (copied from the host's rendered script — it resolves + * SUPERSET_HOME_DIR and SUPERSET_HOST_AGENT_HOOK_URL at runtime, so the + * exact same script works in-container). + */ + +import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + CONTAINER_SUPERSET_DIR, + getSandboxHomeDir, + getSupersetHomeDir, +} from "./paths.ts"; + +const CONTAINER_BIN_DIR = `${CONTAINER_SUPERSET_DIR}/bin`; + +/** + * Bash rcfile for sandbox shells. Mirrors the desktop bash wrapper's + * readiness contract: emits OSC 777 (legacy) + OSC 133;A (current scanner) + * on every prompt. shellLaunchExpectsReadyMarker-equivalent behavior is + * guaranteed by generation — DockerRuntime always reports marker support. + */ +function buildContainerBashRcfile(): string { + return `# Superset sandbox bash rcfile (generated — do not edit) + +[ -f /etc/profile ] && source /etc/profile +[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" + +case ":$PATH:" in + *":${CONTAINER_BIN_DIR}:"*) ;; + *) export PATH="${CONTAINER_BIN_DIR}:$PATH" ;; +esac + +export PS1=$'\\[\\e[1;38;2;52;211;153m\\]❯\\[\\e[0m\\] ' + +__superset_prompt_mark() { + printf "\\033]777;superset-shell-ready\\007\\033]133;A\\007" +} +if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then + PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "__superset_prompt_mark") +else + _superset_orig_prompt_cmd="\${PROMPT_COMMAND}" + if [[ -n "\${_superset_orig_prompt_cmd}" ]]; then + PROMPT_COMMAND="\${_superset_orig_prompt_cmd};__superset_prompt_mark" + else + PROMPT_COMMAND="__superset_prompt_mark" + fi +fi +`; +} + +/** + * Idempotently (re)generate the shared sandbox home. Called before every + * container ensure — cheap writes, and re-running picks up notify.sh + * updates without an image rebuild. + */ +export function ensureSandboxHome(): string { + const homeDir = getSandboxHomeDir(); + for (const sub of ["bin", "bash", "hooks"]) { + mkdirSync(join(homeDir, sub), { recursive: true }); + } + + writeFileSync(join(homeDir, "bash", "rcfile"), buildContainerBashRcfile(), { + mode: 0o644, + }); + + const hostNotifyScript = join(getSupersetHomeDir(), "hooks", "notify.sh"); + if (existsSync(hostNotifyScript)) { + const target = join(homeDir, "hooks", "notify.sh"); + copyFileSync(hostNotifyScript, target); + } + + return homeDir; +} diff --git a/packages/host-service/src/runtime/sandbox/sandbox-reconcile.ts b/packages/host-service/src/runtime/sandbox/sandbox-reconcile.ts new file mode 100644 index 00000000000..37499a920ed --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/sandbox-reconcile.ts @@ -0,0 +1,38 @@ +import { eq } from "drizzle-orm"; +import type { HostDb } from "../../db/index.ts"; +import { workspaces } from "../../db/schema.ts"; +import { + checkDockerAvailable, + listManagedContainers, + removeContainer, +} from "./docker-cli.ts"; + +/** + * Startup sweep: remove Superset-managed containers whose workspace row is + * gone or archived (deleted while docker was down, crashed mid-destroy). + * Never starts containers — the first PTY of a live workspace does that. + * Quiet no-op when docker isn't running. + */ +export async function runSandboxReconcile(db: HostDb): Promise { + const availability = await checkDockerAvailable(); + if (!availability.ok) return; + + const containers = await listManagedContainers(); + if (containers.length === 0) return; + + for (const container of containers) { + const workspace = container.workspaceId + ? db.query.workspaces + .findFirst({ + where: eq(workspaces.id, container.workspaceId), + }) + .sync() + : undefined; + const live = workspace && workspace.archivedAt === null; + if (live) continue; + console.log( + `[sandbox] reconcile: removing orphan container ${container.name}`, + ); + await removeContainer(container.name); + } +} diff --git a/packages/host-service/src/runtime/setup/config.ts b/packages/host-service/src/runtime/setup/config.ts index 65b24d6e73d..b88d2c635b0 100644 --- a/packages/host-service/src/runtime/setup/config.ts +++ b/packages/host-service/src/runtime/setup/config.ts @@ -1,6 +1,11 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { + mergeSandboxConfigs, + type SandboxConfig, + validateSandboxConfig, +} from "./sandbox-config.ts"; const PROJECT_SUPERSET_DIR_NAME = ".superset"; const CONFIG_FILE_NAME = "config.json"; @@ -13,6 +18,7 @@ export interface SetupConfig { teardown?: string[]; run?: string[]; cwd?: string; + sandbox?: SandboxConfig; } interface LocalScriptMerge { @@ -24,6 +30,7 @@ interface LocalSetupConfig { setup?: string[] | LocalScriptMerge; teardown?: string[] | LocalScriptMerge; run?: string[] | LocalScriptMerge; + sandbox?: SandboxConfig; } const SCRIPT_KEYS = ["setup", "teardown", "run"] as const; @@ -50,12 +57,18 @@ function readJson(filePath: string): T | null { function validateSetupConfig( parsed: unknown, source: string, + options: { machineLocal: boolean }, ): SetupConfig | null { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return null; } const obj = parsed as Record; const result: SetupConfig = {}; + if (obj.sandbox !== undefined) { + const sandbox = validateSandboxConfig(obj.sandbox, source, options); + if (sandbox === null) return null; + result.sandbox = sandbox; + } if (obj.cwd !== undefined) { if (typeof obj.cwd !== "string" || obj.cwd.trim().length === 0) { console.error( @@ -79,10 +92,13 @@ function validateSetupConfig( return result; } -function readSetupConfigAt(filePath: string): SetupConfig | null { +function readSetupConfigAt( + filePath: string, + options: { machineLocal: boolean }, +): SetupConfig | null { const parsed = readJson(filePath); if (parsed === null) return null; - return validateSetupConfig(parsed, filePath); + return validateSetupConfig(parsed, filePath, options); } function readLocalConfigAt(filePath: string): LocalSetupConfig | null { @@ -93,6 +109,14 @@ function readLocalConfigAt(filePath: string): LocalSetupConfig | null { } const obj = parsed as Record; const result: LocalSetupConfig = {}; + if (obj.sandbox !== undefined) { + // config.local.json is gitignored — machine-local by definition. + const sandbox = validateSandboxConfig(obj.sandbox, filePath, { + machineLocal: true, + }); + if (sandbox === null) return null; + result.sandbox = sandbox; + } for (const key of SCRIPT_KEYS) { const value = obj[key]; if (value === undefined) continue; @@ -139,6 +163,7 @@ function mergeBaseConfigs( teardown: override.teardown ?? base.teardown, run: override.run ?? base.run, cwd: override.cwd ?? base.cwd, + sandbox: mergeSandboxConfigs(base.sandbox, override.sandbox), }; } @@ -158,6 +183,9 @@ function applyLocalOverlay( result[key] = [...before, ...(base[key] ?? []), ...after]; } } + if (local.sandbox !== undefined) { + result.sandbox = mergeSandboxConfigs(base.sandbox, local.sandbox); + } return result; } @@ -212,9 +240,15 @@ export function loadSetupConfig(args: { /** Override $HOME for tests. Defaults to `os.homedir()`. */ homeDir?: string; }): SetupConfig | null { - const projectConfig = readSetupConfigAt(getProjectConfigPath(args.repoPath)); + // Repo-shipped sources: a cloned repo controls these, so sandbox + // mounts/env are not honored from them (see validateSandboxConfig). + const projectConfig = readSetupConfigAt(getProjectConfigPath(args.repoPath), { + machineLocal: false, + }); const worktreeConfig = args.worktreePath - ? readSetupConfigAt(getProjectConfigPath(args.worktreePath)) + ? readSetupConfigAt(getProjectConfigPath(args.worktreePath), { + machineLocal: false, + }) : null; let userConfig: SetupConfig | null = null; @@ -223,7 +257,7 @@ export function loadSetupConfig(args: { projectId: args.projectId, homeDir: args.homeDir ?? homedir(), })) { - userConfig = readSetupConfigAt(overridePath); + userConfig = readSetupConfigAt(overridePath, { machineLocal: true }); if (userConfig) break; } diff --git a/packages/host-service/src/runtime/setup/sandbox-config.test.ts b/packages/host-service/src/runtime/setup/sandbox-config.test.ts new file mode 100644 index 00000000000..d2341cba110 --- /dev/null +++ b/packages/host-service/src/runtime/setup/sandbox-config.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { + mergeSandboxConfigs, + validateSandboxConfig, +} from "./sandbox-config.ts"; + +const MACHINE_LOCAL = { machineLocal: true }; +const REPO_SOURCE = { machineLocal: false }; + +describe("validateSandboxConfig", () => { + test("accepts a full valid config from a machine-local source", () => { + const config = validateSandboxConfig( + { + enabled: true, + image: "ghcr.io/acme/sandbox:1", + runtime: "runsc", + network: "none", + ports: [3000, 5173, 3000], + resources: { cpus: 4, memoryMb: 8192, pidsLimit: 1024 }, + mounts: ["/opt/cache", "/etc/certs:ro"], + env: ["NPM_TOKEN", "MY_VAR"], + git: { cloneDepth: 50 }, + }, + "test.json", + MACHINE_LOCAL, + ); + expect(config).toEqual({ + enabled: true, + image: "ghcr.io/acme/sandbox:1", + runtime: "runsc", + network: "none", + ports: [3000, 5173], + resources: { cpus: 4, memoryMb: 8192, pidsLimit: 1024 }, + mounts: ["/opt/cache", "/etc/certs:ro"], + env: ["NPM_TOKEN", "MY_VAR"], + git: { cloneDepth: 50 }, + }); + }); + + test("drops mounts and env from repo-shipped sources but keeps the rest", () => { + const config = validateSandboxConfig( + { + enabled: true, + mounts: ["/Users/victim/.ssh"], + env: ["AWS_SECRET_ACCESS_KEY"], + }, + "repo/.superset/config.json", + REPO_SOURCE, + ); + expect(config).toEqual({ enabled: true }); + }); + + test.each([ + ["enabled", { enabled: "yes" }], + ["image", { image: "" }], + ["network", { network: "host" }], + ["ports range", { ports: [0] }], + ["ports type", { ports: ["3000"] }], + ["resources.cpus", { resources: { cpus: -1 } }], + ["resources.memoryMb integer", { resources: { memoryMb: 1.5 } }], + ["git.cloneDepth", { git: { cloneDepth: 0 } }], + ["env name", { env: ["1BAD"] }], + ["mount relative path", { mounts: ["relative/path"] }], + ["non-object", []], + ])("rejects invalid %s", (_label, value) => { + expect(validateSandboxConfig(value, "test.json", MACHINE_LOCAL)).toBeNull(); + }); + + test("trims image and runtime", () => { + expect( + validateSandboxConfig( + { image: " img:1 ", runtime: " runsc " }, + "t", + MACHINE_LOCAL, + ), + ).toEqual({ image: "img:1", runtime: "runsc" }); + }); +}); + +describe("mergeSandboxConfigs", () => { + test("later wins per field; nested objects merge one level deep", () => { + expect( + mergeSandboxConfigs( + { + enabled: true, + image: "base:1", + ports: [3000], + resources: { cpus: 2, memoryMb: 4096 }, + git: { cloneDepth: 10 }, + }, + { + image: "override:2", + ports: [8080], + resources: { memoryMb: 8192 }, + }, + ), + ).toEqual({ + enabled: true, + image: "override:2", + ports: [8080], + resources: { cpus: 2, memoryMb: 8192 }, + git: { cloneDepth: 10 }, + }); + }); + + test("returns the other side when one is undefined", () => { + const config = { enabled: true }; + expect(mergeSandboxConfigs(undefined, config)).toBe(config); + expect(mergeSandboxConfigs(config, undefined)).toBe(config); + }); + + test("override can disable a base-enabled sandbox", () => { + expect( + mergeSandboxConfigs({ enabled: true }, { enabled: false })?.enabled, + ).toBe(false); + }); +}); diff --git a/packages/host-service/src/runtime/setup/sandbox-config.ts b/packages/host-service/src/runtime/setup/sandbox-config.ts new file mode 100644 index 00000000000..74258e7551f --- /dev/null +++ b/packages/host-service/src/runtime/setup/sandbox-config.ts @@ -0,0 +1,262 @@ +/** + * Sandbox configuration for Docker-sandboxed workspaces. + * + * Parsed as the `sandbox` key of `.superset/config.json` through the same + * three-tier merge as setup/teardown/run (see config.ts). Security rule: + * `mounts` and `env` are honored only from machine-local sources (the user + * override under ~/.superset/projects/ and config.local.json) — a cloned + * repo must never be able to mount host paths into its own sandbox. + */ + +export type SandboxNetworkMode = "bridge" | "none"; + +export interface SandboxResources { + /** docker --cpus */ + cpus?: number; + /** docker --memory (megabytes) */ + memoryMb?: number; + /** docker --pids-limit */ + pidsLimit?: number; +} + +export interface SandboxGitConfig { + /** Shallow-clone depth for the sandbox git dir. Omit = full history. */ + cloneDepth?: number; +} + +export interface SandboxConfig { + /** Default false. New workspaces snapshot this at create time. */ + enabled?: boolean; + /** Sandbox image reference. Defaults to the Superset sandbox image. */ + image?: string; + /** OCI runtime name passed to docker --runtime (e.g. "runsc"). */ + runtime?: string; + /** Default "bridge". "none" disables container networking entirely. */ + network?: SandboxNetworkMode; + /** Container ports to publish on 127.0.0.1 at container create. */ + ports?: number[]; + resources?: SandboxResources; + /** Extra bind mounts, "path" or "path:ro". Machine-local sources only. */ + mounts?: string[]; + /** Env var names forwarded from the host env. Machine-local sources only. */ + env?: string[]; + git?: SandboxGitConfig; +} + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") + ); +} + +function invalid(source: string, message: string): null { + console.error(`Invalid sandbox config at ${source}: ${message}`); + return null; +} + +function validatePositiveNumber( + value: unknown, + source: string, + field: string, + { integer }: { integer: boolean }, +): number | null | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value <= 0 || + (integer && !Number.isInteger(value)) + ) { + return invalid( + source, + `'${field}' must be a positive ${integer ? "integer" : "number"}`, + ); + } + return value; +} + +function validateResources( + value: unknown, + source: string, +): SandboxResources | null | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalid(source, "'resources' must be an object"); + } + const obj = value as Record; + const cpus = validatePositiveNumber(obj.cpus, source, "resources.cpus", { + integer: false, + }); + if (cpus === null) return null; + const memoryMb = validatePositiveNumber( + obj.memoryMb, + source, + "resources.memoryMb", + { integer: true }, + ); + if (memoryMb === null) return null; + const pidsLimit = validatePositiveNumber( + obj.pidsLimit, + source, + "resources.pidsLimit", + { integer: true }, + ); + if (pidsLimit === null) return null; + return { + ...(cpus !== undefined && { cpus }), + ...(memoryMb !== undefined && { memoryMb }), + ...(pidsLimit !== undefined && { pidsLimit }), + }; +} + +function validatePorts( + value: unknown, + source: string, +): number[] | null | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) return invalid(source, "'ports' must be an array"); + const ports: number[] = []; + for (const port of value) { + if ( + typeof port !== "number" || + !Number.isInteger(port) || + port < 1 || + port > 65535 + ) { + return invalid(source, "'ports' entries must be integers in 1..65535"); + } + if (!ports.includes(port)) ports.push(port); + } + return ports; +} + +/** + * Validate the `sandbox` value of one config source. Returns null when the + * value is structurally invalid (rejecting the whole source, matching how + * setup/teardown/run behave). `machineLocal` marks sources the repo cannot + * ship (user override, config.local.json); `mounts`/`env` from any other + * source are dropped with a warning rather than honored. + */ +export function validateSandboxConfig( + value: unknown, + source: string, + options: { machineLocal: boolean }, +): SandboxConfig | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalid(source, "'sandbox' must be an object"); + } + const obj = value as Record; + const result: SandboxConfig = {}; + + if (obj.enabled !== undefined) { + if (typeof obj.enabled !== "boolean") { + return invalid(source, "'enabled' must be a boolean"); + } + result.enabled = obj.enabled; + } + for (const field of ["image", "runtime"] as const) { + const fieldValue = obj[field]; + if (fieldValue === undefined) continue; + if (typeof fieldValue !== "string" || fieldValue.trim().length === 0) { + return invalid(source, `'${field}' must be a non-empty string`); + } + result[field] = fieldValue.trim(); + } + if (obj.network !== undefined) { + if (obj.network !== "bridge" && obj.network !== "none") { + return invalid(source, `'network' must be "bridge" or "none"`); + } + result.network = obj.network; + } + + const ports = validatePorts(obj.ports, source); + if (ports === null) return null; + if (ports !== undefined) result.ports = ports; + + const resources = validateResources(obj.resources, source); + if (resources === null) return null; + if (resources !== undefined) result.resources = resources; + + if (obj.git !== undefined) { + if (!obj.git || typeof obj.git !== "object" || Array.isArray(obj.git)) { + return invalid(source, "'git' must be an object"); + } + const cloneDepth = validatePositiveNumber( + (obj.git as Record).cloneDepth, + source, + "git.cloneDepth", + { integer: true }, + ); + if (cloneDepth === null) return null; + result.git = cloneDepth !== undefined ? { cloneDepth } : {}; + } + + for (const field of ["mounts", "env"] as const) { + const fieldValue = obj[field]; + if (fieldValue === undefined) continue; + if (!isStringArray(fieldValue)) { + return invalid(source, `'${field}' must be an array of strings`); + } + if (!options.machineLocal) { + console.warn( + `Ignoring sandbox '${field}' from ${source}: only machine-local ` + + "config (~/.superset/projects/... or config.local.json) may set it", + ); + continue; + } + if (field === "env") { + for (const name of fieldValue) { + if (!ENV_NAME_RE.test(name)) { + return invalid( + source, + `'env' entry ${JSON.stringify(name)} is not a valid variable name`, + ); + } + } + } else { + for (const mount of fieldValue) { + const path = mount.endsWith(":ro") ? mount.slice(0, -3) : mount; + if (path.trim().length === 0 || !path.startsWith("/")) { + return invalid( + source, + `'mounts' entry ${JSON.stringify(mount)} must be an absolute path, optionally ":ro"-suffixed`, + ); + } + } + } + result[field] = fieldValue; + } + + return result; +} + +/** + * Per-field later-wins merge, one level deep for `resources`/`git`. + * Arrays replace wholesale, matching setup/teardown/run semantics. + */ +export function mergeSandboxConfigs( + base: SandboxConfig | undefined, + override: SandboxConfig | undefined, +): SandboxConfig | undefined { + if (!base) return override; + if (!override) return base; + const resources = + base.resources || override.resources + ? { ...base.resources, ...override.resources } + : undefined; + const git = + base.git || override.git ? { ...base.git, ...override.git } : undefined; + return { + enabled: override.enabled ?? base.enabled, + image: override.image ?? base.image, + runtime: override.runtime ?? base.runtime, + network: override.network ?? base.network, + ports: override.ports ?? base.ports, + mounts: override.mounts ?? base.mounts, + env: override.env ?? base.env, + ...(resources && { resources }), + ...(git && { git }), + }; +} diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index d7030ac9d8a..5b034b1ccaa 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -30,6 +30,7 @@ import { projects, terminalSessions, workspaces } from "../db/schema.ts"; import type { EventBus } from "../events/index.ts"; import { portManager } from "../ports/port-manager.ts"; import { getWorkspaceRuntime } from "../runtime/sandbox/registry.ts"; +import type { PtyLaunchSpec } from "../runtime/sandbox/workspace-runtime.ts"; import { sweepAgentBindingsAfterDaemonLoss } from "../terminal-agents/daemon-loss-sweep.ts"; import { markTerminalAgentBindingEnded } from "../terminal-agents/persistence.ts"; import { @@ -2430,17 +2431,28 @@ export async function createTerminalSessionInternal({ ); // The workspace runtime decides where the PTY's process tree lives (host - // shell today, sandbox container when enabled) and builds the launch spec. - const runtime = getWorkspaceRuntime(workspaceId); - await runtime.prepare(); - const launch = await runtime.buildPtyLaunch({ - terminalId, - workspaceId, - workspacePath: workspace.worktreePath, - rootPath, - cwd, - themeType, - }); + // shell, or the workspace's sandbox container) and builds the launch spec. + const runtime = getWorkspaceRuntime(db, workspaceId); + let launch: PtyLaunchSpec; + try { + await runtime.prepare(); + launch = await runtime.buildPtyLaunch({ + terminalId, + workspaceId, + workspacePath: workspace.worktreePath, + rootPath, + cwd, + themeType, + }); + } catch (error) { + // Sandbox provisioning failures (docker down, image pull failed) are + // user-actionable terminal-create errors, not 500s. + return { + kind: "TERMINAL_START_FAILED", + error: + error instanceof Error ? error.message : "Failed to start terminal", + }; + } let daemon: DaemonClient; try { diff --git a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts index 4310807199f..5520efce04d 100644 --- a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts +++ b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts @@ -7,6 +7,8 @@ import { z } from "zod"; import { pullRequests } from "../../../db/schema"; import { invalidateLabelCache } from "../../../ports/static-ports"; import { coercePullRequestState } from "../../../runtime/pull-requests/utils/pull-request-mappers"; +import { destroyWorkspaceSandbox } from "../../../runtime/sandbox/container-manager"; +import { evictWorkspaceRuntime } from "../../../runtime/sandbox/registry"; import { runTeardown, type TeardownResult } from "../../../runtime/teardown"; import { disposeSessionsByWorkspaceId } from "../../../terminal/terminal"; import type { HostServiceContext } from "../../../types"; @@ -404,6 +406,21 @@ async function runDestroyPhases( warnings.push(`Failed to dispose terminal sessions: ${message}`); } + // 3a½. Sandbox container + host-side sandbox state. After terminal + // disposal (the PTYs are docker-exec children of the container) and + // before worktree removal (the container bind-mounts the worktree). A + // failure never blocks the delete — the startup reconcile sweeps + // orphaned containers once docker is back. + if (local?.sandboxEnabled) { + try { + await destroyWorkspaceSandbox(input.workspaceId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + warnings.push(`Failed to remove sandbox container: ${message}`); + } + evictWorkspaceRuntime(input.workspaceId); + } + // 3b. Worktree. Double-force unlocks the rare locked-worktree case and // clears stale metadata when the directory was manually removed. // Runs in the worker pool: the removal is a recursive delete of the diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index 018dc02fea8..b844aa44884 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { createGitEnvResolver } from "../../../runtime/git"; import { type ResolvedRef, resolveRef } from "../../../runtime/git/refs"; +import { resolveSandboxEnabledForNewWorkspace } from "../../../runtime/sandbox/registry"; import type { HostServiceContext } from "../../../types"; import { getHostWorkerPool } from "../../../workers/host-worker-pool"; import { gitFetchBaseRefTask } from "../../../workers/tasks/git"; @@ -477,6 +478,11 @@ async function registerLocalWorkspace(args: { branch: args.branch, name: args.name, taskId: args.taskId ?? null, + sandboxEnabled: resolveSandboxEnabledForNewWorkspace( + ctx.db, + args.projectId, + args.worktreePath, + ), }); } catch (err) { await args.rollbackWorktree(); diff --git a/packages/host-service/src/workspaces/local-workspace-store.ts b/packages/host-service/src/workspaces/local-workspace-store.ts index fae9571e8d0..ebf4c80be7f 100644 --- a/packages/host-service/src/workspaces/local-workspace-store.ts +++ b/packages/host-service/src/workspaces/local-workspace-store.ts @@ -91,6 +91,7 @@ export function toWorkspaceSnapshot(row: HostWorkspaceRow): WorkspaceSnapshot { createdByUserId: row.createdByUserId, createdAt: row.createdAt, updatedAt: row.updatedAt || row.createdAt, + sandboxed: row.sandboxEnabled, }; } @@ -132,6 +133,8 @@ export interface InsertLocalWorkspaceValues { type?: "main" | "worktree" | "session"; taskId?: string | null; createdByUserId?: string | null; + /** Sticky sandbox decision resolved from config at create time. */ + sandboxEnabled?: boolean; } /** @@ -155,6 +158,7 @@ export function insertLocalWorkspace( type: values.type ?? "worktree", taskId: values.taskId ?? null, createdByUserId: values.createdByUserId ?? null, + sandboxEnabled: values.sandboxEnabled ?? false, createdAt: now, updatedAt: now, }) From c15657030698dc4260e1b6954743789d1f511c01 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 22:21:27 +0200 Subject: [PATCH 03/21] test(host-service): docker-gated sandbox integration test Full round-trip against a real docker daemon (SUPERSET_DOCKER_TESTS=1): ensure builds the isolated git dir + container, in-container git resolves /sandbox/git through the read-only .git mask, commits stay sandbox-local while the host sees only working-tree changes, destroy removes container and state. Skipped without the env gate. --- .../sandbox-docker.integration.test.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts diff --git a/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts b/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts new file mode 100644 index 00000000000..1d989c9baed --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts @@ -0,0 +1,199 @@ +/** + * Docker-gated integration test for the sandbox container lifecycle. + * Exercises the REAL code path (git bootstrap → container ensure → exec → + * destroy) against a local docker daemon. Skipped unless + * SUPERSET_DOCKER_TESTS=1 — CI runs it in a linux job with docker. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { execFile } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + destroyWorkspaceSandbox, + ensureContainer, +} from "./container-manager.ts"; +import { resolveSandboxSettings } from "./docker-args.ts"; +import { getDockerCliEnv, inspectContainer } from "./docker-cli.ts"; +import { getSandboxContainerName, getWorkspaceSandboxPaths } from "./paths.ts"; + +const execFileAsync = promisify(execFile); +const DOCKER_TESTS = process.env.SUPERSET_DOCKER_TESTS === "1"; + +const TEST_IMAGE = "superset-sandbox-inttest:local"; +const WORKSPACE_ID = "inttest-sandbox-ws"; + +async function run( + command: string, + args: string[], + options?: { cwd?: string; input?: string }, +): Promise { + const { stdout } = await execFileAsync(command, args, { + cwd: options?.cwd, + env: { ...process.env, ...getDockerCliEnv() }, + timeout: 5 * 60_000, + maxBuffer: 16 * 1024 * 1024, + }); + return stdout; +} + +async function dockerExec(container: string, script: string): Promise { + return run("docker", ["exec", container, "bash", "-lc", script]); +} + +describe.skipIf(!DOCKER_TESTS)("sandbox docker integration", () => { + let fixtureRoot: string; + let repoPath: string; + let worktreePath: string; + let savedHomeDir: string | undefined; + const containerName = getSandboxContainerName(WORKSPACE_ID); + + beforeAll(async () => { + fixtureRoot = mkdtempSync(join(tmpdir(), "superset-sandbox-int-")); + savedHomeDir = process.env.SUPERSET_HOME_DIR; + process.env.SUPERSET_HOME_DIR = join(fixtureRoot, "superset-home"); + + // Minimal image satisfying the sandbox contract (bash + git). + // Debian keeps bash as /bin/bash; alpine would need a symlink. + const dockerfilePath = join(fixtureRoot, "Dockerfile.inttest"); + await Bun.write( + dockerfilePath, + "FROM debian:bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*\n", + ); + await run("docker", [ + "build", + "-t", + TEST_IMAGE, + "-f", + dockerfilePath, + fixtureRoot, + ]); + + // Fixture: main repo + worktree, the shape Superset creates. + repoPath = join(fixtureRoot, "repo"); + worktreePath = join(fixtureRoot, "wt"); + await run("git", ["init", "-q", repoPath]); + const gitC = (...args: string[]) => + run("git", [ + "-C", + repoPath, + "-c", + "user.email=t@t", + "-c", + "user.name=t", + ...args, + ]); + await gitC("commit", "-q", "--allow-empty", "-m", "init"); + await run("git", [ + "-C", + repoPath, + "worktree", + "add", + "-q", + worktreePath, + "-b", + "feature", + ]); + await Bun.write(join(worktreePath, "file.txt"), "hello\n"); + await run("git", ["-C", worktreePath, "add", "file.txt"]); + await run("git", [ + "-C", + worktreePath, + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "-m", + "one", + ]); + }, 10 * 60_000); + + afterAll(async () => { + await destroyWorkspaceSandbox(WORKSPACE_ID).catch(() => {}); + if (savedHomeDir === undefined) delete process.env.SUPERSET_HOME_DIR; + else process.env.SUPERSET_HOME_DIR = savedHomeDir; + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + test( + "ensure → isolated git → destroy round-trip", + async () => { + await ensureContainer({ + workspaceId: WORKSPACE_ID, + worktreePath, + repoPath, + branch: "feature", + settings: resolveSandboxSettings({ image: TEST_IMAGE }), + }); + + const inspection = await inspectContainer(containerName); + expect(inspection.exists).toBe(true); + expect(inspection.running).toBe(true); + + // Idempotent re-ensure. + await ensureContainer({ + workspaceId: WORKSPACE_ID, + worktreePath, + repoPath, + branch: "feature", + settings: resolveSandboxSettings({ image: TEST_IMAGE }), + }); + + // In-container git resolves the isolated git dir via the mask. + const gitDir = await dockerExec( + containerName, + `cd ${worktreePath} && git rev-parse --git-dir`, + ); + expect(gitDir.trim()).toBe("/sandbox/git"); + + const status = await dockerExec( + containerName, + `cd ${worktreePath} && git status --porcelain`, + ); + expect(status.trim()).toBe(""); + + // The mask is read-only and hides the real pointer. + const maskContent = await dockerExec( + containerName, + `cat ${worktreePath}/.git`, + ); + expect(maskContent).toContain("gitdir: /sandbox/git"); + await expect( + dockerExec(containerName, `echo x > ${worktreePath}/.git`), + ).rejects.toThrow(); + + // In-container commit stays sandbox-local. + await dockerExec( + containerName, + `cd ${worktreePath} && echo changed >> file.txt && git add file.txt && git -c user.email=a@a -c user.name=agent commit -q -m in-container`, + ); + const hostLog = await run("git", [ + "-C", + worktreePath, + "log", + "--oneline", + ]); + expect(hostLog).not.toContain("in-container"); + const hostStatus = await run("git", [ + "-C", + worktreePath, + "status", + "--porcelain", + ]); + expect(hostStatus).toContain("M file.txt"); + + // Destroy removes container and host-side state. + await destroyWorkspaceSandbox(WORKSPACE_ID); + const afterDestroy = await inspectContainer(containerName); + expect(afterDestroy.exists).toBe(false); + expect(existsSync(getWorkspaceSandboxPaths(WORKSPACE_ID).stateDir)).toBe( + false, + ); + }, + 10 * 60_000, + ); +}); From a2f4bee847e88d1c04ce1ae0ea59e1a3d842fe82 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 22:30:26 +0200 Subject: [PATCH 04/21] feat(host-service): per-workspace agent-hook token for sandboxed PTYs Sandboxed terminals get a random SUPERSET_AGENT_HOOK_TOKEN; notify.sh (v9) echoes it as an x-superset-hook-token header and notifications.hook drops events carrying a wrong token for the workspace. Verification is tolerant: host workspaces and pre-update scripts (no token) keep working. Tokens are in-memory per host-service lifetime and dropped on sandbox destroy. --- .../main/lib/agent-setup/notify-hook.test.ts | 2 +- .../src/main/lib/agent-setup/notify-hook.ts | 2 +- .../templates/notify-hook.template.sh | 9 ++++ packages/host-service/src/app.ts | 2 + .../src/runtime/sandbox/docker-runtime.ts | 19 +++++--- .../runtime/sandbox/sandbox-tokens.test.ts | 38 +++++++++++++++ .../src/runtime/sandbox/sandbox-tokens.ts | 46 +++++++++++++++++++ .../router/notifications/notifications.ts | 13 ++++++ packages/host-service/src/types.ts | 2 + 9 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 packages/host-service/src/runtime/sandbox/sandbox-tokens.test.ts create mode 100644 packages/host-service/src/runtime/sandbox/sandbox-tokens.ts diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index 3979106eaa4..a91e531792b 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -37,7 +37,7 @@ function runNotifyHook( describe("getNotifyScriptContent", () => { it("bumps the notify hook marker when hook semantics change", () => { - expect(NOTIFY_SCRIPT_MARKER).toBe("# Superset agent notification hook v8"); + expect(NOTIFY_SCRIPT_MARKER).toBe("# Superset agent notification hook v9"); }); it("ignores hooks fired inside a subagent (agent_id present)", () => { diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index ec4ca78fa1b..1793a152de8 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -4,7 +4,7 @@ import { env } from "shared/env.shared"; import { HOOKS_DIR } from "./paths"; export const NOTIFY_SCRIPT_NAME = "notify.sh"; -export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook v8"; +export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook v9"; const NOTIFY_SCRIPT_TEMPLATE_PATH = path.join( __dirname, diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index c0959700041..89d4d692bb5 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -115,9 +115,18 @@ json_escape() { if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"agent\":{\"agentId\":\"$(json_escape "$SUPERSET_AGENT_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\"}}}" + # Sandboxed workspaces inject a per-workspace token; host-service rejects + # present-but-wrong tokens and tolerates absence (older scripts). + if [ -n "$SUPERSET_AGENT_HOOK_TOKEN" ]; then + set -- -H "x-superset-hook-token: $SUPERSET_AGENT_HOOK_TOKEN" + else + set -- + fi + STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ --connect-timeout 2 --max-time 5 \ -H "Content-Type: application/json" \ + "$@" \ -d "$PAYLOAD" \ -o /dev/null -w "%{http_code}" 2>/dev/null) diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index fd4735c0897..14dbac97ecd 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -279,6 +279,8 @@ export function createApp(options: CreateAppOptions): CreateAppResult { isAuthenticated, clientMachineId: c.req.header("x-superset-client-machine-id") ?? undefined, + agentHookHeaderToken: + c.req.header("x-superset-hook-token") ?? undefined, } as Record; }, }), diff --git a/packages/host-service/src/runtime/sandbox/docker-runtime.ts b/packages/host-service/src/runtime/sandbox/docker-runtime.ts index 4386ad8b9e8..11085325943 100644 --- a/packages/host-service/src/runtime/sandbox/docker-runtime.ts +++ b/packages/host-service/src/runtime/sandbox/docker-runtime.ts @@ -7,6 +7,7 @@ import { buildExecArgs, type ResolvedSandboxSettings } from "./docker-args.ts"; import { getDockerCliEnv } from "./docker-cli.ts"; import { getHostAgentHookUrl } from "./host-runtime.ts"; import { CONTAINER_BASH_RCFILE, getSandboxContainerName } from "./paths.ts"; +import { dropHookToken, getOrCreateHookToken } from "./sandbox-tokens.ts"; import type { PtyLaunchSpec, TerminalLaunchContext, @@ -37,12 +38,17 @@ export class DockerRuntime implements WorkspaceRuntime { } async buildPtyLaunch(ctx: TerminalLaunchContext): Promise { - const env = buildContainerTerminalEnv({ - ctx, - hostAgentHookUrl: this.getAgentHookUrl(), - envPassthrough: this.params.settings.envPassthrough, - hostEnv: process.env, - }); + const env = { + ...buildContainerTerminalEnv({ + ctx, + hostAgentHookUrl: this.getAgentHookUrl(), + envPassthrough: this.params.settings.envPassthrough, + hostEnv: process.env, + }), + // notify.sh echoes this back; notifications.hook drops spoofed + // events carrying a wrong token for this workspace. + SUPERSET_AGENT_HOOK_TOKEN: getOrCreateHookToken(this.params.workspaceId), + }; return { shell: "docker", argv: buildExecArgs({ @@ -69,6 +75,7 @@ export class DockerRuntime implements WorkspaceRuntime { } async destroy(): Promise { + dropHookToken(this.params.workspaceId); await destroyWorkspaceSandbox(this.params.workspaceId); } } diff --git a/packages/host-service/src/runtime/sandbox/sandbox-tokens.test.ts b/packages/host-service/src/runtime/sandbox/sandbox-tokens.test.ts new file mode 100644 index 00000000000..bec89048fa9 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/sandbox-tokens.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + dropHookToken, + getOrCreateHookToken, + resetHookTokensForTests, + verifyHookToken, +} from "./sandbox-tokens.ts"; + +describe("sandbox hook tokens", () => { + afterEach(() => { + resetHookTokensForTests(); + }); + + test("stable per workspace and distinct across workspaces", () => { + const a = getOrCreateHookToken("ws-a"); + expect(getOrCreateHookToken("ws-a")).toBe(a); + expect(getOrCreateHookToken("ws-b")).not.toBe(a); + }); + + test("verification is tolerant of absence, strict on mismatch", () => { + // No token registered (host workspace): everything passes. + expect(verifyHookToken("ws-none", undefined)).toBe(true); + expect(verifyHookToken("ws-none", "whatever")).toBe(true); + + const token = getOrCreateHookToken("ws-a"); + // Token-less request from a pre-update notify script passes. + expect(verifyHookToken("ws-a", undefined)).toBe(true); + expect(verifyHookToken("ws-a", token)).toBe(true); + expect(verifyHookToken("ws-a", "wrong")).toBe(false); + expect(verifyHookToken("ws-a", `${token}x`)).toBe(false); + }); + + test("dropped tokens stop constraining verification", () => { + getOrCreateHookToken("ws-a"); + dropHookToken("ws-a"); + expect(verifyHookToken("ws-a", "anything")).toBe(true); + }); +}); diff --git a/packages/host-service/src/runtime/sandbox/sandbox-tokens.ts b/packages/host-service/src/runtime/sandbox/sandbox-tokens.ts new file mode 100644 index 00000000000..c0644175c5d --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/sandbox-tokens.ts @@ -0,0 +1,46 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * Per-workspace agent-hook tokens for sandboxed workspaces. + * + * The notifications.hook endpoint is deliberately unauthenticated (see the + * router), but sandboxed PTYs get a random per-workspace token injected as + * SUPERSET_AGENT_HOOK_TOKEN and echoed back as a request header, scoping + * spoofed lifecycle events without putting the host PSK in agent shells. + * In-memory only — regenerated per host-service lifetime; verification is + * tolerant of token-less requests so pre-update notify scripts keep working. + */ + +const hookTokens = new Map(); + +export function getOrCreateHookToken(workspaceId: string): string { + const existing = hookTokens.get(workspaceId); + if (existing) return existing; + const token = randomBytes(24).toString("hex"); + hookTokens.set(workspaceId, token); + return token; +} + +export function dropHookToken(workspaceId: string): void { + hookTokens.delete(workspaceId); +} + +/** + * Tolerant verification: only a PRESENT-but-mismatched token is rejected. + * No registered token (host workspaces) and token-less requests (pre-update + * notify scripts) both pass. + */ +export function verifyHookToken( + workspaceId: string, + presented: string | undefined, +): boolean { + const registered = hookTokens.get(workspaceId); + if (!registered || presented === undefined) return true; + const a = Buffer.from(presented); + const b = Buffer.from(registered); + return a.length === b.length && timingSafeEqual(a, b); +} + +export function resetHookTokensForTests(): void { + hookTokens.clear(); +} diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts index 495078d1eaa..5b01055ca63 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { terminalSessions, workspaces } from "../../../db/schema"; import { mapEventType } from "../../../events"; +import { verifyHookToken } from "../../../runtime/sandbox/sandbox-tokens"; import type { HostServiceContext } from "../../../types"; import { publicProcedure, router } from "../../index"; @@ -103,6 +104,18 @@ export const notificationsRouter = router({ return { success: true, ignored: true as const }; } + // Sandboxed workspaces carry a per-workspace token; a present-but- + // wrong token is a spoof attempt and is dropped. Token-less requests + // pass (host workspaces, pre-update notify scripts). + if ( + !verifyHookToken( + terminalSession.originWorkspaceId, + ctx.agentHookHeaderToken, + ) + ) { + return { success: true, ignored: true as const }; + } + const agent = normalizeAgentIdentity(input.agent); const occurredAt = Date.now(); diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 1ff088bbba9..925f7fe22f1 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -33,4 +33,6 @@ export interface HostServiceContext { organizationId: string; isAuthenticated: boolean; clientMachineId?: string; + /** x-superset-hook-token header, verified for sandboxed workspaces. */ + agentHookHeaderToken?: string; } From cc2f79c6f7e51d2d9fd4b1abb23d4faf1a2ac622 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 22:37:05 +0200 Subject: [PATCH 05/21] feat(cli,host-service): sandbox CLI bridge via SUPERSET_HOST_ENDPOINT + per-workspace token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside a sandbox container the CLI's manifest/PID host discovery cannot work (127.0.0.1 endpoint, foreign PID namespace). Sandbox PTYs now get SUPERSET_HOST_ENDPOINT (host.docker.internal) and SUPERSET_HOST_TOKEN_FILE (/sandbox/host/token, a per-workspace 0600 token mounted read-only); resolveHostTarget short-circuits discovery when they're set. PskHostAuthProvider accepts registered sandbox tokens alongside the org PSK — the PSK never enters a container; tokens are revoked on sandbox destroy and re-registered on container ensure after restarts. --- .../src/lib/host-target/env-endpoint.test.ts | 56 ++++++++++++++++ .../cli/src/lib/host-target/env-endpoint.ts | 44 +++++++++++++ .../src/lib/host-target/resolveHostTarget.ts | 24 +++++++ .../PskHostAuthProvider.ts | 10 ++- .../src/runtime/sandbox/container-manager.ts | 24 ++++++- .../src/runtime/sandbox/docker-runtime.ts | 19 +++++- .../host-service/src/runtime/sandbox/paths.ts | 2 + .../src/runtime/sandbox/sandbox-cli-tokens.ts | 66 +++++++++++++++++++ .../sandbox-docker.integration.test.ts | 10 +++ 9 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/lib/host-target/env-endpoint.test.ts create mode 100644 packages/cli/src/lib/host-target/env-endpoint.ts create mode 100644 packages/host-service/src/runtime/sandbox/sandbox-cli-tokens.ts diff --git a/packages/cli/src/lib/host-target/env-endpoint.test.ts b/packages/cli/src/lib/host-target/env-endpoint.test.ts new file mode 100644 index 00000000000..df3be2fff30 --- /dev/null +++ b/packages/cli/src/lib/host-target/env-endpoint.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { getEnvHostEndpoint } from "./env-endpoint"; + +describe("getEnvHostEndpoint", () => { + let tempDir: string; + let savedEndpoint: string | undefined; + let savedTokenFile: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), "superset-env-endpoint-")); + savedEndpoint = process.env.SUPERSET_HOST_ENDPOINT; + savedTokenFile = process.env.SUPERSET_HOST_TOKEN_FILE; + delete process.env.SUPERSET_HOST_ENDPOINT; + delete process.env.SUPERSET_HOST_TOKEN_FILE; + }); + + afterEach(() => { + if (savedEndpoint === undefined) delete process.env.SUPERSET_HOST_ENDPOINT; + else process.env.SUPERSET_HOST_ENDPOINT = savedEndpoint; + if (savedTokenFile === undefined) + delete process.env.SUPERSET_HOST_TOKEN_FILE; + else process.env.SUPERSET_HOST_TOKEN_FILE = savedTokenFile; + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("returns null when the endpoint override is unset", () => { + expect(getEnvHostEndpoint()).toBeNull(); + }); + + test("reads the token from the file next to the endpoint", () => { + const tokenPath = path.join(tempDir, "token"); + writeFileSync(tokenPath, "secret-token\n"); + process.env.SUPERSET_HOST_ENDPOINT = "http://host.docker.internal:48123"; + process.env.SUPERSET_HOST_TOKEN_FILE = tokenPath; + expect(getEnvHostEndpoint()).toEqual({ + endpoint: "http://host.docker.internal:48123", + authToken: "secret-token", + }); + }); + + test("throws an actionable error when the token file is missing or empty", () => { + process.env.SUPERSET_HOST_ENDPOINT = "http://host.docker.internal:48123"; + expect(() => getEnvHostEndpoint()).toThrow(/SUPERSET_HOST_TOKEN_FILE/); + + const emptyPath = path.join(tempDir, "empty"); + writeFileSync(emptyPath, ""); + process.env.SUPERSET_HOST_TOKEN_FILE = emptyPath; + expect(() => getEnvHostEndpoint()).toThrow(/empty/); + + process.env.SUPERSET_HOST_TOKEN_FILE = path.join(tempDir, "missing"); + expect(() => getEnvHostEndpoint()).toThrow(/Failed to read/); + }); +}); diff --git a/packages/cli/src/lib/host-target/env-endpoint.ts b/packages/cli/src/lib/host-target/env-endpoint.ts new file mode 100644 index 00000000000..c039b63947d --- /dev/null +++ b/packages/cli/src/lib/host-target/env-endpoint.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { CLIError } from "@superset/cli-framework"; + +export interface EnvHostEndpoint { + endpoint: string; + authToken: string; +} + +/** + * Explicit host-service endpoint override for environments where manifest + * discovery cannot work — chiefly sandboxed workspace containers, where the + * manifest's `127.0.0.1` endpoint isn't routable and its recorded PID + * belongs to another PID namespace. Superset injects both variables into + * sandbox terminals; the token file is a per-workspace credential mounted + * read-only into the container (never the org PSK). + */ +export function getEnvHostEndpoint(): EnvHostEndpoint | null { + const endpoint = process.env.SUPERSET_HOST_ENDPOINT?.trim(); + if (!endpoint) return null; + const tokenFile = process.env.SUPERSET_HOST_TOKEN_FILE?.trim(); + if (!tokenFile) { + throw new CLIError( + "SUPERSET_HOST_ENDPOINT is set but SUPERSET_HOST_TOKEN_FILE is not", + "Both are injected together by Superset; unset SUPERSET_HOST_ENDPOINT to use normal host discovery.", + ); + } + let authToken: string; + try { + authToken = readFileSync(tokenFile, "utf-8").trim(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new CLIError( + `Failed to read host token file at ${tokenFile}: ${message}`, + "Unset SUPERSET_HOST_ENDPOINT to use normal host discovery.", + ); + } + if (!authToken) { + throw new CLIError( + `Host token file at ${tokenFile} is empty`, + "Unset SUPERSET_HOST_ENDPOINT to use normal host discovery.", + ); + } + return { endpoint, authToken }; +} diff --git a/packages/cli/src/lib/host-target/resolveHostTarget.ts b/packages/cli/src/lib/host-target/resolveHostTarget.ts index 405f205dce7..dc1c1be3068 100644 --- a/packages/cli/src/lib/host-target/resolveHostTarget.ts +++ b/packages/cli/src/lib/host-target/resolveHostTarget.ts @@ -7,6 +7,7 @@ import SuperJSON from "superjson"; import type { ApiClient } from "../api-client"; import { isProcessAlive, readManifest } from "../host/manifest"; import { getRelayUrl } from "../host/relay-url"; +import { getEnvHostEndpoint } from "./env-endpoint"; export type HostServiceClient = ReturnType< typeof createTRPCClient @@ -43,6 +44,29 @@ export async function resolveHostTarget( const localHostId = getHostId(); const targetHostId = options.requestedHostId; + // Sandbox containers: the injected endpoint IS the workspace's own host; + // manifest discovery (127.0.0.1 endpoint + PID liveness) cannot cross the + // container boundary, so it is skipped entirely. + const envEndpoint = getEnvHostEndpoint(); + if (envEndpoint) { + return { + kind: "local", + hostId: localHostId, + client: createTRPCClient({ + links: [ + httpBatchLink({ + url: `${envEndpoint.endpoint}/trpc`, + transformer: SuperJSON, + headers: { + Authorization: `Bearer ${envEndpoint.authToken}`, + "x-superset-client-machine-id": localHostId, + }, + }), + ], + }), + }; + } + if (targetHostId === localHostId) { const manifest = readManifest(options.organizationId); if (!manifest) { diff --git a/packages/host-service/src/providers/host-auth/PskHostAuthProvider/PskHostAuthProvider.ts b/packages/host-service/src/providers/host-auth/PskHostAuthProvider/PskHostAuthProvider.ts index 1f956b4b0f6..e13d4c3bc8f 100644 --- a/packages/host-service/src/providers/host-auth/PskHostAuthProvider/PskHostAuthProvider.ts +++ b/packages/host-service/src/providers/host-auth/PskHostAuthProvider/PskHostAuthProvider.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from "node:crypto"; +import { isValidSandboxCliToken } from "../../../runtime/sandbox/sandbox-cli-tokens"; import type { HostAuthProvider } from "../types"; export class PskHostAuthProvider implements HostAuthProvider { @@ -11,11 +12,16 @@ export class PskHostAuthProvider implements HostAuthProvider { validate(request: Request): boolean { const header = request.headers.get("authorization"); const token = header?.startsWith("Bearer ") ? header.slice(7) : null; - return token !== null && this.safeEqual(token); + return token !== null && this.validateToken(token); } + /** + * The org PSK, or a per-workspace sandbox CLI token (mounted into + * sandbox containers so the bundled CLI can reach this host without + * the PSK ever entering an agent's environment). + */ validateToken(token: string): boolean { - return this.safeEqual(token); + return this.safeEqual(token) || isValidSandboxCliToken(token); } private safeEqual(input: string): boolean { diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts index 3ddb8c1f650..2235c9d8339 100644 --- a/packages/host-service/src/runtime/sandbox/container-manager.ts +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -18,11 +18,14 @@ import { import { ensureSandboxGit } from "./git-bootstrap.ts"; import { CONTAINER_GIT_DIR, + CONTAINER_HOST_DIR, CONTAINER_SUPERSET_DIR, getSandboxContainerName, getWorkspaceSandboxPaths, } from "./paths.ts"; +import { dropCliToken, ensureCliTokenFile } from "./sandbox-cli-tokens.ts"; import { ensureSandboxHome } from "./sandbox-home.ts"; +import { dropHookToken } from "./sandbox-tokens.ts"; /** Thrown for user-actionable sandbox failures (docker down, pull failed). */ export class SandboxError extends Error { @@ -47,9 +50,20 @@ export function computeConfigHash(settings: ResolvedSandboxSettings): string { .slice(0, 16); } -function buildWorkspaceMounts(params: EnsureContainerParams): MountSpec[] { +function buildWorkspaceMounts( + params: EnsureContainerParams, + cliTokenHostDir: string, +): MountSpec[] { const paths = getWorkspaceSandboxPaths(params.workspaceId); return [ + // Per-workspace CLI token for the bundled `superset` binary. Not + // nested under the read-only /opt/superset mount — docker can't + // create a mountpoint inside a read-only bind mount. + { + source: cliTokenHostDir, + target: CONTAINER_HOST_DIR, + readOnly: true, + }, // Worktree at its host path — path identity keeps host-side git, // search, and diff working against the same absolute paths. { source: params.worktreePath, target: params.worktreePath }, @@ -102,6 +116,8 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { worktreePath: params.worktreePath, cloneDepth: params.settings.cloneDepth, }); + // Also re-registers the token in-memory after host-service restarts. + const cliToken = await ensureCliTokenFile(params.workspaceId); const name = getSandboxContainerName(params.workspaceId); const configHash = computeConfigHash(params.settings); @@ -143,7 +159,7 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { runtime: params.settings.runtime, network: params.settings.network, resources: params.settings.resources, - mounts: buildWorkspaceMounts(params), + mounts: buildWorkspaceMounts(params, cliToken.hostDir), }), ); await startContainer(name); @@ -152,10 +168,12 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { ); } -/** Remove the workspace's container and host-side sandbox state. */ +/** Remove the workspace's container, tokens, and host-side sandbox state. */ export async function destroyWorkspaceSandbox( workspaceId: string, ): Promise { + dropHookToken(workspaceId); + dropCliToken(workspaceId); const availability = await checkDockerAvailable(); if (availability.ok) { await removeContainer(getSandboxContainerName(workspaceId)); diff --git a/packages/host-service/src/runtime/sandbox/docker-runtime.ts b/packages/host-service/src/runtime/sandbox/docker-runtime.ts index 11085325943..7c303e660fd 100644 --- a/packages/host-service/src/runtime/sandbox/docker-runtime.ts +++ b/packages/host-service/src/runtime/sandbox/docker-runtime.ts @@ -6,14 +6,24 @@ import { import { buildExecArgs, type ResolvedSandboxSettings } from "./docker-args.ts"; import { getDockerCliEnv } from "./docker-cli.ts"; import { getHostAgentHookUrl } from "./host-runtime.ts"; -import { CONTAINER_BASH_RCFILE, getSandboxContainerName } from "./paths.ts"; -import { dropHookToken, getOrCreateHookToken } from "./sandbox-tokens.ts"; +import { + CONTAINER_BASH_RCFILE, + CONTAINER_HOST_DIR, + getSandboxContainerName, +} from "./paths.ts"; +import { getOrCreateHookToken } from "./sandbox-tokens.ts"; import type { PtyLaunchSpec, TerminalLaunchContext, WorkspaceRuntime, } from "./workspace-runtime.ts"; +/** host-service base URL as reachable from inside a sandbox container. */ +function getContainerHostEndpoint(): string { + const port = process.env.HOST_SERVICE_PORT || process.env.PORT; + return port ? `http://host.docker.internal:${port}` : ""; +} + export interface DockerRuntimeParams { workspaceId: string; worktreePath: string; @@ -48,6 +58,10 @@ export class DockerRuntime implements WorkspaceRuntime { // notify.sh echoes this back; notifications.hook drops spoofed // events carrying a wrong token for this workspace. SUPERSET_AGENT_HOOK_TOKEN: getOrCreateHookToken(this.params.workspaceId), + // The bundled CLI short-circuits manifest/PID host discovery + // (meaningless across the container boundary) with these. + SUPERSET_HOST_ENDPOINT: getContainerHostEndpoint(), + SUPERSET_HOST_TOKEN_FILE: `${CONTAINER_HOST_DIR}/token`, }; return { shell: "docker", @@ -75,7 +89,6 @@ export class DockerRuntime implements WorkspaceRuntime { } async destroy(): Promise { - dropHookToken(this.params.workspaceId); await destroyWorkspaceSandbox(this.params.workspaceId); } } diff --git a/packages/host-service/src/runtime/sandbox/paths.ts b/packages/host-service/src/runtime/sandbox/paths.ts index b42b96d7829..4bcb1f9d179 100644 --- a/packages/host-service/src/runtime/sandbox/paths.ts +++ b/packages/host-service/src/runtime/sandbox/paths.ts @@ -5,6 +5,8 @@ import { join } from "node:path"; export const CONTAINER_SUPERSET_DIR = "/opt/superset"; /** Mount target for the per-workspace isolated git dir. */ export const CONTAINER_GIT_DIR = "/sandbox/git"; +/** Mount target for the per-workspace host-access dir (CLI token). */ +export const CONTAINER_HOST_DIR = "/sandbox/host"; /** In-container SUPERSET_HOME_DIR contents live under CONTAINER_SUPERSET_DIR. */ export const CONTAINER_BASH_RCFILE = `${CONTAINER_SUPERSET_DIR}/bash/rcfile`; diff --git a/packages/host-service/src/runtime/sandbox/sandbox-cli-tokens.ts b/packages/host-service/src/runtime/sandbox/sandbox-cli-tokens.ts new file mode 100644 index 00000000000..723e77efb04 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/sandbox-cli-tokens.ts @@ -0,0 +1,66 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { getWorkspaceSandboxPaths } from "./paths.ts"; + +/** + * Per-workspace tokens the bundled `superset` CLI uses from INSIDE a + * sandbox container. The org PSK never enters the container; instead each + * workspace gets a random token written to + * `/host/token` (0600) and mounted read-only at + * /opt/superset/host/token. PskHostAuthProvider accepts registered sandbox + * tokens alongside the PSK; a token is revoked by destroying the sandbox. + * + * The registry is in-memory and repopulated on every container ensure, so + * tokens survive host-service restarts as long as the workspace is used. + */ + +const cliTokens = new Map(); + +export interface CliTokenMount { + /** Host directory to bind-mount read-only at /opt/superset/host. */ + hostDir: string; +} + +export async function ensureCliTokenFile( + workspaceId: string, +): Promise { + const hostDir = join(getWorkspaceSandboxPaths(workspaceId).stateDir, "host"); + await mkdir(hostDir, { recursive: true }); + const tokenPath = join(hostDir, "token"); + let token: string; + if (existsSync(tokenPath)) { + token = (await readFile(tokenPath, "utf-8")).trim(); + } else { + token = randomBytes(32).toString("hex"); + await writeFile(tokenPath, `${token}\n`, { mode: 0o600 }); + } + cliTokens.set(workspaceId, token); + return { hostDir }; +} + +/** Timing-safe check against every registered sandbox CLI token. */ +export function isValidSandboxCliToken(presented: string): boolean { + const presentedBuffer = Buffer.from(presented); + let valid = false; + for (const token of cliTokens.values()) { + const tokenBuffer = Buffer.from(token); + if ( + presentedBuffer.length === tokenBuffer.length && + timingSafeEqual(presentedBuffer, tokenBuffer) + ) { + // No early exit: keep comparison count independent of match position. + valid = true; + } + } + return valid; +} + +export function dropCliToken(workspaceId: string): void { + cliTokens.delete(workspaceId); +} + +export function resetCliTokensForTests(): void { + cliTokens.clear(); +} diff --git a/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts b/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts index 1d989c9baed..f1264689b06 100644 --- a/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts +++ b/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts @@ -143,6 +143,16 @@ describe.skipIf(!DOCKER_TESTS)("sandbox docker integration", () => { settings: resolveSandboxSettings({ image: TEST_IMAGE }), }); + // The per-workspace CLI token is mounted read-only at /sandbox/host. + const tokenInContainer = await dockerExec( + containerName, + "cat /sandbox/host/token", + ); + expect(tokenInContainer.trim().length).toBeGreaterThan(0); + await expect( + dockerExec(containerName, "echo x > /sandbox/host/token"), + ).rejects.toThrow(); + // In-container git resolves the isolated git dir via the mask. const gitDir = await dockerExec( containerName, From 2e62f9aaf7788c45e8e78f6fb1a69cf61baa6f4b Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 22:45:57 +0200 Subject: [PATCH 06/21] feat(host-service): sandbox image package, declared-port publishing, agent-config mounts - packages/sandbox-image: default sandbox Dockerfile (node:22-bookworm-slim + git/curl/ripgrep/jq + pinned Claude Code and Codex CLIs) with a documented image contract; build locally via build:image until CI publishing lands. - sandbox.ports are published on 127.0.0.1 with identical numbers at container create when free (busy ports skipped with a warning); deterministic remap + ports-UI surfacing is the M3 milestone. - sandbox.agentConfig (default true) bind-mounts ~/.claude, ~/.claude.json and ~/.codex into the container so agents reuse host auth; disable to require in-container login. --- .../src/runtime/sandbox/container-manager.ts | 32 ++++++++++++++ .../src/runtime/sandbox/docker-args.test.ts | 6 +++ .../src/runtime/sandbox/docker-args.ts | 13 ++++++ .../host-service/src/runtime/sandbox/paths.ts | 6 +++ .../src/runtime/sandbox/port-probe.ts | 42 +++++++++++++++++++ .../sandbox-docker.integration.test.ts | 14 ++++++- .../src/runtime/setup/sandbox-config.ts | 17 ++++++-- packages/sandbox-image/Dockerfile | 40 ++++++++++++++++++ packages/sandbox-image/README.md | 35 ++++++++++++++++ packages/sandbox-image/package.json | 9 ++++ 10 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 packages/host-service/src/runtime/sandbox/port-probe.ts create mode 100644 packages/sandbox-image/Dockerfile create mode 100644 packages/sandbox-image/README.md create mode 100644 packages/sandbox-image/package.json diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts index 2235c9d8339..cd5ffcd511c 100644 --- a/packages/host-service/src/runtime/sandbox/container-manager.ts +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -1,5 +1,8 @@ import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; import { buildContainerCreateArgs, CONFIG_HASH_LABEL, @@ -18,11 +21,13 @@ import { import { ensureSandboxGit } from "./git-bootstrap.ts"; import { CONTAINER_GIT_DIR, + CONTAINER_HOME_DIR, CONTAINER_HOST_DIR, CONTAINER_SUPERSET_DIR, getSandboxContainerName, getWorkspaceSandboxPaths, } from "./paths.ts"; +import { selectPublishablePorts } from "./port-probe.ts"; import { dropCliToken, ensureCliTokenFile } from "./sandbox-cli-tokens.ts"; import { ensureSandboxHome } from "./sandbox-home.ts"; import { dropHookToken } from "./sandbox-tokens.ts"; @@ -50,12 +55,30 @@ export function computeConfigHash(settings: ResolvedSandboxSettings): string { .slice(0, 16); } +/** + * Host agent config mounted read-write so agents inside the sandbox reuse + * host auth (and OAuth refreshes stay coherent both ways). Accepted v1 + * trade-off — documented in the sandbox config; per-workspace agent homes + * with in-container login are the hardening follow-up. + */ +function buildAgentConfigMounts(): MountSpec[] { + const home = homedir(); + return [".claude", ".claude.json", ".codex"] + .map((entry) => join(home, entry)) + .filter((source) => existsSync(source)) + .map((source) => ({ + source, + target: `${CONTAINER_HOME_DIR}/${basename(source)}`, + })); +} + function buildWorkspaceMounts( params: EnsureContainerParams, cliTokenHostDir: string, ): MountSpec[] { const paths = getWorkspaceSandboxPaths(params.workspaceId); return [ + ...(params.settings.mountAgentConfig ? buildAgentConfigMounts() : []), // Per-workspace CLI token for the bundled `superset` binary. Not // nested under the read-only /opt/superset mount — docker can't // create a mountpoint inside a read-only bind mount. @@ -136,6 +159,14 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { } } + const portSelection = await selectPublishablePorts(params.settings.ports); + if (portSelection.skipped.length > 0) { + console.warn( + `[sandbox] host port(s) ${portSelection.skipped.join(", ")} busy; ` + + `not published for workspace ${params.workspaceId}`, + ); + } + if (!(await imageExists(params.settings.image))) { console.log( `[sandbox] pulling image ${params.settings.image} for workspace ${params.workspaceId}`, @@ -160,6 +191,7 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { network: params.settings.network, resources: params.settings.resources, mounts: buildWorkspaceMounts(params, cliToken.hostDir), + publishedPorts: portSelection.published, }), ); await startContainer(name); diff --git a/packages/host-service/src/runtime/sandbox/docker-args.test.ts b/packages/host-service/src/runtime/sandbox/docker-args.test.ts index f82af872de6..842eea19221 100644 --- a/packages/host-service/src/runtime/sandbox/docker-args.test.ts +++ b/packages/host-service/src/runtime/sandbox/docker-args.test.ts @@ -22,6 +22,7 @@ describe("buildContainerCreateArgs", () => { { source: "/wt", target: "/wt" }, { source: "/mask", target: "/wt/.git", readOnly: true }, ], + publishedPorts: [{ containerPort: 3000, hostPort: 3000 }], }); expect(args).toEqual([ "create", @@ -50,6 +51,8 @@ describe("buildContainerCreateArgs", () => { "1024", "--add-host", "host.docker.internal:host-gateway", + "-p", + "127.0.0.1:3000:3000", "--mount", "type=bind,source=/wt,target=/wt", "--mount", @@ -69,7 +72,9 @@ describe("buildContainerCreateArgs", () => { network: "bridge", resources: { pidsLimit: 2048 }, mounts: [], + publishedPorts: [], }); + expect(args).not.toContain("-p"); expect(args).not.toContain("--runtime"); expect(args).not.toContain("--network"); expect(args).not.toContain("--cpus"); @@ -113,6 +118,7 @@ describe("resolveSandboxSettings", () => { resources: { pidsLimit: 2048 }, extraMounts: [], envPassthrough: [], + mountAgentConfig: true, }); }); diff --git a/packages/host-service/src/runtime/sandbox/docker-args.ts b/packages/host-service/src/runtime/sandbox/docker-args.ts index e56a10ba9dd..59e14f844c2 100644 --- a/packages/host-service/src/runtime/sandbox/docker-args.ts +++ b/packages/host-service/src/runtime/sandbox/docker-args.ts @@ -11,6 +11,11 @@ export interface MountSpec { readOnly?: boolean; } +export interface PublishedPort { + containerPort: number; + hostPort: number; +} + export const MANAGED_LABEL = "com.superset.managed=true"; export const WORKSPACE_ID_LABEL = "com.superset.workspace-id"; export const CONFIG_HASH_LABEL = "com.superset.config-hash"; @@ -24,6 +29,8 @@ export interface ContainerCreateSpec { network: "bridge" | "none"; resources: { cpus?: number; memoryMb?: number; pidsLimit: number }; mounts: MountSpec[]; + /** Published on loopback only — never exposed beyond the host. */ + publishedPorts: PublishedPort[]; } export function buildMountArgs(mounts: MountSpec[]): string[] { @@ -65,6 +72,10 @@ export function buildContainerCreateArgs(spec: ContainerCreateSpec): string[] { // pre-provisions the same name for Linux engines. "--add-host", "host.docker.internal:host-gateway", + ...spec.publishedPorts.flatMap((port) => [ + "-p", + `127.0.0.1:${port.hostPort}:${port.containerPort}`, + ]), ...buildMountArgs(spec.mounts), spec.image, "sleep", @@ -110,6 +121,7 @@ export interface ResolvedSandboxSettings { resources: { cpus?: number; memoryMb?: number; pidsLimit: number }; extraMounts: MountSpec[]; envPassthrough: string[]; + mountAgentConfig: boolean; cloneDepth?: number; } @@ -134,6 +146,7 @@ export function resolveSandboxSettings( }, extraMounts: (config.mounts ?? []).map(parseConfigMount), envPassthrough: config.env ?? [], + mountAgentConfig: config.agentConfig ?? true, ...(config.git?.cloneDepth && { cloneDepth: config.git.cloneDepth }), }; } diff --git a/packages/host-service/src/runtime/sandbox/paths.ts b/packages/host-service/src/runtime/sandbox/paths.ts index 4bcb1f9d179..7774f335f77 100644 --- a/packages/host-service/src/runtime/sandbox/paths.ts +++ b/packages/host-service/src/runtime/sandbox/paths.ts @@ -7,6 +7,12 @@ export const CONTAINER_SUPERSET_DIR = "/opt/superset"; export const CONTAINER_GIT_DIR = "/sandbox/git"; /** Mount target for the per-workspace host-access dir (CLI token). */ export const CONTAINER_HOST_DIR = "/sandbox/host"; +/** + * Home of the default sandbox image's user (root). Agent-config mounts + * target it; custom images with a different default user should disable + * `sandbox.agentConfig` until per-image homes are configurable. + */ +export const CONTAINER_HOME_DIR = "/root"; /** In-container SUPERSET_HOME_DIR contents live under CONTAINER_SUPERSET_DIR. */ export const CONTAINER_BASH_RCFILE = `${CONTAINER_SUPERSET_DIR}/bash/rcfile`; diff --git a/packages/host-service/src/runtime/sandbox/port-probe.ts b/packages/host-service/src/runtime/sandbox/port-probe.ts new file mode 100644 index 00000000000..e86774a1df8 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/port-probe.ts @@ -0,0 +1,42 @@ +import { createServer } from "node:net"; +import type { PublishedPort } from "./docker-args.ts"; + +/** True when 127.0.0.1: is currently bindable on the host. */ +export function isHostPortFree(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen({ host: "127.0.0.1", port, exclusive: true }, () => { + server.close(() => resolve(true)); + }); + }); +} + +export interface PortSelection { + published: PublishedPort[]; + /** Declared ports whose host port was taken — not published. */ + skipped: number[]; +} + +/** + * Same-number publishing only for now: a declared container port is bound + * to the identical host loopback port so existing "open localhost:" + * UX works unchanged; conflicting ports are skipped with a warning. + * Deterministic remapping + surfacing the mapping in the ports UI is the + * M3 ports milestone. + */ +export async function selectPublishablePorts( + declaredPorts: number[], +): Promise { + const published: PublishedPort[] = []; + const skipped: number[] = []; + for (const port of declaredPorts) { + if (await isHostPortFree(port)) { + published.push({ containerPort: port, hostPort: port }); + } else { + skipped.push(port); + } + } + return { published, skipped }; +} diff --git a/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts b/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts index f1264689b06..0a34dc8e9eb 100644 --- a/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts +++ b/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts @@ -122,25 +122,35 @@ describe.skipIf(!DOCKER_TESTS)("sandbox docker integration", () => { test( "ensure → isolated git → destroy round-trip", async () => { + // agentConfig off: the test must not mount the real ~/.claude. + const settings = resolveSandboxSettings({ + image: TEST_IMAGE, + agentConfig: false, + ports: [38472], + }); await ensureContainer({ workspaceId: WORKSPACE_ID, worktreePath, repoPath, branch: "feature", - settings: resolveSandboxSettings({ image: TEST_IMAGE }), + settings, }); const inspection = await inspectContainer(containerName); expect(inspection.exists).toBe(true); expect(inspection.running).toBe(true); + // Declared port published on loopback with the same number. + const portMapping = await run("docker", ["port", containerName]); + expect(portMapping).toContain("38472/tcp -> 127.0.0.1:38472"); + // Idempotent re-ensure. await ensureContainer({ workspaceId: WORKSPACE_ID, worktreePath, repoPath, branch: "feature", - settings: resolveSandboxSettings({ image: TEST_IMAGE }), + settings, }); // The per-workspace CLI token is mounted read-only at /sandbox/host. diff --git a/packages/host-service/src/runtime/setup/sandbox-config.ts b/packages/host-service/src/runtime/setup/sandbox-config.ts index 74258e7551f..afaa70655c2 100644 --- a/packages/host-service/src/runtime/setup/sandbox-config.ts +++ b/packages/host-service/src/runtime/setup/sandbox-config.ts @@ -41,6 +41,12 @@ export interface SandboxConfig { /** Env var names forwarded from the host env. Machine-local sources only. */ env?: string[]; git?: SandboxGitConfig; + /** + * Mount the host's agent config (~/.claude, ~/.claude.json, ~/.codex) + * read-write into the container so agents reuse host auth. Default true; + * disable to require in-container login. + */ + agentConfig?: boolean; } const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; @@ -150,11 +156,13 @@ export function validateSandboxConfig( const obj = value as Record; const result: SandboxConfig = {}; - if (obj.enabled !== undefined) { - if (typeof obj.enabled !== "boolean") { - return invalid(source, "'enabled' must be a boolean"); + for (const field of ["enabled", "agentConfig"] as const) { + const fieldValue = obj[field]; + if (fieldValue === undefined) continue; + if (typeof fieldValue !== "boolean") { + return invalid(source, `'${field}' must be a boolean`); } - result.enabled = obj.enabled; + result[field] = fieldValue; } for (const field of ["image", "runtime"] as const) { const fieldValue = obj[field]; @@ -256,6 +264,7 @@ export function mergeSandboxConfigs( ports: override.ports ?? base.ports, mounts: override.mounts ?? base.mounts, env: override.env ?? base.env, + agentConfig: override.agentConfig ?? base.agentConfig, ...(resources && { resources }), ...(git && { git }), }; diff --git a/packages/sandbox-image/Dockerfile b/packages/sandbox-image/Dockerfile new file mode 100644 index 00000000000..86dfe372a3c --- /dev/null +++ b/packages/sandbox-image/Dockerfile @@ -0,0 +1,40 @@ +# Default Superset workspace sandbox image. +# +# Contract (what host-service's sandbox runtime expects of ANY image used +# as `sandbox.image`): +# - /bin/bash present (terminals launch bash --rcfile /opt/superset/bash/rcfile) +# - git, curl, ca-certificates on PATH (isolated git dir + agent hooks) +# - a `sleep` binary (PID 1 keepalive; docker --init provides the reaper) +# +# Everything else (agent CLIs, language runtimes) is convenience for the +# default image; projects can substitute their own image via sandbox.image. + +FROM node:22-bookworm-slim + +ARG CLAUDE_CODE_VERSION=latest +ARG CODEX_VERSION=latest + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + jq \ + openssh-client \ + procps \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g \ + bun \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "@openai/codex@${CODEX_VERSION}" \ + && npm cache clean --force + +# Host worktrees bind-mount at arbitrary paths with host ownership; git +# must not refuse them inside the sandbox. +RUN git config --system safe.directory "*" + +WORKDIR /root + +CMD ["sleep", "infinity"] diff --git a/packages/sandbox-image/README.md b/packages/sandbox-image/README.md new file mode 100644 index 00000000000..12663927351 --- /dev/null +++ b/packages/sandbox-image/README.md @@ -0,0 +1,35 @@ +# @superset/sandbox-image + +Default Docker image for Superset sandboxed workspaces (the `sandbox` key +in `.superset/config.json`). + +## Local build + +```bash +bun run --cwd packages/sandbox-image build:image +``` + +then point a project at it (machine-local config, e.g. +`.superset/config.local.json`): + +```json +{ + "sandbox": { + "enabled": true, + "image": "superset-sandbox:dev" + } +} +``` + +## Image contract + +Any image used as `sandbox.image` must provide `/bin/bash`, `git`, `curl`, +CA certificates, and a `sleep` binary. The default image additionally ships +node/bun, ripgrep, jq, and pinned Claude Code + Codex CLIs (see build args +`CLAUDE_CODE_VERSION` / `CODEX_VERSION`). + +## Publishing + +CI publishing to `ghcr.io/superset-sh/sandbox` (multi-arch, with the linux +`superset` CLI baked in) is planned; until then build locally and override +`sandbox.image`. diff --git a/packages/sandbox-image/package.json b/packages/sandbox-image/package.json new file mode 100644 index 00000000000..7839752f426 --- /dev/null +++ b/packages/sandbox-image/package.json @@ -0,0 +1,9 @@ +{ + "name": "@superset/sandbox-image", + "version": "0.0.0", + "private": true, + "description": "Default Docker image for Superset sandboxed workspaces", + "scripts": { + "build:image": "docker build -t superset-sandbox:dev ." + } +} From 25965ef8fe52cd054316b18077c9a8a511717655 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 22:59:35 +0200 Subject: [PATCH 07/21] =?UTF-8?q?feat(host-service):=20sandbox=20git=20syn?= =?UTF-8?q?c=20=E2=80=94=20export-on-destroy=20and=20workspaces.syncSandbo?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-container commits live only in the isolated sandbox git dir. Workspace delete now exports them into the main repo as refs/sandbox//* first (and preserves the git dir on disk if the export fails, so commits are never destroyed). workspaces.syncSandbox imports them on demand: export, ancestor check, then advance the workspace branch with git reset --mixed — the shared bind mount already holds the committed content, and a stale host index would make merge --ff-only refuse; --mixed never touches working files, so divergent local edits just stay visible. --- .../src/runtime/sandbox/container-manager.ts | 9 + .../src/runtime/sandbox/git-sync.test.ts | 176 ++++++++++++++++++ .../src/runtime/sandbox/git-sync.ts | 114 ++++++++++++ .../workspace-cleanup/workspace-cleanup.ts | 23 ++- .../src/trpc/router/workspaces/workspaces.ts | 45 +++++ 5 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 packages/host-service/src/runtime/sandbox/git-sync.test.ts create mode 100644 packages/host-service/src/runtime/sandbox/git-sync.ts diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts index cd5ffcd511c..5277f33145e 100644 --- a/packages/host-service/src/runtime/sandbox/container-manager.ts +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -203,6 +203,14 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { /** Remove the workspace's container, tokens, and host-side sandbox state. */ export async function destroyWorkspaceSandbox( workspaceId: string, + options?: { + /** + * Keep the on-disk sandbox state (notably the isolated git dir). + * Set when exporting sandbox commits failed — deleting the git dir + * then would destroy the only copy of the agent's commits. + */ + preserveState?: boolean; + }, ): Promise { dropHookToken(workspaceId); dropCliToken(workspaceId); @@ -215,6 +223,7 @@ export async function destroyWorkspaceSandbox( "container (if any) will be removed by the startup reconcile", ); } + if (options?.preserveState) return; await rm(getWorkspaceSandboxPaths(workspaceId).stateDir, { recursive: true, force: true, diff --git a/packages/host-service/src/runtime/sandbox/git-sync.test.ts b/packages/host-service/src/runtime/sandbox/git-sync.test.ts new file mode 100644 index 00000000000..a65b042e61b --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/git-sync.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execFile } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { ensureSandboxGit } from "./git-bootstrap.ts"; +import { exportSandboxRefs, syncSandboxCommits } from "./git-sync.ts"; +import { getWorkspaceSandboxPaths } from "./paths.ts"; + +const execFileAsync = promisify(execFile); + +const WORKSPACE_ID = "git-sync-test-ws"; +const GIT_USER = ["-c", "user.email=t@t", "-c", "user.name=t"]; + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync("git", [ + "-C", + cwd, + ...GIT_USER, + ...args, + ]); + return stdout; +} + +/** Commit through the isolated sandbox git dir, like in-container git does. */ +async function sandboxGit( + worktreePath: string, + args: string[], +): Promise { + const { gitDir } = getWorkspaceSandboxPaths(WORKSPACE_ID); + const { stdout } = await execFileAsync("git", [...GIT_USER, ...args], { + env: { ...process.env, GIT_DIR: gitDir, GIT_WORK_TREE: worktreePath }, + cwd: worktreePath, + }); + return stdout; +} + +describe("sandbox git sync", () => { + let fixtureRoot: string; + let repoPath: string; + let worktreePath: string; + let savedHomeDir: string | undefined; + + beforeEach(async () => { + fixtureRoot = mkdtempSync(join(tmpdir(), "superset-git-sync-")); + savedHomeDir = process.env.SUPERSET_HOME_DIR; + process.env.SUPERSET_HOME_DIR = join(fixtureRoot, "superset-home"); + + repoPath = join(fixtureRoot, "repo"); + worktreePath = join(fixtureRoot, "wt"); + await execFileAsync("git", ["init", "-q", repoPath]); + await git(repoPath, ["commit", "-q", "--allow-empty", "-m", "init"]); + await git(repoPath, [ + "worktree", + "add", + "-q", + worktreePath, + "-b", + "feature", + ]); + writeFileSync(join(worktreePath, "file.txt"), "hello\n"); + await git(worktreePath, ["add", "file.txt"]); + await git(worktreePath, ["commit", "-q", "-m", "one"]); + + await ensureSandboxGit({ + workspaceId: WORKSPACE_ID, + repoPath, + branch: "feature", + worktreePath, + }); + }); + + afterEach(() => { + if (savedHomeDir === undefined) delete process.env.SUPERSET_HOME_DIR; + else process.env.SUPERSET_HOME_DIR = savedHomeDir; + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + test("fast-forwards the common case: agent edited + committed via the shared worktree", async () => { + // Shared bind mount semantics: the edit is visible host-side (dirty + // worktree) while the commit exists only in the sandbox git dir. + writeFileSync(join(worktreePath, "file.txt"), "hello\nagent change\n"); + await sandboxGit(worktreePath, ["add", "file.txt"]); + await sandboxGit(worktreePath, ["commit", "-q", "-m", "agent commit"]); + + expect(await git(worktreePath, ["status", "--porcelain"])).toContain( + "M file.txt", + ); + expect(await git(worktreePath, ["log", "--oneline"])).not.toContain( + "agent commit", + ); + + const result = await syncSandboxCommits({ + repoPath, + worktreePath, + workspaceId: WORKSPACE_ID, + branch: "feature", + }); + expect(result.status).toBe("fast-forwarded"); + expect(await git(worktreePath, ["log", "--oneline"])).toContain( + "agent commit", + ); + expect((await git(worktreePath, ["status", "--porcelain"])).trim()).toBe( + "", + ); + + // Idempotent re-sync. + const again = await syncSandboxCommits({ + repoPath, + worktreePath, + workspaceId: WORKSPACE_ID, + branch: "feature", + }); + expect(again.status).toBe("up-to-date"); + }); + + test("reports no sandbox history when the sandbox never committed", async () => { + const result = await syncSandboxCommits({ + repoPath, + worktreePath, + workspaceId: WORKSPACE_ID, + branch: "feature", + }); + // Sandbox HEAD equals host HEAD after bootstrap. + expect(result.status).toBe("up-to-date"); + + const missing = await syncSandboxCommits({ + repoPath, + worktreePath, + workspaceId: "never-bootstrapped", + branch: "feature", + }); + expect(missing.status).toBe("no-sandbox-history"); + }); + + test("divergent host history leaves the exported ref for manual merge", async () => { + writeFileSync(join(worktreePath, "sandbox.txt"), "from sandbox\n"); + await sandboxGit(worktreePath, ["add", "sandbox.txt"]); + await sandboxGit(worktreePath, ["commit", "-q", "-m", "sandbox side"]); + // Independent host-side commit → histories diverge. + writeFileSync(join(worktreePath, "host.txt"), "from host\n"); + await git(worktreePath, ["add", "host.txt"]); + await git(worktreePath, ["commit", "-q", "-m", "host side"]); + + const result = await syncSandboxCommits({ + repoPath, + worktreePath, + workspaceId: WORKSPACE_ID, + branch: "feature", + }); + expect(result.status).toBe("diverged"); + if (result.status === "diverged") { + const refSha = await git(repoPath, ["rev-parse", result.ref]); + expect(refSha.trim().length).toBe(40); + } + }); + + test("export-on-destroy preserves commits as refs/sandbox//*", async () => { + writeFileSync(join(worktreePath, "file.txt"), "hello\npreserved\n"); + await sandboxGit(worktreePath, ["add", "file.txt"]); + await sandboxGit(worktreePath, ["commit", "-q", "-m", "preserved commit"]); + + const result = await exportSandboxRefs({ + repoPath, + workspaceId: WORKSPACE_ID, + }); + expect(result.exported).toBe(true); + const log = await git(repoPath, [ + "log", + "--oneline", + `refs/sandbox/${WORKSPACE_ID}/feature`, + ]); + expect(log).toContain("preserved commit"); + }); +}); diff --git a/packages/host-service/src/runtime/sandbox/git-sync.ts b/packages/host-service/src/runtime/sandbox/git-sync.ts new file mode 100644 index 00000000000..21ec4e5ba0d --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/git-sync.ts @@ -0,0 +1,114 @@ +/** + * Bringing sandbox-local git history back to the host. + * + * In-container commits land only in the workspace's isolated git dir. Two + * host-side operations recover them: + * + * - exportSandboxRefs: fetch every sandbox branch into the MAIN repo as + * `refs/sandbox//` (refs + objects only — nothing is + * checked out). Runs before sandbox destroy so commits are never lost. + * - syncSandboxCommits: export, then fast-forward the workspace's real + * branch when host history hasn't diverged; otherwise report divergence + * and leave the exported ref for a manual merge. + */ + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { getWorkspaceSandboxPaths } from "./paths.ts"; + +const execFileAsync = promisify(execFile); + +const GIT_TIMEOUT_MS = 2 * 60_000; + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { + timeout: GIT_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + }); + return stdout; +} + +export function sandboxRefPrefix(workspaceId: string): string { + return `refs/sandbox/${workspaceId}`; +} + +export interface ExportResult { + exported: boolean; + refPrefix: string; +} + +/** + * Fetch all sandbox branches into the main repo under + * refs/sandbox//. Idempotent; no-op when the workspace has no + * sandbox git dir. `+` refspec: the sandbox dir is authoritative for its + * own namespace, so re-exports always win. + */ +export async function exportSandboxRefs(args: { + repoPath: string; + workspaceId: string; +}): Promise { + const refPrefix = sandboxRefPrefix(args.workspaceId); + const { gitDir } = getWorkspaceSandboxPaths(args.workspaceId); + if (!existsSync(join(gitDir, "HEAD"))) { + return { exported: false, refPrefix }; + } + await git(args.repoPath, [ + "fetch", + "--no-write-fetch-head", + gitDir, + `+refs/heads/*:${refPrefix}/*`, + ]); + return { exported: true, refPrefix }; +} + +export type SyncSandboxResult = + | { status: "no-sandbox-history" } + | { status: "up-to-date"; ref: string } + | { status: "fast-forwarded"; ref: string; sha: string } + | { status: "diverged"; ref: string }; + +/** + * Export sandbox refs, then fast-forward the workspace branch when safe: + * clean worktree and host HEAD an ancestor of the sandbox branch. On + * divergence the exported ref is left for a normal host-side merge/rebase. + */ +export async function syncSandboxCommits(args: { + repoPath: string; + worktreePath: string; + workspaceId: string; + branch: string; +}): Promise { + const { exported, refPrefix } = await exportSandboxRefs(args); + if (!exported) return { status: "no-sandbox-history" }; + const ref = `${refPrefix}/${args.branch}`; + + let sandboxSha: string; + try { + sandboxSha = ( + await git(args.worktreePath, ["rev-parse", "--verify", `${ref}^{commit}`]) + ).trim(); + } catch { + // The sandbox never committed on this branch. + return { status: "no-sandbox-history" }; + } + + const headSha = (await git(args.worktreePath, ["rev-parse", "HEAD"])).trim(); + if (headSha === sandboxSha) return { status: "up-to-date", ref }; + + try { + await git(args.worktreePath, ["merge-base", "--is-ancestor", "HEAD", ref]); + } catch { + return { status: "diverged", ref }; + } + + // Forward move only (ancestor-checked). `reset --mixed` advances the + // branch ref + index but never touches working files — the shared bind + // mount already holds the content the sandbox committed, and any extra + // uncommitted host edits simply remain visible as local modifications. + // (`merge --ff-only` is unusable here: the host index is always stale + // relative to in-sandbox commits, so git would refuse the checkout.) + await git(args.worktreePath, ["reset", "--mixed", sandboxSha]); + return { status: "fast-forwarded", ref, sha: sandboxSha }; +} diff --git a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts index 5520efce04d..3b9fad27c85 100644 --- a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts +++ b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts @@ -8,6 +8,7 @@ import { pullRequests } from "../../../db/schema"; import { invalidateLabelCache } from "../../../ports/static-ports"; import { coercePullRequestState } from "../../../runtime/pull-requests/utils/pull-request-mappers"; import { destroyWorkspaceSandbox } from "../../../runtime/sandbox/container-manager"; +import { exportSandboxRefs } from "../../../runtime/sandbox/git-sync"; import { evictWorkspaceRuntime } from "../../../runtime/sandbox/registry"; import { runTeardown, type TeardownResult } from "../../../runtime/teardown"; import { disposeSessionsByWorkspaceId } from "../../../terminal/terminal"; @@ -412,8 +413,28 @@ async function runDestroyPhases( // failure never blocks the delete — the startup reconcile sweeps // orphaned containers once docker is back. if (local?.sandboxEnabled) { + // Sandbox-local commits live only in the isolated git dir; export + // them into the main repo as refs/sandbox//* first, and keep the + // git dir on disk if the export fails so they're never destroyed. + let sandboxExportFailed = false; + if (project) { + try { + await exportSandboxRefs({ + repoPath: project.repoPath, + workspaceId: input.workspaceId, + }); + } catch (err) { + sandboxExportFailed = true; + const message = err instanceof Error ? err.message : String(err); + warnings.push( + `Failed to export sandbox commits (sandbox git state kept on disk): ${message}`, + ); + } + } try { - await destroyWorkspaceSandbox(input.workspaceId); + await destroyWorkspaceSandbox(input.workspaceId, { + preserveState: sandboxExportFailed, + }); } catch (err) { const message = err instanceof Error ? err.message : String(err); warnings.push(`Failed to remove sandbox container: ${message}`); diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index b844aa44884..ba228901337 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -10,6 +10,7 @@ import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { createGitEnvResolver } from "../../../runtime/git"; import { type ResolvedRef, resolveRef } from "../../../runtime/git/refs"; +import { syncSandboxCommits } from "../../../runtime/sandbox/git-sync"; import { resolveSandboxEnabledForNewWorkspace } from "../../../runtime/sandbox/registry"; import type { HostServiceContext } from "../../../types"; import { getHostWorkerPool } from "../../../workers/host-worker-pool"; @@ -1322,6 +1323,50 @@ export const workspacesRouter = router({ return { success: true as const }; }), + /** + * Import sandbox-local git history: export the workspace's isolated git + * dir into the main repo as refs/sandbox//* and fast-forward the + * workspace branch when host history hasn't diverged. + */ + syncSandbox: protectedProcedure + .input(z.object({ workspaceId: z.string() })) + .mutation(async ({ ctx, input }) => { + const local = getLocalWorkspace(ctx.db, input.workspaceId); + if (!local) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace not found: ${input.workspaceId}`, + }); + } + if (!local.sandboxEnabled) { + return { status: "no-sandbox-history" as const }; + } + const project = local.projectId + ? ctx.db.query.projects + .findFirst({ where: eq(projects.id, local.projectId) }) + .sync() + : undefined; + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Local project not found for workspace", + }); + } + try { + return await syncSandboxCommits({ + repoPath: project.repoPath, + worktreePath: local.worktreePath, + workspaceId: local.id, + branch: local.branch, + }); + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to sync sandbox commits: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + generateBranchName: protectedProcedure .input( z.object({ From 98076eb32f195b8353eaeeb96bb8541c849e72ec Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 23:04:42 +0200 Subject: [PATCH 08/21] test(host-service): sandbox end-to-end test against the real app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker-gated E2E: real host-service served on a real port, real PskHostAuthProvider, and the real desktop notify-hook script inside the container. Proves the whole loop: workspaces.create resolves the sticky sandbox flag → container ensure → exec spec carries the SUPERSET_* env → notify.sh hook round-trip records an agent binding while a spoofed token is rejected → sandbox CLI token authenticates (anon 401) → in-container commit fast-forwards via workspaces.syncSandbox → workspace.delete removes the container and preserves unsynced commits as refs/sandbox//*. --- .../test/helpers/createTestHost.ts | 6 +- .../sandbox-e2e.integration.test.ts | 273 ++++++++++++++++++ 2 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/host-service/test/integration/sandbox-e2e.integration.test.ts diff --git a/packages/host-service/test/helpers/createTestHost.ts b/packages/host-service/test/helpers/createTestHost.ts index 17c012fcb14..707cfc29ea9 100644 --- a/packages/host-service/test/helpers/createTestHost.ts +++ b/packages/host-service/test/helpers/createTestHost.ts @@ -13,6 +13,7 @@ import { } from "../../src/app"; import type { HostDb } from "../../src/db"; import * as schema from "../../src/db/schema"; +import type { HostAuthProvider } from "../../src/providers/host-auth"; import type { AppRouter as HostAppRouter } from "../../src/trpc/router"; import { createFakeApiClient, @@ -41,6 +42,9 @@ export interface TestHostOptions { execGh?: (args: string[], options?: unknown) => Promise; chatRuntime?: unknown; chatService?: unknown; + /** Override the host-auth provider (e.g. the real PskHostAuthProvider + * when a test exercises sandbox CLI-token acceptance). */ + hostAuth?: HostAuthProvider; } export interface TestHost { @@ -101,7 +105,7 @@ export async function createTestHost( }, providers: { auth: new FakeApiAuthProvider(), - hostAuth: new FakeHostAuthProvider(psk), + hostAuth: options.hostAuth ?? new FakeHostAuthProvider(psk), credentials: new MemoryGitCredentialProvider(options.githubToken ?? null), modelResolver: new FakeModelResolver(), }, diff --git a/packages/host-service/test/integration/sandbox-e2e.integration.test.ts b/packages/host-service/test/integration/sandbox-e2e.integration.test.ts new file mode 100644 index 00000000000..bcb7086671c --- /dev/null +++ b/packages/host-service/test/integration/sandbox-e2e.integration.test.ts @@ -0,0 +1,273 @@ +/** + * End-to-end test for Docker-sandboxed workspaces against the REAL + * host-service app served on a real port (so the container reaches it via + * host.docker.internal), with the REAL PskHostAuthProvider and the REAL + * desktop notify-hook script running inside the container. + * + * Covers, in order: sticky sandbox resolution at workspace create → + * container ensure → PTY launch spec runs in-container with the injected + * SUPERSET_* env → agent hook round-trip (accept + spoof rejection) → + * CLI-token auth against a protected procedure → in-container commit → + * workspaces.syncSandbox fast-forward → export-on-destroy via + * workspace.delete. Gated on SUPERSET_DOCKER_TESTS=1. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { execFile } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { eq } from "drizzle-orm"; +import { terminalAgentBindings, workspaces } from "../../src/db/schema"; +import { PskHostAuthProvider } from "../../src/providers/host-auth"; +import { inspectContainer } from "../../src/runtime/sandbox/docker-cli"; +import { getSandboxContainerName } from "../../src/runtime/sandbox/paths"; +import { getWorkspaceRuntime } from "../../src/runtime/sandbox/registry"; +import { cloudFlows } from "../helpers/cloud-fakes"; +import { createProjectScenario } from "../helpers/scenarios"; +import { seedTerminalSession } from "../helpers/seed"; + +const execFileAsync = promisify(execFile); +const DOCKER_TESTS = process.env.SUPERSET_DOCKER_TESTS === "1"; + +const TEST_IMAGE = "superset-sandbox-e2e:local"; +const PSK = "e2e-psk-secret"; +const NOTIFY_TEMPLATE = resolve( + import.meta.dir, + "../../../../apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh", +); + +async function run(command: string, args: string[]): Promise { + const { stdout } = await execFileAsync(command, args, { + timeout: 5 * 60_000, + maxBuffer: 16 * 1024 * 1024, + }); + return stdout; +} + +describe.skipIf(!DOCKER_TESTS)("sandbox end-to-end", () => { + let fixtureRoot: string; + let savedHomeDir: string | undefined; + let savedHostPort: string | undefined; + let scenario: Awaited>; + let server: ReturnType; + + beforeAll(async () => { + fixtureRoot = mkdtempSync(join(tmpdir(), "superset-sandbox-e2e-")); + savedHomeDir = process.env.SUPERSET_HOME_DIR; + savedHostPort = process.env.HOST_SERVICE_PORT; + process.env.SUPERSET_HOME_DIR = join(fixtureRoot, "superset-home"); + + // The REAL desktop notify hook, rendered like agent-setup does. + mkdirSync(join(process.env.SUPERSET_HOME_DIR, "hooks"), { + recursive: true, + }); + const notifyScript = readFileSync(NOTIFY_TEMPLATE, "utf-8") + .replaceAll("{{MARKER}}", "# Superset agent notification hook (e2e)") + .replaceAll("{{DEFAULT_PORT}}", "51741"); + const notifyPath = join( + process.env.SUPERSET_HOME_DIR, + "hooks", + "notify.sh", + ); + writeFileSync(notifyPath, notifyScript); + chmodSync(notifyPath, 0o755); + + // Minimal image satisfying the contract + curl for the hook script. + const dockerfilePath = join(fixtureRoot, "Dockerfile.e2e"); + writeFileSync( + dockerfilePath, + "FROM debian:bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates && rm -rf /var/lib/apt/lists/*\n", + ); + await run("docker", [ + "build", + "-t", + TEST_IMAGE, + "-f", + dockerfilePath, + fixtureRoot, + ]); + + scenario = await createProjectScenario({ + hostOptions: { + psk: PSK, + hostAuth: new PskHostAuthProvider(PSK), + apiOverrides: cloudFlows.workspaceCreateOk(), + }, + }); + + // Repo-shipped sandbox config: enabled + image are honored from the + // repo source (mounts/env would not be). + mkdirSync(join(scenario.repo.repoPath, ".superset"), { recursive: true }); + writeFileSync( + join(scenario.repo.repoPath, ".superset", "config.json"), + JSON.stringify({ + sandbox: { enabled: true, image: TEST_IMAGE, agentConfig: false }, + }), + ); + + // Real port so the container can reach the app via host.docker.internal. + server = Bun.serve({ + port: 0, + fetch: (request) => scenario.host.app.fetch(request), + }); + process.env.HOST_SERVICE_PORT = String(server.port); + }, 10 * 60_000); + + afterAll(async () => { + server?.stop(true); + if (savedHomeDir === undefined) delete process.env.SUPERSET_HOME_DIR; + else process.env.SUPERSET_HOME_DIR = savedHomeDir; + if (savedHostPort === undefined) delete process.env.HOST_SERVICE_PORT; + else process.env.HOST_SERVICE_PORT = savedHostPort; + await scenario?.dispose(); + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + test( + "create → container → hooks → CLI auth → commit sync → delete export", + async () => { + // ── Workspace create resolves the sticky sandbox flag ───────── + const created = await scenario.host.trpc.workspaces.create.mutate({ + projectId: scenario.projectId, + name: "sandboxed", + branch: "feat/sbx", + }); + const workspaceId = created?.workspace?.id; + if (!workspaceId) throw new Error("workspace create failed"); + const row = scenario.host.db + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + if (!row) throw new Error("workspace row missing"); + expect(row.sandboxEnabled).toBe(true); + const containerName = getSandboxContainerName(workspaceId); + + // ── Runtime resolution + container ensure ───────────────────── + const runtime = getWorkspaceRuntime(scenario.host.db, workspaceId); + expect(runtime.kind).toBe("docker"); + await runtime.prepare(); + expect((await inspectContainer(containerName)).running).toBe(true); + + // ── PTY launch spec, exercised in-container ─────────────────── + const goodTerminal = seedTerminalSession(scenario.host, { + originWorkspaceId: workspaceId, + }); + const spoofTerminal = seedTerminalSession(scenario.host, { + originWorkspaceId: workspaceId, + }); + const spec = await runtime.buildPtyLaunch({ + terminalId: goodTerminal.id, + workspaceId, + workspacePath: row.worktreePath, + rootPath: scenario.repo.repoPath, + cwd: row.worktreePath, + }); + expect(spec.shell).toBe("docker"); + expect(spec.expectsReadyMarker).toBe(true); + + // Same exec argv, non-interactive: -it → -i, inner bash -c script. + const argvBase = spec.argv + .map((arg) => (arg === "-it" ? "-i" : arg)) + .slice(0, -3); + const script = ` +set -u +echo "WSID=$SUPERSET_WORKSPACE_ID" +echo "MARKER_LINES=$(grep -c '133;A' /opt/superset/bash/rcfile)" +echo '{"hook_event_name":"SessionStart","session_id":"e2e-session"}' \ + | SUPERSET_AGENT_ID=claude bash "$SUPERSET_HOME_DIR/hooks/notify.sh" || true +echo '{"hook_event_name":"SessionStart","session_id":"spoof"}' \ + | SUPERSET_AGENT_ID=claude SUPERSET_TERMINAL_ID=${spoofTerminal.id} SUPERSET_AGENT_HOOK_TOKEN=wrong-token \ + bash "$SUPERSET_HOME_DIR/hooks/notify.sh" || true +TOKEN=$(cat /sandbox/host/token) +SYNC_URL="$SUPERSET_HOST_ENDPOINT/trpc/workspaces.syncSandbox" +BODY='{"json":{"workspaceId":"${workspaceId}"}}' +echo "AUTH_OK=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d "$BODY" "$SYNC_URL")" +echo "AUTH_ANON=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$BODY" "$SYNC_URL")" +`; + const output = await run("docker", [ + ...argvBase, + "/bin/bash", + "-c", + script, + ]); + + expect(output).toContain(`WSID=${workspaceId}`); + expect(output).not.toContain("MARKER_LINES=0"); + // The bundled-CLI auth path: sandbox token accepted, anonymous not. + expect(output).toContain("AUTH_OK=200"); + expect(output).toContain("AUTH_ANON=401"); + + // Hook round-trip: correct token recorded an agent binding … + const goodBinding = scenario.host.db + .select() + .from(terminalAgentBindings) + .where(eq(terminalAgentBindings.terminalId, goodTerminal.id)) + .get(); + expect(goodBinding?.agentId).toBe("claude"); + // … the spoofed token did not. + const spoofBinding = scenario.host.db + .select() + .from(terminalAgentBindings) + .where(eq(terminalAgentBindings.terminalId, spoofTerminal.id)) + .get(); + expect(spoofBinding).toBeFalsy(); + + // ── In-container commit → syncSandbox fast-forward ──────────── + await run("docker", [ + "exec", + "-w", + row.worktreePath, + containerName, + "bash", + "-c", + `echo change >> e2e.txt && git add e2e.txt && git -c user.email=a@a -c user.name=agent commit -q -m "agent commit"`, + ]); + const sync = await scenario.host.trpc.workspaces.syncSandbox.mutate({ + workspaceId, + }); + expect(sync.status).toBe("fast-forwarded"); + const hostLog = await run("git", [ + "-C", + row.worktreePath, + "log", + "--oneline", + ]); + expect(hostLog).toContain("agent commit"); + + // ── Unsynced commit survives delete as refs/sandbox//* ──── + await run("docker", [ + "exec", + "-w", + row.worktreePath, + containerName, + "bash", + "-c", + `echo more >> e2e.txt && git add e2e.txt && git -c user.email=a@a -c user.name=agent commit -q -m "unsynced commit"`, + ]); + await scenario.host.trpc.workspace.delete.mutate({ id: workspaceId }); + + expect((await inspectContainer(containerName)).exists).toBe(false); + expect(existsSync(row.worktreePath)).toBe(false); + const exportedLog = await run("git", [ + "-C", + scenario.repo.repoPath, + "log", + "--oneline", + `refs/sandbox/${workspaceId}/feat/sbx`, + ]); + expect(exportedLog).toContain("unsynced commit"); + }, + 10 * 60_000, + ); +}); From beca3548e421ffa2c0fb3000ddbfd123afaa9ab5 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 13 Aug 2026 23:13:05 +0200 Subject: [PATCH 09/21] feat(desktop): sandboxed-workspace badge in the dashboard sidebar workspace.list and the workspace:changed snapshot now carry sandboxed; the sidebar row shows a container icon with a tooltip when the workspace's terminals run inside a Docker sandbox. --- .../useHostWorkspaces.utils.ts | 5 +++ .../DashboardSidebarExpandedWorkspaceRow.tsx | 36 ++++++++++++++----- .../buildDashboardSidebarProjects.ts | 3 ++ .../useDashboardSidebarData.ts | 1 + .../components/DashboardSidebar/types.ts | 2 ++ .../src/trpc/router/workspace/workspace.ts | 1 + 6 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.ts b/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.ts index 9f8225ee5bd..42ce2da15bf 100644 --- a/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.ts +++ b/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.ts @@ -24,6 +24,8 @@ export type HostShapedWorkspace = Omit< export interface HostWorkspaceRow extends HostShapedWorkspace { worktreePath: string; worktreeExists: boolean; + /** True when the workspace's terminals run inside a sandbox container. */ + sandboxed?: boolean; /** Non-null = archived tombstone (only served on `includeArchived`). */ archivedAt?: number | null; archiveReason?: "merged" | "deleted" | null; @@ -33,6 +35,8 @@ export interface HostWorkspaceRow extends HostShapedWorkspace { export interface HostWorkspaceItem extends HostShapedWorkspace { worktreePath?: string; worktreeExists?: boolean; + /** True when the workspace's terminals run inside a sandbox container. */ + sandboxed?: boolean; /** False when the host didn't answer. */ hostReachable: boolean; /** Non-null = archived tombstone (only present on `includeArchived`). */ @@ -190,6 +194,7 @@ export function applyWorkspaceChangedEvent( createdAt: new Date(snapshot.createdAt), updatedAt: new Date(snapshot.updatedAt), worktreePath: snapshot.worktreePath, + sandboxed: snapshot.sandboxed, // A host broadcasting created/updated just acted on the worktree; // keep a known value over assuming. worktreeExists: existing?.worktreeExists ?? true, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index 70808a941f1..9b5b20f645f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -9,6 +9,7 @@ import { useRef, } from "react"; import { HiCheck, HiMiniMinus, HiMiniXMark } from "react-icons/hi2"; +import { LuContainer } from "react-icons/lu"; import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; import { HotkeyLabel } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -286,16 +287,33 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< )} /> ) : ( - + + {name || branch} + {isSelected && , selected} + + {workspace.sandboxed && ( + + + + + + + + Sandboxed — terminals run in a Docker container + + )} - > - {name || branch} - {isSelected && , selected} )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts index adcb5a164b8..46f43d8b49c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts @@ -49,6 +49,8 @@ export interface SidebarWorkspaceInput { tabOrder: number; sectionId: string | null; pinnedAt: number | null; + /** True when the workspace's terminals run inside a sandbox container. */ + sandboxed?: boolean; pendingTransaction: WorkspaceTransactionSnapshot | null; } @@ -104,6 +106,7 @@ function decorateSidebarWorkspace( updatedAt: workspace.updatedAt, taskId: workspace.taskId, isPinned: workspace.pinnedAt != null, + sandboxed: workspace.sandboxed, pendingTransaction: workspace.pendingTransaction, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 1292815f74d..1c2dd73add3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -299,6 +299,7 @@ export function useDashboardSidebarData() { sectionId: localState.sectionId, isHidden: localState.isHidden, pinnedAt: localState.pinnedAt, + sandboxed: workspace.sandboxed, }, ]; }), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index 9ba0fc84502..6bacaea4e93 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -45,6 +45,8 @@ export interface DashboardSidebarWorkspace { updatedAt: Date; taskId: string | null; isPinned: boolean; + /** True when the workspace's terminals run inside a sandbox container. */ + sandboxed?: boolean; pendingTransaction: WorkspaceTransactionSnapshot | null; } diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 26e5a3bc7a0..c31286b4118 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -67,6 +67,7 @@ export const workspaceRouter = router({ return rows.map((row) => ({ ...toCloudShape(row, ctx.organizationId), worktreePath: row.worktreePath, + sandboxed: row.sandboxEnabled, // Tombstones' worktrees are gone by definition; stat-checking an // unbounded, forever-growing archive on every poll adds up. worktreeExists: From 5d7f2442507f853cb1b2c1d0510c67cee08a9975 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 10:48:48 +0200 Subject: [PATCH 10/21] chore: register @superset/sandbox-image workspace in bun.lock --- bun.lock | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bun.lock b/bun.lock index 842d390131a..503ffbea3c4 100644 --- a/bun.lock +++ b/bun.lock @@ -1074,6 +1074,10 @@ "typescript": "6.0.3", }, }, + "packages/sandbox-image": { + "name": "@superset/sandbox-image", + "version": "0.0.0", + }, "packages/sdk": { "name": "@superset/sdk", "version": "0.0.1-alpha.12", @@ -2965,6 +2969,8 @@ "@superset/relay2": ["@superset/relay2@workspace:apps/relay2"], + "@superset/sandbox-image": ["@superset/sandbox-image@workspace:packages/sandbox-image"], + "@superset/sdk": ["@superset/sdk@workspace:packages/sdk"], "@superset/shared": ["@superset/shared@workspace:packages/shared"], From 11039ea82349eb41e0b2236d989ec7eba2e959c2 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 11:28:49 +0200 Subject: [PATCH 11/21] test: adapt sandbox tests to upstream merge - host-runtime.test: fake db satisfies the default-account lookup the launch env now performs; expected env gains organizationId - sandbox-e2e: notify-hook template moved to packages/agent-setup --- .../src/runtime/sandbox/host-runtime.test.ts | 22 ++++++++++++------- .../sandbox-e2e.integration.test.ts | 3 ++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/host-service/src/runtime/sandbox/host-runtime.test.ts b/packages/host-service/src/runtime/sandbox/host-runtime.test.ts index c86672368ea..1e881bd7845 100644 --- a/packages/host-service/src/runtime/sandbox/host-runtime.test.ts +++ b/packages/host-service/src/runtime/sandbox/host-runtime.test.ts @@ -29,6 +29,16 @@ const BASE_ENV = { PATH: "/usr/bin:/bin", }; +// Satisfies the default-account lookup (`db.select().from(hostSettings).get()`) +// with "no selection", so the launch env gets no account overlay. +const FAKE_DB = { + select: () => ({ from: () => ({ get: () => undefined }) }), + query: { + workspaces: { findFirst: () => ({ sync: () => undefined }) }, + projects: { findFirst: () => ({ sync: () => undefined }) }, + }, +} as unknown as Parameters[0]; + const LAUNCH_CONTEXT = { terminalId: "term-1", workspaceId: "ws-1", @@ -36,6 +46,7 @@ const LAUNCH_CONTEXT = { rootPath: "/tmp/repo", cwd: "/tmp/worktrees/ws-1/sub", themeType: "light" as const, + db: FAKE_DB, }; describe("HostRuntime", () => { @@ -81,6 +92,7 @@ describe("HostRuntime", () => { baseEnv, shell, supersetHomeDir: homeDir, + organizationId: process.env.ORGANIZATION_ID || "", themeType: LAUNCH_CONTEXT.themeType, cwd: LAUNCH_CONTEXT.cwd, terminalId: LAUNCH_CONTEXT.terminalId, @@ -135,14 +147,8 @@ describe("HostRuntime", () => { test("registry resolves unsandboxed workspaces to the shared host runtime", () => { // Registry falls back to the host runtime when the workspace row is // missing or has sandboxEnabled=false — a fake empty db covers both. - const fakeDb = { - query: { - workspaces: { findFirst: () => ({ sync: () => undefined }) }, - projects: { findFirst: () => ({ sync: () => undefined }) }, - }, - } as unknown as Parameters[0]; - const runtime = getWorkspaceRuntime(fakeDb, "any-workspace"); + const runtime = getWorkspaceRuntime(FAKE_DB, "any-workspace"); expect(runtime.kind).toBe("host"); - expect(runtime).toBe(getWorkspaceRuntime(fakeDb, "another-workspace")); + expect(runtime).toBe(getWorkspaceRuntime(FAKE_DB, "another-workspace")); }); }); diff --git a/packages/host-service/test/integration/sandbox-e2e.integration.test.ts b/packages/host-service/test/integration/sandbox-e2e.integration.test.ts index bcb7086671c..6fb18fb8684 100644 --- a/packages/host-service/test/integration/sandbox-e2e.integration.test.ts +++ b/packages/host-service/test/integration/sandbox-e2e.integration.test.ts @@ -43,7 +43,7 @@ const TEST_IMAGE = "superset-sandbox-e2e:local"; const PSK = "e2e-psk-secret"; const NOTIFY_TEMPLATE = resolve( import.meta.dir, - "../../../../apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh", + "../../../agent-setup/templates/notify-hook.template.sh", ); async function run(command: string, args: string[]): Promise { @@ -172,6 +172,7 @@ describe.skipIf(!DOCKER_TESTS)("sandbox end-to-end", () => { workspacePath: row.worktreePath, rootPath: scenario.repo.repoPath, cwd: row.worktreePath, + db: scenario.host.db, }); expect(spec.shell).toBe("docker"); expect(spec.expectsReadyMarker).toBe(true); From 7f9a62adcc2d95f784e50d14df5e28e45a956dd5 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 12:11:47 +0200 Subject: [PATCH 12/21] fix(db): accept db.localhost for the local neon proxy db.localtest.me depends on public wildcard DNS, which routers with DNS-rebind protection refuse to resolve. db.localhost is loopback by RFC 6761 without any DNS lookup, so local dev works offline and behind such routers. --- packages/db/src/local-proxy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/db/src/local-proxy.ts b/packages/db/src/local-proxy.ts index 8eb89f2d624..e1fc0310eda 100644 --- a/packages/db/src/local-proxy.ts +++ b/packages/db/src/local-proxy.ts @@ -1,10 +1,12 @@ import { neonConfig } from "@neondatabase/serverless"; -const LOCAL_DATABASE_HOST = "db.localtest.me"; +// db.localtest.me relies on public wildcard DNS, which DNS-rebind-protecting +// routers block; db.localhost is guaranteed loopback (RFC 6761) without DNS. +const LOCAL_DATABASE_HOSTS = new Set(["db.localtest.me", "db.localhost"]); export function isLocalProxy(databaseUrl: string): boolean { try { - return new URL(databaseUrl).hostname === LOCAL_DATABASE_HOST; + return LOCAL_DATABASE_HOSTS.has(new URL(databaseUrl).hostname); } catch { return false; } From d39f31a61afb0897e79b78eb4167ef7d1446d030 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 12:35:24 +0200 Subject: [PATCH 13/21] feat(settings): host-wide sandbox default for new workspaces New Sandboxes settings section: toggle to sandbox every new workspace by default and a provider dropdown (Docker; forward-compatible with future lightweight OS sandboxes). Stored per host in host_settings (migration 0027); resolveSandboxEnabledForNewWorkspace precedence is project config sandbox.enabled (explicit true/false) over the host default. The get endpoint reports docker daemon availability for the UI hint. --- .../SettingsSidebar/GeneralSettings.tsx | 9 +- .../SandboxSettings/SandboxSettings.tsx | 201 ++++ .../components/SandboxSettings/index.ts | 1 + .../settings/sandboxes/page.tsx | 14 + .../utils/settings-search/settings-search.ts | 37 + .../src/renderer/stores/settings-state.ts | 1 + .../host-service/drizzle/0027_tan_magneto.sql | 2 + .../drizzle/meta/0027_snapshot.json | 1038 +++++++++++++++++ .../host-service/drizzle/meta/_journal.json | 7 + packages/host-service/src/db/schema.ts | 7 + .../src/runtime/sandbox/registry.test.ts | 93 ++ .../src/runtime/sandbox/registry.ts | 13 +- .../src/trpc/router/settings/index.ts | 6 + .../trpc/router/settings/sandbox-defaults.ts | 55 + 14 files changed, 1479 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/SandboxSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/page.tsx create mode 100644 packages/host-service/drizzle/0027_tan_magneto.sql create mode 100644 packages/host-service/drizzle/meta/0027_snapshot.json create mode 100644 packages/host-service/src/runtime/sandbox/registry.test.ts create mode 100644 packages/host-service/src/trpc/router/settings/sandbox-defaults.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index 2da111fd8ef..40720fd346a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -22,7 +22,7 @@ import { HiOutlineUser, HiOutlineUserGroup, } from "react-icons/hi2"; -import { LuBrain, LuGitBranch, LuKeyboard } from "react-icons/lu"; +import { LuBrain, LuContainer, LuGitBranch, LuKeyboard } from "react-icons/lu"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { electronTrpc } from "renderer/lib/electron-trpc"; import type { SettingsSection } from "renderer/stores/settings-state"; @@ -44,6 +44,7 @@ type SettingsRoute = | "/settings/behavior" | "/settings/browser" | "/settings/git" + | "/settings/sandboxes" | "/settings/agents" | "/settings/terminal" | "/settings/links" @@ -124,6 +125,12 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Git & Worktrees", icon: , }, + { + id: "/settings/sandboxes", + section: "sandboxes", + label: "Sandboxes", + icon: , + }, { id: "/settings/agents", section: "agents", diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/SandboxSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/SandboxSettings.tsx new file mode 100644 index 00000000000..257b0d56009 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/SandboxSettings.tsx @@ -0,0 +1,201 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { toast } from "@superset/ui/sonner"; +import { Switch } from "@superset/ui/switch"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable"; +import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + HostSelect, + type HostSelectOption, +} from "../../../components/HostSelect"; +import { SettingsRow } from "../../../components/SettingsRow"; + +type SandboxProvider = "docker"; + +interface SandboxSettingsProps { + hostId: string | null; +} + +/** + * Host-wide sandbox defaults: whether NEW workspaces run their terminals and + * agents inside an isolated sandbox, and which backend provides it. The + * decision is snapshotted per workspace at creation; a project's explicit + * `sandbox.enabled` in `.superset/config.json` always overrides the default. + */ +export function SandboxSettings({ hostId }: SandboxSettingsProps) { + const navigate = useNavigate(); + const hostService = useLocalHostService(); + const { machineId } = hostService; + const { currentDeviceName, localHostId, otherHosts } = + useWorkspaceHostOptions(); + const targetHostUrl = useHostUrl(hostId); + const targetHostId = hostId ?? machineId; + const queryClient = useQueryClient(); + + const hostOptions = useMemo(() => { + const options: HostSelectOption[] = []; + if (localHostId) { + options.push({ + id: localHostId, + name: currentDeviceName ?? "This device", + isLocal: true, + isOnline: true, + }); + } + for (const host of otherHosts) { + options.push({ + id: host.id, + name: host.name, + isLocal: false, + isOnline: host.isOnline, + }); + } + if (targetHostId && !options.some((o) => o.id === targetHostId)) { + options.push({ + id: targetHostId, + name: targetHostId === machineId ? "This device" : targetHostId, + isLocal: targetHostId === machineId, + isOnline: targetHostId === machineId, + }); + } + return options; + }, [currentDeviceName, localHostId, machineId, otherHosts, targetHostId]); + + const selectedHost = useMemo( + () => hostOptions.find((o) => o.id === targetHostId) ?? null, + [hostOptions, targetHostId], + ); + const hasMultipleHosts = hostOptions.length > 1; + const isHostOnline = selectedHost?.isOnline ?? true; + const selectedHostName = selectedHost?.isLocal + ? "this device" + : (selectedHost?.name ?? "this device"); + + const defaultsQuery = useQuery({ + queryKey: ["host-sandbox-defaults", targetHostUrl] as const, + enabled: !!targetHostUrl && isHostOnline, + queryFn: () => { + if (!targetHostUrl) throw new Error("Host service unavailable"); + return getHostServiceClientByUrl( + targetHostUrl, + ).settings.sandboxDefaults.get.query(); + }, + }); + + const enabled = defaultsQuery.data?.enabled ?? false; + const provider: SandboxProvider = defaultsQuery.data?.provider ?? "docker"; + const dockerAvailable = defaultsQuery.data?.dockerAvailable ?? null; + + const setMutation = useMutation({ + mutationFn: (vars: { enabled: boolean; provider: SandboxProvider }) => { + if (!targetHostUrl) { + throw new Error( + getHostServiceUnavailableMessage(hostService, { + action: "update sandbox settings", + }), + ); + } + return getHostServiceClientByUrl( + targetHostUrl, + ).settings.sandboxDefaults.set.mutate(vars); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["host-sandbox-defaults", targetHostUrl], + }); + }, + onError: (err) => + toast.error( + err instanceof Error + ? err.message + : "Failed to update sandbox settings", + ), + }); + + const controlsDisabled = + !targetHostUrl || + !isHostOnline || + defaultsQuery.isLoading || + setMutation.isPending; + + return ( +
+
+
+

Sandboxes

+

+ Isolation for new workspaces on {selectedHostName}. Projects can + override this via sandbox.enabled in their Superset + config. +

+
+ {hasMultipleHosts && targetHostId ? ( + { + void navigate({ + to: "/settings/sandboxes", + search: { hostId: nextHostId }, + replace: true, + }); + }} + /> + ) : null} +
+ +
+ + + setMutation.mutate({ enabled: next, provider }) + } + aria-label="Sandbox new workspaces by default" + /> + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/index.ts new file mode 100644 index 00000000000..fd54196ab58 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/index.ts @@ -0,0 +1 @@ +export { SandboxSettings } from "./SandboxSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/page.tsx new file mode 100644 index 00000000000..b55f5ab2b43 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/page.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SandboxSettings } from "./components/SandboxSettings"; + +export const Route = createFileRoute("/_authenticated/settings/sandboxes/")({ + component: SandboxSettingsPage, + validateSearch: (search: Record): { hostId?: string } => ({ + hostId: typeof search.hostId === "string" ? search.hostId : undefined, + }), +}); + +function SandboxSettingsPage() { + const { hostId } = Route.useSearch(); + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index ed9c6e24ce7..046022c2861 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -47,6 +47,9 @@ export const SETTING_ITEM_ID = { GIT_DELETE_LOCAL_BRANCH: "git-delete-local-branch", GIT_WORKTREE_LOCATION: "git-worktree-location", + SANDBOXES_DEFAULT: "sandboxes-default", + SANDBOXES_PROVIDER: "sandboxes-provider", + AGENTS_ENABLED: "agents-enabled", AGENTS_COMMANDS: "agents-commands", AGENTS_TASK_PROMPTS: "agents-task-prompts", @@ -190,6 +193,9 @@ export const SETTING_ITEM_VARIANT: Record = { [SETTING_ITEM_ID.GIT_DELETE_LOCAL_BRANCH]: "v1", [SETTING_ITEM_ID.GIT_WORKTREE_LOCATION]: "shared", + [SETTING_ITEM_ID.SANDBOXES_DEFAULT]: "shared", + [SETTING_ITEM_ID.SANDBOXES_PROVIDER]: "shared", + [SETTING_ITEM_ID.AGENTS_ENABLED]: "shared", [SETTING_ITEM_ID.AGENTS_COMMANDS]: "shared", [SETTING_ITEM_ID.AGENTS_TASK_PROMPTS]: "shared", @@ -768,6 +774,37 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "custom", ], }, + { + id: SETTING_ITEM_ID.SANDBOXES_DEFAULT, + section: "sandboxes", + title: "Sandbox new workspaces", + description: + "Run every new workspace's terminals and agents inside an isolated sandbox by default", + keywords: [ + "sandbox", + "docker", + "container", + "isolation", + "security", + "workspace", + "worktree", + "default", + ], + }, + { + id: SETTING_ITEM_ID.SANDBOXES_PROVIDER, + section: "sandboxes", + title: "Sandbox provider", + description: "Which sandbox backend new workspaces use", + keywords: [ + "sandbox", + "provider", + "docker", + "container", + "runtime", + "backend", + ], + }, { id: SETTING_ITEM_ID.BEHAVIOR_FILE_OPEN_MODE, section: "behavior", diff --git a/apps/desktop/src/renderer/stores/settings-state.ts b/apps/desktop/src/renderer/stores/settings-state.ts index 85ebd691dc4..83e61f9b957 100644 --- a/apps/desktop/src/renderer/stores/settings-state.ts +++ b/apps/desktop/src/renderer/stores/settings-state.ts @@ -12,6 +12,7 @@ export type SettingsSection = | "behavior" | "browser" | "git" + | "sandboxes" | "agents" | "terminal" | "links" diff --git a/packages/host-service/drizzle/0027_tan_magneto.sql b/packages/host-service/drizzle/0027_tan_magneto.sql new file mode 100644 index 00000000000..d60ab918e3f --- /dev/null +++ b/packages/host-service/drizzle/0027_tan_magneto.sql @@ -0,0 +1,2 @@ +ALTER TABLE `host_settings` ADD `sandbox_new_workspaces` integer;--> statement-breakpoint +ALTER TABLE `host_settings` ADD `sandbox_provider` text; \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0027_snapshot.json b/packages/host-service/drizzle/meta/0027_snapshot.json new file mode 100644 index 00000000000..2b693f819d8 --- /dev/null +++ b/packages/host-service/drizzle/meta/0027_snapshot.json @@ -0,0 +1,1038 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1f1c1d82-96fc-4d9c-88cf-e1eb0e217e56", + "prevId": "147f1e45-d573-4a77-a789-9c00e1cc38e8", + "tables": { + "host_agent_configs": { + "name": "host_agent_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preset_id": { + "name": "preset_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_id": { + "name": "icon_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "args_json": { + "name": "args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "prompt_transport": { + "name": "prompt_transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_args_json": { + "name": "prompt_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "resume_args_json": { + "name": "resume_args_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "env_json": { + "name": "env_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "host_agent_configs_display_order_idx": { + "name": "host_agent_configs_display_order_idx", + "columns": [ + "display_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_settings": { + "name": "host_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_claude_config_dir": { + "name": "default_claude_config_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_codex_home": { + "name": "default_codex_home", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_new_workspaces": { + "name": "sandbox_new_workspaces", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_provider": { + "name": "sandbox_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sparse_checkout_paths": { + "name": "sparse_checkout_paths", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "naming_instructions": { + "name": "naming_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "merged_at": { + "name": "merged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_agent_bindings": { + "name": "terminal_agent_bindings", + "columns": { + "terminal_id": { + "name": "terminal_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_session_id": { + "name": "agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_event_type": { + "name": "last_event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_agent_bindings_workspace_id_idx": { + "name": "terminal_agent_bindings_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk": { + "name": "terminal_agent_bindings_terminal_id_terminal_sessions_id_fk", + "tableFrom": "terminal_agent_bindings", + "tableTo": "terminal_sessions", + "columnsFrom": [ + "terminal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_workspace_id": { + "name": "origin_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dispose_requested_at": { + "name": "dispose_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", + "columns": [ + "origin_workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "origin_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_pull_requests": { + "name": "workspace_pull_requests", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_at": { + "name": "linked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_pull_requests_workspace_idx": { + "name": "workspace_pull_requests_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_pull_requests_workspace_id_workspaces_id_fk": { + "name": "workspace_pull_requests_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_pull_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_pull_requests_pull_request_id_pull_requests_id_fk": { + "name": "workspace_pull_requests_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspace_pull_requests", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_pull_requests_workspace_id_pull_request_id_pk": { + "columns": [ + "workspace_id", + "pull_request_id" + ], + "name": "workspace_pull_requests_workspace_id_pull_request_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_owner": { + "name": "upstream_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_repo": { + "name": "upstream_repo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_branch": { + "name": "upstream_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suppressed_pull_request_id": { + "name": "suppressed_pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'worktree'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archive_reason": { + "name": "archive_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_enabled": { + "name": "sandbox_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sandbox_image_digest": { + "name": "sandbox_image_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_port_map_json": { + "name": "sandbox_port_map_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_archived_at_idx": { + "name": "workspaces_archived_at_idx", + "columns": [ + "archived_at" + ], + "isUnique": false + }, + "workspaces_upstream_ref_idx": { + "name": "workspaces_upstream_ref_idx", + "columns": [ + "upstream_owner", + "upstream_repo", + "upstream_branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + }, + "workspaces_one_main_per_project": { + "name": "workspaces_one_main_per_project", + "columns": [ + "project_id" + ], + "isUnique": true, + "where": "type = 'main'" + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspaces_suppressed_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_suppressed_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "suppressed_pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index f2322a4d65f..ee99b1b6bbb 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1787821073790, "tag": "0026_tired_jackal", "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1787826413089, + "tag": "0027_tan_magneto", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index 2e82a798e7b..ac5f95bb9e6 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -124,6 +124,13 @@ export const hostSettings = sqliteTable("host_settings", { // inject (CLAUDE_CONFIG_DIR / CODEX_HOME). Null = the system default login. defaultClaudeConfigDir: text("default_claude_config_dir"), defaultCodexHome: text("default_codex_home"), + // Host-wide default for NEW workspaces: run their terminals in a sandbox. + // A project's explicit `sandbox.enabled` config always wins over this. + sandboxNewWorkspaces: integer("sandbox_new_workspaces", { mode: "boolean" }), + // Which sandbox backend the default uses. Only "docker" exists today; the + // column keeps the settings dropdown forward-compatible with future + // lightweight OS-level sandboxes. + sandboxProvider: text("sandbox_provider").$type<"docker">(), }); export const pullRequests = sqliteTable( diff --git a/packages/host-service/src/runtime/sandbox/registry.test.ts b/packages/host-service/src/runtime/sandbox/registry.test.ts new file mode 100644 index 00000000000..6ddb261d2cd --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/registry.test.ts @@ -0,0 +1,93 @@ +import { Database as BunDatabase } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { HostDb } from "../../db/index.ts"; +import * as schema from "../../db/schema.ts"; +import { hostSettings, projects } from "../../db/schema.ts"; +import { resolveSandboxEnabledForNewWorkspace } from "./registry.ts"; + +const MIGRATIONS_FOLDER = path.resolve(import.meta.dir, "../../../drizzle"); +const PROJECT_ID = "11111111-1111-4111-8111-111111111111"; + +describe("resolveSandboxEnabledForNewWorkspace", () => { + let repoDir: string; + let db: HostDb; + let sqlite: BunDatabase; + + const writeProjectConfig = (config: Record) => { + mkdirSync(path.join(repoDir, ".superset"), { recursive: true }); + writeFileSync( + path.join(repoDir, ".superset", "config.json"), + JSON.stringify(config), + ); + }; + + const setHostDefault = (enabled: boolean) => { + db.insert(hostSettings) + .values({ id: 1, sandboxNewWorkspaces: enabled }) + .onConflictDoUpdate({ + target: hostSettings.id, + set: { sandboxNewWorkspaces: enabled }, + }) + .run(); + }; + + beforeEach(() => { + repoDir = mkdtempSync(path.join(tmpdir(), "superset-sbx-registry-")); + sqlite = new BunDatabase(":memory:"); + const drizzleDb = drizzle(sqlite, { schema }); + migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); + db = drizzleDb as unknown as HostDb; + db.insert(projects) + .values({ + id: PROJECT_ID, + repoPath: repoDir, + name: "sbx-test", + }) + .run(); + }); + + afterEach(() => { + sqlite.close(); + rmSync(repoDir, { recursive: true, force: true }); + }); + + test("defaults to host execution with no config and no host default", () => { + expect(resolveSandboxEnabledForNewWorkspace(db, PROJECT_ID, repoDir)).toBe( + false, + ); + }); + + test("host default sandboxes new workspaces when project config is silent", () => { + setHostDefault(true); + expect(resolveSandboxEnabledForNewWorkspace(db, PROJECT_ID, repoDir)).toBe( + true, + ); + }); + + test("explicit project enabled=true wins without a host default", () => { + writeProjectConfig({ sandbox: { enabled: true } }); + expect(resolveSandboxEnabledForNewWorkspace(db, PROJECT_ID, repoDir)).toBe( + true, + ); + }); + + test("explicit project enabled=false overrides the host default", () => { + setHostDefault(true); + writeProjectConfig({ sandbox: { enabled: false } }); + expect(resolveSandboxEnabledForNewWorkspace(db, PROJECT_ID, repoDir)).toBe( + false, + ); + }); + + test("unknown project resolves to host execution", () => { + setHostDefault(true); + expect( + resolveSandboxEnabledForNewWorkspace(db, "does-not-exist", repoDir), + ).toBe(false); + }); +}); diff --git a/packages/host-service/src/runtime/sandbox/registry.ts b/packages/host-service/src/runtime/sandbox/registry.ts index 65de18d7dc4..3011664ea78 100644 --- a/packages/host-service/src/runtime/sandbox/registry.ts +++ b/packages/host-service/src/runtime/sandbox/registry.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import type { HostDb } from "../../db/index.ts"; -import { projects, workspaces } from "../../db/schema.ts"; +import { hostSettings, projects, workspaces } from "../../db/schema.ts"; import { loadSetupConfig } from "../setup/config.ts"; import { computeConfigHash } from "./container-manager.ts"; import { resolveSandboxSettings } from "./docker-args.ts"; @@ -65,8 +65,10 @@ export function evictWorkspaceRuntime(workspaceId: string): void { } /** - * The sticky sandbox decision for a NEW workspace, resolved from the - * project's sandbox config at create time and persisted on the row. + * The sticky sandbox decision for a NEW workspace, resolved at create time + * and persisted on the row. Precedence: an explicit `sandbox.enabled` in the + * project's config (true OR false) wins; otherwise the host-wide + * "sandbox new workspaces" default from settings applies. */ export function resolveSandboxEnabledForNewWorkspace( db: HostDb, @@ -82,5 +84,8 @@ export function resolveSandboxEnabledForNewWorkspace( projectId, worktreePath, }); - return config?.sandbox?.enabled === true; + const projectSetting = config?.sandbox?.enabled; + if (typeof projectSetting === "boolean") return projectSetting; + const settingsRow = db.select().from(hostSettings).get(); + return settingsRow?.sandboxNewWorkspaces === true; } diff --git a/packages/host-service/src/trpc/router/settings/index.ts b/packages/host-service/src/trpc/router/settings/index.ts index cd2e7378e95..c2421bfdfc3 100644 --- a/packages/host-service/src/trpc/router/settings/index.ts +++ b/packages/host-service/src/trpc/router/settings/index.ts @@ -1,13 +1,19 @@ import { router } from "../../index"; import { agentConfigsRouter } from "./agent-configs"; import { branchPrefixRouter } from "./branch-prefix"; +import { sandboxDefaultsRouter } from "./sandbox-defaults"; import { worktreeLocationRouter } from "./worktree-location"; export const settingsRouter = router({ agentConfigs: agentConfigsRouter, branchPrefix: branchPrefixRouter, + sandboxDefaults: sandboxDefaultsRouter, worktreeLocation: worktreeLocationRouter, }); export type { HostAgentConfig } from "./agent-configs"; +export type { + HostSandboxDefaults, + SandboxProvider, +} from "./sandbox-defaults"; export type { HostWorktreeLocationSettings } from "./worktree-location"; diff --git a/packages/host-service/src/trpc/router/settings/sandbox-defaults.ts b/packages/host-service/src/trpc/router/settings/sandbox-defaults.ts new file mode 100644 index 00000000000..fcbe8a0a443 --- /dev/null +++ b/packages/host-service/src/trpc/router/settings/sandbox-defaults.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { hostSettings } from "../../../db/schema"; +import { checkDockerAvailable } from "../../../runtime/sandbox/docker-cli"; +import { protectedProcedure, router } from "../../index"; + +export const SANDBOX_PROVIDERS = ["docker"] as const; +export type SandboxProvider = (typeof SANDBOX_PROVIDERS)[number]; + +export interface HostSandboxDefaults { + /** Sandbox NEW workspaces by default. Project config overrides per repo. */ + enabled: boolean; + provider: SandboxProvider; + /** Whether the docker daemon answered a probe just now — UI hint only. */ + dockerAvailable: boolean; +} + +/** + * Host-wide sandbox default for new workspaces, stored in the single-row + * `host_settings` table (`id = 1`). Applies at workspace creation (the sticky + * per-workspace `sandboxEnabled` snapshot); a project's explicit + * `sandbox.enabled` in `.superset/config.json` always wins over this default. + */ +export const sandboxDefaultsRouter = router({ + get: protectedProcedure.query( + async ({ ctx }): Promise => { + const row = ctx.db.select().from(hostSettings).get(); + const docker = await checkDockerAvailable(); + return { + enabled: row?.sandboxNewWorkspaces ?? false, + provider: row?.sandboxProvider ?? "docker", + dockerAvailable: docker.ok, + }; + }, + ), + + set: protectedProcedure + .input( + z.object({ + enabled: z.boolean(), + provider: z.enum(SANDBOX_PROVIDERS).default("docker"), + }), + ) + .mutation(({ ctx, input }) => { + const values = { + sandboxNewWorkspaces: input.enabled, + sandboxProvider: input.provider, + }; + ctx.db + .insert(hostSettings) + .values({ id: 1, ...values }) + .onConflictDoUpdate({ target: hostSettings.id, set: values }) + .run(); + return { success: true as const }; + }), +}); From 67823f9b105e2608fc39d06575b70384b3da60c2 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 12:49:28 +0200 Subject: [PATCH 14/21] feat(sandbox): eager container bootstrap at workspace create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registerLocalWorkspace kicks off ensureContainer fire-and-forget; the first terminal joins the in-flight ensure instead of paying cold start - in-memory provisioning state (provisioning/ready/error) surfaced on WorkspaceSnapshot and workspace.list as sandboxStatus; sidebar row shows 'Initializing sandbox…' while the container comes up - dev builds default to the locally built superset-sandbox:dev image (the ghcr.io default isn't published yet); pull failures now include the build command or config hint --- .../useHostWorkspaces.utils.ts | 3 ++ .../DashboardSidebarExpandedWorkspaceRow.tsx | 9 +++- .../buildDashboardSidebarProjects.ts | 2 + .../useDashboardSidebarData.ts | 1 + .../components/DashboardSidebar/types.ts | 1 + packages/host-service/src/events/types.ts | 6 +++ .../src/runtime/sandbox/container-manager.ts | 47 +++++++++++++++++-- .../src/runtime/sandbox/docker-args.ts | 12 ++++- .../src/runtime/sandbox/sandbox-bootstrap.ts | 37 +++++++++++++++ .../src/trpc/router/workspace/workspace.ts | 4 ++ .../src/trpc/router/workspaces/workspaces.ts | 11 +++++ .../src/workspaces/local-workspace-store.ts | 17 +++++++ 12 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 packages/host-service/src/runtime/sandbox/sandbox-bootstrap.ts diff --git a/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.ts b/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.ts index 498dd8d271a..1494534efc4 100644 --- a/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.ts +++ b/apps/desktop/src/renderer/hooks/host-workspaces/useHostWorkspaces/useHostWorkspaces.utils.ts @@ -29,6 +29,7 @@ export interface HostWorkspaceRow extends HostShapedWorkspace { worktreeExists: boolean; /** True when the workspace's terminals run inside a sandbox container. */ sandboxed?: boolean; + sandboxStatus?: "provisioning" | "ready" | "error"; /** Non-null = archived tombstone (only served on `includeArchived`). */ archivedAt?: number | null; archiveReason?: "merged" | "deleted" | null; @@ -40,6 +41,7 @@ export interface HostWorkspaceItem extends HostShapedWorkspace { worktreeExists?: boolean; /** True when the workspace's terminals run inside a sandbox container. */ sandboxed?: boolean; + sandboxStatus?: "provisioning" | "ready" | "error"; /** False when the host didn't answer. */ hostReachable: boolean; /** Non-null = archived tombstone (only present on `includeArchived`). */ @@ -237,6 +239,7 @@ export function applyWorkspaceChangedEvent( updatedAt: new Date(snapshot.updatedAt), worktreePath: snapshot.worktreePath, sandboxed: snapshot.sandboxed, + sandboxStatus: snapshot.sandboxStatus, // A host broadcasting created/updated just acted on the worktree; // keep a known value over assuming. worktreeExists: existing?.worktreeExists ?? true, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index 33ef9fb3443..641be040eb0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -122,7 +122,14 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< } }, [isActive]); - const creationStatusText = isPending ? "Creating…" : null; + // Sandbox provisioning outlives the create mutation (container ensure is + // fire-and-forget), so it shows both during and after the pending state. + const creationStatusText = + workspace.sandboxStatus === "provisioning" + ? "Initializing sandbox…" + : isPending + ? "Creating…" + : null; const isMainWorkspace = workspace.type === "main"; // No hover action button on the local main workspace: a stray click on the // minus would remove the project's anchor row. Removal stays available via diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts index d250c94fc96..ede4686f104 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/buildDashboardSidebarProjects.ts @@ -51,6 +51,7 @@ export interface SidebarWorkspaceInput { pinnedAt: number | null; /** True when the workspace's terminals run inside a sandbox container. */ sandboxed?: boolean; + sandboxStatus?: "provisioning" | "ready" | "error"; pendingTransaction: WorkspaceTransactionSnapshot | null; } @@ -107,6 +108,7 @@ function decorateSidebarWorkspace( taskId: workspace.taskId, isPinned: workspace.pinnedAt != null, sandboxed: workspace.sandboxed, + sandboxStatus: workspace.sandboxStatus, pendingTransaction: workspace.pendingTransaction, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 0c1d2fde9da..1a75652d8ef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -302,6 +302,7 @@ export function useDashboardSidebarData() { isHidden: localState.isHidden, pinnedAt: localState.pinnedAt, sandboxed: workspace.sandboxed, + sandboxStatus: workspace.sandboxStatus, }, ]; }), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index 6bacaea4e93..cda710c2161 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -47,6 +47,7 @@ export interface DashboardSidebarWorkspace { isPinned: boolean; /** True when the workspace's terminals run inside a sandbox container. */ sandboxed?: boolean; + sandboxStatus?: "provisioning" | "ready" | "error"; pendingTransaction: WorkspaceTransactionSnapshot | null; } diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index a4faf845727..fb731338139 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -72,6 +72,12 @@ export interface WorkspaceSnapshot { updatedAt: number; /** True when the workspace's terminals run inside a sandbox container. */ sandboxed: boolean; + /** + * Container-provision progress for sandboxed workspaces, driven by the + * eager bootstrap at create. Undefined when not sandboxed or when the + * state is unknown (e.g. after a host-service restart). + */ + sandboxStatus?: "provisioning" | "ready" | "error"; } export interface WorkspaceChangedMessage { diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts index 5277f33145e..8341a81f90d 100644 --- a/packages/host-service/src/runtime/sandbox/container-manager.ts +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -6,6 +6,7 @@ import { basename, join } from "node:path"; import { buildContainerCreateArgs, CONFIG_HASH_LABEL, + LOCAL_SANDBOX_IMAGE, type MountSpec, type ResolvedSandboxSettings, } from "./docker-args.ts"; @@ -109,6 +110,30 @@ function buildWorkspaceMounts( const ensureInFlight = new Map>(); +export type SandboxProvisioningState = "provisioning" | "ready" | "error"; + +/** + * In-memory container-provision state per workspace, surfaced on + * WorkspaceSnapshot so the renderer can show "Initializing sandbox…" during + * eager bootstrap. Not persisted: after a host restart the state is unknown + * until the next ensure, which is exactly when it becomes interesting again. + */ +const provisioningStates = new Map(); + +export function getSandboxProvisioningState( + workspaceId: string, +): SandboxProvisioningState | undefined { + return provisioningStates.get(workspaceId); +} + +/** Flag the workspace as provisioning before its ensure work is scheduled, + * so a snapshot emitted in the same tick already shows the step. */ +export function markSandboxProvisioning(workspaceId: string): void { + if (provisioningStates.get(workspaceId) !== "ready") { + provisioningStates.set(workspaceId, "provisioning"); + } +} + /** * Ensure the workspace's sandbox container exists and is running. * Single-flight per workspace: concurrent terminal opens (agent + setup @@ -117,9 +142,18 @@ const ensureInFlight = new Map>(); export function ensureContainer(params: EnsureContainerParams): Promise { const existing = ensureInFlight.get(params.workspaceId); if (existing) return existing; - const promise = doEnsureContainer(params).finally(() => { - ensureInFlight.delete(params.workspaceId); - }); + provisioningStates.set(params.workspaceId, "provisioning"); + const promise = doEnsureContainer(params) + .then(() => { + provisioningStates.set(params.workspaceId, "ready"); + }) + .catch((error) => { + provisioningStates.set(params.workspaceId, "error"); + throw error; + }) + .finally(() => { + ensureInFlight.delete(params.workspaceId); + }); ensureInFlight.set(params.workspaceId, promise); return promise; } @@ -175,8 +209,12 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { await pullImage(params.settings.image); } catch (error) { const message = error instanceof Error ? error.message : String(error); + const hint = + params.settings.image === LOCAL_SANDBOX_IMAGE + ? " Build it locally: bun run --cwd packages/sandbox-image build:image" + : " Set sandbox.image in .superset/config.json to a reachable image."; throw new SandboxError( - `Failed to pull sandbox image ${params.settings.image}: ${message}`, + `Failed to pull sandbox image ${params.settings.image}: ${message}.${hint}`, ); } } @@ -214,6 +252,7 @@ export async function destroyWorkspaceSandbox( ): Promise { dropHookToken(workspaceId); dropCliToken(workspaceId); + provisioningStates.delete(workspaceId); const availability = await checkDockerAvailable(); if (availability.ok) { await removeContainer(getSandboxContainerName(workspaceId)); diff --git a/packages/host-service/src/runtime/sandbox/docker-args.ts b/packages/host-service/src/runtime/sandbox/docker-args.ts index 59e14f844c2..9434c713a3a 100644 --- a/packages/host-service/src/runtime/sandbox/docker-args.ts +++ b/packages/host-service/src/runtime/sandbox/docker-args.ts @@ -125,7 +125,17 @@ export interface ResolvedSandboxSettings { cloneDepth?: number; } -export const DEFAULT_SANDBOX_IMAGE = "ghcr.io/superset-sh/sandbox:latest"; +/** Locally-built tag (`bun run --cwd packages/sandbox-image build:image`). */ +export const LOCAL_SANDBOX_IMAGE = "superset-sandbox:dev"; +export const PUBLISHED_SANDBOX_IMAGE = "ghcr.io/superset-sh/sandbox:latest"; +/** + * Development builds default to the locally-built image — the published one + * doesn't exist until the image CI ships, and dev machines build their own. + */ +export const DEFAULT_SANDBOX_IMAGE = + process.env.NODE_ENV === "development" + ? LOCAL_SANDBOX_IMAGE + : PUBLISHED_SANDBOX_IMAGE; const DEFAULT_PIDS_LIMIT = 2048; /** Apply defaults to a parsed SandboxConfig. */ diff --git a/packages/host-service/src/runtime/sandbox/sandbox-bootstrap.ts b/packages/host-service/src/runtime/sandbox/sandbox-bootstrap.ts new file mode 100644 index 00000000000..ae2cbd35287 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/sandbox-bootstrap.ts @@ -0,0 +1,37 @@ +import type { HostDb } from "../../db/index.ts"; +import type { EventBus } from "../../events/index.ts"; +import { notifyWorkspaceUpdated } from "../../workspaces/local-workspace-store.ts"; +import { markSandboxProvisioning } from "./container-manager.ts"; +import { getWorkspaceRuntime } from "./registry.ts"; + +/** + * Eagerly provision a sandboxed workspace's container at CREATE time instead + * of waiting for the first terminal. Fire-and-forget: the create mutation + * stays fast, the first PTY joins the single-flight ensure already running, + * and snapshot events keep the renderer's "Initializing sandbox…" step live. + * + * Failures are logged, not thrown — the first terminal repeats the ensure + * and surfaces the actionable error in the terminal UI. + */ +export function bootstrapWorkspaceSandbox( + ctx: { db: HostDb; eventBus: EventBus }, + workspaceId: string, +): void { + const runtime = getWorkspaceRuntime(ctx.db, workspaceId); + if (runtime.kind !== "docker") return; + + markSandboxProvisioning(workspaceId); + notifyWorkspaceUpdated(ctx, workspaceId); + + void runtime + .prepare() + .catch((error) => { + console.warn( + `[sandbox] eager bootstrap failed for workspace ${workspaceId}:`, + error instanceof Error ? error.message : error, + ); + }) + .finally(() => { + notifyWorkspaceUpdated(ctx, workspaceId); + }); +} diff --git a/packages/host-service/src/trpc/router/workspace/workspace.ts b/packages/host-service/src/trpc/router/workspace/workspace.ts index 63b6d25718f..9cab3ef390c 100644 --- a/packages/host-service/src/trpc/router/workspace/workspace.ts +++ b/packages/host-service/src/trpc/router/workspace/workspace.ts @@ -4,6 +4,7 @@ import { TRPCError } from "@trpc/server"; import { eq, isNull } from "drizzle-orm"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; +import { getSandboxProvisioningState } from "../../../runtime/sandbox/container-manager"; import { toCloudShape, updateLocalWorkspace, @@ -68,6 +69,9 @@ export const workspaceRouter = router({ ...toCloudShape(row, ctx.organizationId), worktreePath: row.worktreePath, sandboxed: row.sandboxEnabled, + sandboxStatus: row.sandboxEnabled + ? getSandboxProvisioningState(row.id) + : undefined, // Tombstones' worktrees are gone by definition; stat-checking an // unbounded, forever-growing archive on every poll adds up. worktreeExists: diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index cb48be98ea9..fb79151162c 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -12,6 +12,7 @@ import { createGitEnvResolver } from "../../../runtime/git"; import { type ResolvedRef, resolveRef } from "../../../runtime/git/refs"; import { syncSandboxCommits } from "../../../runtime/sandbox/git-sync"; import { resolveSandboxEnabledForNewWorkspace } from "../../../runtime/sandbox/registry"; +import { bootstrapWorkspaceSandbox } from "../../../runtime/sandbox/sandbox-bootstrap"; import type { HostServiceContext } from "../../../types"; import { getHostWorkerPool } from "../../../workers/host-worker-pool"; import { gitFetchBaseRefTask } from "../../../workers/tasks/git"; @@ -508,6 +509,16 @@ async function registerLocalWorkspace(args: { }); } + // Provision the sandbox container now (fire-and-forget) rather than on + // first terminal — creation shows "Initializing sandbox…" and the first + // PTY joins the in-flight ensure instead of paying the cold start. + if (localRow.sandboxEnabled) { + bootstrapWorkspaceSandbox( + { db: ctx.db, eventBus: ctx.eventBus }, + localRow.id, + ); + } + void ctx.api.v2Workspace.trackCreated .mutate({ workspaceId: localRow.id, diff --git a/packages/host-service/src/workspaces/local-workspace-store.ts b/packages/host-service/src/workspaces/local-workspace-store.ts index ebf4c80be7f..f9615fd14a3 100644 --- a/packages/host-service/src/workspaces/local-workspace-store.ts +++ b/packages/host-service/src/workspaces/local-workspace-store.ts @@ -8,6 +8,7 @@ import type { HostDb } from "../db"; import { workspaces } from "../db/schema"; import type { EventBus } from "../events"; import type { WorkspaceSnapshot } from "../events/types"; +import { getSandboxProvisioningState } from "../runtime/sandbox/container-manager"; import type { ApiClient } from "../types"; export type HostWorkspaceRow = typeof workspaces.$inferSelect; @@ -92,6 +93,9 @@ export function toWorkspaceSnapshot(row: HostWorkspaceRow): WorkspaceSnapshot { createdAt: row.createdAt, updatedAt: row.updatedAt || row.createdAt, sandboxed: row.sandboxEnabled, + sandboxStatus: row.sandboxEnabled + ? getSandboxProvisioningState(row.id) + : undefined, }; } @@ -287,6 +291,19 @@ export function unarchiveLocalWorkspace( if (row) emitWorkspaceChanged(ctx.eventBus, "created", row); } +/** + * Re-broadcast a workspace's current snapshot as an "updated" event — used + * when derived snapshot state (e.g. sandbox provisioning) changes without a + * row write. + */ +export function notifyWorkspaceUpdated( + ctx: { db: HostDb; eventBus: EventBus }, + workspaceId: string, +): void { + const row = getLocalWorkspace(ctx.db, workspaceId); + if (row) emitWorkspaceChanged(ctx.eventBus, "updated", row); +} + function emitWorkspaceChanged( eventBus: EventBus, eventType: "created" | "updated", From f3537899159245eca7631d9b25bc1162c2cd78a4 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 12:51:03 +0200 Subject: [PATCH 15/21] fix(sandbox): allow claude --dangerously-skip-permissions in containers The container runs as root and Claude Code refuses the flag as root unless IS_SANDBOX=1 marks the environment as an intentional sandbox. Injected per-exec so existing containers pick it up on the next terminal without recreation. --- packages/host-service/src/runtime/sandbox/docker-runtime.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/host-service/src/runtime/sandbox/docker-runtime.ts b/packages/host-service/src/runtime/sandbox/docker-runtime.ts index 7c303e660fd..ef305e92fd0 100644 --- a/packages/host-service/src/runtime/sandbox/docker-runtime.ts +++ b/packages/host-service/src/runtime/sandbox/docker-runtime.ts @@ -62,6 +62,11 @@ export class DockerRuntime implements WorkspaceRuntime { // (meaningless across the container boundary) with these. SUPERSET_HOST_ENDPOINT: getContainerHostEndpoint(), SUPERSET_HOST_TOKEN_FILE: `${CONTAINER_HOST_DIR}/token`, + // The container runs as root; Claude Code refuses + // --dangerously-skip-permissions as root unless this marks the + // environment as an intentional sandbox (its devcontainer escape + // hatch). Accurate here — that isolation is the whole point. + IS_SANDBOX: "1", }; return { shell: "docker", From 9dbfb775088a90a694a75428651535eb851b8cc6 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 13:00:49 +0200 Subject: [PATCH 16/21] feat(sandbox): readable container names + per-instance ownership - containers are named superset-- so Docker Desktop shows which workspace each sandbox belongs to; the workspace-id label stays authoritative and destroy/cleanup resolve containers by label, healing renames and legacy names - new com.superset.home ownership label; the startup reconcile only sweeps containers created by THIS host instance. Fixes integration test runs (own temp DB, same docker daemon) deleting the dev app's live workspace containers as orphans --- .../src/runtime/sandbox/container-manager.ts | 22 +++++++++++-- .../src/runtime/sandbox/docker-args.test.ts | 4 +++ .../src/runtime/sandbox/docker-args.ts | 11 +++++++ .../src/runtime/sandbox/docker-cli.ts | 30 ++++++++++++++--- .../src/runtime/sandbox/docker-runtime.ts | 7 +++- .../src/runtime/sandbox/paths.test.ts | 33 +++++++++++++++++++ .../host-service/src/runtime/sandbox/paths.ts | 32 ++++++++++++++++-- .../src/runtime/sandbox/registry.ts | 23 +++++++++++-- .../sandbox-docker.integration.test.ts | 7 +++- .../src/runtime/sandbox/sandbox-reconcile.ts | 8 +++++ .../sandbox-e2e.integration.test.ts | 10 ++++-- 11 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 packages/host-service/src/runtime/sandbox/paths.test.ts diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts index 8341a81f90d..ebda41a307d 100644 --- a/packages/host-service/src/runtime/sandbox/container-manager.ts +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -15,6 +15,7 @@ import { createContainer, imageExists, inspectContainer, + listWorkspaceContainerNames, pullImage, removeContainer, startContainer, @@ -26,6 +27,7 @@ import { CONTAINER_HOST_DIR, CONTAINER_SUPERSET_DIR, getSandboxContainerName, + getSupersetHomeDir, getWorkspaceSandboxPaths, } from "./paths.ts"; import { selectPublishablePorts } from "./port-probe.ts"; @@ -46,6 +48,8 @@ export interface EnsureContainerParams { worktreePath: string; repoPath: string; branch: string; + /** Human-readable container-name slug (workspace name/branch). */ + nameSlug: string; settings: ResolvedSandboxSettings; } @@ -176,7 +180,7 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { // Also re-registers the token in-memory after host-service restarts. const cliToken = await ensureCliTokenFile(params.workspaceId); - const name = getSandboxContainerName(params.workspaceId); + const name = getSandboxContainerName(params.workspaceId, params.nameSlug); const configHash = computeConfigHash(params.settings); const inspection = await inspectContainer(name); @@ -219,11 +223,21 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { } } + // A rename (workspace or branch) changes the display name; the old-named + // container for this workspace would otherwise linger as a duplicate. + const staleNames = ( + await listWorkspaceContainerNames(params.workspaceId) + ).filter((existing) => existing !== name); + for (const stale of staleNames) { + await removeContainer(stale); + } + await createContainer( buildContainerCreateArgs({ name, workspaceId: params.workspaceId, configHash, + ownerHome: getSupersetHomeDir(), image: params.settings.image, runtime: params.settings.runtime, network: params.settings.network, @@ -255,7 +269,11 @@ export async function destroyWorkspaceSandbox( provisioningStates.delete(workspaceId); const availability = await checkDockerAvailable(); if (availability.ok) { - await removeContainer(getSandboxContainerName(workspaceId)); + // By label, not by name: display names drift (slug/rename) and legacy + // containers used the bare superset-ws- form. + for (const name of await listWorkspaceContainerNames(workspaceId)) { + await removeContainer(name); + } } else { console.warn( `[sandbox] docker unavailable during destroy of ${workspaceId}; ` + diff --git a/packages/host-service/src/runtime/sandbox/docker-args.test.ts b/packages/host-service/src/runtime/sandbox/docker-args.test.ts index 842eea19221..5e77a17fd37 100644 --- a/packages/host-service/src/runtime/sandbox/docker-args.test.ts +++ b/packages/host-service/src/runtime/sandbox/docker-args.test.ts @@ -14,6 +14,7 @@ describe("buildContainerCreateArgs", () => { name: "superset-ws-abc", workspaceId: "abc", configHash: "deadbeef", + ownerHome: "/home/me/.superset", image: "img:1", runtime: "runsc", network: "none", @@ -36,6 +37,8 @@ describe("buildContainerCreateArgs", () => { "com.superset.workspace-id=abc", "--label", "com.superset.config-hash=deadbeef", + "--label", + "com.superset.home=/home/me/.superset", "--restart", "unless-stopped", "--init", @@ -68,6 +71,7 @@ describe("buildContainerCreateArgs", () => { name: "n", workspaceId: "w", configHash: "h", + ownerHome: "/home/me/.superset", image: "img", network: "bridge", resources: { pidsLimit: 2048 }, diff --git a/packages/host-service/src/runtime/sandbox/docker-args.ts b/packages/host-service/src/runtime/sandbox/docker-args.ts index 9434c713a3a..31ef748f287 100644 --- a/packages/host-service/src/runtime/sandbox/docker-args.ts +++ b/packages/host-service/src/runtime/sandbox/docker-args.ts @@ -19,11 +19,20 @@ export interface PublishedPort { export const MANAGED_LABEL = "com.superset.managed=true"; export const WORKSPACE_ID_LABEL = "com.superset.workspace-id"; export const CONFIG_HASH_LABEL = "com.superset.config-hash"; +/** + * Which host-service instance owns the container, keyed by its superset home + * dir. Reconcile only touches containers with ITS OWN home label — a test + * host or a second dev instance must never sweep another instance's + * containers as orphans. + */ +export const OWNER_HOME_LABEL = "com.superset.home"; export interface ContainerCreateSpec { name: string; workspaceId: string; configHash: string; + /** Superset home dir of the owning host-service (OWNER_HOME_LABEL). */ + ownerHome: string; image: string; runtime?: string; network: "bridge" | "none"; @@ -59,6 +68,8 @@ export function buildContainerCreateArgs(spec: ContainerCreateSpec): string[] { `${WORKSPACE_ID_LABEL}=${spec.workspaceId}`, "--label", `${CONFIG_HASH_LABEL}=${spec.configHash}`, + "--label", + `${OWNER_HOME_LABEL}=${spec.ownerHome}`, "--restart", "unless-stopped", "--init", diff --git a/packages/host-service/src/runtime/sandbox/docker-cli.ts b/packages/host-service/src/runtime/sandbox/docker-cli.ts index dd1807ae80e..a0dbe76e54d 100644 --- a/packages/host-service/src/runtime/sandbox/docker-cli.ts +++ b/packages/host-service/src/runtime/sandbox/docker-cli.ts @@ -178,10 +178,30 @@ export async function removeContainer(name: string): Promise { export interface ManagedContainer { name: string; workspaceId: string | null; + /** OWNER_HOME_LABEL value; null on containers from before the label. */ + ownerHome: string | null; running: boolean; } /** All containers carrying the Superset managed label, running or not. */ +/** + * All container names labeled with this workspace id — authoritative lookup + * for destroy/cleanup, since display names can drift (rename, slug change). + */ +export async function listWorkspaceContainerNames( + workspaceId: string, +): Promise { + const out = await docker([ + "ps", + "-a", + "--filter", + `label=com.superset.workspace-id=${workspaceId}`, + "--format", + "{{.Names}}", + ]); + return out.split("\n").filter((name) => name.trim().length > 0); +} + export async function listManagedContainers(): Promise { const out = await docker([ "ps", @@ -200,13 +220,15 @@ export async function listManagedContainers(): Promise { state: string; labels: string; }; - const workspaceLabel = parsed.labels + const labelPairs = parsed.labels .split(",") - .map((pair) => pair.split("=")) - .find(([key]) => key === "com.superset.workspace-id"); + .map((pair) => pair.split("=")); + const labelValue = (key: string) => + labelPairs.find(([k]) => k === key)?.[1] ?? null; containers.push({ name: parsed.name, - workspaceId: workspaceLabel?.[1] ?? null, + workspaceId: labelValue("com.superset.workspace-id"), + ownerHome: labelValue("com.superset.home"), running: parsed.state === "running", }); } catch { diff --git a/packages/host-service/src/runtime/sandbox/docker-runtime.ts b/packages/host-service/src/runtime/sandbox/docker-runtime.ts index ef305e92fd0..bafb7104fd1 100644 --- a/packages/host-service/src/runtime/sandbox/docker-runtime.ts +++ b/packages/host-service/src/runtime/sandbox/docker-runtime.ts @@ -29,6 +29,8 @@ export interface DockerRuntimeParams { worktreePath: string; repoPath: string; branch: string; + /** Human-readable container-name slug (workspace name/branch). */ + nameSlug: string; settings: ResolvedSandboxSettings; } @@ -71,7 +73,10 @@ export class DockerRuntime implements WorkspaceRuntime { return { shell: "docker", argv: buildExecArgs({ - containerName: getSandboxContainerName(this.params.workspaceId), + containerName: getSandboxContainerName( + this.params.workspaceId, + this.params.nameSlug, + ), cwd: ctx.cwd, env, command: ["/bin/bash", "--rcfile", CONTAINER_BASH_RCFILE], diff --git a/packages/host-service/src/runtime/sandbox/paths.test.ts b/packages/host-service/src/runtime/sandbox/paths.test.ts new file mode 100644 index 00000000000..3790919a782 --- /dev/null +++ b/packages/host-service/src/runtime/sandbox/paths.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { getSandboxContainerName, sandboxNameSlug } from "./paths.ts"; + +describe("sandboxNameSlug", () => { + test("sanitizes to docker-safe lowercase and dedupes parts", () => { + expect(sandboxNameSlug("Add tests", "feat/add-tests")).toBe( + "add-tests-feat-add-tests", + ); + expect(sandboxNameSlug("main", "main")).toBe("main"); + expect(sandboxNameSlug(null, "Fix (WS) #12!")).toBe("fix-ws-12"); + }); + + test("truncates long inputs without a trailing dash", () => { + const slug = sandboxNameSlug("a".repeat(28), "branch-name-that-is-long"); + expect(slug.length).toBeLessThanOrEqual(30); + expect(slug.endsWith("-")).toBe(false); + }); +}); + +describe("getSandboxContainerName", () => { + test("slugged name keeps a short unique id suffix", () => { + expect( + getSandboxContainerName( + "9431dce6-39c7-4fd9-b31c-4abc80b73170", + "add-tests", + ), + ).toBe("superset-add-tests-9431dce6"); + }); + + test("falls back to the id-only form without a slug", () => { + expect(getSandboxContainerName("abc")).toBe("superset-ws-abc"); + }); +}); diff --git a/packages/host-service/src/runtime/sandbox/paths.ts b/packages/host-service/src/runtime/sandbox/paths.ts index 7774f335f77..b8d0d37ff65 100644 --- a/packages/host-service/src/runtime/sandbox/paths.ts +++ b/packages/host-service/src/runtime/sandbox/paths.ts @@ -56,6 +56,34 @@ export function getWorkspaceSandboxPaths( }; } -export function getSandboxContainerName(workspaceId: string): string { - return `superset-ws-${workspaceId}`; +const CONTAINER_SLUG_MAX = 30; + +/** + * Docker-safe, human-readable slug from workspace naming parts (name, + * branch). Duplicate/empty parts collapse; "" when nothing usable survives. + */ +export function sandboxNameSlug( + ...parts: Array +): string { + const unique = [...new Set(parts.filter((p): p is string => !!p))]; + return unique + .join("-") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, CONTAINER_SLUG_MAX) + .replace(/-+$/, ""); +} + +/** + * Container name: readable slug (so Docker Desktop shows which workspace it + * is) + short workspace-id suffix for uniqueness. The workspace-id LABEL is + * authoritative for cleanup — names are display + exec targeting only. + */ +export function getSandboxContainerName( + workspaceId: string, + nameSlug?: string, +): string { + if (!nameSlug) return `superset-ws-${workspaceId}`; + return `superset-${nameSlug}-${workspaceId.slice(0, 8)}`; } diff --git a/packages/host-service/src/runtime/sandbox/registry.ts b/packages/host-service/src/runtime/sandbox/registry.ts index 3011664ea78..801e94d25e2 100644 --- a/packages/host-service/src/runtime/sandbox/registry.ts +++ b/packages/host-service/src/runtime/sandbox/registry.ts @@ -6,14 +6,24 @@ import { computeConfigHash } from "./container-manager.ts"; import { resolveSandboxSettings } from "./docker-args.ts"; import { DockerRuntime } from "./docker-runtime.ts"; import { HostRuntime } from "./host-runtime.ts"; +import { sandboxNameSlug } from "./paths.ts"; import type { WorkspaceRuntime } from "./workspace-runtime.ts"; const hostRuntime = new HostRuntime(); const dockerRuntimes = new Map< string, - { configHash: string; runtime: DockerRuntime } + { configHash: string; nameSlug: string; runtime: DockerRuntime } >(); +/** Container-name slug from a workspace row — shared with tests so name + * expectations can't drift from what the runtime actually creates. */ +export function computeWorkspaceNameSlug(row: { + name: string | null; + branch: string; +}): string { + return sandboxNameSlug(row.name, row.branch); +} + /** * Resolve the execution runtime for a workspace. * @@ -44,18 +54,25 @@ export function getWorkspaceRuntime( })?.sandbox ?? {}; const settings = resolveSandboxSettings(sandboxConfig); const configHash = computeConfigHash(settings); + const nameSlug = computeWorkspaceNameSlug(workspace); const cached = dockerRuntimes.get(workspaceId); - if (cached && cached.configHash === configHash) return cached.runtime; + if ( + cached && + cached.configHash === configHash && + cached.nameSlug === nameSlug + ) + return cached.runtime; const runtime = new DockerRuntime({ workspaceId, worktreePath: workspace.worktreePath, repoPath: project.repoPath, branch: workspace.branch, + nameSlug, settings, }); - dockerRuntimes.set(workspaceId, { configHash, runtime }); + dockerRuntimes.set(workspaceId, { configHash, nameSlug, runtime }); return runtime; } diff --git a/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts b/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts index 0a34dc8e9eb..3caacf5a379 100644 --- a/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts +++ b/packages/host-service/src/runtime/sandbox/sandbox-docker.integration.test.ts @@ -48,7 +48,10 @@ describe.skipIf(!DOCKER_TESTS)("sandbox docker integration", () => { let repoPath: string; let worktreePath: string; let savedHomeDir: string | undefined; - const containerName = getSandboxContainerName(WORKSPACE_ID); + const containerName = getSandboxContainerName( + WORKSPACE_ID, + "inttest-feature", + ); beforeAll(async () => { fixtureRoot = mkdtempSync(join(tmpdir(), "superset-sandbox-int-")); @@ -133,6 +136,7 @@ describe.skipIf(!DOCKER_TESTS)("sandbox docker integration", () => { worktreePath, repoPath, branch: "feature", + nameSlug: "inttest-feature", settings, }); @@ -150,6 +154,7 @@ describe.skipIf(!DOCKER_TESTS)("sandbox docker integration", () => { worktreePath, repoPath, branch: "feature", + nameSlug: "inttest-feature", settings, }); diff --git a/packages/host-service/src/runtime/sandbox/sandbox-reconcile.ts b/packages/host-service/src/runtime/sandbox/sandbox-reconcile.ts index 37499a920ed..77c3ca8be7d 100644 --- a/packages/host-service/src/runtime/sandbox/sandbox-reconcile.ts +++ b/packages/host-service/src/runtime/sandbox/sandbox-reconcile.ts @@ -6,6 +6,7 @@ import { listManagedContainers, removeContainer, } from "./docker-cli.ts"; +import { getSupersetHomeDir } from "./paths.ts"; /** * Startup sweep: remove Superset-managed containers whose workspace row is @@ -20,7 +21,14 @@ export async function runSandboxReconcile(db: HostDb): Promise { const containers = await listManagedContainers(); if (containers.length === 0) return; + const ownerHome = getSupersetHomeDir(); for (const container of containers) { + // Only sweep containers THIS host instance created. Several instances + // share one docker daemon (dev app, integration tests, multiple orgs); + // another instance's live workspace looks like an orphan in our DB. + // Unlabeled containers (pre-ownership builds) are left alone too — + // never delete what we can't prove we own. + if (container.ownerHome !== ownerHome) continue; const workspace = container.workspaceId ? db.query.workspaces .findFirst({ diff --git a/packages/host-service/test/integration/sandbox-e2e.integration.test.ts b/packages/host-service/test/integration/sandbox-e2e.integration.test.ts index 6fb18fb8684..786b91e80af 100644 --- a/packages/host-service/test/integration/sandbox-e2e.integration.test.ts +++ b/packages/host-service/test/integration/sandbox-e2e.integration.test.ts @@ -31,7 +31,10 @@ import { terminalAgentBindings, workspaces } from "../../src/db/schema"; import { PskHostAuthProvider } from "../../src/providers/host-auth"; import { inspectContainer } from "../../src/runtime/sandbox/docker-cli"; import { getSandboxContainerName } from "../../src/runtime/sandbox/paths"; -import { getWorkspaceRuntime } from "../../src/runtime/sandbox/registry"; +import { + computeWorkspaceNameSlug, + getWorkspaceRuntime, +} from "../../src/runtime/sandbox/registry"; import { cloudFlows } from "../helpers/cloud-fakes"; import { createProjectScenario } from "../helpers/scenarios"; import { seedTerminalSession } from "../helpers/seed"; @@ -151,7 +154,10 @@ describe.skipIf(!DOCKER_TESTS)("sandbox end-to-end", () => { .get(); if (!row) throw new Error("workspace row missing"); expect(row.sandboxEnabled).toBe(true); - const containerName = getSandboxContainerName(workspaceId); + const containerName = getSandboxContainerName( + workspaceId, + computeWorkspaceNameSlug(row), + ); // ── Runtime resolution + container ensure ───────────────────── const runtime = getWorkspaceRuntime(scenario.host.db, workspaceId); From 940c6f05582b88a95be72ac427b8ff3b9614a66e Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 13:12:24 +0200 Subject: [PATCH 17/21] feat(sandbox): support all npm-distributed agent CLIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new @superset/agent-setup/agent-home-paths registry: home-relative config/auth paths per agent, owned next to the wrapper writers; the sandbox derives its agent-config mounts from it (nested XDG shapes preserved, only existing paths mounted) instead of a hardcoded claude/codex list - default image adds opencode-ai, @google/gemini-cli, @sourcegraph/amp, @github/copilot, mastracode — every supported agent with a verified official npm distribution. droid/cursor-agent (curl-only installers) and kimi/grok/vibe/pi need a custom sandbox.image; their config dirs are already in the mount registry --- packages/agent-setup/package.json | 4 ++ packages/agent-setup/src/agent-home-paths.ts | 41 +++++++++++++++++++ .../src/runtime/sandbox/container-manager.ts | 23 +++++++---- packages/sandbox-image/Dockerfile | 13 ++++++ 4 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 packages/agent-setup/src/agent-home-paths.ts diff --git a/packages/agent-setup/package.json b/packages/agent-setup/package.json index 00434eec36f..da511455899 100644 --- a/packages/agent-setup/package.json +++ b/packages/agent-setup/package.json @@ -16,6 +16,10 @@ "types": "./src/paths.ts", "default": "./src/paths.ts" }, + "./agent-home-paths": { + "types": "./src/agent-home-paths.ts", + "default": "./src/agent-home-paths.ts" + }, "./config": { "types": "./src/config.ts", "default": "./src/config.ts" diff --git a/packages/agent-setup/src/agent-home-paths.ts b/packages/agent-setup/src/agent-home-paths.ts new file mode 100644 index 00000000000..608a9617318 --- /dev/null +++ b/packages/agent-setup/src/agent-home-paths.ts @@ -0,0 +1,41 @@ +/** + * Home-relative paths each supported agent CLI reads config/auth/hooks from. + * Owned here, next to the wrapper writers that target the same locations, so + * consumers (the workspace sandbox mounts host agent config into containers + * from this list) track new agents automatically instead of keeping a + * parallel hardcoded list. + * + * Keep entries in sync with the corresponding agent-wrappers-*.ts writer. + * fs-free on purpose — see agent-setup-targets.ts for the same convention. + */ +export const AGENT_HOME_CONFIG_PATHS: readonly string[] = [ + // claude (agent-wrappers-claude-codex-opencode.ts) + ".claude", + ".claude.json", + // codex + ".codex", + // opencode: XDG config + auth.json under XDG data + ".config/opencode", + ".local/share/opencode", + // gemini (agent-wrappers-gemini.ts) + ".gemini", + // amp (agent-wrappers-amp.ts) + ".config/amp", + // copilot + ".copilot", + // mastracode (agent-wrappers-mastra.ts) + ".mastracode", + // droid / Factory (agent-wrappers-droid.ts) + ".factory", + // kimi (agent-wrappers-kimi.ts) + ".kimi-code", + // grok (agent-wrappers-grok.ts) + ".grok", + // vibe (agent-wrappers-vibe.ts) + ".vibe", + // pi + oh-my-pi (agent-wrappers-pi.ts / agent-wrappers-omp.ts) + ".pi", + ".omp", + // cursor-agent (agent-wrappers-cursor.ts) + ".cursor", +]; diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts index ebda41a307d..112a9f1c248 100644 --- a/packages/host-service/src/runtime/sandbox/container-manager.ts +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -2,7 +2,8 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; import { homedir } from "node:os"; -import { basename, join } from "node:path"; +import { join } from "node:path"; +import { AGENT_HOME_CONFIG_PATHS } from "@superset/agent-setup/agent-home-paths"; import { buildContainerCreateArgs, CONFIG_HASH_LABEL, @@ -65,16 +66,22 @@ export function computeConfigHash(settings: ResolvedSandboxSettings): string { * host auth (and OAuth refreshes stay coherent both ways). Accepted v1 * trade-off — documented in the sandbox config; per-workspace agent homes * with in-container login are the hardening follow-up. + * + * The path list lives in agent-setup next to each agent's wrapper writer, + * so new agents get their config into sandboxes without touching this file. + * Only paths that exist on the host are mounted — an agent the user never + * ran contributes nothing. */ function buildAgentConfigMounts(): MountSpec[] { const home = homedir(); - return [".claude", ".claude.json", ".codex"] - .map((entry) => join(home, entry)) - .filter((source) => existsSync(source)) - .map((source) => ({ - source, - target: `${CONTAINER_HOME_DIR}/${basename(source)}`, - })); + return AGENT_HOME_CONFIG_PATHS.filter((entry) => + existsSync(join(home, entry)), + ).map((entry) => ({ + source: join(home, entry), + // Preserve the home-relative shape (e.g. .config/opencode) so XDG + // paths land where the CLI looks for them in-container. + target: `${CONTAINER_HOME_DIR}/${entry}`, + })); } function buildWorkspaceMounts( diff --git a/packages/sandbox-image/Dockerfile b/packages/sandbox-image/Dockerfile index 86dfe372a3c..ea2e0ae0e46 100644 --- a/packages/sandbox-image/Dockerfile +++ b/packages/sandbox-image/Dockerfile @@ -13,6 +13,11 @@ FROM node:22-bookworm-slim ARG CLAUDE_CODE_VERSION=latest ARG CODEX_VERSION=latest +ARG OPENCODE_VERSION=latest +ARG GEMINI_CLI_VERSION=latest +ARG AMP_VERSION=latest +ARG COPILOT_VERSION=latest +ARG MASTRACODE_VERSION=latest RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -25,10 +30,18 @@ RUN apt-get update \ ripgrep \ && rm -rf /var/lib/apt/lists/* +# Every Superset-supported agent CLI with an official npm distribution. +# Not npm-distributed (bring your own via sandbox.image + config mounts): +# droid/Factory and cursor-agent (curl installers), kimi, grok, vibe, pi/omp. RUN npm install -g \ bun \ "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ "@openai/codex@${CODEX_VERSION}" \ + "opencode-ai@${OPENCODE_VERSION}" \ + "@google/gemini-cli@${GEMINI_CLI_VERSION}" \ + "@sourcegraph/amp@${AMP_VERSION}" \ + "@github/copilot@${COPILOT_VERSION}" \ + "mastracode@${MASTRACODE_VERSION}" \ && npm cache clean --force # Host worktrees bind-mount at arbitrary paths with host ownership; git From cbf582054915d4402caf506ca57d17e166c2269f Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 16:01:39 +0200 Subject: [PATCH 18/21] fix(sandbox): address code-review findings - cli: sandbox host target was missing the required ws endpoint (real @superset/cli typecheck failure); return baseWsUrl/token - workspace-cleanup: preserve isolated git state when a sandboxed workspace has no project, instead of destroying the only copy of unexported commits - container-manager: never docker-start an already-running container; restart only stopped ones (daemon restart/crash/reboot recovery) - sidebar: propagate sandboxed/sandboxStatus to auto-included main workspace rows so their badge and provisioning status show - settings: block sandbox writes and show a message when the defaults fetch fails, so a toggle can't persist a value from unknown state - image: pin all agent CLI versions (reproducible rebuilds) + document build args and bundled packages in the README - e2e test: tear down the container in afterAll so an aborted run can't leak an orphaned container --- .../useDashboardSidebarData.ts | 2 ++ .../SandboxSettings/SandboxSettings.tsx | 11 ++++++++++ .../src/lib/host-target/resolveHostTarget.ts | 4 ++++ .../src/runtime/sandbox/container-manager.ts | 13 +++++++---- .../workspace-cleanup/workspace-cleanup.ts | 10 ++++++++- .../sandbox-e2e.integration.test.ts | 10 +++++++++ packages/sandbox-image/Dockerfile | 17 ++++++++------ packages/sandbox-image/README.md | 22 +++++++++++++++++-- 8 files changed, 75 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index 1a75652d8ef..fac966e08d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -348,6 +348,8 @@ export function useDashboardSidebarData() { taskId: workspace.taskId, createdAt: workspace.createdAt, updatedAt: workspace.updatedAt, + sandboxed: workspace.sandboxed, + sandboxStatus: workspace.sandboxStatus, tabOrder: MAIN_WORKSPACE_TAB_ORDER, sectionId: null as string | null, // Auto-included mains have no local-state row; pinning one diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/SandboxSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/SandboxSettings.tsx index 257b0d56009..4d192a3b8ed 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/SandboxSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/sandboxes/components/SandboxSettings/SandboxSettings.tsx @@ -123,10 +123,14 @@ export function SandboxSettings({ hostId }: SandboxSettingsProps) { ), }); + // A failed fetch leaves `enabled`/`provider` on their fallbacks, which were + // never read from the host — block writes so a toggle can't persist a value + // derived from unknown current state. const controlsDisabled = !targetHostUrl || !isHostOnline || defaultsQuery.isLoading || + defaultsQuery.isError || setMutation.isPending; return ( @@ -155,6 +159,13 @@ export function SandboxSettings({ hostId }: SandboxSettingsProps) { ) : null} + {defaultsQuery.isError ? ( +

+ Couldn't load sandbox settings from {selectedHostName}. Controls are + disabled until the host responds. +

+ ) : null} +
{ if (inspection.exists) { const staleConfig = inspection.labels[CONFIG_HASH_LABEL] !== configHash; - if (inspection.running && !staleConfig) return; - if (!inspection.running && staleConfig) { - // Stopped + outdated: recreate. A RUNNING container with stale - // config keeps serving — never kill live terminals implicitly. + if (inspection.running) { + // Already running: keep serving even with stale config — never kill + // live terminals implicitly; the new config lands on next recreate. + return; + } + if (staleConfig) { + // Stopped + outdated: recreate with the current config. await removeContainer(name); } else { + // Stopped + current config (daemon restart, crash, reboot, docker + // stop): just restart the existing container. await startContainer(name); return; } diff --git a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts index 8eff1c6f459..98343ac59eb 100644 --- a/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts +++ b/packages/host-service/src/trpc/router/workspace-cleanup/workspace-cleanup.ts @@ -428,13 +428,17 @@ async function runDestroyPhases( // Sandbox-local commits live only in the isolated git dir; export // them into the main repo as refs/sandbox//* first, and keep the // git dir on disk if the export fails so they're never destroyed. - let sandboxExportFailed = false; + // Preserve the git dir unless commits are provably safe in the main + // repo. With no project there's nowhere to export to, so keep state + // on disk rather than destroy the only copy of sandbox-local commits. + let sandboxExportFailed = !project; if (project) { try { await exportSandboxRefs({ repoPath: project.repoPath, workspaceId: input.workspaceId, }); + sandboxExportFailed = false; } catch (err) { sandboxExportFailed = true; const message = err instanceof Error ? err.message : String(err); @@ -442,6 +446,10 @@ async function runDestroyPhases( `Failed to export sandbox commits (sandbox git state kept on disk): ${message}`, ); } + } else { + warnings.push( + "Workspace has no project; sandbox git state kept on disk to avoid losing unexported commits.", + ); } try { await destroyWorkspaceSandbox(input.workspaceId, { diff --git a/packages/host-service/test/integration/sandbox-e2e.integration.test.ts b/packages/host-service/test/integration/sandbox-e2e.integration.test.ts index 786b91e80af..89c78233ca4 100644 --- a/packages/host-service/test/integration/sandbox-e2e.integration.test.ts +++ b/packages/host-service/test/integration/sandbox-e2e.integration.test.ts @@ -29,6 +29,7 @@ import { promisify } from "node:util"; import { eq } from "drizzle-orm"; import { terminalAgentBindings, workspaces } from "../../src/db/schema"; import { PskHostAuthProvider } from "../../src/providers/host-auth"; +import { destroyWorkspaceSandbox } from "../../src/runtime/sandbox/container-manager"; import { inspectContainer } from "../../src/runtime/sandbox/docker-cli"; import { getSandboxContainerName } from "../../src/runtime/sandbox/paths"; import { @@ -63,6 +64,9 @@ describe.skipIf(!DOCKER_TESTS)("sandbox end-to-end", () => { let savedHostPort: string | undefined; let scenario: Awaited>; let server: ReturnType; + // Hoisted so afterAll can tear the container down even if an assertion + // between provision and the in-test workspace.delete throws. + let createdWorkspaceId: string | undefined; beforeAll(async () => { fixtureRoot = mkdtempSync(join(tmpdir(), "superset-sandbox-e2e-")); @@ -127,6 +131,11 @@ describe.skipIf(!DOCKER_TESTS)("sandbox end-to-end", () => { }, 10 * 60_000); afterAll(async () => { + // Best-effort: the happy path already deleted the workspace; this only + // fires when an assertion aborted the test mid-run and left a container. + if (createdWorkspaceId) { + await destroyWorkspaceSandbox(createdWorkspaceId).catch(() => {}); + } server?.stop(true); if (savedHomeDir === undefined) delete process.env.SUPERSET_HOME_DIR; else process.env.SUPERSET_HOME_DIR = savedHomeDir; @@ -147,6 +156,7 @@ describe.skipIf(!DOCKER_TESTS)("sandbox end-to-end", () => { }); const workspaceId = created?.workspace?.id; if (!workspaceId) throw new Error("workspace create failed"); + createdWorkspaceId = workspaceId; const row = scenario.host.db .select() .from(workspaces) diff --git a/packages/sandbox-image/Dockerfile b/packages/sandbox-image/Dockerfile index ea2e0ae0e46..d4c3f0bf262 100644 --- a/packages/sandbox-image/Dockerfile +++ b/packages/sandbox-image/Dockerfile @@ -11,13 +11,16 @@ FROM node:22-bookworm-slim -ARG CLAUDE_CODE_VERSION=latest -ARG CODEX_VERSION=latest -ARG OPENCODE_VERSION=latest -ARG GEMINI_CLI_VERSION=latest -ARG AMP_VERSION=latest -ARG COPILOT_VERSION=latest -ARG MASTRACODE_VERSION=latest +# Pinned for reproducible rebuilds; override any at build time with +# --build-arg =. Bump these deliberately when refreshing the +# published image. +ARG CLAUDE_CODE_VERSION=2.1.247 +ARG CODEX_VERSION=0.150.1 +ARG OPENCODE_VERSION=1.18.23 +ARG GEMINI_CLI_VERSION=0.57.0 +ARG AMP_VERSION=0.0.1787829336-g289152 +ARG COPILOT_VERSION=1.0.80 +ARG MASTRACODE_VERSION=0.36.1 RUN apt-get update \ && apt-get install -y --no-install-recommends \ diff --git a/packages/sandbox-image/README.md b/packages/sandbox-image/README.md index 12663927351..11581fe727d 100644 --- a/packages/sandbox-image/README.md +++ b/packages/sandbox-image/README.md @@ -25,8 +25,26 @@ then point a project at it (machine-local config, e.g. Any image used as `sandbox.image` must provide `/bin/bash`, `git`, `curl`, CA certificates, and a `sleep` binary. The default image additionally ships -node/bun, ripgrep, jq, and pinned Claude Code + Codex CLIs (see build args -`CLAUDE_CODE_VERSION` / `CODEX_VERSION`). +node/bun, ripgrep, jq, `openssh-client`, `procps`, and pinned agent CLIs. + +### Pinned build args + +Each agent CLI is pinned for reproducible rebuilds; override at build time +with `--build-arg =`: + +| Build arg | Package | +| --- | --- | +| `CLAUDE_CODE_VERSION` | `@anthropic-ai/claude-code` | +| `CODEX_VERSION` | `@openai/codex` | +| `OPENCODE_VERSION` | `opencode-ai` | +| `GEMINI_CLI_VERSION` | `@google/gemini-cli` | +| `AMP_VERSION` | `@sourcegraph/amp` | +| `COPILOT_VERSION` | `@github/copilot` | +| `MASTRACODE_VERSION` | `mastracode` | + +Agents without an official npm package (droid/Factory, cursor-agent, kimi, +grok, vibe, pi/omp) are not bundled — supply a custom `sandbox.image` with +them installed; their host config dirs already mount into the sandbox. ## Publishing From 8157a31d0481e66ea09ca907c906f55068826470 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 16:26:36 +0200 Subject: [PATCH 19/21] fix(sandbox): address second code-review batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth: hook token now REQUIRED once registered (reject missing/wrong), not just wrong — closes the token-less spoofing bypass (CWE-287) - config: agentConfig (host credential mount) is machine-local-only and defaults OFF, so a cloned repo can't mount host agent creds (CWE-829) - git-sync: guard reset --mixed with the checked-out branch so a manual checkout / detached HEAD can't advance the wrong branch - git-bootstrap: use the last-written sha file as the completion sentinel and rebuild partial state, so an interrupted bootstrap can't report success with the .git isolation mask missing - container-manager: rename containers in place on workspace/branch rename (docker rename preserves live exec sessions) instead of rm -f - container start already-running fix retained from prior batch Covers CodeRabbit items #6, #9, #10, #11, #12. --- .../src/runtime/sandbox/container-manager.ts | 32 ++++++++++++------- .../src/runtime/sandbox/docker-args.test.ts | 2 +- .../src/runtime/sandbox/docker-args.ts | 4 ++- .../src/runtime/sandbox/docker-cli.ts | 19 +++++++++++ .../src/runtime/sandbox/git-bootstrap.ts | 15 +++++++-- .../src/runtime/sandbox/git-sync.ts | 10 ++++++ .../runtime/sandbox/sandbox-tokens.test.ts | 12 ++++--- .../src/runtime/sandbox/sandbox-tokens.ts | 10 +++--- .../src/runtime/setup/sandbox-config.test.ts | 17 ++++++++-- .../src/runtime/setup/sandbox-config.ts | 26 +++++++++++---- 10 files changed, 113 insertions(+), 34 deletions(-) diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts index a4da5b0f2ce..480e436cc52 100644 --- a/packages/host-service/src/runtime/sandbox/container-manager.ts +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -19,6 +19,7 @@ import { listWorkspaceContainerNames, pullImage, removeContainer, + renameContainer, startContainer, } from "./docker-cli.ts"; import { ensureSandboxGit } from "./git-bootstrap.ts"; @@ -63,9 +64,11 @@ export function computeConfigHash(settings: ResolvedSandboxSettings): string { /** * Host agent config mounted read-write so agents inside the sandbox reuse - * host auth (and OAuth refreshes stay coherent both ways). Accepted v1 - * trade-off — documented in the sandbox config; per-workspace agent homes - * with in-container login are the hardening follow-up. + * host auth (and OAuth refreshes stay coherent both ways). OPT-IN only: gated + * behind `agentConfig`, which is honored solely from machine-local config — + * a cloned repo can never mount the host user's credentials into its own + * sandbox. Per-workspace agent homes with in-container login are the + * hardening follow-up. * * The path list lives in agent-setup next to each agent's wrapper writer, * so new agents get their config into sandboxes without touching this file. @@ -189,6 +192,20 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { const name = getSandboxContainerName(params.workspaceId, params.nameSlug); const configHash = computeConfigHash(params.settings); + + // Heal a display-name drift (workspace or branch rename) by renaming the + // existing container in place. `docker rename` keeps the container AND its + // live exec/terminal sessions alive, unlike remove+recreate. Only if the + // rename can't apply (target taken, source gone) do we fall back to + // removing the stale-named leftover. + const priorNames = ( + await listWorkspaceContainerNames(params.workspaceId) + ).filter((existing) => existing !== name); + for (const prior of priorNames) { + const renamed = await renameContainer(prior, name); + if (!renamed) await removeContainer(prior); + } + const inspection = await inspectContainer(name); if (inspection.exists) { @@ -235,15 +252,6 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { } } - // A rename (workspace or branch) changes the display name; the old-named - // container for this workspace would otherwise linger as a duplicate. - const staleNames = ( - await listWorkspaceContainerNames(params.workspaceId) - ).filter((existing) => existing !== name); - for (const stale of staleNames) { - await removeContainer(stale); - } - await createContainer( buildContainerCreateArgs({ name, diff --git a/packages/host-service/src/runtime/sandbox/docker-args.test.ts b/packages/host-service/src/runtime/sandbox/docker-args.test.ts index 5e77a17fd37..f0eca3cfe8a 100644 --- a/packages/host-service/src/runtime/sandbox/docker-args.test.ts +++ b/packages/host-service/src/runtime/sandbox/docker-args.test.ts @@ -122,7 +122,7 @@ describe("resolveSandboxSettings", () => { resources: { pidsLimit: 2048 }, extraMounts: [], envPassthrough: [], - mountAgentConfig: true, + mountAgentConfig: false, }); }); diff --git a/packages/host-service/src/runtime/sandbox/docker-args.ts b/packages/host-service/src/runtime/sandbox/docker-args.ts index 31ef748f287..62f3f8e7fb0 100644 --- a/packages/host-service/src/runtime/sandbox/docker-args.ts +++ b/packages/host-service/src/runtime/sandbox/docker-args.ts @@ -167,7 +167,9 @@ export function resolveSandboxSettings( }, extraMounts: (config.mounts ?? []).map(parseConfigMount), envPassthrough: config.env ?? [], - mountAgentConfig: config.agentConfig ?? true, + // Default OFF: mounting host agent credentials is opt-in via + // machine-local config, so a repo-enabled sandbox never gets them. + mountAgentConfig: config.agentConfig ?? false, ...(config.git?.cloneDepth && { cloneDepth: config.git.cloneDepth }), }; } diff --git a/packages/host-service/src/runtime/sandbox/docker-cli.ts b/packages/host-service/src/runtime/sandbox/docker-cli.ts index a0dbe76e54d..eaa4f2db33b 100644 --- a/packages/host-service/src/runtime/sandbox/docker-cli.ts +++ b/packages/host-service/src/runtime/sandbox/docker-cli.ts @@ -164,6 +164,25 @@ export async function startContainer(name: string): Promise { await docker(["start", name], { timeoutMs: 60_000 }); } +/** + * Rename a container in place. Unlike remove+recreate, this preserves the + * running container and its live `docker exec` (terminal) sessions. Returns + * false if the source is gone or the target name is already taken, so the + * caller can fall back to removal. + */ +export async function renameContainer( + from: string, + to: string, +): Promise { + try { + await docker(["rename", from, to], { timeoutMs: 30_000 }); + return true; + } catch (error) { + if (error instanceof DockerCliError) return false; + throw error; + } +} + export async function removeContainer(name: string): Promise { try { await docker(["rm", "-f", name], { timeoutMs: 60_000 }); diff --git a/packages/host-service/src/runtime/sandbox/git-bootstrap.ts b/packages/host-service/src/runtime/sandbox/git-bootstrap.ts index 7dc7133e2e9..d460a1c090e 100644 --- a/packages/host-service/src/runtime/sandbox/git-bootstrap.ts +++ b/packages/host-service/src/runtime/sandbox/git-bootstrap.ts @@ -12,8 +12,7 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import { promisify } from "node:util"; import { CONTAINER_GIT_DIR, getWorkspaceSandboxPaths } from "./paths.ts"; @@ -109,7 +108,16 @@ export async function ensureSandboxGit( params: GitBootstrapParams, ): Promise { const paths = getWorkspaceSandboxPaths(params.workspaceId); - if (existsSync(join(paths.gitDir, "HEAD"))) return; + // The bootstrap-sha file is the LAST artifact written, so it is the only + // safe "done" sentinel: keying off gitDir/HEAD would treat a run + // interrupted after the bare clone but before the .git mask as complete, + // leaving the isolation mask absent. Anything short of the sentinel is a + // partial state — wipe and rebuild it atomically. + if (existsSync(paths.bootstrapShaFile)) return; + if (existsSync(paths.gitDir) || existsSync(paths.dotGitFile)) { + await rm(paths.gitDir, { recursive: true, force: true }); + await rm(paths.dotGitFile, { force: true }); + } await mkdir(paths.stateDir, { recursive: true }); @@ -131,5 +139,6 @@ export async function ensureSandboxGit( await writeFile(paths.dotGitFile, `gitdir: ${CONTAINER_GIT_DIR}\n`, { mode: 0o444, }); + // Written last: its presence marks the bootstrap complete (see above). await writeFile(paths.bootstrapShaFile, `${headSha}\n`, { mode: 0o644 }); } diff --git a/packages/host-service/src/runtime/sandbox/git-sync.ts b/packages/host-service/src/runtime/sandbox/git-sync.ts index 21ec4e5ba0d..f6027630fce 100644 --- a/packages/host-service/src/runtime/sandbox/git-sync.ts +++ b/packages/host-service/src/runtime/sandbox/git-sync.ts @@ -94,6 +94,16 @@ export async function syncSandboxCommits(args: { return { status: "no-sandbox-history" }; } + // `reset --mixed` moves whatever branch is checked out. Guard against the + // worktree having switched away from args.branch (manual checkout, detached + // HEAD) so we never advance the wrong branch to the sandbox commit. + const checkedOutBranch = ( + await git(args.worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]) + ).trim(); + if (checkedOutBranch !== args.branch) { + return { status: "diverged", ref }; + } + const headSha = (await git(args.worktreePath, ["rev-parse", "HEAD"])).trim(); if (headSha === sandboxSha) return { status: "up-to-date", ref }; diff --git a/packages/host-service/src/runtime/sandbox/sandbox-tokens.test.ts b/packages/host-service/src/runtime/sandbox/sandbox-tokens.test.ts index bec89048fa9..1b0621f4a84 100644 --- a/packages/host-service/src/runtime/sandbox/sandbox-tokens.test.ts +++ b/packages/host-service/src/runtime/sandbox/sandbox-tokens.test.ts @@ -17,17 +17,19 @@ describe("sandbox hook tokens", () => { expect(getOrCreateHookToken("ws-b")).not.toBe(a); }); - test("verification is tolerant of absence, strict on mismatch", () => { - // No token registered (host workspace): everything passes. + test("verification passes when no token is registered", () => { + // No token registered (host workspace, pre-update script): all pass. expect(verifyHookToken("ws-none", undefined)).toBe(true); expect(verifyHookToken("ws-none", "whatever")).toBe(true); + }); + test("verification requires a matching token once one is registered", () => { const token = getOrCreateHookToken("ws-a"); - // Token-less request from a pre-update notify script passes. - expect(verifyHookToken("ws-a", undefined)).toBe(true); - expect(verifyHookToken("ws-a", token)).toBe(true); + // A registered workspace rejects both a missing and a wrong token. + expect(verifyHookToken("ws-a", undefined)).toBe(false); expect(verifyHookToken("ws-a", "wrong")).toBe(false); expect(verifyHookToken("ws-a", `${token}x`)).toBe(false); + expect(verifyHookToken("ws-a", token)).toBe(true); }); test("dropped tokens stop constraining verification", () => { diff --git a/packages/host-service/src/runtime/sandbox/sandbox-tokens.ts b/packages/host-service/src/runtime/sandbox/sandbox-tokens.ts index c0644175c5d..3f3289699f4 100644 --- a/packages/host-service/src/runtime/sandbox/sandbox-tokens.ts +++ b/packages/host-service/src/runtime/sandbox/sandbox-tokens.ts @@ -26,16 +26,18 @@ export function dropHookToken(workspaceId: string): void { } /** - * Tolerant verification: only a PRESENT-but-mismatched token is rejected. - * No registered token (host workspaces) and token-less requests (pre-update - * notify scripts) both pass. + * Verification: a workspace with a registered token REQUIRES a matching one — + * a missing or wrong token is rejected. Workspaces with no registered token + * (host workspaces, pre-update notify scripts) still pass, so this only + * tightens sandboxed workspaces without breaking the unsandboxed path. */ export function verifyHookToken( workspaceId: string, presented: string | undefined, ): boolean { const registered = hookTokens.get(workspaceId); - if (!registered || presented === undefined) return true; + if (!registered) return true; + if (presented === undefined) return false; const a = Buffer.from(presented); const b = Buffer.from(registered); return a.length === b.length && timingSafeEqual(a, b); diff --git a/packages/host-service/src/runtime/setup/sandbox-config.test.ts b/packages/host-service/src/runtime/setup/sandbox-config.test.ts index d2341cba110..79bc2322328 100644 --- a/packages/host-service/src/runtime/setup/sandbox-config.test.ts +++ b/packages/host-service/src/runtime/setup/sandbox-config.test.ts @@ -37,17 +37,30 @@ describe("validateSandboxConfig", () => { }); }); - test("drops mounts and env from repo-shipped sources but keeps the rest", () => { + test("drops mounts, env, and agentConfig from repo-shipped sources but keeps the rest", () => { const config = validateSandboxConfig( { enabled: true, + image: "repo/custom:latest", + agentConfig: true, mounts: ["/Users/victim/.ssh"], env: ["AWS_SECRET_ACCESS_KEY"], }, "repo/.superset/config.json", REPO_SOURCE, ); - expect(config).toEqual({ enabled: true }); + // A cloned repo can opt its workspaces into a sandbox and pick an image, + // but must NOT be able to mount the host's agent credentials. + expect(config).toEqual({ enabled: true, image: "repo/custom:latest" }); + }); + + test("keeps agentConfig from a machine-local source", () => { + const config = validateSandboxConfig( + { enabled: true, agentConfig: true }, + "~/.superset/projects/app/config.json", + MACHINE_LOCAL, + ); + expect(config).toEqual({ enabled: true, agentConfig: true }); }); test.each([ diff --git a/packages/host-service/src/runtime/setup/sandbox-config.ts b/packages/host-service/src/runtime/setup/sandbox-config.ts index afaa70655c2..4152ed74139 100644 --- a/packages/host-service/src/runtime/setup/sandbox-config.ts +++ b/packages/host-service/src/runtime/setup/sandbox-config.ts @@ -156,13 +156,27 @@ export function validateSandboxConfig( const obj = value as Record; const result: SandboxConfig = {}; - for (const field of ["enabled", "agentConfig"] as const) { - const fieldValue = obj[field]; - if (fieldValue === undefined) continue; - if (typeof fieldValue !== "boolean") { - return invalid(source, `'${field}' must be a boolean`); + if (obj.enabled !== undefined) { + if (typeof obj.enabled !== "boolean") { + return invalid(source, "'enabled' must be a boolean"); + } + result.enabled = obj.enabled; + } + // agentConfig bind-mounts the host user's live agent credentials + // (~/.claude, ~/.codex, ...) into the container. A cloned repo must not be + // able to turn that on for itself — same trust rule as mounts/env. + if (obj.agentConfig !== undefined) { + if (typeof obj.agentConfig !== "boolean") { + return invalid(source, "'agentConfig' must be a boolean"); + } + if (!options.machineLocal) { + console.warn( + `Ignoring sandbox 'agentConfig' from ${source}: only machine-local ` + + "config (~/.superset/projects/... or config.local.json) may set it", + ); + } else { + result.agentConfig = obj.agentConfig; } - result[field] = fieldValue; } for (const field of ["image", "runtime"] as const) { const fieldValue = obj[field]; From 171b7c1efdfcfb186e050707e6960309f60321e7 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 22:57:59 +0200 Subject: [PATCH 20/21] fix(sandbox): scope CLI tokens + remaining correctness (CWE-862 et al.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CWE-862 (#4): a sandbox CLI token is now a narrower principal than the PSK. createContext resolves the token to its workspace; protectedProcedure enforces a default-deny ACL (sandbox-token-acl.ts) so a token may only act on its OWN workspace's terminals/agents and syncSandbox — never create host-run sessions/workspaces, read host files, touch other workspaces, or reach auth/settings/project/host routes. WS routes scoped the same way. Closes the host-RCE / credential-exfil escape. #13: stage long initial-command / fish-transport scripts into a bind-mounted per-workspace launch dir (same host==container path) so the container shell can source them; host runtime still uses tmpdir. #15: defer eager sandbox bootstrap past the AI branch rename so the isolated git dir is created on the final branch (sync no longer misses commits stranded under the old ref). #7: add publish-sandbox-image CI workflow (multi-arch GHCR) so the production default image resolves; README updated. #8 (cleartext CLI transport): documented — impact now bounded by the token scoping above; TLS/unix-socket transport tracked as M4 hardening. --- .github/workflows/publish-sandbox-image.yml | 55 ++++++++ packages/host-service/src/app.ts | 33 ++++- .../src/runtime/sandbox/container-manager.ts | 10 +- .../src/runtime/sandbox/docker-runtime.ts | 4 + .../host-service/src/runtime/sandbox/paths.ts | 7 + .../src/runtime/sandbox/sandbox-cli-tokens.ts | 22 ++- .../src/runtime/sandbox/workspace-runtime.ts | 6 + .../host-service/src/terminal/terminal.ts | 14 +- packages/host-service/src/trpc/index.ts | 39 ++++-- .../src/trpc/router/workspaces/workspaces.ts | 38 ++++- .../src/trpc/sandbox-token-acl.test.ts | 124 ++++++++++++++++ .../src/trpc/sandbox-token-acl.ts | 132 ++++++++++++++++++ packages/host-service/src/types.ts | 7 + packages/sandbox-image/README.md | 9 +- 14 files changed, 472 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/publish-sandbox-image.yml create mode 100644 packages/host-service/src/trpc/sandbox-token-acl.test.ts create mode 100644 packages/host-service/src/trpc/sandbox-token-acl.ts diff --git a/.github/workflows/publish-sandbox-image.yml b/.github/workflows/publish-sandbox-image.yml new file mode 100644 index 00000000000..2267e7dab42 --- /dev/null +++ b/.github/workflows/publish-sandbox-image.yml @@ -0,0 +1,55 @@ +name: Publish sandbox image + +# Builds and publishes the default Superset workspace sandbox image to GHCR. +# host-service defaults non-development workspaces to +# ghcr.io/superset-sh/sandbox:latest, so this must run before that default is +# reachable in a release. Multi-arch (amd64 + arm64) so both host CPUs work. + +on: + push: + branches: [main] + paths: + - "packages/sandbox-image/**" + - ".github/workflows/publish-sandbox-image.yml" + workflow_dispatch: + inputs: + tag: + description: "Image tag to publish (default: latest)" + required: false + type: string + default: latest + +concurrency: + group: publish-sandbox-image + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + env: + IMAGE_TAG: ${{ inputs.tag || 'latest' }} + steps: + - uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: packages/sandbox-image + file: packages/sandbox-image/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/superset-sh/sandbox:${{ env.IMAGE_TAG }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 857da5ee2d4..67a0fd75d94 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -20,6 +20,7 @@ import { createGitEnvResolver, createGitFactory } from "./runtime/git"; import { runMainWorkspaceSweep } from "./runtime/main-workspace-sweep"; import { runProjectBackfill } from "./runtime/project-backfill"; import { PullRequestRuntimeManager } from "./runtime/pull-requests"; +import { resolveSandboxTokenWorkspace } from "./runtime/sandbox/sandbox-cli-tokens"; import { runSandboxReconcile } from "./runtime/sandbox/sandbox-reconcile"; import { readSandboxIdentity, @@ -36,6 +37,7 @@ import { execGh as defaultExecGh, type ExecGh, } from "./trpc/router/workspace-creation/utils/exec-gh"; +import { checkSandboxWsAccess } from "./trpc/sandbox-token-acl"; import type { ApiClient, BrowserBridgeConfig } from "./types"; import { getHostWorkerPool } from "./workers/host-worker-pool"; import { gitWorkspaceRefsTask } from "./workers/tasks/git"; @@ -256,11 +258,27 @@ export function createApp(options: CreateAppOptions): CreateAppResult { })(); const wsAuth: MiddlewareHandler = async (c, next) => { - const token = c.req.query("token"); + const queryToken = c.req.query("token"); + const authHeader = c.req.header("authorization"); + const bearer = authHeader?.startsWith("Bearer ") + ? authHeader.slice(7) + : undefined; const authorized = (await providers.hostAuth.validate(c.req.raw)) || - (token && (await providers.hostAuth.validateToken(token))); + (queryToken != null && + (await providers.hostAuth.validateToken(queryToken))); if (!authorized) return c.json({ error: "Unauthorized" }, 401); + // Sandbox-token principals get the same workspace scoping as tRPC: only + // their own terminals, never host-scoped sockets or session creation. + const sandboxWs = resolveSandboxTokenWorkspace(queryToken ?? bearer ?? ""); + if (sandboxWs) { + const decision = checkSandboxWsAccess({ + path: new URL(c.req.url).pathname, + tokenWorkspaceId: sandboxWs, + db, + }); + if (!decision.allowed) return c.json({ error: "Forbidden" }, 403); + } return next(); }; app.use("/terminal/*", wsAuth); @@ -296,6 +314,16 @@ export function createApp(options: CreateAppOptions): CreateAppResult { allowMethodOverride: true, createContext: async (_opts, c) => { const isAuthenticated = await providers.hostAuth.validate(c.req.raw); + // Identify a sandbox-token principal so protectedProcedure can + // scope it to its own workspace. The bearer is the same token the + // container's bundled CLI sends; PSK requests resolve to null. + const authHeader = c.req.header("authorization"); + const bearer = authHeader?.startsWith("Bearer ") + ? authHeader.slice(7) + : undefined; + const sandboxWorkspaceId = bearer + ? (resolveSandboxTokenWorkspace(bearer) ?? undefined) + : undefined; return { git, credentials: providers.credentials, @@ -308,6 +336,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { terminalAgentStore, organizationId: config.organizationId, isAuthenticated, + sandboxWorkspaceId, clientMachineId: c.req.header("x-superset-client-machine-id") ?? undefined, agentHookHeaderToken: diff --git a/packages/host-service/src/runtime/sandbox/container-manager.ts b/packages/host-service/src/runtime/sandbox/container-manager.ts index 480e436cc52..6adffbf1f0e 100644 --- a/packages/host-service/src/runtime/sandbox/container-manager.ts +++ b/packages/host-service/src/runtime/sandbox/container-manager.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { rm } from "node:fs/promises"; +import { mkdir, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { AGENT_HOME_CONFIG_PATHS } from "@superset/agent-setup/agent-home-paths"; @@ -111,6 +111,10 @@ function buildWorkspaceMounts( readOnly: true, }, { source: paths.gitDir, target: CONTAINER_GIT_DIR }, + // Staged launch scripts at their host path (read-write), so a long + // initial command / fish transport the shell `source`s resolves in the + // container. Same source==target keeps the typed path valid both sides. + { source: paths.launchDir, target: paths.launchDir }, // File-over-file mask: hides the worktree's real .git pointer (which // targets the unmounted main repo) behind the sandbox git dir path. { @@ -189,6 +193,10 @@ async function doEnsureContainer(params: EnsureContainerParams): Promise { }); // Also re-registers the token in-memory after host-service restarts. const cliToken = await ensureCliTokenFile(params.workspaceId); + // Bind-mount target for staged launch scripts must exist before create. + await mkdir(getWorkspaceSandboxPaths(params.workspaceId).launchDir, { + recursive: true, + }); const name = getSandboxContainerName(params.workspaceId, params.nameSlug); const configHash = computeConfigHash(params.settings); diff --git a/packages/host-service/src/runtime/sandbox/docker-runtime.ts b/packages/host-service/src/runtime/sandbox/docker-runtime.ts index bafb7104fd1..d7cc7ff36d6 100644 --- a/packages/host-service/src/runtime/sandbox/docker-runtime.ts +++ b/packages/host-service/src/runtime/sandbox/docker-runtime.ts @@ -10,6 +10,7 @@ import { CONTAINER_BASH_RCFILE, CONTAINER_HOST_DIR, getSandboxContainerName, + getWorkspaceSandboxPaths, } from "./paths.ts"; import { getOrCreateHookToken } from "./sandbox-tokens.ts"; import type { @@ -88,6 +89,9 @@ export class DockerRuntime implements WorkspaceRuntime { // The generated container rcfile always installs the OSC 133;A // prompt marker (sandbox-home.ts owns the contract). expectsReadyMarker: true, + // Staged launch scripts must be readable inside the container at the + // same host path — this dir is bind-mounted there (container-manager). + stagingDir: getWorkspaceSandboxPaths(this.params.workspaceId).launchDir, }; } diff --git a/packages/host-service/src/runtime/sandbox/paths.ts b/packages/host-service/src/runtime/sandbox/paths.ts index b8d0d37ff65..c0be64c5a7e 100644 --- a/packages/host-service/src/runtime/sandbox/paths.ts +++ b/packages/host-service/src/runtime/sandbox/paths.ts @@ -42,6 +42,12 @@ export interface WorkspaceSandboxPaths { dotGitFile: string; /** HEAD sha the sandbox git dir was bootstrapped from. */ bootstrapShaFile: string; + /** + * Host dir for staged launch scripts (long initial commands / fish prompt + * transport), bind-mounted at the SAME path inside the container so the + * `source ` the shell runs resolves in both. Removed with stateDir. + */ + launchDir: string; } export function getWorkspaceSandboxPaths( @@ -53,6 +59,7 @@ export function getWorkspaceSandboxPaths( gitDir: join(stateDir, "git"), dotGitFile: join(stateDir, "dot-git"), bootstrapShaFile: join(stateDir, "bootstrap-sha"), + launchDir: join(stateDir, "launch"), }; } diff --git a/packages/host-service/src/runtime/sandbox/sandbox-cli-tokens.ts b/packages/host-service/src/runtime/sandbox/sandbox-cli-tokens.ts index 723e77efb04..85f0bf538df 100644 --- a/packages/host-service/src/runtime/sandbox/sandbox-cli-tokens.ts +++ b/packages/host-service/src/runtime/sandbox/sandbox-cli-tokens.ts @@ -40,21 +40,29 @@ export async function ensureCliTokenFile( return { hostDir }; } -/** Timing-safe check against every registered sandbox CLI token. */ -export function isValidSandboxCliToken(presented: string): boolean { +/** + * The workspace a sandbox CLI token belongs to, or null if it matches none. + * Timing-safe: compares against every registered token with no early exit, so + * the work is independent of match position. + */ +export function resolveSandboxTokenWorkspace(presented: string): string | null { const presentedBuffer = Buffer.from(presented); - let valid = false; - for (const token of cliTokens.values()) { + let match: string | null = null; + for (const [workspaceId, token] of cliTokens.entries()) { const tokenBuffer = Buffer.from(token); if ( presentedBuffer.length === tokenBuffer.length && timingSafeEqual(presentedBuffer, tokenBuffer) ) { - // No early exit: keep comparison count independent of match position. - valid = true; + match = workspaceId; } } - return valid; + return match; +} + +/** Timing-safe check against every registered sandbox CLI token. */ +export function isValidSandboxCliToken(presented: string): boolean { + return resolveSandboxTokenWorkspace(presented) !== null; } export function dropCliToken(workspaceId: string): void { diff --git a/packages/host-service/src/runtime/sandbox/workspace-runtime.ts b/packages/host-service/src/runtime/sandbox/workspace-runtime.ts index 566cbbc0888..59e9218d6e6 100644 --- a/packages/host-service/src/runtime/sandbox/workspace-runtime.ts +++ b/packages/host-service/src/runtime/sandbox/workspace-runtime.ts @@ -40,6 +40,12 @@ export interface PtyLaunchSpec { * initial commands can safely wait for shell readiness before typing. */ expectsReadyMarker: boolean; + /** + * Directory for staged launch scripts (long initial commands, fish prompt + * transport). Must be readable by the shell at the SAME path — for the + * docker runtime this is a bind-mounted host dir. Undefined → host tmpdir. + */ + stagingDir?: string; } export interface WorkspaceRuntime { diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index f02d3cb5b58..9fda62bf1f9 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -507,6 +507,13 @@ interface TerminalSession { */ launchShellName: string; + /** + * Where to stage launch scripts (long initial commands, fish transport). + * Set for sandbox runtimes to a bind-mounted host dir readable at the same + * path inside the container; undefined → host tmpdir(). + */ + stagingDir?: string; + /** * Side-channel UTF-8 decoder. portManager.checkOutputForHint takes a * string and does text-pattern matching for "Local: http://…" hints, @@ -1633,10 +1640,10 @@ function stageInitialCommandScript( ): { typedLine: string; scriptPath: string } | null { const safeId = session.terminalId.replace(/[^\w-]/g, "_").slice(0, 60); const scriptPath = join( - tmpdir(), + session.stagingDir ?? tmpdir(), `superset-launch-${safeId}-${randomBytes(4).toString("hex")}.sh`, ); - // tmpdir paths never contain quotes, so this quoting is identical in + // staging-dir paths never contain quotes, so this quoting is identical in // POSIX shells and fish. const quotedPath = `'${scriptPath.replaceAll("'", "'\\''")}'`; const sourceKeyword = session.launchShellName === "fish" ? "source" : "."; @@ -1961,7 +1968,7 @@ function stageFishPromptTransport( ): { commandText: string; promptPath: string } | null { const safeId = session.terminalId.replace(/[^\w-]/g, "_").slice(0, 60); const promptPath = join( - tmpdir(), + session.stagingDir ?? tmpdir(), `superset-launch-prompt-${safeId}-${randomBytes(4).toString("hex")}.txt`, ); try { @@ -2704,6 +2711,7 @@ export async function createTerminalSessionInternal({ // host-service lifetime — flag it as queued so we don't double-fire it. initialCommandQueued: isAdopted, launchShellName: basename(launch.shell), + stagingDir: launch.stagingDir, portHintDecoder: new StringDecoder("utf8"), modeTracker, adoptionReplaySettled: Promise.resolve(), diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index 378a9a35975..f23513c9e7e 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -11,6 +11,7 @@ import { type ProjectNotSetupCause, type TeardownFailureCause, } from "./error-types"; +import { checkSandboxTokenAccess } from "./sandbox-token-acl"; export interface RouterMeta { /** @@ -105,15 +106,35 @@ export const router = t.router; export const createCallerFactory = t.createCallerFactory; export const publicProcedure = baseProcedure; -export const protectedProcedure = baseProcedure.use(async ({ ctx, next }) => { - if (!ctx.isAuthenticated) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid or missing authentication token.", - }); - } - return next({ ctx }); -}); +export const protectedProcedure = baseProcedure.use( + async ({ ctx, next, path, getRawInput }) => { + if (!ctx.isAuthenticated) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid or missing authentication token.", + }); + } + // A sandbox CLI token is a narrower principal than the host PSK: it may + // only act on its own workspace, per the default-deny ACL. This closes + // the escape where the in-container token drove the whole host API + // (host RCE, credential theft, cross-workspace ops — CWE-862). + if (ctx.sandboxWorkspaceId) { + const decision = await checkSandboxTokenAccess({ + path, + rawInput: await getRawInput(), + tokenWorkspaceId: ctx.sandboxWorkspaceId, + db: ctx.db, + }); + if (!decision.allowed) { + throw new TRPCError({ + code: "FORBIDDEN", + message: `Sandbox token not authorized for ${path}: ${decision.reason}`, + }); + } + } + return next({ ctx }); + }, +); /** * For procedures that only make sense on a machine someone owns. diff --git a/packages/host-service/src/trpc/router/workspaces/workspaces.ts b/packages/host-service/src/trpc/router/workspaces/workspaces.ts index fb79151162c..d71235387f5 100644 --- a/packages/host-service/src/trpc/router/workspaces/workspaces.ts +++ b/packages/host-service/src/trpc/router/workspaces/workspaces.ts @@ -483,6 +483,13 @@ async function registerLocalWorkspace(args: { worktreePath: string; taskId: string | undefined; rollbackWorktree: () => Promise; + /** + * Skip the eager sandbox bootstrap here — the caller will trigger it after + * a possible AI branch rename, so the isolated git dir is initialized with + * the FINAL branch (bootstrapping before the rename strands commits under + * the old ref, which syncSandbox then can't find). + */ + deferSandboxBootstrap?: boolean; }): Promise { const { ctx } = args; @@ -511,8 +518,9 @@ async function registerLocalWorkspace(args: { // Provision the sandbox container now (fire-and-forget) rather than on // first terminal — creation shows "Initializing sandbox…" and the first - // PTY joins the in-flight ensure instead of paying the cold start. - if (localRow.sandboxEnabled) { + // PTY joins the in-flight ensure instead of paying the cold start. Skipped + // when the caller defers past a pending branch rename (see the param doc). + if (localRow.sandboxEnabled && !args.deferSandboxBootstrap) { bootstrapWorkspaceSandbox( { db: ctx.db, eventBus: ctx.eventBus }, localRow.id, @@ -1072,6 +1080,7 @@ export const workspacesRouter = router({ }); } + aiCanRenameBranch = !typedBranch; workspaceRow = await registerLocalWorkspace({ ctx, id: input.id, @@ -1081,8 +1090,12 @@ export const workspacesRouter = router({ worktreePath, taskId: input.taskId, rollbackWorktree, + // A branch rename may still be applied below; bootstrap + // the sandbox only after it so the isolated git dir gets + // the final branch name. + deferSandboxBootstrap: + aiCanRenameBranch && aiNamesPromise != null, }); - aiCanRenameBranch = !typedBranch; } } } @@ -1125,6 +1138,25 @@ export const workspacesRouter = router({ } } + // Deferred until after the possible rename above (matches the + // deferSandboxBootstrap condition passed to registerLocalWorkspace): + // the workspace row now carries the final branch, so the isolated + // git dir bootstraps on the right ref. No-op if not sandboxed. + if ( + workspaceRow && + !alreadyExists && + aiCanRenameBranch && + aiNamesPromise != null + ) { + const finalRow = getLocalWorkspace(ctx.db, workspaceRow.id); + if (finalRow?.sandboxEnabled) { + bootstrapWorkspaceSandbox( + { db: ctx.db, eventBus: ctx.eventBus }, + workspaceRow.id, + ); + } + } + const terminalsResult: Array<{ terminalId: string; label?: string }> = []; const sugarLaunches = input.agents ?? []; diff --git a/packages/host-service/src/trpc/sandbox-token-acl.test.ts b/packages/host-service/src/trpc/sandbox-token-acl.test.ts new file mode 100644 index 00000000000..6bae199f8b4 --- /dev/null +++ b/packages/host-service/src/trpc/sandbox-token-acl.test.ts @@ -0,0 +1,124 @@ +import { Database as BunDatabase } from "bun:sqlite"; +import { beforeEach, describe, expect, test } from "bun:test"; +import path from "node:path"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { HostDb } from "../db"; +import * as schema from "../db/schema"; +import { projects, terminalSessions, workspaces } from "../db/schema"; +import { + checkSandboxTokenAccess, + checkSandboxWsAccess, +} from "./sandbox-token-acl"; + +const MIGRATIONS = path.resolve(import.meta.dir, "../../drizzle"); +const OWN_WS = "11111111-1111-4111-8111-111111111111"; +const OTHER_WS = "22222222-2222-4222-8222-222222222222"; +const OWN_TERMINAL = "term-own"; +const OTHER_TERMINAL = "term-other"; + +describe("sandbox token ACL", () => { + let db: HostDb; + + beforeEach(() => { + const sqlite = new BunDatabase(":memory:"); + const d = drizzle(sqlite, { schema }); + migrate(d, { migrationsFolder: MIGRATIONS }); + db = d as unknown as HostDb; + db.insert(projects) + .values({ id: "proj-1", repoPath: "/tmp/repo", name: "p" }) + .run(); + db.insert(workspaces) + .values([ + { + id: OWN_WS, + projectId: "proj-1", + worktreePath: "/tmp/own", + branch: "own", + type: "worktree", + }, + { + id: OTHER_WS, + projectId: "proj-1", + worktreePath: "/tmp/other", + branch: "other", + type: "worktree", + }, + ]) + .run(); + db.insert(terminalSessions) + .values([ + { id: OWN_TERMINAL, originWorkspaceId: OWN_WS }, + { id: OTHER_TERMINAL, originWorkspaceId: OTHER_WS }, + ]) + .run(); + }); + + const check = (pathName: string, rawInput: unknown) => + checkSandboxTokenAccess({ + path: pathName, + rawInput, + tokenWorkspaceId: OWN_WS, + db, + }); + + test("denies unlisted procedures (creation, host-scoped)", async () => { + for (const p of [ + "terminal.launchSession", + "terminal.createSession", + "workspaces.create", + "workspace.list", + "filesystem.browseHost", + "auth.setAnthropicApiKey", + "project.list", + ]) { + expect((await check(p, {})).allowed).toBe(false); + } + }); + + test("allows own-workspace agent + terminal ops", async () => { + expect((await check("agents.run", { workspaceId: OWN_WS })).allowed).toBe( + true, + ); + expect( + (await check("terminal.send", { terminalId: OWN_TERMINAL })).allowed, + ).toBe(true); + expect( + (await check("terminal.list", { workspaceId: OWN_WS })).allowed, + ).toBe(true); + expect( + (await check("workspaces.syncSandbox", { workspaceId: OWN_WS })).allowed, + ).toBe(true); + expect( + (await check("workspaces.syncSandbox", { workspaceId: OTHER_WS })) + .allowed, + ).toBe(false); + }); + + test("denies cross-workspace access by workspaceId and by terminalId", async () => { + expect((await check("agents.run", { workspaceId: OTHER_WS })).allowed).toBe( + false, + ); + expect( + (await check("terminal.send", { terminalId: OTHER_TERMINAL })).allowed, + ).toBe(false); + }); + + test("fails closed when scope can't be resolved", async () => { + expect((await check("terminal.list", {})).allowed).toBe(false); + expect( + (await check("terminal.send", { terminalId: "does-not-exist" })).allowed, + ).toBe(false); + }); + + const ws = (pathName: string) => + checkSandboxWsAccess({ path: pathName, tokenWorkspaceId: OWN_WS, db }); + + test("ws: attach own terminal allowed, others + host routes denied", () => { + expect(ws(`/terminal/${OWN_TERMINAL}`).allowed).toBe(true); + expect(ws(`/terminal/${OTHER_TERMINAL}`).allowed).toBe(false); + expect(ws("/terminal/sessions").allowed).toBe(false); + expect(ws("/events").allowed).toBe(false); + expect(ws("/browser/x").allowed).toBe(false); + }); +}); diff --git a/packages/host-service/src/trpc/sandbox-token-acl.ts b/packages/host-service/src/trpc/sandbox-token-acl.ts new file mode 100644 index 00000000000..57dc02f5c76 --- /dev/null +++ b/packages/host-service/src/trpc/sandbox-token-acl.ts @@ -0,0 +1,132 @@ +import { eq } from "drizzle-orm"; +import type { HostDb } from "../db"; +import { terminalSessions } from "../db/schema"; + +/** + * Authorization policy for requests authenticated with a per-workspace sandbox + * CLI token (as opposed to the host PSK). + * + * The threat: the token is readable by the untrusted agent inside the + * container. Without scoping, it authenticates like the PSK and can drive the + * whole host-service API — create host-run session/workspaces (host RCE), + * read arbitrary host files, steal provider credentials, act on other + * workspaces (CWE-862). So sandbox tokens are DEFAULT-DENY: only the + * operations below, each scoped to the token's OWN workspace, are permitted. + * + * Deliberately NOT permitted (all can execute on the host or cross workspaces): + * any workspace/session creation, filesystem.*, auth.*, settings.*, project.*, + * github.*, host.*, usage.*, and cross-workspace terminal/agent operations. + * Cross-workspace orchestration must run from a trusted (PSK) context. + */ + +type ScopeResolver = ( + rawInput: unknown, + db: HostDb, +) => string | undefined | Promise; + +function readStringField(rawInput: unknown, field: string): string | undefined { + if (rawInput && typeof rawInput === "object" && field in rawInput) { + const value = (rawInput as Record)[field]; + return typeof value === "string" ? value : undefined; + } + return undefined; +} + +const byWorkspaceId: ScopeResolver = (rawInput) => + readStringField(rawInput, "workspaceId"); + +const byTerminalId: ScopeResolver = (rawInput, db) => { + const terminalId = readStringField(rawInput, "terminalId"); + if (!terminalId) return undefined; + const row = db + .select({ workspaceId: terminalSessions.originWorkspaceId }) + .from(terminalSessions) + .where(eq(terminalSessions.id, terminalId)) + .get(); + return row?.workspaceId ?? undefined; +}; + +/** + * Allowed procedure paths → how to resolve the workspace the call targets. + * A path absent here is denied outright for sandbox principals. + */ +const SANDBOX_TOKEN_ACL: Record = { + // Manage the token's own workspace terminals. + "terminal.list": byWorkspaceId, + "terminal.send": byTerminalId, + "terminal.snapshot": byTerminalId, + "terminal.killSession": byTerminalId, + "terminal.writeInput": byTerminalId, + "terminal.hasRunningProcess": byTerminalId, + // Launch an agent in the token's own (already sandboxed) workspace. + "agents.run": byWorkspaceId, + // Import the token's own sandbox commits into its worktree. + "workspaces.syncSandbox": byWorkspaceId, +}; + +export interface SandboxAclDecision { + allowed: boolean; + reason?: string; +} + +/** + * WebSocket authorization for a sandbox-token principal. Only the token's own + * workspace terminals may be attached to or deleted; host-scoped socket routes + * (/events, /chat-v3, /browser) and session creation/listing are denied. + */ +export function checkSandboxWsAccess(args: { + path: string; + tokenWorkspaceId: string; + db: HostDb; +}): SandboxAclDecision { + const { path } = args; + if (!path.startsWith("/terminal/")) { + return { allowed: false, reason: "host-scoped socket route" }; + } + // Create + list-all are host operations; never via a sandbox token. + if (path === "/terminal/sessions" || path === "/terminal/resource-sessions") { + return { allowed: false, reason: "session create/list not permitted" }; + } + // /terminal/sessions/:terminalId (delete) or /terminal/:terminalId (attach) + const deleteMatch = path.match(/^\/terminal\/sessions\/([^/]+)$/); + const attachMatch = path.match(/^\/terminal\/([^/]+)$/); + const terminalId = + deleteMatch?.[1] ?? + (attachMatch && attachMatch[1] !== "sessions" ? attachMatch[1] : undefined); + if (!terminalId) { + return { allowed: false, reason: "unrecognized terminal route" }; + } + const row = args.db + .select({ workspaceId: terminalSessions.originWorkspaceId }) + .from(terminalSessions) + .where(eq(terminalSessions.id, terminalId)) + .get(); + if (!row?.workspaceId) { + return { allowed: false, reason: "terminal not found" }; + } + if (row.workspaceId !== args.tokenWorkspaceId) { + return { allowed: false, reason: "cross-workspace access denied" }; + } + return { allowed: true }; +} + +export async function checkSandboxTokenAccess(args: { + path: string; + rawInput: unknown; + tokenWorkspaceId: string; + db: HostDb; +}): Promise { + const resolver = SANDBOX_TOKEN_ACL[args.path]; + if (!resolver) { + return { allowed: false, reason: "not permitted for a sandbox token" }; + } + const target = await resolver(args.rawInput, args.db); + if (!target) { + // Fail closed: a scoped procedure we can't pin to a workspace is denied. + return { allowed: false, reason: "workspace scope could not be resolved" }; + } + if (target !== args.tokenWorkspaceId) { + return { allowed: false, reason: "cross-workspace access denied" }; + } + return { allowed: true }; +} diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index a12b25df301..8df3d4c6878 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -30,6 +30,13 @@ export interface HostServiceContext { terminalAgentStore: TerminalAgentStore; organizationId: string; isAuthenticated: boolean; + /** + * Set when the request authenticated with a per-workspace sandbox CLI + * token (not the host PSK) — the id of that workspace. A narrower + * principal than the PSK: protectedProcedure restricts it to own-workspace + * operations via the sandbox-token ACL. Undefined for PSK/desktop callers. + */ + sandboxWorkspaceId?: string; clientMachineId?: string; /** x-superset-hook-token header, verified for sandboxed workspaces. */ agentHookHeaderToken?: string; diff --git a/packages/sandbox-image/README.md b/packages/sandbox-image/README.md index 11581fe727d..0b1e98155ee 100644 --- a/packages/sandbox-image/README.md +++ b/packages/sandbox-image/README.md @@ -48,6 +48,9 @@ them installed; their host config dirs already mount into the sandbox. ## Publishing -CI publishing to `ghcr.io/superset-sh/sandbox` (multi-arch, with the linux -`superset` CLI baked in) is planned; until then build locally and override -`sandbox.image`. +`.github/workflows/publish-sandbox-image.yml` builds and pushes the multi-arch +image to `ghcr.io/superset-sh/sandbox:latest` on every push to `main` that +touches this package (or via manual `workflow_dispatch`). That is the default +`sandbox.image` for non-development hosts. For local development the runtime +defaults to the locally built `superset-sandbox:dev` instead, so a published +image isn't required to dogfood. From a52eb37ac2f22725bb8c3daa25e1d165d8158d69 Mon Sep 17 00:00:00 2001 From: Mikolaj Graf Date: Thu, 27 Aug 2026 23:26:42 +0200 Subject: [PATCH 21/21] fix(sandbox): close WS ACL bypass + document image publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CWE-863: wsAuth resolved only one token channel, so a sandbox bearer paired with a junk ?token= skipped the ACL and reached host-scoped sockets (/events). Resolve both channels independently and apply the ACL if either is a sandbox token. Regression test added (both channels → 403 on /events). - README + workflow: :latest (not custom tags) feeds the default image, and the GHCR package must be made public before the production default is pullable. --- .github/workflows/publish-sandbox-image.yml | 4 ++ packages/host-service/src/app.ts | 6 ++- .../test/integration/sandbox-ws-auth.test.ts | 52 +++++++++++++++++++ packages/sandbox-image/README.md | 20 +++++-- 4 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 packages/host-service/test/integration/sandbox-ws-auth.test.ts diff --git a/.github/workflows/publish-sandbox-image.yml b/.github/workflows/publish-sandbox-image.yml index 2267e7dab42..a27e151e909 100644 --- a/.github/workflows/publish-sandbox-image.yml +++ b/.github/workflows/publish-sandbox-image.yml @@ -53,3 +53,7 @@ jobs: tags: ghcr.io/superset-sh/sandbox:${{ env.IMAGE_TAG }} cache-from: type=gha cache-to: type=gha,mode=max +# NOTE: the GHCR package must be made PUBLIC once (Packages → sandbox → +# Package settings → Change visibility) so hosts can `docker pull` the default +# ghcr.io/superset-sh/sandbox:latest without a login. Until then, non-dev +# provisioning fails with "denied"; dev hosts use the local superset-sandbox:dev. diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index e0543d82f49..78cf4306812 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -272,7 +272,11 @@ export function createApp(options: CreateAppOptions): CreateAppResult { if (!authorized) return c.json({ error: "Unauthorized" }, 401); // Sandbox-token principals get the same workspace scoping as tRPC: only // their own terminals, never host-scoped sockets or session creation. - const sandboxWs = resolveSandboxTokenWorkspace(queryToken ?? bearer ?? ""); + // Resolve BOTH channels independently — a sandbox bearer paired with a + // junk `?token=` must not skip the ACL (CWE-863). + const sandboxWs = + resolveSandboxTokenWorkspace(bearer ?? "") ?? + resolveSandboxTokenWorkspace(queryToken ?? ""); if (sandboxWs) { const decision = checkSandboxWsAccess({ path: new URL(c.req.url).pathname, diff --git a/packages/host-service/test/integration/sandbox-ws-auth.test.ts b/packages/host-service/test/integration/sandbox-ws-auth.test.ts new file mode 100644 index 00000000000..6e52821bfae --- /dev/null +++ b/packages/host-service/test/integration/sandbox-ws-auth.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { PskHostAuthProvider } from "../../src/providers/host-auth"; +import { getWorkspaceSandboxPaths } from "../../src/runtime/sandbox/paths"; +import { + ensureCliTokenFile, + resetCliTokensForTests, +} from "../../src/runtime/sandbox/sandbox-cli-tokens"; +import { createTestHost } from "../helpers/createTestHost"; + +const WS = "11111111-1111-4111-8111-111111111111"; +const PSK = "test-psk-ws-auth"; + +let host: Awaited>; +let sandboxToken: string; + +beforeEach(async () => { + resetCliTokensForTests(); + host = await createTestHost({ + psk: PSK, + hostAuth: new PskHostAuthProvider(PSK), + }); + await ensureCliTokenFile(WS); + sandboxToken = readFileSync( + join(getWorkspaceSandboxPaths(WS).stateDir, "host", "token"), + "utf-8", + ).trim(); +}); + +afterEach(async () => { + await host.dispose(); + resetCliTokensForTests(); +}); + +// A sandbox bearer must not reach a host-scoped WS route (/events) even when +// paired with a junk ?token= value — the ACL keys off BOTH channels (CWE-863). +test("sandbox bearer + junk query token is still denied /events", async () => { + const res = await host.fetch( + new Request(`http://local/events?token=${host.psk}-not-real`, { + headers: { authorization: `Bearer ${sandboxToken}` }, + }), + ); + expect(res.status).toBe(403); +}); + +test("sandbox token via query param is denied /events", async () => { + const res = await host.fetch( + new Request(`http://local/events?token=${sandboxToken}`), + ); + expect(res.status).toBe(403); +}); diff --git a/packages/sandbox-image/README.md b/packages/sandbox-image/README.md index 0b1e98155ee..409f2a17545 100644 --- a/packages/sandbox-image/README.md +++ b/packages/sandbox-image/README.md @@ -49,8 +49,18 @@ them installed; their host config dirs already mount into the sandbox. ## Publishing `.github/workflows/publish-sandbox-image.yml` builds and pushes the multi-arch -image to `ghcr.io/superset-sh/sandbox:latest` on every push to `main` that -touches this package (or via manual `workflow_dispatch`). That is the default -`sandbox.image` for non-development hosts. For local development the runtime -defaults to the locally built `superset-sandbox:dev` instead, so a published -image isn't required to dogfood. +image to `ghcr.io/superset-sh/sandbox`. Pushes to `main` that touch this +package publish `:latest`; a manual `workflow_dispatch` publishes `:latest` by +default or a custom `tag` input. + +`:latest` is the default `sandbox.image` for non-development hosts +(`packages/host-service/src/runtime/sandbox/docker-args.ts`). Only `:latest` +feeds that default — a manual versioned publish (e.g. `:2026-09-01`) does NOT +change the image workspaces pull unless you also point `sandbox.image` at it. + +**Before the default is usable in production, the GHCR package must be public** +(Packages → sandbox → Package settings → Change visibility → Public). Hosts run +`docker pull` with no login path, so a private package fails provisioning with +"denied"/"not found". For local development none of this applies: the runtime +defaults to the locally built `superset-sandbox:dev`, so no published image is +required to dogfood.