@botbuddy/cli 1.33.3 → 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.3",
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 */ }