Skip to content

Add a process sidecar provisioner as the default backend (CL-6534) - #637

Merged
TheGreatAxios merged 4 commits into
mainfrom
cl-6534-local-sidecar-provisioner
Sep 4, 2026
Merged

TheGreatAxios merged 4 commits into
mainfrom
cl-6534-local-sidecar-provisioner

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Step 1 of CL-6534.

Why

Workbench is going fully native on Interchange: the UI becomes an
Interchange client and the backend becomes mostly stock Interchange, with
custom backend machinery removed. Two constraints shape this step:

  • It must stay easy for an operator to decide what kinds of sidecars to
    rig up and how to structure them.
  • The default install, with no provisioner configured, must still work
    on a single server
    — many chats and workflows on the same VPS.

Today an unconfigured hub registers no sidecar provisioner, so exclusive
sidecar placement fails closed until an operator sets up Docker or an E2B
account. That is the wrong default for a single-server install, and it is
also the shape the later steps (retiring folded runs) need in place first:
every run becomes an allocation, and an allocation needs a backend that
exists out of the box.

What this does

Adds packages/process-provisioner — a SidecarProvisioner whose
ensure spawns one apps/sidecar process per allocation on the hub's own
host, mirroring the env bun run dev gives the local sidecar: the hub
WebSocket URL, the allocation's provisioner-issued bearer token, its
sidecar id, and a per-allocation data directory under HUB_DATA_DIR.
destroy sends SIGTERM (escalating to SIGKILL after a grace period) and
removes that directory.

It supplies only the OS-level unit. Idempotence, generation fencing,
destroy tombstones, and obsolete-unit sweeping come from
@corbits/sandbox-sidecar's shared core — the same core docker and e2b
use. A unit is scoped by generation on disk
(allocations/<id>/gen-<n>/sidecar.pid), which is what keeps the sweep
honest across a hub restart: a new generation's pid never overwrites the
record of the unit it replaces, so a superseded process is still found and
stopped. Binding fingerprint is process:v1:<sidecar entry>:<hub ws url>.
Every OS failure path reports through reportError with the allocation id
and returns a classified rejection carrying the refId.

Registers it as the default. With SIDECAR_PROVISIONERS unset the hub
registers process as its sole backend and default. With it set, behaviour
is exactly as before — explicit ids, the SIDECAR_DEFAULT_PROVISIONER
rules, loud boot failures for an unknown/duplicate id or a missing backend
setting — with process accepted as an id like any other.

Wires the sidecar identity validator. createSidecarRouter's
validateSidecarIdentity was never supplied, so it fell back to the
router's always-true default. It is now the same
createSidecarCredentialResolver({ db }) that authenticates the handshake:
resolve for the token, isCurrent for the registration, readiness, and
routing boundaries — exactly how upstream's own apps/hub/src/server.ts
wires it. Without it a provisioner-issued token stays accepted after its
allocation was superseded or destroyed, which a process-provisioned sidecar
reaches easily: a child that outlives its allocation keeps reconnecting to
the same hub.

How to configure

# Nothing set: `process` alone, on this host. This is the default.

# Or name backends explicitly (this replaces the default; list `process`
# too if you want it alongside another):
SIDECAR_PROVISIONERS=process,docker
SIDECAR_DEFAULT_PROVISIONER=docker
DOCKER_PROVISIONER_IMAGE=ghcr.io/...

# Optional overrides for the process backend — unset, it spawns this
# repository's apps/sidecar/src/index.ts with the bun running the hub:
PROCESS_PROVISIONER_SIDECAR_ENTRY=/opt/workbench/apps/sidecar/src/index.ts
PROCESS_PROVISIONER_RUNTIME=/usr/local/bin/bun

docs/sidecar-provisioners.md is the one page an operator reads: the three
shipped backends side by side (isolation, requirements, where each keeps
its hub-side state) and the three steps to add another. .env.example
carries the same set against SIDECAR_PROVISIONERS, and
IMPLEMENTATION.md's deployment section points at the doc.

Verification

  • packages/process-provisioner unit suite: spawn injected, so no real
    process starts. Asserts the spawned env, idempotent ensure, stale
    generation rejection, a newer generation superseding and stopping the
    old process, spawn failure classification, destroy cleanup and
    tombstone fencing, and allocation-id validation.
  • apps/hub/test/config.test.ts covers the new default, process as an
    explicit id, its overrides, and that an explicit list without process
    does not get it.
  • bun run typecheck, bun run lint, bun run check:structural: green.
  • DB-gated suites run locally against Postgres (apps/hub/test/composition.test.ts,
    packages/sidecar-placement): green — the hub boots with the new default
    backend and the identity validator wired.
  • No end-to-end harness boots the hub with a provisioner and dials a real
    sidecar back, so the live check is the browser walkthrough:
    bun run dev, then bun run scripts/e2e/browser/walkthrough.ts.

