From a231b95df5207dcb9d027840f24d562572ede327 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 17:09:56 -0700 Subject: [PATCH] refactor(hub)!: drop the docker and e2b sidecar backends; process is the only provisioner (CL-8330) --- .env.example | 12 +- AGENTS.md | 2 - IMPLEMENTATION.md | 6 +- apps/hub/package.json | 1 - .../docker-command-runner.test.ts | 22 -- .../src/provisioners/docker-fencing.test.ts | 174 ----------- apps/hub/src/provisioners/docker.test.ts | 237 --------------- apps/hub/src/provisioners/docker.ts | 199 ------------- apps/hub/src/provisioners/e2b-config.test.ts | 45 --- apps/hub/src/provisioners/e2b.test.ts | 27 -- apps/hub/src/provisioners/e2b.ts | 272 ------------------ .../src/provisioners/fake-command-runner.ts | 18 -- apps/hub/src/provisioners/process.ts | 17 +- apps/hub/src/server.ts | 82 ++---- apps/sidecar-docker/Dockerfile | 29 -- apps/sidecar-docker/package.json | 8 - apps/sidecar-e2b/README.md | 111 ------- apps/sidecar-e2b/package.json | 19 -- .../template/build-context.test.ts | 111 ------- apps/sidecar-e2b/template/build-context.ts | 98 ------- apps/sidecar-e2b/template/build.ts | 26 -- apps/sidecar-e2b/template/definition.ts | 84 ------ apps/sidecar-e2b/template/start-sidecar.ts | 30 -- apps/sidecar-e2b/tsconfig.json | 9 - bun.lock | 46 --- docs/sidecar-provisioners.md | 66 ----- package.json | 3 +- 27 files changed, 30 insertions(+), 1724 deletions(-) delete mode 100644 apps/hub/src/provisioners/docker-command-runner.test.ts delete mode 100644 apps/hub/src/provisioners/docker-fencing.test.ts delete mode 100644 apps/hub/src/provisioners/docker.test.ts delete mode 100644 apps/hub/src/provisioners/docker.ts delete mode 100644 apps/hub/src/provisioners/e2b-config.test.ts delete mode 100644 apps/hub/src/provisioners/e2b.test.ts delete mode 100644 apps/hub/src/provisioners/e2b.ts delete mode 100644 apps/hub/src/provisioners/fake-command-runner.ts delete mode 100644 apps/sidecar-docker/Dockerfile delete mode 100644 apps/sidecar-docker/package.json delete mode 100644 apps/sidecar-e2b/README.md delete mode 100644 apps/sidecar-e2b/package.json delete mode 100644 apps/sidecar-e2b/template/build-context.test.ts delete mode 100644 apps/sidecar-e2b/template/build-context.ts delete mode 100644 apps/sidecar-e2b/template/build.ts delete mode 100644 apps/sidecar-e2b/template/definition.ts delete mode 100644 apps/sidecar-e2b/template/start-sidecar.ts delete mode 100644 apps/sidecar-e2b/tsconfig.json delete mode 100644 docs/sidecar-provisioners.md diff --git a/.env.example b/.env.example index 70db5b5d76..6e39804570 100644 --- a/.env.example +++ b/.env.example @@ -56,16 +56,8 @@ PRINCIPAL_KEY_ENCRYPTION_KEY=111111111111111111111111111111111111111111111111111 # Markdown-to-PDF rendering through a Gotenberg server. # GOTENBERG_URL=http://localhost:3000 -# Where sidecars run: comma-separated ids, first is the default. -# process (default, no config) | docker | e2b. See docs/sidecar-provisioners.md. -# SIDECAR_PROVISIONERS=process -# DOCKER_PROVISIONER_IMAGE= -# E2B_API_KEY= -# E2B_TEMPLATE= +# Sidecars run as child processes of the hub. Both optional. # PROCESS_PROVISIONER_SIDECAR_ENTRY= # PROCESS_PROVISIONER_RUNTIME= -# ws(s):// address sidecars dial back to; required for docker and e2b. +# ws(s):// address sidecars dial back to. # HUB_SIDECAR_WEBSOCKET_URL= -# JSON array of {"provider","specifier","export"} replacing the sidecar's -# default inference adapter manifest. -# SIDECAR_ADAPTER_MANIFEST= diff --git a/AGENTS.md b/AGENTS.md index 471f630649..08ad886e48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,5 @@ passing; rebuilding coverage for the area it covered happens under CL-8150. disagrees with it is wrong until a review changes the doc - [docs/GLOSSARY.md](docs/GLOSSARY.md) — product-term to platform-term mapping - [docs/local-dev.md](docs/local-dev.md) — running fully local with Ollama -- [docs/sidecar-provisioners.md](docs/sidecar-provisioners.md) — choosing - and configuring where sidecars run - [docs/package-migrations.md](docs/package-migrations.md) — how a package's own database migrations are written and applied diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 048d85d7e8..da5334f8cd 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -34,10 +34,8 @@ upstream first, then gets re-vendored. Deployment mechanics are not yet settled: - Target hosting platform and process topology (one hub, how many - sidecars, where they run) are undecided. -- The sidecar provisioner an install uses in production (`process`, - `docker`, or `e2b`) is a per-deployment choice — see - [docs/sidecar-provisioners.md](docs/sidecar-provisioners.md). + sidecars, where they run) are undecided. Sidecars run as child + processes of the hub; there is no other provisioner backend. - Secrets management for `CREDENTIAL_ENCRYPTION_KEY` and `PRINCIPAL_KEY_ENCRYPTION_KEY` in a real deployment is undecided. - Migration/rollout ordering across `hub`, `sidecar`, and `web` on deploy diff --git a/apps/hub/package.json b/apps/hub/package.json index c254aa5d0e..24d5f83a74 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -48,7 +48,6 @@ "arktype": "catalog:", "better-auth": "catalog:", "drizzle-orm": "catalog:", - "e2b": "^2.36.1", "hono": "catalog:", "postgres": "catalog:", "simple-icons": "^16.28.0" diff --git a/apps/hub/src/provisioners/docker-command-runner.test.ts b/apps/hub/src/provisioners/docker-command-runner.test.ts deleted file mode 100644 index 0cd8e0bc7d..0000000000 --- a/apps/hub/src/provisioners/docker-command-runner.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createFakeCommandRunner } from "./fake-command-runner"; - -describe("createFakeCommandRunner", () => { - test("records every invocation and returns the handler's result", async () => { - const runner = createFakeCommandRunner(async () => ({ - stdout: "container-id\n", - stderr: "", - exitCode: 0, - })); - - const result = await runner.run(["run", "-d", "alpine"]); - - expect(result).toEqual({ - stdout: "container-id\n", - stderr: "", - exitCode: 0, - }); - expect(runner.calls).toEqual([["run", "-d", "alpine"]]); - }); -}); diff --git a/apps/hub/src/provisioners/docker-fencing.test.ts b/apps/hub/src/provisioners/docker-fencing.test.ts deleted file mode 100644 index 4063918565..0000000000 --- a/apps/hub/src/provisioners/docker-fencing.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Fencing/leak edge cases for the docker sidecar provisioner, adapted from -// the review critique that first demonstrated them. The first two cases -// used to prove real leaks (a second concurrent docker run, and an -// unkilled older-generation container); they now assert the fixed -// behavior — in-flight ensure dedup and obsolete-container sweeping — -// while the last two guard the fencing behavior that was already correct. -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { createFakeCommandRunner } from "./fake-command-runner"; -import { createDockerSidecarProvisioner } from "./docker"; -import { createAllocationStateStore } from "./sandbox-sidecar"; - -const IMAGE = "ghcr.io/corbits/sidecar:latest"; - -let dataDir: string; - -beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "docker-provisioner-fencing-")); -}); - -afterEach(async () => { - await rm(dataDir, { recursive: true, force: true }); -}); - -function request(overrides: Partial> = {}) { - return { - allocationId: "alloc-1", - generation: 1, - tenantId: "tenant-1", - anchorRunId: "run-1", - sidecarId: "sidecar-1", - token: "tok", - hubWebSocketUrl: "wss://hub.example.com/ws", - ...overrides, - }; -} - -describe("fencing edge cases", () => { - test("concurrent ensure at the same generation does not leak a container", async () => { - let runCount = 0; - let releaseFirstRun: (() => void) | undefined; - const firstRunGate = new Promise((resolveGate) => { - releaseFirstRun = resolveGate; - }); - const commands = createFakeCommandRunner(async (args) => { - if (args[0] === "run") { - runCount += 1; - const id = `container-${String(runCount)}`; - if (runCount === 1) await firstRunGate; - return { stdout: `${id}\n`, stderr: "", exitCode: 0 }; - } - return { stdout: "", stderr: "", exitCode: 0 }; - }); - const store = createAllocationStateStore(join(dataDir, "state.json")); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - - const first = provisioner.ensure(request()); - // Give the first call time to pass observeEnsure and block in docker run. - await new Promise((r) => setTimeout(r, 20)); - const second = provisioner.ensure(request()); - await new Promise((r) => setTimeout(r, 20)); - releaseFirstRun?.(); - const [firstResult, secondResult] = await Promise.all([first, second]); - - const runs = commands.calls.filter((call) => call[0] === "run"); - // The in-flight ensure map coalesces the second call onto the first: - // only one docker run, and both callers see the same container. - expect(runs.length).toBe(1); - expect(firstResult).toEqual(secondResult); - const record = await store.getRecord("alloc-1"); - expect(record?.externalRef).toBe("container-1"); - }); - - test("newer-generation ensure removes the older generation's container", async () => { - let runCount = 0; - const liveContainers = new Set(); - const commands = createFakeCommandRunner(async (args) => { - if (args[0] === "run") { - runCount += 1; - const id = `container-${String(runCount)}`; - liveContainers.add(id); - return { stdout: `${id}\n`, stderr: "", exitCode: 0 }; - } - if (args[0] === "ps") { - return { - stdout: liveContainers.size > 0 ? `${Array.from(liveContainers).join("\n")}\n` : "", - stderr: "", - exitCode: 0, - }; - } - if (args[0] === "rm") { - liveContainers.delete(args[1] ?? ""); - } - return { stdout: "", stderr: "", exitCode: 0 }; - }); - const store = createAllocationStateStore(join(dataDir, "state.json")); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - - await provisioner.ensure(request({ generation: 1 })); - await provisioner.ensure(request({ generation: 2 })); - - const stops = commands.calls.filter((call) => call[0] === "stop"); - // The generation-2 ensure sweeps every container still labeled for - // this allocation other than its own — container-1 (gen 1) is - // stopped and removed, leaving only container-2 recorded. - expect(stops).toContainEqual(["stop", "container-1"]); - expect(stops).not.toContainEqual(["stop", "container-2"]); - const record = await store.getRecord("alloc-1"); - expect(record?.externalRef).toBe("container-2"); - }); - - test("destroy at a newer generation than the record still removes the recorded container", async () => { - const commands = createFakeCommandRunner(async (args) => { - if (args[0] === "run") { - return { stdout: "container-1\n", stderr: "", exitCode: 0 }; - } - return { stdout: "", stderr: "", exitCode: 0 }; - }); - const store = createAllocationStateStore(join(dataDir, "state.json")); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - await provisioner.ensure(request({ generation: 1 })); - - const result = await provisioner.destroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 2, - }); - - expect(result).toEqual({ kind: "destroyed" }); - expect(commands.calls).toContainEqual(["stop", "container-1"]); - }); - - test("older ensure after a newer destroy is fenced", async () => { - const commands = createFakeCommandRunner(async () => ({ - stdout: "", - stderr: "", - exitCode: 0, - })); - const store = createAllocationStateStore(join(dataDir, "state.json")); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - await provisioner.destroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 2, - }); - - const result = await provisioner.ensure(request({ generation: 1 })); - - expect(result).toMatchObject({ - kind: "rejected", - code: "stale_generation", - }); - expect(commands.calls.filter((c) => c[0] === "run")).toHaveLength(0); - }); -}); diff --git a/apps/hub/src/provisioners/docker.test.ts b/apps/hub/src/provisioners/docker.test.ts deleted file mode 100644 index 1a81cafdf6..0000000000 --- a/apps/hub/src/provisioners/docker.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { createFakeCommandRunner, type FakeCommandRunner } from "./fake-command-runner"; -import { createDockerSidecarProvisioner } from "./docker"; -import { createAllocationStateStore, type AllocationStateStore } from "./sandbox-sidecar"; - -const IMAGE = "ghcr.io/corbits/sidecar:latest"; -const TOKEN = "s3cr3t-bootstrap-token"; - -let dataDir: string; -let store: AllocationStateStore; - -beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "docker-provisioner-plugin-")); - store = createAllocationStateStore(join(dataDir, "state.json")); -}); - -afterEach(async () => { - await rm(dataDir, { recursive: true, force: true }); -}); - -function ensuredRunner(containerId: string): FakeCommandRunner { - return createFakeCommandRunner(async (args) => { - if (args[0] === "run") { - return { stdout: `${containerId}\n`, stderr: "", exitCode: 0 }; - } - return { stdout: "", stderr: "", exitCode: 0 }; - }); -} - -function baseEnsureRequest(overrides: Partial> = {}) { - return { - allocationId: "alloc-1", - generation: 1, - tenantId: "tenant-1", - anchorRunId: "run-1", - sidecarId: "sidecar-1", - token: TOKEN, - hubWebSocketUrl: "wss://hub.example.com/ws", - ...overrides, - }; -} - -describe("ensure", () => { - test("runs docker run -d with the image, labels, and env vars", async () => { - const commands = ensuredRunner("container-abc"); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - - const result = await provisioner.ensure(baseEnsureRequest()); - - expect(result).toEqual({ kind: "accepted", externalRef: "container-abc" }); - const runCall = commands.calls.find((call) => call[0] === "run"); - expect(runCall).toEqual([ - "run", - "-d", - "--label", - "corbits.allocationId=alloc-1", - "--label", - "corbits.sidecarId=sidecar-1", - "-e", - "HUB_WS_URL=wss://hub.example.com/ws", - "-e", - `SIDECAR_TOKEN=${TOKEN}`, - "-e", - "SIDECAR_ID=sidecar-1", - "-e", - "SIDECAR_DATA_DIR=/home/sidecar/interchange-sidecar-data", - IMAGE, - ]); - }); - - test("never logs or persists the raw token; only its hash is stored", async () => { - const commands = ensuredRunner("container-abc"); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - - await provisioner.ensure(baseEnsureRequest()); - - const raw = await readFile(join(dataDir, "state.json"), "utf8"); - expect(raw).not.toContain(TOKEN); - const state = JSON.parse(raw); - expect(state.records[0].tokenHashSha256).toMatch(/^[0-9a-f]{64}$/); - expect(state.records[0].tokenHashSha256).not.toBe(TOKEN); - }); - - test("is idempotent: a second ensure at the same generation does not call docker run again", async () => { - const commands = ensuredRunner("container-abc"); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - - const first = await provisioner.ensure(baseEnsureRequest()); - const second = await provisioner.ensure(baseEnsureRequest()); - - expect(first).toEqual(second); - expect(commands.calls.filter((call) => call[0] === "run")).toHaveLength(1); - }); - - test("rejects a stale generation without calling docker", async () => { - const commands = ensuredRunner("container-abc"); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - - await provisioner.ensure(baseEnsureRequest({ generation: 3 })); - const result = await provisioner.ensure(baseEnsureRequest({ generation: 2 })); - - expect(result).toMatchObject({ - kind: "rejected", - code: "stale_generation", - }); - expect(commands.calls.filter((call) => call[0] === "run")).toHaveLength(1); - }); - - test("rejects when docker run exits non-zero", async () => { - const commands = createFakeCommandRunner(async () => ({ - stdout: "", - stderr: "Error: no such image", - exitCode: 125, - })); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - - const result = await provisioner.ensure(baseEnsureRequest()); - - expect(result).toMatchObject({ - kind: "rejected", - code: "docker_run_failed", - retryable: true, - }); - }); -}); - -describe("destroy", () => { - test("stops and removes the container recorded for the allocation", async () => { - const commands = ensuredRunner("container-abc"); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - await provisioner.ensure(baseEnsureRequest()); - - const result = await provisioner.destroy({ - allocationId: "alloc-1", - generation: 1, - sidecarId: "sidecar-1", - }); - - expect(result).toEqual({ kind: "destroyed" }); - expect(commands.calls).toContainEqual(["stop", "container-abc"]); - expect(commands.calls).toContainEqual(["rm", "container-abc"]); - }); - - test("prefers externalRef over the stored containerId when given", async () => { - const commands = ensuredRunner("container-abc"); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - await provisioner.ensure(baseEnsureRequest()); - - await provisioner.destroy({ - allocationId: "alloc-1", - generation: 1, - sidecarId: "sidecar-1", - externalRef: "container-xyz", - }); - - expect(commands.calls).toContainEqual(["stop", "container-xyz"]); - expect(commands.calls).not.toContainEqual(["stop", "container-abc"]); - }); - - test("is idempotent when the container is already gone", async () => { - const commands = createFakeCommandRunner(async (args) => { - if (args[0] === "stop" || args[0] === "rm") { - return { stdout: "", stderr: "Error: No such container", exitCode: 1 }; - } - return { stdout: "", stderr: "", exitCode: 0 }; - }); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - - const result = await provisioner.destroy({ - allocationId: "alloc-1", - generation: 1, - sidecarId: "sidecar-1", - externalRef: "container-gone", - }); - - expect(result).toEqual({ kind: "destroyed" }); - }); - - test("fences a stale ensure that arrives after destroy", async () => { - const commands = ensuredRunner("container-abc"); - const provisioner = createDockerSidecarProvisioner({ - config: { image: IMAGE, stateFilePath: join(dataDir, "state.json") }, - commands, - store, - }); - await provisioner.ensure(baseEnsureRequest()); - await provisioner.destroy({ - allocationId: "alloc-1", - generation: 1, - sidecarId: "sidecar-1", - }); - - const result = await provisioner.ensure(baseEnsureRequest()); - - expect(result).toMatchObject({ - kind: "rejected", - code: "generation_destroyed", - }); - expect(commands.calls.filter((call) => call[0] === "run")).toHaveLength(1); - }); -}); diff --git a/apps/hub/src/provisioners/docker.ts b/apps/hub/src/provisioners/docker.ts deleted file mode 100644 index 700f5447e0..0000000000 --- a/apps/hub/src/provisioners/docker.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { resolve } from "node:path"; - -import { type } from "arktype"; - -import { - BackendOperationError, - createAllocationStateStore, - createSidecarProvisioner, - sidecarCapabilityDeclarations, - type AllocationStateStore, - type SidecarBackend, - type StartUnitArgs, -} from "./sandbox-sidecar"; -import type { SidecarProvisioner } from "@intx/hub-sessions"; - -const Environment = type({ - DOCKER_PROVISIONER_IMAGE: "string > 0", - "DOCKER_PROVISIONER_DATA_DIR?": "string > 0", -}); - -const DEFAULT_DATA_DIR = ".data/docker-provisioner"; - -export type DockerProvisionerConfig = { - readonly image: string; - readonly stateFilePath: string; -}; - -/** - * Parse the docker provisioner's configuration out of an environment map. - * Throws at the call site, listing every problem at once, so a - * misconfigured process dies at boot instead of failing at first use. - */ -export function readDockerProvisionerConfig( - env: Record, -): DockerProvisionerConfig { - const parsed = Environment(env); - if (parsed instanceof type.errors) { - throw new Error(`invalid docker provisioner environment: ${parsed.summary}`); - } - const dataDir = resolve(parsed.DOCKER_PROVISIONER_DATA_DIR ?? DEFAULT_DATA_DIR); - return { - image: parsed.DOCKER_PROVISIONER_IMAGE, - stateFilePath: resolve(dataDir, "state.json"), - }; -} - -export type CommandResult = { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -}; - -export interface CommandRunner { - run(args: readonly string[]): Promise; -} - -export function createBunCommandRunner(): CommandRunner { - return { - async run(args) { - const child = Bun.spawn(["docker", ...args], { - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - return { stdout, stderr, exitCode }; - }, - }; -} - -const ALLOCATION_ID_LABEL = "corbits.allocationId"; -const SIDECAR_ID_LABEL = "corbits.sidecarId"; - -// The path @workbench/sidecar's own boot config expects SIDECAR_DATA_DIR -// to resolve inside the container filesystem — see apps/sidecar/src/ -// config.ts. It is not host-configurable: unlike DOCKER_PROVISIONER_DATA_DIR -// (this provisioner's own state file, on the host), this is a path inside -// every sidecar container's own filesystem, so one fixed value is correct -// for every allocation. -const CONTAINER_SIDECAR_DATA_DIR = "/home/sidecar/interchange-sidecar-data"; - -/** - * Implements `./sandbox-sidecar`'s SidecarBackend port against the local - * `docker` CLI: `docker run` starts a unit, `docker stop`/`docker rm` - * remove one, and `docker ps` filtered by allocation label finds existing - * units. All docker-specific knowledge (labels, env vars, the image) - * lives here; the shared core never sees a docker command. - */ -export function createDockerBackend( - commands: CommandRunner, - config: DockerProvisionerConfig, -): SidecarBackend { - return { - async startUnit(args: StartUnitArgs): Promise { - const result = await commands.run([ - "run", - "-d", - "--label", - `${ALLOCATION_ID_LABEL}=${args.allocationId}`, - "--label", - `${SIDECAR_ID_LABEL}=${args.sidecarId}`, - "-e", - `HUB_WS_URL=${args.hubWebSocketUrl}`, - "-e", - `SIDECAR_TOKEN=${args.token}`, - "-e", - `SIDECAR_ID=${args.sidecarId}`, - "-e", - `SIDECAR_DATA_DIR=${CONTAINER_SIDECAR_DATA_DIR}`, - config.image, - ]); - if (result.exitCode !== 0) { - throw new BackendOperationError( - "docker_run_failed", - `docker run exited with code ${String(result.exitCode)}: ${result.stderr.trim()}`, - true, - ); - } - return result.stdout.trim(); - }, - - async stopUnit(externalRef: string): Promise { - const failure = await removeContainer(commands, externalRef); - if (failure !== null) { - throw new BackendOperationError("docker_destroy_failed", failure, true); - } - }, - - async findUnitsByAllocation(allocationId: string): Promise { - const result = await commands.run([ - "ps", - "-aq", - "--filter", - `label=${ALLOCATION_ID_LABEL}=${allocationId}`, - ]); - if (result.exitCode !== 0) return []; - return result.stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - }, - }; -} - -/** - * Stops and removes a container, treating "already gone" as success so - * destroy() stays idempotent. Returns a diagnostic message for any other - * docker failure instead of silently reporting destruction. - */ -async function removeContainer( - commands: CommandRunner, - containerId: string, -): Promise { - const stop = await commands.run(["stop", containerId]); - if (stop.exitCode !== 0 && !isMissingContainerError(stop.stderr)) { - return `docker stop exited with code ${String(stop.exitCode)}: ${stop.stderr.trim()}`; - } - const remove = await commands.run(["rm", containerId]); - if (remove.exitCode !== 0 && !isMissingContainerError(remove.stderr)) { - return `docker rm exited with code ${String(remove.exitCode)}: ${remove.stderr.trim()}`; - } - return null; -} - -function isMissingContainerError(stderr: string): boolean { - return stderr.toLowerCase().includes("no such container"); -} - -const PROVISIONER_API_VERSION = 1 as const; - -export type CreateDockerSidecarProvisionerOpts = { - readonly env?: Record; - readonly config?: DockerProvisionerConfig; - readonly commands?: CommandRunner; - readonly store?: AllocationStateStore; -}; - -export function createDockerSidecarProvisioner( - opts: CreateDockerSidecarProvisionerOpts = {}, -): SidecarProvisioner { - const config = opts.config ?? readDockerProvisionerConfig(opts.env ?? process.env); - const commands = opts.commands ?? createBunCommandRunner(); - const store = opts.store ?? createAllocationStateStore(config.stateFilePath); - - return createSidecarProvisioner({ - id: "docker", - apiVersion: PROVISIONER_API_VERSION, - bindingFingerprint: `docker:v1:${config.image}`, - // Declares nothing, so this provisioner serves any deployment that - // states no capability requirement — the behaviour it had before - // Interchange replaced sidecar placement with capability selection. - capabilities: sidecarCapabilityDeclarations("container"), - backend: createDockerBackend(commands, config), - store, - }); -} diff --git a/apps/hub/src/provisioners/e2b-config.test.ts b/apps/hub/src/provisioners/e2b-config.test.ts deleted file mode 100644 index 669f7b874e..0000000000 --- a/apps/hub/src/provisioners/e2b-config.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Restructured from the private repo faremeter/interchange-e2b-provisioner -// (github.com/faremeter/interchange-e2b-provisioner) at commit c1e3182. We -// now own this code; it is not a vendored path. - -import { describe, expect, test } from "bun:test"; - -import { readProvisionerConfig } from "./e2b"; - -describe("E2B provisioner configuration", () => { - test("reads the required boundary configuration", () => { - expect( - readProvisionerConfig( - { - E2B_API_KEY: `e2b_${"a".repeat(32)}`, - E2B_TEMPLATE: "template-one", - E2B_SANDBOX_TIMEOUT_MS: "120000", - }, - "/tmp/e2b-state", - ), - ).toEqual({ - apiKey: `e2b_${"a".repeat(32)}`, - template: "template-one", - dataDir: "/tmp/e2b-state", - sandboxTimeoutMs: 120_000, - requestTimeoutMs: 60_000, - }); - }); - - test("rejects a relative state path and invalid timeout", () => { - const base = { - E2B_API_KEY: `e2b_${"a".repeat(32)}`, - E2B_TEMPLATE: "template-one", - }; - expect(() => readProvisionerConfig(base, "relative-state")).toThrow(/absolute path/); - expect(() => - readProvisionerConfig( - { - ...base, - E2B_SANDBOX_TIMEOUT_MS: "1000", - }, - "/tmp/e2b-state", - ), - ).toThrow(/at least 60000/); - }); -}); diff --git a/apps/hub/src/provisioners/e2b.test.ts b/apps/hub/src/provisioners/e2b.test.ts deleted file mode 100644 index 4f2b7cdb1d..0000000000 --- a/apps/hub/src/provisioners/e2b.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Restructured from the private repo faremeter/interchange-e2b-provisioner -// (github.com/faremeter/interchange-e2b-provisioner) at commit c1e3182. We -// now own this code; it is not a vendored path. -import { describe, expect, test } from "bun:test"; -import { createAllocationStateStore } from "./sandbox-sidecar"; - -import { createSidecarProvisioner, type ProvisionerConfig } from "./e2b"; - -describe("Interchange plugin adapter", () => { - test("exposes a stable versioned binding", () => { - const config: ProvisionerConfig = { - apiKey: `e2b_${"a".repeat(32)}`, - template: "template-one", - dataDir: "/tmp/not-used", - sandboxTimeoutMs: 60_000, - requestTimeoutMs: 60_000, - }; - const provisioner = createSidecarProvisioner({ - config, - store: createAllocationStateStore("/tmp/not-used/state.json"), - }); - - expect(provisioner.id).toBe("e2b"); - expect(provisioner.apiVersion).toBe(1); - expect(provisioner.bindingFingerprint).toBe("e2b:v1:template-one"); - }); -}); diff --git a/apps/hub/src/provisioners/e2b.ts b/apps/hub/src/provisioners/e2b.ts deleted file mode 100644 index 9567abbc5c..0000000000 --- a/apps/hub/src/provisioners/e2b.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Restructured from the private repo faremeter/interchange-e2b-provisioner -// (github.com/faremeter/interchange-e2b-provisioner) at commit c1e3182. We -// now own this code; it is not a vendored path. -import { isAbsolute } from "node:path"; - -import { - AuthenticationError, - InvalidArgumentError, - RateLimitError, - Sandbox, - SandboxNotFoundError, - TimeoutError, -} from "e2b"; -import { type } from "arktype"; - -import { - BackendOperationError, - createAllocationStateStore, - createSidecarProvisioner as createCoreSidecarProvisioner, - sidecarCapabilityDeclarations, - type AllocationStateStore, - type SidecarBackend, - type StartUnitArgs, -} from "./sandbox-sidecar"; -import type { SidecarProvisioner } from "@intx/hub-sessions"; - -const Environment = type({ - E2B_API_KEY: "string > 0", - E2B_TEMPLATE: "string > 0", - "E2B_SANDBOX_TIMEOUT_MS?": "string", -}); - -export type ProvisionerConfig = { - readonly apiKey: string; - readonly template: string; - readonly dataDir: string; - readonly sandboxTimeoutMs: number; - readonly requestTimeoutMs: number; -}; - -/** - * `dataDir` is the HUB's own state directory for this backend -- where the - * allocation fences, destroy tombstones, and sandbox refs live -- not the - * sandbox's `SIDECAR_DATA_DIR`. The hub derives it from its data dir the - * same way it does for the container backend, so it is a caller argument - * rather than an environment variable an operator could point somewhere - * unrelated. - */ -export function readProvisionerConfig( - env: Record, - dataDir: string, -): ProvisionerConfig { - const parsed = Environment(env); - if (parsed instanceof type.errors) { - throw new Error(`Invalid E2B provisioner configuration: ${parsed.summary}`); - } - if (!parsed.E2B_API_KEY.startsWith("e2b_") || parsed.E2B_API_KEY.length < 16) { - throw new Error("E2B_API_KEY must be a valid E2B API key"); - } - if (!isAbsolute(dataDir)) { - throw new Error("E2B provisioner data dir must be an absolute path"); - } - - const sandboxTimeoutMs = readTimeout(parsed.E2B_SANDBOX_TIMEOUT_MS, 15 * 60 * 1_000); - return { - apiKey: parsed.E2B_API_KEY, - template: parsed.E2B_TEMPLATE, - dataDir, - sandboxTimeoutMs, - requestTimeoutMs: 60_000, - }; -} -function readTimeout(value: string | undefined, fallback: number): number { - if (value === undefined || value.trim() === "") return fallback; - const timeoutMs = Number(value); - if (!Number.isInteger(timeoutMs) || timeoutMs < 60_000) { - throw new Error("E2B_SANDBOX_TIMEOUT_MS must be at least 60000"); - } - if (timeoutMs > 24 * 60 * 60 * 1_000) { - throw new Error("E2B_SANDBOX_TIMEOUT_MS cannot exceed 24 hours"); - } - return timeoutMs; -} - -const PROVISIONER_MARKER = "interchange-e2b-v1"; -const LAUNCHER_COMMAND = "bun run /opt/interchange-e2b/start-sidecar.ts"; - -export type SandboxOperationFailure = { - readonly code: string; - readonly message: string; - readonly retryable: boolean; -}; - -/** - * Implements `./sandbox-sidecar`'s SidecarBackend port against the E2B - * SDK: which SDK calls map to start/stop/find, sandbox metadata handling, - * and error classification (retryable vs terminal). Generation fencing, - * the allocation state store, and obsolete-unit sweeping all come from - * the shared core — this only ever talks to E2B. - */ -export function createE2BBackend(config: ProvisionerConfig): SidecarBackend { - const connection = { - apiKey: config.apiKey, - requestTimeoutMs: config.requestTimeoutMs, - }; - - async function listAllocationSandboxIds(allocationId: string): Promise { - const paginator = Sandbox.list({ - ...connection, - query: { - metadata: { - intx_provisioner: PROVISIONER_MARKER, - intx_allocation_id: allocationId, - }, - state: ["running", "paused"], - }, - }); - const sandboxIds: string[] = []; - while (paginator.hasNext) { - const page = await paginator.nextItems(connection); - for (const sandbox of page) { - sandboxIds.push(sandbox.sandboxId); - } - } - return sandboxIds; - } - - return { - async startUnit(args: StartUnitArgs): Promise { - try { - const sandbox = await Sandbox.create(config.template, { - ...connection, - timeoutMs: config.sandboxTimeoutMs, - lifecycle: { onTimeout: "kill", autoResume: false }, - allowInternetAccess: true, - metadata: { - intx_provisioner: PROVISIONER_MARKER, - intx_allocation_id: args.allocationId, - intx_sidecar_id: args.sidecarId, - intx_generation: String(args.generation), - }, - }); - await sandbox.commands.run(LAUNCHER_COMMAND, { - background: true, - cwd: "/repo", - // E2B otherwise applies its 60-second command default even to a - // background process. Keep the sidecar alive for the sandbox's - // configured allocation lifetime; the sandbox timeout remains - // the outer cleanup bound. - timeoutMs: config.sandboxTimeoutMs, - envs: { - // KNOWN CAVEAT: the sidecar allocation token reaches the - // sandbox through a plain environment variable. E2B has no - // narrower secret-injection primitive for background - // commands today; this is an accepted, unmitigated exposure - // surface. - HUB_WS_URL: args.hubWebSocketUrl, - SIDECAR_ID: args.sidecarId, - SIDECAR_TOKEN: args.token, - SIDECAR_DATA_DIR: "/home/user/interchange-sidecar-data", - NODE_ENV: "production", - }, - }); - return sandbox.sandboxId; - } catch (error) { - throw toBackendOperationError(error); - } - }, - - async stopUnit(externalRef: string): Promise { - try { - await Sandbox.kill(externalRef, connection); - } catch (error) { - if (error instanceof SandboxNotFoundError) return; - throw toBackendOperationError(error); - } - }, - - async findUnitsByAllocation(allocationId: string) { - try { - return await listAllocationSandboxIds(allocationId); - } catch (error) { - throw toBackendOperationError(error); - } - }, - }; -} - -function toBackendOperationError(error: unknown): BackendOperationError { - const classified = classifyE2BError(error); - return new BackendOperationError(classified.code, classified.message, classified.retryable); -} - -export function classifyE2BError(error: unknown): SandboxOperationFailure { - if (error instanceof AuthenticationError) { - return { - code: "e2b_authentication_failed", - message: "E2B rejected the provisioner credentials", - retryable: false, - }; - } - if (error instanceof InvalidArgumentError) { - return { - code: "e2b_invalid_request", - message: error.message, - retryable: false, - }; - } - if (error instanceof RateLimitError) { - return { - code: "e2b_rate_limited", - message: error.message, - retryable: true, - }; - } - if (error instanceof TimeoutError) { - return { - code: "e2b_timeout", - message: error.message, - retryable: true, - }; - } - if (error instanceof SandboxNotFoundError) { - return { - code: "sandbox_not_found", - message: error.message, - retryable: true, - }; - } - return { - code: "e2b_operation_failed", - message: error instanceof Error ? error.message : String(error), - retryable: true, - }; -} - -const PROVISIONER_API_VERSION = 1 as const; - -function requireDataDir(dataDir: string | undefined): string { - if (dataDir === undefined) { - throw new Error( - "createSidecarProvisioner requires dataDir (the hub's state directory for this backend) when no config is supplied", - ); - } - return dataDir; -} - -export type CreateSidecarProvisionerOpts = { - readonly env?: Record; - /** Hub-side state directory for this backend's allocation fences. */ - readonly dataDir?: string; - readonly config?: ProvisionerConfig; - readonly store?: AllocationStateStore; -}; - -export function createSidecarProvisioner( - opts: CreateSidecarProvisionerOpts = {}, -): SidecarProvisioner { - const config = - opts.config ?? readProvisionerConfig(opts.env ?? process.env, requireDataDir(opts.dataDir)); - return createCoreSidecarProvisioner({ - id: "e2b", - apiVersion: PROVISIONER_API_VERSION, - bindingFingerprint: `e2b:v1:${config.template}`, - // Declares nothing, so this provisioner serves any deployment that - // states no capability requirement — the behaviour it had before - // Interchange replaced sidecar placement with capability selection. - capabilities: sidecarCapabilityDeclarations("vm"), - backend: createE2BBackend(config), - store: opts.store ?? createAllocationStateStore(`${config.dataDir}/state.json`), - }); -} diff --git a/apps/hub/src/provisioners/fake-command-runner.ts b/apps/hub/src/provisioners/fake-command-runner.ts deleted file mode 100644 index ddf31bc3aa..0000000000 --- a/apps/hub/src/provisioners/fake-command-runner.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CommandResult, CommandRunner } from "./docker"; - -export type FakeCommandRunner = CommandRunner & { - readonly calls: (readonly string[])[]; -}; - -export function createFakeCommandRunner( - handler: (args: readonly string[]) => Promise, -): FakeCommandRunner { - const calls: (readonly string[])[] = []; - return { - calls, - async run(args) { - calls.push(args); - return handler(args); - }, - }; -} diff --git a/apps/hub/src/provisioners/process.ts b/apps/hub/src/provisioners/process.ts index a2cf3206cc..73a6a041e9 100644 --- a/apps/hub/src/provisioners/process.ts +++ b/apps/hub/src/provisioners/process.ts @@ -61,8 +61,7 @@ export type ReadProcessProvisionerConfigArgs = { /** * The HUB's own state directory for this backend, derived from * `HUB_DATA_DIR` by the caller — never an environment variable an - * operator could point somewhere unrelated. Matches how the docker and - * e2b backends receive theirs. + * operator could point somewhere unrelated. */ readonly dataDir: string; readonly hubWebSocketUrl: string; @@ -171,10 +170,9 @@ const TERMINATION_POLL_MS = 100; /** * Implements `SidecarBackend` (see `./sandbox-sidecar`) by running one - * `apps/sidecar` process per allocation on the hub host itself. This is - * the backend an install with no `SIDECAR_PROVISIONERS` configured gets, - * so one server hosts many chats and workflows with no container runtime - * and no remote sandbox account. + * `apps/sidecar` process per allocation on the hub host itself, the only + * sidecar backend, so one server hosts many chats and workflows with no + * container runtime and no remote sandbox account. * * Layout under the hub's data dir, one directory per started unit: * @@ -471,11 +469,10 @@ export type CreateProcessSidecarProvisionerOpts = { }; /** - * The default sidecar backend: every allocation is a child process of the + * The only sidecar backend: every allocation is a child process of the * hub on the same host. Idempotence, generation fencing, and destroy - * tombstones come from `./sandbox-sidecar`'s shared core, exactly as they - * do for the docker and e2b backends — this module supplies only the - * OS-level unit. + * tombstones come from `./sandbox-sidecar`'s shared core — this module + * supplies only the OS-level unit. * * The binding fingerprint pins the two facts that decide what a * provisioned sidecar actually is: which entry point runs, and which hub diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index f4d77abcfe..dc11e6180c 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -77,11 +77,6 @@ import { createCryptoProviderCache, } from "@corbits/webhook-triggers"; import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows"; -import { - createSidecarProvisioner as createE2BSidecarProvisioner, - readProvisionerConfig as readE2BProvisionerConfig, -} from "./provisioners/e2b"; -import { createDockerSidecarProvisioner } from "./provisioners/docker"; import { createProcessSidecarProvisioner, readProcessProvisionerConfig, @@ -112,7 +107,6 @@ import { createProviderHealthStore } from "@corbits/connections/provider-health" import { CONNECTOR_REGISTRY, MCP_PRESETS } from "./native-connector-registry"; import type { DB } from "@intx/db"; import { sidecar, workflowRun } from "@intx/db/schema"; -import { type } from "arktype"; import path from "node:path"; // The same condition registry `mountHubRoutes` builds by default when no @@ -149,44 +143,22 @@ function createWorkflowRunAuthenticator(deps: { db: DB["db"] }) { }; } -// SIDECAR_PROVISIONERS: comma-separated backend ids to register for -// exclusive-sidecar placement. Unset or empty registers "process" alone, -// so a single-server install works with no operator configuration. -const SidecarProvisionersEnv = type({ - "SIDECAR_PROVISIONERS?": type("string"), -}); - -function buildSidecarProvisioner( - id: "process" | "docker" | "e2b", +function buildProcessSidecarProvisioner( hubDataDir: string, hubWebSocketUrl: string, role: ProcessProvisionerRole, ): SidecarProvisioner { - switch (id) { - case "process": - return createProcessSidecarProvisioner({ - role, - config: readProcessProvisionerConfig({ - env: process.env, - dataDir: path.resolve( - hubDataDir, - role === "probe" ? "process-provisioner-probe" : "process-provisioner", - ), - hubWebSocketUrl, - }), - }); - case "docker": - return createDockerSidecarProvisioner({ - config: { - image: process.env["DOCKER_PROVISIONER_IMAGE"] ?? "", - stateFilePath: path.resolve(hubDataDir, "docker-provisioner", "state.json"), - }, - }); - case "e2b": - return createE2BSidecarProvisioner({ - config: readE2BProvisionerConfig(process.env, path.resolve(hubDataDir, "e2b-provisioner")), - }); - } + return createProcessSidecarProvisioner({ + role, + config: readProcessProvisionerConfig({ + env: process.env, + dataDir: path.resolve( + hubDataDir, + role === "probe" ? "process-provisioner-probe" : "process-provisioner", + ), + hubWebSocketUrl, + }), + }); } export type CreateHubServerOpts = { @@ -354,30 +326,12 @@ export async function createHubServer({ const hubSidecarWebSocketUrl = process.env["HUB_SIDECAR_WEBSOCKET_URL"] ?? `ws://127.0.0.1:${String(port)}/api/sidecars/ws`; - const parsedProvisionerIds = SidecarProvisionersEnv(process.env); - const provisionerIdsRaw: string = - parsedProvisionerIds instanceof type.errors - ? "" - : (parsedProvisionerIds.SIDECAR_PROVISIONERS ?? ""); - const provisionerIds = provisionerIdsRaw - .split(",") - .map((id) => id.trim()) - .filter( - (id): id is "process" | "docker" | "e2b" => - id === "process" || id === "docker" || id === "e2b", - ); - const resolvedProvisionerIds = - provisionerIds.length > 0 ? provisionerIds : (["process"] as const); - const deploymentProvisioners = - sidecarProvisioners ?? - resolvedProvisionerIds.map((id) => - buildSidecarProvisioner(id, hubDataDir, hubSidecarWebSocketUrl, "deployment"), - ); - const probeProvisioners = - probeSidecarProvisioners ?? - resolvedProvisionerIds.map((id) => - buildSidecarProvisioner(id, hubDataDir, hubSidecarWebSocketUrl, "probe"), - ); + const deploymentProvisioners = sidecarProvisioners ?? [ + buildProcessSidecarProvisioner(hubDataDir, hubSidecarWebSocketUrl, "deployment"), + ]; + const probeProvisioners = probeSidecarProvisioners ?? [ + buildProcessSidecarProvisioner(hubDataDir, hubSidecarWebSocketUrl, "probe"), + ]; const sidecarPlugins = createSidecarPluginRegistry({ provisioners: deploymentProvisioners, diff --git a/apps/sidecar-docker/Dockerfile b/apps/sidecar-docker/Dockerfile deleted file mode 100644 index a5dd6a9916..0000000000 --- a/apps/sidecar-docker/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -# Build context is the REPO ROOT, not apps/sidecar: workspace deps -# (@intx/*, @corbits/*) are `workspace:*` packages resolved from source, -# so `bun install` needs the whole monorepo tree, not just this app. -# -# docker build -f apps/sidecar-docker/Dockerfile -t corbits-sidecar:dev . -# -# (see `bun run build:sidecar-image` in the root package.json) - -FROM oven/bun:1.3.9 AS base -WORKDIR /repo - -FROM base AS install -COPY . . -# Scoped to the sidecar's own workspace closure: an unfiltered install -# pulls the whole monorepo's dependency graph (the web app's frontend -# stack included), which is both wasted image weight and enough memory -# pressure to OOM a small build container. -RUN bun install --frozen-lockfile --filter=@workbench/sidecar - -FROM base AS runtime -RUN addgroup --system sidecar \ - && adduser --system --ingroup sidecar --home /home/sidecar sidecar \ - && mkdir -p /home/sidecar/interchange-sidecar-data \ - && chown -R sidecar:sidecar /home/sidecar -COPY --from=install /repo /repo -USER sidecar -ENV SIDECAR_DATA_DIR=/home/sidecar/interchange-sidecar-data -VOLUME ["/home/sidecar/interchange-sidecar-data"] -CMD ["bun", "apps/sidecar/src/index.ts"] diff --git a/apps/sidecar-docker/package.json b/apps/sidecar-docker/package.json deleted file mode 100644 index e3b1b36b06..0000000000 --- a/apps/sidecar-docker/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "@workbench/sidecar-docker", - "version": "0.0.1", - "private": true, - "description": "Docker image build for the docker SidecarProvisioner backend (apps/hub/src/provisioners/docker.ts)", - "license": "SEE LICENSE IN ../../LICENSE.md", - "type": "module" -} diff --git a/apps/sidecar-e2b/README.md b/apps/sidecar-e2b/README.md deleted file mode 100644 index 562220edb5..0000000000 --- a/apps/sidecar-e2b/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# @workbench/sidecar-e2b - -The E2B image template that boots `apps/sidecar` inside an -[E2B](https://e2b.dev) sandbox. The `e2b` `SidecarProvisioner` backend -itself lives in `apps/hub/src/provisioners/e2b.ts` — this app holds only -the template build. - -## What ships in the image - -The template (`template/definition.ts`) builds the E2B image from this -repository's own tree — there is no sibling Interchange checkout. The build -context is staged on disk first (`template/build-context.ts`) as an explicit -allowlist: - -- `apps/sidecar` — full source, the entrypoint (`src/index.ts`). -- The workspace packages `apps/sidecar` transitively depends on — full - source (`packages/credential-providers`, and - the vendored Interchange packages under `vendor/intx/*` it uses). -- Every other workspace member declared under the root `package.json`'s - `workspaces` globs (`apps/*`, `packages/*`, `tools/*`, `vendor/intx/*`, - `workflows/*`) — `package.json` only. This stub is required for - `bun install --frozen-lockfile` to resolve the whole workspace graph - (bun's lockfile enumerates every workspace member by path and fails - otherwise); it never ships that package's source. -- The root `package.json` and `bun.lock`. -- `template/start-sidecar.ts`, copied to `/opt/interchange-e2b/` in the - image — creates `SIDECAR_DATA_DIR`, then spawns the sidecar and forwards - `SIGTERM`/`SIGINT`, propagating its exit code. - -Vendored packages under `vendor/intx/*` are consumed as TypeScript source: -their `package.json#exports` already point at `./src/*` (see any -`vendor/intx/*/VENDORED-FROM`), so the image build does **not** pass -`--conditions=intx-src` — that upstream condition doesn't apply here. - -**No tool packages are baked into the image.** Tools are materialized at -deploy time from registries via pins (`apps/sidecar/src/tool-materialization.ts`); -the image stays tool-agnostic. - -### Excluded, by construction - -The stager only ever reads the root manifest/lockfile and the five -workspace glob roots above — it never enumerates `.data/`, `.env*`, -`.worktrees/`, or `.git`, so nothing in them can reach the image regardless -of what a future contributor puts there. `template/build-context.test.ts` -asserts this directly against the real repository tree. The template's -`fileIgnorePatterns` repeat the exclusion as defense in depth, but the -allowlist staging step is what actually enforces it. - -### Base image - -Pinned to `oven/bun:1.3.9` (`fromBunImage("1.3.9")`) — an explicit patch -version, not `latest` or a bare major, so the image is reproducible across -builds and matches this workspace's `engines.bun` requirement and the Bun -version the lockfile was produced with. - -## Building the template - -```sh -E2B_API_KEY=e2b_... bun apps/sidecar-e2b/template/build.ts -``` - -This runs `Template.build(...)` against the real E2B API — it needs a valid -`E2B_API_KEY` and creates real infrastructure. It is not exercised by -`bun test`. On success it prints the immutable template ID: - -``` -Built E2B template () -E2B_TEMPLATE= -``` - -Use the printed **template ID**, not the template name — the name is a -mutable alias and the provisioner's binding fingerprint must pin the exact -image. Set that ID as `E2B_TEMPLATE` for the provisioner (see -`apps/hub/src/provisioners/e2b.ts`). - -## Operator environment variables - -Required by the provisioner (`readProvisionerConfig` in -`apps/hub/src/provisioners/e2b.ts`): - -- `E2B_API_KEY` — E2B API key (`e2b_...`). -- `E2B_TEMPLATE` — the template ID printed by the build step above. -- `E2B_PROVISIONER_DATA_DIR` — absolute path for the provisioner's local - state. -- `E2B_SANDBOX_TIMEOUT_MS` (optional) — sandbox lifetime, 60s–24h, defaults - to 15 minutes. - -Per-allocation, the provisioner passes `HUB_WS_URL`, `SIDECAR_ID`, and -`SIDECAR_TOKEN` into the sandbox's background launch command. - -**`HUB_WS_URL` must be a public `wss://` URL or tunnel.** An E2B sandbox is -a remote machine — it cannot reach a hub bound to `localhost` or a -private/dev-only network. Point it at a publicly reachable hub endpoint, or -a tunnel (e.g. an ngrok/Cloudflare tunnel) in front of a local hub. - -## What is NOT verified here - -Building and booting an actual E2B image needs a real `E2B_API_KEY` and -creates billable infrastructure, so this app's tests do not do it. -Unverified without E2B credentials: - -- That `Template.build(...)` actually succeeds against the E2B API. -- That `bun install --frozen-lockfile` succeeds _inside_ the real E2B - build environment (verified locally instead: `build-context.test.ts` - stages the real repository tree and asserts every workspace member bun's - lockfile expects is present with at least a `package.json`, which is the - condition a local `bun install --frozen-lockfile` repro confirmed is - sufficient). -- That the booted sandbox's `bun run /opt/interchange-e2b/start-sidecar.ts` - successfully dials `HUB_WS_URL` and stays alive for the configured - sandbox lifetime. diff --git a/apps/sidecar-e2b/package.json b/apps/sidecar-e2b/package.json deleted file mode 100644 index 51a87b354a..0000000000 --- a/apps/sidecar-e2b/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@workbench/sidecar-e2b", - "version": "0.0.1", - "private": true, - "description": "E2B sandbox template build for the e2b SidecarProvisioner backend (apps/hub/src/provisioners/e2b.ts)", - "license": "SEE LICENSE IN ../../LICENSE.md", - "type": "module", - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "e2b": "^2.36.1" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/apps/sidecar-e2b/template/build-context.test.ts b/apps/sidecar-e2b/template/build-context.test.ts deleted file mode 100644 index 5d7264ee49..0000000000 --- a/apps/sidecar-e2b/template/build-context.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { afterEach, describe, expect, test } from "bun:test"; - -import { SIDECAR_FULL_SOURCE_DIRS, listWorkspaceMembers, stageBuildContext } from "./build-context"; - -const repositoryRoot = resolve(import.meta.dir, "../../.."); - -let stagingDirs: string[] = []; - -function stage(): string { - const dir = mkdtempSync(join(tmpdir(), "build-context-test-")); - stagingDirs.push(dir); - stageBuildContext(repositoryRoot, dir); - return dir; -} - -function walk(dir: string): string[] { - const entries: string[] = []; - for (const name of readdirSync(dir)) { - const full = join(dir, name); - entries.push(full); - if (statSync(full).isDirectory()) { - entries.push(...walk(full)); - } - } - return entries; -} - -afterEach(() => { - for (const dir of stagingDirs) { - rmSync(dir, { recursive: true, force: true }); - } - stagingDirs = []; -}); - -describe("stageBuildContext", () => { - test("never stages secret-bearing repository paths", () => { - const staged = stage(); - const allPaths = walk(staged); - - const forbiddenNames = [".data", ".env", ".worktrees", ".git"]; - for (const path of allPaths) { - const base = path.split("/").pop() ?? ""; - for (const forbidden of forbiddenNames) { - expect(base).not.toBe(forbidden); - expect(base.startsWith(".env.")).toBe(false); - } - } - }); - - test("never even reads outside the declared workspace roots and root manifest files", () => { - // Regression guard for the allowlist design: this asserts on the - // *inputs* the stager is allowed to read, not just its outputs, so a - // future change that widens the scan (e.g. `readdirSync(repositoryRoot)`) - // fails here even before it could copy anything sensitive. - const source = readFileSync(resolve(import.meta.dir, "build-context.ts"), "utf8"); - expect(source).not.toContain("readdirSync(repositoryRoot)"); - }); - - test("ships full source for apps/sidecar and its workspace dependency closure", () => { - const staged = stage(); - for (const dir of SIDECAR_FULL_SOURCE_DIRS) { - expect(existsSync(join(staged, dir, "package.json"))).toBe(true); - } - expect(existsSync(join(staged, "apps/sidecar/src/index.ts"))).toBe(true); - expect(existsSync(join(staged, "vendor/intx/hub-sessions/src/index.ts"))).toBe(true); - }); - - test("ships root package.json and bun.lock", () => { - const staged = stage(); - expect(existsSync(join(staged, "package.json"))).toBe(true); - expect(existsSync(join(staged, "bun.lock"))).toBe(true); - }); - - test("ships every workspace member the root package.json globs match, at minimum as a package.json stub", () => { - const staged = stage(); - const members = listWorkspaceMembers(repositoryRoot); - expect(members.length).toBeGreaterThan(50); - - for (const member of members) { - const manifestPath = join(staged, ...member.split("/"), "package.json"); - expect(existsSync(manifestPath)).toBe(true); - } - }); - - test("stubs out non-dependency workspace members without shipping their source", () => { - const staged = stage(); - const fullSourceSet = new Set(SIDECAR_FULL_SOURCE_DIRS); - const members = listWorkspaceMembers(repositoryRoot); - const stubbed = members.filter((member) => !fullSourceSet.has(member)); - expect(stubbed.length).toBeGreaterThan(0); - - for (const member of stubbed) { - const stagedDir = join(staged, ...member.split("/")); - const entries = readdirSync(stagedDir); - expect(entries).toEqual(["package.json"]); - } - }); - - test("excludes node_modules and dist from full-source directories", () => { - const staged = stage(); - const allPaths = walk(join(staged, "apps/sidecar")); - for (const path of allPaths) { - const base = path.split("/").pop() ?? ""; - expect(base).not.toBe("node_modules"); - expect(base).not.toBe("dist"); - } - }); -}); diff --git a/apps/sidecar-e2b/template/build-context.ts b/apps/sidecar-e2b/template/build-context.ts deleted file mode 100644 index ed4dd95953..0000000000 --- a/apps/sidecar-e2b/template/build-context.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Builds an on-disk staging directory that becomes the E2B build context. -// -// This is an allowlist, not a denylist: the only repository paths ever read -// are the root manifest/lockfile and the workspace glob roots declared in -// the root package.json (`apps/*`, `packages/*`, `tools/*`, `vendor/intx/*`, -// `workflows/*`). Directories such as `.data`, `.env*`, `.worktrees`, and -// `.git` are never enumerated, so a future secret-bearing directory added -// outside those roots cannot leak into the image no matter what it -// contains. Within the workspace roots, only the packages `apps/sidecar` -// actually depends on (SIDECAR_FULL_SOURCE_DIRS) get their source copied; -// every other workspace member contributes just its `package.json`, which -// is enough for `bun install --frozen-lockfile` to resolve the whole -// workspace graph (see the package.json-stub test in build-context.test.ts -// for the proof). - -import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; -import { copyFileSync } from "node:fs"; -import { join, posix } from "node:path"; - -export const WORKSPACE_GLOB_ROOTS = [ - "apps", - "packages", - "tools", - "vendor/intx", - "workflows", -] as const; - -// The transitive workspace-package closure of `apps/sidecar`'s -// `dependencies` (computed by walking package.json workspace:* deps -// starting from @workbench/sidecar). Anything not listed here ships as a -// package.json-only stub. -export const SIDECAR_FULL_SOURCE_DIRS = [ - "apps/sidecar", - "packages/credential-providers", - "vendor/intx/db", - "vendor/intx/hub-sessions", - "vendor/intx/workflow", - "vendor/intx/workflow-deploy", - "vendor/intx/workflow-host", -] as const; - -const FULL_SOURCE_COPY_EXCLUDES = new Set(["node_modules", "dist", "coverage", ".turbo"]); - -function listWorkspaceMemberDirs(repositoryRoot: string, globRoot: string): string[] { - const absoluteGlobRoot = join(repositoryRoot, ...globRoot.split("/")); - if (!existsSync(absoluteGlobRoot)) { - return []; - } - return readdirSync(absoluteGlobRoot) - .filter((entry) => { - const entryPath = join(absoluteGlobRoot, entry); - return statSync(entryPath).isDirectory() && existsSync(join(entryPath, "package.json")); - }) - .map((entry) => posix.join(globRoot, entry)); -} - -export function listWorkspaceMembers(repositoryRoot: string): string[] { - return WORKSPACE_GLOB_ROOTS.flatMap((globRoot) => - listWorkspaceMemberDirs(repositoryRoot, globRoot), - ); -} - -function copyFullSource(repositoryRoot: string, relativeDir: string, destinationRoot: string) { - const src = join(repositoryRoot, ...relativeDir.split("/")); - const dest = join(destinationRoot, ...relativeDir.split("/")); - cpSync(src, dest, { - recursive: true, - filter: (candidate) => { - const base = candidate.split("/").pop() ?? ""; - return !FULL_SOURCE_COPY_EXCLUDES.has(base); - }, - }); -} - -function copyManifestStub(repositoryRoot: string, relativeDir: string, destinationRoot: string) { - const srcManifest = join(repositoryRoot, ...relativeDir.split("/"), "package.json"); - const destDir = join(destinationRoot, ...relativeDir.split("/")); - mkdirSync(destDir, { recursive: true }); - copyFileSync(srcManifest, join(destDir, "package.json")); -} - -// Stages the exact tree the image needs at `destinationDir`. Only ever -// reads: repositoryRoot/package.json, repositoryRoot/bun.lock, and the -// workspace glob roots above. -export function stageBuildContext(repositoryRoot: string, destinationDir: string) { - mkdirSync(destinationDir, { recursive: true }); - copyFileSync(join(repositoryRoot, "package.json"), join(destinationDir, "package.json")); - copyFileSync(join(repositoryRoot, "bun.lock"), join(destinationDir, "bun.lock")); - - const fullSourceDirs = new Set(SIDECAR_FULL_SOURCE_DIRS); - for (const member of listWorkspaceMembers(repositoryRoot)) { - if (fullSourceDirs.has(member)) { - copyFullSource(repositoryRoot, member, destinationDir); - } else { - copyManifestStub(repositoryRoot, member, destinationDir); - } - } -} diff --git a/apps/sidecar-e2b/template/build.ts b/apps/sidecar-e2b/template/build.ts deleted file mode 100644 index 1336a9cda6..0000000000 --- a/apps/sidecar-e2b/template/build.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Restructured from the private repo faremeter/interchange-e2b-provisioner -// (github.com/faremeter/interchange-e2b-provisioner) at commit c1e3182. We -// now own this code; it is not a vendored path. - -import { Template, defaultBuildLogger } from "e2b"; - -import { createSidecarTemplate } from "./definition"; - -const apiKey = process.env["E2B_API_KEY"]; -if (apiKey === undefined || !apiKey.startsWith("e2b_")) { - throw new Error("E2B_API_KEY is required to build the template"); -} - -const templateName = process.env["E2B_TEMPLATE_NAME"] ?? "interchange-sidecar"; -const template = createSidecarTemplate(); - -const build = await Template.build(template, templateName, { - apiKey, - cpuCount: 2, - memoryMB: 2_048, - onBuildLogs: defaultBuildLogger(), -}); - -process.stdout.write( - `Built E2B template ${build.name} (${build.templateId})\nE2B_TEMPLATE=${build.templateId}\n`, -); diff --git a/apps/sidecar-e2b/template/definition.ts b/apps/sidecar-e2b/template/definition.ts deleted file mode 100644 index 9fc76acd73..0000000000 --- a/apps/sidecar-e2b/template/definition.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Restructured from the private repo faremeter/interchange-e2b-provisioner -// (github.com/faremeter/interchange-e2b-provisioner) at commit c1e3182. We -// now own this code; it is not a vendored path. -// -// Builds the E2B image from THIS repository's own tree: apps/sidecar plus -// the vendored Interchange source it depends on (vendor/intx/*, consumed as -// TypeScript source -- see any vendor/intx/*/VENDORED-FROM, whose exports -// maps already point at ./src/*, so no `--conditions=intx-src` is needed -// here or at runtime). There is no sibling Interchange checkout in this -// repo, unlike the upstream provisioner this was restructured from. -// -// The build context is staged on disk first (see build-context.ts) so the -// image only ever contains an explicit allowlist: apps/sidecar's full -// source, the workspace packages it transitively depends on, and a -// package.json-only stub for every other workspace member (needed for -// `bun install --frozen-lockfile` to resolve the whole workspace graph). -// `.data`, `.env*`, `.worktrees`, and `.git` are never read by the stager, -// so they cannot end up in the image regardless of what they contain. - -import { copyFileSync, mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { Template } from "e2b"; - -import { stageBuildContext } from "./build-context"; - -// oven/bun:1.3.9 pinned explicitly (not `latest` or a bare major/minor) so -// the image is reproducible across builds and matches the Bun version this -// workspace's `engines.bun` and lockfile were produced with. -const BUN_IMAGE_VERSION = "1.3.9"; - -/** The workspace whose dependency closure is the only one this image needs. */ -const SIDECAR_PACKAGE_NAME = "@workbench/sidecar"; - -export function createSidecarTemplate(repositoryRootValue = resolve(import.meta.dir, "../../..")) { - const repositoryRoot = resolve(repositoryRootValue); - const packageTemplateDir = resolve(import.meta.dir); - - const stagingDirectory = mkdtempSync(join(tmpdir(), "e2b-sidecar-context-")); - stageBuildContext(repositoryRoot, join(stagingDirectory, "repo")); - copyFileSync( - join(packageTemplateDir, "start-sidecar.ts"), - join(stagingDirectory, "start-sidecar.ts"), - ); - - return ( - Template({ - fileContextPath: stagingDirectory, - // Defense in depth only: stageBuildContext already never reads these - // paths, so this list should never have anything to match. - fileIgnorePatterns: [ - "**/.git/**", - "**/node_modules/**", - "**/.env", - "**/.env.*", - "**/.data/**", - "**/.worktrees/**", - "**/dist/**", - "**/tmp/**", - "**/coverage/**", - ], - }) - .fromBunImage(BUN_IMAGE_VERSION) - .setUser("root") - .makeDir(["/repo", "/opt/interchange-e2b"], { - user: "root", - mode: 0o755, - }) - .copy("repo/", "/repo/", { user: "root" }) - .copy("start-sidecar.ts", "/opt/interchange-e2b/start-sidecar.ts", { - user: "root", - mode: 0o500, - }) - .setWorkdir("/repo") - // Scoped to the sidecar's own workspace closure. Every workspace - // member ships a package.json stub so the lockfile resolves, but an - // unfiltered install then pulls the WHOLE monorepo's dependency graph - // -- the web app's frontend stack included -- which OOM-killed the - // build container at 2GB. `--filter` installs only what the sidecar - // needs, which is also all the image should ever carry. - .runCmd(`bun install --frozen-lockfile --filter=${SIDECAR_PACKAGE_NAME}`) - ); -} diff --git a/apps/sidecar-e2b/template/start-sidecar.ts b/apps/sidecar-e2b/template/start-sidecar.ts deleted file mode 100644 index beb1a26540..0000000000 --- a/apps/sidecar-e2b/template/start-sidecar.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Restructured from the private repo faremeter/interchange-e2b-provisioner -// (github.com/faremeter/interchange-e2b-provisioner) at commit c1e3182. We -// now own this code; it is not a vendored path. - -import { mkdir } from "node:fs/promises"; - -function requireEnv(name: string): string { - const value = process.env[name]; - if (value === undefined || value.trim() === "") { - throw new Error(`${name} environment variable is required`); - } - return value; -} - -const dataDir = requireEnv("SIDECAR_DATA_DIR"); -await mkdir(dataDir, { recursive: true, mode: 0o700 }); - -const child = Bun.spawn([process.execPath, "run", "/repo/apps/sidecar/src/index.ts"], { - cwd: "/repo", - env: process.env, - stdin: "ignore", - stdout: "inherit", - stderr: "inherit", -}); - -for (const signal of ["SIGTERM", "SIGINT"] as const) { - process.on(signal, () => child.kill(signal)); -} - -process.exitCode = await child.exited; diff --git a/apps/sidecar-e2b/tsconfig.json b/apps/sidecar-e2b/tsconfig.json deleted file mode 100644 index 8355721ee6..0000000000 --- a/apps/sidecar-e2b/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["template"], - "exclude": [], - "compilerOptions": { - "types": ["bun"], - "noEmit": true - } -} diff --git a/bun.lock b/bun.lock index 27a9756f69..3e7efa6aa7 100644 --- a/bun.lock +++ b/bun.lock @@ -91,7 +91,6 @@ "arktype": "catalog:", "better-auth": "catalog:", "drizzle-orm": "catalog:", - "e2b": "^2.36.1", "hono": "catalog:", "postgres": "catalog:", "simple-icons": "^16.28.0", @@ -135,21 +134,6 @@ "typescript": "catalog:", }, }, - "apps/sidecar-docker": { - "name": "@workbench/sidecar-docker", - "version": "0.0.1", - }, - "apps/sidecar-e2b": { - "name": "@workbench/sidecar-e2b", - "version": "0.0.1", - "dependencies": { - "e2b": "^2.36.1", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "apps/web": { "name": "@workbench/web", "version": "0.0.1", @@ -1165,14 +1149,8 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.15.0", "", {}, "sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA=="], - "@chat-adapter/state-memory": ["@chat-adapter/state-memory@4.40.0", "", { "dependencies": { "chat": "4.40.0" } }, "sha512-NmFFkvUfnDBDS/mkzrtKHdW757EGt0eX3OQH5IPxrKqhjSsNzpqUdkJ/uDB4BP0Rbex4oW/HgwhZ+JGONVbMBQ=="], - "@connectrpc/connect": ["@connectrpc/connect@2.2.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ=="], - - "@connectrpc/connect-web": ["@connectrpc/connect-web@2.2.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.2.0" } }, "sha512-uuBUcBTYfb/r1o98LzCCZk1H90yx0vmZ5m21dpw/q4EUwsKwkCDGI8H0aEdjcZld+Q2jJ5A5ADvPqfb6lzQ/sg=="], - "@corbits/access-tools": ["@corbits/access-tools@workspace:tools/access"], "@corbits/agent-directory": ["@corbits/agent-directory@workspace:packages/agent-directory"], @@ -1713,10 +1691,6 @@ "@workbench/sidecar": ["@workbench/sidecar@workspace:apps/sidecar"], - "@workbench/sidecar-docker": ["@workbench/sidecar-docker@workspace:apps/sidecar-docker"], - - "@workbench/sidecar-e2b": ["@workbench/sidecar-e2b@workspace:apps/sidecar-e2b"], - "@workbench/web": ["@workbench/web@workspace:apps/web"], "@workflow/serde": ["@workflow/serde@4.1.0-beta.2", "", {}, "sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww=="], @@ -1785,8 +1759,6 @@ "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "chat": ["chat@4.40.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" }, "peerDependencies": { "ai": "^6.0.182 || ^7.0.0", "workflow": "^5.0.0-beta.35", "zod": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["ai", "workflow", "zod"] }, "sha512-slu3VDxItlelEZ8A5vqzlmtT2WErqj2YCGuhhcNx+Ev0+wHeBUqUDUA7hXkca+BfFtW1ZX7ECcySCTG2q2gefg=="], @@ -1803,8 +1775,6 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -1849,16 +1819,12 @@ "diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="], - "dockerfile-ast": ["dockerfile-ast@0.7.1", "", { "dependencies": { "vscode-languageserver-textdocument": "^1.0.8", "vscode-languageserver-types": "^3.17.3" } }, "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw=="], - "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "e2b": ["e2b@2.50.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.14.1", "@connectrpc/connect": "^2.2.0", "@connectrpc/connect-web": "^2.2.0", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^13.0.6", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.22", "undici": "^7.29.1" }, "optionalDependencies": { "undici8": "npm:undici@8.10.2" } }, "sha512-XADlFhXGMvvWGPnyBiah6EFwNFBg1XIvdLInKhjMq/tuhlpN46Btj9BpuxwjvGenVJNZgimzn6Xj0XwttrMKjQ=="], - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "electron-to-chromium": ["electron-to-chromium@1.5.430", "", {}, "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ=="], @@ -2195,12 +2161,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="], - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], - "oxfmt": ["oxfmt@0.68.0", "", { "dependencies": { "tinypool": "2.1.2" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.68.0", "@oxfmt/binding-android-arm64": "0.68.0", "@oxfmt/binding-darwin-arm64": "0.68.0", "@oxfmt/binding-darwin-x64": "0.68.0", "@oxfmt/binding-freebsd-x64": "0.68.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.68.0", "@oxfmt/binding-linux-arm-musleabihf": "0.68.0", "@oxfmt/binding-linux-arm64-gnu": "0.68.0", "@oxfmt/binding-linux-arm64-musl": "0.68.0", "@oxfmt/binding-linux-ppc64-gnu": "0.68.0", "@oxfmt/binding-linux-riscv64-gnu": "0.68.0", "@oxfmt/binding-linux-riscv64-musl": "0.68.0", "@oxfmt/binding-linux-s390x-gnu": "0.68.0", "@oxfmt/binding-linux-x64-gnu": "0.68.0", "@oxfmt/binding-linux-x64-musl": "0.68.0", "@oxfmt/binding-openharmony-arm64": "0.68.0", "@oxfmt/binding-win32-arm64-msvc": "0.68.0", "@oxfmt/binding-win32-ia32-msvc": "0.68.0", "@oxfmt/binding-win32-x64-msvc": "0.68.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-Z0XMofcXCGUXbcpBHnWyUiX93BGiw1B+lcHNbQDWEtOhX06ewoFfu4zXkyiLhRrNnMq0twqXRHUcJetf+GsiQQ=="], "oxlint": ["oxlint@1.83.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.83.0", "@oxlint/binding-android-arm64": "1.83.0", "@oxlint/binding-darwin-arm64": "1.83.0", "@oxlint/binding-darwin-x64": "1.83.0", "@oxlint/binding-freebsd-x64": "1.83.0", "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", "@oxlint/binding-linux-arm-musleabihf": "1.83.0", "@oxlint/binding-linux-arm64-gnu": "1.83.0", "@oxlint/binding-linux-arm64-musl": "1.83.0", "@oxlint/binding-linux-ppc64-gnu": "1.83.0", "@oxlint/binding-linux-riscv64-gnu": "1.83.0", "@oxlint/binding-linux-riscv64-musl": "1.83.0", "@oxlint/binding-linux-s390x-gnu": "1.83.0", "@oxlint/binding-linux-x64-gnu": "1.83.0", "@oxlint/binding-linux-x64-musl": "1.83.0", "@oxlint/binding-openharmony-arm64": "1.83.0", "@oxlint/binding-win32-arm64-msvc": "1.83.0", "@oxlint/binding-win32-ia32-msvc": "1.83.0", "@oxlint/binding-win32-x64-msvc": "1.83.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ=="], @@ -2225,8 +2187,6 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], @@ -2373,12 +2333,8 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], - "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - "undici8": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], - "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], @@ -2409,8 +2365,6 @@ "vscode-jsonrpc": ["vscode-jsonrpc@9.0.2", "", {}, "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ=="], - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.14", "", {}, "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ=="], - "vscode-languageserver-types": ["vscode-languageserver-types@3.18.3", "", {}, "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw=="], "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.3", "", {}, "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig=="], diff --git a/docs/sidecar-provisioners.md b/docs/sidecar-provisioners.md deleted file mode 100644 index ab13a798bb..0000000000 --- a/docs/sidecar-provisioners.md +++ /dev/null @@ -1,66 +0,0 @@ -# Sidecar provisioners - -A **sidecar** is the execution host a tenant's agents and workflow runs -actually run on. A **provisioner** is the backend that creates and -destroys one — Interchange's `SidecarProvisioner` contract. - -## The shipped backends - -| id | Where a sidecar runs | Isolation | Requires | Module | -| --------- | ---------------------------------------- | ----------------------------------------------- | -------------------------------------------------------- | ----------------------------------------- | -| `process` | A child process of the hub, same host | Process only — shared kernel, filesystem, user | nothing | `apps/hub/src/provisioners/process.ts` | -| `docker` | A container on the hub's host | Container — own filesystem and network namespace | `DOCKER_PROVISIONER_IMAGE`, a local `docker` CLI | `apps/hub/src/provisioners/docker.ts` | -| `e2b` | A remote [E2B](https://e2b.dev) sandbox | VM — separate machine | `E2B_API_KEY`, `E2B_TEMPLATE`, a publicly reachable hub | `apps/hub/src/provisioners/e2b.ts` | - -The docker backend's sidecar image builds from `apps/sidecar-docker/`; the -e2b backend's sandbox template builds from `apps/sidecar-e2b/template/`. -Both are asset-only apps the hub does not import — `buildSidecarProvisioner` -in `apps/hub/src/server.ts` imports the provisioner logic straight from -`apps/hub/src/provisioners/`. - -`process` is the default: with `SIDECAR_PROVISIONERS` unset, the hub -registers it alone, so one server runs many tenants with no operator -configuration. Pick `docker` or `e2b` when a sidecar runs untrusted code -or needs its own filesystem and network. - -## Configuring it - -```sh -# Register a set of backends. Unset means exactly `process`. -SIDECAR_PROVISIONERS=process,docker -# Which of them exclusive placements provision on. Optional when the list -# has one id; required when it has more. -SIDECAR_DEFAULT_PROVISIONER=docker -``` - -`SIDECAR_PROVISIONERS` replaces the default rather than adding to it — -list `process` explicitly to keep it alongside another backend. An -unknown id, a missing backend setting, or a default naming an unlisted id -all fail boot with a message naming the variable. Per-backend settings -are documented in `.env.example` and `apps/hub/src/config.ts`. - -### Capability matching - -Interchange selects a provisioner by matching a deployment's required -capabilities against what each registered backend declares, failing -closed when nothing matches. The shipped backends form a ladder -(`isolation:process` → `isolation:container` → `isolation:vm`): each -reaches its own rung and below, never above. - -| Declaration | `process` | `docker` | `e2b` | -| ---------------------- | ----------- | ----------- | --------- | -| `isolation:process` | available | available | available | -| `isolation:container` | blocked | available | available | -| `isolation:vm` | blocked | blocked | available | - -## Adding a backend - -1. Implement a `SidecarBackend` (copy `apps/hub/src/provisioners/process.ts`) - — idempotence, generation fencing, and destroy tombstones come from - `apps/hub/src/provisioners/sandbox-sidecar.ts`'s shared core. -2. Add a case to `buildSidecarProvisioner` in `apps/hub/src/server.ts`, - with its own required settings parsed there. -3. If the backend needs its own image or template assets, add them to a - new `apps/sidecar-/` app; the hub itself never imports it. - -No other hub surgery is needed. diff --git a/package.json b/package.json index cf52b61a5a..308d7e6292 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,7 @@ "publish-tools": "bun scripts/publish-tools.ts", "setup": "bun scripts/db-setup.ts", "setup:memory": "bun run scripts/setup-memory.ts", - "reset": "bun scripts/reset.ts", - "build:sidecar-image": "docker build -f apps/sidecar-docker/Dockerfile -t corbits-sidecar:dev ." + "reset": "bun scripts/reset.ts" }, "devDependencies": { "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#5309a15d4aa504f132c54b4da08bb672b4903873",