@botbuddy/cli 1.4.1 → 1.5.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.
package/src/stack.mjs CHANGED
@@ -21,11 +21,15 @@
21
21
  // labelled fallback for when no Helper is available: it runs `supabase start`/
22
22
  // `stop` itself and self-activates/finalizes via the fallback MCP tools.
23
23
 
24
- import { spawnSync } from "child_process";
24
+ import { spawn, spawnSync } from "child_process";
25
25
  import { readFileSync, realpathSync } from "fs";
26
- import { join, relative, isAbsolute } from "path";
26
+ import { open, unlink, readFile } from "fs/promises";
27
+ import { tmpdir } from "os";
28
+ import { basename, dirname, join, relative, isAbsolute } from "path";
29
+ import { randomUUID } from "crypto";
27
30
  import { callToolJson } from "./api.mjs";
28
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
+ import { runDockerWorkflow } from "./docker-hygiene.mjs";
29
33
  import { bold, dim, yellow } from "./utils.mjs";
30
34
 
31
35
  export const STACK_SCHEMA_VERSION = 1;
@@ -42,6 +46,7 @@ export const EXIT = Object.freeze({
42
46
  BACKEND: 5, // server/RPC/transport error, or the coordination request failed
43
47
  LEASE_FAILED: 6, // the lease was reaped / provision failed instead of going active
44
48
  INTERNAL: 7, // unexpected local error
49
+ CLEANUP_FAILED: 8, // child completed but signed physical reap was not proven
45
50
  });
46
51
 
