@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 +1 -1
- package/src/stack-isolation.mjs +410 -8
package/package.json
CHANGED
package/src/stack-isolation.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
192
|
-
`
|
|
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 */ }
|