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/scratch does this, and the example below adds a repo copy.
  • Run threads in a container. A custom handle is the spec's answer, but as built it cannot run a thread (see What you build); mount a local-path root 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/progressenv.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

  1. Discovery. environments.providers calls kernel/host-runtime.environmentProviders { hostId } per online host; core publishes your host artifact, pushes the role template (env.registerProvider), starts the role with scope: none, and asks env/describe. The answer is { providerId, label, ownsRoot, optionsSchema } (packages/core/src/host-runtime.ts).
  2. Provision. The plugin writes the row as provisioning, resolves the project's source on the host (sourceId or the default; null when none), and streams kernel/host-runtime.environmentsProvision. The host's EnvironmentsPort sends env/provision { envId, options, source } to your role; the bootstrap validates options against your schema first (INVALID_PARAMS on issues) and runs provision under an AbortController keyed by envId. Every progress.step / output is a role/progress line → env.progress → a progress / output stream item. The budget is 20 min per call (local/environments.ts).
  3. Ready. The handle is persisted on the row with managed: ownsRoot; summarize fills path and the vcs chips. The bridge driver later reads handle.root and writeRoots into SessionParams.workspace for the thread's bridge.
  4. Cancel. environments.cancelProvision (or abandoning the stream) sends the env/cancel notification; your signal aborts.
  5. Restart. After a host restart the plugin calls env/reconnect with the persisted handle; return it, or mark persisted.missing so the row shows gone.
  6. Destroy. Retire (grace environments/retireGraceMinutes, default 5) or bb environment destroy: the plugin stops the env's bridges and closes its ptys, then env/destroy reaches your role; you kill by cwd, remove, and the row moves to destroyed. A result lost to a disconnect becomes destroy.lost and is retried on the next session.

Pitfalls

  • spawn.newEnvironment admits only local-dir | git-worktree | scratch (plugins/threads/src/server/core.ts, ENVIRONMENT_ROLES), and resolves the provider id by the host's descriptors (endsWith("/<role>"), else env-local/<role>). A provider with a new name is reached through environments.provision plus spawn.environment: <envId>; newEnvironment needs a threads change.
  • custom handles cannot run a thread as built (§6.5, D14). Do not return one; if you need a container, bind-mount a local-path root and run the agent's bridge against it.
  • A provider role's scope is none, so ctx.fs / ctx.exec / ctx.vcs are bound to the plugin root (dataDir), not the source (§6.5; env-local README request 2). Reading the source needs node:fs; the copy above sits under the plugin root, which is why ctx.vcs.git can run inside it.
  • destroy must be idempotent and refuse foreign handles. The lifecycle retries; a tampered or mis-routed row must never steer remove at a directory you did not create (ownedRel). Call ctx.process.killByCwd before 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-worktree wraps git worktree add/remove in env-local/worktree-metadata.
  • summarize has no scheduled caller in kernel-host beyond environmentsSummarize; keep it cheap and never let it throw for a missing root (report (missing)).
  • Long provisioning: raise limits.idleMs on the row if a setup step can stay silent; env-local/git-worktree uses 600 000 ms and runs .bb-env-setup.sh with a 15 min budget, streaming each line through progress.output.
  • Options defaults: the picker renders optionsSchema (z.toJSONSchema, io: "input"); a caller who omits a field gets INVALID_PARAMS unless the schema defaults it. environments.provision defaults options to {}, 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 under ctx.vcs.lock("env-local/checkout"), a no-op destroy. git-worktree (ownsRoot: true): <hostDataDir>/worktrees/<envId>/<repo>, fetch the remote base, git worktree add -B, copy .worktreeinclude matches, 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.ts and host.test.ts: the smallest provider and its containment tests.
  • plugins/environments/README.md and src/contracts.ts: the lifecycle, provision stream items, providers.
  • Guide 10 (host tier) for roles, limits, and process hygiene.