@botbuddy/cli 1.33.3 → 1.33.5

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.5",
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). */
@@ -36,7 +36,9 @@ export const LEASED_STACKS_DIR = ".botbuddy/stacks";
36
36
  // therefore gets a contiguous block above all of them, and every block stays a
37
37
  // valid TCP port (61000 + 200×20 − 1 = 64999).
38
38
  export const LEASED_PORT_BASE = 61000;
39
- /** Ports reserved per leased stack (the repo declares 10; the rest is headroom). */
39
+ /** Ports reserved per leased stack. botbuddy-web declares 10; a repo that declares
40
+ * none (Supply Guard) has 7 materialized for it from the Supabase defaults
41
+ * (BOT-1835). The rest is headroom. */
40
42
  export const LEASED_PORT_STRIDE = 20;
41
43
  /** Distinct blocks available. Two worktree+slot pairs CAN hash to one block; as
42
44
  * with the hermetic/e2e ports that is a loud `supabase start` port-bind failure,
@@ -119,6 +121,115 @@ export function portMapInConfig(toml) {
119
121
  return map;
120
122
  }
121
123
 
124
+ /**
125
+ * BOT-1835 — every host port the Supabase CLI binds, with the value it uses when
126
+ * the config names none.
127
+ *
128
+ * An UNDECLARED port is not an absent port. The CLI decodes its own embedded
129
+ * `config.toml` template first and the repo's file on top, so a config that says
130
+ * nothing about `[studio]` still binds 54323 — the same 54323 every other stack
131
+ * on the machine falls back to. BOT-1798 rewrote only the ports a repo DECLARED,
132
+ * which is why botbuddy-web's leases landed on 61xxx while Supply Guard's — whose
133
+ * `supabase/config.toml` is one `project_id` line — kept the whole 543xx default
134
+ * block and collided with the SG shared stack and with each other.
135
+ *
136
+ * `bindsWhenUnset: false` means the CLI publishes that port only when the config
137
+ * names one (inbucket's smtp/pop3 ports), so such a service is remapped when it
138
+ * IS declared and never newly exposed when it is not. Its documented default
139
+ * still belongs in the avoid-set: a config that names 54325 collides with every
140
+ * other config that names 54325.
141
+ *
142
+ * `sections` lists the spellings of one service, canonical first: 2.116 renamed
143
+ * `[inbucket]` to `[local_smtp]` and still accepts the old name. The canonical
144
+ * entry is the one materialization CREATES when a config names none of them —
145
+ * `[inbucket]` deliberately, because every CLI in the field understands it
146
+ * (a version that only knows `[local_smtp]` would ignore an unknown section and
147
+ * silently bind the default, which is the bug).
148
+ *
149
+ * `[realtime]` is NOT here: the CLI publishes no host port for it (its section
150
+ * has `enabled`/`ip_version` only). A `[realtime] port` a repo declares anyway is
151
+ * still remapped by the generic declared-port pass below.
152
+ */
153
+ export const SUPABASE_SERVICE_PORTS = Object.freeze([
154
+ { service: "api", label: "api gateway", sections: ["api"], key: "port", defaultPort: 54321, defaultEnabled: true },
155
+ { service: "db", label: "database", sections: ["db"], key: "port", defaultPort: 54322, defaultEnabled: true },
156
+ { service: "db-shadow", label: "shadow database", sections: ["db"], key: "shadow_port", defaultPort: 54320, defaultEnabled: true },
157
+ { service: "pooler", label: "connection pooler", sections: ["db.pooler"], key: "port", defaultPort: 54329, defaultEnabled: false },
158
+ { service: "studio", label: "studio", sections: ["studio"], key: "port", defaultPort: 54323, defaultEnabled: true },
159
+ { service: "mail", label: "mail testing UI", sections: ["inbucket", "local_smtp", "mailpit"], key: "port", defaultPort: 54324, defaultEnabled: true },
160
+ { service: "mail-smtp", label: "mail SMTP", sections: ["inbucket", "local_smtp", "mailpit"], key: "smtp_port", defaultPort: 54325, defaultEnabled: true, bindsWhenUnset: false },
161
+ { service: "mail-pop3", label: "mail POP3", sections: ["inbucket", "local_smtp", "mailpit"], key: "pop3_port", defaultPort: 54326, defaultEnabled: true, bindsWhenUnset: false },
162
+ { service: "analytics", label: "analytics", sections: ["analytics"], key: "port", defaultPort: 54327, defaultEnabled: true },
163
+ { service: "edge-inspector", label: "edge runtime inspector", sections: ["edge_runtime"], key: "inspector_port", defaultPort: 8083, defaultEnabled: true },
164
+ ].map(Object.freeze));
165
+
166
+ /** Every port value the Supabase CLI would pick on its own — the set a leased
167
+ * stack's ports must avoid entirely, whichever repo it was materialized from. */
168
+ export const SUPABASE_DEFAULT_PORTS = Object.freeze(
169
+ new Set(SUPABASE_SERVICE_PORTS.map((s) => s.defaultPort).filter((p) => p > 0)),
170
+ );
171
+
172
+ /** `[section] enabled = true|false` as a map, for the sections that declare it.
173
+ * Comments are not configuration; a value the CLI would not accept is ignored so
174
+ * the caller falls back to the documented default. */
175
+ function enabledFlagsInConfig(toml) {
176
+ const flags = new Map();
177
+ let section = "";
178
+ for (const raw of String(toml).split(/\r?\n/)) {
179
+ const line = raw.trim();
180
+ if (line.startsWith("#")) continue;
181
+ const sec = /^\[([^\]]+)\]/.exec(line);
182
+ if (sec) { section = sec[1].trim(); continue; }
183
+ const dotted = /^([A-Za-z0-9_.]+)\.enabled\s*=\s*(true|false)\b/.exec(line);
184
+ if (dotted) { if (!flags.has(dotted[1])) flags.set(dotted[1], dotted[2] === "true"); continue; }
185
+ if (!section) continue;
186
+ const m = /^enabled\s*=\s*(true|false)\b/.exec(line);
187
+ if (m && !flags.has(section)) flags.set(section, m[1] === "true");
188
+ }
189
+ return flags;
190
+ }
191
+
192
+ /**
193
+ * What the Supabase CLI would actually do with `configText`, per service: which
194
+ * section spells it, whether the config declares its port, whether the CLI binds
195
+ * it at all, and the port it would end up using (`effectivePort` — declared, or
196
+ * the CLI's own default). This is the view BOTH the materializer (which ports
197
+ * must be made explicit) and the isolation assertion (which ports must differ
198
+ * from the default and from the shared stack's) are written against.
199
+ */
200
+ export function supabaseServicePortState(configText) {
201
+ const ports = portMapInConfig(configText);
202
+ const flags = enabledFlagsInConfig(configText);
203
+ return SUPABASE_SERVICE_PORTS.map((svc) => {
204
+ const declaredSection = svc.sections.find((s) => ports.has(`${s}.${svc.key}`)) ?? null;
205
+ // A service is spelled by whichever of its aliases the config already uses —
206
+ // for its port, for any of its other ports, or for its `enabled` flag — so a
207
+ // `[local_smtp]` repo never gets an `[inbucket]` section appended beside it.
208
+ const usedSection = declaredSection
209
+ ?? svc.sections.find((s) => SUPABASE_SERVICE_PORTS.some((o) => o.service !== svc.service && o.sections.includes(s) && ports.has(`${s}.${o.key}`)))
210
+ ?? svc.sections.find((s) => flags.has(s))
211
+ ?? svc.sections[0];
212
+ const enabledSection = svc.sections.find((s) => flags.has(s));
213
+ const enabled = enabledSection === undefined ? svc.defaultEnabled : flags.get(enabledSection);
214
+ const declaredPort = declaredSection === null ? null : Number(ports.get(`${declaredSection}.${svc.key}`));
215
+ const fallback = svc.bindsWhenUnset === false ? null : svc.defaultPort;
216
+ const effectivePort = !enabled ? null : (declaredPort ?? fallback);
217
+ return {
218
+ service: svc.service,
219
+ label: svc.label,
220
+ key: svc.key,
221
+ defaultPort: svc.defaultPort,
222
+ section: usedSection,
223
+ qualifiedKey: `${usedSection}.${svc.key}`,
224
+ declared: declaredPort !== null,
225
+ declaredPort,
226
+ enabled,
227
+ bound: effectivePort !== null,
228
+ effectivePort,
229
+ };
230
+ });
231
+ }
232
+
122
233
  /** Lowercase-alphanumeric tail of a ticket/slot, e.g. `BOT-1798` → `bot1798`. */
