@botbuddy/cli 1.33.10 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/api.mjs +194 -22
  3. package/src/stack.mjs +66 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.33.10",
3
+ "version": "1.34.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -5,7 +5,7 @@ import { AGENT_KEY_RE } from "./agent-key.mjs";
5
5
  // BOT-1864: api.mjs ↔ agent-session.mjs is a deliberate import cycle — each side
6
6
  // only CALLS the other's functions at request time, never at module evaluation.
7
7
  import { resolveAgentSessionCredential } from "./agent-session.mjs";
8
- import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
8
+ import { die, red, cyan, dim, yellow, prettyJson } from "./utils.mjs";
9
9
 
10
10
  // Same slug shape the server accepts on `?tenant=`.
11
11
  const TENANT_PIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -57,7 +57,7 @@ async function genericEndpoint({ tenant = null, resolvePin = resolveDefaultTenan
57
57
  // tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
58
58
  // BOT-1864: the generic commands (`bb call`, resources, heartbeat/locks/tasks)
59
59
  // first honour this worktree's tier-3 session — see resolveCallAuth.
60
- async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {}) {
60
+ async function authHeader(toolName = null, { resolveAuth = resolveCallAuth, exitCode = 1 } = {}) {
61
61
  const resolved = await resolveAuth({ toolName, preferSession: true });
62
62
  if (resolved.auth) {
63
63
  const expiresAt = resolved.ownerExpiresAt ?? null;
@@ -66,12 +66,15 @@ async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {
66
66
  }
67
67
  return resolved;
68
68
  }
69
+ // BOT-1896: a missing/expired local credential is auth-class — callTool passes
70
+ // CALL_EXIT.AUTH so it never collapses into the generic exit 1.
71
+ const fail = (msg) => { console.error(`${red("✗")} ${msg}`); process.exit(exitCode); };
69
72
  const code = resolved.error?.error;
70
- if (code === "token_expired") die(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
73
+ if (code === "token_expired") fail(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
71
74
  if (code === "session_token_malformed") {
72
- die(`The agent session token has an unexpected shape (source: ${resolved.error.source ?? "unknown"}). Run: ${cyan("bb doctor --fix")} in this checkout.`);
75
+ fail(`The agent session token has an unexpected shape (source: ${resolved.error.source ?? "unknown"}). Run: ${cyan("bb doctor --fix")} in this checkout.`);
73
76
  }
74
- die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
77
+ fail(`Not authenticated. Run: ${cyan("botbuddy login")}`);
75
78
  }
76
79
 
77
80
  // BOT-1864: a cached session the server rejects (revoked by a concurrent
@@ -96,39 +99,200 @@ async function requestEndpoint(resolved, { tenant, resolvePin }) {
96
99
  return SESSION_SOURCES.has(resolved?.source) ? toolEndpoint(tenant) : genericEndpoint({ tenant, resolvePin });
97
100
  }
98
101
 
99
- export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, resolveAuth = resolveCallAuth, log = (line) => console.log(line) } = {}) {
102
+ // BOT-1896: `botbuddy call` (and every command riding callTool) exits with a
103
+ // code that tells a tenant gate WHY it failed: AUTH when BotBuddy refused the
104
+ // credential (fail closed), TRANSPORT when BotBuddy could not be reached or
105
+ // answered with a gateway/5xx failure (the only case that may degrade to a
106
+ // local-only lock). The values reuse `botbuddy stack`'s EXIT table (stack.mjs:
107
+ // AUTH 3, BACKEND 5) — restated rather than imported because stack.mjs imports
108
+ // this module; bot1896-call-auth-exit.test.mjs pins them equal.
109
+ // ERROR (1) is the generic failure: a reachable server said something other than
110
+ // "ok" or "auth" (a proxy 404, a 429 page, …). A gate must fail closed on it —
111
+ // only TRANSPORT licenses a local-only fallback.
112
+ export const CALL_EXIT = Object.freeze({ OK: 0, ERROR: 1, AUTH: 3, TRANSPORT: 5 });
113
+
114
+ // BOT-1896: mirror of the server's AUTH_CLASS_ERROR_CODES
115
+ // (supabase/functions/_shared/lib/authDenial.ts), used when an older server
116
+ // sends a typed code without `error_class`. Pinned equal by the same test.
117
+ export const CALL_AUTH_ERROR_CODES = new Set([
118
+ "scope_denied",
119
+ "session_token_revoked",
120
+ "session_token_expired",
121
+ "session_token_scope",
122
+ "credential_unknown",
123
+ "credential_missing",
124
+ "MCP_REAUTH_TENANT_REQUIRED",
125
+ "MCP_TENANT_PIN_REQUIRED",
126
+ "MCP_TENANT_SESSION_MISMATCH",
127
+ "MCP_TENANT_MEMBERSHIP_REQUIRED",
128
+ "MCP_TENANT_PIN_UNSUPPORTED",
129
+ ]);
130
+
131
+ // BOT-1896: the codes the server's 401 gates emit when a request authenticated
132
+ // as nothing — a dead/absent credential, never a tenant refusal.
133
+ const UNAUTHENTICATED_CODES = new Set(["credential_unknown", "credential_missing", "session_token_revoked", "session_token_expired"]);
134
+
135
+ function typedCodeOf(fields) {
136
+ if (!fields || typeof fields !== "object") return null;
137
+ const raw = fields.error_code ?? fields.code;
138
+ return typeof raw === "string" && raw ? raw : null;
139
+ }
140
+
141
+ function isAuthClass(fields) {
142
+ return Boolean(fields && typeof fields === "object" &&
143
+ (fields.error_class === "auth" || CALL_AUTH_ERROR_CODES.has(typedCodeOf(fields))));
144
+ }
145
+
146
+ /**
147
+ * BOT-1896: classify one tools/call exchange. Pure — no I/O.
148
+ * kind "ok" — a normal result (printed as before, exit 0)
149
+ * kind "tool_error" — a non-auth isError result (printed as before, exit 0)
150
+ * kind "server_error" — a non-auth JSON-RPC error (die as before, exit 1)
151
+ * kind "auth" — BotBuddy refused the credential → receipt, CALL_EXIT.AUTH
152
+ * kind "transport" — no HTTP response, or a 5xx → receipt, CALL_EXIT.TRANSPORT
153
+ * kind "http_error" — any other HTTP failure (non-JSON or error-less 4xx,
154
+ * a non-JSON 2xx) → receipt, CALL_EXIT.ERROR (fail closed)
155
+ * The receipt carries the server's `error_code` and typed fields UNCHANGED. A
156
+ * 401/403 is auth even with no typed code (an older server), never transport.
157
+ */
158
+ export function classifyCallResponse({ status = null, data = null, transportError = null, parseError = null } = {}) {
159
+ if (transportError) {
160
+ return {
161
+ kind: "transport",
162
+ receipt: { ok: false, error_class: "transport", error_code: "transport_error", http_status: null, message: String(transportError?.message ?? transportError) },
163
+ };
164
+ }
165
+ const authStatus = status === 401 || status === 403;
166
+ if (parseError || !data || typeof data !== "object") {
167
+ if (authStatus) {
168
+ return { kind: "auth", receipt: { ok: false, error_class: "auth", error_code: "unauthorized", http_status: status, message: "BotBuddy refused the credential (no typed body)." } };
169
+ }
170
+ // Codex P1 (PR #970): only a 5xx gateway page reads as transport. A reachable
171
+ // endpoint answering non-JSON with anything else (a proxy 404 from a wrong
172
+ // URL, a 429 rate-limit page) must fail closed, never license a fallback.
173
+ return typeof status === "number" && status >= 500
174
+ ? { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "invalid_response", http_status: status, message: `BotBuddy returned a non-JSON response (HTTP ${status}).` } }
175
+ : httpError(status, `BotBuddy returned a non-JSON response (HTTP ${status ?? "?"}).`);
176
+ }
177
+ const rpcError = data.error && typeof data.error === "object" ? data.error : null;
178
+ if (rpcError) {
179
+ const detail = rpcError.data && typeof rpcError.data === "object" ? rpcError.data : {};
180
+ if (isAuthClass(detail) || authStatus || rpcError.code === -32001) {
181
+ return {
182
+ kind: "auth",
183
+ receipt: { ok: false, ...detail, error_class: "auth", error_code: typedCodeOf(detail) ?? "unauthorized", http_status: status, message: rpcError.message ?? null },
184
+ };
185
+ }
186
+ if (typeof status === "number" && status >= 500) {
187
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: rpcError.message ?? null } };
188
+ }
189
+ return { kind: "server_error", receipt: null };
190
+ }
191
+ // A bare REST-style `{ error }` body (not JSON-RPC).
192
+ if (typeof data.error === "string") {
193
+ if (isAuthClass(data) || authStatus) {
194
+ return { kind: "auth", receipt: { ok: false, ...data, error_class: "auth", error_code: typedCodeOf(data) ?? "unauthorized", http_status: status, message: data.error } };
195
+ }
196
+ if (typeof status === "number" && status >= 500) {
197
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: data.error } };
198
+ }
199
+ return { kind: "server_error", receipt: null };
200
+ }
201
+ if (authStatus) {
202
+ return { kind: "auth", receipt: { ok: false, error_class: "auth", error_code: "unauthorized", http_status: status, message: null } };
203
+ }
204
+ if (typeof status === "number" && status >= 500) {
205
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: null } };
206
+ }
207
+ if (typeof status === "number" && status >= 400) {
208
+ return httpError(status, null);
209
+ }
210
+ if (data.result?.isError === true) {
211
+ const first = data.result?.content?.[0]?.text;
212
+ let envelope = null;
213
+ try { envelope = typeof first === "string" ? JSON.parse(first) : null; } catch { envelope = null; }
214
+ if (isAuthClass(envelope)) {
215
+ const { success: _success, ...fields } = envelope;
216
+ return { kind: "auth", receipt: { ok: false, ...fields, error_class: "auth", error_code: typedCodeOf(envelope), http_status: status } };
217
+ }
218
+ return { kind: "tool_error", receipt: null };
219
+ }
220
+ return { kind: "ok", receipt: null };
221
+ }
222
+
223
+ function httpError(status, message) {
224
+ return { kind: "http_error", receipt: { ok: false, error_class: "http", error_code: `http_${status ?? "unknown"}`, http_status: status, message } };
225
+ }
226
+
227
+ function authFailureLine(receipt) {
228
+ const what = receipt.error_code === "scope_denied" && receipt.tool && receipt.required_scope
229
+ ? `scope_denied: \`${receipt.tool}\` requires the \`${receipt.required_scope}\` scope${receipt.key_prefix ? ` (presented a ${receipt.key_prefix} key)` : ""}`
230
+ : receipt.error_code;
231
+ return `BotBuddy refused the credential (${what}).${receipt.remedy ? ` ${receipt.remedy}` : ""}`;
232
+ }
233
+
234
+ export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, resolveAuth = resolveCallAuth, log = (line) => console.log(line), errorLog = (line) => console.error(line) } = {}) {
100
235
  const body = {
101
236
  jsonrpc: "2.0",
102
237
  id: 1,
103
238
  method: "tools/call",
104
239
  params: { name: toolName, arguments: args },
105
240
  };
241
+ // BOT-1896: never throw on the wire — a fetch rejection or a non-JSON body is
242
+ // returned as a classified transport failure instead of an uncaught exit 1.
106
243
  const send = async (resolved) => {
107
244
  const headers = { "Content-Type": "application/json", ...resolved.auth };
108
- const res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
109
- return { status: res.status, data: await res.json() };
245
+ let res;
246
+ try {
247
+ res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
248
+ } catch (transportError) {
249
+ return { status: null, data: null, transportError };
250
+ }
251
+ try {
252
+ return { status: res.status, data: await res.json() };
253
+ } catch (parseError) {
254
+ return { status: res.status, data: null, parseError };
255
+ }
110
256
  };
111
257
 
112
- const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth });
113
- let { status, data } = await send(first);
258
+ const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth, exitCode: CALL_EXIT.AUTH });
259
+ let exchange = await send(first);
114
260
  let staleSession = false;
