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
56 changes: 40 additions & 16 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -313,28 +313,52 @@ ALLOW_UNVERIFIED_EMAILS=1
# SLACK_WORKBENCH_TENANT_SLUG=
# SLACK_DEFAULT_AGENT_DEFINITION_ID=

# Exclusive per-workbench sidecar placement (CL-6096, CL-6283). Leave unset
# (the default) to keep every workbench on the hub's current single shared
# sidecar, with no exclusive-placement backend registered — a workbench's
# "run this workbench on its own sidecar" setting still saves, but exclusive
# deployments fail closed until a provisioner is configured here.
# Exclusive per-workbench sidecar placement (CL-6096, CL-6283, CL-6534).
# Leave unset (the default) and the hub registers the "process" backend
# alone: each exclusive sidecar runs as one apps/sidecar child process on
# the hub's own host, so a single server hosts many chats and workflows
# with no configuration, no container runtime, and no sandbox account.
#
# SIDECAR_PROVISIONERS is a comma-separated list of backend ids to register,
# e.g. "docker" (each id has its own required settings below it — "docker"
# needs DOCKER_PROVISIONER_IMAGE). SIDECAR_DEFAULT_PROVISIONER picks which
# listed id exclusive placements provision on; optional when exactly one id
# is listed, required when listing more than one.
# SIDECAR_PROVISIONERS=docker
# SIDECAR_DEFAULT_PROVISIONER=docker
# SIDECAR_PROVISIONERS is a comma-separated list of backend ids to
# register. Setting it replaces the default entirely — list "process" too
# if you want it alongside another backend. Supported ids:
#
# process same host, one child process per allocation (the default).
# No required settings. Process isolation only: sidecars share
# the hub's kernel, filesystem, and user account.
# docker one container per allocation via the local docker CLI.
# Requires DOCKER_PROVISIONER_IMAGE.
# e2b one remote E2B sandbox per allocation. Requires E2B_API_KEY
# and E2B_TEMPLATE (see packages/e2b-sandbox-sidecar/README.md).
#
# SIDECAR_DEFAULT_PROVISIONER picks which listed id exclusive placements
# provision on; optional when exactly one id is listed, required when
# listing more than one.
#
# Adding another backend is three steps, no hub surgery: implement
# SidecarProvisioner in its own package (copy packages/process-provisioner
# — the allocation lifecycle itself comes from @corbits/sandbox-sidecar),
# add its id to SIDECAR_PROVISIONER_IDS in apps/hub/src/config.ts, and add
# a case to buildSidecarProvisioner in apps/hub/src/index.ts.
# SIDECAR_PROVISIONERS=process
# SIDECAR_DEFAULT_PROVISIONER=process
# DOCKER_PROVISIONER_IMAGE=

# Overrides for the "process" backend, both optional. Unset, it spawns
# this repository's own apps/sidecar/src/index.ts with the bun binary
# running the hub — which is why the default install needs no settings.
# Set these to run a sidecar build from somewhere else on the host.
# PROCESS_PROVISIONER_SIDECAR_ENTRY=/opt/workbench/apps/sidecar/src/index.ts
# PROCESS_PROVISIONER_RUNTIME=/usr/local/bin/bun

# HUB_SIDECAR_WEBSOCKET_URL overrides the ws(s):// address a provisioned
# sidecar dials back to reach this hub. Leave unset to derive it from
# BASE_URL — fine when the hub and its sidecars can reach each other at
# that address. Set this explicitly whenever SIDECAR_PROVISIONERS includes
# "docker": a docker sidecar container's own "localhost" is itself, not the
# hub host, so BASE_URL's derived ws:// URL cannot reach the hub from
# inside it.
# that address, which is always true for the "process" backend (a child
# process on the hub's own host). Set this explicitly whenever
# SIDECAR_PROVISIONERS includes "docker" or "e2b": a container's own
# "localhost" is itself, not the hub host, and an E2B sandbox is a remote
# machine that needs a publicly reachable wss:// URL or tunnel.
# HUB_SIDECAR_WEBSOCKET_URL=