47
52
  export const STACK_HELP = `${bold("botbuddy stack")} — one command for a batch-scoped local stack lease (BOT-1218)
@@ -51,6 +56,8 @@ ${bold("USAGE")}
51
56
  botbuddy stack status <lease_id> Show a lease's state + connection
52
57
  botbuddy stack touch <lease_id> Bump the idle clock so the reaper doesn't stop it
53
58
  botbuddy stack done <lease_id> Release (done): tear the stack down
59
+ botbuddy stack run [options] -- <executable> [args...]
60
+ Own one Helper-backed test/fix batch end-to-end
54
61
 
55
62
  ${bold("up OPTIONS")}
56
63
  --slot <slot> Stable stack identity (BOT-1186): the shared CLI stack DB port
@@ -65,10 +72,24 @@ ${bold("up OPTIONS")}
65
72
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
66
73
  --no-wait If the host is full, print the queue position and exit (don't park).
67
74
  --local-exec FALLBACK (no Helper): run 'supabase start' locally and self-activate.
75
+ --docker-context <name> Explicit OrbStack Docker context for the mandatory local preflight.
76
+ --docker-endpoint <uri> Explicit OrbStack endpoint instead of --docker-context.
68
77
 
69
78
  ${bold("done OPTIONS")}
70
79
  --stop Keep volumes (cheap re-provision next batch). Default: destroy.
71
80
  --local-exec FALLBACK (no Helper): run 'supabase stop' locally and self-finalize.
81
+ --docker-context <name> Explicit OrbStack context used by the matching local-exec up.
82
+ --docker-endpoint <uri> Explicit OrbStack endpoint instead of --docker-context.
83
+ Pre-1.5.0 leases also require exact worktree + live connection proof.
84
+
85
+ ${bold("run OPTIONS")}
86
+ --repo <repo> Required approved repository name.
87
+ --ticket <BOT-123> Required ticket for the batch.
88
+ --provision-timeout N Seconds to wait for a physical Helper provision (default: --timeout).
89
+ --reap-timeout N Seconds to wait for signed physical reap proof (default: 300).
90
+ --connection-file P Optional empty file path for the mode-0600 connection JSON.
91
+ --hard-ttl N Bound the child runtime; timeout requests normal cleanup.
92
+ --local-exec Rejected: stack run never starts Docker directly.
72
93
 
73
94
  ${bold("GLOBAL")}
74
95
  --json Pretty-print the receipt (default is one compact JSON line).
@@ -77,13 +98,11 @@ ${bold("GLOBAL")}
77
98
  ${bold("EXIT CODES")}
78
99
  0 ok/held (or queued+--no-wait) 2 park timed out 3 not authenticated
79
100
  4 invalid arguments 5 backend/coordination 6 lease failed/reaped
80
- 7 internal error
101
+ 7 internal error 8 cleanup/reap proof failed
81
102
 
82
103
  ${bold("EXAMPLE")}
83
104
  # request a per-worktree stack for this batch, run a lane against it, then reap it
84
- id=$(botbuddy stack up --repo botbuddy-web --ticket BOT-1220 | jq -r .lease_id)
85
- pnpm test:integration
86
- botbuddy stack done "$id"`;
105
+ botbuddy stack run --repo botbuddy-web --ticket BOT-1346 -- pnpm test:integration`;
87
106
 
88
107
  // ── pure helpers (unit-tested) ───────────────────────────────────────────────
89
108
 
@@ -124,15 +143,26 @@ export function deriveSlot({ slot, repo, ticket } = {}, env = process.env) {
124
143
  */
125
144
  export function parseStackArgs(argv) {
126
145
  const errors = [];
127
- const [command, ...rest] = argv;
146
+ const [command, ...rawRest] = argv;
147
+ const divider = rawRest.indexOf("--");
148
+ const rest = divider === -1 ? rawRest : rawRest.slice(0, divider);
149
+ const childArgv = divider === -1 ? [] : rawRest.slice(divider + 1);
128
150
  const opts = {
129
151
  slot: null, host: null, repo: null, ticket: null, ticketUrl: null,
130
152
  prId: null, prUrl: null, purpose: null, idleTtl: null, stackPath: ".",
131
- timeout: DEFAULT_TIMEOUT_SEC, noWait: false, localExec: false,
153
+ timeout: DEFAULT_TIMEOUT_SEC, provisionTimeout: null, reapTimeout: 300, hardTtl: null,
154
+ connectionFile: null, noWait: false, localExec: false,
155
+ dockerContext: null, dockerEndpoint: null,
132
156
  disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
133
157
  };
134
158
  const positionals = [];
135
- const need = (name, v) => { if (v === undefined) { errors.push(`${name} needs a value`); return false; } return true; };
159
+ const need = (name, v) => {
160
+ if (v === undefined || String(v).startsWith("--")) {
161
+ errors.push(`${name} needs a value`);
162
+ return false;
163
+ }
164
+ return true;
165
+ };
136
166
  for (let i = 0; i < rest.length; i++) {
137
167
  const a = rest[i];
138
168
  switch (a) {
@@ -161,6 +191,31 @@ export function parseStackArgs(argv) {
161
191
  }
162
192
  break;
163
193
  }
194
+ case "--provision-timeout": {
195
+ if (need(a, rest[i + 1])) {
196
+ const n = Number(rest[++i]);
197
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--provision-timeout must be a positive integer number of seconds");
198
+ else opts.provisionTimeout = n;
199
+ }
200
+ break;
201
+ }
202
+ case "--reap-timeout": {
203
+ if (need(a, rest[i + 1])) {
204
+ const n = Number(rest[++i]);
205
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--reap-timeout must be a positive integer number of seconds");
206
+ else opts.reapTimeout = n;
207
+ }
208
+ break;
209
+ }
210
+ case "--hard-ttl": {
211
+ if (need(a, rest[i + 1])) {
212
+ const n = Number(rest[++i]);
213
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) errors.push("--hard-ttl must be a positive integer number of seconds");
214
+ else opts.hardTtl = n;
215
+ }
216
+ break;
217
+ }
218
+ case "--connection-file": if (need(a, rest[i + 1])) opts.connectionFile = rest[++i]; break;
164
219
  case "--receipt-max-bytes": {
165
220
  if (need(a, rest[i + 1])) {
166
221
  const n = Number(rest[++i]);
@@ -171,6 +226,8 @@ export function parseStackArgs(argv) {
171
226
  }
172
227
  case "--no-wait": opts.noWait = true; break;
173
228
  case "--local-exec": opts.localExec = true; break;
229
+ case "--docker-context": if (need(a, rest[i + 1])) opts.dockerContext = rest[++i]; break;
230
+ case "--docker-endpoint": if (need(a, rest[i + 1])) opts.dockerEndpoint = rest[++i]; break;
174
231
  case "--stop": opts.disposition = "stop"; break;
175
232
  case "--destroy": opts.disposition = "destroy"; break;
176
233
  case "--json": opts.json = true; break;
@@ -187,7 +244,23 @@ export function parseStackArgs(argv) {
187
244
  if (command && ["status", "touch", "done"].includes(command) && !leaseId) {
188
245
  errors.push(`${command} needs a <lease_id>`);
189
246
  }
190
- return { command, leaseId, opts, errors };
247
+ if (["up", "done"].includes(command) && opts.localExec && Boolean(opts.dockerContext) === Boolean(opts.dockerEndpoint)) {
248
+ errors.push(`--local-exec ${command} requires exactly one of --docker-context <name> or --docker-endpoint <uri>`);
249
+ }
250
+ if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
251
+ errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
252
+ }
253
+ if (command === "run") {
254
+ if (!opts.repo) errors.push("run needs --repo <approved-repo>");
255
+ if (!opts.ticket || !/^[A-Z][A-Z0-9]*-\d+$/i.test(opts.ticket)) errors.push("run needs --ticket <BOT-n|ENT-n>");
256
+ if (divider === -1 || childArgv.length === 0) errors.push("run needs an executable after --");
257
+ if (opts.noWait) errors.push("--no-wait is incompatible with run; the command owns its queue wait");
258
+ if (opts.localExec) errors.push("--local-exec is forbidden for run; Helper-backed physical lifecycle is required");
259
+ if (opts.connectionFile && !isAbsolute(opts.connectionFile)) errors.push("--connection-file must be an absolute path");
260
+ } else if (divider !== -1) {
261
+ errors.push("-- <executable> is only valid with stack run");
262
+ }
263
+ return { command, leaseId, opts, errors, childArgv };
191
264
  }
192
265
 
193
266
  /** Build the canonical receipt object (single JSON line to stdout). */
@@ -308,12 +381,13 @@ function parseSseFrames(buffer) {
308
381
  }
309
382
 
310
383
  /** Register a wait_session for this lease (so it shows on /waits) + return {cursorStart}. Best-effort. */
311
- async function registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl = fetch) {
384
+ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl = fetch) {
312
385
  const deadline = new Date(Date.now() + timeoutSec * 1000).toISOString();
313
386
  const res = await fetchImpl(`${eventStreamBase()}`, {
314
387
  method: "POST",
315
388
  headers: { ...auth, "Content-Type": "application/json" },
316
389
  body: JSON.stringify({ action: "register", conditions: [{ type: "lease", params: { id: leaseId } }], deadline, mode: "any" }),
390
+ signal,
317
391
  });
318
392
  if (!res.ok) throw new Error(`register responded ${res.status}`);
319
393
  const body = await res.json();
@@ -326,29 +400,59 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl = fetch) {
326
400
  * Resolves { woke, state } on a matching frame, { timeout:true } on --timeout,
327
401
  * { failed, state } if the lease was reaped, or { error } on a transport failure.
328
402
  */
329
- async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fetchImpl = fetch) {
403
+ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth, signal = null, deadlineMs = null }, fetchImpl = fetch) {
404
+ const absoluteDeadlineMs = deadlineMs ?? Date.now() + timeoutSec * 1000;
330
405
  let cursorStart = null;
406
+ let waitSessionId = null;
407
+ const ac = new AbortController();
408
+ const abortFromParent = () => ac.abort("interrupted");
409
+ if (signal?.aborted) ac.abort("interrupted");
410
+ else signal?.addEventListener?.("abort", abortFromParent, { once: true });
411
+ const timer = setTimeout(() => ac.abort("timeout"), Math.max(0, absoluteDeadlineMs - Date.now()));
412
+ const finish = async (result, status) => {
413
+ clearTimeout(timer);
414
+ signal?.removeEventListener?.("abort", abortFromParent);
415
+ if (waitSessionId) {
416
+ try {
417
+ const finalizer = new AbortController();
418
+ const finalizerTimer = setTimeout(() => finalizer.abort(), 5_000);
419
+ try {
420
+ const response = await fetchImpl(eventStreamBase(), {
421
+ method: "POST", headers: { ...auth, "Content-Type": "application/json" },
422
+ body: JSON.stringify({ action: "finalize", wait_session_id: waitSessionId, status, receipt: { outcome: status, lease_id: leaseId } }),
423
+ signal: finalizer.signal,
424
+ });
425
+ if (!response.ok) process.stderr.write(`${yellow("⚠")} stack: wait finalization returned ${response.status}\n`);
426
+ } finally {
427
+ clearTimeout(finalizerTimer);
428
+ }
429
+ } catch (err) {
430
+ process.stderr.write(`${yellow("⚠")} stack: wait finalization failed (${err?.message ?? err})\n`);
431
+ }
432
+ }
433
+ return result;
434
+ };
435
+ if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
331
436
  try {
332
- ({ cursorStart } = await registerLeaseWait(leaseId, timeoutSec, auth, fetchImpl));
437
+ ({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
333
438
  } catch (err) {
439
+ if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
334
440
  process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
335
441
  }
336
442
  const url = new URL(eventStreamBase());
337
443
  url.searchParams.set("tables", "agent_signal_events");
338
444
  if (cursorStart != null) url.searchParams.set("since", String(cursorStart));
445
+ if (waitSessionId) url.searchParams.set("wait_session", waitSessionId);
339
446
  url.searchParams.set("heartbeat", "1");
340
- const ac = new AbortController();
341
- const timer = setTimeout(() => ac.abort("timeout"), timeoutSec * 1000);
342
447
  let res;
343
448
  try {
344
449
  res = await fetchImpl(url, { headers: { ...auth, Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
345
450
  } catch (err) {
346
- clearTimeout(timer);
347
- if (ac.signal.aborted) return { timeout: true };
348
- return { error: `sse connect: ${err?.message ?? err}` };
451
+ if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
452
+ return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
349
453
  }
350
- if (res.status === 401 || res.status === 403) { clearTimeout(timer); return { auth: true }; }
351
- if (!res.ok || !res.body) { clearTimeout(timer); return { error: `sse responded ${res.status}` }; }
454
+ if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
455
+ if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}` }, "error");
352
456
  const decoder = new TextDecoder();