115
- if (!auth && isRejectedCachedSessionResponse(first, status, data)) {
261
+ if (!auth && isRejectedCachedSessionResponse(first, exchange.status, exchange.data)) {
116
262
  const durable = await resolveAuth({ toolName, preferSession: false });
117
- if (durable.auth) ({ status, data } = await send(durable));
263
+ if (durable.auth) exchange = await send(durable);
118
264
  else staleSession = true;
119
265
  }
266
+ const { status, data } = exchange;
120
267
  const exportedRejected = !auth && (first.source === "env" || first.source === "flag")
121
268
  && (status === 401 || status === 403 || data?.error?.code === -32001);
122
269
 
270
+ const code = data?.error?.data?.code;
271
+ const hint = code === "MCP_TENANT_PIN_REQUIRED"
272
+ ? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
273
+ : staleSession
274
+ ? ` The cached agent session in this checkout was rejected; run ${cyan("bb doctor --fix")} here.`
275
+ : exportedRejected
276
+ ? ` The exported agent session token was rejected; unset ${cyan("BOTBUDDY_AGENT_SESSION_TOKEN")} (and its legacy aliases) to use this checkout's session or your login.`
277
+ : "";
278
+
279
+ const outcome = classifyCallResponse(exchange);
280
+ if (outcome.kind === "auth") {
281
+ log(JSON.stringify(outcome.receipt, null, 2));
282
+ errorLog(`${red("✗")} ${authFailureLine(outcome.receipt)}${hint}`);
283
+ return CALL_EXIT.AUTH;
284
+ }
285
+ if (outcome.kind === "transport") {
286
+ log(JSON.stringify(outcome.receipt, null, 2));
287
+ errorLog(`${red("✗")} BotBuddy unreachable (${outcome.receipt.error_code}${outcome.receipt.http_status ? `, HTTP ${outcome.receipt.http_status}` : ""}): ${outcome.receipt.message ?? ""}`);
288
+ return CALL_EXIT.TRANSPORT;
289
+ }
290
+ if (outcome.kind === "http_error") {
291
+ log(JSON.stringify(outcome.receipt, null, 2));
292
+ errorLog(`${red("✗")} BotBuddy answered HTTP ${outcome.receipt.http_status ?? "?"} (${outcome.receipt.error_code}); not treating this as unreachable.${hint}`);
293
+ return CALL_EXIT.ERROR;
294
+ }
123
295
  if (data.error?.message) {
124
- const code = data.error?.data?.code;
125
- const hint = code === "MCP_TENANT_PIN_REQUIRED"
126
- ? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
127
- : staleSession
128
- ? ` The cached agent session in this checkout was rejected; run ${cyan("bb doctor --fix")} here.`
129
- : exportedRejected
130
- ? ` The exported agent session token was rejected; unset ${cyan("BOTBUDDY_AGENT_SESSION_TOKEN")} (and its legacy aliases) to use this checkout's session or your login.`
131
- : "";
132
296
  die(`Server error: ${data.error.message}${hint}`);
133
297
  }
134
298
 
@@ -260,13 +424,21 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
260
424
  // { error: { code: -32002, data: { code } } }). Surface that `code` so a
261
425
  // caller can tell a tenant refusal apart from a plain credential failure.
262
426
  // Best-effort: an opaque/bodyless 401/403 keeps the generic shape.
427
+ // BOT-1896: the unauthenticated 401 now carries a typed code too
428
+ // (credential_unknown / session_token_revoked / …). Those are exposed as
429
+ // `error_code` but NOT lifted into `code`, which callers (mcp-key,
430
+ // isRejectedCachedMcpSession) read as "a structured refusal other than a dead
431
+ // credential" — so a bad key keeps its not-authenticated / self-heal path.
263
432
  let code = null;
433
+ let errorCode = null;
264
434
  try {
265
435
  const body = await res.json();
266
436
  const raw = body?.error?.data?.code ?? body?.error?.code ?? body?.code ?? null;
267
- if (typeof raw === "string" && raw) code = raw;
437
+ if (typeof raw === "string" && raw && !UNAUTHENTICATED_CODES.has(raw)) code = raw;
438
+ const typed = body?.error?.data?.error_code ?? body?.error_code ?? null;
439
+ if (typeof typed === "string" && typed) errorCode = typed;
268
440
  } catch { /* opaque body */ }
269
- return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}) };
441
+ return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}), ...(errorCode ? { error_code: errorCode } : {}) };
270
442
  }
