@botbuddy/cli 1.31.2 → 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 +1 -1
- package/src/stack-file-lock.mjs +18 -2
- package/src/stack.mjs +425 -62
package/package.json
CHANGED
package/src/stack-file-lock.mjs
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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'
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
604
|
+
callerRoot = realpathSync(cwd);
|
|
607
605
|
} catch (error) {
|
|
608
|
-
return { ok: false, error: `could not resolve the invoking
|
|
606
|
+
return { ok: false, error: `could not resolve the invoking worktree: ${error.message}` };
|
|
609
607
|
}
|
|
610
|
-
if (
|
|
611
|
-
|
|
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 =
|
|
616
|
-
?
|
|
617
|
-
: join(
|
|
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:
|
|
638
|
-
stack_path:
|
|
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
|
-
/**
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
666
|
-
"
|
|
667
|
-
"
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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 =
|
|
678
|
-
|
|
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
|
|
682
|
-
export function localTeardown(
|
|
683
|
-
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold("supabase stop")} in
|
|
684
|
-
const res = spawn("supabase", ["stop", "--workdir",
|
|
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
|
|
812
|
-
// provisioner partially starts a stack and
|
|
813
|
-
//
|
|
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:
|
|
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(
|
|
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.",
|