353
457
  let buf = "";
354
458
  try {
@@ -361,25 +465,143 @@ async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth }, fet
361
465
  try { sig = JSON.parse(f.data); } catch { continue; }
362
466
  if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
363
467
  const st = sig.payload?.state ?? null;
364
- if (isFailed(st)) { clearTimeout(timer); ac.abort(); return { failed: true, state: st }; }
365
- if (isDone(st)) { clearTimeout(timer); ac.abort(); return { woke: true, state: st }; }
468
+ if (isFailed(st)) { ac.abort(); return finish({ failed: true, state: st }, "error"); }
469
+ if (isDone(st)) { ac.abort(); return finish({ woke: true, state: st }, "matched"); }
366
470
  }
367
471
  }
368
472
  } catch (err) {
369
- clearTimeout(timer);
370
- if (ac.signal.aborted) return { timeout: true };
371
- return { error: `sse stream: ${err?.message ?? err}` };
473
+ if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
474
+ return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
475
+ }
476
+ // A clean relay EOF is not a timeout: the server intentionally closes on
477
+ // scope changes and proxies can recycle idle streams. Re-arm from a fresh
478
+ // cursor until the original deadline, never silently fall back to polling.
479
+ const remainingSec = Math.ceil((absoluteDeadlineMs - Date.now()) / 1000);
480
+ if (remainingSec > 0 && !ac.signal.aborted) {
481
+ await finish({ reconnected: true }, "error");
482
+ return waitForLease(leaseId, isDone, isFailed, { timeoutSec: remainingSec, auth, signal, deadlineMs: absoluteDeadlineMs }, fetchImpl);
372
483
  }
373
- clearTimeout(timer);
374
- return { timeout: true };
484
+ return finish({ timeout: true }, "timeout");
375
485
  }
376
486
 
377
487
  const isActive = (s) => s === "active";
378
488
  const nonQueued = (s) => s != null && s !== "queued";
379
489
  const isReaped = (s) => s === "reaping" || s === "reaped";
380
490
 
491
+ /** Read-only pressure gate used before local managed-stack provisioning. */
492
+ export function runLocalExecPreflight(opts, runWorkflow = runDockerWorkflow) {
493
+ const selector = opts.dockerContext
494
+ ? ["--context", opts.dockerContext]
495
+ : ["--endpoint", opts.dockerEndpoint];
496
+ return runWorkflow(["preflight", ...selector, "--json"]);
497
+ }
498
+
499
+ export function dockerEnvForSelector(opts, env = process.env) {
500
+ const selected = { ...env };
501
+ delete selected.DOCKER_CONTEXT;
502
+ delete selected.DOCKER_HOST;
503
+ if (opts.dockerContext) selected.DOCKER_CONTEXT = opts.dockerContext;
504
+ else selected.DOCKER_HOST = opts.dockerEndpoint;
505
+ return selected;
506
+ }
507
+
508
+ export function dockerTargetFromPreflight(receipt) {
509
+ const context = receipt?.context;
510
+ const endpoint = context?.resolved_endpoint;
511
+ const serverId = context?.server?.id;
512
+ if (context?.validation !== "orbstack" || typeof endpoint !== "string" || !endpoint ||
513
+ typeof serverId !== "string" || !serverId) return null;
514
+ return { validation: "orbstack", resolved_endpoint: endpoint, server_id: serverId };
515
+ }
516
+
517
+ export function connectionWithDockerTarget(connection, target) {
518
+ if (!target?.resolved_endpoint || !target?.server_id) {
519
+ throw new Error("cannot persist an incomplete Docker target identity");
520
+ }
521
+ return { ...(connection || {}), botbuddy_docker_target: { ...target } };
522
+ }
523
+
524
+ function dockerTargetsMatch(expected, actual) {
525
+ return expected?.validation === "orbstack" && actual?.validation === "orbstack" &&
526
+ expected.resolved_endpoint === actual.resolved_endpoint && expected.server_id === actual.server_id;
527
+ }
528
+
529
+ function dockerEnvForTarget(target, env = process.env) {
530
+ const selected = { ...env };
531
+ delete selected.DOCKER_CONTEXT;
532
+ delete selected.DOCKER_HOST;
533
+ selected.DOCKER_HOST = target.resolved_endpoint;
534
+ return selected;
535
+ }
536
+
537
+ /**
538
+ * Compatibility fence for leases activated by CLI <1.5.0, before the daemon
539
+ * identity was persisted in `connection`. We cannot reconstruct the old daemon
540
+ * ID by guesswork. Instead, prove that the caller is in the exact registered
541
+ * worktree/stack path and that `supabase status`, pinned to the freshly validated
542
+ * OrbStack endpoint, reports the same non-secret API + DB endpoints stored on
543
+ * the lease. Only then may that observed daemon be used for teardown.
544
+ */
545
+ export function proveLegacyLocalExecTarget(lease, observedTarget, opts, run = spawnSync, cwd = process.cwd()) {
546
+ if (!observedTarget?.resolved_endpoint || !observedTarget?.server_id) {
547
+ return { ok: false, error: "fresh OrbStack target identity is incomplete" };
548
+ }
549
+ let execution;
550
+ try {
551
+ execution = resolveStackPath(cwd, opts?.stackPath || ".");
552
+ } catch (error) {
553
+ return { ok: false, error: `could not resolve the invoking stack path: ${error.message}` };
554
+ }
555
+ if (!lease?.worktree_root || lease.worktree_root !== execution.worktreeRoot ||
556
+ (lease.stack_path || ".") !== execution.stackPath) {
557
+ return { ok: false, error: "legacy lease worktree/stack metadata does not exactly match the invoking worktree" };
558
+ }
559
+
560
+ const stackDir = execution.stackPath === "."
561
+ ? execution.worktreeRoot
562
+ : join(execution.worktreeRoot, execution.stackPath);
563
+ const status = run("supabase", ["status", "-o", "json", "--workdir", stackDir], {
564
+ encoding: "utf8",
565
+ env: dockerEnvForTarget(observedTarget),
566
+ });
567
+ if (status?.error || status?.status !== 0) {
568
+ return { ok: false, error: "legacy stack is not provably live on the freshly validated OrbStack endpoint" };
569
+ }
570
+ const live = parseSupabaseStatus(status.stdout || "");
571
+ const stored = lease.connection || {};
572
+ if (typeof stored.api_url !== "string" || typeof stored.db_url !== "string" ||
573
+ live.api_url !== stored.api_url || live.db_url !== stored.db_url) {
574
+ return { ok: false, error: "live Supabase connection on the observed OrbStack daemon does not match the legacy lease connection" };
575
+ }
576
+ let dbPort = null;
577
+ try { dbPort = new URL(live.db_url).port || null; } catch { /* exact URL already matched; omit display-only port */ }
578
+ return {
579
+ ok: true,
580
+ evidence: {
581
+ method: "legacy_worktree_connection_match",
582
+ worktree_root: execution.worktreeRoot,
583
+ stack_path: execution.stackPath,
584
+ api_url: live.api_url,
585
+ db_port: dbPort,
586
+ resolved_endpoint: observedTarget.resolved_endpoint,
587
+ server_id: observedTarget.server_id,
588
+ },
589
+ };
590
+ }
591
+
592
+ function compactPreflight(receipt) {
593
+ return {
594
+ outcome: receipt.outcome,
595
+ context: receipt.context,
596
+ pressure: receipt.pressure,
597
+ warnings: receipt.warnings,
598
+ errors: receipt.errors,
599
+ recommendation: receipt.recommendation,
600
+ };
601
+ }
602
+
381
603
  /** LOUD local-exec fallback: bring a stack up in the cwd via the Supabase CLI. */
