@botbuddy/cli 1.31.1 → 1.32.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.31.1",
3
+ "version": "1.32.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent-key.mjs CHANGED
@@ -9,6 +9,8 @@
9
9
  // (supabase/functions/_shared/sessionToken.ts). The prior `bb_agent_` shape is
10
10
  // accepted as a legacy alias for one release (its hash is already on `sessions`).
11
11
  // This is a SHAPE check only — authorization is the server's hashed lookup.
12
+ import { TIER3_LEGACY, credentialTier } from "./credential-kinds.mjs";
13
+
12
14
  export const AGENT_KEY_RE = /^bb_(agent|sess)_[0-9a-f]{64}$/;
13
15
 
14
16
  // BOT-1649: BOTBUDDY_AGENT_SESSION_TOKEN names the short-lived credential
@@ -32,3 +34,57 @@ export function readAgentSessionTokenEnv(env = process.env) {
32
34
 
33
35
  // Deprecated exported alias for downstream CLI integrations.
34
36
  export const readAgentKeyEnv = readAgentSessionTokenEnv;
37
+
38
+ // BOT-1701 (AC-6) — one canonical stderr nudge when a tier-3 legacy alias is used
39
+ // for a wait/run: a legacy CLI flag (--agent-key / --session-token), a legacy env
40
+ // var (BOTBUDDY_AGENT_KEY / BOTBUDDY_SESSION_TOKEN) that actually supplied the
41
+ // token, or a bb_agent_-prefixed token value. Returns a SINGLE line (never more)
42
+ // naming the canonical flag + env, or null when only canonical names were used.
43
+ // The caller writes it to STDERR only, so a machine-parseable stdout receipt is
44
+ // byte-identical to a canonical-flag run. Precedence matches the CLI: a token
45
+ // flag overrides the env, so the env is only "used" when no token flag was given.
46
+ // The value passed after any tier-3 token flag (canonical or legacy), or null.
47
+ function tokenAfterFlag(argv, flags) {
48
+ for (let i = 0; i < argv.length; i++) {
49
+ if (flags.includes(argv[i])) return argv[i + 1] ?? null;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function legacyAgentAliasHint({ argv = [], env = process.env, token = null } = {}) {
55
+ const tier3 = credentialTier(3);
56
+ const canonicalFlag = tier3.flag; // --agent-session-token
57
+ const canonicalEnv = tier3.env; // BOTBUDDY_AGENT_SESSION_TOKEN
58
+ const tokenFlags = [canonicalFlag, ...TIER3_LEGACY.flags];
59
+
60
+ // Only the CLI's own args count. `bb run/test … -- <workload> --agent-key …`
61
+ // forwards a workload after `--` whose identically named flags are NOT BotBuddy
62
+ // credentials, so never scan past the separator (Codex R6).
63
+ const sep = argv.indexOf("--");
64
+ const cliArgs = sep === -1 ? argv : argv.slice(0, sep);
65
+
66
+ const legacyFlagUsed = TIER3_LEGACY.flags.some((f) => cliArgs.includes(f));
67
+ const flagToken = tokenAfterFlag(cliArgs, tokenFlags);
68
+ const anyTokenFlag = flagToken !== null || legacyFlagUsed || cliArgs.includes(canonicalFlag);
69
+
70
+ // The value the env would supply (canonical wins), so a bb_agent_ VALUE behind
71
+ // the canonical flag/env is nudged even though the NAME is canonical.
72
+ let legacyEnvUsed = false;
73
+ let envToken = null;
74
+ if (!anyTokenFlag) {
75
+ const canonical = typeof env[canonicalEnv] === "string" ? env[canonicalEnv].trim() : "";
76
+ if (canonical) {
77
+ envToken = env[canonicalEnv];
78
+ } else {
79
+ for (const e of TIER3_LEGACY.envs) {
80
+ if (typeof env[e] === "string" && env[e].trim() !== "") { legacyEnvUsed = true; envToken = env[e]; break; }
81
+ }
82
+ }
83
+ }
84
+
85
+ const effectiveToken = token ?? flagToken ?? envToken;
86
+ const legacyPrefixUsed = typeof effectiveToken === "string" && effectiveToken.startsWith(TIER3_LEGACY.prefix);
87
+
88
+ if (!legacyFlagUsed && !legacyEnvUsed && !legacyPrefixUsed) return null;
89
+ return `note: --agent-key / --session-token / $BOTBUDDY_AGENT_KEY / ${TIER3_LEGACY.prefix} are one-release legacy aliases — use ${canonicalFlag} or $${canonicalEnv} (docs/agent-connectivity.md)`;
90
+ }
Binary file
@@ -30,6 +30,48 @@ export const CREDENTIAL_PREFIXES = [
30
30
  { prefix: "bb_", kind: "legacy", label: "legacy personal key" },
31
31
  ];
32
32
 
33
+ // BOT-1701 — the ONE canonical per-tier credential contract. The same noun,
34
+ // prefix, CLI flag, env var, and `credential_kind` for each tier, named the same
35
+ // way EVERYWHERE it is met: the setup-block help text, the connectivity guide's
36
+ // tier table, the `bb wait/run/test/pw --help` flags, the connectivity error
37
+ // strings, and the server's `whoami credential_kind`. A drift test
38
+ // (credential-naming-contract.test.mjs) fails when any of those surfaces names a
39
+ // tier-3 credential by anything other than the canonical noun/flag/env below.
40
+ // Decided by BOT-1649 — do not re-decide here. Additive only: legacy aliases
41
+ // (TIER3_LEGACY) keep working for one release and are listed separately so a
42
+ // surface can name them ONLY on a line that says "alias"/"legacy".
43
+ /**
44
+ * @typedef {{ tier: 1|2|3, noun: string, prefix: string, flag: string|null, env: string, kind: string }} CredentialTier
45
+ * @type {ReadonlyArray<CredentialTier>}
46
+ */
47
+ export const CREDENTIAL_TIERS = Object.freeze([
48
+ Object.freeze({ tier: 1, noun: "client key", prefix: "bb_cli_", flag: null, env: "BOTBUDDY_CLIENT_KEY", kind: "cli" }),
49
+ Object.freeze({ tier: 2, noun: "MCP key", prefix: "bb_mcp_", flag: null, env: "BOTBUDDY_MCP_KEY", kind: "mcp" }),
50
+ Object.freeze({ tier: 3, noun: "agent session token", prefix: "bb_sess_", flag: "--agent-session-token", env: "BOTBUDDY_AGENT_SESSION_TOKEN", kind: "agent_session" }),
51
+ ]);
52
+
53
+ /**
54
+ * The tier-3 legacy aliases, accepted for one release. Kept apart from the
55
+ * canonical table so a surface can name them only on an "alias"/"legacy" line and
56
+ * the CLI can nudge an operator toward the canonical flag/env (BOT-1701 AC-6).
57
+ */
58
+ export const TIER3_LEGACY = Object.freeze({
59
+ flags: Object.freeze(["--agent-key", "--session-token"]),
60
+ envs: Object.freeze(["BOTBUDDY_AGENT_KEY", "BOTBUDDY_SESSION_TOKEN"]),
61
+ prefix: "bb_agent_",
62
+ });
63
+
64
+ /** The canonical tier row for a tier number (1|2|3), or undefined. */
65
+ export function credentialTier(tier) {
66
+ return CREDENTIAL_TIERS.find((t) => t.tier === tier);
67
+ }
68
+
69
+ /** The canonical tier row whose prefix a secret carries, or undefined. */
70
+ export function credentialTierByPrefix(secret) {
71
+ if (typeof secret !== "string") return undefined;
72
+ return CREDENTIAL_TIERS.find((t) => secret.startsWith(t.prefix));
73
+ }
74
+
33
75
  /**
34
76
  * Classify a raw secret by its prefix.
35
77
  * @param {unknown} secret
package/src/pw/run.mjs CHANGED
@@ -7,13 +7,13 @@ import { canonicalizeHostString } from "./host.mjs";
7
7
  import { resolveAgentBinding } from "../wait-profile.mjs";
8
8
  import { loadConfig, getConfig } from "../config.mjs";
9
9
  import { VERSION } from "../version.mjs";
10
- import { readAgentSessionTokenEnv, AGENT_KEY_RE } from "../agent-key.mjs";
10
+ import { readAgentSessionTokenEnv, AGENT_KEY_RE, legacyAgentAliasHint } from "../agent-key.mjs";
11
11
  import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
12
12
  // BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
13
13
  // server-side, so the lane name bb-pw builds/matches/prints is the one the lock
14
14
  // kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
15
15
  const hostFor = (env) => canonicalizeHostString(env.PLAYWRIGHT_MCP_HOST || env.HOSTNAME || os.hostname());
16
- function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--tenant <slug> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n"); }
16
+ function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] [--agent-session-token <token>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN);\n --agent-key / --session-token are one-release legacy aliases.\n--tenant <slug> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n"); }
17
17
  function redact(value, secretValues = []) { return secretValues.reduce((text, secret) => secret ? text.split(secret).join("[redacted]") : text, String(value ?? "")); }
18
18
  // BOT-1488: the register_agent identity for this machine, persisted by
19
19
  // `botbuddy register` into ~/.botbuddy/config.json. This is the SESSION agent
@@ -99,9 +99,28 @@ export async function runPw(argv, deps = {}) {
99
99
  if (code !== 0) stderr.write(`${versionLine()} — if your checkout's cli/package.json is newer, this global is stale: npm i -g @botbuddy/cli@latest, or run node cli/bin/bb-pw.mjs from the repo.\n`);
100
100
  return code;
101
101
  }
102
+ // The leading option/value pairs bb pw consumes before the lane/verb (mirrors the
103
+ // while-loop in runPwInner). Everything after the first non-option (the lane) is a
104
+ // Playwright workload argument and is never a BotBuddy credential.
105
+ const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"];
106
+ function leadingPwOptions(args) {
107
+ const lead = [];
108
+ for (let i = 0; i < args.length && PW_LEADING_FLAGS.includes(args[i]); i += 2) {
109
+ lead.push(args[i]);
110
+ if (i + 1 < args.length) lead.push(args[i + 1]);
111
+ }
112
+ return lead;
113
+ }
114
+
102
115
  async function runPwInner(argv, deps = {}) {
103
116
  const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
104
117
  if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
118
+ // BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy flag/env alias. bb pw
119
+ // only recognizes credential flags as LEADING options (the while-loop below);
120
+ // a `--agent-key` after the lane/verb is a Playwright workload argument, not a
121
+ // BotBuddy credential, so scan only the leading options (Codex R9).
122
+ const aliasHint = legacyAgentAliasHint({ argv: leadingPwOptions(args), env });
123
+ if (aliasHint) stderr.write(`bb pw: ${aliasHint}\n`);
105
124
  // BOT-1649: --agent-session-token / $BOTBUDDY_AGENT_SESSION_TOKEN is canonical;
106
125
  // legacy flags and env aliases remain accepted as a
107
126
  // session identity alongside --session-id, so a token-armed session need not
package/src/run.mjs CHANGED
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { callToolJson } from "./api.mjs";
16
16
  import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
17
- import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
17
+ import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
18
18
  import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
19
19
  import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
20
20
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
@@ -532,9 +532,14 @@ export async function cmdRun(args) {
532
532
  return;
533
533
  }
534
534
  if (args[0] === "help" || args[0] === "--help") {
535
- console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>");
535
+ console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
536
536
  return;
537
537
  }
538
+ // BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used
539
+ // (stderr only, so the stdout receipt stays byte-identical). A cached bb_agent_
540
+ // token is nudged inside resolveAgentSessionCredential.
541
+ const aliasHint = legacyAgentAliasHint({ argv: args, env: process.env });
542
+ if (aliasHint) process.stderr.write(`botbuddy run: ${aliasHint}\n`);
538
543
  if (args.includes("--foreground")) {
539
544
  let identity;
540
545
  try { identity = await loadTelemetryIdentity(); }
@@ -8,46 +8,39 @@
8
8
  // session persistence; normal operator output must never turn that into a
9
9
  // multi-command credential handoff.
10
10
 
11
+ import { credentialTier } from "./credential-kinds.mjs";
12
+
11
13
  /** The canonical guide, referenced from every SETUP block and recovery. */
12
14
  export const CONNECTIVITY_GUIDE = "docs/agent-connectivity.md";
13
15
 
14
16
  // The three connectivity credential tiers, named consistently everywhere. A
15
17
  // connectivity error names the MISSING tier and the exact next command; the
16
18
  // guide's tiered table and the help SETUP block use these same labels.
19
+ //
20
+ // BOT-1701: the noun, prefix, flag, env, and kind for each tier are OWNED by
21
+ // credential-kinds.mjs (CREDENTIAL_TIERS) — the single source of truth. This map
22
+ // only adds the setup-specific fields (the tier number, the `setup` command, and
23
+ // the scope). A drift test asserts every user-facing surface agrees with the
24
+ // canonical table, so `label`/`prefix`/`env`/`flag` here can never fork from it.
17
25
  export const TIERS = Object.freeze({
18
26
  // Tier 1 — per-machine client key. `bb login`, USER-scoped (reaches every
19
27
  // tenant you belong to; each request pins one). Never a wait credential.
20
- client: Object.freeze({
21
- n: 1,
22
- label: "client key",
23
- prefix: "bb_cli_",
24
- env: "BOTBUDDY_CLIENT_KEY",
25
- setup: "bb login",
26
- scope: "user",
27
- }),
28
+ client: Object.freeze({ ...tierFields(1), setup: "bb login", scope: "user" }),
28
29
  // Tier 2 — per-tenant MCP config key. `bb mcp setup`, TENANT-bound, lives in
29
30
  // .mcp.json under $BOTBUDDY_MCP_KEY. Authenticates MCP calls + register_agent.
30
- mcp: Object.freeze({
31
- n: 2,
32
- label: "MCP key",
33
- prefix: "bb_mcp_",
34
- env: "BOTBUDDY_MCP_KEY",
35
- setup: "bb mcp setup",
36
- scope: "tenant",
37
- }),
38
- // Tier 3 — per-session agent token, tenant-bound, 8 h TTL, revoked on
31
+ mcp: Object.freeze({ ...tierFields(2), setup: "bb mcp setup", scope: "tenant" }),
32
+ // Tier 3 — per-session agent session token, tenant-bound, 8 h TTL, revoked on
39
33
  // re-register. The CLI caches it in .botbuddy/agent-state.json and restores
40
34
  // it automatically for wait/run/test/pw.
41
- session: Object.freeze({
42
- n: 3,
43
- label: "session token",
44
- prefix: "bb_sess_",
45
- env: "BOTBUDDY_AGENT_SESSION_TOKEN",
46
- setup: "register_agent",
47
- scope: "session",
48
- }),
35
+ session: Object.freeze({ ...tierFields(3), setup: "register_agent", scope: "session" }),
49
36
  });
50
37
 
38
+ /** Project a canonical CREDENTIAL_TIERS row onto the TIERS shape (n/label/…). */
39
+ function tierFields(tier) {
40
+ const t = credentialTier(tier);
41
+ return { n: t.tier, label: t.noun, prefix: t.prefix, env: t.env, flag: t.flag };
42
+ }
43
+
51
44
  /**
52
45
  * The shared SETUP block reproduced verbatim in every `--help` output. Plain
53
46
  * text (no ANSI) so the snapshot test can match it byte-for-byte across surfaces.
@@ -11,9 +11,25 @@ import { join } from "node:path";
11
11
 
12
12
  let tmpCounter = 0;
13
13
 
14
+ // Decode the escape sequences a TOML BASIC string ("...") allows, so an escaped value
15
+ // compares equal to its literal form (BOT-1711 Codex R15): `"shared\u002Dcanonical"` and
16
+ // `"shared-canonical"` are the same project to Supabase. LITERAL strings ('...') are raw.
17
+ function decodeTomlBasicString(s) {
18
+ return String(s).replace(/\\(u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|[btnfr"\\])/g, (_m, esc) => {
19
+ if (esc[0] === "u" || esc[0] === "U") return String.fromCodePoint(parseInt(esc.slice(1), 16));
20
+ return { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" }[esc];
21
+ });
22
+ }
23
+
14
24
  export function projectIdFromConfig(configText) {
15
- const match = String(configText).match(/^\s*project_id\s*=\s*"([^"]+)"/m);
16
- return match ? match[1] : null;
25
+ // TOML accepts BASIC ("...", with escapes) and LITERAL ('...', raw) strings; match either
26
+ // so neither a single-quoted nor an escaped project_id can bypass identity checks
27
+ // (BOT-1711 Codex R6/R15). The basic-string body allows escaped quotes (`\"`).
28
+ const text = String(configText);
29
+ const basic = text.match(/^\s*project_id\s*=\s*"((?:[^"\\]|\\.)*)"/m);
30
+ if (basic) return decodeTomlBasicString(basic[1]);
31
+ const literal = text.match(/^\s*project_id\s*=\s*'([^']*)'/m);
32
+ return literal ? literal[1] : null;
17
33
  }
18
34
 
19
35
  export function dbPortFromConfig(configText) {
package/src/stack.mjs CHANGED
@@ -32,6 +32,7 @@ import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
33
  import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
34
34
  import { runDockerCommand, runDockerWorkflow, ADMITTED_DOCKER_VALIDATIONS } from "./docker-hygiene.mjs";
35
+ import { projectIdFromConfig } from "./stack-file-lock.mjs";
35
36
  import { machineUuid } from "./machine-id.mjs";
36
37
  import { bold, dim, yellow } from "./utils.mjs";
37
38
 
@@ -70,11 +71,15 @@ ${bold("up OPTIONS")}
70
71
  --repo <repo> Repository the batch is for (e.g. botbuddy-web).
71
72
  --ticket <BOT-123> Ticket the batch is for (also used to derive the slot).
72
73
  --stack-path <relative> Stack directory inside the registered worktree (default: .).
74
+ REQUIRED with --local-exec: point it at a slot-derived stack whose
75
+ supabase/config.toml declares a DISTINCT project_id + remapped ports
76
+ (never the worktree root's shared canonical project).
73
77
  --purpose <text> Free-text purpose recorded on the lease.
74
78
  --idle-ttl <seconds> Idle seconds before the reaper STOPS an unused stack (default 1800).
75
79
  --timeout <seconds> Max seconds to park for capacity before giving up (default ${DEFAULT_TIMEOUT_SEC}).
76
80
  --no-wait If the host is full, print the queue position and exit (don't park).
77
- --local-exec FALLBACK (no Helper): run 'supabase start' locally and self-activate.
81
+ --local-exec FALLBACK (no Helper): run 'supabase start' in an ISOLATED --stack-path
82
+ stack and self-activate. Refused at the worktree root (shared stack).
78
83
  --docker-context <name> Explicit Docker context for the mandatory local preflight
79
84
  (an allowlisted engine: OrbStack or Docker Desktop, e.g. desktop-linux).
80
85
  --docker-endpoint <uri> Explicit Docker endpoint instead of --docker-context.
@@ -253,6 +258,15 @@ export function parseStackArgs(argv) {
253
258
  if (["up", "done"].includes(command) && opts.localExec && Boolean(opts.dockerContext) === Boolean(opts.dockerEndpoint)) {
254
259
  errors.push(`--local-exec ${command} requires exactly one of --docker-context <name> or --docker-endpoint <uri>`);
255
260
  }
261
+ // BOT-1711: a local-exec `up` must isolate the leased stack in a --stack-path subdirectory
262
+ // (its own supabase/config.toml → distinct project_id + remapped ports). The worktree root
263
+ // is the developer's shared canonical dev stack; `supabase start`/`stop` there is the exact
264
+ // clobber this closes. `done` needs no --stack-path (the teardown dir comes from the lease).
265
+ if (command === "up" && opts.localExec && (typeof opts.stackPath !== "string" || opts.stackPath === ".")) {
266
+ errors.push("--local-exec up requires --stack-path <isolated-stack-dir>: an isolated leased stack must not be the " +
267
+ "worktree root's default (shared canonical) project. Point --stack-path at a slot-derived stack directory with its " +
268
+ "own supabase/config.toml (distinct project_id + remapped ports).");
269
+ }
256
270
  if ((opts.dockerContext || opts.dockerEndpoint) && !(["up", "done"].includes(command) && opts.localExec)) {
257
271
  errors.push("--docker-context and --docker-endpoint are valid only with `stack up --local-exec` or `stack done --local-exec`");
258
272
  }
@@ -292,29 +306,6 @@ export function truncateReceipt(receipt, maxBytes = DEFAULT_RECEIPT_MAX_BYTES) {
292
306
  };
293
307
  }
294
308
 
295
- /**
296
- * Resolve a LOCAL Supabase API origin to pin into the `supabase start` environment
297
- * (BOT-903 / Codex P1): without `VITE_SUPABASE_URL` pinned, config.toml's
298
- * `env(VITE_SUPABASE_URL)` falls back to the repo's `.env` PRODUCTION origin, so the
299
- * "disposable" local edge runtime would address `https://api.bot-buddy.ai`. Precedence:
300
- * 1. an already-exported local (`127.0.0.1`/`localhost`) `VITE_SUPABASE_URL`;
301
- * 2. `http://127.0.0.1:<[api] port>` read from `./supabase/config.toml`.
302
- * Returns null when neither is available — the caller then REFUSES to run `supabase
303
- * start` rather than risk crossing into production.
304
- */
305
- export function resolveLocalSupabaseUrl(env = process.env, cwd = process.cwd()) {
306
- const cur = env.VITE_SUPABASE_URL;
307
- if (cur && /(127\.0\.0\.1|localhost)/.test(cur)) return cur;
308
- try {
309
- const toml = readFileSync(`${cwd}/supabase/config.toml`, "utf8");
310
- // The [api] section's `port = NNNNN` (stop at the next section header).
311
- const section = /\[api\]([\s\S]*?)(\n\[|$)/.exec(toml);
312
- const m = section && /\bport\s*=\s*(\d+)/.exec(section[1]);
313
- if (m) return `http://127.0.0.1:${m[1]}`;
314
- } catch { /* no config.toml here */ }
315
- return null;
316
- }
317
-
318
309
  /** Resolve the requested stack directory once, before it leaves the coding
319
310
  * machine. This closes both `..` and symlink escapes; the server separately
320
311
  * verifies the resulting root is a registered worktree on the selected host. */
@@ -597,24 +588,30 @@ function dockerEnvForTarget(target, env = process.env) {
597
588
  * OrbStack endpoint, reports the same non-secret API + DB endpoints stored on
598
589
  * the lease. Only then may that observed daemon be used for teardown.
599
590
  */
600
- export function proveLegacyLocalExecTarget(lease, observedTarget, opts, run = spawnSync, cwd = process.cwd()) {
591
+ export function proveLegacyLocalExecTarget(lease, observedTarget, _opts, run = spawnSync, cwd = process.cwd()) {
601
592
  if (!observedTarget?.resolved_endpoint || !observedTarget?.server_id) {
602
593
  return { ok: false, error: "fresh OrbStack target identity is incomplete" };
603
594
  }
604
- let execution;
595
+ if (!lease?.worktree_root) {
596
+ return { ok: false, error: "legacy lease has no recorded worktree_root" };
597
+ }
598
+ // BOT-1711 (Codex R10): prove the caller is in the lease's REGISTERED worktree root, and
599
+ // take the stack subdirectory from the LEASE's recorded stack_path — NOT from opts.stackPath.
600
+ // `stack done <id> --local-exec` carries no --stack-path, so re-resolving opts.stackPath
601
+ // ("." by default) would reject every non-root legacy lease as an invoking-worktree mismatch.
602
+ let callerRoot;
605
603
  try {
606
- execution = resolveStackPath(cwd, opts?.stackPath || ".");
604
+ callerRoot = realpathSync(cwd);
607
605
  } catch (error) {
608
- return { ok: false, error: `could not resolve the invoking stack path: ${error.message}` };
606
+ return { ok: false, error: `could not resolve the invoking worktree: ${error.message}` };
609
607
  }
610
- if (!lease?.worktree_root || lease.worktree_root !== execution.worktreeRoot ||
611
- (lease.stack_path || ".") !== execution.stackPath) {
612
- return { ok: false, error: "legacy lease worktree/stack metadata does not exactly match the invoking worktree" };
608
+ if (lease.worktree_root !== callerRoot) {
609
+ return { ok: false, error: "legacy lease worktree does not match the invoking worktree" };
613
610
  }
614
-
615
- const stackDir = execution.stackPath === "."
616
- ? execution.worktreeRoot
617
- : join(execution.worktreeRoot, execution.stackPath);
611
+ const leaseStackPath = lease.stack_path || ".";
612
+ const stackDir = leaseStackPath === "."
613
+ ? lease.worktree_root
614
+ : join(lease.worktree_root, leaseStackPath);
618
615
  const status = run("supabase", ["status", "-o", "json", "--workdir", stackDir], {
619
616
  encoding: "utf8",
620
617
  env: dockerEnvForTarget(observedTarget),
@@ -634,8 +631,8 @@ export function proveLegacyLocalExecTarget(lease, observedTarget, opts, run = sp
634
631
  ok: true,
635
632
  evidence: {
636
633
  method: "legacy_worktree_connection_match",
637
- worktree_root: execution.worktreeRoot,
638
- stack_path: execution.stackPath,
634
+ worktree_root: lease.worktree_root,
635
+ stack_path: leaseStackPath,
639
636
  api_url: live.api_url,
640
637
  db_port: dbPort,
641
638
  resolved_endpoint: observedTarget.resolved_endpoint,
@@ -655,33 +652,282 @@ function compactPreflight(receipt) {
655
652
  };
656
653
  }
657
654
 
658
- /** LOUD local-exec fallback: bring a stack up in the cwd via the Supabase CLI. */
659
- function localProvision(opts, dockerTarget) {
660
- // Pin a LOCAL origin so `supabase start` never bakes the repo's prod origin into the
661
- // edge runtime (BOT-903 / Codex P1). Refuse rather than risk crossing into production.
662
- const url = resolveLocalSupabaseUrl();
663
- if (!url) {
655
+ /**
656
+ * BOT-903 / BOT-1711 (Codex P1) — pin the edge origin to the ISOLATED stack's OWN
657
+ * `[api] port`.
658
+ *
659
+ * `supabase start` bakes `VITE_SUPABASE_URL` into the edge runtime. Trusting an ambient
660
+ * local `VITE_SUPABASE_URL` is unsafe for a leased stack: if the caller has exported the
661
+ * shared canonical stack's origin, `supabase start` brings the isolated project up but
662
+ * bakes the SHARED stack's URL into its edge runtime, so tests that follow those URLs
663
+ * escape the leased stack into shared data (and without any local origin it would fall
664
+ * back to the repo's PRODUCTION origin). The isolated stack's own `[api] port` is
665
+ * therefore authoritative: require it, and reject an ambient LOCAL origin whose port
666
+ * does not match it. Throws a caller-facing Error otherwise.
667
+ */
668
+ export function resolveIsolatedStackApiUrl(stackDir, env = process.env, read = readFileSync) {
669
+ let port = null;
670
+ try {
671
+ const toml = read(`${stackDir}/supabase/config.toml`, "utf8");
672
+ // Derive the API port from the SAME comment-aware, section-qualified, integer-normalizing
673
+ // parser used for isolation (BOT-1711 R16): a separate ad-hoc regex here diverged — it
674
+ // treated a commented `# [api]\n# port =` as config and mis-pinned the edge origin.
675
+ port = portMapInConfig(toml).get("api.port") ?? null;
676
+ } catch { /* handled below */ }
677
+ if (!port) {
678
+ throw new Error(
679
+ `refusing --local-exec: the isolated stack at ${stackDir} declares no supabase/config.toml [api] port — ` +
680
+ "cannot pin the leased stack's own edge origin (the edge runtime must emit the leased stack's URLs, not the shared stack's).",
681
+ );
682
+ }
683
+ const origin = `http://127.0.0.1:${port}`;
684
+ const ambient = env.VITE_SUPABASE_URL;
685
+ if (ambient) {
686
+ let host = null; let ambientPort = null;
687
+ try { const u = new URL(ambient); host = u.hostname; ambientPort = u.port; } catch { /* non-URL ambient is ignored */ }
688
+ if ((host === "127.0.0.1" || host === "localhost") && ambientPort !== String(port)) {
689
+ throw new Error(
690
+ `refusing --local-exec: ambient VITE_SUPABASE_URL (${ambient}) is a LOCAL origin whose port does not match the ` +
691
+ `isolated stack's API port (${origin}); the edge runtime would emit another stack's URLs. Unset VITE_SUPABASE_URL ` +
692
+ "or point it at the leased stack.",
693
+ );
694
+ }
695
+ }
696
+ return origin;
697
+ }
698
+
699
+ /** Resolve the absolute stack directory (where `supabase/config.toml` lives) for a
700
+ * resolved {worktreeRoot, stackPath}. `"."` is the worktree root itself. */
701
+ export function stackDirFor(execution) {
702
+ return execution.stackPath === "."
703
+ ? execution.worktreeRoot
704
+ : join(execution.worktreeRoot, execution.stackPath);
705
+ }
706
+
707
+ /** Absolute stack directory a lease was provisioned in, from its recorded
708
+ * worktree_root/stack_path (BOT-1711 teardown). Falls back to `cwd` for a lease
709
+ * that predates worktree_root recording. */
710
+ export function leaseStackDir(lease, cwd = process.cwd()) {
711
+ const worktreeRoot = lease?.worktree_root;
712
+ if (!worktreeRoot) return cwd;
713
+ const stackPath = lease?.stack_path || ".";
714
+ return stackPath === "." ? worktreeRoot : join(worktreeRoot, stackPath);
715
+ }
716
+
717
+ /** Read a `supabase/config.toml` project_id under `dir`, or null if unreadable. */
718
+ function projectIdUnder(dir, read = readFileSync) {
719
+ try { return projectIdFromConfig(read(`${dir}/supabase/config.toml`, "utf8")); }
720
+ catch { return null; }
721
+ }
722
+
723
+ /** Every port a `config.toml` allocates, as a SECTION-QUALIFIED map `"<section>.<key>" ->
724
+ * "<port>"` (e.g. `api.port`, `db.shadow_port`, `inbucket.pop3_port`). Section-qualified so
725
+ * the SAME `port` key under [api]/[db]/[studio]/[inbucket] stays distinct, which lets a
726
+ * target be checked for BOTH completeness (declares every port the root does) and
727
+ * disjointness (shares no port value). */
728
+ // Normalize any valid TOML integer literal to its decimal string: decimal (with `_`
729
+ // separators), or `0x`/`0o`/`0b` radix forms. Returns null for a non-integer. Without this
730
+ // a hex/octal port (`0xdc01` == 56321) or a separated one (`56_321`) would parse as a
731
+ // truncated value and bypass the port-collision checks while Supabase binds the full port
732
+ // (BOT-1711 Codex R11/R14).
733
+ function tomlIntToDecimal(token) {
734
+ const cleaned = String(token).replace(/_/g, "");
735
+ const n = Number(cleaned);
736
+ return Number.isInteger(n) && n >= 0 ? String(n) : null;
737
+ }
738
+
739
+ // TOML decimal integers may carry a leading sign (`+56321`); radix forms may not. A
740
+ // negative value is rejected downstream by tomlIntToDecimal (BOT-1711 Codex R15).
741
+ const TOML_INT_PORT = "(0[xX][0-9A-Fa-f_]+|0[oO][0-7_]+|0[bB][01_]+|[+-]?[0-9][0-9_]*)";
742
+
743
+ function portMapInConfig(toml) {
744
+ const map = new Map();
745
+ let section = "";
746
+ const bare = new RegExp(`^((?:[A-Za-z0-9]+_)?port)\\s*=\\s*${TOML_INT_PORT}`);
747
+ // Dotted TOML key form (BOT-1711 R16): `api.port = N` / `db.shadow_port = N`, section-
748
+ // qualified inline instead of under a `[section]` header. Normalizes to the same key.
749
+ const dotted = new RegExp(`^([A-Za-z0-9_]+)\\.((?:[A-Za-z0-9]+_)?port)\\s*=\\s*${TOML_INT_PORT}`);
750
+ for (const raw of String(toml).split(/\r?\n/)) {
751
+ const line = raw.trim();
752
+ if (line.startsWith("#")) continue; // comments are not configuration
753
+ const sec = /^\[([^\]]+)\]/.exec(line);
754
+ if (sec) { section = sec[1].trim(); continue; }
755
+ const dot = dotted.exec(line);
756
+ if (dot) {
757
+ const dec = tomlIntToDecimal(dot[3]);
758
+ if (dec != null) map.set(`${dot[1]}.${dot[2]}`, dec);
759
+ continue;
760
+ }
761
+ const m = bare.exec(line);
762
+ if (m) {
763
+ const dec = tomlIntToDecimal(m[2]);
764
+ if (dec != null) map.set(`${section}.${m[1]}`, dec);
765
+ }
766
+ }
767
+ return map;
768
+ }
769
+
770
+ /** Read a stack directory's identity — project_id + its COMPLETE, section-qualified port
771
+ * allocation — from its `supabase/config.toml`, in one read. Empty/null when unreadable. */
772
+ function readStackIdentity(dir, read = readFileSync) {
773
+ try {
774
+ const toml = read(`${dir}/supabase/config.toml`, "utf8");
775
+ return { project: projectIdFromConfig(toml), ports: portMapInConfig(toml) };
776
+ } catch { return { project: null, ports: new Map() }; }
777
+ }
778
+
779
+ /**
780
+ * BOT-1711 (Codex P1) — is a MODERN lease's recorded teardown target NON-isolated,
781
+ * i.e. the worktree root or a stack whose project_id is the worktree default? A
782
+ * pre-1.32 client could have minted a modern lease (one that carries a
783
+ * botbuddy_docker_target) at the worktree root (`stack_path "."`); tearing it down
784
+ * with `supabase stop` there would stop the shared canonical stack. Refuse those.
785
+ * Root (`stack_path "."`) is always non-isolated; for a subdir the project_id check
786
+ * is best-effort (unreadable config ⇒ treated as isolated, since the parse+provision
787
+ * guards already blocked a same-project subdir at `up`). Never mutates.
788
+ */
789
+ export function localExecTeardownIsNonIsolated(lease, read = readFileSync) {
790
+ const stackPath = lease?.stack_path || ".";
791
+ if (stackPath === ".") return true;
792
+ const worktreeRoot = lease?.worktree_root;
793
+ if (!worktreeRoot) return false;
794
+ const rootProject = projectIdUnder(worktreeRoot, read);
795
+ const stackProject = projectIdUnder(join(worktreeRoot, stackPath), read);
796
+ return Boolean(rootProject && stackProject && rootProject === stackProject);
797
+ }
798
+
799
+ /**
800
+ * BOT-1711 — the ISOLATION invariant for `--local-exec`.
801
+ *
802
+ * `supabase start`/`stop` operate whatever `project_id` the target `supabase/config.toml`
803
+ * declares. A leased batch must bring up a *disposable, isolated* stack — never the
804
+ * developer's shared canonical dev stack (the worktree root's committed default project).
805
+ * Running local-exec at the worktree root would `supabase start` (and later `supabase stop`)
806
+ * the shared canonical stack — the exact accident BOT-1711 documents (a `stack done` that
807
+ * stopped 12 shared containers).
808
+ *
809
+ * So refuse unless the target stack declares BOTH a project_id AND a COMPLETE port
810
+ * allocation DISTINCT from the worktree root's default stack. A project_id alone is not
811
+ * enough (Codex R2 P2): a config copied from the root with only project_id changed still
812
+ * binds the shared allocation's ports, so `supabase start` would squat the canonical
813
+ * endpoints if the shared stack is down, or fail after reserving the lease if it is up.
814
+ * And comparing only the API/DB ports is not enough (Codex R3 P2): the root also allocates
815
+ * `shadow_port`, Studio, Inbucket, analytics, and inspector ports, any of which a partial
816
+ * copy could still share. So require the leased stack's ENTIRE set of allocated ports to be
817
+ * disjoint from the root's. An isolated leased stack lives in a `--stack-path` subdirectory
818
+ * whose `supabase/config.toml` carries its own project_id AND a fully remapped port set (the
819
+ * SG<n> pattern / the canonical slot allocation). Throws otherwise; never mutates anything.
820
+ */
821
+ export function assertIsolatedLocalExecTarget(execution, read = readFileSync) {
822
+ const stackDir = stackDirFor(execution);
823
+ const stack = readStackIdentity(stackDir, read);
824
+ if (!stack.project) {
825
+ throw new Error(
826
+ `refusing --local-exec: no supabase/config.toml project_id under ${execution.stackPath} — ` +
827
+ "an isolated leased stack needs its own supabase/config.toml (distinct project_id + remapped ports).",
828
+ );
829
+ }
830
+ const root = execution.stackPath === "." ? stack : readStackIdentity(execution.worktreeRoot, read);
831
+ // FAIL CLOSED (Codex R6 P1): if the worktree-root identity is unreadable — absent, or a
832
+ // project_id the parser doesn't recognize — isolation cannot be proven and the port checks
833
+ // would be vacuous, yet the shared canonical containers may still be running (e.g. its
834
+ // config was renamed/regenerated). Refuse rather than accept an unprovable target.
835
+ if (execution.stackPath !== "." && !root.project) {
836
+ throw new Error(
837
+ "refusing --local-exec: cannot read the worktree root's supabase/config.toml project_id, so isolation from the " +
838
+ "shared canonical stack cannot be proven (its containers may still be running). Ensure the worktree root has a " +
839
+ "readable supabase/config.toml before running an isolated leased stack.",
840
+ );
841
+ }
842
+ if (root.project && stack.project === root.project) {
843
+ throw new Error(
844
+ `refusing --local-exec: the target stack project_id "${stack.project}" is the worktree's default ` +
845
+ "(shared canonical) project — `supabase start`/`stop` here would operate the shared dev stack, not an " +
846
+ "isolated leased stack. Point --stack-path at a slot-derived stack directory whose supabase/config.toml " +
847
+ "declares a DISTINCT project_id and remapped ports (mirror the repo's SG<n> isolation).",
848
+ );
849
+ }
850
+ // COMPLETENESS (Codex R4 P2): every port the shared stack allocates must be explicitly
851
+ // declared by the target too. A port the target OMITS falls back to Supabase's default,
852
+ // which the config never reveals and which collides with any other default-using stack —
853
+ // and provisioning then fails only AFTER the lease is reserved (a fenced lease). Require
854
+ // the full allocation up front instead.
855
+ const missing = [...root.ports.keys()].filter((k) => !stack.ports.has(k));
856
+ if (missing.length) {
857
+ throw new Error(
858
+ `refusing --local-exec: the target stack omits port(s) the shared stack allocates (${missing.join(", ")}) — ` +
859
+ "an omitted port falls back to Supabase's default and collides with other stacks. Declare and remap the COMPLETE " +
860
+ "port allocation in the leased stack's supabase/config.toml (e.g. via the canonical slot allocation).",
861
+ );
862
+ }
863
+ // INTERNAL UNIQUENESS (Codex R8 P2): two of the target's OWN services on the same port
864
+ // (e.g. api.port == db.port) would make `supabase start` collide internally — after the
865
+ // lease is reserved, leaving it fenced. Each declared port must be distinct.
866
+ const stackValues = [...stack.ports.values()];
867
+ const intraDupes = [...new Set(stackValues.filter((v, i) => stackValues.indexOf(v) !== i))];
868
+ if (intraDupes.length) {
869
+ throw new Error(
870
+ `refusing --local-exec: the target stack assigns the same port to multiple services (${intraDupes.join(", ")}) — ` +
871
+ "`supabase start` would collide internally. Give every service a distinct port in the leased stack's supabase/config.toml.",
872
+ );
873
+ }
874
+ // DISJOINTNESS: no port VALUE may be shared with the canonical allocation, or
875
+ // `supabase start` binds the shared endpoints (squatting them if the shared stack is
876
+ // down, or failing after the lease is reserved if it is up).
877
+ const rootValues = new Set(root.ports.values());
878
+ const shared = [...new Set(stack.ports.values())].filter((v) => rootValues.has(v));
879
+ if (shared.length) {
664
880
  throw new Error(
665
- "refusing --local-exec: cannot determine a LOCAL Supabase API origin (no supabase/config.toml [api] port " +
666
- "in this directory, and VITE_SUPABASE_URL is not a 127.0.0.1/localhost origin). `supabase start` would bake " +
667
- "the repository's PRODUCTION origin into the edge runtime — run from a repo with supabase/config.toml, or " +
668
- "export VITE_SUPABASE_URL=http://127.0.0.1:<port> first.",
881
+ `refusing --local-exec: the target stack reuses the worktree default stack's port(s) ${shared.join(", ")} ` +
882
+ "`supabase start` would bind the shared allocation's endpoints. Remap ALL of the leased stack's ports in its " +
883
+ "supabase/config.toml (api/db/shadow/studio/inbucket/analytics/inspector), e.g. via the canonical slot allocation.",
669
884
  );
670
885
  }
671
- const spawnEnv = { ...dockerEnvForTarget(dockerTarget), VITE_SUPABASE_URL: url };
672
- process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in this worktree (VITE_SUPABASE_URL=${url}).\n`);
673
- const start = spawnSync("supabase", ["start", "--workdir", process.cwd()], { encoding: "utf8", env: spawnEnv });
886
+ }
887
+
888
+ /**
889
+ * BOT-1711 (Codex R7 P2) — the COMPLETE non-mutating pre-flight for a local-exec target,
890
+ * run in `cmdUp` BEFORE any lease is minted or reserved. It proves both:
891
+ * 1. config isolation from the shared canonical stack (`assertIsolatedLocalExecTarget`), and
892
+ * 2. that the edge origin resolves to the leased stack's own [api] port and no ambient
893
+ * `VITE_SUPABASE_URL` points at another stack (`resolveIsolatedStackApiUrl`).
894
+ * Both were previously proven only inside `localProvision` (post-reserve), so a target known
895
+ * invalid before any container started still left a fenced lease. Throws on any failure.
896
+ */
897
+ export function validateLocalExecTarget(execution, env = process.env, read = readFileSync) {
898
+ assertIsolatedLocalExecTarget(execution, read);
899
+ resolveIsolatedStackApiUrl(stackDirFor(execution), env, read);
900
+ }
901
+
902
+ /** LOUD local-exec fallback: bring an ISOLATED stack up in the resolved stack dir. */
903
+ export function localProvision(opts, dockerTarget, execution, spawn = spawnSync, env = process.env) {
904
+ const stackDir = stackDirFor(execution);
905
+ // BOT-1711: never operate the repo's default (shared canonical) project.
906
+ assertIsolatedLocalExecTarget(execution);
907
+ // Pin the edge origin to the ISOLATED stack's OWN [api] port (BOT-903 / BOT-1711 Codex
908
+ // P1): never the repo's prod origin, and never an ambient shared-stack origin — either
909
+ // would make the isolated stack's edge runtime emit another stack's URLs.
910
+ const url = resolveIsolatedStackApiUrl(stackDir, env);
911
+ const spawnEnv = { ...dockerEnvForTarget(dockerTarget, env), VITE_SUPABASE_URL: url };
912
+ process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — no BotBuddy Helper; running ${bold("supabase start")} in ${stackDir} (VITE_SUPABASE_URL=${url}).\n`);
913
+ const start = spawn("supabase", ["start", "--workdir", stackDir], { encoding: "utf8", env: spawnEnv });
674
914
  if (start.status !== 0) {
675
915
  throw new Error(`supabase start failed (${start.status}): ${(start.stderr || start.stdout || "").slice(0, 400)}`);
676
916
  }
677
- const status = spawnSync("supabase", ["status", "-o", "json"], { encoding: "utf8", env: spawnEnv });
678
- return parseSupabaseStatus(status.stdout || "");
917
+ const status = spawn("supabase", ["status", "-o", "json", "--workdir", stackDir], { encoding: "utf8", env: spawnEnv });
918
+ const conn = parseSupabaseStatus(status.stdout || "");
919
+ // BOT-1711 (Codex R5 P2): persist the PROVISIONED project_id on the lease connection so
920
+ // teardown can detect a config that was edited/regenerated between `up` and `done` and
921
+ // refuse rather than `supabase stop` a replacement stack.
922
+ const provisionedProject = projectIdUnder(stackDir);
923
+ if (provisionedProject) conn.project_id = provisionedProject;
924
+ return conn;
679
925
  }
680
926
 
681
- /** LOUD local-exec fallback: tear the stack down in the cwd. Returns true iff it succeeded. */
682
- export function localTeardown(_opts, dockerTarget, spawn = spawnSync, env = process.env) {
683
- process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in this worktree.\n`);
684
- const res = spawn("supabase", ["stop", "--workdir", process.cwd()], {
927
+ /** LOUD local-exec fallback: tear the stack down in `stackDir`. Returns true iff it succeeded. */
928
+ export function localTeardown(stackDir, dockerTarget, spawn = spawnSync, env = process.env) {
929
+ process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in ${stackDir}.\n`);
930
+ const res = spawn("supabase", ["stop", "--workdir", stackDir], {
685
931
  encoding: "utf8",
686
932
  env: dockerEnvForTarget(dockerTarget, env),
687
933
  });
@@ -707,6 +953,7 @@ export async function cmdUp(opts, {
707
953
  waitFn = waitForLease,
708
954
  emitResult = emit,
709
955
  machineUuidFn = machineUuid,
956
+ assertIsolated = validateLocalExecTarget,
710
957
  } = {}) {
711
958
  let slot;
712
959
  try { slot = deriveSlot(opts); } catch (e) {
@@ -719,6 +966,16 @@ export async function cmdUp(opts, {
719
966
  let localPreflight = null;
720
967
  let localDockerTarget = null;
721
968
  if (opts.localExec) {
969
+ // BOT-1711 (Codex R2/R7 P2): prove BOTH config isolation from the resolved --stack-path
970
+ // AND the edge origin (no ambient VITE_SUPABASE_URL pointing at another stack) BEFORE any
971
+ // backend mutation. `resolveStackPath` canonicalizes `..`/symlinks, so a target the parser
972
+ // missed (e.g. `--stack-path ./` → the worktree root) is only caught here. Running this
973
+ // non-mutating validation now means an invalid target is refused before a lease is ever
974
+ // minted or reserved, so it can never leave a fenced lease.
975
+ try { assertIsolated(execution); }
976
+ catch (e) {
977
+ return emitResult(buildReceipt({ command: "up", outcome: "refused", slot, error: e.message }), opts, EXIT.LEASE_FAILED);
978
+ }
722
979
  const checked = await runPreflight(opts);
723
980
  localPreflight = compactPreflight(checked.receipt);
724
981
  localDockerTarget = dockerTargetFromPreflight(checked.receipt);
@@ -768,6 +1025,10 @@ export async function cmdUp(opts, {
768
1025
  }
769
1026
  let leaseId = d.lease_id;
770
1027
  let state = d.state;
1028
+ // BOT-1711 (Codex R13): request_stack_lease returned an EXISTING active lease (reused),
1029
+ // rather than a freshly minted queued/provisioning one. A reused lease was not provisioned
1030
+ // in this invocation, so its recorded metadata cannot be trusted for a local-exec target.
1031
+ const reusedExisting = state === "active";
771
1032
 
772
1033
  if (state === "queued") {
773
1034
  if (opts.noWait) {
@@ -802,18 +1063,38 @@ export async function cmdUp(opts, {
802
1063
  lease_cancellation: leaseCancellation,
803
1064
  }), opts, EXIT.LEASE_FAILED);
804
1065
  }
1066
+ // BOT-1711 (Codex R8 P2): the target config could have been edited while this lease
1067
+ // was QUEUED for capacity. Re-run the non-mutating target validation NOW, before
1068
+ // reserving the provision job; on failure atomically release the minted lease so a
1069
+ // config invalidated during the queue wait never leaves a fenced lease.
1070
+ try { assertIsolated(execution); }
1071
+ catch (e) {
1072
+ const cancelled = await call("cancel_unclaimed_stack_lease", { lease_id: leaseId });
1073
+ const leaseCancellation = cancelled.ok && cancelled.data?.success
1074
+ ? { success: true, state: cancelled.data.state, provision_job_cancelled: cancelled.data.provision_job_cancelled === true }
1075
+ : { success: false, error: cancelled.error || cancelled.data?.code || "atomic cancellation failed" };
1076
+ return emitResult(buildReceipt({
1077
+ command: "up", outcome: "refused", lease_id: leaseId, state, slot, error: e.message,
1078
+ lease_cancellation: leaseCancellation,
1079
+ }), opts, EXIT.LEASE_FAILED);
1080
+ }
805
1081
  // Reserve the queued provision job for THIS agent BEFORE `supabase start`.
806
1082
  // Otherwise a Helper can claim it while the local provisioner runs, winning
807
1083
  // the later activation race and leaving an untracked local stack (two
808
1084
  // provisioners contending for one slot). If the reservation is LOST, nothing
809
1085
  // local has started yet, so it is safe to atomically release the minted lease
810
1086
  // and free the slot (BOT-1421 review).
811
- // Persist the validated Docker target with the reservation so that if the
812
- // provisioner partially starts a stack and then fails, the fenced lease can
813
- // still be torn down by `stack done --local-exec` (BOT-1421 review).
1087
+ // Persist the validated Docker target AND the target stack's project_id with the
1088
+ // reservation (BOT-1711 Codex R14): if the provisioner partially starts a stack and
1089
+ // then exits nonzero, the fenced lease still carries a provisioned identity so
1090
+ // `stack done --local-exec` recognises it as a current-client lease (not an untrusted
1091
+ // pre-1.32 one) and can tear the partial stack down instead of refusing.
1092
+ const reservedProjectId = projectIdUnder(stackDirFor(execution));
1093
+ const reservedConnection = connectionWithDockerTarget(
1094
+ reservedProjectId ? { project_id: reservedProjectId } : {}, localDockerTarget);
814
1095
  const reserved = await call("reserve_stack_lease", {
815
1096
  lease_id: leaseId,
816
- connection: connectionWithDockerTarget({}, localDockerTarget),
1097
+ connection: reservedConnection,
817
1098
  });
818
1099
  if (!reserved.ok || !reserved.data?.success) {
819
1100
  // A Helper can win the reservation race by taking the queued provision
@@ -857,7 +1138,7 @@ export async function cmdUp(opts, {
857
1138
  } else {
858
1139
  let conn;
859
1140
  try {
860
- conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget), localDockerTarget);
1141
+ conn = connectionWithDockerTarget(localProvisionFn(opts, localDockerTarget, execution), localDockerTarget);
861
1142
  } catch (e) {
862
1143
  // Once the local provisioner has run, `supabase start` may have created
863
1144
  // (or fully started) containers even on a nonzero exit or a status-parse
@@ -888,6 +1169,37 @@ export async function cmdUp(opts, {
888
1169
  if (!g || g.state !== "active") {
889
1170
  return emitResult(buildReceipt({ command: "up", outcome: "error", lease_id: leaseId, state: g?.state, error: g ? `lease is ${g.state}, not active` : (got.error || "could not read lease") }), opts, g?.state && isReaped(g.state) ? EXIT.LEASE_FAILED : EXIT.BACKEND);
890
1171
  }
1172
+ // BOT-1711 (Codex R12 P1): `request_stack_lease` REUSES an existing active lease for this
1173
+ // slot (e.g. a pre-1.32 lease recorded with stack_path "."). The isolation validation above
1174
+ // only covers the newly requested --stack-path, so a reused lease could hand back a
1175
+ // different (possibly shared-root) connection as "active". Refuse a lease whose recorded
1176
+ // worktree/stack does not match the validated target instead of returning it.
1177
+ if (opts.localExec) {
1178
+ const leaseStackPath = g.stack_path || ".";
1179
+ const stackMismatch = leaseStackPath !== execution.stackPath;
1180
+ const worktreeMismatch = g.worktree_root != null && g.worktree_root !== execution.worktreeRoot;
1181
+ if (stackMismatch || worktreeMismatch) {
1182
+ return emitResult(buildReceipt({
1183
+ command: "up", outcome: "refused", lease_id: leaseId, state: "active", slot,
1184
+ error: `refusing --local-exec: the active lease is bound to ${g.worktree_root || "<unknown>"} / stack_path "${leaseStackPath}", ` +
1185
+ `not the validated ${execution.worktreeRoot} / "${execution.stackPath}" — an existing lease for this slot was reused and ` +
1186
+ "points at a different (possibly shared) stack. Release that lease with `stack done`, or use a slot dedicated to this stack.",
1187
+ observed_worktree_root: g.worktree_root || null, observed_stack_path: leaseStackPath,
1188
+ }), opts, EXIT.LEASE_FAILED);
1189
+ }
1190
+ // BOT-1711 (Codex R13 P1): a REUSED lease whose metadata matches can still be a pre-1.32
1191
+ // lease that recorded this path but actually provisioned the worktree ROOT — its recorded
1192
+ // stack_path lies. The only trustworthy signal is a persisted provisioned identity, which
1193
+ // only BOT-1711+ local provisioning writes. Refuse a reused lease that lacks it.
1194
+ if (reusedExisting && !g.connection?.project_id) {
1195
+ return emitResult(buildReceipt({
1196
+ command: "up", outcome: "refused", lease_id: leaseId, state: "active", slot,
1197
+ error: "refusing --local-exec: reused an existing active lease with no persisted provisioned identity " +
1198
+ "(project_id) — a pre-1.32 lease recorded this stack_path but may have provisioned the shared worktree root, so its " +
1199
+ "connection cannot be trusted as the isolated stack. Release it with `stack done` and re-provision, or use a dedicated slot.",
1200
+ }), opts, EXIT.LEASE_FAILED);
1201
+ }
1202
+ }
891
1203
  return emitResult(buildReceipt({
892
1204
  command: "up", outcome: "active", lease_id: leaseId, state: "active",
893
1205
  host_key: g.host_key, slot: g.slot, connection: g.connection,
@@ -934,6 +1246,7 @@ export async function cmdDone(leaseId, opts, {
934
1246
  const call = (name, args, callOptions = {}) => callTool(name, args, { ...callOptions, auth });
935
1247
  let dockerTarget = null;
936
1248
  let legacyTargetProof = null;
1249
+ let teardownDir = process.cwd();
937
1250
  if (opts.localExec) {
938
1251
  const current = await call("get_stack_lease", { lease_id: leaseId });
939
1252
  if (!current.ok || !current.data?.success) {
@@ -941,7 +1254,57 @@ export async function cmdDone(leaseId, opts, {
941
1254
  error: current.error || current.data?.code || "could not verify the lease Docker target" }), opts,
942
1255
  current.auth ? EXIT.AUTH : EXIT.BACKEND);
943
1256
  }
1257
+ // BOT-1711: tear down in the directory the stack was PROVISIONED in (the lease's
1258
+ // recorded worktree_root/stack_path), not the invoking cwd — otherwise `supabase
1259
+ // stop` at the worktree root would stop the shared canonical stack.
1260
+ teardownDir = leaseStackDir(current.data);
1261
+ // BOT-1711 (Codex P1, R4): refuse local-exec teardown of ANY lease whose target is the
1262
+ // worktree root / default project — BEFORE branching on the persisted Docker target.
1263
+ // This covers a modern lease minted at the root by a pre-1.32 client AND a pre-1.5
1264
+ // targetless legacy lease: the legacy connection-match proof would otherwise authorize
1265
+ // `supabase stop --workdir <worktreeRoot>`, which stops the shared canonical stack.
1266
+ // Matching endpoints do not prove the shared stack is disposable. Fail closed; the
1267
+ // operator / `botbuddy docker hygiene` reclaims it. (A legacy lease with a genuinely
1268
+ // isolated non-root stack_path still reaches the proof path below.)
1269
+ if (localExecTeardownIsNonIsolated(current.data)) {
1270
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
1271
+ error: "refusing --local-exec teardown: this lease targets the worktree root (default/shared canonical project); " +
1272
+ "`supabase stop` here would stop the shared stack. Reclaim it with `botbuddy docker hygiene` or the operator, not local-exec. " +
1273
+ "Lease and slot remain fenced.",
1274
+ observed_stack_path: current.data.stack_path || ".",
1275
+ }), opts, EXIT.LEASE_FAILED);
1276
+ }
1277
+ // BOT-1711 (Codex R5 P2): if the stack directory's config was edited/regenerated between
1278
+ // `up` and `done`, its project_id no longer matches what was provisioned — `supabase stop`
1279
+ // there would stop a REPLACEMENT stack while the original containers keep running, and the
1280
+ // old lease would still finalize. Refuse on project drift (the provisioned project_id is
1281
+ // persisted on the lease connection at `up`).
1282
+ const provisionedProject = current.data.connection?.project_id;
1283
+ if (provisionedProject) {
1284
+ const currentProject = projectIdUnder(teardownDir);
1285
+ if (currentProject && currentProject !== provisionedProject) {
1286
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
1287
+ error: `refusing --local-exec teardown: ${teardownDir} now declares project_id "${currentProject}" but the lease ` +
1288
+ `provisioned "${provisionedProject}" — its config changed since provisioning, so \`supabase stop\` could stop a ` +
1289
+ "replacement stack and orphan the original. Reclaim with `botbuddy docker hygiene` or the operator.",
1290
+ provisioned_project_id: provisionedProject, observed_project_id: currentProject,
1291
+ }), opts, EXIT.LEASE_FAILED);
1292
+ }
1293
+ }
944
1294
  const expected = current.data.connection?.botbuddy_docker_target;
1295
+ // BOT-1711 (Codex R13 P1): a modern-target lease that lacks a persisted provisioned
1296
+ // project_id was NOT provisioned by BOT-1711+ local-exec (e.g. a pre-1.32 lease that
1297
+ // recorded a non-root stack_path but actually started the worktree ROOT). Its recorded
1298
+ // path cannot be trusted to point `supabase stop` at the right stack, and there is no
1299
+ // identity to compare, so fail closed rather than risk stopping an unrelated stack.
1300
+ if (expected && !current.data.connection?.project_id) {
1301
+ return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
1302
+ error: "refusing --local-exec teardown: this lease has a Docker target but no persisted provisioned identity " +
1303
+ "(project_id) — it predates BOT-1711 isolated provisioning and its recorded stack_path may not match the stack it " +
1304
+ "actually started. Reclaim it with `botbuddy docker hygiene` or the operator, not local-exec.",
1305
+ observed_stack_path: current.data.stack_path || ".",
1306
+ }), opts, EXIT.LEASE_FAILED);
1307
+ }
945
1308
  let checked;
946
1309
  try { checked = await runPreflight(opts); } catch (error) {
947
1310
  return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
@@ -988,7 +1351,7 @@ export async function cmdDone(leaseId, opts, {
988
1351
  // the next queued lease, which would collide with containers still running on this
989
1352
  // slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
990
1353
  // the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
991
- if (!localTeardownFn(opts, dockerTarget)) {
1354
+ if (!localTeardownFn(teardownDir, dockerTarget)) {
992
1355
  return emitResult(buildReceipt({
993
1356
  command: "done", outcome: "error", lease_id: leaseId, state,
994
1357
  error: "local `supabase stop` failed — NOT finalizing; the slot stays fenced. Tear the stack down and re-run `stack done --local-exec`, or let the reaper reconcile.",
package/src/test-lane.mjs CHANGED
@@ -21,7 +21,7 @@ import { execFileSync } from "node:child_process";
21
21
 
22
22
  import { EXIT, launchRun, receiptPath, DEFAULT_TIMEOUT_SECONDS } from "./run.mjs";
23
23
  import { callToolJson } from "./api.mjs";
24
- import { readAgentSessionTokenEnv } from "./agent-key.mjs";
24
+ import { readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
25
25
  import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
26
26
 
27
27
  // EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
@@ -338,7 +338,8 @@ function laneList(cwd) {
338
338
  }
339
339
 
340
340
  function testHelp() {
341
- console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--lane-kind <${LANE_KINDS.join("|")}>] [--wait] [--json] [-- <extra args>]
341
+ console.log(`botbuddy test <lane> [--session-id <uuid>] [--agent-session-token <token>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--lane-kind <${LANE_KINDS.join("|")}>] [--wait] [--json] [-- <extra args>]
342
+ --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases
342
343
  botbuddy test list List the lanes configured in .botbuddy/lanes.json
343
344
  botbuddy test help Show this help
344
345
 
@@ -351,6 +352,9 @@ export async function cmdTest(args, { cwd = process.cwd() } = {}) {
351
352
  const { subcommand } = parseTestArgs(args);
352
353
  if (subcommand === "help" || args[0] === "--help" || args[0] === "-h") { testHelp(); return; }
353
354
  if (subcommand === "list") { process.exitCode = laneList(cwd); return; }
355
+ // BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy flag/env alias.
356
+ const aliasHint = legacyAgentAliasHint({ argv: args, env: process.env });
357
+ if (aliasHint) process.stderr.write(`botbuddy test: ${aliasHint}\n`);
354
358
  const result = await launchTestLane(args, { cwd });
355
359
  process.stdout.write(result.line + "\n");
356
360
  // --wait (AC-9): after launch, block until the run completes and exit with the
package/src/wait.mjs CHANGED
@@ -18,7 +18,7 @@ import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
21
- import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
21
+ import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
22
22
  import { clearAgentState, hasBootstrapAgentSession, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
23
23
  import { touchAgentStateExpiry } from "./agent-state.mjs";
24
24
  import { SETUP_BLOCK } from "./setup-block.mjs";
@@ -1344,6 +1344,16 @@ async function acknowledgeSavedWait(id) {
1344
1344
 
1345
1345
  export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1346
1346
  if (argv.length === 0 || argv[0] === "--offset") return inspectSavedWaits(argv);
1347
+ // BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy alias (flag, env, or a
1348
+ // bb_agent_ token value), emitted BEFORE any subcommand dispatch so the
1349
+ // credentialed recovery actions (resume/cancel/acknowledge) warn too (Codex R7).
1350
+ // STDERR only, so a machine-parseable stdout receipt is byte-identical. Skipped
1351
+ // on the internal recovery re-entry (resumeSavedWait recurses into runWait with
1352
+ // recoveryLocalWaitId set) so a resume never nudges twice (Codex R8).
1353
+ if (recoveryLocalWaitId == null) {
1354
+ const aliasHint = legacyAgentAliasHint({ argv, env: process.env });
1355
+ if (aliasHint) process.stderr.write(`botbuddy wait: ${aliasHint}\n`);
1356
+ }
1347
1357
  if (argv[0] === "resume") {
1348
1358
  const id = argv[1];
1349
1359
  if (!id || argv.length > 2) return emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "select exactly one saved wait: bb wait resume <local_wait_id>"));