Environment providers
An environment is where a thread runs: a root directory on one host, its write roots, and the facts the UI shows as chips (branch, repo, path). The environments plugin owns the lifecycle (provisioning → ready → retiring → destroying → destroyed), the kernel owns the row, and a host-tier environment provider materializes the directory: it provisions, reconnects after a host restart, destroys, and summarizes. This page builds env-copy/repo-copy, a provider that copies the project's repo into a private directory per thread and optionally switches branch.
Use this when
- A scratch directory per thread. A throwaway root under the role's data dir;
env-local/scratchdoes this, and the example below adds a repo copy. - Run threads in a container. A
customhandle is the spec's answer, but as built it cannot run a thread (see What you build); mount alocal-pathroot the container shares instead. - An environment on a remote host. The same provider runs on any enrolled host once its artifact is ensured there;
environments.provision { hostId }picks the host. - Isolate experiments from the checkout. A copy that never touches the user's working tree, unlike
local-dir.
What you build
| Member (§6.5) | As built |
|---|---|
defineEnvironmentProvider({ name, label, ownsRoot, options, provision, reconnect, destroy, summarize }) |
built; options is required and is a zod schema core turns into JSON Schema for the picker |
provision({ envId, options, source: ProjectSource | null, signal, progress }, ctx) |
built; source is null when the project has no source on this host (dc07292bf, §0.7) |
reconnect({ envId, handle }, ctx), destroy({ envId, handle, signal, progress }, ctx) |
built (env/reconnect, env/destroy, env/cancel) |
summarize(handle, ctx) |
served by the role; EnvironmentsPort.summarize and kernel/host-runtime.environmentsSummarize exist at dc07292bf |
env/describe → { label, ownsRoot, optionsSchema } |
built; answered by the bootstrap from your label, ownsRoot, options — nothing to write |
EnvironmentHandle local-path { root, writeRoots, persisted } |
built: the kernel serves fs/exec/vcs/pty at root and the bridge driver fills SessionParams.workspace from it |
EnvironmentHandle custom { writeRoots, persisted } |
shape only: no root, no lanes (openLane is absent, D14), so no thread can run in it. This page builds a local-path provider. |
ProgressSink.step(key, text, status) / .output(line) |
built; role/progress → env.progress → the provision stream; threads appends each item as a provision event (threads/server/core.ts notice). §6.5 still names environment/provision-progress, the host-appended kind the dc07292bf note retires |
Steps
1. Declare the role
The provider id is <pluginId>/<name>: env-copy/repo-copy. requires names the service that drives it.
"bb": {
"id": "env-copy", "name": "Repo copy environments", "description": "Copy the repo into a private directory per thread",
"category": "developer", "host": "./src/host.ts", "contracts": "./src/contracts.ts",
"requires": { "environments/environments": "^1.0.0" },
"contributes": { "hostRoles": [ { "role": "environment-provider", "name": "repo-copy", "limits": { "idleMs": 600000 } } ] }
}2. Options schema in contracts.ts
Make every field explicit; describe text becomes the picker's help.
import { z } from "zod";
export const repoCopyOptionsSchema = z.strictObject({
branch: z.string().min(1).nullable().describe("Create or reset this branch in the copy; null keeps the source's HEAD"),
skipNodeModules: z.boolean().describe("Leave node_modules out of the copy"),
});
export type RepoCopyOptions = z.infer<typeof repoCopyOptionsSchema>;3. The provider
Containment is the whole job (§6.5 rules): the env id is one path segment; every root is derived from envId under roleStorage(ctx), which refuses a path that escapes the role's data dir; a persisted handle is input and is refused unless its kind and root match what this role would have issued. The copy itself uses node:fs, because the source lives outside the role's root; ctx.vcs.git works inside the copy because the copy is under the plugin root the provider role's ports are bound to.
// src/host.ts
import { cp } from "node:fs/promises";
import path from "node:path";
import { KernelError } from "@get-bb/plugin-sdk/contracts";
import { defineEnvironmentProvider, defineHostEntry, defineHostRole, roleStorage } from "@get-bb/plugin-sdk/host";
import type { EnvironmentHandle, EnvironmentSummary, RoleContext } from "@get-bb/plugin-sdk/host";
import { repoCopyOptionsSchema } from "./contracts.js";
import type { RepoCopyOptions } from "./contracts.js";
const PROVIDER = "env-copy/repo-copy";
const ENV_ID = /^[A-Za-z0-9_-]+$/;
const GIT = { timeoutMs: 60_000, maxOutputBytes: 1 << 20, stdin: null };
const copyRel = (envId: string): string => {
if (!ENV_ID.test(envId)) throw new KernelError({ code: "invalid_input", message: `${PROVIDER}: malformed envId`, data: { envId } });
return `copies/${envId}`;
};
const handleFor = (root: string, persisted: Record<string, string | null>): EnvironmentHandle =>
({ kind: "local-path", root, writeRoots: [root], persisted });
/** Only a local-path handle rooted exactly where this role puts `envId` is ours; anything else is refused before any fs call. */
function ownedRel(ctx: Pick<RoleContext, "paths">, envId: string, handle: EnvironmentHandle): string {
const rel = copyRel(envId);
const expected = roleStorage(ctx).path(rel);
if (handle.kind !== "local-path" || path.resolve(handle.root) !== expected)
throw new KernelError({ code: "precondition", message: `${PROVIDER}: not this environment's copy`, data: { envId, expected } });
return rel;
}
export const repoCopy = defineEnvironmentProvider<RepoCopyOptions>({
name: "repo-copy",
label: "Repo copy",
ownsRoot: true,
options: repoCopyOptionsSchema,
async provision({ envId, options, source, signal, progress }, ctx) {
if (source === null)
throw new KernelError({ code: "host/source_required", message: `${PROVIDER}: the project has no source on this host`, data: { envId } });
const storage = roleStorage(ctx);
const rel = copyRel(envId);
const root = storage.path(rel);
await storage.mkdir(rel);
progress.step("copy", `Copying ${source.path}`, "started");
await cp(source.path, root, {
recursive: true,
filter: (src) => !(options.skipNodeModules && path.basename(src) === "node_modules"),
});
if (signal.aborted) {
await storage.remove(rel, { recursive: true });
throw new KernelError({ code: "cancelled", message: `${PROVIDER}: provisioning was cancelled`, data: { envId } });
}
progress.step("copy", `Copied into ${root}`, "completed");
if (options.branch !== null) {
progress.step("branch", `Switching to ${options.branch}`, "started");
const result = await ctx.vcs.git(["switch", "-C", options.branch], { cwd: root, ...GIT });
for (const line of result.stderr.split("\n").filter((l) => l.length > 0)) progress.output(line);
if (result.exitCode !== 0) {
progress.step("branch", `git switch failed (${result.exitCode})`, "failed");
await storage.remove(rel, { recursive: true });
throw new KernelError({ code: "host/git_failed", message: result.stderr.trim(), data: { envId, exitCode: result.exitCode } });
}
progress.step("branch", `On ${options.branch}`, "completed");
}
return handleFor(root, { source: source.path, branch: options.branch });
},
async reconnect({ envId, handle }, ctx) {
const rel = ownedRel(ctx, envId, handle);
const st = await roleStorage(ctx).stat(rel);
return st !== null && st.kind === "dir" ? handle : { ...handle, persisted: { ...handle.persisted, missing: true } };
},
async destroy({ envId, handle, progress }, ctx) {
const rel = ownedRel(ctx, envId, handle);
const storage = roleStorage(ctx);
progress.step("kill", "Stopping processes in the copy", "started");
const killed = await ctx.process.killByCwd(storage.path(rel), { graceMs: 2_000 });
progress.step("kill", `Stopped ${killed} process(es)`, "completed");
progress.step("rm", "Removing the copy", "started");
await storage.remove(rel, { recursive: true }); // idempotent: the lifecycle retries a lost destroy
progress.step("rm", "Removed the copy", "completed");
},
async summarize(handle, ctx): Promise<EnvironmentSummary> {
const root = handle.kind === "local-path" ? handle.root : null;
const label = root === null ? "copy" : path.basename(handle.persisted["source"] === undefined ? root : String(handle.persisted["source"]));
const empty = { label: `${label} (missing)`, path: root, isRepo: false, isWorktree: false, branch: null, baseBranch: null, defaultBranch: null };
if (root === null) return empty;
const head = await ctx.vcs.git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, ...GIT }).catch(() => null);
if (head === null || head.exitCode !== 0) return empty;
return { ...empty, label, isRepo: true, branch: head.stdout.trim() || null };
},
});
export default defineHostEntry({
roles: [defineHostRole({ role: "environment-provider", name: "repo-copy", provider: repoCopy })],
});4. Test it over a fake RoleContext
Root the fake context at a real temp dir (§9.5). Ports you do not pass throw host/unsupported, so a test sees exactly what the provider touched; supply vcs and process fakes for the branch and destroy paths.
import { fakeRoleContext } from "@get-bb/plugin-sdk/testing";
const ctx = fakeRoleContext({ pluginId: "env-copy", role: "environment-provider", name: "repo-copy", dataDir,
ports: { process: { killByCwd: async () => 0, spawnGroup: () => Promise.reject(new Error("unused")) } } });
const source = { id: "src_1", projectId: "proj_1", hostId: "host_1", path: repoDir, gitRemoteUrl: null, isDefault: true };
const handle = await repoCopy.provision({ envId: "env_1", options: { branch: null, skipNodeModules: true }, source, signal, progress }, ctx.context);
await expect(repoCopy.destroy({ envId: "env_1", handle: { ...handle, root: "/tmp" }, signal, progress }, ctx.context)).rejects.toMatchObject({ code: "precondition" });5. Provision and use it
environments/environments.provision streams the transcript and ends with the ready row; a thread then reuses the environment.
bb environment provision --project proj_x --host host_x --provider env-copy/repo-copy --options '{"branch":"spike/idea","skipNodeModules":true}'
bb thread spawn "try the idea" -p proj_x --environment <envId>What happens at runtime
- Discovery.
environments.providerscallskernel/host-runtime.environmentProviders { hostId }per online host; core publishes your host artifact, pushes the role template (env.registerProvider), starts the role withscope: none, and asksenv/describe. The answer is{ providerId, label, ownsRoot, optionsSchema }(packages/core/src/host-runtime.ts). - Provision. The plugin writes the row as
provisioning, resolves the project's source on the host (sourceIdor the default;nullwhen none), and streamskernel/host-runtime.environmentsProvision. The host'sEnvironmentsPortsendsenv/provision { envId, options, source }to your role; the bootstrap validatesoptionsagainst your schema first (INVALID_PARAMSon issues) and runsprovisionunder anAbortControllerkeyed byenvId. Everyprogress.step/outputis arole/progressline →env.progress→ aprogress/outputstream item. The budget is 20 min per call (local/environments.ts). - Ready. The handle is persisted on the row with
managed: ownsRoot;summarizefillspathand thevcschips. The bridge driver later readshandle.rootandwriteRootsintoSessionParams.workspacefor the thread's bridge. - Cancel.
environments.cancelProvision(or abandoning the stream) sends theenv/cancelnotification; yoursignalaborts. - Restart. After a host restart the plugin calls
env/reconnectwith the persisted handle; return it, or markpersisted.missingso the row showsgone. - Destroy. Retire (grace
environments/retireGraceMinutes, default 5) orbb environment destroy: the plugin stops the env's bridges and closes its ptys, thenenv/destroyreaches your role; you kill by cwd, remove, and the row moves todestroyed. A result lost to a disconnect becomesdestroy.lostand is retried on the next session.
Pitfalls
spawn.newEnvironmentadmits onlylocal-dir | git-worktree | scratch(plugins/threads/src/server/core.ts,ENVIRONMENT_ROLES), and resolves the provider id by the host's descriptors (endsWith("/<role>"), elseenv-local/<role>). A provider with a new name is reached throughenvironments.provisionplusspawn.environment: <envId>;newEnvironmentneeds athreadschange.customhandles cannot run a thread as built (§6.5, D14). Do not return one; if you need a container, bind-mount alocal-pathroot and run the agent's bridge against it.- A provider role's scope is
none, soctx.fs/ctx.exec/ctx.vcsare bound to the plugin root (dataDir), not the source (§6.5;env-localREADME request 2). Reading the source needsnode:fs; the copy above sits under the plugin root, which is whyctx.vcs.gitcan run inside it. destroymust be idempotent and refuse foreign handles. The lifecycle retries; a tampered or mis-routed row must never steerremoveat a directory you did not create (ownedRel). Callctx.process.killByCwdbefore removing anything (§6.5).- Per-environment mutation serialization is not built on the host;
ctx.vcs.lock(name, fn)(keyed on the repo's common dir and<pluginId>/<name>) is the mutex you have (§6.5).env-local/git-worktreewrapsgit worktree add/removeinenv-local/worktree-metadata. summarizehas no scheduled caller in kernel-host beyondenvironmentsSummarize; keep it cheap and never let it throw for a missing root (report(missing)).- Long provisioning: raise
limits.idleMson the row if a setup step can stay silent;env-local/git-worktreeuses 600 000 ms and runs.bb-env-setup.shwith a 15 min budget, streaming each line throughprogress.output. - Options defaults: the picker renders
optionsSchema(z.toJSONSchema,io: "input"); a caller who omits a field getsINVALID_PARAMSunless the schema defaults it.environments.provisiondefaultsoptionsto{}, so either default every field or document the required ones.
See also
- Reference §6.5 (environment providers), §6.6 (
roleStorage), §6.4 (RoleContext), §3.11 (kernel/host-runtime), §9.5 (fakeRoleContext), §0.7. plugins/env-local/src/host/{local-dir,git-worktree,scratch,roots}.ts: the three first-party providers.local-dir(ownsRoot: false): the source as is, optional branch switch underctx.vcs.lock("env-local/checkout"), a no-opdestroy.git-worktree(ownsRoot: true):<hostDataDir>/worktrees/<envId>/<repo>, fetch the remote base,git worktree add -B, copy.worktreeincludematches, run.bb-env-setup.sh, roll back on failure; destroy = kill-by-cwd,worktree remove --force,branch -D.scratch:<hostDataDir>/scratch/<envId>.examples/plugins/hello-slot/src/host.tsandhost.test.ts: the smallest provider and its containment tests.plugins/environments/README.mdandsrc/contracts.ts: the lifecycle,provisionstream items,providers.- Guide 10 (host tier) for roles, limits, and process hygiene.