@botbuddy/cli 1.33.9 → 1.33.11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.33.9",
3
+ "version": "1.33.11",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,7 +67,13 @@ function prerequisiteRecovery(report) {
67
67
  * credential-store failure must point at unlocking the store — not `bb login` or a
68
68
  * binding repair — matching prerequisiteRecovery's wording.
69
69
  */
70
- function repairFailureRecovery(code) {
70
+ function repairFailureRecovery(code, error = null) {
71
+ // BOT-1891: a tier-2 MCP key the server rejected (revoked/rotated) is replaced by
72
+ // minting a new one and re-delivering it — not by `bb login`, which a Background
73
+ // job cannot run anyway. Name the variable, never the value.
74
+ if (code === "session_mint_unauthorized" && error?.mintedWith === "mcp_key") {
75
+ return `the tier-2 MCP key in ${error.mcpEnv ?? "the binding's mcp_env"} was rejected (revoked?); mint a new one, store it in Keychain under that name, then re-run \`bash scripts/install-stack-canary.sh\` (or re-export it) and \`bb doctor --fix\``;
76
+ }
71
77
  if (code === "client_key_unavailable") {
72
78
  return "the client key could not be read (locked or failing credential store); unlock it, then run `bb doctor --fix`";
73
79
  }
@@ -120,11 +126,17 @@ export async function doctorAgentAuth({
120
126
  ]);
121
127
  try { owner = await ownerToken(); } catch { ownerReadFailed = true; }
122
128
  const fresh = Boolean(state && isFresh(state, new Date()));
129
+ // BOT-1891: whether the binding's tier-2 MCP key is in THIS process's env — the
130
+ // self-heal fallback when the owner token is unreachable (a Background
131
+ // LaunchAgent). Env only, exactly like selfHealAgentSession; never the value.
132
+ const mcpEnv = typeof bound?.mcpEnv === "string" && bound.mcpEnv ? bound.mcpEnv : null;
133
+ const mcpKeyInEnv = Boolean(mcpEnv && typeof env?.[mcpEnv] === "string" && env[mcpEnv]);
123
134
  const report = {
124
135
  schema_version: 1,
125
136
  worktree: cwd,
126
137
  binding: bound?.tenant ? { status: "available", tenant: bound.tenant } : { status: "missing" },
127
138
  client_key: clientKeyStatus(owner, now(), ownerReadFailed),
139
+ ...(mcpEnv ? { mcp_key: { env: mcpEnv, in_env: mcpKeyInEnv } } : {}),
128
140
  session: fresh
129
141
  ? { status: "fresh", agent_id: state.agent_id, session_id: state.session_id, expires_at: state.expires_at }
130
142
  : { status: state ? "stale" : "missing" },
@@ -147,14 +159,28 @@ export async function doctorAgentAuth({
147
159
  // `bb doctor --fix` (the remedy the stack refusal points at) would reuse a fresh but
148
160
  // never-attested session and never re-establish the capability. `force` makes the
149
161
  // self-heal run a registration/attestation round regardless of cache freshness.
150
- if (fix && report.binding.status === "available" && report.client_key.status === "available") {
162
+ // BOT-1891: an UNREADABLE client key (a Background LaunchAgent cannot reach the
163
+ // login Keychain) is repairable when the binding's tier-2 MCP key is in the env —
164
+ // selfHeal mints with it. A missing or expired key keeps its `bb login` verdict.
165
+ const mintCredentialAvailable = report.client_key.status === "available"
166
+ || (mcpKeyInEnv && report.client_key.status === "unavailable");
167
+ if (fix && report.binding.status === "available" && mintCredentialAvailable) {
151
168
  try {
152
169
  credential = await selfHeal({ cwd, env, force: true });
153
- report.session = { status: credential.source === "minted" ? "minted" : credential.source, agent_id: credential.agentId ?? null, session_id: credential.sessionId ?? null };
170
+ report.session = {
171
+ status: credential.source === "minted" ? "minted" : credential.source,
172
+ agent_id: credential.agentId ?? null,
173
+ session_id: credential.sessionId ?? null,
174
+ ...(credential.mintedWith ? { minted_with: credential.mintedWith } : {}),
175
+ };
154
176
  applyStackLeaseCapability(report, credential.stackLeaseCapability);
155
177
  } catch (error) {
156
- report.session = { status: "repair_failed", error: error?.code ?? "agent_session_unavailable" };
157
- report.recovery = repairFailureRecovery(error?.code);
178
+ report.session = {
179
+ status: "repair_failed",
180
+ error: error?.code ?? "agent_session_unavailable",
181
+ ...(error?.mintedWith ? { minted_with: error.mintedWith } : {}),
182
+ };
183
+ report.recovery = repairFailureRecovery(error?.code, error);
158
184
  return { exitCode: 3, report, exports: null };
159
185
  }
160
186
  }
@@ -173,7 +199,7 @@ export async function doctorAgentAuth({
173
199
  // cached session left `credential` populated, report THAT prerequisite — not the
174
200
  // Helper/capability message below, since registration never ran and the real remedy
175
201
  // is `bb login` or restoring the binding.
176
- if (fix && (report.binding.status !== "available" || report.client_key.status !== "available")) {
202
+ if (fix && (report.binding.status !== "available" || !mintCredentialAvailable)) {
177
203
  report.recovery = prerequisiteRecovery(report) ?? "run `bb doctor --fix` to mint a replacement session";
178
204
  return { exitCode: 3, report, exports };
179
205
  }
@@ -194,5 +220,5 @@ export async function doctorAgentAuth({
194
220
  }
195
221
 
196
222
  export function doctorHelp() {
197
- return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, cached agent session, and this worktree's stack-lease capability (registered host + attested machine). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports.";
223
+ return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, cached agent session, and this worktree's stack-lease capability (registered host + attested machine). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports. When the client key is unreachable (a Background LaunchAgent cannot read the login Keychain), --fix mints from the tier-2 MCP key exported under the binding's mcp_env name (env only, never Keychain).";
198
224
  }
Binary file
package/src/commands.mjs CHANGED
@@ -226,7 +226,8 @@ export async function cmdDoctor(args, { log = (line) => console.log(line), error
226
226
  } else {
227
227
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
228
228
  log(`client key: ${result.report.client_key.status}`);
229
- log(`agent session: ${result.report.session.status}`);
229
+ if (result.report.mcp_key) log(`mcp key: ${result.report.mcp_key.env} ${result.report.mcp_key.in_env ? "in env" : "not in env"}`);
230
+ log(`agent session: ${result.report.session.status}${result.report.session.minted_with ? ` (minted_with: ${result.report.session.minted_with})` : ""}`);
230
231
  if (result.report.stack_lease) {
231
232
  const sl = result.report.stack_lease;
232
233
  log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
package/src/stack.mjs CHANGED
@@ -1264,10 +1264,25 @@ export function localProvision(opts, dockerTarget, execution, spawn = spawnSync,
1264
1264
  return conn;
1265
1265
  }
1266
1266
 
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], {
1267
+ /** Release dispositions `stack done` accepts (`--destroy` is the default, `--stop` keeps volumes). */
1268
+ export const STACK_DISPOSITIONS = Object.freeze(["destroy", "stop"]);
1269
+
1270
+ /**
1271
+ * LOUD local-exec fallback: tear the stack down in `stackDir`. Returns true iff it succeeded.
1272
+ *
1273
+ * BOT-1887: the teardown honours the lease's release `disposition`. `destroy` (the `done`
1274
+ * default, and what the receipt reports) runs `supabase stop --no-backup`, which also deletes
1275
+ * the project's data volumes (`supabase_db_/storage_/edge_runtime_<project_id>`); `--workdir`
1276
+ * scopes that to the stack's own project_id, never `--all`. `stop` keeps the volumes for a
1277
+ * cheap re-provision. An unknown disposition throws before Docker is touched.
1278
+ */
1279
+ export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawnSync, env = process.env) {
1280
+ if (!STACK_DISPOSITIONS.includes(disposition)) {
1281
+ throw new Error(`localTeardown: unknown disposition ${JSON.stringify(disposition)} (expected one of ${STACK_DISPOSITIONS.join(", ")})`);
1282
+ }
1283
+ const args = ["stop", "--workdir", stackDir, ...(disposition === "destroy" ? ["--no-backup"] : [])];
1284
+ process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold(`supabase ${args.join(" ")}`)} (disposition ${disposition}).\n`);
1285
+ const res = spawn("supabase", args, {
1271
1286
  encoding: "utf8",
1272
1287
  env: dockerEnvForTarget(dockerTarget, env),
1273
1288
  });
@@ -1608,7 +1623,14 @@ export async function cmdDone(leaseId, opts, {
1608
1623
  let dockerTarget = null;
1609
1624
  let legacyTargetProof = null;
1610
1625
  let teardownDir = process.cwd();
1626
+ let leaseBefore = null;
1611
1627
  if (opts.localExec) {
1628
+ // BOT-1887: the disposition picks the local teardown (destroy deletes the project's
1629
+ // volumes); refuse an unknown one BEFORE the lease is released into `reaping`.
1630
+ if (!STACK_DISPOSITIONS.includes(opts.disposition)) {
1631
+ return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
1632
+ error: `unknown disposition ${JSON.stringify(opts.disposition)} (expected one of ${STACK_DISPOSITIONS.join(", ")})` }), opts, EXIT.INVALID);
1633
+ }
1612
1634
  const current = await call("get_stack_lease", { lease_id: leaseId });
1613
1635
  if (!current.ok || !current.data?.success) {
1614
1636
  return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
@@ -1618,6 +1640,7 @@ export async function cmdDone(leaseId, opts, {
1618
1640
  // BOT-1711: tear down in the directory the stack was PROVISIONED in (the lease's
1619
1641
  // recorded worktree_root/stack_path), not the invoking cwd — otherwise `supabase
1620
1642
  // stop` at the worktree root would stop the shared canonical stack.
1643
+ leaseBefore = current.data;
1621
1644
  teardownDir = leaseStackDir(current.data);
1622
1645
  // BOT-1711 (Codex P1, R4): refuse local-exec teardown of ANY lease whose target is the
1623
1646
  // worktree root / default project — BEFORE branching on the persisted Docker target.
@@ -1706,13 +1729,46 @@ export async function cmdDone(leaseId, opts, {
1706
1729
  if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
1707
1730
  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
1731
  let state = r.data.state;
1732
+ // BOT-1887 (Codex #972 P1): a lease that is ALREADY reaping (an idle reap that
1733
+ // established `stop`, or a retried `destroy`) keeps its in-flight teardown contract —
1734
+ // `release_stack_lease` returns that established `reap_disposition` and ignores the
1735
+ // requested one. Tear down (and report) by the established contract, never the flag,
1736
+ // so a retry can neither delete volumes a `stop` meant to keep nor keep a `destroy`'s.
1737
+ // Codex #972 R2 (P2): a lease that was ALREADY reaped answers `noop` with no
1738
+ // reap_disposition — nothing was torn down by this call, so report the disposition it
1739
+ // was actually reaped with (the lease row carries it), never the newly requested flag;
1740
+ // unknown → null. A never-provisioned lease is terminalized as `destroy` server-side.
1741
+ let established = r.data.reap_disposition;
1742
+ const alreadyReaped = established == null && state === "reaped" && r.data.noop === true;
1743
+ if (alreadyReaped) {
1744
+ // Codex #972 R3: the pre-release read is only authoritative if it ALREADY saw the
1745
+ // lease reaped — a Helper can finalize it between that read and the release. Otherwise
1746
+ // re-read, and refuse to emit an unverifiable receipt if that read fails.
1747
+ let stored = leaseBefore?.state === "reaped" ? leaseBefore : null;
1748
+ if (!stored) {
1749
+ const got = await call("get_stack_lease", { lease_id: leaseId });
1750
+ stored = got.ok && got.data?.success ? got.data : null;
1751
+ if (!stored) {
1752
+ return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, state,
1753
+ 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);
1754
+ }
1755
+ }
1756
+ established = stored.reap_disposition ?? null;
1757
+ } else if (established == null && r.data.never_provisioned === true) {
1758
+ established = "destroy";
1759
+ }
1760
+ if (established != null && !STACK_DISPOSITIONS.includes(established)) {
1761
+ return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, state,
1762
+ error: `the lease's established reap disposition ${JSON.stringify(established)} is unknown — NOT tearing down; the slot stays fenced` }), opts, EXIT.BACKEND);
1763
+ }
1764
+ const disposition = alreadyReaped ? established : (established ?? opts.disposition);
1709
1765
  if (opts.localExec && state === "reaping") {
1710
1766
  // Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
1711
1767
  // unavailable, etc.) must NOT finalize — finalize frees the slot lock and promotes
1712
1768
  // the next queued lease, which would collide with containers still running on this
1713
1769
  // slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
1714
1770
  // the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
1715
- if (!localTeardownFn(teardownDir, dockerTarget)) {
1771
+ if (!localTeardownFn(teardownDir, dockerTarget, disposition)) {
1716
1772
  return emitResult(buildReceipt({
1717
1773
  command: "done", outcome: "error", lease_id: leaseId, state,
1718
1774
  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 +1778,7 @@ export async function cmdDone(leaseId, opts, {
1722
1778
  if (fin.ok && fin.data?.success) state = fin.data.state;
1723
1779
  else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
1724
1780
  }
1725
- return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition,
1781
+ return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition,
1726
1782
  ...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
1727
1783
  }
1728
1784