Notes

Upstream's SidecarProvisioner at the #632 target pin (692c3106) adds a
capabilities: readonly SidecarCapabilityDeclaration[] member and an
optional-externalRef clarification that main's vendored contract does not
have. This provisioner is built against what main has; declaring
capabilities lands with the #632 re-pin, alongside the same addition for
the docker and e2b backends. Nothing is shimmed for it here.

Part of CL-6534

…L-6534)

Covers the provisioner's spawn env, idempotent ensure, generation
fencing, destroy cleanup, and allocation-id validation against an
injected process runner, plus the hub config's new default: an
unconfigured install registers the process backend alone.
Adds @corbits/process-provisioner: a SidecarProvisioner whose ensure
spawns one apps/sidecar process per allocation on the hub host, with the
allocation's own token, hub WebSocket URL, and a per-generation data
directory under HUB_DATA_DIR; destroy signals the process and removes
that directory. Idempotence, generation fencing, and obsolete-unit
sweeping come from @corbits/sandbox-sidecar's shared core, as they do for
the docker and e2b backends; a unit is scoped by generation so a
superseded process is still found and stopped after a hub restart.

The hub registers it as the sole backend when SIDECAR_PROVISIONERS is
unset, so an install that configures nothing provisions exclusive
sidecars on a single server. An explicit list behaves exactly as before,
with 'process' accepted as an id.

Also wires the sidecar identity validator the router leaves defaulted to
always-true: one createSidecarCredentialResolver now serves both seams,
resolving a presented token at the handshake and revalidating that
identity at the registration, readiness, and routing boundaries. Without
it a provisioner-issued token stays accepted after its allocation was
superseded or destroyed.
Adds docs/sidecar-provisioners.md as the one page comparing the shipped
backends — process (default, same host), docker, e2b — with what each
requires, where each keeps its hub-side state, and the three steps to add
another. .env.example now documents the same set against
SIDECAR_PROVISIONERS, and IMPLEMENTATION.md's deployment section points
at it.
@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown

CL-6534

The workbench hub now always has a backend registered; the guard stays
because this package is a library a host with none can still consume.
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Self-review

No defects found; this is ready as-is. The process backend, its idempotence/generation-fencing tests, the identity wiring, and the docs all hold up under a close read.

  • DISMISSED — dev auth regression risk: createSidecarCredentialResolver's isCurrent only gates the "allocated" credential kind (rows created by a provisioner). scripts/dev.ts's ensureSidecarIdentity inserts the dev sidecar's row with no credentialScope, which defaults to "shared" (vendor/intx/db/src/schema/sidecar.ts), and isCurrent for "shared" only checks that no allocation row exists for that sidecar id — unaffected by this PR. bun run dev's hand-started sidecar still authenticates.
  • DISMISSED — pid reuse after a hub crash: acknowledged and documented in packages/process-provisioner/README.md's Trade-offs section; not a regression this PR introduces, and the generation-scoped pid file plus hub reconciliation on boot bound the risk.
  • DISMISSED — env passed to spawned child: sidecarEnvFor in process-backend.ts sends only SIDECAR_DATA_DIR, HUB_WS_URL, SIDECAR_ID, SIDECAR_TOKEN, PATH, and optional HOME/TMPDIR — no DATABASE_URL/CREDENTIAL_ENCRYPTION_KEY leak, matches apps/sidecar/src/config.ts's requirements.
  • DISMISSED — allocation id / path traversal: ALLOCATION_ID_PATTERN = /^[A-Za-z0-9._-]+$/ rejects ../escape-style ids before any path join, and directory removal (stopUnit) only ever targets resolve(allocationsDir, allocationId, ...) under that validated id — covered by a test.
  • DISMISSED — docs/.env.example/IMPLEMENTATION.md/package README all agree with apps/hub/src/config.ts and index.ts's actual switch statement.

Targeted tests:

  • bun test in packages/process-provisioner — 14 pass, 0 fail
  • bun test in packages/sandbox-sidecar — 14 pass, 0 fail
  • bun run typecheck — clean, exit 0

Not run: apps/hub/test/composition.test.ts (no local DATABASE_URL/docker compose stack up in this session).

@TheGreatAxios
TheGreatAxios merged commit b8ab6be into main Sep 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant