@botbuddy/cli 1.31.2 → 1.32.1

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.2",
3
+ "version": "1.32.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/pw/args.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { TARGET_VERBS, parseTargetFlags, hasTargetFlags, buildLocatorTarget, classifyTarget, isSnapshotRef } from "./targets.mjs";
2
+ import { TRANSLATE_VERBS, planTranslateVerb, MAX_EXEC_ARG } from "./translate.mjs";
2
3
  export const GLOBAL_VERBS = new Set(["list", "close-all", "kill-all", "reap"]);
3
4
  const secret = /^@ENV:(.+)$/;
4
5
  export function resolveRef(value, env = process.env) {
@@ -16,6 +17,20 @@ export function planInvocation(argv, env = process.env) {
16
17
  if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be a positive integer and include a verb`);
17
18
  if (verb === "status") return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "status" };
18
19
  const rest = argv.slice(2); let forwarded = rest, target = null, fresh = false;
20
+ // BOT-1702: bb-pw OWNS upload/route/unroute and translates them to upstream
21
+ // `run-code` (see translate.mjs). A resolved `@ENV:` secret in any value routes
22
+ // the generated code through the socket path so it never lands in argv, and is
23
+ // tracked for redaction — mirroring the sibling verbs' guarantee.
24
+ if (TRANSLATE_VERBS.has(verb)) {
25
+ const { code, secretValues } = planTranslateVerb(verb, rest, { env, resolveRef });
26
+ // Use the daemon socket (JSON, no argv limit) for a resolved secret OR a
27
+ // payload too large for a single child-process argument (Codex R2 P2: a big
28
+ // --inline/body would otherwise fail with E2BIG). Measure UTF-8 BYTES, not
29
+ // UTF-16 code units — the kernel's per-arg limit is on encoded bytes, so a
30
+ // multibyte body (e.g. emoji/CJK) could otherwise slip past (Codex R3 P2).
31
+ const sensitive = secretValues.length > 0 || Buffer.byteLength(code, "utf8") > MAX_EXEC_ARG;
32
+ return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: sensitive ? "socket" : "exec", execArgv: sensitive ? null : [`-s=lane-${lane}`, "run-code", code], socketArgs: sensitive ? ["run-code", code] : null, telemetryUrl: null, secretValues, rollup: false, target: null, targetKind: null, fresh: false, translate: true };
33
+ }
19
34
  if (TARGET_VERBS.has(verb)) {
20
35
  const parsed = parseTargetFlags(rest); fresh = parsed.fresh;
21
36
  forwarded = hasTargetFlags(parsed.flags) ? [buildLocatorTarget(parsed.flags), ...parsed.rest] : parsed.rest;
@@ -1,4 +1,7 @@
1
1
  export const NAV = "navigate";
2
- const interaction = new Set(["click", "fill", "type", "select", "press", "drag", "hover", "dblclick", "check", "uncheck"]);
2
+ // `upload` mutates the page (it sets the input's files), so it counts as an
3
+ // interaction — otherwise session telemetry would omit every upload (BOT-1702
4
+ // Codex R27 P2).
5
+ const interaction = new Set(["click", "fill", "type", "select", "press", "drag", "hover", "dblclick", "check", "uncheck", "upload"]);
3
6
  export function actionTypeFromMethod(method) { const value = String(method); return ["goto", "open", "go-back", "go-forward", "reload"].includes(value) ? NAV : value === "screenshot" ? "screenshot" : value === "snapshot" ? "snapshot" : interaction.has(value) ? "interaction" : "other"; }
4
7
  export function deriveSession(events, { meta = {} } = {}) { const ordered = [...events].sort((a,b) => a.ts-b.ts), type = (name) => ordered.filter((event) => actionTypeFromMethod(event.method) === name); const nav = type(NAV); return { ...meta, started_at: ordered[0]?.ts ?? null, ended_at: ordered.at(-1)?.ts ?? null, duration_ms: ordered.length ? ordered.at(-1).ts - ordered[0].ts : 0, active_ms: 0, idle_threshold_ms: 60000, navigations: nav.length, distinct_routes: [...new Set(nav.map((event) => { try { return new URL(event.url).pathname; } catch { return String(event.url).split("?")[0]; } }))], screenshots: type("screenshot").length, snapshots: type("snapshot").length, interactions: type("interaction").length, actions_total: ordered.length }; }
package/src/pw/run.mjs CHANGED
@@ -13,8 +13,19 @@ import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredent
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>] [--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
- function redact(value, secretValues = []) { return secretValues.reduce((text, secret) => secret ? text.split(secret).join("[redacted]") : text, String(value ?? "")); }
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\nFile upload (BOT-1702) — works on a hidden <input type=file>:\n bb-pw <lane> upload <selector> <path...>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,size=<bytes>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,bytes=@base64:<b64>\n bb-pw <lane> upload <selector> --inline name=<n>,mime=<m>,text=<str>\n --inline repeats; builds synthetic files in memory (no workstation FS).\n <selector> also accepts --testid/--role/--label/--text like click/fill.\n\nNetwork mocking (BOT-1702) — routes persist on the lane across invocations:\n bb-pw <lane> route <url-glob> stall hold the request pending\n (screenshot the loading state)\n bb-pw <lane> route <url-glob> abort [--error <code>] fail the request (default: failed)\n bb-pw <lane> route <url-glob> fulfill --status <n> [--content-type <ct>]\n [--body <str|@base64:..|@file:PATH>]\n bb-pw <lane> unroute [<url-glob>] release one route, or all\n bb-pw <lane> route-list list the routes owned on this lane\n (offline mode already exists upstream: bb-pw <lane> network-state-set offline)\n"); }
17
+ // Redact LONGEST secrets first: replacing a short value that is a substring of a
18
+ // longer secret (e.g. a filename that also appears inside the payload's base64)
19
+ // would otherwise break the longer match and leave the remainder recoverable
20
+ // (Codex R11 P1).
21
+ export function redact(value, secretValues = []) { return [...new Set(secretValues.filter(Boolean))].sort((a, b) => b.length - a.length).reduce((text, secret) => text.split(secret).join("[redacted]"), String(value ?? "")); }
22
+ // bb-pw OWNS the translate verbs, so their generated run-code is an internal
23
+ // implementation detail. When a secret is embedded in it, @playwright/cli echoes
24
+ // the source back ("### Ran Playwright code") in forms our value-based redaction
25
+ // cannot always match (JSON- vs single-quote-escaped, normalized, base64…). When
26
+ // any secret is in play we therefore DROP the echoed code block entirely — the
27
+ // robust fix for the whole class — while still redacting the rest (Codex R11).
28
+ export const stripCodeEcho = (text) => String(text ?? "").replace(/### Ran Playwright code\n```[\s\S]*?\n```\n?/g, "");
18
29
  // BOT-1488: the register_agent identity for this machine, persisted by
19
30
  // `botbuddy register` into ~/.botbuddy/config.json. This is the SESSION agent
20
31
  // that acquire_resources binds a lane to — distinct from the tenant-bound
@@ -161,7 +172,17 @@ async function runPwInner(argv, deps = {}) {
161
172
  const effHost = auth.canonicalHost ?? host;
162
173
  if (plan.mode === "status") { const lock = auth.coordinator?.status ? await auth.coordinator.status({ host: effHost, slot: plan.lane }) : null; stdout.write(JSON.stringify({ lane: plan.lane, session: plan.session, host: effHost, lock, spooled_events: telemetry.count(plan.lane) }, null, 2) + "\n"); return 0; }
163
174
  if (actionTypeFromMethod(plan.verb) !== "other") telemetry.append(plan.lane, { ts: Date.now(), method: plan.verb, url: actionTypeFromMethod(plan.verb) === NAV ? plan.telemetryUrl : null });
164
- const inspect = plan.mode === "socket" || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh)); let code;
165
- if (inspect) { if (plan.fresh) await socketRun({ ...plan, socketArgs: ["snapshot"] }, env).catch(() => {}); const result = await socketRun({ ...plan, socketArgs: plan.socketArgs ?? plan.execArgv.slice(1) }, env); if (result.text) stdout.write(`${redact(result.text, plan.secretValues)}\n`); if (!result.ok) stderr.write(`${redact(plan.targetKind === "ref" && isStaleRefError(result.error) ? staleRefRemediation(plan.target) : result.error, plan.secretValues)}\n`); code = result.ok ? 0 : 1; } else code = await spawnExec(plan, env);
175
+ // Translate verbs ALWAYS run via the socket (even a small, non-secret one), so
176
+ // run.mjs not an inherited-stdio spawnExec owns their output and can strip
177
+ // the internal code echo; socketRun falls back to execArgv.slice(1) as its args
178
+ // (Codex R26 P2).
179
+ const inspect = plan.mode === "socket" || plan.translate || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh)); let code;
180
+ // bb-pw's translate verbs (upload/route/...) generate internal run-code; that
181
+ // echoed "### Ran Playwright code" block is never useful to the caller and can
182
+ // be large (a 100KB+ base64 payload) or carry a secret, so strip it for every
183
+ // translated command — not only secret ones (Codex R25 P2) — while still
184
+ // redacting any tracked secret from the rest.
185
+ const suppressEcho = !!plan.translate || !!plan.secretValues?.length;
186
+ if (inspect) { if (plan.fresh) await socketRun({ ...plan, socketArgs: ["snapshot"] }, env).catch(() => {}); const result = await socketRun({ ...plan, socketArgs: plan.socketArgs ?? plan.execArgv.slice(1) }, env); if (result.text) { const text = suppressEcho ? stripCodeEcho(result.text) : result.text; stdout.write(`${redact(text, plan.secretValues)}\n`); } if (!result.ok) { let errText = plan.targetKind === "ref" && isStaleRefError(result.error) ? staleRefRemediation(plan.target) : result.error; if (suppressEcho) errText = stripCodeEcho(errText); stderr.write(`${redact(errText, plan.secretValues)}\n`); } code = result.ok ? 0 : 1; } else code = await spawnExec(plan, env);
166
187
  if (plan.rollup && code === 0) await telemetry.rollup({ lane: plan.lane, coordinator: auth.coordinator, host: effHost }); return code;
167
188
  }
@@ -3,7 +3,12 @@ export const TARGET_VERBS = new Set(["click", "dblclick", "fill", "hover", "chec
3
3
  export const isSnapshotRef = (value) => REF.test(String(value ?? ""));
4
4
  export const classifyTarget = (value) => /^getBy[A-Z]/.test(String(value ?? "")) ? "locator" : isSnapshotRef(value) ? "ref" : "selector";
5
5
  const BASE = ["role", "placeholder", "text", "testid", "label", "title", "alt"];
6
- const quote = (value) => `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
6
+ // Produce a VALID single-quoted JS string literal. Besides backslash and the
7
+ // quote, escape newline/CR — an unescaped newline in a single-quoted literal is a
8
+ // syntax error, which would break the run-code that embeds a locator built from a
9
+ // multiline flag value (BOT-1702 Codex R24 P2). (U+2028/U+2029 are legal in
10
+ // string literals since ES2019, so they need no escaping.)
11
+ const quote = (value) => "'" + String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r") + "'";
7
12
  export function parseTargetFlags(args) {
8
13
  const flags = {}, rest = []; let fresh = false;
9
14
  for (let index = 0; index < args.length; index += 1) {
@@ -15,6 +20,10 @@ export function parseTargetFlags(args) {
15
20
  if (![...BASE, "name"].includes(name)) { rest.push(args[index]); continue; }
16
21
  const value = inline ?? args[++index];
17
22
  if (value === undefined) throw new Error(`bb-pw: --${name} needs a value`);
23
+ // Reject a repeated locator flag rather than silently keeping the last value,
24
+ // so a concatenated command can't target a different element while reporting
25
+ // success (BOT-1702 Codex R22 P2).
26
+ if (name in flags) throw new Error(`bb-pw: duplicate flag --${name}`);
18
27
  flags[name] = value;
19
28
  }
20
29
  return { flags, rest, fresh };
@@ -0,0 +1,411 @@
1
+ import { resolve } from "node:path";
2
+ import { readFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { parseTargetFlags, hasTargetFlags, buildLocatorTarget } from "./targets.mjs";
5
+
6
+ // BOT-1702: bb-pw OWNS the `upload`/`route`/`unroute` verbs and translates them
7
+ // to upstream `@playwright/cli run-code`, whose function body runs against the
8
+ // lane daemon's persistent page. This keeps the surface in our control (no MS
9
+ // fork) and lets route handlers persist across invocations, so
10
+ // `route <glob> stall` → `goto` → `screenshot` → `unroute` works. Driver:
11
+ // a Supply Guard acceptance flow (its policy-document upload UI).
12
+ export const TRANSLATE_VERBS = new Set(["upload", "route", "unroute", "route-list"]);
13
+ const ROUTE_ACTIONS = new Set(["stall", "abort", "fulfill"]);
14
+ // Force the socket transport (JSON over the daemon socket, no argv limit) once
15
+ // the generated run-code exceeds this, instead of passing it as one child-process
16
+ // argument. Linux caps a single argv entry at ~128 KiB (MAX_ARG_STRLEN), but
17
+ // Windows caps the WHOLE command line at 32,767 chars — so pick the limit by
18
+ // platform, well under each cap, or a large `--inline`/fulfill body would fail in
19
+ // spawn() (Codex R2/R23 P2).
20
+ export const MAX_EXEC_ARG = process.platform === "win32" ? 8_000 : 100_000;
21
+ // A JS string/number literal safe to embed in generated code. JSON.stringify
22
+ // emits a valid double-quoted JS string (or a bare number), so it doubles as our
23
+ // escaper for selectors, globs, bodies and base64 blobs.
24
+ const j = (value) => JSON.stringify(value);
25
+
26
+ // Node's base64 decoder is permissive: it silently drops invalid chars AND
27
+ // normalizes non-canonical padding bits (e.g. "AB==" → "AA=="), so a malformed
28
+ // payload would decode to different bytes than the caller supplied (Codex R4/R6
29
+ // P2). Accept only input that round-trips to itself, i.e. canonical standard
30
+ // base64.
31
+ function decodeBase64(text, ctx) {
32
+ const s = String(text);
33
+ const buf = Buffer.from(s, "base64");
34
+ if (buf.toString("base64") !== s)
35
+ throw new Error(`bb-pw: ${ctx}: invalid base64 (expected canonical standard padded base64)`);
36
+ return buf;
37
+ }
38
+
39
+ // A fulfill body is embedded as a JS string literal, so it must be exact UTF-8.
40
+ // Reject bytes that don't round-trip (e.g. a base64/file payload with byte 0xff),
41
+ // rather than silently corrupting them via `toString("utf8")` → U+FFFD (Codex R19
42
+ // P2). Binary response bodies are out of scope.
43
+ function utf8Body(buf, ctx) {
44
+ const str = buf.toString("utf8");
45
+ if (!Buffer.from(str, "utf8").equals(buf))
46
+ throw new Error(`bb-pw: ${ctx}: body is not valid UTF-8 (binary fulfill bodies are not supported — provide text/JSON)`);
47
+ return str;
48
+ }
49
+
50
+ // The finite set Playwright's Route.abort accepts; anything else asserts in
51
+ // Chromium at request time, so validate at plan time (Codex R6 P2).
52
+ const ABORT_ERRORS = new Set(["aborted", "accessdenied", "addressunreachable", "blockedbyclient", "blockedbyresponse", "connectionaborted", "connectionclosed", "connectionfailed", "connectionrefused", "connectionreset", "internetdisconnected", "namenotresolved", "timedout", "failed"]);
53
+
54
+ // Parse one `--inline name=<n>,mime=<m>,(size=<bytes>|bytes=@base64:<b64>|text=<str>)`
55
+ // spec into an in-memory file descriptor. Never touches the workstation FS, so an
56
+ // agent can craft a 0-byte or spoofed-bytes file without staging one on disk.
57
+ export function parseInline(spec) {
58
+ const s = String(spec);
59
+ // The content source (size=/bytes=/text=) is the LAST field and its value runs
60
+ // to end-of-string, so a text/CSV/query-string payload may contain commas —
61
+ // even a literal `,name=`/`,size=` — without being mis-split (Codex R8 P2).
62
+ const src = /(?:^|,)(size|bytes|text)=/.exec(s);
63
+ if (!src) throw new Error("bb-pw: --inline needs one of size=<bytes>, bytes=@base64:<b64>, or text=<str> as its last field");
64
+ const srcKey = src[1];
65
+ const srcStart = src.index + (src[0].length - (srcKey.length + 1));
66
+ const srcValue = s.slice(srcStart + srcKey.length + 1);
67
+ // The head (everything before the source) holds name=/mime=; those values never
68
+ // contain commas, so a plain comma split is safe here.
69
+ const kv = {};
70
+ const head = s.slice(0, srcStart).replace(/,$/, "");
71
+ if (head) for (const part of head.split(",")) {
72
+ const eq = part.indexOf("=");
73
+ if (eq < 0) throw new Error(`bb-pw: --inline expects key=value pairs, got "${part}"`);
74
+ const key = part.slice(0, eq).trim();
75
+ // Only name/mime are valid before the content source; reject an unknown or
76
+ // misspelled field (e.g. `filename=`) or a duplicate rather than ignoring it
77
+ // and uploading with different metadata than the caller intended (Codex R20).
78
+ if (key !== "name" && key !== "mime") throw new Error(`bb-pw: --inline: unknown field "${key}" (allowed: name, mime, and one of size/bytes/text)`);
79
+ if (key in kv) throw new Error(`bb-pw: --inline: duplicate field "${key}"`);
80
+ kv[key] = part.slice(eq + 1);
81
+ }
82
+ if (!kv.name) throw new Error("bb-pw: --inline needs name=<filename> before the content source");
83
+ if (!kv.mime) throw new Error("bb-pw: --inline needs mime=<type> before the content source");
84
+ let buffer;
85
+ if (srcKey === "size") {
86
+ const n = Number(srcValue);
87
+ if (!/^\d+$/.test(srcValue) || !Number.isInteger(n) || n < 0) throw new Error(`bb-pw: --inline size must be a non-negative integer, got "${srcValue}"`);
88
+ buffer = Buffer.alloc(n);
89
+ } else if (srcKey === "bytes") {
90
+ const match = /^@base64:(.*)$/s.exec(srcValue);
91
+ if (!match) throw new Error("bb-pw: --inline bytes= must be @base64:<data>");
92
+ buffer = decodeBase64(match[1], "--inline bytes");
93
+ } else {
94
+ buffer = Buffer.from(srcValue, "utf8");
95
+ }
96
+ // Bytes are carried as base64 and decoded in the BROWSER via `atob` (see
97
+ // buildUploadCode): base64 is ~1.33× vs ~3-4× for a JS number array, keeping the
98
+ // generated arg small (Codex R2 P2), and the browser has `atob` where the
99
+ // run-code VM lacks Node `Buffer` and Playwright's Buffer-only FilePayload.
100
+ return { name: kv.name, mimeType: kv.mime, base64: buffer.toString("base64") };
101
+ }
102
+
103
+ // `selectorExpr` is a full page accessor expression, e.g. `page.locator("#x")` or
104
+ // `page.getByTestId('x')`. `files` is either [{path}] (real disk paths) or
105
+ // [{name,mimeType,bytes}] (synthetic in-memory). Both paths work on a hidden
106
+ // <input type=file>:
107
+ // - real paths → Playwright `setInputFiles([...paths])` (native chooser path).
108
+ // - synthetic → built as File objects in the BROWSER via DataTransfer and
109
+ // assigned to `input.files`, dispatching input+change. This is fully hermetic
110
+ // (no workstation FS) and avoids Playwright's Buffer-only FilePayload, which
111
+ // is unreachable from the sandboxed run-code context (no Node Buffer there).
112
+ export function buildUploadCode(selectorExpr, files) {
113
+ const synthetic = files.length > 0 && files[0].path === undefined;
114
+ if (!synthetic) {
115
+ const items = files.map((file) => j(file.path));
116
+ return `async page => { await ${selectorExpr}.setInputFiles([${items.join(", ")}]); }`;
117
+ }
118
+ const payload = files.map((file) => ({ name: file.name, mime: file.mimeType, b64: file.base64 }));
119
+ // Enforce the same constraints Playwright's setInputFiles applies, which this
120
+ // synthetic path would otherwise bypass — allowing browser states a real user
121
+ // cannot reach (Codex R13 P2): the target must be an <input type=file>, and
122
+ // multiple files require a `multiple` input.
123
+ return `async page => { await ${selectorExpr}.evaluate((el, files) => { if (el instanceof HTMLLabelElement && el.control) el = el.control; if (!(el instanceof HTMLInputElement) || el.type !== "file") throw new Error("bb-pw upload: target is not an <input type=file>"); if (el.webkitdirectory) throw new Error("bb-pw upload: cannot assign synthetic files to a directory (webkitdirectory) input"); if (files.length > 1 && !el.multiple) throw new Error("bb-pw upload: multiple files require an <input multiple>"); const dt = new DataTransfer(); for (const f of files) { const bin = atob(f.b64); const u8 = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); dt.items.add(new File([u8], f.name, { type: f.mime })); } el.files = dt.files; el.dispatchEvent(new Event("input", { bubbles: true, composed: true })); el.dispatchEvent(new Event("change", { bubbles: true, composed: true })); }, ${JSON.stringify(payload)}); }`;
124
+ }
125
+
126
+ // bb-pw's routes are registered via `page.route` inside the run-code VM, which
127
+ // bypasses upstream's route registry — so upstream `route-list` would report "No
128
+ // active routes" and a leftover stall/fulfill mock would be invisible (Codex R2
129
+ // P2). We therefore track owned routes on `page.__bbPwRoutes` (the daemon reuses
130
+ // one page object across invocations) and OWN `route-list`/`unroute` so they read
131
+ // and prune that registry.
132
+ // Bookkeeping key = a non-reversible digest of the RESOLVED glob. It mirrors
133
+ // Playwright's page.unroute (which matches on the resolved pattern), so two routes
134
+ // installed under the same `@ENV:` ref but DIFFERENT resolved values get distinct
135
+ // keys and are pruned independently (Codex R4 P2). It is a hash, so it never
136
+ // leaks a secret glob into the registry that route-list prints (Codex R3 P1).
137
+ const matchKey = (resolvedGlob) => createHash("sha256").update(String(resolvedGlob)).digest("hex").slice(0, 16);
138
+ // `displayGlob` is what route-list shows — the raw arg (e.g. `@ENV:SIGNED_URL`),
139
+ // never the resolved value.
140
+ const trackRoute = (displayGlob, action, key) => `(page.__bbPwRoutes = page.__bbPwRoutes || []).push({ glob: ${j(displayGlob)}, action: ${j(action)}, key: ${j(key)} });`;
141
+
142
+ export function buildRouteCode(glob, action, opts = {}, displayGlob = glob) {
143
+ const g = j(glob), key = matchKey(glob);
144
+ const track = trackRoute(displayGlob, action, key);
145
+ if (action === "stall")
146
+ // Capture each intercepted route (never resolving it) so the matched request
147
+ // hangs pending — a screenshot captures the loading state — and unroute can
148
+ // later release it (Codex R3 P2), instead of leaving it hung forever.
149
+ return `async page => { await page.route(${g}, route => { (page.__bbPwPending = page.__bbPwPending || []).push({ key: ${j(key)}, route }); }); ${track} }`;
150
+ if (action === "abort")
151
+ return `async page => { await page.route(${g}, route => route.abort(${j(opts.error || "failed")})); ${track} }`;
152
+ if (action === "fulfill") {
153
+ const fields = [];
154
+ if (opts.status !== undefined) fields.push(`status: ${Number(opts.status)}`);
155
+ if (opts.contentType !== undefined) fields.push(`contentType: ${j(opts.contentType)}`);
156
+ // Body is ALWAYS a plain string literal. @base64:/@file: are decoded at plan
157
+ // time (Node, where Buffer exists) — the generated handler runs in the
158
+ // @playwright/cli run-code VM, which exposes no Node Buffer, so a
159
+ // `Buffer.from(...)` here would throw ReferenceError on the first request.
160
+ if (opts.body !== undefined) fields.push(`body: ${j(opts.body)}`);
161
+ return `async page => { await page.route(${g}, route => route.fulfill({ ${fields.join(", ")} })); ${track} }`;
162
+ }
163
+ throw new Error(`bb-pw: route action must be stall|abort|fulfill, got "${action}"`);
164
+ }
165
+
166
+ // On unroute, also RELEASE any requests already caught by a stall handler —
167
+ // page.unroute only stops future interceptions and would leave the pending
168
+ // request hanging (Codex R3 P2) — and prune only the records whose resolved key
169
+ // matches, so a colliding display ref keeps its other route (R4 P2). For a
170
+ // SPECIFIC unroute, release via route.fallback() so any broader still-registered
171
+ // mock in the chain still runs (route.continue() would go straight to the network
172
+ // and bypass it); for clear-all there is no remaining chain, so continue()
173
+ // directly (Codex R23 P2).
174
+ export function buildUnrouteCode(glob, displayGlob = glob) {
175
+ // Only null/undefined means "clear all"; an (already-rejected) empty string
176
+ // must never reach the unrouteAll branch (Codex R5 P2).
177
+ if (glob != null) {
178
+ const g = j(glob), key = j(matchKey(glob));
179
+ return `async page => { await page.unroute(${g}); for (const p of (page.__bbPwPending || []).filter(p => p.key === ${key})) { try { await p.route.fallback(); } catch {} } if (page.__bbPwPending) page.__bbPwPending = page.__bbPwPending.filter(p => p.key !== ${key}); if (page.__bbPwRoutes) page.__bbPwRoutes = page.__bbPwRoutes.filter(r => r.key !== ${key}); }`;
180
+ }
181
+ return `async page => { await page.unrouteAll(); for (const p of (page.__bbPwPending || [])) { try { await p.route.continue(); } catch {} } page.__bbPwPending = []; page.__bbPwRoutes = []; }`;
182
+ }
183
+
184
+ export function buildRouteListCode() {
185
+ // Return the ARRAY directly (glob + action only; the internal match key stays
186
+ // out of the output). @playwright/cli's run-code wrapper JSON.stringifies the
187
+ // result once — stringifying here too would emit a JSON string, leaving
188
+ // automation a string after one parse (Codex R25 P2).
189
+ return `async page => { return (page.__bbPwRoutes || []).map(r => ({ glob: r.glob, action: r.action })); }`;
190
+ }
191
+
192
+ // Allowed flags per route action; anything else is a typo or an incompatible
193
+ // option and must be rejected rather than silently ignored (Codex R3 P2).
194
+ const ROUTE_FLAGS = { stall: [], abort: ["error"], fulfill: ["status", "content-type", "body"] };
195
+
196
+ function parseSimpleFlags(args) {
197
+ const flags = {};
198
+ for (let index = 0; index < args.length; index += 1) {
199
+ const match = /^--([a-z][a-z-]*)(?:=(.*))?$/s.exec(String(args[index]));
200
+ if (!match) throw new Error(`bb-pw: unexpected argument "${args[index]}"`);
201
+ const value = match[2] !== undefined ? match[2] : args[index += 1];
202
+ if (value === undefined) throw new Error(`bb-pw: --${match[1]} needs a value`);
203
+ // Reject a repeated flag rather than silently keeping the last value, so a
204
+ // concatenated command can't run against a different mock than intended
205
+ // (Codex R21 P2).
206
+ if (match[1] in flags) throw new Error(`bb-pw: duplicate flag --${match[1]}`);
207
+ flags[match[1]] = value;
208
+ }
209
+ return flags;
210
+ }
211
+
212
+ function planUpload(rest, rr, markSecret, resolveMeta) {
213
+ const { flags, rest: remainder } = parseTargetFlags(rest);
214
+ let selectorExpr, fileArgs;
215
+ if (hasTargetFlags(flags)) {
216
+ // Resolve `@ENV:` in each locator flag value (not the boolean `exact`), so a
217
+ // secret/ref in a target flag behaves like any other value (Codex R1 P2).
218
+ const resolved = Object.fromEntries(Object.entries(flags).map(([key, value]) => [key, key === "exact" ? value : rr(value)]));
219
+ selectorExpr = `page.${buildLocatorTarget(resolved)}`;
220
+ fileArgs = remainder;
221
+ } else {
222
+ const selector = remainder[0];
223
+ if (selector === undefined || String(selector).startsWith("--"))
224
+ throw new Error("bb-pw: upload needs a <selector> (or --testid/--role/--label/--text)");
225
+ selectorExpr = `page.locator(${j(rr(selector))})`;
226
+ fileArgs = remainder.slice(1);
227
+ }
228
+ // Parse one --inline spec. If the spec itself came from a secret ref
229
+ // (`--inline @ENV:SPEC`), parseInline SPLITS and TRANSFORMS it, so the whole
230
+ // recorded secret no longer matches the substrings that reach errors, nor the
231
+ // base64 payload embedded in the echoed code. Mark the derived name/mime/base64
232
+ // sensitive, and on an invalid secret spec throw a value-free error so no
233
+ // fragment leaks (Codex R10 P1).
234
+ const parseInlineArg = (spec) => {
235
+ const { value: resolved, secret: specWasSecret } = resolveMeta(spec);
236
+ let file;
237
+ try { file = parseInline(resolved); }
238
+ catch (error) { throw specWasSecret ? new Error("bb-pw: --inline spec from a secret ref is invalid (value redacted)") : error; }
239
+ if (specWasSecret) { markSecret(file.name); markSecret(file.mimeType); markSecret(file.base64); }
240
+ return file;
241
+ };
242
+ const paths = [], inlines = [];
243
+ for (let index = 0; index < fileArgs.length; index += 1) {
244
+ const token = String(fileArgs[index]);
245
+ if (token === "--inline") {
246
+ const spec = fileArgs[index += 1];
247
+ if (spec === undefined) throw new Error("bb-pw: --inline needs a value");
248
+ inlines.push(parseInlineArg(spec));
249
+ } else if (token.startsWith("--inline=")) {
250
+ inlines.push(parseInlineArg(token.slice("--inline=".length)));
251
+ } else if (token.startsWith("--")) {
252
+ throw new Error(`bb-pw: upload: unexpected flag "${token}"`);
253
+ } else {
254
+ paths.push(fileArgs[index]);
255
+ }
256
+ }
257
+ if (inlines.length && paths.length)
258
+ throw new Error("bb-pw: upload takes either real <path...> OR --inline synthetic files, not both");
259
+ if (!inlines.length && !paths.length)
260
+ throw new Error("bb-pw: upload needs at least one <path> or --inline file");
261
+ const files = inlines.length ? inlines : paths.map((path) => {
262
+ const { value, secret } = resolveMeta(path);
263
+ const abs = resolve(value);
264
+ // If the path came from a secret ref, normalization may change its spelling;
265
+ // mark the resolved absolute form sensitive too (Codex R11/R14 P2).
266
+ if (secret) markSecret(abs);
267
+ return { path: abs };
268
+ });
269
+ return buildUploadCode(selectorExpr, files);
270
+ }
271
+
272
+ function planRoute(rest, rr, markSecret, resolveMeta) {
273
+ const rawGlob = rest[0];
274
+ if (rawGlob === undefined || rawGlob === "" || String(rawGlob).startsWith("--"))
275
+ throw new Error("bb-pw: route needs a <url-glob> pattern");
276
+ const action = rest[1];
277
+ if (action === undefined || String(action).startsWith("--"))
278
+ throw new Error("bb-pw: route needs an action: stall|abort|fulfill");
279
+ if (!ROUTE_ACTIONS.has(action))
280
+ throw new Error(`bb-pw: route action must be stall|abort|fulfill, got "${action}"`);
281
+ const flags = parseSimpleFlags(rest.slice(2));
282
+ const allowed = ROUTE_FLAGS[action];
283
+ const unknown = Object.keys(flags).filter((flag) => !allowed.includes(flag));
284
+ if (unknown.length)
285
+ throw new Error(`bb-pw: route ${action}: unsupported flag(s) ${unknown.map((f) => `--${f}`).join(", ")}. Allowed: ${allowed.length ? allowed.map((f) => `--${f}`).join(", ") : "none"}`);
286
+ // displayGlob is the raw arg (safe: a secret ref stays "@ENV:X"); g is resolved.
287
+ const g = rr(rawGlob), displayGlob = rawGlob;
288
+ if (action === "stall") return buildRouteCode(g, "stall", {}, displayGlob);
289
+ if (action === "abort") {
290
+ let error;
291
+ if (flags.error !== undefined) {
292
+ error = rr(flags.error);
293
+ if (!ABORT_ERRORS.has(error)) throw new Error(`bb-pw: route abort --error "${error}" is not a valid code. One of: ${[...ABORT_ERRORS].join(", ")}`);
294
+ }
295
+ return buildRouteCode(g, "abort", { error }, displayGlob);
296
+ }
297
+ const opts = {};
298
+ // --status is required for fulfill (contract: `fulfill --status <n>`); omitting
299
+ // it would silently default to 200 and turn a negative-path mock into a success
300
+ // (Codex R6 P2).
301
+ if (flags.status === undefined) throw new Error("bb-pw: route fulfill requires --status <n>");
302
+ {
303
+ // Resolve @ENV: like the other values, then validate — an unresolved ref
304
+ // would become status: NaN in the handler (Codex R2 P2). Require a valid HTTP
305
+ // status (100-599): Playwright evaluates `status || 200`, so 0 (and other
306
+ // out-of-range values) would silently return 200 (Codex R7 P2).
307
+ const status = rr(flags.status);
308
+ if (!/^\d+$/.test(String(status)) || Number(status) < 100 || Number(status) > 599)
309
+ throw new Error(`bb-pw: route fulfill --status must be a valid HTTP status (100-599), got "${status}"`);
310
+ opts.status = status;
311
+ }
312
+ if (flags["content-type"] !== undefined) opts.contentType = rr(flags["content-type"]);
313
+ if (flags.body !== undefined) {
314
+ // Resolve @ENV: FIRST, then interpret @base64:/@file: on the RESOLVED value —
315
+ // so `--body @ENV:BODY` where BODY is `@base64:…`/`@file:…` is decoded/loaded,
316
+ // not fulfilled with the literal marker (Codex R16 P2).
317
+ const { value: resolvedBody, secret: bodyWasSecret } = resolveMeta(flags.body);
318
+ const base64 = /^@base64:(.*)$/s.exec(resolvedBody);
319
+ const file = /^@file:(.*)$/s.exec(resolvedBody);
320
+ // Decode to a UTF-8 string at plan time so the generated handler embeds a
321
+ // plain string (no Buffer in the run-code VM). Fulfill bodies are text/JSON
322
+ // mock responses; binary bodies are out of scope.
323
+ if (base64) opts.body = utf8Body(decodeBase64(base64[1], "route fulfill --body @base64"), "route fulfill --body @base64");
324
+ else if (file) {
325
+ // When the @file: path itself came from a secret ref, mark the EXTRACTED
326
+ // path sensitive before reading, so a filesystem error (ENOENT etc.) that
327
+ // contains the bare path is redacted by the planner-error path (Codex R17
328
+ // P2). @file: contents are always sensitive — mark them so the body takes
329
+ // the socket path and is redacted, never emitted into argv/logs (R8 P2).
330
+ if (bodyWasSecret) markSecret(file[1]);
331
+ opts.body = markSecret(utf8Body(readFileSync(file[1]), "route fulfill --body @file"));
332
+ }
333
+ else opts.body = resolvedBody;
334
+ // If the ref itself was secret, the decoded/loaded body inherits provenance.
335
+ if (bodyWasSecret) markSecret(opts.body);
336
+ }
337
+ return buildRouteCode(g, "fulfill", opts, displayGlob);
338
+ }
339
+
340
+ function planUnroute(rest, rr) {
341
+ // Reject flag-shaped args: a typo like `unroute --glob=x` must NOT silently
342
+ // fall through to clearing every route (Codex R4 P2).
343
+ const flags = rest.filter((arg) => String(arg).startsWith("--"));
344
+ if (flags.length) throw new Error(`bb-pw: unroute takes no flags, got ${flags.join(", ")}`);
345
+ const positionals = rest.filter((arg) => !String(arg).startsWith("--"));
346
+ if (positionals.length > 1) throw new Error("bb-pw: unroute takes at most one <url-glob> (omit to clear all)");
347
+ const rawGlob = positionals[0];
348
+ if (rawGlob === undefined) return buildUnrouteCode(null, null);
349
+ // Fail closed on an explicitly empty glob (e.g. an unset "$GLOB"): it must not
350
+ // silently clear every route (Codex R5 P2).
351
+ if (rawGlob === "") throw new Error("bb-pw: unroute given an empty <url-glob>; omit the argument to clear all routes");
352
+ return buildUnrouteCode(rr(rawGlob), rawGlob);
353
+ }
354
+
355
+ function planRouteList(rest) {
356
+ // route-list takes no operands; reject a stray glob/flag rather than silently
357
+ // ignoring it and returning the full registry (Codex R18 P2).
358
+ if (rest.length) throw new Error(`bb-pw: route-list takes no arguments, got ${rest.join(" ")}`);
359
+ return buildRouteListCode();
360
+ }
361
+
362
+ // Build the generated run-code for a translate verb. `resolveRef` is passed in
363
+ // from args.mjs (avoids an import cycle) so `@ENV:` refs resolve the same way as
364
+ // for every other verb; any resolved secret is collected so the caller can route
365
+ // through the socket path and redact it from output.
366
+ export function planTranslateVerb(verb, rest, { env, resolveRef }) {
367
+ const secretValues = [];
368
+ // Track a secret in BOTH the raw form (as it may appear in a browser result)
369
+ // AND the JSON-escaped inner form (as it appears embedded via JSON.stringify in
370
+ // the generated code that @playwright/cli echoes back), so redaction catches a
371
+ // secret containing quotes/backslashes/newlines too (Codex R8 P2).
372
+ const pushSecret = (value) => {
373
+ const raw = String(value);
374
+ if (raw && !secretValues.includes(raw)) secretValues.push(raw);
375
+ const escaped = JSON.stringify(raw).slice(1, -1);
376
+ if (escaped !== raw && !secretValues.includes(escaped)) secretValues.push(escaped);
377
+ };
378
+ // Resolve @ENV: and report provenance. Provenance comes from resolveRef().secret
379
+ // directly — NOT from growth of the (deduplicated) secretValues, which would
380
+ // wrongly read false when the same secret was already tracked (Codex R14 P2).
381
+ const resolveMeta = (value) => {
382
+ const resolved = resolveRef(value, env);
383
+ if (resolved.secret) pushSecret(resolved.value);
384
+ return { value: String(resolved.value), secret: !!resolved.secret };
385
+ };
386
+ const rr = (value) => resolveMeta(value).value;
387
+ // Mark a value sensitive so it forces the socket transport and is redacted from
388
+ // output — used for content loaded from disk (@file:), which was never on the
389
+ // command line and must not be newly exposed in argv/logs (Codex R8 P2).
390
+ const markSecret = (value) => { pushSecret(value); return value; };
391
+ let code;
392
+ try {
393
+ code = verb === "upload" ? planUpload(rest, rr, markSecret, resolveMeta)
394
+ : verb === "route" ? planRoute(rest, rr, markSecret, resolveMeta)
395
+ : verb === "unroute" ? planUnroute(rest, rr)
396
+ : verb === "route-list" ? planRouteList(rest)
397
+ : (() => { throw new Error(`bb-pw: unknown translate verb ${verb}`); })();
398
+ } catch (error) {
399
+ // A planning error may interpolate a resolved @ENV: value (e.g. an invalid
400
+ // --status/--error). runPwInner writes the thrown message straight to stderr,
401
+ // before secretValues is applied — so redact it here, using the secrets rr()
402
+ // has already collected (Codex R9 P2).
403
+ throw new Error(redactSecrets(error?.message ?? String(error), secretValues));
404
+ }
405
+ return { code, secretValues };
406
+ }
407
+
408
+ // Dedupe + longest-first, matching run.mjs redact(): a short secret that is a
409
+ // substring of a longer one must not be replaced first, or the longer match
410
+ // breaks and leaks its remainder (Codex R12 P1).
411
+ const redactSecrets = (text, secrets) => [...new Set(secrets.filter(Boolean))].sort((a, b) => b.length - a.length).reduce((acc, secret) => acc.split(secret).join("[redacted]"), String(text ?? ""));
@@ -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.",