123
234
  function identityTag(ticket, slot) {
124
235
  const source = String(ticket ?? "").trim() || String(slot ?? "").trim();
@@ -153,6 +264,49 @@ export function leasedStackIdentity({ worktreeRoot, slot, ticket, nonce } = {})
153
264
 
154
265
  const PROJECT_ID_RE = /^[a-z][a-z0-9]{0,19}$/;
155
266
 
267
+ const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
268
+
269
+ /**
270
+ * BOT-1835 — write `<key> = <port>` for a service the source config left implicit,
271
+ * into the config body `out` (an array of lines, mutated in place).
272
+ *
273
+ * Three placements, in order of how the source already spells that table:
274
+ * 1. a `[section]` header exists → the key joins it, directly under the header;
275
+ * 2. only ROOT-LEVEL dotted keys exist (`api.port = …`) → the key joins them in
276
+ * the same dotted form, because TOML forbids re-opening with `[api]` a table
277
+ * that dotted keys already defined;
278
+ * 3. the table is absent → append it. Appending only ever adds a port, never an
279
+ * `enabled` flag, so materialization can never start a service the source
280
+ * does not run.
281
+ */
282
+ function insertSectionPort(out, section, key, port, defaultPort) {
283
+ const note = defaultPort > 0 ? ` # BOT-1835: undeclared, so the CLI would bind its default ${defaultPort}` : "";
284
+ const isComment = (line) => line.trim().startsWith("#");
285
+ const headerAt = out.findIndex((line) => !isComment(line) && new RegExp(`^\\s*\\[${escapeRe(section)}\\]\\s*(?:#.*)?$`).test(line));
286
+ if (headerAt >= 0) {
287
+ // After the section's LAST key, so a service needing two ports (`[db]`) reads
288
+ // in declaration order and a trailing comment keeps the line it introduces.
289
+ let at = headerAt;
290
+ for (let i = headerAt + 1; i < out.length; i++) {
291
+ if (!isComment(out[i]) && /^\s*\[[^\]]+\]/.test(out[i])) break;
292
+ if (!isComment(out[i]) && /^\s*[A-Za-z0-9_."']+.*=/.test(out[i])) at = i;
293
+ }
294
+ out.splice(at + 1, 0, `${key} = ${port}${note}`);
295
+ return;
296
+ }
297
+ const firstHeader = out.findIndex((line) => !isComment(line) && /^\s*\[[^\]]+\]/.test(line));
298
+ const limit = firstHeader < 0 ? out.length : firstHeader;
299
+ const dotted = new RegExp(`^\\s*${escapeRe(section)}\\.[A-Za-z0-9_]+\\s*=`);
300
+ let lastDotted = -1;
301
+ for (let i = 0; i < limit; i++) if (!isComment(out[i]) && dotted.test(out[i])) lastDotted = i;
302
+ if (lastDotted >= 0) {
303
+ out.splice(lastDotted + 1, 0, `${section}.${key} = ${port}${note}`);
304
+ return;
305
+ }
306
+ while (out.length && out[out.length - 1].trim() === "") out.pop();
307
+ out.push("", `[${section}]`, `${key} = ${port}${note}`, "");
308
+ }
309
+
156
310
  /**
157
311
  * The worktree's own config with exactly THREE things changed: `project_id`,
158
312
  * every declared host port, and the edge runtime's own public origin. Everything
@@ -184,12 +338,12 @@ export function rewriteLeasedStackConfig(configText, { projectId, portBase } = {
184
338
  const bare = new RegExp(`^(\\s*)((?:[A-Za-z0-9]+_)?port)(\\s*=\\s*)${TOML_INT_PORT}(.*)$`);
185
339
  const dotted = new RegExp(`^(\\s*)([A-Za-z0-9_]+\\.(?:[A-Za-z0-9]+_)?port)(\\s*=\\s*)${TOML_INT_PORT}(.*)$`);
186
340
  const ports = new Map();
187
- const assign = (key) => {
341
+ const assign = (key, why = `supabase/config.toml declares more than ${LEASED_PORT_STRIDE} host ports`) => {
188
342
  if (!ports.has(key)) {
189
343
  if (ports.size >= LEASED_PORT_STRIDE) {
190
344
  throw new Error(
191
- `refusing to materialize an isolated leased stack: supabase/config.toml declares more than ${LEASED_PORT_STRIDE} host ports ` +
192
- `(at "${key}") — the leased port block cannot hold them all, and reusing a port would collide inside the stack. ` +
345
+ `refusing to materialize an isolated leased stack: ${why} (at "${key}") the leased port block ` +
346
+ `of ${LEASED_PORT_STRIDE} cannot hold them all, and reusing a port would collide inside the stack. ` +
193
347
  "Widen LEASED_PORT_STRIDE (and the band in docs/docker-capacity.md) before adding more services.",
194
348
  );
195
349
  }
@@ -224,6 +378,19 @@ export function rewriteLeasedStackConfig(configText, { projectId, portBase } = {
224
378
  }
225
379
  out.push(line);
226
380
  }
381
+ // BOT-1835 — then make the UNDECLARED ports explicit. Everything above remaps
382
+ // what the repo wrote down; a repo that writes nothing down (Supply Guard's
383
+ // config is a single `project_id` line) would otherwise inherit Supabase's own
384
+ // 543xx defaults and collide with the shared stack and with every other lease.
385
+ // Only services the CLI would actually bind are given a port — a disabled
386
+ // section, and a port the CLI publishes only when named (inbucket's
387
+ // smtp/pop3), stay exactly as the source left them.
388
+ for (const svc of supabaseServicePortState(configText)) {
389
+ if (svc.declared || !svc.bound) continue;
390
+ const port = assign(svc.qualifiedKey, `supabase/config.toml leaves the ${svc.label} port to Supabase's default (${svc.defaultPort}) and the leased block is already full`);
391
+ insertSectionPort(out, svc.section, svc.key, port, svc.defaultPort);
392
+ }
393
+
227
394
  // Pin the edge origin AFTER the port pass: [edge_runtime.secrets] may precede
228
395
  // [api] in the file, so the leased api port is only known once every line is seen.
229
396
  const apiPort = ports.get("api.port");
@@ -315,6 +482,34 @@ export function assertLeasedStackIsolated(rootConfigText, stackConfigText) {
315
482
  if (shared.length) {
316
483
  throw new Error(`refusing to lease a materialized stack that reuses the shared stack's port(s) ${shared.join(", ")}.`);
317
484
  }
485
+ // BOT-1835 — the checks above compare what the two configs WRITE DOWN, which is
486
+ // vacuous for a repo that writes nothing down: the CLI then binds its own
487
+ // defaults and the "isolated" stack is the 543xx block every other stack falls
488
+ // back to. So compare what the CLI would actually BIND, service by service.
489
+ const rootServices = new Map(supabaseServicePortState(rootConfigText).map((s) => [s.service, s]));
490
+ for (const svc of supabaseServicePortState(stackConfigText)) {
491
+ if (!svc.bound) continue;
492
+ if (!svc.declared) {
493
+ throw new Error(
494
+ `refusing to lease a materialized stack that leaves the ${svc.label} port undeclared: the Supabase CLI binds its own ` +
495
+ `default (${svc.defaultPort}) there, and so does every other stack on this host that declares none.`,
496
+ );
497
+ }
498
+ if (svc.effectivePort === svc.defaultPort) {
499
+ throw new Error(
500
+ `refusing to lease a materialized stack whose ${svc.label} port is Supabase's default (${svc.defaultPort}) — ` +
501
+ "the shared canonical stack and every other default-porting stack bind exactly that port.",
502
+ );
503
+ }
504
+ const rootPort = rootServices.get(svc.service)?.effectivePort ?? null;
505
+ if (rootPort !== null && svc.effectivePort === rootPort) {
506
+ throw new Error(
507
+ `refusing to lease a materialized stack whose ${svc.label} port (${svc.effectivePort}) is the port the worktree's own ` +
508
+ "stack uses — declared there or inherited from Supabase's defaults, it is the same bound socket.",
509
+ );
510
+ }
511
+ }
512
+
318
513
  // The edge runtime must emit THIS stack's public URLs (BOT-1798, Codex #928 R9).
319
514
  // A config that leaves `env(VITE_SUPABASE_URL)` in place — the repo default,
320
515
  // because a local `supabase start` exports the origin into the shell — resolves
@@ -871,6 +1066,202 @@ function prune(stackSupabaseDir, { readdir, remove }) {
871
1066
  }
872
1067
  }
873
1068
 
1069
+ // ── BOT-1828: functions that import a file outside supabase/ ───────────────
1070
+ //
1071
+ // Supply Guard's `supabase/functions/admin-api-keys/index.ts` imports
1072
+ // `../../../src/lib/platformApiKeysCatalog.ts` — outside `supabase/`, so the whole-tree
1073
+ // copy above never brings it along. The Supabase CLI resolves function imports when it
1074
+ // STARTS the edge runtime, so `supabase start` inside the isolated stack fails deep
1075
+ // inside the Helper with a raw ENOENT. This is a dependency-free regex walk over
1076
+ // `import …/export …/from "…"` and dynamic `import("…")` string literals — not a real
1077
+ // module resolver (the published CLI is Node-stdlib only) — so it can miss an exotic
1078
+ // re-export form, but every specifier it DOES find is resolved for real (existence +
1079
+ // worktree-containment) before anything is trusted.
1080
+ const ESCAPING_IMPORT_SCAN_EXTENSIONS = [".ts", ".js", ".mjs"];
1081
+ const ESCAPING_IMPORT_MANIFEST_NAMES = ["import_map.json", "deno.json"];
1082
+ const SUPABASE_DIR_PREFIX = `supabase${sep}`;
1083
+
1084
+ const IMPORT_SPECIFIER_PATTERNS = [
1085
+ // `import … from "…"` / `export … from "…"` (default, named, namespace, type-only).
1086
+ /\b(?:import|export)\b[^'";]*?\bfrom\s*["']([^"']+)["']/g,
1087
+ // dynamic `import("…")`.
1088
+ /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
1089
+ // bare side-effecting `import "…"` (no `from` clause).
1090
+ /^[ \t]*import\s*["']([^"']+)["']\s*;?/gm,
1091
+ ];
1092
+
1093
+ /** Strip comments before scanning for import specifiers (Codex #946 R1 P2): a
1094
+ * `// import { old } from "../../../removed.ts"` note (or block comment) is not a
1095
+ * live dependency, but the plain regex above has no idea — treating it as one turns
1096
+ * a stale comment into a hard refusal of the whole stack. Block comments are removed
1097
+ * outright; `//` line comments are stripped with a quote-aware per-character scan so
1098
+ * a real specifier like `"https://deno.land/std/foo.ts"` (a `//` INSIDE a string) is
1099
+ * never mistaken for a comment. Not a real tokenizer — a `/*`/`//` inside a template
1100
+ * literal can still confuse it — but it closes the concrete failure mode a bare
1101
+ * comment caused, which is what actually shipped in real function source. */
1102
+ function stripComments(source) {
1103
+ const noBlockComments = source.replace(/\/\*[\s\S]*?\*\//g, "");
1104
+ return noBlockComments
1105
+ .split("\n")
1106
+ .map((line) => {
1107
+ let inString = null;
1108
+ for (let i = 0; i < line.length; i++) {
1109
+ const ch = line[i];
1110
+ if (inString) {
1111
+ if (ch === "\\") { i++; continue; }
1112
+ if (ch === inString) inString = null;
1113
+ continue;
1114
+ }
1115
+ if (ch === '"' || ch === "'" || ch === "`") { inString = ch; continue; }
1116
+ if (ch === "/" && line[i + 1] === "/") return line.slice(0, i);
1117
+ }
1118
+ return line;
1119
+ })
1120
+ .join("\n");
1121
+ }
1122
+
1123
+ /** Every string-literal specifier this source imports or re-exports, deduped. */
1124
+ function extractImportSpecifiers(source) {
1125
+ const specifiers = new Set();
1126
+ const clean = stripComments(source);
1127
+ for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
1128
+ pattern.lastIndex = 0;
1129
+ let match;
1130
+ while ((match = pattern.exec(clean)) !== null) specifiers.add(match[1]);
1131
+ }
1132
+ return specifiers;
1133
+ }
1134
+
1135
+ function isRelativeSpecifier(specifier) {
1136
+ return specifier.startsWith("./") || specifier.startsWith("../");
1137
+ }
1138
+
1139
+ /** Recursively list files under `dir` (absolute) whose name matches one of `names`
1140
+ * (exact basenames, e.g. "deno.json") or ends with one of `exts` (e.g. ".ts") — via
1141
+ * the same low-level readdir/stat the rest of the materializer uses, so a
1142
+ * fault-injecting test still exercises this path. A missing `dir` has nothing to scan. */
1143
+ function listMatchingFiles(dir, { exts = [], names = [] }, { readdir, stat }) {
1144
+ const out = [];
1145
+ const pending = [dir];
1146
+ while (pending.length) {
1147
+ const current = pending.pop();
1148
+ let entries;
1149
+ try { entries = readdir(current); } catch { continue; }
1150
+ for (const entry of entries) {
1151
+ const full = join(current, entry);
1152
+ let info;
1153
+ try { info = stat(full); } catch { continue; }
1154
+ if (info.isDirectory()) { pending.push(full); continue; }
1155
+ if (names.includes(entry) || exts.some((ext) => entry.endsWith(ext))) out.push(full);
1156
+ }
1157
+ }
1158
+ return out;
1159
+ }
1160
+
1161
+ /**
1162
+ * Scan `supabase/functions/**` (source files plus `import_map.json`/`deno.json`
1163
+ * "imports" entries) for RELATIVE specifiers that resolve outside `supabase/`, copy
1164
+ * each into the stack directory at the identical worktree-relative path, and follow
1165
+ * relative imports of the COPIED files transitively. Refuses loudly — before this
1166
+ * call copies anything — if a specifier resolves outside the worktree root or to a
1167
+ * file that does not exist, naming the importing file and the specifier. Returns the
1168
+ * sorted list of worktree-relative paths copied (possibly empty).
1169
+ */
1170
+ function copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath }) {
1171
+ const functionsDir = join(worktreeRoot, "supabase", "functions");
1172
+ const visited = new Set(); // worktree-relative paths already queued or copied
1173
+ const queue = [];
1174
+
1175
+ const resolveSpecifier = (specifier, importingAbsDir, importingRelPath) => {
1176
+ // Lexical: `join` collapses `..`/`.` as STRINGS but never follows a symlinked
1177
+ // directory component. fs reads/copies below follow symlinks transparently, so
1178
+ // this is a perfectly usable path for existence/content — and, per Codex #946
1179
+ // R1 P2, it is the path an import specifier reads relative to the COPIED file's
1180
+ // own on-disk location once materialized, so it is what the destination and any
1181
+ // further relative-import resolution must use too.
1182
+ const candidate = join(importingAbsDir, specifier);
1183
+ let real;
1184
+ try {
1185
+ real = realpath(candidate);
1186
+ } catch (error) {
1187
+ throw new Error(
1188
+ `refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which does not exist ` +
1189
+ `(resolved to ${candidate}${error?.code ? `: ${error.code}` : ""}). Fix the import or add the missing file.`,
1190
+ );
1191
+ }
1192
+ // Security containment uses the REAL, symlink-resolved location: a symlink
1193
+ // whose TARGET is outside the worktree must be refused even when its own
1194
+ // lexical path looks like it stays inside.
1195
+ const realRel = relative(worktreeRoot, real);
1196
+ if (realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) {
1197
+ throw new Error(
1198
+ `refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which resolves to ` +
1199
+ `${real} — outside the worktree root ${worktreeRoot}. Move the file into the worktree, or fix the import.`,
1200
+ );
1201
+ }
1202
+ // The DESTINATION (and this file's own identity in `visited`/`queue`) mirrors
1203
+ // the LEXICAL candidate, not `real`: copying to `real`'s location would place
1204
+ // the file somewhere the unmodified import specifier never names, and the
1205
+ // materialized stack would fail with the same missing-file error this ticket
1206
+ // exists to fix (Codex #946 R1 P2).
1207
+ const rel = relative(worktreeRoot, candidate);
1208
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
1209
+ throw new Error(
1210
+ `refusing to materialize an isolated leased stack: ${importingRelPath} imports "${specifier}", which resolves to ` +
1211
+ `${candidate} — outside the worktree root ${worktreeRoot}. Move the file into the worktree, or fix the import.`,
1212
+ );
1213
+ }
1214
+ if (rel === "supabase" || rel.startsWith(SUPABASE_DIR_PREFIX)) return; // already copied whole
1215
+ if (visited.has(rel)) return;
1216
+ visited.add(rel);
1217
+ queue.push({ absPath: candidate, relPath: rel });
1218
+ };
1219
+
1220
+ const scanForImports = (absPath, relPath) => {
1221
+ let source;
1222
+ try { source = read(absPath, "utf8"); } catch { return; }
1223
+ for (const specifier of extractImportSpecifiers(source)) {
1224
+ if (isRelativeSpecifier(specifier)) resolveSpecifier(specifier, dirname(absPath), relPath);
1225
+ }
1226
+ };
1227
+
1228
+ for (const file of listMatchingFiles(functionsDir, { exts: ESCAPING_IMPORT_SCAN_EXTENSIONS }, { readdir, stat })) {
1229
+ scanForImports(file, relative(worktreeRoot, file));
1230
+ }
1231
+ for (const file of listMatchingFiles(functionsDir, { names: ESCAPING_IMPORT_MANIFEST_NAMES }, { readdir, stat })) {
1232
+ let parsed;
1233
+ try { parsed = JSON.parse(read(file, "utf8")); } catch { continue; }
1234
+ const imports = parsed?.imports;
1235
+ if (!imports || typeof imports !== "object") continue;
1236
+ const relPath = relative(worktreeRoot, file);
1237
+ for (const specifier of Object.values(imports)) {
1238
+ if (typeof specifier === "string" && isRelativeSpecifier(specifier)) {
1239
+ resolveSpecifier(specifier, dirname(file), relPath);
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+ const extraFiles = [];
1245
+ while (queue.length) {
1246
+ const item = queue.shift();
1247
+ const dest = join(stackDir, item.relPath);
1248
+ mkdir(dirname(dest), { recursive: true });
1249
+ // An import-map "imports" entry may be a PREFIX mapping to a directory (e.g.
1250
+ // `"@lib/": "../../../src/lib/"`, valid Deno import-map syntax) rather than a
1251
+ // single file — cpSync without `recursive` throws ERR_FS_EISDIR on that (Codex
1252
+ // #946 R1 P1). Copy the whole directory in that case; there is no single file
1253
+ // whose text to scan for further imports.
1254
+ let isDir = false;
1255
+ try { isDir = stat(item.absPath).isDirectory(); } catch { /* copy() will report it */ }
1256
+ copy(item.absPath, dest, isDir ? { recursive: true, force: true } : { force: true });
1257
+ extraFiles.push(item.relPath);
1258
+ if (!isDir && ESCAPING_IMPORT_SCAN_EXTENSIONS.some((ext) => item.relPath.endsWith(ext))) {
1259
+ scanForImports(item.absPath, item.relPath);
1260
+ }
1261
+ }
1262
+ return extraFiles.sort();
1263
+ }
1264
+
874
1265
  /**
875
1266
  * Materialize (or refresh) the isolated stack directory for this run and return
876
1267
  * its path RELATIVE to the worktree root — never `"."`. The directory belongs to
@@ -941,7 +1332,7 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
941
1332
  // stable per-slot one, so the project id is exactly as specific (and keeps a
942
1333
  // refresh idempotent).
943
1334
  const invocationToken = nonce ? String(nonce) : identity.projectId;
944
- const marker = (portBase) => JSON.stringify({
1335
+ const marker = (portBase, extraFiles = []) => JSON.stringify({
945
1336
  schema: 1,
946
1337
  project_id: identity.projectId,
947
1338
  invocation_token: invocationToken,
@@ -953,6 +1344,10 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
953
1344
  retain_until: createdAt + Math.max(LEASED_STACK_GC_MIN_AGE_MS, ttlMs + LEASED_STACK_RETENTION_GRACE_MS),
954
1345
  owner_pid: process.pid,
955
1346
  host: host(),
1347
+ // BOT-1828: worktree-relative paths copied because an edge function imports
1348
+ // outside supabase/ — recorded so an operator can see why this stack dir has
1349
+ // (e.g.) a src/ directory, instead of guessing at a `supabase start` failure.
1350
+ extra_files: extraFiles,
956
1351
  });
957
1352
  const registryDir = io.portRegistryDir ?? defaultPortRegistryDir();
958
1353
  const writeExclusive = io.writeExclusive ?? ((path, data) => writeFileSync(path, data, { flag: "wx" }));
@@ -1016,11 +1411,18 @@ export function materializeRunStackDir(cwd, { slot, ticket, nonce, idleTtlSecs }
1016
1411
  filter: (source) => !SKIP_ENTRIES.has(basename(source)),
1017
1412
  });
1018
1413
  write(join(stackDir, "supabase", "config.toml"), text);
1414
+
1415
+ // BOT-1828: bring along any file an edge function imports OUTSIDE supabase/
1416
+ // (Supply Guard's admin-api-keys/index.ts pulls in ../../../src/lib/…) —
1417
+ // `supabase start` resolves function imports when it starts the edge runtime, so
1418
+ // without this the Helper fails deep inside `supabase start` with a raw ENOENT.
1419
+ const extraFiles = copyEscapingImports(worktreeRoot, stackDir, { read, mkdir, copy, readdir, stat, realpath: resolveRoot });
1420
+
1019
1421
  // Re-state the claim after the copy: the marker records who owns this directory,
1020
1422
  // which port block it holds, and how long a later run must keep it (Codex #928
1021
1423
  // 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 });
1424
+ atomicWrite(join(stackDir, LEASED_STACK_MARKER), marker(portBase, extraFiles), { write, rename });
1425
+ atomicWrite(portClaimPath(registryDir, portBase), marker(portBase, extraFiles), { write, rename });
1024
1426
  return stackPath;
1025
1427
  } catch (error) {
1026
1428
  try { remove(portClaimPath(registryDir, portBase), { force: true }); } catch { /* best effort */ }