@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- if (isExcludedBundlePath(rel)) { excluded.push(rel); continue; }
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
- if (isExcludedBundlePath(toPosix(inside))) { excluded.push(rel); continue; }
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
- export function buildStackBundle(stackDir, { maxBytes = DEFAULT_BUNDLE_MAX_BYTES, rawLimitBytes = null, readFile = readFileSync, extraFiles = [] } = {}) {
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
- const start = spawn("supabase", ["start", "--workdir", stackDir], { encoding: "utf8", env: spawnEnv });
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
- if (executionRequest === "remote") {
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
- async function cmdRun(opts, childArgv) {
3362
- const result = await runStackLifecycle(opts, childArgv);
3363
- return emit(buildReceipt({
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
- }), opts, result.exitCode);
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. */