@botbuddy/cli 1.33.2 → 1.33.4

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.2",
3
+ "version": "1.33.4",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  import { createHash, randomUUID } from "node:crypto";
23
23
  import { cpSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24
24
  import { homedir, hostname } from "node:os";
25
- import { basename, join } from "node:path";
25
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
26
26
  import { lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
27
27
 
28
28
  /** Where materialized leased stacks live inside the worktree (gitignored). */
@@ -871,6 +871,202 @@ function prune(stackSupabaseDir, { readdir, remove }) {
871
871
  }
872
872
  }
873
873
 
874
+ // ── BOT-1828: functions that import a file outside supabase/ ───────────────
875
+ //
876
+ // Supply Guard's `supabase/functions/admin-api-keys/index.ts` imports
877
+ // `../../../src/lib/platformApiKeysCatalog.ts` — outside `supabase/`, so the whole-tree
878
+ // copy above never brings it along. The Supabase CLI resolves function imports when it
879
+ // STARTS the edge runtime, so `supabase start` inside the isolated stack fails deep
880
+ // inside the Helper with a raw ENOENT. This is a dependency-free regex walk over
881
+ // `import …/export …/from "…"` and dynamic `import("…")` string literals — not a real
882
+ // module resolver (the published CLI is Node-stdlib only) — so it can miss an exotic
883
+ // re-export form, but every specifier it DOES find is resolved for real (existence +
884
+ // worktree-containment) before anything is trusted.
885
+ const ESCAPING_IMPORT_SCAN_EXTENSIONS = [".ts", ".js", ".mjs"];
886
+ const ESCAPING_IMPORT_MANIFEST_NAMES = ["import_map.json", "deno.json"];
887
+ const SUPABASE_DIR_PREFIX = `supabase${sep}`;
888
+
889
+ const IMPORT_SPECIFIER_PATTERNS = [
890
+ // `import … from "…"` / `export … from "…"` (default, named, namespace, type-only).
891
+ /\b(?:import|export)\b[^'";]*?\bfrom\s*["']([^"']+)["']/g,
892
+ // dynamic `import("…")`.
893
+ /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
894
+ // bare side-effecting `import "…"` (no `from` clause).
895
+ /^[ \t]*import\s*["']([^"']+)["']\s*;?/gm,
896
+ ];
897
+
898
+ /** Strip comments before scanning for import specifiers (Codex #946 R1 P2): a
899
+ * `// import { old } from "../../../removed.ts"` note (or block comment) is not a
900
+ * live dependency, but the plain regex above has no idea — treating it as one turns
901
+ * a stale comment into a hard refusal of the whole stack. Block comments are removed
902
+ * outright; `//` line comments are stripped with a quote-aware per-character scan so
903
+ * a real specifier like `"https://deno.land/std/foo.ts"` (a `//` INSIDE a string) is
904
+ * never mistaken for a comment. Not a real tokenizer — a `/*`/`//` inside a template
905
+ * literal can still confuse it — but it closes the concrete failure mode a bare
906
+ * comment caused, which is what actually shipped in real function source. */
907
+ function stripComments(source) {
908
+ const noBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, "");
909
+ return noBlockComments
910
+ .split("\n")
911
+ .map((line) => {
912
+ let inString = null;
913
+ for (let i = 0; i < line.length; i++) {
914
+ const ch = line[i];
915
+ if (inString) {
916
+ if (ch === "\\") { i++; continue; }
917
+ if (ch === inString) inString = null;
918
+ continue;
919
+ }
920
+ if (ch === '"' || ch === "'" || ch === "`") { inString = ch; continue; }
921
+ if (ch === "/" && line[i + 1] === "/") return line.slice(0, i);
922
+ }
923
+ return line;
924
+ })
925
+ .join("\n");
926
+ }
927
+
928
+ /** Every string-literal specifier this source imports or re-exports, deduped. */
929
+ function extractImportSpecifiers(source) {
930
+ const specifiers = new Set();
931
+ const clean = stripComments(source);
932
+ for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
933
+ pattern.lastIndex = 0;
934
+ let match;
935
+ while ((match = pattern.exec(clean)) !== null) specifiers.add(match[1]);
936
+ }
937
+ return specifiers;
938
+ }
939
+
940
+ function isRelativeSpecifier(specifier) {
941
+ return specifier.startsWith("./") || specifier.startsWith("../");
942
+ }
943
+
944
+ /** Recursively list files under `dir` (absolute) whose name matches one of `names`
945
+ * (exact basenames, e.g. "deno.json") or ends with one of `exts` (e.g. ".ts") — via
946
+ * the same low-level readdir/stat the rest of the materializer uses, so a
947
+ * fault-injecting test still exercises this path. A missing `dir` has nothing to scan. */
948
+ function listMatchingFiles(dir, { exts = [], names = [] }, { readdir, stat }) {
949
+ const out = [];
950
+ const pending = [dir];
951
+ while (pending.length) {
952
+ const current = pending.pop();
953
+ let entries;
954
+ try { entries = readdir(current); } catch { continue; }
955
+ for (const entry of entries) {
956
+ const full = join(current, entry);
957
+ let info;
958
+ try { info = stat(full); } catch { continue; }
959
+ if (info.isDirectory()) { pending.push(full); continue; }
960
+ if (names.includes(entry) || exts.some((ext) => entry.endsWith(ext))) out.push(full);
961
+ }
962
+ }
963
+ return out;
964
+ }
965
+
966
+ /**
967
+ * Scan `supabase/functions/**` (source files plus `import_map.json`/`deno.json`
968
+ * "imports" entries) for RELATIVE specifiers that resolve outside `supabase/`, copy
969
+ * each into the stack directory at the identical worktree-relative path, and follow
970
+ * relative imports of the COPIED files transitively. Refuses loudly — before this
971
+ * call copies anything — if a specifier resolves outside the worktree root or to a
972
+ * file that does not exist, naming the importing file and the specifier. Returns the
973
+ * sorted list of worktree-relative paths copied (possibly empty).
974
+ */
975
+ function copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath }) {
976
+ const functionsDir = join(worktreeRoot, "supabase", "functions");
977
+ const visited = new Set(); // worktree-relative paths already queued or copied
978
+ const queue = [];
979
+
980
+ const resolveSpecifier = (specifier, importingAbsDir, importingRelPath) => {
981
+ // Lexical: `join` collapses `..`/`.` as STRINGS but never follows a symlinked
982
+ // directory component. fs reads/copies below follow symlinks transparently, so
983
+ // this is a perfectly usable path for existence/content — and, per Codex #946
984
+ // R1 P2, it is the path an import specifier reads relative to the COPIED file's
985
+ // own on-disk location once materialized, so it is what the destination and any
986
+ // further relative-import resolution must use too.
987
+ const candidate = join(importingAbsDir, specifier);
988
+ let real;
989
+ try {
990
+ real = realpath(candidate);
991
+ } catch (error) {
992
+ throw new Error(
993
+ `refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which does not exist ` +
994
+ `(resolved to ${candidate}${error?.code ? `: ${error.code}` : ""}). Fix the import or add the missing file.`,
995
+ );
996
+ }
997
+ // Security containment uses the REAL, symlink-resolved location: a symlink
998
+ // whose TARGET is outside the worktree must be refused even when its own
999
+ // lexical path looks like it stays inside.
1000
+ const realRel = relative(worktreeRoot, real);
1001
+ if (realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
1002
+ throw new Error(
1003
+ `refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which resolves to ` +
1004
+ `${real} — outside the worktree root ${worktreeRoot}. Move the file into the worktree, or fix the import.`,
1005
+ );
1006
+ }
1007
+ // The DESTINATION (and this file's own identity in `visited`/`queue`) mirrors
1008
+ // the LEXICAL candidate, not `real`: copying to `real`'s location would place
1009
+ // the file somewhere the unmodified import specifier never names, and the
1010
+ // materialized stack would fail with the same missing-file error this ticket
1011
+ // exists to fix (Codex #946 R1 P2).
1012
+ const rel = relative(worktreeRoot, candidate);
1013
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
1014
+ throw new Error(
1015
+ `refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which resolves to ` +
1016
+ `${candidate} — outside the worktree root ${worktreeRoot}. Move the file into the worktree, or fix the import.`,
1017
+ );
1018
+ }
1019
+ if (rel === "supabase" || rel.startsWith(SUPABASE_DIR_PREFIX)) return; // already copied whole
1020
+ if (visited.has(rel)) return;
1021
+ visited.add(rel);
1022
+ queue.push({ absPath: candidate, relPath: rel });
1023
+ };
1024
+
1025
+ const scanForImports = (absPath, relPath) => {
1026
+ let source;
1027
+ try { source = read(absPath, "utf8"); } catch { return; }
1028
+ for (const specifier of extractImportSpecifiers(source)) {
1029
+ if (isRelativeSpecifier(specifier)) resolveSpecifier(specifier, dirname(absPath), relPath);
1030
+ }
1031
+ };
1032
+
1033
+ for (const file of listMatchingFiles(functionsDir, { exts: ESCAPING_IMPORT_SCAN_EXTENSIONS }, { readdir, stat })) {
1034
+ scanForImports(file, relative(worktreeRoot, file));
1035
+ }
1036
+ for (const file of listMatchingFiles(functionsDir, { names: ESCAPING_IMPORT_MANIFEST_NAMES }, { readdir, stat })) {
1037
+ let parsed;
1038
+ try { parsed = JSON.parse(read(file, "utf8")); } catch { continue; }
1039
+ const imports = parsed?.imports;
1040
+ if (!imports || typeof imports !== "object") continue;
1041
+ const relPath = relative(worktreeRoot, file);
1042
+ for (const specifier of Object.values(imports)) {
1043
+ if (typeof specifier === "string" && isRelativeSpecifier(specifier)) {
1044
+ resolveSpecifier(specifier, dirname(file), relPath);
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ const extraFiles = [];
1050
+ while (queue.length) {
1051
+ const item = queue.shift();
1052
+ const dest = join(stackDir, item.relPath);
1053
+ mkdir(dirname(dest), { recursive: true });
1054
+ // An import-map "imports" entry may be a PREFIX mapping to a directory (e.g.
1055
+ // `"@lib/": "../../../src/lib/"`, valid Deno import-map syntax) rather than a
1056
+ // single file — cpSync without `recursive` throws ERR_FS_EISDIR on that (Codex
1057
+ // #946 R1 P1). Copy the whole directory in that case; there is no single file
1058
+ // whose text to scan for further imports.
1059
+ let isDir = false;
1060
+ try { isDir = stat(item.absPath).isDirectory(); } catch { /* copy() will report it */ }
1061
+ copy(item.absPath, dest, isDir ? { recursive: true, force: true } : { force: true });
1062
+ extraFiles.push(item.relPath);
1063
+ if (!isDir && ESCAPING_IMPORT_SCAN_EXTENSIONS.some((ext) => item.relPath.endsWith(ext))) {
1064
+ scanForImports(item.absPath, item.relPath);
1065
+ }
1066
+ }
1067
+ return extraFiles.sort();
1068
+ }
1069
+
874
1070
  /**
875
1071
  * Materialize (or refresh) the isolated stack directory for this run and return
876
1072
  * its path RELATIVE to the worktree root — never `"."`. The directory belongs to
@@ -941,7 +1137,7 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
941
1137
  // stable per-slot one, so the project id is exactly as specific (and keeps a
942
1138
  // refresh idempotent).
943
1139
  const invocationToken = nonce ? String(nonce) : identity.projectId;
944
- const marker = (portBase) => JSON.stringify({
1140
+ const marker = (portBase, extraFiles = []) => JSON.stringify({
945
1141
  schema: 1,
946
1142
  project_id: identity.projectId,
947
1143
  invocation_token: invocationToken,
@@ -953,6 +1149,10 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
953
1149
  retain_until: createdAt + Math.max(LEASED_STACK_GC_MIN_AGE_MS, ttlMs + LEASED_STACK_RETENTION_GRACE_MS),
954
1150
  owner_pid: process.pid,
955
1151
  host: host(),
1152
+ // BOT-1828: worktree-relative paths copied because an edge function imports
1153
+ // outside supabase/ — recorded so an operator can see why this stack dir has
1154
+ // (e.g.) a src/ directory, instead of guessing at a `supabase start` failure.
1155
+ extra_files: extraFiles,
956
1156
  });
957
1157
  const registryDir = io.portRegistryDir ?? defaultPortRegistryDir();
958
1158
  const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
@@ -1016,11 +1216,18 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1016
1216
  filter: (source) => !SKIP_ENTRIES.has(basename(source)),
1017
1217
  });
1018
1218
  write(join(stackDir, "supabase", "config.toml"), text);
1219
+
1220
+ // BOT-1828: bring along any file an edge function imports OUTSIDE supabase/
1221
+ // (Supply Guard's admin-api-keys/index.ts pulls in ../../../src/lib/…) —
1222
+ // `supabase start` resolves function imports when it starts the edge runtime, so
1223
+ // without this the Helper fails deep inside `supabase start` with a raw ENOENT.
1224
+ const extraFiles = copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath: resolveRoot });
1225
+
1019
1226
  // Re-state the claim after the copy: the marker records who owns this directory,
1020
1227
  // which port block it holds, and how long a later run must keep it (Codex #928
1021
1228
  // R7/R12). It lives beside `supabase/`, which the refresh replaces wholesale.
1022
- atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase), { write, rename });
1023
- atomicWrite(portClaimPath(registryDir, portBase), marker(portBase), { write, rename });
1229
+ atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase, extraFiles), { write, rename });
1230
+ atomicWrite(portClaimPath(registryDir, portBase), marker(portBase, extraFiles), { write, rename });
1024
1231
  return stackPath;
1025
1232
  } catch (error) {
1026
1233
  try { remove(portClaimPath(registryDir, portBase), { force: true }); } catch { /* best effort */ }
package/src/stack.mjs CHANGED
@@ -713,6 +713,20 @@ async function registerLeaseWait(leaseId, timeoutSec, auth, signal, fetchImpl =
713
713
  return { waitSessionId: body.wait_session_id ?? null, cursorStart: body.cursor_start ?? null };
714
714
  }
715
715
 
716
+ // BOT-1827: the abort reason is always one of these four explicit strings —
717
+ // branch on the reason itself rather than inferring "anything but interrupted
718
+ // is a timeout". "matched"/"failed" are only ever set AFTER the SSE loop has
719
+ // already been left via `break` (never from inside the `for await`), so the
720
+ // controller's own teardown can never race a `{ woke: true }` / `{ failed: true }`
721
+ // return the way it did when `ac.abort()` was called before returning from
722
+ // inside the loop.
723
+ function resultForAbortReason(reason) {
724
+ if (reason === "interrupted") return { result: { interrupted: true }, status: "error" };
725
+ // "timeout", or any other/unset reason (e.g. an externally-triggered AbortError
726
+ // with no reason attached) — defensively treated the same as a real deadline.
727
+ return { result: { timeout: true }, status: "timeout" };
728
+ }
729
+
716
730
  /**
717
731
  * Zero-poll wait for `lease:<leaseId>` to reach a state satisfying `isDone(state)`.
718
732
  * Registers a wait_session (visible on /waits), then blocks on the event-stream SSE.
@@ -751,11 +765,17 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
751
765
  }
752
766
  return result;
753
767
  };
754
- if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
768
+ if (ac.signal.aborted) {
769
+ const { result, status } = resultForAbortReason(ac.signal.reason);
770
+ return finish(result, status);
771
+ }
755
772
  try {
756
773
  ({ cursorStart, waitSessionId } = await registerLeaseWait(leaseId, Math.max(0, Math.ceil((absoluteDeadlineMs - Date.now()) / 1000)), auth, ac.signal, fetchImpl));
757
774
  } catch (err) {
758
- if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
775
+ if (ac.signal.aborted) {
776
+ const { result, status } = resultForAbortReason(ac.signal.reason);
777
+ return finish(result, status);
778
+ }
759
779
  process.stderr.write(`${yellow("⚠")} stack: wait registration failed (${err?.message ?? err}); the lease will not appear on /waits — parking live-only.\n`);
760
780
  }
761
781
  const url = new URL(eventStreamBase());
@@ -767,15 +787,27 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
767
787
  try {
768
788
  res = await fetchImpl(url, { headers: { ...relayAuthHeaders(auth), Accept: "text/event-stream", "Accept-Encoding": "identity" }, signal: ac.signal });
769
789
  } catch (err) {
770
- if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
790
+ if (ac.signal.aborted) {
791
+ const { result, status } = resultForAbortReason(ac.signal.reason);
792
+ return finish(result, status);
793
+ }
771
794
  return finish({ error: `sse connect: ${err?.message ?? err}` }, "error");
772
795
  }
773
796
  if (res.status === 401 || res.status === 403) return finish({ auth: true }, "error");
774
797
  if (!res.ok || !res.body) return finish({ error: `sse responded ${res.status}` }, "error");
775
798
  const decoder = new TextDecoder();
776
799
  let buf = "";
800
+ // BOT-1827: record a match/failure locally and `break` out of BOTH loops
801
+ // without touching `ac`/`abort()` from inside the `for await`. Aborting the
802
+ // controller while it is still the signal driving the body iterator makes
803
+ // the implicit IteratorClose (triggered by `break`/`return`) reject with an
804
+ // AbortError, which used to land in the `catch` below and override the
805
+ // already-decided `{ woke: true }` / `{ failed: true }` result with a
806
+ // `{ timeout: true }` — finalizing the wait session twice. Deciding first,
807
+ // aborting only AFTER the loop has been left cleanly, removes the race.
808
+ let decided = null;
777
809
  try {
778
- for await (const chunk of res.body) {
810
+ frameLoop: for await (const chunk of res.body) {
779
811
  buf += decoder.decode(chunk, { stream: true });
780
812
  const { frames, rest } = parseSseFrames(buf);
781
813
  buf = rest;
@@ -784,13 +816,26 @@ export async function waitForLease(leaseId, isDone, isFailed, { timeoutSec, auth
784
816
  try { sig = JSON.parse(f.data); } catch { continue; }
785
817
  if (sig.signal_type !== "stack_lease" || sig.subject_key !== `lease:${leaseId}`) continue;
786
818
  const st = sig.payload?.state ?? null;
787
- if (isFailed(st)) { ac.abort(); return finish({ failed: true, state: st }, "error"); }
788
- if (isDone(st)) { ac.abort(); return finish({ woke: true, state: st }, "matched"); }
819
+ if (isFailed(st)) { decided = { reason: "failed", result: { failed: true, state: st }, status: "error" }; break frameLoop; }
820
+ if (isDone(st)) { decided = { reason: "matched", result: { woke: true, state: st }, status: "matched" }; break frameLoop; }
789
821
  }
790
822
  }
791
823
  } catch (err) {
792
- if (ac.signal.aborted) return finish(ac.signal.reason === "interrupted" ? { interrupted: true } : { timeout: true }, ac.signal.reason === "interrupted" ? "error" : "timeout");
793
- return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
824
+ // A decided result always wins over a teardown error surfaced by leaving
825
+ // the loop (e.g. the body iterator's `return()` rejecting during
826
+ // cleanup) — fall through to the `decided` handling below instead of
827
+ // reporting the teardown failure.
828
+ if (!decided) {
829
+ if (ac.signal.aborted) {
830
+ const { result, status } = resultForAbortReason(ac.signal.reason);
831
+ return finish(result, status);
832
+ }
833
+ return finish({ error: `sse stream: ${err?.message ?? err}` }, "error");
834
+ }
835
+ }
836
+ if (decided) {
837
+ ac.abort(decided.reason);
838
+ return finish(decided.result, decided.status);
794
839
  }
795
840
  // A clean relay EOF is not a timeout: the server intentionally closes on
796
841
  // scope changes and proxies can recycle idle streams. Re-arm from a fresh
@@ -1868,8 +1913,16 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
1868
1913
  if (reaped?.failed) return cleanupResult = { ok: false, error: "the signed reap failed and the lease may still hold containers (fenced) — see stack hygiene" };
1869
1914
  // Belt-and-braces: the same race can still slip between the pre-check above
1870
1915
  // and the wait registering (or the wait's own live-signal miss), so recheck
1871
- // once more before falling back to a generic timeout/error message.
1872
- const postCheck = durableReapFailure(await api.get(leaseId));
1916
+ // once more before falling back to a generic timeout/error message. Mirrors
1917
+ // the provision wait's BOT-1822 recheck (which only ever needed to confirm
1918
+ // failure), but the reap wait can miss EITHER terminal outcome the same
1919
+ // way — BOT-1827 (lease 239af20d): the reap genuinely completed (server
1920
+ // `reaped` live emit) while the zero-poll wait still reported `{ timeout }`,
1921
+ // so a healthy stack was reported as a hard failure. One direct read tells
1922
+ // a real timeout apart from a missed wake in either direction.
1923
+ const recheck = await api.get(leaseId);
1924
+ if (recheck?.ok && recheck.data?.success && recheck.data.state === "reaped") return cleanupResult = { ok: true, state: "reaped" };
1925
+ const postCheck = durableReapFailure(recheck);
1873
1926
  if (postCheck) return cleanupResult = postCheck;
1874
1927
  return cleanupResult = { ok: false, error: reaped?.timeout ? `signed reap did not arrive within ${opts.reapTimeout}s` : (reaped?.error || "signed reap was not proven") };
1875
1928
  })();