Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,12 @@
"@corbits/connections": "workspace:*",
"@corbits/credential-providers": "workspace:*",
"@corbits/cron": "workspace:*",
"@corbits/docker-provisioner": "workspace:*",
"@corbits/e2b-sandbox-sidecar": "workspace:*",
"@corbits/error-sink": "workspace:*",
"@corbits/hub-api-client": "workspace:*",
"@corbits/inference-catalog": "workspace:*",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#e85b81e4a2e0cc318dd33a4f0f92337b6fac7f38",
"@corbits/mcp-tools": "workspace:*",
"@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913",
"@corbits/process-provisioner": "workspace:*",
"@corbits/skills": "workspace:*",
"@corbits/tool-registry-publish": "workspace:*",
"@corbits/url-path": "workspace:*",
Expand All @@ -51,6 +48,7 @@
"arktype": "catalog:",
"better-auth": "catalog:",
"drizzle-orm": "catalog:",
"e2b": "^2.36.1",
"hono": "catalog:",
"postgres": "catalog:",
"simple-icons": "^16.28.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";

import { createFakeCommandRunner } from "./fake-command-runner";
import { createDockerSidecarProvisioner } from "./interchange-plugin";
import { createAllocationStateStore } from "@corbits/sandbox-sidecar";
import { createDockerSidecarProvisioner } from "./docker";
import { createAllocationStateStore } from "./sandbox-sidecar";

const IMAGE = "ghcr.io/corbits/sidecar:latest";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";

import { createFakeCommandRunner, type FakeCommandRunner } from "./fake-command-runner";
import { createDockerSidecarProvisioner } from "./interchange-plugin";
import { createAllocationStateStore, type AllocationStateStore } from "@corbits/sandbox-sidecar";
import { createDockerSidecarProvisioner } from "./docker";
import { createAllocationStateStore, type AllocationStateStore } from "./sandbox-sidecar";

const IMAGE = "ghcr.io/corbits/sidecar:latest";
const TOKEN = "s3cr3t-bootstrap-token";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,75 @@
import { resolve } from "node:path";

import { type } from "arktype";

import {
BackendOperationError,
createAllocationStateStore,
createSidecarProvisioner,
sidecarCapabilityDeclarations,
type AllocationStateStore,
type SidecarBackend,
type StartUnitArgs,
} from "@corbits/sandbox-sidecar";
} 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";

import type { CommandRunner } from "./command-runner";
import type { DockerProvisionerConfig } from "./config";
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<string, string | undefined>,
): 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<CommandResult>;
}

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";
Expand All @@ -19,11 +83,11 @@ const SIDECAR_ID_LABEL = "corbits.sidecarId";
const CONTAINER_SIDECAR_DATA_DIR = "/home/sidecar/interchange-sidecar-data";

/**
* Implements @corbits/sandbox-sidecar's SidecarBackend port against the
* local `docker` CLI: `docker run` starts a unit, `docker stop`/`docker rm`
* 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.
* 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,
Expand Down Expand Up @@ -104,3 +168,32 @@ async function removeContainer(
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<string, string | undefined>;
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,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { describe, expect, test } from "bun:test";

import { readProvisionerConfig } from "./config";
import { readProvisionerConfig } from "./e2b";

describe("E2B provisioner configuration", () => {
test("reads the required boundary configuration", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
// (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 "@corbits/sandbox-sidecar";
import { createAllocationStateStore } from "./sandbox-sidecar";

import type { ProvisionerConfig } from "./config";
import { createSidecarProvisioner } from "./interchange-plugin";
import { createSidecarProvisioner, type ProvisionerConfig } from "./e2b";

describe("Interchange plugin adapter", () => {
test("exposes a stable versioned binding", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
// The E2B specifics originated 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.
//
// Implements @corbits/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 now live in
// the shared core — this file only ever talks to E2B.
// 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,
Expand All @@ -15,14 +11,76 @@ import {
SandboxNotFoundError,
TimeoutError,
} from "e2b";
import { type } from "arktype";

import {
BackendOperationError,
createAllocationStateStore,
createSidecarProvisioner as createCoreSidecarProvisioner,
sidecarCapabilityDeclarations,
type AllocationStateStore,
type SidecarBackend,
type StartUnitArgs,
} from "@corbits/sandbox-sidecar";
} 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;
};

import type { ProvisionerConfig } from "./config";
/**
* `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<string, string | undefined>,
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";
Expand All @@ -33,6 +91,13 @@ export type SandboxOperationFailure = {
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,
Expand Down Expand Up @@ -168,3 +233,40 @@ export function classifyE2BError(error: unknown): SandboxOperationFailure {
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<string, string | undefined>;
/** 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`),
});
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CommandResult, CommandRunner } from "./command-runner";
import type { CommandResult, CommandRunner } from "./docker";

export type FakeCommandRunner = CommandRunner & {
readonly calls: (readonly string[])[];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SidecarProcessRunner, SpawnSidecarProcessArgs } from "./process-runner";
import type { SidecarProcessRunner, SpawnSidecarProcessArgs } from "./process";

export type FakeSidecarProcessRunner = SidecarProcessRunner & {
readonly spawns: SpawnSidecarProcessArgs[];
Expand Down
Loading
Loading