# SIDECAR_ADAPTER_MANIFEST configures custom Interchange inference adapters
Expand Down
8 changes: 8 additions & 0 deletions IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ Deployment is explicit via **Pulumi**, targeting **Railway**. CI runs
tests only — nothing auto-deploys on `main`; a deploy is a deliberate,
separate action.

**Where sidecars run** is one variable, `SIDECAR_PROVISIONERS`. Unset —
the default — the hub registers the `process` backend alone and runs each
exclusive sidecar as a child process on its own host, so a single-server
install works with no configuration. `docker` and `e2b` are the other
shipped backends. See
[docs/sidecar-provisioners.md](docs/sidecar-provisioners.md) for the
comparison, the required settings per backend, and how to add another.

## Workbench Definition (shipped)

`WorkbenchDefinition` (`WorkbenchDefinitionSchema` in
Expand Down
1 change: 1 addition & 0 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@corbits/notify": "workspace:*",
"@corbits/preferences": "workspace:*",
"@corbits/presence": "workspace:*",
"@corbits/process-provisioner": "workspace:*",
"@corbits/run-key-history": "workspace:*",
"@corbits/run-scope": "workspace:*",
"@corbits/sidecar-placement": "workspace:*",
Expand Down
60 changes: 46 additions & 14 deletions apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,17 @@ const HubEnv = type({
"opt-in to initialize hub git-on-disk state inside a directory that is already a git work tree; refused by default because a nested init that misses its own .git walks up and commits onto the enclosing working branch",
),
"SIDECAR_PROVISIONERS?": type("string").describe(
"comma-separated sidecar-allocation backend ids to register for workbenches placed on their own exclusive sidecar, e.g. 'docker'; unset or empty (default) keeps the hub on its current single shared sidecar with no exclusive-placement backend available",
"comma-separated sidecar-allocation backend ids to register for workbenches placed on their own exclusive sidecar: 'process' (a child process on this host), 'docker', or 'e2b'; unset or empty (default) registers 'process' alone, so a single-server install provisions exclusive sidecars with no operator configuration",
),
"SIDECAR_DEFAULT_PROVISIONER?": type("string > 0").describe(
"which id listed in SIDECAR_PROVISIONERS is the default backend exclusive placements provision on; required when SIDECAR_PROVISIONERS lists more than one id, optional (defaults to that one id) when it lists exactly one",
),
"PROCESS_PROVISIONER_SIDECAR_ENTRY?": type("string > 0").describe(
"the sidecar entry point the process backend spawns for each exclusive allocation; unset (default) resolves this repository's own apps/sidecar/src/index.ts",
),
"PROCESS_PROVISIONER_RUNTIME?": type("string > 0").describe(
"the executable the process backend runs that entry point with; unset (default) reuses the bun binary running the hub",
),
"E2B_API_KEY?": type("string > 0").describe(
"the E2B API key the e2b sidecar provisioner authenticates with; required when SIDECAR_PROVISIONERS includes 'e2b'",
),
Expand Down Expand Up @@ -304,10 +310,22 @@ export type E2BSidecarProvisionerConfig = {
readonly sandboxTimeoutMs?: string;
};

/** The default backend: one `apps/sidecar` child process per allocation
* on this host. It needs no operator settings at all — an unconfigured
* install gets it — and both env keys below are overrides for an operator
* running a sidecar build from somewhere other than this repository. */
export type ProcessSidecarProvisionerConfig = {
readonly id: "process";
readonly sidecarEntryPath?: string;
readonly runtimePath?: string;
};

export type SidecarProvisionerConfig =
DockerSidecarProvisionerConfig | E2BSidecarProvisionerConfig;
| ProcessSidecarProvisionerConfig
| DockerSidecarProvisionerConfig
| E2BSidecarProvisionerConfig;

const SIDECAR_PROVISIONER_IDS = ["docker", "e2b"] as const;
const SIDECAR_PROVISIONER_IDS = ["process", "docker", "e2b"] as const;
type SidecarProvisionerId = (typeof SIDECAR_PROVISIONER_IDS)[number];

function isSidecarProvisionerId(value: string): value is SidecarProvisionerId {
Expand Down Expand Up @@ -368,12 +386,11 @@ export type HubConfig = {
* production cadence (`routine-scheduler.ts`'s own default). */
readonly routineSchedulerPollIntervalMs?: number;
/** Every sidecar-allocation backend registered for exclusive placement,
* zero or more, each addressable by its provisioner id. Empty when
* unconfigured — the registry then holds no provisioners and every
* exclusive placement fails closed at deployment time. */
* one or more, each addressable by its provisioner id. Never empty: an
* install that configures nothing registers the `process` backend, so
* exclusive placement works on a single server out of the box. */
readonly sidecarProvisioners: readonly SidecarProvisionerConfig[];
/** Which registered id exclusive placements provision on. Undefined
* exactly when `sidecarProvisioners` is empty. */
/** Which registered id exclusive placements provision on. */
readonly defaultSidecarProvisionerId?: string;
/** Overrides the ws(s):// URL a provisioned sidecar dials back to reach
* this hub. Unset derives it from baseUrl instead. */
Expand Down Expand Up @@ -470,11 +487,13 @@ type SidecarProvisionersConfig = {
* backend's own required settings (e.g. `DOCKER_PROVISIONER_IMAGE` for
* `docker`) must be present, and an unknown id, a duplicate id, or a
* `SIDECAR_DEFAULT_PROVISIONER` naming a backend that isn't listed all
* fail boot loudly — never silently falling back to the no-provisioner
* default. With `SIDECAR_PROVISIONERS` unset or empty, this returns no
* provisioners and no default, so every exclusive placement fails
* closed at deployment time rather than falling back to the shared
* sidecar.
* fail boot loudly. With `SIDECAR_PROVISIONERS` unset or empty, the
* `process` backend is registered as the sole default: an operator who
* configures nothing gets exclusive sidecar placement on the hub's own
* host, which is what makes a single-server install work with many
* chats and workflows on one VPS. Choosing where sidecars run is then
* one variable — `SIDECAR_PROVISIONERS` — and listing it explicitly
* (including `process`) behaves exactly as any other explicit list.
*/
function sidecarProvisionersFrom(
parsed: ParsedHubEnv,
Expand All @@ -493,7 +512,10 @@ function sidecarProvisionersFrom(
].join("\n"),
);
}
return { provisioners: [] };
return {
provisioners: [sidecarProvisionerConfigFor("process", parsed)],
defaultProvisionerId: "process",
};
}

const seen = new Set<string>();
Expand Down Expand Up @@ -547,6 +569,16 @@ function sidecarProvisionerConfigFor(
parsed: ParsedHubEnv,
): SidecarProvisionerConfig {
switch (id) {
case "process":
return {
id: "process",
...(parsed.PROCESS_PROVISIONER_SIDECAR_ENTRY === undefined
? {}
: { sidecarEntryPath: parsed.PROCESS_PROVISIONER_SIDECAR_ENTRY }),
...(parsed.PROCESS_PROVISIONER_RUNTIME === undefined
? {}
: { runtimePath: parsed.PROCESS_PROVISIONER_RUNTIME }),
};
case "docker": {
if (parsed.DOCKER_PROVISIONER_IMAGE === undefined) {
throw new Error(
Expand Down
72 changes: 56 additions & 16 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ import {
createSessionService,
createSidecarAllocationReconciler,
createSidecarPluginRegistry,
createSidecarCredentialResolver,
createSidecarRouter,
createSidecarTokenAuthenticator,
createWorkflowAllocationService,
createWorkflowDispatchService,
DEFAULT_ASSET_REF,
Expand Down Expand Up @@ -251,6 +251,10 @@ import {
createTenantGrantLister,
} from "./grant-allowance";
import { createDockerSidecarProvisioner } from "@corbits/docker-provisioner";
import {
createProcessSidecarProvisioner,
readProcessProvisionerConfig,
} from "@corbits/process-provisioner";
import { getArtifact, writeArtifactVersion } from "@corbits/artifacts";
import {
createArtifactDbStore,
Expand Down Expand Up @@ -527,8 +531,30 @@ export function hubCredentialCipher(
function buildSidecarProvisioner(
config: SidecarProvisionerConfig,
hubDataDir: string,
hubWebSocketUrl: string,
): SidecarProvisioner {
switch (config.id) {
case "process":
// Same derivation as the other two backends: the hub-side
// allocation state lives under the hub's own data dir, and so do
// the per-allocation directories each spawned sidecar uses as its
// own SIDECAR_DATA_DIR.
return createProcessSidecarProvisioner({
config: readProcessProvisionerConfig({
env: {
...(config.sidecarEntryPath === undefined
? {}
: {
PROCESS_PROVISIONER_SIDECAR_ENTRY: config.sidecarEntryPath,
}),
...(config.runtimePath === undefined
? {}
: { PROCESS_PROVISIONER_RUNTIME: config.runtimePath }),
},
dataDir: path.resolve(hubDataDir, "process-provisioner"),
hubWebSocketUrl,
}),
});
case "docker":
return createDockerSidecarProvisioner({
config: {
Expand Down Expand Up @@ -780,9 +806,20 @@ export async function createHub(config: HubConfig) {
)) !== null;
const pinnedPackageCredentialBindingsFor =
createPinnedPackageCredentialBindingsFor(isConnectorConnected);
// One resolver serves both seams, exactly as @intx/hub-sessions's own
// reference host wires them: `resolve` turns a presented bearer token
// into a verified identity at the handshake, and `isCurrent`
// revalidates that identity at the registration, readiness, and
// routing boundaries. Without the second one the router falls back to
// its always-true default, and a provisioner-issued token stays
// accepted after its allocation was superseded or destroyed — which a
// process-provisioned sidecar reaches easily, since a child that
// outlives its allocation keeps reconnecting to the same hub.
const sidecarCredentials = createSidecarCredentialResolver({ db });
const sidecarRouter = createSidecarRouter({
hubPublicKey,
authenticateSidecar: createSidecarTokenAuthenticator({ db }),
authenticateSidecar: async ({ token }) => sidecarCredentials.resolve(token),
validateSidecarIdentity: sidecarCredentials.isCurrent,
lookups,
});
// A finalized turn's persisted-artifact tool-call results become
Expand Down Expand Up @@ -946,20 +983,26 @@ export async function createHub(config: HubConfig) {
}),
workflowDeploySourceStore,
);
const hubWebSocketUrl =
config.sidecarWebSocketUrl ??
`${config.baseUrl.replace(/^http/, "ws")}/api/sidecars/ws`;
// Provisioner plugins are injected at the application composition
// boundary, mirroring @intx/hub-sessions's own reference wiring: the
// registry always exists, but ships with no provisioners (and no
// default) until SIDECAR_PROVISIONERS names one or more builds. A
// workbench's "run this workbench on its own sidecar" setting can then
// always write a tenant's exclusive `sidecarPlacement`; without any
// configured provisioner that placement simply fails closed at
// deployment time rather than silently falling back to the shared
// sidecar. Adding a new backend here is: implement `SidecarProvisioner`
// in its own package, add a case to `buildSidecarProvisioner`, and add
// its id to `apps/hub/src/config.ts`'s `SIDECAR_PROVISIONER_IDS`.
// boundary, mirroring @intx/hub-sessions's own reference wiring. An
// install that configures nothing registers the `process` backend
// (`@corbits/process-provisioner`) as the sole default, so a
// workbench's "run this workbench on its own sidecar" setting works on
// one server with no operator setup; `SIDECAR_PROVISIONERS` is the one
// variable that changes where sidecars run. Adding a new backend here
// is: implement `SidecarProvisioner` in its own package, add a case to
// `buildSidecarProvisioner`, and add its id to
// `apps/hub/src/config.ts`'s `SIDECAR_PROVISIONER_IDS`.
const sidecarPlugins = createSidecarPluginRegistry({
provisioners: config.sidecarProvisioners.map((provisionerConfig) =>
buildSidecarProvisioner(provisionerConfig, config.hubDataDir),
buildSidecarProvisioner(
provisionerConfig,
config.hubDataDir,
hubWebSocketUrl,
),
),
...(config.defaultSidecarProvisionerId !== undefined
? { defaultProvisionerId: config.defaultSidecarProvisionerId }
Expand All @@ -985,9 +1028,6 @@ export async function createHub(config: HubConfig) {
return row?.address ?? null;
},
});
const hubWebSocketUrl =
config.sidecarWebSocketUrl ??
`${config.baseUrl.replace(/^http/, "ws")}/api/sidecars/ws`;
const sidecarAllocationReconciler = createSidecarAllocationReconciler({
allocationStore: sidecarAllocationStore,
plugins: sidecarPlugins,
Expand Down
44 changes: 40 additions & 4 deletions apps/hub/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ describe("readHubConfig", () => {
signInRateLimit: { windowSeconds: 60, max: 10 },
allowPlaintextSecrets: false,
allowUnverifiedEmails: false,
sidecarProvisioners: [],
sidecarProvisioners: [{ id: "process" }],
defaultSidecarProvisionerId: "process",
envProviderKeys: {},
envProviderBaseUrls: {},
envCredentialPlantAdmin: {
Expand Down Expand Up @@ -442,10 +443,45 @@ describe("readHubConfig", () => {
});

describe("sidecarProvisioners", () => {
test("defaults to empty with no default id, matching the current static-sidecar behavior", () => {
test("an unconfigured install registers the process backend as its sole default", () => {
const config = readHubConfig(validEnv);
expect(config.sidecarProvisioners).toEqual([]);
expect(config.defaultSidecarProvisionerId).toBeUndefined();
expect(config.sidecarProvisioners).toEqual([{ id: "process" }]);
expect(config.defaultSidecarProvisionerId).toBe("process");
});

test("the process backend's optional overrides reach its config", () => {
const config = readHubConfig({
...validEnv,
PROCESS_PROVISIONER_SIDECAR_ENTRY: "/opt/sidecar/index.js",
PROCESS_PROVISIONER_RUNTIME: "/usr/bin/node",
});
expect(config.sidecarProvisioners).toEqual([
{
id: "process",
sidecarEntryPath: "/opt/sidecar/index.js",
runtimePath: "/usr/bin/node",
},
]);
});

test("process is a listable id like any other backend", () => {
const config = readHubConfig({
...validEnv,
SIDECAR_PROVISIONERS: "process",
});
expect(config.sidecarProvisioners).toEqual([{ id: "process" }]);
expect(config.defaultSidecarProvisionerId).toBe("process");
});

test("an explicit list without process does not get the default backend", () => {
const config = readHubConfig({
...validEnv,
SIDECAR_PROVISIONERS: "docker",
DOCKER_PROVISIONER_IMAGE: "ghcr.io/corbits/sidecar:latest",
});
expect(config.sidecarProvisioners.map((one) => one.id)).toEqual([
"docker",
]);
});

test("SIDECAR_PROVISIONERS=docker with an image is wired and becomes the default", () => {
Expand Down
Loading
Loading