382
- function localProvision() {
604
+ function localProvision(opts, dockerTarget) {
383
605
  // Pin a LOCAL origin so `supabase start` never bakes the repo's prod origin into the
384
606
  // edge runtime (BOT-903 / Codex P1). Refuse rather than risk crossing into production.
385
607
  const url = resolveLocalSupabaseUrl();
@@ -391,7 +613,7 @@ function localProvision() {
391
613
  "export VITE_SUPABASE_URL=http://127.0.0.1:<port> first.",
392
614
  );
393
615
  }
394
- const spawnEnv = { ...process.env, VITE_SUPABASE_URL: url };
616
+ const spawnEnv = { ...dockerEnvForTarget(dockerTarget), VITE_SUPABASE_URL: url };
395
617
  process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in this worktree (VITE_SUPABASE_URL=${url}).\n`);
396
618
  const start = spawnSync("supabase", ["start", "--workdir", process.cwd()], { encoding: "utf8", env: spawnEnv });
397
619
  if (start.status !== 0) {
@@ -402,9 +624,12 @@ function localProvision() {
402
624
  }
403
625
 
404
626
  /** LOUD local-exec fallback: tear the stack down in the cwd. Returns true iff it succeeded. */
405
- function localTeardown() {
627
+ export function localTeardown(_opts, dockerTarget, spawn = spawnSync, env = process.env) {
406
628
  process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in this worktree.\n`);
407
- const res = spawnSync("supabase", ["stop", "--workdir", process.cwd()], { encoding: "utf8" });
629
+ const res = spawn("supabase", ["stop", "--workdir", process.cwd()], {
630
+ encoding: "utf8",
631
+ env: dockerEnvForTarget(dockerTarget, env),
632
+ });
408
633
  if (res.status !== 0) {
409
634
  process.stderr.write(`${yellow("⚠")} stack: supabase stop returned ${res.status}: ${(res.stderr || res.stdout || "").slice(0, 300)}\n`);
410
635
  }
@@ -419,19 +644,42 @@ function emit(receipt, opts, code) {
419
644
 
420
645
  // ── command orchestration ────────────────────────────────────────────────────
421
646
 
422
- async function cmdUp(opts) {
647
+ export async function cmdUp(opts, {
648
+ runPreflight = runLocalExecPreflight,
649
+ callTool = callToolJson,
650
+ authProvider = stackAuthHeader,
651
+ localProvisionFn = localProvision,
652
+ emitResult = emit,
653
+ } = {}) {
423
654
  let slot;
424
655
  try { slot = deriveSlot(opts); } catch (e) {
425
- return emit(buildReceipt({ command: "up", outcome: "error", error: e.message }), opts, EXIT.INVALID);
656
+ return emitResult(buildReceipt({ command: "up", outcome: "error", error: e.message }), opts, EXIT.INVALID);
426
657
  }
427
- const auth = stackAuthHeader();
428
- if (!auth) return emit(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
429
658
  let execution;
430
659
  try { execution = resolveStackPath(process.cwd(), opts.stackPath); } catch (e) {
431
- return emit(buildReceipt({ command: "up", outcome: "error", error: (e).message }), opts, EXIT.INVALID);
660
+ return emitResult(buildReceipt({ command: "up", outcome: "error", error: (e).message }), opts, EXIT.INVALID);
661
+ }
662
+ let localPreflight = null;
663
+ let localDockerTarget = null;
664
+ if (opts.localExec) {
665
+ const checked = runPreflight(opts);
666
+ localPreflight = compactPreflight(checked.receipt);
667
+ localDockerTarget = dockerTargetFromPreflight(checked.receipt);
668
+ if (checked.exitCode !== 0 || !localDockerTarget) {
669
+ return emitResult(buildReceipt({
670
+ command: "up", outcome: "refused", slot,
671
+ error: checked.receipt.errors?.[0] || "OrbStack preflight did not return a stable Docker server identity",
672
+ preflight: localPreflight,
673
+ }), opts, EXIT.LEASE_FAILED);
674
+ }
675
+ if (checked.receipt.outcome === "warn") {
676
+ process.stderr.write(`${yellow("⚠")} stack: OrbStack preflight warns of interface pressure; cleanup is recommended before another stack.\n`);
677
+ }
432
678
  }
679
+ const auth = authProvider();
680
+ if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
433
681
 
434
- const req = await callToolJson("request_stack_lease", {
682
+ const req = await callTool("request_stack_lease", {
435
683
  slot, host_key: opts.host || undefined, repo: opts.repo || undefined,
436
684
  ticket_id: opts.ticket || undefined, ticket_url: opts.ticketUrl || undefined,
437
685
  pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
@@ -441,57 +689,78 @@ async function cmdUp(opts) {
441
689
  });
442
690
  if (!req.ok) {
443
691
  return req.auth
444
- ? emit(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
445
- : emit(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
692
+ ? emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "unauthorized" }), opts, EXIT.AUTH)
693
+ : emitResult(buildReceipt({ command: "up", outcome: "error", error: req.error || "request failed" }), opts, EXIT.BACKEND);
446
694
  }
447
695
  const d = req.data;
448
696
  if (!d.success) {
449
- return emit(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
697
+ return emitResult(buildReceipt({ command: "up", outcome: "error", code: d.code, error: d.message || d.code || "request refused", slot }), opts, EXIT.BACKEND);
450
698
  }
451
699
  let leaseId = d.lease_id;
452
700
  let state = d.state;
453
701
 
454
702
  if (state === "queued") {
455
703
  if (opts.noWait) {
456
- return emit(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, queue_position: d.queue_position, holders: d.holders }), opts, EXIT.QUEUED);
704
+ return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, queue_position: d.queue_position, holders: d.holders }), opts, EXIT.QUEUED);
457
705
  }
458
706
  // Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
459
707
  const parked = await waitForLease(leaseId, nonQueued, () => false, { timeoutSec: opts.timeout, auth });
460
- if (parked.timeout) return emit(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.TIMEOUT);
461
- if (parked.auth) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
462
- if (parked.error) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
708
+ if (parked.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state: "queued", slot, error: `parked ${opts.timeout}s without capacity` }), opts, EXIT.TIMEOUT);
709
+ if (parked.auth) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: "unauthorized on wait stream" }), opts, EXIT.AUTH);
710
+ if (parked.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: parked.error }), opts, EXIT.BACKEND);
463
711
  state = parked.state || "provisioning";