271
443
  let payload;
272
444
  try { payload = await res.json(); } catch { return { ok: false, status: res.status, error: "invalid_json" }; }
package/src/stack.mjs CHANGED
@@ -27,7 +27,7 @@ import { open, unlink, readFile } from "fs/promises";
27
27
  import { tmpdir } from "os";
28
28
  import { basename, dirname, join, relative, isAbsolute } from "path";
29
29
  import { randomUUID } from "crypto";
30
- import { callToolJson } from "./api.mjs";
30
+ import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
33
  import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
@@ -696,6 +696,9 @@ export function stackPathRootRefusalNotice(code, message) {
696
696
  * reads as "the control plane is unhealthy, retry". Every other refusal keeps BACKEND (5).
697
697
  */
698
698
  export function leaseRefusalExit(code) {
699
+ // BOT-1896: an auth-class refusal (scope_denied, a tenant mismatch, …) is the
700
+ // server refusing the credential — never BACKEND, which a gate reads as "down".
701
+ if (CALL_AUTH_ERROR_CODES.has(code)) return EXIT.AUTH;
699
702
  return code === STACK_PATH_ROOT_REFUSED_CODE ? EXIT.INVALID : EXIT.BACKEND;
700
703
  }
701
704
 
@@ -1264,10 +1267,25 @@ export function localProvision(opts, dockerTarget, execution, spawn = spawnSync,
1264
1267
  return conn;
1265
1268
  }
1266
1269
 
1267
- /** LOUD local-exec fallback: tear the stack down in `stackDir`. Returns true iff it succeeded. */
1268
- export function localTeardown(stackDir, dockerTarget, spawn = spawnSync, env = process.env) {
1269
- process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in ${stackDir}.\n`);
1270
- const res = spawn("supabase", ["stop", "--workdir", stackDir], {
1270
+ /** Release dispositions `stack done` accepts (`--destroy` is the default, `--stop` keeps volumes). */
1271
+ export const STACK_DISPOSITIONS = Object.freeze(["destroy", "stop"]);
1272
+
1273
+ /**
1274
+ * LOUD local-exec fallback: tear the stack down in `stackDir`. Returns true iff it succeeded.
1275
+ *
1276
+ * BOT-1887: the teardown honours the lease's release `disposition`. `destroy` (the `done`
1277
+ * default, and what the receipt reports) runs `supabase stop --no-backup`, which also deletes
1278
+ * the project's data volumes (`supabase_db_/storage_/edge_runtime_<project_id>`); `--workdir`
1279
+ * scopes that to the stack's own project_id, never `--all`. `stop` keeps the volumes for a
1280
+ * cheap re-provision. An unknown disposition throws before Docker is touched.
1281
+ */
1282
+ export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawnSync, env = process.env) {
1283
+ if (!STACK_DISPOSITIONS.includes(disposition)) {
1284
+ throw new Error(`localTeardown: unknown disposition ${JSON.stringify(disposition)} (expected one of ${STACK_DISPOSITIONS.join(", ")})`);
1285
+ }
1286
+ const args = ["stop", "--workdir", stackDir, ...(disposition === "destroy" ? ["--no-backup"] : [])];
1287
+ process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold(`supabase ${args.join(" ")}`)} (disposition ${disposition}).\n`);
1288
+ const res = spawn("supabase", args, {
1271
1289
  encoding: "utf8",
1272
1290
  env: dockerEnvForTarget(dockerTarget, env),
1273
1291
  });
@@ -1608,7 +1626,14 @@ export async function cmdDone(leaseId, opts, {
1608
1626
  let dockerTarget = null;
1609
1627
  let legacyTargetProof = null;
1610
1628
  let teardownDir = process.cwd();
1629
+ let leaseBefore = null;
1611
1630
  if (opts.localExec) {
1631
+ // BOT-1887: the disposition picks the local teardown (destroy deletes the project's
1632
+ // volumes); refuse an unknown one BEFORE the lease is released into `reaping`.
1633
+ if (!STACK_DISPOSITIONS.includes(opts.disposition)) {
1634
+ return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
1635
+ error: `unknown disposition ${JSON.stringify(opts.disposition)} (expected one of ${STACK_DISPOSITIONS.join(", ")})` }), opts, EXIT.INVALID);
1636
+ }
1612
1637
  const current = await call("get_stack_lease", { lease_id: leaseId });
1613
1638
  if (!current.ok || !current.data?.success) {
1614
1639
  return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
@@ -1618,6 +1643,7 @@ export async function cmdDone(leaseId, opts, {
1618
1643
  // BOT-1711: tear down in the directory the stack was PROVISIONED in (the lease's
1619
1644
  // recorded worktree_root/stack_path), not the invoking cwd — otherwise `supabase
1620
1645
  // stop` at the worktree root would stop the shared canonical stack.
1646
+ leaseBefore = current.data;
1621
1647
  teardownDir = leaseStackDir(current.data);
1622
1648
  // BOT-1711 (Codex P1, R4): refuse local-exec teardown of ANY lease whose target is the
1623
1649
  // worktree root / default project — BEFORE branching on the persisted Docker target.
@@ -1706,13 +1732,46 @@ export async function cmdDone(leaseId, opts, {
1706
1732
  if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
1707
1733
  if (!r.data.success) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
1708
1734
  let state = r.data.state;
1735
+ // BOT-1887 (Codex #972 P1): a lease that is ALREADY reaping (an idle reap that
1736
+ // established `stop`, or a retried `destroy`) keeps its in-flight teardown contract —
1737
+ // `release_stack_lease` returns that established `reap_disposition` and ignores the
1738
+ // requested one. Tear down (and report) by the established contract, never the flag,
1739
+ // so a retry can neither delete volumes a `stop` meant to keep nor keep a `destroy`'s.
1740
+ // Codex #972 R2 (P2): a lease that was ALREADY reaped answers `noop` with no
1741
+ // reap_disposition — nothing was torn down by this call, so report the disposition it
1742
+ // was actually reaped with (the lease row carries it), never the newly requested flag;
1743
+ // unknown → null. A never-provisioned lease is terminalized as `destroy` server-side.
1744
+ let established = r.data.reap_disposition;
1745
+ const alreadyReaped = established == null && state === "reaped" && r.data.noop === true;
1746
+ if (alreadyReaped) {
1747
+ // Codex #972 R3: the pre-release read is only authoritative if it ALREADY saw the
1748
+ // lease reaped — a Helper can finalize it between that read and the release. Otherwise
1749
+ // re-read, and refuse to emit an unverifiable receipt if that read fails.
1750
+ let stored = leaseBefore?.state === "reaped" ? leaseBefore : null;
1751
+ if (!stored) {
1752
+ const got = await call("get_stack_lease", { lease_id: leaseId });
1753
+ stored = got.ok && got.data?.success ? got.data : null;
1754
+ if (!stored) {
1755
+ return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, state,
1756
+ error: `the lease was already reaped but its reap disposition could not be read (${got.error || got.data?.code || "unreadable"}); nothing was torn down` }), opts, got.auth ? EXIT.AUTH : EXIT.BACKEND);
1757
+ }
1758
+ }
1759
+ established = stored.reap_disposition ?? null;
1760
+ } else if (established == null && r.data.never_provisioned === true) {
1761
+ established = "destroy";
1762
+ }
1763
+ if (established != null && !STACK_DISPOSITIONS.includes(established)) {
1764
+ return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, state,
1765
+ error: `the lease's established reap disposition ${JSON.stringify(established)} is unknown — NOT tearing down; the slot stays fenced` }), opts, EXIT.BACKEND);
1766
+ }
1767
+ const disposition = alreadyReaped ? established : (established ?? opts.disposition);
1709
1768
  if (opts.localExec && state === "reaping") {
1710
1769
  // Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
1711
1770
  // unavailable, etc.) must NOT finalize — finalize frees the slot lock and promotes
1712
1771
  // the next queued lease, which would collide with containers still running on this
1713
1772
  // slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
1714
1773
  // the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
1715
- if (!localTeardownFn(teardownDir, dockerTarget)) {
1774
+ if (!localTeardownFn(teardownDir, dockerTarget, disposition)) {
1716
1775
  return emitResult(buildReceipt({
1717
1776
  command: "done", outcome: "error", lease_id: leaseId, state,
1718
1777
  error: "local `supabase stop` failed — NOT finalizing; the slot stays fenced. Tear the stack down and re-run `stack done --local-exec`, or let the reaper reconcile.",
@@ -1722,7 +1781,7 @@ export async function cmdDone(leaseId, opts, {
1722
1781
  if (fin.ok && fin.data?.success) state = fin.data.state;
1723
1782
  else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
1724
1783
  }
1725
- return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition,
1784
+ return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition,
1726
1785
  ...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
1727
1786
  }
1728
1787