@botbuddy/cli 1.43.0 → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/stack-bundle.mjs +197 -7
- package/src/stack.mjs +99 -7
package/package.json
CHANGED
package/src/stack-bundle.mjs
CHANGED
|
@@ -50,6 +50,170 @@ export function isExcludedBundlePath(relPath) {
|
|
|
50
50
|
return SECRET_DOTENV.test(name);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// ── BOT-2269: --bundle-include (exact paths) + the content guard ─────────────
|
|
54
|
+
|
|
55
|
+
const NO_OPT_IN = new Set();
|
|
56
|
+
/** An opted-in file is config, not data: anything bigger is refused before it is read. */
|
|
57
|
+
export const BUNDLE_INCLUDE_MAX_BYTES = 1024 * 1024;
|
|
58
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate one `--bundle-include` path: an EXACT stack-relative POSIX path to a file — no
|
|
62
|
+
* glob characters, no `..`/`.`/empty segments, not absolute, no backslashes, and never a path
|
|
63
|
+
* the bundle always drops whatever the caller asks (node_modules, Supabase CLI scratch, the
|
|
64
|
+
* retention marker). Returns the path unchanged; throws BUNDLE_INCLUDE_INVALID.
|
|
65
|
+
*/
|
|
66
|
+
export function validateBundleIncludePath(rel) {
|
|
67
|
+
const path = typeof rel === "string" ? rel : "";
|
|
68
|
+
const refuse = (why) => bundleError("BUNDLE_INCLUDE_INVALID", `--bundle-include ${JSON.stringify(path)}: ${why}`);
|
|
69
|
+
if (!path) throw refuse("needs an exact stack-relative file path (e.g. supabase/functions/.env)");
|
|
70
|
+
if (GLOB_CHARS.test(path)) throw refuse("globs are not accepted; name each file exactly");
|
|
71
|
+
if (path.includes("\\")) throw refuse("use forward slashes");
|
|
72
|
+
if (path.startsWith("/")) throw refuse("must be relative to the stack directory, not absolute");
|
|
73
|
+
const parts = path.split("/");
|
|
74
|
+
if (parts.some((p) => p === "" || p === "." || p === "..")) throw refuse("must not contain '..', '.' or empty segments, or end in '/'");
|
|
75
|
+
if (parts.some((p) => EXCLUDED_NAMES.has(p)) || (parts.length === 1 && EXCLUDED_ROOT_FILES.has(path))) {
|
|
76
|
+
throw refuse("is inside a path the bundle never ships (node_modules, supabase/.temp|.branches, .bb-stack.json)");
|
|
77
|
+
}
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEMO_SUPABASE_SECRET = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"; // the Supabase CLI's fixed local default
|
|
82
|
+
const LIVE_KEY_SHAPES = [
|
|
83
|
+
{ what: "a live Stripe secret key (sk_live_…)", re: /(?<![A-Za-z0-9])sk_live_[A-Za-z0-9]{8,}/ },
|
|
84
|
+
{ what: "a live Stripe restricted key (rk_live_…)", re: /(?<![A-Za-z0-9])rk_live_[A-Za-z0-9]{8,}/ },
|
|
85
|
+
// Both documented Resend forms: re_<id>_<secret> and the single-segment re_<secret> (Codex #1125 P1).
|
|
86
|
+
// Short mock values (re_test_mock) stay below the 16-character floor.
|
|
87
|
+
{ what: "a Resend API key (re_…)", re: /(?<![A-Za-z0-9_])re_[A-Za-z0-9_-]{16,}/ },
|
|
88
|
+
// Stripe webhook signing secrets look the same in test and live mode; mocks stay short
|
|
89
|
+
// (whsec_local_mock), a real one is whsec_ + 32 characters (Codex #1125 P1).
|
|
90
|
+
{ what: "a Stripe webhook signing secret (whsec_…)", re: /(?<![A-Za-z0-9_])whsec_[A-Za-z0-9]{24,}/ },
|
|
91
|
+
{ what: "a GitHub token (gh?_…)", re: /(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{30,}/ },
|
|
92
|
+
{ what: "a GitHub fine-grained token (github_pat_…)", re: /(?<![A-Za-z0-9_])github_pat_[A-Za-z0-9_]{20,}/ },
|
|
93
|
+
{ what: "an AWS access key id (AKIA…/ASIA…)", re: /(?<![A-Z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![0-9A-Z])/ },
|
|
94
|
+
{ what: "a PEM private key block", re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/ },
|
|
95
|
+
{ what: "a Supabase secret key (sb_secret_…)", re: /(?<![A-Za-z0-9_])sb_secret_[A-Za-z0-9_-]{16,}/g, allow: (m) => m === DEMO_SUPABASE_SECRET },
|
|
96
|
+
];
|
|
97
|
+
const JWT_RE = /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g;
|
|
98
|
+
const LOCAL_JWT_REFS = new Set(["local", "demo", "supabase-demo"]);
|
|
99
|
+
|
|
100
|
+
function jwtPayload(token) {
|
|
101
|
+
try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")); } catch { return null; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Why `value` (under `key`) is a live credential, or null. Never returns any part of the value. */
|
|
105
|
+
function liveCredentialShape(key, value) {
|
|
106
|
+
for (const shape of LIVE_KEY_SHAPES) {
|
|
107
|
+
if (shape.allow) {
|
|
108
|
+
for (const m of value.matchAll(shape.re)) if (!shape.allow(m[0])) return shape.what;
|
|
109
|
+
} else if (shape.re.test(value)) return shape.what;
|
|
110
|
+
}
|
|
111
|
+
const serviceRoleKey = /SERVICE_ROLE_KEY$/i.test(key ?? "");
|
|
112
|
+
for (const [token] of value.matchAll(JWT_RE)) {
|
|
113
|
+
const claims = jwtPayload(token);
|
|
114
|
+
if (!claims || typeof claims !== "object") {
|
|
115
|
+
if (serviceRoleKey) return "a JWT that cannot be decoded (a service-role key must be the Supabase demo key)";
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (typeof claims.ref === "string" && !LOCAL_JWT_REFS.has(claims.ref)) return "a JWT for a hosted Supabase project (it carries a project ref)";
|
|
119
|
+
const demo = claims.iss === "supabase-demo";
|
|
120
|
+
if ((serviceRoleKey || claims.role === "service_role") && !demo) return "a service-role JWT that is not the Supabase demo key";
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** An unescaped closing quote of each dotenv quoting style. */
|
|
126
|
+
const CLOSING_QUOTE = { '"': /(?<!\\)"/, "'": /(?<!\\)'/, "`": /(?<!\\)`/ };
|
|
127
|
+
|
|
128
|
+
/** Parse dotenv text into [{ key, value, line }] (quotes, `export`, comments, multi-line quoted values). */
|
|
129
|
+
function parseDotenv(text) {
|
|
130
|
+
const lines = text.split(/\r?\n/);
|
|
131
|
+
const out = [];
|
|
132
|
+
for (let i = 0; i < lines.length; i++) {
|
|
133
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s?(.*)$/.exec(lines[i]);
|
|
134
|
+
if (!match) continue;
|
|
135
|
+
const [, key, rest] = match;
|
|
136
|
+
const start = i;
|
|
137
|
+
let value = rest.trim();
|
|
138
|
+
const quote = value[0];
|
|
139
|
+
if (quote === '"' || quote === "'" || quote === "`") {
|
|
140
|
+
let body = value.slice(1);
|
|
141
|
+
// A quoted value may span lines (a PEM block); consume until the closing quote.
|
|
142
|
+
const closing = CLOSING_QUOTE[quote];
|
|
143
|
+
while (!closing.test(body) && i + 1 < lines.length) body += `\n${lines[++i]}`;
|
|
144
|
+
const close = body.search(closing);
|
|
145
|
+
value = close === -1 ? body : body.slice(0, close);
|
|
146
|
+
} else {
|
|
147
|
+
value = value.replace(/\s+#.*$/, "");
|
|
148
|
+
}
|
|
149
|
+
out.push({ key, value, line: start + 1, endLine: i + 1 });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isDotenvName = (rel) => /^\.env/.test(rel.split("/").pop()) || /\.env$/.test(rel);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* BOT-2269 content guard for one opted-in file. Refuses (BUNDLE_SECRET_ENV) when any value
|
|
158
|
+
* matches a live-credential shape, naming the KEY (or the line) and the shape — never the
|
|
159
|
+
* value. Every raw line is scanned as well (inline comments, lines the parser cannot
|
|
160
|
+
* attribute), so formatting is not a way around it. Returns { keys } (dotenv key names, file order, deduplicated).
|
|
161
|
+
*/
|
|
162
|
+
export function scanBundleIncludeEnv(rel, text) {
|
|
163
|
+
const refuse = (where, what) => bundleError("BUNDLE_SECRET_ENV",
|
|
164
|
+
`refusing to ship ${rel} in the stack bundle: ${where} looks like ${what}. Remove real provider credentials ` +
|
|
165
|
+
"from the opted-in file (a remote lease ships it to another host); mock values, the Supabase demo keys and " +
|
|
166
|
+
"loopback URLs are fine.");
|
|
167
|
+
const entries = isDotenvName(rel) ? parseDotenv(text) : [];
|
|
168
|
+
const keyOfLine = new Map();
|
|
169
|
+
for (const { key, value, line, endLine } of entries) {
|
|
170
|
+
for (let n = line; n <= endLine; n++) keyOfLine.set(n, key);
|
|
171
|
+
const what = liveCredentialShape(key, value);
|
|
172
|
+
if (what) throw refuse(key, what);
|
|
173
|
+
}
|
|
174
|
+
// Every RAW line is scanned too, not only the parsed values: an inline comment after a
|
|
175
|
+
// valid assignment (`MODE=mock # old KEY=sk_live_…`) never reaches a parsed value but
|
|
176
|
+
// still ships (Codex #1125 P1), and a line the parser cannot attribute ships as well.
|
|
177
|
+
const lines = text.split(/\r?\n/);
|
|
178
|
+
for (let i = 0; i < lines.length; i++) {
|
|
179
|
+
const what = liveCredentialShape(null, lines[i]);
|
|
180
|
+
if (what) throw refuse(keyOfLine.has(i + 1) ? `${keyOfLine.get(i + 1)} (line ${i + 1})` : `line ${i + 1}`, what);
|
|
181
|
+
}
|
|
182
|
+
// A PEM block split across unattributed lines is caught by its BEGIN line above.
|
|
183
|
+
return { keys: [...new Set(entries.map((e) => e.key))] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve and guard every `--bundle-include` path against the materialised stack root:
|
|
188
|
+
* syntax (validateBundleIncludePath), existence (BUNDLE_INCLUDE_MISSING), the existing symlink
|
|
189
|
+
* rules (the path, and every link on the way, must resolve inside the stack —
|
|
190
|
+
* BUNDLE_UNSAFE_LINK), a regular file (BUNDLE_INCLUDE_NOT_A_FILE) of at most
|
|
191
|
+
* BUNDLE_INCLUDE_MAX_BYTES, and the content guard. Returns [{ path, sha256, keys }] sorted.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveBundleIncludes(root, include = [], { readFile = readFileSync } = {}) {
|
|
194
|
+
const paths = [...new Set((include ?? []).map(validateBundleIncludePath))].sort();
|
|
195
|
+
return paths.map((path) => {
|
|
196
|
+
const abs = join(root, path);
|
|
197
|
+
try { lstatSync(abs); } catch {
|
|
198
|
+
throw bundleError("BUNDLE_INCLUDE_MISSING", `refusing --bundle-include ${path}: it does not exist in the materialised stack (${root})`);
|
|
199
|
+
}
|
|
200
|
+
let real;
|
|
201
|
+
try { real = realpathSync(abs); } catch { throw bundleError("BUNDLE_UNSAFE_LINK", `refusing --bundle-include ${path}: it is a dangling symlink`); }
|
|
202
|
+
const inside = relative(root, real);
|
|
203
|
+
if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
|
|
204
|
+
throw bundleError("BUNDLE_UNSAFE_LINK", `refusing --bundle-include ${path}: it resolves outside the stack directory (${real})`);
|
|
205
|
+
}
|
|
206
|
+
const info = statSync(real);
|
|
207
|
+
if (!info.isFile()) throw bundleError("BUNDLE_INCLUDE_NOT_A_FILE", `refusing --bundle-include ${path}: it is not a regular file`);
|
|
208
|
+
if (info.size > BUNDLE_INCLUDE_MAX_BYTES) {
|
|
209
|
+
throw bundleError("BUNDLE_INCLUDE_TOO_LARGE", `refusing --bundle-include ${path}: ${info.size} bytes is over the ${BUNDLE_INCLUDE_MAX_BYTES}-byte cap for an opted-in file`);
|
|
210
|
+
}
|
|
211
|
+
const body = readFile(abs);
|
|
212
|
+
const { keys } = scanBundleIncludeEnv(path, body.toString("utf8"));
|
|
213
|
+
return { path, sha256: createHash("sha256").update(body).digest("hex"), keys };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
53
217
|
// ── ustar writer ─────────────────────────────────────────────────────────────
|
|
54
218
|
|
|
55
219
|
const BLOCK = 512;
|
|
@@ -115,12 +279,14 @@ function toPosix(rel) {
|
|
|
115
279
|
* host's Docker bind mounts cannot follow a link anyway); one resolving outside
|
|
116
280
|
* it is refused, so a bundle can never carry a file from elsewhere on this machine.
|
|
117
281
|
*/
|
|
118
|
-
function collect(root, rootReal, relDir, out, excluded, seenDirs = new Set([rootReal])) {
|
|
282
|
+
function collect(root, rootReal, relDir, out, excluded, seenDirs = new Set([rootReal]), optIn = NO_OPT_IN) {
|
|
119
283
|
const absDir = relDir ? join(root, relDir) : root;
|
|
120
284
|
const names = readdirSync(absDir).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
121
285
|
for (const name of names) {
|
|
122
286
|
const rel = relDir ? `${relDir}/${name}` : name;
|
|
123
|
-
|
|
287
|
+
// BOT-2269: an exact --bundle-include path overrides the exclusion for that path only.
|
|
288
|
+
const optedIn = optIn.has(rel);
|
|
289
|
+
if (!optedIn && isExcludedBundlePath(rel)) { excluded.push(rel); continue; }
|
|
124
290
|
const abs = join(root, rel);
|
|
125
291
|
let info = lstatSync(abs);
|
|
126
292
|
if (info.isSymbolicLink()) {
|
|
@@ -132,7 +298,8 @@ function collect(root, rootReal, relDir, out, excluded, seenDirs = new Set([root
|
|
|
132
298
|
}
|
|
133
299
|
// Codex #1077 P1: the exclusion rules apply to what a link POINTS AT too, or an
|
|
134
300
|
// innocently named link (config-link -> .env) would ship an excluded secret.
|
|
135
|
-
|
|
301
|
+
// An opted-in path names its own content (the guard scanned it), whatever it points at.
|
|
302
|
+
if (!optedIn && isExcludedBundlePath(toPosix(inside))) { excluded.push(rel); continue; }
|
|
136
303
|
info = statSync(abs);
|
|
137
304
|
}
|
|
138
305
|
if (info.isDirectory()) {
|
|
@@ -141,7 +308,7 @@ function collect(root, rootReal, relDir, out, excluded, seenDirs = new Set([root
|
|
|
141
308
|
const real = realpathSync(abs);
|
|
142
309
|
if (seenDirs.has(real)) throw bundleError("BUNDLE_UNSAFE_LINK", `refusing to bundle ${rel}: it is a symlink to a directory already in the bundle (a cycle or duplicate)`);
|
|
143
310
|
out.push({ rel: `${rel}/`, type: "dir" });
|
|
144
|
-
collect(root, rootReal, rel, out, excluded, new Set([...seenDirs, real]));
|
|
311
|
+
collect(root, rootReal, rel, out, excluded, new Set([...seenDirs, real]), optIn);
|
|
145
312
|
} else if (info.isFile()) {
|
|
146
313
|
out.push({ rel, type: "file", abs, size: info.size, executable: (info.mode & 0o111) !== 0 });
|
|
147
314
|
}
|
|
@@ -161,16 +328,32 @@ function collect(root, rootReal, relDir, out, excluded, seenDirs = new Set([root
|
|
|
161
328
|
* canonical bind-RPC readiness probe as a local one. Each must be a regular file (not a
|
|
162
329
|
* symlink) with a safe relative name; one the stack dir already carries is not duplicated.
|
|
163
330
|
*/
|
|
164
|
-
|
|
331
|
+
/**
|
|
332
|
+
* BOT-2269: `include` ([stack-relative paths], `bb stack run --bundle-include`) ships exactly
|
|
333
|
+
* those files even when the default rules exclude them (`SECRET_DOTENV`), after
|
|
334
|
+
* `resolveBundleIncludes` has validated each path and passed its content guard. The result's
|
|
335
|
+
* `included` lists [{ path, sha256, keys }] (key NAMES only). With no includes the archive is
|
|
336
|
+
* byte-identical to the pre-BOT-2269 bundle.
|
|
337
|
+
*/
|
|
338
|
+
export function buildStackBundle(stackDir, { maxBytes = DEFAULT_BUNDLE_MAX_BYTES, rawLimitBytes = null, readFile = readFileSync, extraFiles = [], include = [] } = {}) {
|
|
165
339
|
const root = realpathSync(stackDir);
|
|
166
340
|
try {
|
|
167
341
|
if (!statSync(join(root, "supabase", "config.toml")).isFile()) throw new Error("not a file");
|
|
168
342
|
} catch {
|
|
169
343
|
throw bundleError("BUNDLE_NOT_A_STACK", `refusing to bundle ${root}: it has no supabase/config.toml, so it is not a materialised stack directory`);
|
|
170
344
|
}
|
|
345
|
+
const included = resolveBundleIncludes(root, include, { readFile });
|
|
346
|
+
const optIn = included.length ? new Set(included.map((e) => e.path)) : NO_OPT_IN;
|
|
171
347
|
const entries = [];
|
|
172
348
|
const excluded = [];
|
|
173
|
-
collect(root, root, "", entries, excluded);
|
|
349
|
+
collect(root, root, "", entries, excluded, new Set([root]), optIn);
|
|
350
|
+
for (const { path } of included) {
|
|
351
|
+
// Reachable only through a path the walk does not enter (e.g. a directory link whose
|
|
352
|
+
// target is excluded): refuse rather than report a file that did not ship.
|
|
353
|
+
if (!entries.some((e) => e.rel === path)) {
|
|
354
|
+
throw bundleError("BUNDLE_INCLUDE_EXCLUDED", `refusing --bundle-include ${path}: the bundle walk never reaches it (it sits behind an excluded directory or link)`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
174
357
|
const present = new Set(entries.map((e) => e.rel));
|
|
175
358
|
const extras = [];
|
|
176
359
|
for (const { rel, abs } of extraFiles) {
|
|
@@ -207,6 +390,13 @@ export function buildStackBundle(stackDir, { maxBytes = DEFAULT_BUNDLE_MAX_BYTES
|
|
|
207
390
|
// huge file is a BUNDLE_TOO_LARGE error, never an out-of-memory kill.
|
|
208
391
|
if (raw + entry.size > rawLimit) throw tooLarge(`more than ${rawLimit} bytes before compression (at ${entry.rel}, ${entry.size} bytes)`);
|
|
209
392
|
const body = readFile(entry.abs);
|
|
393
|
+
if (optIn.has(entry.rel)) {
|
|
394
|
+
// The bytes that ship must be the bytes the guard scanned.
|
|
395
|
+
const scanned = included.find((e) => e.path === entry.rel);
|
|
396
|
+
if (createHash("sha256").update(body).digest("hex") !== scanned.sha256) {
|
|
397
|
+
throw bundleError("BUNDLE_INCLUDE_CHANGED", `refusing --bundle-include ${entry.rel}: it changed while the bundle was being built; re-run`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
210
400
|
files += 1;
|
|
211
401
|
return entryBlocks(toPosix(entry.rel), { size: body.length, mode: entry.executable ? 0o755 : 0o644, type: "0" }, body);
|
|
212
402
|
})();
|
|
@@ -217,7 +407,7 @@ export function buildStackBundle(stackDir, { maxBytes = DEFAULT_BUNDLE_MAX_BYTES
|
|
|
217
407
|
const bytes = gzipSync(Buffer.concat(chunks), { level: 9 });
|
|
218
408
|
bytes[9] = 3; // gzip OS byte: "Unix" on every platform, so the digest is portable
|
|
219
409
|
if (bytes.length > maxBytes) throw tooLarge(`${bytes.length} bytes compressed`);
|
|
220
|
-
return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.length, files, excluded };
|
|
410
|
+
return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), size: bytes.length, files, excluded, included };
|
|
221
411
|
}
|
|
222
412
|
|
|
223
413
|
/** Entry names in archive order (PAX long names resolved). For tests and diagnostics. */
|
package/src/stack.mjs
CHANGED
|
@@ -39,7 +39,7 @@ import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from
|
|
|
39
39
|
import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
|
|
40
40
|
import { assertLeasedStackIsolated, leasedStackIdentity, materializeRunStackDir, portMapInConfig, refreshLeasedStackRetention, releaseLeasedStackClaim, reservePortBlocks } from "./stack-isolation.mjs";
|
|
41
41
|
import { machineUuid } from "./machine-id.mjs";
|
|
42
|
-
import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle } from "./stack-bundle.mjs";
|
|
42
|
+
import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validateBundleIncludePath } from "./stack-bundle.mjs";
|
|
43
43
|
import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
|
|
44
44
|
import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
|
|
45
45
|
import { bold, dim, yellow } from "./utils.mjs";
|
|
@@ -60,6 +60,13 @@ const CALLBACK_PORT_REMOTE_REFUSAL =
|
|
|
60
60
|
"(pre-forward GatewayPorts probe). Use --local-only for a run that needs a callback port.";
|
|
61
61
|
export const DEFAULT_RECEIPT_MAX_BYTES = 10240;
|
|
62
62
|
export const DEFAULT_TIMEOUT_SEC = 3600;
|
|
63
|
+
/**
|
|
64
|
+
* BOT-2154: how long `stack up --local-exec` lets `supabase start` run before it gives up and
|
|
65
|
+
* releases the lease itself (`--provision-timeout` overrides). Deliberately below the server's
|
|
66
|
+
* 1200 s `local_exec_provision_ttl_secs` backstop, so the CLI's own clean `stack done` wins the
|
|
67
|
+
* race against the reaper.
|
|
68
|
+
*/
|
|
69
|
+
export const LOCAL_EXEC_PROVISION_TIMEOUT_SEC = 900;
|
|
63
70
|
/** BOT-2230: the bounds the control plane enforces on an owner place hold (hold_stack_lease_place). */
|
|
64
71
|
export const HOLD_ON_TIMEOUT_MIN_SEC = 60;
|
|
65
72
|
export const HOLD_ON_TIMEOUT_MAX_SEC = 14400;
|
|
@@ -122,6 +129,10 @@ ${bold("up OPTIONS")}
|
|
|
122
129
|
deadline (exit 2) and a failed wait (its own exit code) never retry.
|
|
123
130
|
--local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
|
|
124
131
|
stack and self-activate. Refused at the worktree root (shared stack).
|
|
132
|
+
--provision-timeout N LOCAL-EXEC: seconds 'supabase start' may run (default ${LOCAL_EXEC_PROVISION_TIMEOUT_SEC}). On
|
|
133
|
+
timeout up runs 'stack done --local-exec' itself and exits 6 with
|
|
134
|
+
park_reason provision_timeout (BOT-2154). The server also fails a local-exec
|
|
135
|
+
lease still provisioning after local_exec_provision_ttl_secs (default 1200).
|
|
125
136
|
Refused (exit 4, ENGINE_PUBLISHES_BEYOND_LOOPBACK) when the Docker
|
|
126
137
|
engine would publish the stack's ports beyond loopback (BOT-2146).
|
|
127
138
|
--docker-context <name> Explicit Docker context for the mandatory local preflight
|
|
@@ -170,6 +181,15 @@ ${bold("run OPTIONS")}
|
|
|
170
181
|
--bundle-max-mb N Remote-lease bundle size cap in MB (default 50, compressed). The bundle
|
|
171
182
|
excludes .bb-stack.json, node_modules, supabase/.temp|.branches and
|
|
172
183
|
untracked-secret dotenv files (.env, .env.local, .env.*.local).
|
|
184
|
+
--bundle-include <path> Ship this EXACT stack-relative file in the bundle even though the
|
|
185
|
+
defaults exclude it (repeatable), e.g. supabase/functions/.env so a remote
|
|
186
|
+
stack starts with the same functions env as a local one. No globs, no '..';
|
|
187
|
+
the file must exist in the materialised stack and pass the content guard:
|
|
188
|
+
a value shaped like a live credential (sk_live_/rk_live_, Resend re_, whsec_,
|
|
189
|
+
ghp_/github_pat_, AKIA/ASIA, a PEM private key, sb_secret_, a JWT with a
|
|
190
|
+
hosted project ref, a non-demo service-role JWT) refuses the run before any
|
|
191
|
+
lease exists (exit 4 BUNDLE_SECRET_ENV, naming the KEY, never the value).
|
|
192
|
+
The receipt and the lease carry bundle_included [{path, sha256, keys}].
|
|
173
193
|
|
|
174
194
|
${bold("GLOBAL")}
|
|
175
195
|
--json Pretty-print the receipt (default is one compact JSON line).
|
|
@@ -201,6 +221,9 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
|
|
|
201
221
|
5 CALLBACK_NOT_LOOPBACK (reserved for BOT-2184) the host's -R listener is not loopback-only
|
|
202
222
|
(sshd GatewayPorts yes) or could not be checked with lsof
|
|
203
223
|
4 BUNDLE_TOO_LARGE --remote and the bundle is over --bundle-max-mb
|
|
224
|
+
4 BUNDLE_SECRET_ENV a --bundle-include file holds a live-credential-shaped value (names the key)
|
|
225
|
+
4 BUNDLE_INCLUDE_* a --bundle-include path is missing, not a file, too large, or excluded
|
|
226
|
+
(BUNDLE_UNSAFE_LINK: it resolves outside the stack); no lease is created
|
|
204
227
|
4 CALLBACK_PORT_UNSUPPORTED_REMOTE --callback-port on a remote lease (refused until BOT-2184;
|
|
205
228
|
under auto the remote lease is released first)
|
|
206
229
|
6 TUNNEL_LOST the forward exited mid-child: the child is stopped, the lease released,
|
|
@@ -269,6 +292,9 @@ export function parseStackArgs(argv) {
|
|
|
269
292
|
dockerContext: null, dockerEndpoint: null,
|
|
270
293
|
// BOT-2142: execution placement (null = command default), remote-lease callback + bundle cap.
|
|
271
294
|
execution: null, remote: false, localOnly: false, callbackPort: null, bundleMaxBytes: null,
|
|
295
|
+
// BOT-2269: exact stack-relative files shipped in the remote-lease bundle despite the
|
|
296
|
+
// default exclusions (`--bundle-include`, repeatable).
|
|
297
|
+
bundleInclude: [],
|
|
272
298
|
disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
|
|
273
299
|
};
|
|
274
300
|
const positionals = [];
|
|
@@ -358,6 +384,13 @@ export function parseStackArgs(argv) {
|
|
|
358
384
|
}
|
|
359
385
|
break;
|
|
360
386
|
}
|
|
387
|
+
case "--bundle-include": {
|
|
388
|
+
if (need(a, rest[i + 1])) {
|
|
389
|
+
const path = rest[++i];
|
|
390
|
+
try { opts.bundleInclude.push(validateBundleIncludePath(path)); } catch (error) { errors.push(error.message); }
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
361
394
|
case "--bundle-max-mb": {
|
|
362
395
|
if (need(a, rest[i + 1])) {
|
|
363
396
|
const n = Number(rest[++i]);
|
|
@@ -405,6 +438,12 @@ export function parseStackArgs(argv) {
|
|
|
405
438
|
} else if (opts.remote && opts.callbackPort != null) {
|
|
406
439
|
errors.push(CALLBACK_PORT_REMOTE_REFUSAL);
|
|
407
440
|
}
|
|
441
|
+
if (opts.bundleInclude.length && command !== "run") {
|
|
442
|
+
errors.push("--bundle-include is valid only with `stack run` (only run builds a remote-lease bundle)");
|
|
443
|
+
} else if (opts.bundleInclude.length && opts.localOnly) {
|
|
444
|
+
errors.push("--bundle-include ships files in the remote-lease bundle, and --local-only builds no bundle; drop one of them");
|
|
445
|
+
}
|
|
446
|
+
opts.bundleInclude = [...new Set(opts.bundleInclude)];
|
|
408
447
|
opts.execution = opts.remote ? "remote" : opts.localOnly ? "local" : null;
|
|
409
448
|
if (typeof opts.stackPath !== "string" || !opts.stackPath.trim() || opts.stackPath.startsWith("/") ||
|
|
410
449
|
opts.stackPath.split("/").some((part) => part === "..")) {
|
|
@@ -1851,7 +1890,16 @@ export function localProvision(opts, dockerTarget, execution, spawn = spawnSync,
|
|
|
1851
1890
|
const url = resolveIsolatedStackApiUrl(stackDir, env);
|
|
1852
1891
|
const spawnEnv = { ...dockerEnvForTarget(dockerTarget, env), VITE_SUPABASE_URL: url };
|
|
1853
1892
|
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in ${stackDir} (VITE_SUPABASE_URL=${url}).\n`);
|
|
1854
|
-
|
|
1893
|
+
// BOT-2154: never an unbounded start — a hung `supabase start` held the host slot for hours.
|
|
1894
|
+
const timeoutSec = opts?.provisionTimeout ?? LOCAL_EXEC_PROVISION_TIMEOUT_SEC;
|
|
1895
|
+
const start = spawn("supabase", ["start", "--workdir", stackDir], {
|
|
1896
|
+
encoding: "utf8", env: spawnEnv, timeout: timeoutSec * 1000, killSignal: "SIGTERM",
|
|
1897
|
+
});
|
|
1898
|
+
if (start.error?.code === "ETIMEDOUT") {
|
|
1899
|
+
const timedOut = new Error(`supabase start did not finish within ${timeoutSec}s (--provision-timeout); stopping it and releasing the lease`);
|
|
1900
|
+
timedOut.code = "PROVISION_TIMEOUT";
|
|
1901
|
+
throw timedOut;
|
|
1902
|
+
}
|
|
1855
1903
|
if (start.status !== 0) {
|
|
1856
1904
|
throw new Error(`supabase start failed (${start.status}): ${(start.stderr || start.stdout || "").slice(0, 400)}`);
|
|
1857
1905
|
}
|
|
@@ -1942,11 +1990,21 @@ function emit(receipt, opts, code) {
|
|
|
1942
1990
|
|
|
1943
1991
|
// ── command orchestration ────────────────────────────────────────────────────
|
|
1944
1992
|
|
|
1993
|
+
/** BOT-2154: run `stack done` in-process and capture its receipt instead of printing it, so
|
|
1994
|
+
* `stack up` still emits exactly one receipt. */
|
|
1995
|
+
async function runLocalDone(leaseId, opts, deps = {}) {
|
|
1996
|
+
let receipt = null;
|
|
1997
|
+
const exitCode = await cmdDone(leaseId, opts, { ...deps, emitResult: (r, _o, code) => { receipt = r; return code; } });
|
|
1998
|
+
return { exitCode, receipt };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
1945
2001
|
export async function cmdUp(opts, {
|
|
1946
2002
|
runPreflight = runLocalExecPreflight,
|
|
1947
2003
|
callTool = callToolJson,
|
|
1948
2004
|
authProvider = stackAuthHeader,
|
|
1949
2005
|
localProvisionFn = localProvision,
|
|
2006
|
+
// BOT-2154: the self-release after a timed-out local start (`stack done --local-exec`).
|
|
2007
|
+
localDoneFn = null,
|
|
1950
2008
|
waitFn = waitForLease,
|
|
1951
2009
|
emitResult = emit,
|
|
1952
2010
|
machineUuidFn = machineUuid,
|
|
@@ -2278,6 +2336,21 @@ export async function cmdUp(opts, {
|
|
|
2278
2336
|
try {
|
|
2279
2337
|
conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget, execution), localDockerTarget);
|
|
2280
2338
|
} catch (e) {
|
|
2339
|
+
if (e?.code === "PROVISION_TIMEOUT") {
|
|
2340
|
+
// BOT-2154: a bounded start that ran out of time. Release the lease ourselves —
|
|
2341
|
+
// `stack done --local-exec` stops whatever the start left behind and reaps the lease
|
|
2342
|
+
// — instead of leaving it provisioning for the server's backstop to find.
|
|
2343
|
+
const done = await (localDoneFn ?? ((id, doneOpts) => runLocalDone(id, doneOpts, { callTool, authProvider })))(
|
|
2344
|
+
leaseId, { ...opts, localExec: true, disposition: "destroy", json: false });
|
|
2345
|
+
const doneReceipt = done?.receipt ?? {};
|
|
2346
|
+
const cleaned = done?.exitCode === 0;
|
|
2347
|
+
return emitResult(buildReceipt({
|
|
2348
|
+
command: "up", outcome: "timeout", code: "PROVISION_TIMEOUT", park_reason: "provision_timeout",
|
|
2349
|
+
lease_id: leaseId, slot, error: e.message, cleanup: cleaned,
|
|
2350
|
+
done: { outcome: doneReceipt.outcome ?? null, state: doneReceipt.state ?? null, exit_code: done?.exitCode ?? null },
|
|
2351
|
+
...(cleaned ? {} : { cleanup_error: doneReceipt.error ?? `stack done --local-exec exited ${done?.exitCode}` }),
|
|
2352
|
+
}), opts, EXIT.LEASE_FAILED);
|
|
2353
|
+
}
|
|
2281
2354
|
// Once the local provisioner has run, `supabase start` may have created
|
|
2282
2355
|
// (or fully started) containers even on a nonzero exit or a status-parse
|
|
2283
2356
|
// failure. Cancelling here would reap the lease and free the slot while
|
|
@@ -2978,6 +3051,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
2978
3051
|
// `--local-only` never ships anything.
|
|
2979
3052
|
let executionRequest = opts.execution ?? "auto";
|
|
2980
3053
|
let bundle = null;
|
|
3054
|
+
const bundleInclude = opts.bundleInclude ?? [];
|
|
2981
3055
|
if (executionRequest !== "local") {
|
|
2982
3056
|
try {
|
|
2983
3057
|
// BOT-2162: ship the worktree's BOT-2107 readiness declaration with the stack, so the
|
|
@@ -2988,10 +3062,17 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
2988
3062
|
bundle = buildBundle(stackDirFor(execution), {
|
|
2989
3063
|
maxBytes: opts.bundleMaxBytes ?? DEFAULT_BUNDLE_MAX_BYTES,
|
|
2990
3064
|
...(hasDeclaration ? { extraFiles: [{ rel: ".botbuddy/config.json", abs: declaration }] } : {}),
|
|
3065
|
+
...(bundleInclude.length ? { include: bundleInclude } : {}),
|
|
2991
3066
|
});
|
|
3067
|
+
for (const entry of bundle.included ?? []) {
|
|
3068
|
+
stderrLine(`stack: the bundle includes ${entry.path} (sha256 ${entry.sha256.slice(0, 12)}…; keys: ${entry.keys.join(", ") || "none"})`);
|
|
3069
|
+
}
|
|
2992
3070
|
} catch (error) {
|
|
2993
3071
|
const message = String(error?.message ?? error);
|
|
2994
|
-
|
|
3072
|
+
// BOT-2269: the caller asked for these files to ship. A refused path or a live key is
|
|
3073
|
+
// a hard refusal in every mode — quietly falling back to a co-located lease would
|
|
3074
|
+
// hide it — and it happens before any lease exists.
|
|
3075
|
+
if (executionRequest === "remote" || bundleInclude.length) {
|
|
2995
3076
|
const fixable = typeof error?.code === "string" && error.code.startsWith("BUNDLE_");
|
|
2996
3077
|
return { exitCode: fixable ? EXIT.INVALID : EXIT.INTERNAL, outcome: "error", code: error?.code ?? "BUNDLE_FAILED", error: message };
|
|
2997
3078
|
}
|
|
@@ -3009,6 +3090,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3009
3090
|
machine_uuid: hardwareUuid,
|
|
3010
3091
|
execution: executionRequest,
|
|
3011
3092
|
...(bundle ? { bundle_sha256: bundle.sha256 } : {}),
|
|
3093
|
+
// BOT-2269: what the bundle carries beyond the defaults (paths, sha256, key NAMES).
|
|
3094
|
+
...(bundle?.included?.length ? { bundle_included: bundle.included } : {}),
|
|
3012
3095
|
...(opts.callbackPort != null ? { callback_port: opts.callbackPort } : {}),
|
|
3013
3096
|
};
|
|
3014
3097
|
// The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
|
|
@@ -3128,6 +3211,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3128
3211
|
return { exitCode: EXIT.BACKEND, outcome: "error", code: "BUNDLE_UPLOAD_FAILED", leaseId, cleanup, error: uploaded?.error || "bundle upload failed" };
|
|
3129
3212
|
}
|
|
3130
3213
|
placement.bundle = { sha256: bundle.sha256, bytes: bundle.size };
|
|
3214
|
+
if (bundle.included?.length) placement.bundleIncluded = bundle.included;
|
|
3131
3215
|
stderrLine(`stack: lease ${leaseId} is remote on ${placement.hostKey ?? "?"} — uploaded the ${bundle.size}-byte stack bundle (sha256 ${bundle.sha256.slice(0, 12)}…)`);
|
|
3132
3216
|
} else if (bundle && state === "queued" && request.data.bundle_upload_url) {
|
|
3133
3217
|
// BOT-2207: the lease queued HERE but may still move: every minute the control plane
|
|
@@ -3142,6 +3226,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3142
3226
|
}
|
|
3143
3227
|
if (uploaded?.ok) {
|
|
3144
3228
|
placement.bundle = { sha256: bundle.sha256, bytes: bundle.size };
|
|
3229
|
+
if (bundle.included?.length) placement.bundleIncluded = bundle.included;
|
|
3145
3230
|
stderrLine(`stack: lease ${leaseId} queued on ${placement.hostKey ?? "?"} — uploaded the ${bundle.size}-byte stack bundle so it can move to another owned host that admits first`);
|
|
3146
3231
|
} else {
|
|
3147
3232
|
stderrLine(`${yellow("⚠")} stack: could not upload the stack bundle (${uploaded?.error || "upload failed"}); lease ${leaseId} stays queued on ${placement.hostKey ?? "this host"} and cannot move to another host.`);
|
|
@@ -3358,9 +3443,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
|
|
|
3358
3443
|
}
|
|
3359
3444
|
}
|
|
3360
3445
|
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
return
|
|
3446
|
+
/** The `stack run` receipt fields for a lifecycle result (exported for the receipt tests). */
|
|
3447
|
+
export function runReceiptFields(result) {
|
|
3448
|
+
return {
|
|
3364
3449
|
// BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
|
|
3365
3450
|
// so automation can branch on it instead of matching the message text.
|
|
3366
3451
|
command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
|
|
@@ -3376,6 +3461,8 @@ async function cmdRun(opts, childArgv) {
|
|
|
3376
3461
|
transport: result.execution?.transport ?? "none",
|
|
3377
3462
|
...(result.execution?.forwards ? { forwards: result.execution.forwards } : {}),
|
|
3378
3463
|
...(result.execution?.bundle ? { bundle: result.execution.bundle } : {}),
|
|
3464
|
+
// BOT-2269: the opted-in files that shipped in that bundle — paths, sha256, key names.
|
|
3465
|
+
...(result.execution?.bundle && result.execution?.bundleIncluded ? { bundle_included: result.execution.bundleIncluded } : {}),
|
|
3379
3466
|
// BOT-2207: why it ran where it did (e.g. moved_remote_on_requeue) and every host weighed.
|
|
3380
3467
|
...(result.execution?.placementReason ? { placement_reason: result.execution.placementReason } : {}),
|
|
3381
3468
|
...(result.execution?.considered ? { considered: result.execution.considered } : {}),
|
|
@@ -3384,7 +3471,12 @@ async function cmdRun(opts, childArgv) {
|
|
|
3384
3471
|
// incident (reason, window, attempts, recovered) — never as the lease's outcome.
|
|
3385
3472
|
...(result.incident ? { platform_incident: result.incident } : {}),
|
|
3386
3473
|
...(result.ledger ? { unit_ledger: result.ledger } : {}),
|
|
3387
|
-
}
|
|
3474
|
+
};
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
async function cmdRun(opts, childArgv) {
|
|
3478
|
+
const result = await runStackLifecycle(opts, childArgv);
|
|
3479
|
+
return emit(buildReceipt(runReceiptFields(result)), opts, result.exitCode);
|
|
3388
3480
|
}
|
|
3389
3481
|
|
|
3390
3482
|
/** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
|