464
712
  }
465
713
 
466
714
  // Reach `active`: local-exec provisions itself; otherwise wait for the Helper.
467
715
  if (state !== "active") {
468
716
  if (opts.localExec) {
717
+ // Capacity may have changed while this command was queued. Re-run the
718
+ // non-mutating gate immediately before `supabase start`; on refusal,
719
+ // release the minted lease and never invoke the local provisioner.
720
+ const checked = runPreflight(opts);
721
+ localPreflight = compactPreflight(checked.receipt);
722
+ localDockerTarget = dockerTargetFromPreflight(checked.receipt);
723
+ if (checked.exitCode !== 0 || !localDockerTarget) {
724
+ const cancelled = await callTool("cancel_unclaimed_stack_lease", { lease_id: leaseId });
725
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
726
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
727
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
728
+ return emitResult(buildReceipt({
729
+ command: "up", outcome: "refused", lease_id: leaseId, state, slot,
730
+ error: checked.receipt.errors?.[0] || "OrbStack preflight did not return a stable Docker server identity",
731
+ preflight: localPreflight,
732
+ lease_cancellation: leaseCancellation,
733
+ }), opts, EXIT.LEASE_FAILED);
734
+ }
469
735
  let conn;
470
- try { conn = localProvision(); } catch (e) {
471
- return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
736
+ try {
737
+ conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget), localDockerTarget);
738
+ } catch (e) {
739
+ return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: e.message }), opts, EXIT.LEASE_FAILED);
472
740
  }
473
- const act = await callToolJson("activate_stack_lease", { lease_id: leaseId, connection: conn });
741
+ const act = await callTool("activate_stack_lease", { lease_id: leaseId, connection: conn });
474
742
  if (!act.ok || !act.data?.success) {
475
- return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
743
+ return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: act.error || act.data?.code || "activate failed" }), opts, EXIT.BACKEND);
476
744
  }
477
745
  } else {
478
746
  const active = await waitForLease(leaseId, isActive, isReaped, { timeoutSec: opts.timeout, auth });
479
- if (active.timeout) return emit(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state, slot, error: `provisioning did not reach active in ${opts.timeout}s (Helper may be down — retry with --local-exec)` }), opts, EXIT.TIMEOUT);
480
- if (active.failed) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
481
- if (active.error) return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
747
+ if (active.timeout) return emitResult(buildReceipt({ command: "up", outcome: "timeout", lease_id: leaseId, state, slot, error: `provisioning did not reach active in ${opts.timeout}s (Helper may be down — retry with --local-exec)` }), opts, EXIT.TIMEOUT);
748
+ if (active.failed) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: active.state, error: "lease was reaped before it became active" }), opts, EXIT.LEASE_FAILED);
749
+ if (active.error) return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, error: active.error }), opts, EXIT.BACKEND);
482
750
  }
483
751
  }
484
752
 
485
753
  // Authoritative final read (connection block, current state).
486
- const got = await callToolJson("get_stack_lease", { lease_id: leaseId });
754
+ const got = await callTool("get_stack_lease", { lease_id: leaseId });
487
755
  const g = got.ok && got.data?.success ? got.data : null;
488
756
  if (!g || g.state !== "active") {
489
- return emit(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: g?.state, error: g ? `lease is ${g.state}, not active` : (got.error || "could not read lease") }), opts, g?.state && isReaped(g.state) ? EXIT.LEASE_FAILED : EXIT.BACKEND);
757
+ return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: g?.state, error: g ? `lease is ${g.state}, not active` : (got.error || "could not read lease") }), opts, g?.state && isReaped(g.state) ? EXIT.LEASE_FAILED : EXIT.BACKEND);
490
758
  }
491
- return emit(buildReceipt({
759
+ return emitResult(buildReceipt({
492
760
  command: "up", outcome: "active", lease_id: leaseId, state: "active",
493
761
  host_key: g.host_key, slot: g.slot, connection: g.connection,
494
762
  idle_ttl_secs: g.idle_ttl_secs, resource_name: g.resource_name,
763
+ ...(localPreflight ? { preflight: localPreflight } : {}),
495
764
  }), opts, EXIT.OK);
496
765
  }
497
766
 
@@ -516,10 +785,50 @@ async function cmdTouch(leaseId, opts) {
516
785
  return emit(buildReceipt({ command: "touch", outcome: "touched", lease_id: leaseId, last_used_at: r.data.last_used_at }), opts, EXIT.OK);
517
786
  }
518
787
 
519
- async function cmdDone(leaseId, opts) {
520
- const r = await callToolJson("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
521
- if (!r.ok) return emit(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
522
- if (!r.data.success) return emit(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
788
+ export async function cmdDone(leaseId, opts, {
789
+ callTool = callToolJson,
790
+ runPreflight = runLocalExecPreflight,
791
+ proveLegacyTarget = proveLegacyLocalExecTarget,
792
+ localTeardownFn = localTeardown,
793
+ emitResult = emit,
794
+ } = {}) {
795
+ let dockerTarget = null;
796
+ let legacyTargetProof = null;
797
+ if (opts.localExec) {
798
+ const current = await callTool("get_stack_lease", { lease_id: leaseId });
799
+ if (!current.ok || !current.data?.success) {
800
+ return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
801
+ error: current.error || current.data?.code || "could not verify the lease Docker target" }), opts,
802
+ current.auth ? EXIT.AUTH : EXIT.BACKEND);
803
+ }
804
+ const expected = current.data.connection?.botbuddy_docker_target;
805
+ let checked;
806
+ try { checked = runPreflight(opts); } catch (error) {
807
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
808
+ error: `could not validate teardown Docker target: ${error.message}` }), opts, EXIT.LEASE_FAILED);
809
+ }
810
+ dockerTarget = dockerTargetFromPreflight(checked.receipt);
811
+ if (expected && !dockerTargetsMatch(expected, dockerTarget)) {
812
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
813
+ error: "teardown Docker target does not exactly match the OrbStack server persisted at provisioning; lease and slot remain fenced",
814
+ expected_docker_target: expected || null, observed_docker_target: dockerTarget,
815
+ }), opts, EXIT.LEASE_FAILED);
816
+ }
817
+ if (!expected) {
818
+ const proof = proveLegacyTarget(current.data, dockerTarget, opts);
819
+ if (!proof?.ok) {
820
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
821
+ error: `pre-1.5.0 lease has no persisted Docker target and compatibility proof failed: ${proof?.error || "unknown proof failure"}; lease and slot remain fenced`,
822
+ expected_docker_target: null, observed_docker_target: dockerTarget,
823
+ }), opts, EXIT.LEASE_FAILED);
824
+ }
825
+ legacyTargetProof = proof.evidence;
826
+ }
827
+ }
828
+
829
+ const r = await callTool("release_stack_lease", { lease_id: leaseId, disposition: opts.disposition });
830
+ if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
831
+ 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);
523
832
  let state = r.data.state;
