@botbuddy/cli 1.43.1 → 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.1",
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";
@@ -181,6 +181,15 @@ ${bold("run OPTIONS")}
181
181
  --bundle-max-mb N Remote-lease bundle size cap in MB (default 50, compressed). The bundle
182
182
  excludes .bb-stack.json, node_modules, supabase/.temp|.branches and
183
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}].
184
193
 
185
194
  ${bold("GLOBAL")}
186
195
  --json Pretty-print the receipt (default is one compact JSON line).
@@ -212,6 +221,9 @@ ${bold("REMOTE LEASES")} (BOT-2142, stack run)
212
221
  5 CALLBACK_NOT_LOOPBACK (reserved for BOT-2184) the host's -R listener is not loopback-only
213
222
  (sshd GatewayPorts yes) or could not be checked with lsof
214
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
215
227
  4 CALLBACK_PORT_UNSUPPORTED_REMOTE --callback-port on a remote lease (refused until BOT-2184;
216
228
  under auto the remote lease is released first)
217
229
  6 TUNNEL_LOST the forward exited mid-child: the child is stopped, the lease released,
@@ -280,6 +292,9 @@ export function parseStackArgs(argv) {
280
292
  dockerContext: null, dockerEndpoint: null,
281
293
  // BOT-2142: execution placement (null = command default), remote-lease callback + bundle cap.
282
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: [],
283
298
  disposition: "destroy", json: false, receiptMaxBytes: DEFAULT_RECEIPT_MAX_BYTES,
284
299
  };
285
300
  const positionals = [];
@@ -369,6 +384,13 @@ export function parseStackArgs(argv) {
369
384
  }
370
385
  break;
371
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
+ }
372
394
  case "--bundle-max-mb": {
373
395
  if (need(a, rest[i + 1])) {
374
396
  const n = Number(rest[++i]);
@@ -416,6 +438,12 @@ export function parseStackArgs(argv) {
416
438
  } else if (opts.remote && opts.callbackPort != null) {
417
439
  errors.push(CALLBACK_PORT_REMOTE_REFUSAL);
418
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)];
419
447
  opts.execution = opts.remote ? "remote" : opts.localOnly ? "local" : null;
420
448
  if (typeof opts.stackPath !== "string" || !opts.stackPath.trim() || opts.stackPath.startsWith("/") ||
421
449
  opts.stackPath.split("/").some((part) => part === "..")) {
@@ -3023,6 +3051,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3023
3051
  // `--local-only` never ships anything.
3024
3052
  let executionRequest = opts.execution ?? "auto";
3025
3053
  let bundle = null;
3054
+ const bundleInclude = opts.bundleInclude ?? [];
3026
3055
  if (executionRequest !== "local") {
3027
3056
  try {
3028
3057
  // BOT-2162: ship the worktree's BOT-2107 readiness declaration with the stack, so the
@@ -3033,10 +3062,17 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3033
3062
  bundle = buildBundle(stackDirFor(execution), {
3034
3063
  maxBytes: opts.bundleMaxBytes ?? DEFAULT_BUNDLE_MAX_BYTES,
3035
3064
  ...(hasDeclaration ? { extraFiles: [{ rel: ".botbuddy/config.json", abs: declaration }] } : {}),
3065
+ ...(bundleInclude.length ? { include: bundleInclude } : {}),
3036
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
+ }
3037
3070
  } catch (error) {
3038
3071
  const message = String(error?.message ?? error);
3039
- 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) {
3040
3076
  const fixable = typeof error?.code === "string" && error.code.startsWith("BUNDLE_");
3041
3077
  return { exitCode: fixable ? EXIT.INVALID : EXIT.INTERNAL, outcome: "error", code: error?.code ?? "BUNDLE_FAILED", error: message };
3042
3078
  }
@@ -3054,6 +3090,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3054
3090
  machine_uuid: hardwareUuid,
3055
3091
  execution: executionRequest,
3056
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 } : {}),
3057
3095
  ...(opts.callbackPort != null ? { callback_port: opts.callbackPort } : {}),
3058
3096
  };
3059
3097
  // The retrying caller (api → rpc.call) transparently clears+self-heals+retries the
@@ -3173,6 +3211,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3173
3211
  return { exitCode: EXIT.BACKEND, outcome: "error", code: "BUNDLE_UPLOAD_FAILED", leaseId, cleanup, error: uploaded?.error || "bundle upload failed" };
3174
3212
  }
3175
3213
  placement.bundle = { sha256: bundle.sha256, bytes: bundle.size };
3214
+ if (bundle.included?.length) placement.bundleIncluded = bundle.included;
3176
3215
  stderrLine(`stack: lease ${leaseId} is remote on ${placement.hostKey ?? "?"} — uploaded the ${bundle.size}-byte stack bundle (sha256 ${bundle.sha256.slice(0, 12)}…)`);
3177
3216
  } else if (bundle && state === "queued" && request.data.bundle_upload_url) {
3178
3217
  // BOT-2207: the lease queued HERE but may still move: every minute the control plane
@@ -3187,6 +3226,7 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3187
3226
  }
3188
3227
  if (uploaded?.ok) {
3189
3228
  placement.bundle = { sha256: bundle.sha256, bytes: bundle.size };
3229
+ if (bundle.included?.length) placement.bundleIncluded = bundle.included;
3190
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`);
3191
3231
  } else {
3192
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.`);
@@ -3403,9 +3443,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3403
3443
  }
3404
3444
  }
3405
3445
 
3406
- async function cmdRun(opts, childArgv) {
3407
- const result = await runStackLifecycle(opts, childArgv);
3408
- 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 {
3409
3449
  // BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
3410
3450
  // so automation can branch on it instead of matching the message text.
3411
3451
  command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
@@ -3421,6 +3461,8 @@ async function cmdRun(opts, childArgv) {
3421
3461
  transport: result.execution?.transport ?? "none",
3422
3462
  ...(result.execution?.forwards ? { forwards: result.execution.forwards } : {}),
3423
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 } : {}),
3424
3466
  // BOT-2207: why it ran where it did (e.g. moved_remote_on_requeue) and every host weighed.
3425
3467
  ...(result.execution?.placementReason ? { placement_reason: result.execution.placementReason } : {}),
3426
3468
  ...(result.execution?.considered ? { considered: result.execution.considered } : {}),
@@ -3429,7 +3471,12 @@ async function cmdRun(opts, childArgv) {
3429
3471
  // incident (reason, window, attempts, recovered) — never as the lease's outcome.
3430
3472
  ...(result.incident ? { platform_incident: result.incident } : {}),
3431
3473
  ...(result.ledger ? { unit_ledger: result.ledger } : {}),
3432
- }), 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);
3433
3480
  }
3434
3481
 
3435
3482
  /** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */