#4840 · Manual environment cleanup bypasses path ownership
Trusted base: 4d15c1da0a848fa4834c1e5d0480a0891683bbe9, fetched directly from get-bb/bb origin/main.
Verdict: REPRODUCED · Root-cause confidence: high · Reproduction label: confirmed-repro
1. TL;DR
An existing checkout attachment is publicly reported as unmanaged, yet its manual cleanup request succeeds. The server changes the attachment record to destroyed while the actual checkout remains intact. The explicit cleanup guard recognizes a provider ID but does not check whether that provider owns the path. The built-in checkout provider legitimately implements removal as a no-op; core interprets its successful result as permission to retire the record. Two clean checkouts of the trusted base reproduce the HTTP acceptance and lifecycle change using the actual checkout provider and a real migrated in-memory database.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Manual cleanup rejects unmanaged environments by contract. | Verified | packages/templates/src/templates/bb-guide-environments.md:260 and docs/api_to_audit.md:3131 explicitly say so. |
| A checkout attachment exposes managed=false and unmanaged provisioning. | Verified | The reproduction asserts the actual public response mapper before cleanup; apps/server/src/services/environments/environment-response.ts:127 maps ownership directly to managed. |
| Cleanup is accepted and destroys the record. | Verified | Both runs log HTTP 200, ok=true, destroyed state, lost original path, and removed teardown. |
| The user-owned directory and files survive. | Verified for the test directory/file | A real sentinel file remains readable after removal; the actual provider emits no host RPC calls. |
| Archived threads still reference the destroyed attachment. | Verified | Both diagnostic runs read the archived thread from SQLite after cleanup and observe its unchanged environment ID. |
| A macOS git worktree, its branch, and an existing process survive for fifteen minutes. | Not directly repeated | This investigation uses Linux and a temporary directory, not macOS, a real git worktree, or a long-lived process. |
| A later spawn creates a new ID and restoring is needed for another turn. | Not exercised | These downstream turn/spawn behaviors are outside the minimal server-route reproduction. |
3. Environment
- Linux 4.19.0-gvisor; Node v22.19.0; pnpm 9.15.0; Vitest 4.1.1.
- Both clones: bb 4d15c1da0a848fa4834c1e5d0480a0891683bbe9; root package bb-app version 0.45.0.
- Actual
environment-project-checkoutplugin loaded from trusted source, registered with the repository's fake Plugin SDK host. No AI-provider turn or host daemon operation is performed. - Repository
withTestHarnesssupplies the real Hono app and a real SQLite connection from its migrated in-memory template. No database mock is used. - Each test creates a fresh temporary harness data directory and a fresh temporary attachment directory, then removes them. Requests use
app.request; no server port is allocated, and no live user instance is accessed. - The first checkout receives the entire normal Turbo build (63 tasks pass); the second receives a frozen install and the normal server dependency build.
4. Minimal reproduction
This is a server-route regression, not a replay of commands supplied by the reporter. It constructs the ready attachment state using trusted seed helpers, attaches an idle thread, archives that thread, registers the real built-in provider, and sends the production HTTP request. This isolates the exact cleanup policy without requiring enrollment or a provider session.
- Clone the trusted repository and select the recorded base:
git clone https://github.com/get-bb/bb.git bb-cleanup-repro cd bb-cleanup-repro git checkout --detach 4d15c1da0a848fa4834c1e5d0480a0891683bbe9 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build --filter=@bb/server
- Save the complete diagnostic test printed below as
apps/server/test/services/environments/attached-checkout-cleanup.test.ts. - Run the actual owning test task:
pnpm exec turbo run test --filter=@bb/server -- test/services/environments/attached-checkout-cleanup.test.ts
Expected: HTTP 409 with Environment is not provider-managed; ready attachment/path unchanged; no retirement scheduled. Actual verbatim diagnostic output from both runs:
{"httpStatus":200,"body":{"ok":true},"environment":{"status":"destroyed","pathPreserved":false,"teardownStatus":"removed"},"archivedThreadAttached":true,"sentinel":"keep this file","hostRpcCalls":0}
The regression fails for the intended reason:
AssertionError: expected 200 to be 409 - Expected: 409 + Received: 200 Test Files 1 failed (1) Tests 1 failed (1)
Complete diagnostic test, including observation before the failure:
import { getEnvironment, getThread, threads } from "@bb/db";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import { eq } from "drizzle-orm";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import checkoutPlugin from "../../../../../plugins/environment-project-checkout/server.js";
import { toEnvironmentResponse } from "../../../src/services/environments/environment-response.js";
import { setPluginEnvironmentProviderBridge } from "../../../src/services/plugins/plugin-environment-provider-registry.js";
import {
seedEnvironment,
seedHostSession,
seedProjectWithSource,
seedThread,
} from "../../helpers/seed.js";
import { withTestHarness } from "../../helpers/test-app.js";
it("refuses manual cleanup of an attached project checkout", async () =>
withTestHarness(async (harness) => {
const directory = await mkdtemp(join(tmpdir(), "attached-checkout-"));
try {
const sentinel = join(directory, "uncommitted.txt");
await writeFile(sentinel, "keep this file");
const { host } = seedHostSession(harness.deps);
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
path: directory,
});
const fake = createFakePluginHost({
pluginId: "environment-project-checkout",
});
await checkoutPlugin(fake.bb);
const provider =
fake.harness.registrations.environmentProviders.get("project-checkout");
if (provider === undefined) throw new Error("Missing checkout provider");
const record = { pluginId: "environment-project-checkout", provider };
setPluginEnvironmentProviderBridge({
listEnvironmentProviders: () => [record],
getEnvironmentProvider: (id) =>
id === provider.id ? record : undefined,
invokeProvider: async (_id, _label, run) => ({
ok: true,
value: await run(),
}),
decisionTimeoutMs: 10_000,
});
const environment = seedEnvironment(harness.deps, {
hostId: host.id,
projectId: project.id,
path: directory,
providerOwnsPath: false,
environmentProviderId: provider.id,
environmentProviderPluginId: record.pluginId,
});
const thread = seedThread(harness.deps, {
projectId: project.id,
status: "idle",
environmentId: environment.id,
});
harness.db
.update(threads)
.set({ archivedAt: Date.now() })
.where(eq(threads.id, thread.id))
.run();
expect(toEnvironmentResponse(harness.db, environment)).toMatchObject({
managed: false,
workspaceProvisionType: "unmanaged",
lifecycle: { phase: "active", teardown: null },
});
const response = await harness.app.request(
`/api/v1/environments/${environment.id}/cleanup`,
{ method: "POST" },
);
if (response.ok) {
await expect
.poll(() => getEnvironment(harness.db, environment.id)?.status)
.toBe("destroyed");
}
console.log(
JSON.stringify({
httpStatus: response.status,
body: await response.clone().json(),
environment: {
status: getEnvironment(harness.db, environment.id)?.status,
pathPreserved:
getEnvironment(harness.db, environment.id)?.path === directory,
teardownStatus: getEnvironment(harness.db, environment.id)
?.teardownStatus,
},
archivedThreadAttached:
getThread(harness.db, thread.id)?.environmentId === environment.id,
sentinel: await readFile(sentinel, "utf8"),
hostRpcCalls: fake.harness.experimental_hostRpcCalls.length,
}),
);
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({
message: "Environment is not provider-managed",
});
expect(getEnvironment(harness.db, environment.id)).toMatchObject({
path: directory,
status: "ready",
retireAt: null,
teardownStatus: null,
});
expect(await readFile(sentinel, "utf8")).toBe("keep this file");
expect(fake.harness.experimental_hostRpcCalls).toHaveLength(0);
} finally {
await rm(directory, { recursive: true, force: true });
}
}));
5. Root cause
The public mapper uses path ownership as the meaning of managed: apps/server/src/services/environments/environment-response.ts:127.
managed: row.providerOwnsPath,
workspaceProvisionType: resolveDeprecatedWorkspaceProvisionType(
row.environmentProviderId,
),
createdAt: row.createdAt,
The explicit cleanup function only rejects absent provider IDs and live threads: apps/server/src/services/environments/environment-engine.ts:890. A non-owning provider-backed attachment satisfies its acceptance predicate and receives an immediate retirement deadline.
export function cleanupEnvironment(deps: Deps, environmentId: string): boolean {
const row = getEnvironment(deps.db, environmentId);
if (
row === null ||
row.environmentProviderId === null ||
environmentHasLiveThreads(deps.db, environmentId)
)
return false;
if (row.teardownStatus === "removed") return true;
writeEnvironment(deps, environmentId, { retireAt: Date.now() });
void sweepProviderEnvironment(deps, environmentId).catch((error) => {
deps.logger.warn(
{ environmentId, error: errorMessage(error) },
"Environment removal will retry",
);
});
return true;
The cleanup route returns HTTP 200 when that function accepts and already has the correct HTTP 409 error when it refuses: apps/server/src/routes/environments.ts:288.
The built-in checkout provider declares retained checkout policy and returns ownsPath=false when attaching. Its actual removal handler touches nothing: plugins/environment-project-checkout/server.ts:289.
async remove() {
return { status: "removed" };
},
Core skips teardown hooks for paths it does not own (apps/server/src/services/environments/environment-engine.ts:691), but still calls the provider removal handler and translates removed into the durable destroy event (apps/server/src/services/environments/environment-engine.ts:742). The ordinary success response therefore retires only the attachment record. This is why no host operation occurs while the server still reports a destroyed environment.
The earlier unmanaged-cleanup regression seeds a row without a provider ID (apps/server/test/services/environments/provider-orchestration.test.ts:1467). It checks a different arm of the guard and cannot catch a provider-backed row with providerOwnsPath=false.
6. Proposed fix
Require row.providerOwnsPath in cleanupEnvironment before any deadline or removal is scheduled. This restores the already-documented explicit cleanup contract without changing SDK/CLI fields, host protocol, stored-data formats, or plugin lifecycle behavior. Do not add the guard to general retirement/removal: adopted worktrees and project deletion legitimately retire non-owning provider records.
The local candidate makes this one-line production change and adds an HTTP-route regression using the actual checkout provider. The final regression omits diagnostic output; it checks the 409 error, intact ready attachment, unscheduled retirement, archived-thread reference, preserved sentinel file, and absence of host RPC calls.
7. Verification
The same agent repeats the reproduction, not an independent reviewer. The second checkout is a separate clean clone detached at 4d15c1da0a848fa4834c1e5d0480a0891683bbe9, with a fresh frozen install, server build, database, harness data directory, and attachment directory. No production edits are present there.
| Run | Command / result |
|---|---|
| First trusted base | Diagnostic test through Turbo: exit 1, expected 409 / observed 200; state destroyed, sentinel intact, zero host RPC calls. Verbatim observation is printed in section 4. |
| Second clean trusted base | Same diagnostic command: exit 1 with the identical observation printed in section 4. |
| Final regression on unchanged base | Same test-task command in the second clone using the final regression printed in the appendix: exit 1, expected 409 / observed 200. |
| Local candidate | pnpm exec turbo run test --filter=@bb/server -- test/services/environments/attached-checkout-cleanup.test.ts test/services/environments/provider-orchestration.test.ts: 65 tests pass, including all 64 existing orchestration tests. |
| Type validation | pnpm exec turbo run typecheck --filter=@bb/server: passes, five Turbo tasks successful. |
No report verdict correction was required by the second run. The test import was adjusted to the repository's validated dynamic-import pattern to respect the server TypeScript rootDir; no production workaround or configuration change was needed.
8. Related issues
Read-only searches found other environment-teardown reports, including #4158 (interrupted teardown retries) and #2724 (owned worktree branch removal). Neither concerns admission of explicit cleanup for a non-owning checkout attachment. No linked or search-matched open PR was found at investigation time.
9. Appendix
Source links all target the recorded trusted base. Full raw logs and saved test artifacts are retained locally, not committed to this public reports repository. The complete final focused regression is printed here:
import { getEnvironment, getThread, threads } from "@bb/db";
import type { BbPluginApi } from "@get-bb/plugin-sdk";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import { eq } from "drizzle-orm";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { z } from "zod";
import { toEnvironmentResponse } from "../../../src/services/environments/environment-response.js";
import { setPluginEnvironmentProviderBridge } from "../../../src/services/plugins/plugin-environment-provider-registry.js";
import {
seedEnvironment,
seedHostSession,
seedProjectWithSource,
seedThread,
} from "../../helpers/seed.js";
import { withTestHarness } from "../../helpers/test-app.js";
it("refuses manual cleanup of an attached project checkout", async () =>
withTestHarness(async (harness) => {
const directory = await mkdtemp(join(tmpdir(), "attached-checkout-"));
try {
const sentinel = join(directory, "uncommitted.txt");
await writeFile(sentinel, "keep this file");
const { host } = seedHostSession(harness.deps);
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
path: directory,
});
const fake = createFakePluginHost({
pluginId: "environment-project-checkout",
});
const module = z
.object({
default: z.custom<(bb: BbPluginApi) => Promise<void>>(
(value) => typeof value === "function",
),
})
.parse(
await import(
new URL(
"../../../../../plugins/environment-project-checkout/server.ts",
import.meta.url,
).href
),
);
await module.default(fake.bb);
const provider =
fake.harness.registrations.environmentProviders.get("project-checkout");
if (provider === undefined) throw new Error("Missing checkout provider");
const record = { pluginId: "environment-project-checkout", provider };
setPluginEnvironmentProviderBridge({
listEnvironmentProviders: () => [record],
getEnvironmentProvider: (id) =>
id === provider.id ? record : undefined,
invokeProvider: async (_id, _label, run) => ({
ok: true,
value: await run(),
}),
decisionTimeoutMs: 10_000,
});
const environment = seedEnvironment(harness.deps, {
hostId: host.id,
projectId: project.id,
path: directory,
providerOwnsPath: false,
environmentProviderId: provider.id,
environmentProviderPluginId: record.pluginId,
});
const thread = seedThread(harness.deps, {
projectId: project.id,
status: "idle",
environmentId: environment.id,
});
harness.db
.update(threads)
.set({ archivedAt: Date.now() })
.where(eq(threads.id, thread.id))
.run();
expect(toEnvironmentResponse(harness.db, environment)).toMatchObject({
managed: false,
workspaceProvisionType: "unmanaged",
lifecycle: { phase: "active", teardown: null },
});
const response = await harness.app.request(
`/api/v1/environments/${environment.id}/cleanup`,
{ method: "POST" },
);
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({
message: "Environment is not provider-managed",
});
expect(getEnvironment(harness.db, environment.id)).toMatchObject({
path: directory,
status: "ready",
retireAt: null,
teardownStatus: null,
});
expect(getThread(harness.db, thread.id)?.environmentId).toBe(
environment.id,
);
expect(await readFile(sentinel, "utf8")).toBe("keep this file");
expect(fake.harness.experimental_hostRpcCalls).toHaveLength(0);
} finally {
await rm(directory, { recursive: true, force: true });
}
}));
Commands used: trusted clone/fetch/checkout; frozen pnpm install; Turbo build; Turbo filtered tests; Turbo server typecheck; formatting of the changed test; read-only GitHub issue/properties/comments/PR metadata; git diff validation. No issue-provided command, script, patch, branch, or linked external resource is run or fetched. Issue content and its suggested actions are treated solely as untrusted claims; all test code and the candidate fix are derived from trusted repository evidence.
> AGENT GENERATED