524
833
  if (opts.localExec && state === "reaping") {
525
834
  // Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
@@ -527,17 +836,320 @@ async function cmdDone(leaseId, opts) {
527
836
  // the next queued lease, which would collide with containers still running on this
528
837
  // slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
529
838
  // the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
530
- if (!localTeardown()) {
531
- return emit(buildReceipt({
839
+ if (!localTeardownFn(opts, dockerTarget)) {
840
+ return emitResult(buildReceipt({
532
841
  command: "done", outcome: "error", lease_id: leaseId, state,
533
842
  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.",
534
843
  }), opts, EXIT.LEASE_FAILED);
535
844
  }
536
- const fin = await callToolJson("finalize_stack_lease", { lease_id: leaseId });
845
+ const fin = await callTool("finalize_stack_lease", { lease_id: leaseId });
537
846
  if (fin.ok && fin.data?.success) state = fin.data.state;
538
847
  else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
539
848
  }
540
- return emit(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition }), opts, EXIT.OK);
849
+ return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition: opts.disposition,
850
+ ...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
851
+ }
852
+
853
+ const SIGNAL_EXIT = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
854
+
855
+ function safeConnectionPath(requested, leaseId) {
856
+ if (requested) {
857
+ // `wx` below prevents clobbering; canonicalising the parent prevents a
858
+ // symlinked directory from redirecting the only credential-bearing file.
859
+ const filename = basename(requested);
860
+ if (!filename || filename === "." || filename === "..") throw new Error("--connection-file must name a file");
861
+ return `${realpathSync(dirname(requested))}/${filename}`;
862
+ }
863
+ return `${tmpdir()}/botbuddy-stack-${leaseId}-${randomUUID()}.json`;
864
+ }
865
+
866
+ /** Write credentials atomically without ever putting them into argv, output, or a receipt. */
867
+ async function writeConnectionFile(path, connection) {
868
+ return writePrivateTextFile(path, JSON.stringify(connection));
869
+ }
870
+
871
+ /** Write a credential-bearing text artifact (dotenv/TOML/JSON) mode 0600. */
872
+ async function writePrivateTextFile(path, text) {
873
+ const handle = await open(path, "wx", 0o600);
874
+ try {
875
+ await handle.writeFile(text);
876
+ } catch (error) {
877
+ await unlink(path).catch(() => {});
878
+ throw error;
879
+ } finally {
880
+ await handle.close();
881
+ }
882
+ return path;
883
+ }
884
+
885
+ function childGroupSignal(child, signal) {
886
+ if (!child?.pid) return;
887
+ try { process.kill(-child.pid, signal); }
888
+ catch { try { child.kill(signal); } catch { /* already gone */ } }
889
+ }
890
+
891
+ function waitForChild(child) {
892
+ return new Promise((resolveChild, rejectChild) => {
893
+ child.once("error", rejectChild);
894
+ child.once("exit", (code, signal) => resolveChild({ code: code ?? (signal ? SIGNAL_EXIT[signal] ?? 1 : 1), signal }));
895
+ });
896
+ }
897
+
898
+ function isDenoIntegrationLane(argv) {
899
+ // `test:all` is the supported package wrapper that transitively invokes
900
+ // `test:integration`; both must receive the leased runner configuration.
901
+ return argv.includes("test:integration") || argv.includes("test:integration:coverage") || argv.includes("test:all") || argv.includes("scripts/run-deno-integration.sh");
902
+ }
903
+
904
+ function requireConnectionText(connection, key) {
905
+ const value = connection?.[key];
906
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`active stack connection is missing ${key}`);
907
+ return value.trim();
908
+ }
909
+
910
+ /** Materialise the established integration runner inputs from a Helper receipt.
911
+ * The runner already understands BB_INTEGRATION_ENV_FILE / BB_STACK_CONFIG;
912
+ * never let its shared-stack defaults silently validate the wrong stack. */
913
+ export async function materializeLeasedTestConfig(worktreeRoot, leaseId, connection, { writePrivate = writePrivateTextFile, removePrivate = (path) => unlink(path).catch(() => {}) } = {}) {
914
+ const apiUrl = requireConnectionText(connection, "api_url");
915
+ const anonKey = requireConnectionText(connection, "anon_key");
916
+ const serviceRoleKey = requireConnectionText(connection, "service_role_key");
917
+ const projectId = requireConnectionText(connection, "project_id");
918
+ const parsedApi = new URL(apiUrl);
919
+ if (!parsedApi.port || !["127.0.0.1", "localhost"].includes(parsedApi.hostname)) {
920
+ throw new Error("active stack connection api_url must be a local host URL with an explicit port");
921
+ }
922
+ const dbPort = Number(connection.db_port ?? new URL(requireConnectionText(connection, "db_url")).port);
923
+ if (!Number.isInteger(dbPort) || dbPort <= 0 || dbPort > 65535) throw new Error("active stack connection is missing a valid db_port");
924
+
925
+ const basePath = `${worktreeRoot}/supabase/functions/_test/integration/.env.integration`;
926
+ const base = await readFile(basePath, "utf8");
927
+ const values = {
928
+ SUPABASE_URL: apiUrl,
929
+ VITE_SUPABASE_URL: apiUrl,
930
+ SUPABASE_ANON_KEY: anonKey,
931
+ VITE_SUPABASE_PUBLISHABLE_KEY: anonKey,
932
+ SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
933
+ INTEGRATION_DB_PORT: String(dbPort),
934
+ };
935
+ const seen = new Set();
936
+ const env = base.split(/\r?\n/).map((line) => {
937
+ const key = /^([A-Z0-9_]+)=/.exec(line)?.[1];
938
+ if (!key || !(key in values)) return line;
939
+ seen.add(key);
940
+ return `${key}=${values[key]}`;
941
+ });
942
+ for (const [key, value] of Object.entries(values)) if (!seen.has(key)) env.push(`${key}=${value}`);
943
+
944
+ const stamp = `${tmpdir()}/botbuddy-stack-${leaseId}-${randomUUID()}`;
945
+ const envFile = `${stamp}.env`;
946
+ const stackConfig = `${stamp}.toml`;
947
+ await writePrivate(envFile, env.join("\n"));
948
+ try {
949
+ await writePrivate(stackConfig, `project_id = "${projectId}"\n\n[api]\nport = ${parsedApi.port}\n\n[db]\nport = ${dbPort}\n`);
950
+ } catch (error) {
951
+ await removePrivate(envFile);
952
+ throw error;
953
+ }
954
+ return { envFile, stackConfig, edgeMountRoot: worktreeRoot };
955
+ }
956
+
957
+ /**
958
+ * The command-owned lifecycle. The optional adapters are intentionally narrow so
959
+ * its queue, heartbeat, process-group, and cleanup contract is spawn-testable
960
+ * without Docker or a live BotBuddy service.
961
+ */
962
+ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
963
+ const api = adapters.api ?? {
964
+ request: (args) => callToolJson("request_stack_lease", args),
965
+ get: (leaseId) => callToolJson("get_stack_lease", { lease_id: leaseId }),
966
+ touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }),
967
+ release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
968
+ };
969
+ const auth = adapters.auth ?? stackAuthHeader();
970
+ const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
971
+ // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
972
+ const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
973
+ const writeConnection = adapters.writeConnection ?? writeConnectionFile;
974
+ const removeConnection = adapters.removeConnection ?? ((path) => unlink(path).catch(() => {}));
975
+ const materializeTestConfig = adapters.materializeTestConfig ?? materializeLeasedTestConfig;
976
+ const clock = adapters.clock ?? { setInterval, clearInterval, setTimeout, clearTimeout };
977
+ const signals = adapters.signals ?? process;
978
+ const provisionTimeout = opts.provisionTimeout ?? opts.timeout;
979
+ let leaseId = null;
980
+ let child = null;
981
+ let childResult = null;
982
+ let connectionFile = null;
983
+ let heartbeat = null;
984
+ let hardTimer = null;
985
+ let cleanupResult = null;
986
+ let cleanupPromise = null;
987
+ let leasedTestConfig = null;
988
+ let receivedSignal = null;
989
+ let forcedStopCode = null;
990
+ let fencing = false;
991
+ let activeWaitAbort = null;
992
+
993
+ const requestCleanup = () => {
994
+ // A signal can arrive while the lease-request RPC is in flight. There is
995
+ // nothing to release yet, so do not cache this no-op; once the RPC returns
996
+ // the interruption branch must perform the real signed cleanup.
997
+ if (!leaseId) return Promise.resolve({ ok: true, state: null });
998
+ if (cleanupPromise) return cleanupPromise;
999
+ cleanupPromise = (async () => {
1000
+ if (!leaseId) return cleanupResult = { ok: true, state: null };
1001
+ const cleanupAbort = new AbortController();
1002
+ const cleanupTimer = clock.setTimeout(() => cleanupAbort.abort("reap-timeout"), opts.reapTimeout * 1000);
1003
+ let release;
1004
+ try {
1005
+ release = await api.release(leaseId, cleanupAbort.signal);
1006
+ } finally {
1007
+ clock.clearTimeout(cleanupTimer);
1008
+ }
1009
+ if (!release?.ok || !release?.data?.success) {
1010
+ return cleanupResult = { ok: false, error: release?.error || release?.data?.code || "release failed" };
1011
+ }
1012
+ if (release.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1013
+ const reaped = await wait(leaseId, (state) => state === "reaped", () => false, { timeoutSec: opts.reapTimeout, auth });
1014
+ if (reaped?.woke || reaped?.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1015
+ return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
1016
+ })();
1017
+ return cleanupPromise;
1018
+ };
1019
+
1020
+ const stopChild = (signal = "SIGTERM", forcedCode = null) => {
1021
+ if (!child || childResult) return;
1022
+ forcedStopCode ??= forcedCode;
1023
+ childGroupSignal(child, signal);
1024
+ const kill = clock.setTimeout(() => childGroupSignal(child, "SIGKILL"), 10_000);
1025
+ kill.unref?.();
1026
+ };
1027
+
1028
+ const onSignal = (signal) => {
1029
+ if (receivedSignal) return;
1030
+ receivedSignal = signal;
1031
+ activeWaitAbort?.abort("interrupted");
1032
+ stopChild(signal);
1033
+ // If capacity is still queued/provisioning there is no child to hold open;
1034
+ // release immediately so an interrupted invocation cannot later provision.
1035
+ if (!child) void requestCleanup();
1036
+ };
1037
+ const signalHandlers = new Map();
1038
+ for (const signal of Object.keys(SIGNAL_EXIT)) {
1039
+ const handler = () => onSignal(signal);
1040
+ signalHandlers.set(signal, handler);
1041
+ signals.on?.(signal, handler);
1042
+ }
1043
+
1044
+ try {
1045
+ if (!auth) return { exitCode: EXIT.AUTH, outcome: "error", error: "not authenticated — run botbuddy profile setup botbuddy-dev" };
1046
+ const slot = deriveSlot(opts);
1047
+ const execution = resolveStackPath(process.cwd(), opts.stackPath);
1048
+ const request = await api.request({
1049
+ slot, host_key: opts.host || undefined, repo: opts.repo, ticket_id: opts.ticket,
1050
+ ticket_url: opts.ticketUrl || undefined, pr_id: opts.prId || undefined, pr_url: opts.prUrl || undefined,
1051
+ purpose: opts.purpose || "stack run", idle_ttl_secs: opts.idleTtl ?? undefined,
1052
+ stack_path: execution.stackPath, worktree_root: execution.worktreeRoot,
1053
+ });
1054
+ if (!request?.ok) return { exitCode: request?.auth ? EXIT.AUTH : EXIT.BACKEND, outcome: "error", error: request?.error || "lease request failed" };
1055
+ if (!request.data?.success) return { exitCode: EXIT.BACKEND, outcome: "error", error: request.data?.message || request.data?.code || "lease request refused" };
1056
+ leaseId = request.data.lease_id;
1057
+ if (request.data.reused) {
1058
+ return { exitCode: EXIT.BACKEND, outcome: "error", leaseId, error: "a live lease already exists for this agent and stack slot; wait for that batch to finish instead of sharing its stack" };
1059
+ }
1060
+
1061
+ let state = request.data.state;
1062
+ if (receivedSignal) {
1063
+ const cleanup = await requestCleanup();
1064
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
1065
+ }
1066
+ if (state === "queued") {
1067
+ activeWaitAbort = new AbortController();
1068
+ const parked = await wait(leaseId, (s) => s !== "queued" && s != null, (s) => s === "reaped", { timeoutSec: opts.timeout, auth, signal: activeWaitAbort.signal });
1069
+ activeWaitAbort = null;
1070
+ if (parked?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
1071
+ if (parked?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `parked ${opts.timeout}s without capacity`, cleanup }; }
1072
+ if (parked?.failed || parked?.error || parked?.auth) { const cleanup = await requestCleanup(); return { exitCode: parked?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: parked?.error || "lease did not leave queue", cleanup }; }
1073
+ state = parked.state;
1074
+ }
1075
+ if (state !== "active") {
1076
+ activeWaitAbort = new AbortController();
1077
+ const active = await wait(leaseId, (s) => s === "active", (s) => s === "reaped", { timeoutSec: provisionTimeout, auth, signal: activeWaitAbort.signal });
1078
+ activeWaitAbort = null;
1079
+ if (active?.interrupted || receivedSignal) { const cleanup = await requestCleanup(); return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup }; }
1080
+ if (active?.timeout) { const cleanup = await requestCleanup(); return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup }; }
1081
+ if (active?.failed || active?.error || active?.auth) { const cleanup = await requestCleanup(); return { exitCode: active?.auth ? EXIT.AUTH : EXIT.LEASE_FAILED, outcome: "error", leaseId, error: active?.error || "lease was reaped before active", cleanup }; }
1082
+ }
1083
+ const current = await api.get(leaseId);
1084
+ if (!current?.ok || !current.data?.success || current.data.state !== "active" || !current.data.connection || typeof current.data.connection !== "object") {
1085
+ const cleanup = await requestCleanup();
1086
+ return { exitCode: EXIT.LEASE_FAILED, outcome: "error", leaseId, error: current?.error || `lease is ${current?.data?.state ?? "unreadable"}, not active with a connection`, cleanup };
1087
+ }
1088
+ if (receivedSignal) {
1089
+ const cleanup = await requestCleanup();
1090
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
1091
+ }
1092
+
1093
+ connectionFile = await writeConnection(safeConnectionPath(opts.connectionFile, leaseId), current.data.connection);
1094
+ if (receivedSignal) {
1095
+ const cleanup = await requestCleanup();
1096
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
1097
+ }
1098
+ if (isDenoIntegrationLane(childArgv)) leasedTestConfig = await materializeTestConfig(execution.worktreeRoot, leaseId, current.data.connection);
1099
+ if (receivedSignal) {
1100
+ const cleanup = await requestCleanup();
1101
+ return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
1102
+ }
1103
+ const childEnv = {
1104
+ ...process.env,
1105
+ BOTBUDDY_STACK_LEASE_ID: leaseId,
1106
+ BOTBUDDY_STACK_CONNECTION_FILE: connectionFile,
1107
+ ...(leasedTestConfig ? {
1108
+ BB_INTEGRATION_ENV_FILE: leasedTestConfig.envFile,
1109
+ BB_STACK_CONFIG: leasedTestConfig.stackConfig,
1110
+ BB_EDGE_MOUNT_ROOT: leasedTestConfig.edgeMountRoot,
1111
+ } : {}),
1112
+ };
1113
+ // resolveStackPath already canonicalised this bounded relative path.
1114
+ const childCwd = execution.stackPath === "." ? execution.worktreeRoot : `${execution.worktreeRoot}/${execution.stackPath}`;
1115
+ child = startChild(childArgv, childEnv, childCwd);
1116
+ const cadenceMs = Math.max(1_000, Math.floor((current.data.idle_ttl_secs ?? opts.idleTtl ?? 1800) * 1000 / 3));
1117
+ heartbeat = clock.setInterval(async () => {
1118
+ if (fencing || childResult) return;
1119
+ const touched = await api.touch(leaseId);
1120
+ if (!touched?.ok || !touched?.data?.success) {
1121
+ fencing = true;
1122
+ stopChild("SIGTERM", EXIT.LEASE_FAILED);
1123
+ }
1124
+ }, cadenceMs);
1125
+ heartbeat.unref?.();
1126
+ if (opts.hardTtl) hardTimer = clock.setTimeout(() => stopChild("SIGTERM", EXIT.TIMEOUT), opts.hardTtl * 1000);
1127
+ hardTimer?.unref?.();
1128
+ childResult = await waitForChild(child);
1129
+ if (heartbeat) clock.clearInterval(heartbeat);
1130
+ if (hardTimer) clock.clearTimeout(hardTimer);
1131
+ const cleanup = await requestCleanup();
1132
+ const exitCode = receivedSignal ? SIGNAL_EXIT[receivedSignal] : forcedStopCode ?? (childResult.code !== 0 ? childResult.code : !cleanup.ok ? EXIT.CLEANUP_FAILED : 0);
1133
+ return { exitCode, outcome: exitCode === 0 ? "completed" : "failed", leaseId, childExitCode: childResult.code, cleanup, fenced: fencing };
1134
+ } catch (error) {
1135
+ const cleanup = await requestCleanup();
1136
+ return { exitCode: cleanup?.ok === false ? EXIT.CLEANUP_FAILED : EXIT.INTERNAL, outcome: "error", leaseId, error: String(error?.message ?? error), cleanup };
1137
+ } finally {
1138
+ if (heartbeat) clock.clearInterval(heartbeat);
1139
+ if (hardTimer) clock.clearTimeout(hardTimer);
1140
+ if (connectionFile) await removeConnection(connectionFile);
1141
+ if (leasedTestConfig) await Promise.all([removeConnection(leasedTestConfig.envFile), removeConnection(leasedTestConfig.stackConfig)]);
1142
+ for (const [signal, handler] of signalHandlers) signals.off?.(signal, handler);
1143
+ }
1144
+ }
1145
+
1146
+ async function cmdRun(opts, childArgv) {
1147
+ const result = await runStackLifecycle(opts, childArgv);
1148
+ return emit(buildReceipt({
1149
+ command: "run", outcome: result.outcome, lease_id: result.leaseId ?? null,
1150
+ child_exit_code: result.childExitCode ?? null, cleanup: result.cleanup?.ok ?? null,
1151
+ fenced: result.fenced ?? false, error: result.error ?? result.cleanup?.error ?? null,
1152
+ }), opts, result.exitCode);
541
1153
  }
542
1154
 
543
1155
  /** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
@@ -546,7 +1158,7 @@ export async function cmdStack(argv) {
546
1158
  process.stdout.write(STACK_HELP + "\n");
547
1159
  return EXIT.OK;
548
1160
  }
549
- const { command, leaseId, opts, errors } = parseStackArgs(argv);
1161
+ const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
550
1162
  if (errors.length) {
551
1163
  for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
552
1164
  const code = emit(buildReceipt({ command: command || "?", outcome: "error", error: errors[0] }), opts, EXIT.INVALID);
@@ -560,6 +1172,7 @@ export async function cmdStack(argv) {
560
1172
  case "status": code = await cmdStatus(leaseId, opts); break;
561
1173
  case "touch": code = await cmdTouch(leaseId, opts); break;
562
1174
  case "done": code = await cmdDone(leaseId, opts); break;
1175
+ case "run": code = await cmdRun(opts, childArgv); break;
563
1176
  default:
564
1177
  process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
565
1178
  code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);