@botbuddy/cli 1.31.1 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-key.mjs +56 -0
- package/src/agent-session.mjs +0 -0
- package/src/credential-kinds.mjs +42 -0
- package/src/pw/run.mjs +21 -2
- package/src/run.mjs +7 -2
- package/src/setup-block.mjs +18 -25
- package/src/stack-file-lock.mjs +18 -2
- package/src/stack.mjs +425 -62
- package/src/test-lane.mjs +6 -2
- package/src/wait.mjs +11 -1
package/package.json
CHANGED
package/src/agent-key.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// (supabase/functions/_shared/sessionToken.ts). The prior `bb_agent_` shape is
|
|
10
10
|
// accepted as a legacy alias for one release (its hash is already on `sessions`).
|
|
11
11
|
// This is a SHAPE check only — authorization is the server's hashed lookup.
|
|
12
|
+
import { TIER3_LEGACY, credentialTier } from "./credential-kinds.mjs";
|
|
13
|
+
|
|
12
14
|
export const AGENT_KEY_RE = /^bb_(agent|sess)_[0-9a-f]{64}$/;
|
|
13
15
|
|
|
14
16
|
// BOT-1649: BOTBUDDY_AGENT_SESSION_TOKEN names the short-lived credential
|
|
@@ -32,3 +34,57 @@ export function readAgentSessionTokenEnv(env = process.env) {
|
|
|
32
34
|
|
|
33
35
|
// Deprecated exported alias for downstream CLI integrations.
|
|
34
36
|
export const readAgentKeyEnv = readAgentSessionTokenEnv;
|
|
37
|
+
|
|
38
|
+
// BOT-1701 (AC-6) — one canonical stderr nudge when a tier-3 legacy alias is used
|
|
39
|
+
// for a wait/run: a legacy CLI flag (--agent-key / --session-token), a legacy env
|
|
40
|
+
// var (BOTBUDDY_AGENT_KEY / BOTBUDDY_SESSION_TOKEN) that actually supplied the
|
|
41
|
+
// token, or a bb_agent_-prefixed token value. Returns a SINGLE line (never more)
|
|
42
|
+
// naming the canonical flag + env, or null when only canonical names were used.
|
|
43
|
+
// The caller writes it to STDERR only, so a machine-parseable stdout receipt is
|
|
44
|
+
// byte-identical to a canonical-flag run. Precedence matches the CLI: a token
|
|
45
|
+
// flag overrides the env, so the env is only "used" when no token flag was given.
|
|
46
|
+
// The value passed after any tier-3 token flag (canonical or legacy), or null.
|
|
47
|
+
function tokenAfterFlag(argv, flags) {
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
if (flags.includes(argv[i])) return argv[i + 1] ?? null;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function legacyAgentAliasHint({ argv = [], env = process.env, token = null } = {}) {
|
|
55
|
+
const tier3 = credentialTier(3);
|
|
56
|
+
const canonicalFlag = tier3.flag; // --agent-session-token
|
|
57
|
+
const canonicalEnv = tier3.env; // BOTBUDDY_AGENT_SESSION_TOKEN
|
|
58
|
+
const tokenFlags = [canonicalFlag, ...TIER3_LEGACY.flags];
|
|
59
|
+
|
|
60
|
+
// Only the CLI's own args count. `bb run/test … -- <workload> --agent-key …`
|
|
61
|
+
// forwards a workload after `--` whose identically named flags are NOT BotBuddy
|
|
62
|
+
// credentials, so never scan past the separator (Codex R6).
|
|
63
|
+
const sep = argv.indexOf("--");
|
|
64
|
+
const cliArgs = sep === -1 ? argv : argv.slice(0, sep);
|
|
65
|
+
|
|
66
|
+
const legacyFlagUsed = TIER3_LEGACY.flags.some((f) => cliArgs.includes(f));
|
|
67
|
+
const flagToken = tokenAfterFlag(cliArgs, tokenFlags);
|
|
68
|
+
const anyTokenFlag = flagToken !== null || legacyFlagUsed || cliArgs.includes(canonicalFlag);
|
|
69
|
+
|
|
70
|
+
// The value the env would supply (canonical wins), so a bb_agent_ VALUE behind
|
|
71
|
+
// the canonical flag/env is nudged even though the NAME is canonical.
|
|
72
|
+
let legacyEnvUsed = false;
|
|
73
|
+
let envToken = null;
|
|
74
|
+
if (!anyTokenFlag) {
|
|
75
|
+
const canonical = typeof env[canonicalEnv] === "string" ? env[canonicalEnv].trim() : "";
|
|
76
|
+
if (canonical) {
|
|
77
|
+
envToken = env[canonicalEnv];
|
|
78
|
+
} else {
|
|
79
|
+
for (const e of TIER3_LEGACY.envs) {
|
|
80
|
+
if (typeof env[e] === "string" && env[e].trim() !== "") { legacyEnvUsed = true; envToken = env[e]; break; }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const effectiveToken = token ?? flagToken ?? envToken;
|
|
86
|
+
const legacyPrefixUsed = typeof effectiveToken === "string" && effectiveToken.startsWith(TIER3_LEGACY.prefix);
|
|
87
|
+
|
|
88
|
+
if (!legacyFlagUsed && !legacyEnvUsed && !legacyPrefixUsed) return null;
|
|
89
|
+
return `note: --agent-key / --session-token / $BOTBUDDY_AGENT_KEY / ${TIER3_LEGACY.prefix} are one-release legacy aliases — use ${canonicalFlag} or $${canonicalEnv} (docs/agent-connectivity.md)`;
|
|
90
|
+
}
|
package/src/agent-session.mjs
CHANGED
|
Binary file
|
package/src/credential-kinds.mjs
CHANGED
|
@@ -30,6 +30,48 @@ export const CREDENTIAL_PREFIXES = [
|
|
|
30
30
|
{ prefix: "bb_", kind: "legacy", label: "legacy personal key" },
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
+
// BOT-1701 — the ONE canonical per-tier credential contract. The same noun,
|
|
34
|
+
// prefix, CLI flag, env var, and `credential_kind` for each tier, named the same
|
|
35
|
+
// way EVERYWHERE it is met: the setup-block help text, the connectivity guide's
|
|
36
|
+
// tier table, the `bb wait/run/test/pw --help` flags, the connectivity error
|
|
37
|
+
// strings, and the server's `whoami credential_kind`. A drift test
|
|
38
|
+
// (credential-naming-contract.test.mjs) fails when any of those surfaces names a
|
|
39
|
+
// tier-3 credential by anything other than the canonical noun/flag/env below.
|
|
40
|
+
// Decided by BOT-1649 — do not re-decide here. Additive only: legacy aliases
|
|
41
|
+
// (TIER3_LEGACY) keep working for one release and are listed separately so a
|
|
42
|
+
// surface can name them ONLY on a line that says "alias"/"legacy".
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {{ tier: 1|2|3, noun: string, prefix: string, flag: string|null, env: string, kind: string }} CredentialTier
|
|
45
|
+
* @type {ReadonlyArray<CredentialTier>}
|
|
46
|
+
*/
|
|
47
|
+
export const CREDENTIAL_TIERS = Object.freeze([
|
|
48
|
+
Object.freeze({ tier: 1, noun: "client key", prefix: "bb_cli_", flag: null, env: "BOTBUDDY_CLIENT_KEY", kind: "cli" }),
|
|
49
|
+
Object.freeze({ tier: 2, noun: "MCP key", prefix: "bb_mcp_", flag: null, env: "BOTBUDDY_MCP_KEY", kind: "mcp" }),
|
|
50
|
+
Object.freeze({ tier: 3, noun: "agent session token", prefix: "bb_sess_", flag: "--agent-session-token", env: "BOTBUDDY_AGENT_SESSION_TOKEN", kind: "agent_session" }),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The tier-3 legacy aliases, accepted for one release. Kept apart from the
|
|
55
|
+
* canonical table so a surface can name them only on an "alias"/"legacy" line and
|
|
56
|
+
* the CLI can nudge an operator toward the canonical flag/env (BOT-1701 AC-6).
|
|
57
|
+
*/
|
|
58
|
+
export const TIER3_LEGACY = Object.freeze({
|
|
59
|
+
flags: Object.freeze(["--agent-key", "--session-token"]),
|
|
60
|
+
envs: Object.freeze(["BOTBUDDY_AGENT_KEY", "BOTBUDDY_SESSION_TOKEN"]),
|
|
61
|
+
prefix: "bb_agent_",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The canonical tier row for a tier number (1|2|3), or undefined. */
|
|
65
|
+
export function credentialTier(tier) {
|
|
66
|
+
return CREDENTIAL_TIERS.find((t) => t.tier === tier);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The canonical tier row whose prefix a secret carries, or undefined. */
|
|
70
|
+
export function credentialTierByPrefix(secret) {
|
|
71
|
+
if (typeof secret !== "string") return undefined;
|
|
72
|
+
return CREDENTIAL_TIERS.find((t) => secret.startsWith(t.prefix));
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
/**
|
|
34
76
|
* Classify a raw secret by its prefix.
|
|
35
77
|
* @param {unknown} secret
|
package/src/pw/run.mjs
CHANGED
|
@@ -7,13 +7,13 @@ import { canonicalizeHostString } from "./host.mjs";
|
|
|
7
7
|
import { resolveAgentBinding } from "../wait-profile.mjs";
|
|
8
8
|
import { loadConfig, getConfig } from "../config.mjs";
|
|
9
9
|
import { VERSION } from "../version.mjs";
|
|
10
|
-
import { readAgentSessionTokenEnv, AGENT_KEY_RE } from "../agent-key.mjs";
|
|
10
|
+
import { readAgentSessionTokenEnv, AGENT_KEY_RE, legacyAgentAliasHint } from "../agent-key.mjs";
|
|
11
11
|
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
|
|
12
12
|
// BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
|
|
13
13
|
// server-side, so the lane name bb-pw builds/matches/prints is the one the lock
|
|
14
14
|
// kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
|
|
15
15
|
const hostFor = (env) => canonicalizeHostString(env.PLAYWRIGHT_MCP_HOST || env.HOSTNAME || os.hostname());
|
|
16
|
-
function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--tenant <slug> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n"); }
|
|
16
|
+
function help(out) { out.write("Usage: pw [--tenant <slug>] [--session-id <id>] [--agent-session-token <token>] <lane> <verb> [args…]\n\nAliases: bb-pw <lane> <verb> [args…] · botbuddy pw <lane> <verb> [args…]\n (all three drive the same lock-gated Playwright lane)\n\n--session-id <id> accept a lane held by this arming-session agent id (from\n register_agent); defaults to $BOTBUDDY_SESSION_ID, then the\n id saved by `botbuddy register`.\n--agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN);\n --agent-key / --session-token are one-release legacy aliases.\n--tenant <slug> override the worktree .botbuddy-agent.json tenant when falling\n back to the .mcp.json ($BOTBUDDY_MCP_KEY) credential.\n"); }
|
|
17
17
|
function redact(value, secretValues = []) { return secretValues.reduce((text, secret) => secret ? text.split(secret).join("[redacted]") : text, String(value ?? "")); }
|
|
18
18
|
// BOT-1488: the register_agent identity for this machine, persisted by
|
|
19
19
|
// `botbuddy register` into ~/.botbuddy/config.json. This is the SESSION agent
|
|
@@ -99,9 +99,28 @@ export async function runPw(argv, deps = {}) {
|
|
|
99
99
|
if (code !== 0) stderr.write(`${versionLine()} — if your checkout's cli/package.json is newer, this global is stale: npm i -g @botbuddy/cli@latest, or run node cli/bin/bb-pw.mjs from the repo.\n`);
|
|
100
100
|
return code;
|
|
101
101
|
}
|
|
102
|
+
// The leading option/value pairs bb pw consumes before the lane/verb (mirrors the
|
|
103
|
+
// while-loop in runPwInner). Everything after the first non-option (the lane) is a
|
|
104
|
+
// Playwright workload argument and is never a BotBuddy credential.
|
|
105
|
+
const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token"];
|
|
106
|
+
function leadingPwOptions(args) {
|
|
107
|
+
const lead = [];
|
|
108
|
+
for (let i = 0; i < args.length && PW_LEADING_FLAGS.includes(args[i]); i += 2) {
|
|
109
|
+
lead.push(args[i]);
|
|
110
|
+
if (i + 1 < args.length) lead.push(args[i + 1]);
|
|
111
|
+
}
|
|
112
|
+
return lead;
|
|
113
|
+
}
|
|
114
|
+
|
|
102
115
|
async function runPwInner(argv, deps = {}) {
|
|
103
116
|
const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
|
|
104
117
|
if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
|
|
118
|
+
// BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy flag/env alias. bb pw
|
|
119
|
+
// only recognizes credential flags as LEADING options (the while-loop below);
|
|
120
|
+
// a `--agent-key` after the lane/verb is a Playwright workload argument, not a
|
|
121
|
+
// BotBuddy credential, so scan only the leading options (Codex R9).
|
|
122
|
+
const aliasHint = legacyAgentAliasHint({ argv: leadingPwOptions(args), env });
|
|
123
|
+
if (aliasHint) stderr.write(`bb pw: ${aliasHint}\n`);
|
|
105
124
|
// BOT-1649: --agent-session-token / $BOTBUDDY_AGENT_SESSION_TOKEN is canonical;
|
|
106
125
|
// legacy flags and env aliases remain accepted as a
|
|
107
126
|
// session identity alongside --session-id, so a token-armed session need not
|
package/src/run.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { callToolJson } from "./api.mjs";
|
|
16
16
|
import { parseLaneEvents, laneCaseCounts, flushLaneCases, buildLaneSummary, laneSummaryFilename } from "./test-lane-events.mjs";
|
|
17
|
-
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
17
|
+
import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
18
18
|
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
19
19
|
import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
|
|
20
20
|
import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
|
|
@@ -532,9 +532,14 @@ export async function cmdRun(args) {
|
|
|
532
532
|
return;
|
|
533
533
|
}
|
|
534
534
|
if (args[0] === "help" || args[0] === "--help") {
|
|
535
|
-
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>");
|
|
535
|
+
console.log("botbuddy run --session-id <id> --environment <local|preview|staging|production|none> [--timeout <sec>] [--rerun-reason <json>] -- <command> [args...]\nbotbuddy run --foreground --kind <test|wait|browser|ci|hook|stack> --environment <local|preview|staging|production|none> -- <command> [args...]\nbotbuddy run cancel <run_id>\n --agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases");
|
|
536
536
|
return;
|
|
537
537
|
}
|
|
538
|
+
// BOT-1701 (AC-6): one stderr nudge when a tier-3 legacy flag/env alias is used
|
|
539
|
+
// (stderr only, so the stdout receipt stays byte-identical). A cached bb_agent_
|
|
540
|
+
// token is nudged inside resolveAgentSessionCredential.
|
|
541
|
+
const aliasHint = legacyAgentAliasHint({ argv: args, env: process.env });
|
|
542
|
+
if (aliasHint) process.stderr.write(`botbuddy run: ${aliasHint}\n`);
|
|
538
543
|
if (args.includes("--foreground")) {
|
|
539
544
|
let identity;
|
|
540
545
|
try { identity = await loadTelemetryIdentity(); }
|
package/src/setup-block.mjs
CHANGED
|
@@ -8,46 +8,39 @@
|
|
|
8
8
|
// session persistence; normal operator output must never turn that into a
|
|
9
9
|
// multi-command credential handoff.
|
|
10
10
|
|
|
11
|
+
import { credentialTier } from "./credential-kinds.mjs";
|
|
12
|
+
|
|
11
13
|
/** The canonical guide, referenced from every SETUP block and recovery. */
|
|
12
14
|
export const CONNECTIVITY_GUIDE = "docs/agent-connectivity.md";
|
|
13
15
|
|
|
14
16
|
// The three connectivity credential tiers, named consistently everywhere. A
|
|
15
17
|
// connectivity error names the MISSING tier and the exact next command; the
|
|
16
18
|
// guide's tiered table and the help SETUP block use these same labels.
|
|
19
|
+
//
|
|
20
|
+
// BOT-1701: the noun, prefix, flag, env, and kind for each tier are OWNED by
|
|
21
|
+
// credential-kinds.mjs (CREDENTIAL_TIERS) — the single source of truth. This map
|
|
22
|
+
// only adds the setup-specific fields (the tier number, the `setup` command, and
|
|
23
|
+
// the scope). A drift test asserts every user-facing surface agrees with the
|
|
24
|
+
// canonical table, so `label`/`prefix`/`env`/`flag` here can never fork from it.
|
|
17
25
|
export const TIERS = Object.freeze({
|
|
18
26
|
// Tier 1 — per-machine client key. `bb login`, USER-scoped (reaches every
|
|
19
27
|
// tenant you belong to; each request pins one). Never a wait credential.
|
|
20
|
-
client: Object.freeze({
|
|
21
|
-
n: 1,
|
|
22
|
-
label: "client key",
|
|
23
|
-
prefix: "bb_cli_",
|
|
24
|
-
env: "BOTBUDDY_CLIENT_KEY",
|
|
25
|
-
setup: "bb login",
|
|
26
|
-
scope: "user",
|
|
27
|
-
}),
|
|
28
|
+
client: Object.freeze({ ...tierFields(1), setup: "bb login", scope: "user" }),
|
|
28
29
|
// Tier 2 — per-tenant MCP config key. `bb mcp setup`, TENANT-bound, lives in
|
|
29
30
|
// .mcp.json under $BOTBUDDY_MCP_KEY. Authenticates MCP calls + register_agent.
|
|
30
|
-
mcp: Object.freeze({
|
|
31
|
-
|
|
32
|
-
label: "MCP key",
|
|
33
|
-
prefix: "bb_mcp_",
|
|
34
|
-
env: "BOTBUDDY_MCP_KEY",
|
|
35
|
-
setup: "bb mcp setup",
|
|
36
|
-
scope: "tenant",
|
|
37
|
-
}),
|
|
38
|
-
// Tier 3 — per-session agent token, tenant-bound, 8 h TTL, revoked on
|
|
31
|
+
mcp: Object.freeze({ ...tierFields(2), setup: "bb mcp setup", scope: "tenant" }),
|
|
32
|
+
// Tier 3 — per-session agent session token, tenant-bound, 8 h TTL, revoked on
|
|
39
33
|
// re-register. The CLI caches it in .botbuddy/agent-state.json and restores
|
|
40
34
|
// it automatically for wait/run/test/pw.
|
|
41
|
-
session: Object.freeze({
|
|
42
|
-
n: 3,
|
|
43
|
-
label: "session token",
|
|
44
|
-
prefix: "bb_sess_",
|
|
45
|
-
env: "BOTBUDDY_AGENT_SESSION_TOKEN",
|
|
46
|
-
setup: "register_agent",
|
|
47
|
-
scope: "session",
|
|
48
|
-
}),
|
|
35
|
+
session: Object.freeze({ ...tierFields(3), setup: "register_agent", scope: "session" }),
|
|
49
36
|
});
|
|
50
37
|
|
|
38
|
+
/** Project a canonical CREDENTIAL_TIERS row onto the TIERS shape (n/label/…). */
|
|
39
|
+
function tierFields(tier) {
|
|
40
|
+
const t = credentialTier(tier);
|
|
41
|
+
return { n: t.tier, label: t.noun, prefix: t.prefix, env: t.env, flag: t.flag };
|
|
42
|
+
}
|
|
43
|
+
|
|
51
44
|
/**
|
|
52
45
|
* The shared SETUP block reproduced verbatim in every `--help` output. Plain
|
|
53
46
|
* text (no ANSI) so the snapshot test can match it byte-for-byte across surfaces.
|
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.",
|
package/src/test-lane.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { execFileSync } from "node:child_process";
|
|
|
21
21
|
|
|
22
22
|
import { EXIT, launchRun, receiptPath, DEFAULT_TIMEOUT_SECONDS } from "./run.mjs";
|
|
23
23
|
import { callToolJson } from "./api.mjs";
|
|
24
|
-
import { readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
24
|
+
import { readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
25
25
|
import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
26
26
|
|
|
27
27
|
// EXIT.{OK,INVALID,BACKEND,INTERNAL} plus a --wait timeout code (AC-9).
|
|
@@ -338,7 +338,8 @@ function laneList(cwd) {
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
function testHelp() {
|
|
341
|
-
console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--lane-kind <${LANE_KINDS.join("|")}>] [--wait] [--json] [-- <extra args>]
|
|
341
|
+
console.log(`botbuddy test <lane> [--session-id <uuid>] [--agent-session-token <token>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--lane-kind <${LANE_KINDS.join("|")}>] [--wait] [--json] [-- <extra args>]
|
|
342
|
+
--agent-session-token <token> session credential (env $BOTBUDDY_AGENT_SESSION_TOKEN); --agent-key / --session-token are one-release legacy aliases
|
|
342
343
|
botbuddy test list List the lanes configured in .botbuddy/lanes.json
|
|
343
344
|
botbuddy test help Show this help
|
|
344
345
|
|
|
@@ -351,6 +352,9 @@ export async function cmdTest(args, { cwd = process.cwd() } = {}) {
|
|
|
351
352
|
const { subcommand } = parseTestArgs(args);
|
|
352
353
|
if (subcommand === "help" || args[0] === "--help" || args[0] === "-h") { testHelp(); return; }
|
|
353
354
|
if (subcommand === "list") { process.exitCode = laneList(cwd); return; }
|
|
355
|
+
// BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy flag/env alias.
|
|
356
|
+
const aliasHint = legacyAgentAliasHint({ argv: args, env: process.env });
|
|
357
|
+
if (aliasHint) process.stderr.write(`botbuddy test: ${aliasHint}\n`);
|
|
354
358
|
const result = await launchTestLane(args, { cwd });
|
|
355
359
|
process.stdout.write(result.line + "\n");
|
|
356
360
|
// --wait (AC-9): after launch, block until the run completes and exit with the
|
package/src/wait.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
|
|
|
18
18
|
import { VERSION } from "./version.mjs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { latestPublicCliCommand } from "./public-invocation.mjs";
|
|
21
|
-
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
21
|
+
import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
|
|
22
22
|
import { clearAgentState, hasBootstrapAgentSession, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
|
|
23
23
|
import { touchAgentStateExpiry } from "./agent-state.mjs";
|
|
24
24
|
import { SETUP_BLOCK } from "./setup-block.mjs";
|
|
@@ -1344,6 +1344,16 @@ async function acknowledgeSavedWait(id) {
|
|
|
1344
1344
|
|
|
1345
1345
|
export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
1346
1346
|
if (argv.length === 0 || argv[0] === "--offset") return inspectSavedWaits(argv);
|
|
1347
|
+
// BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy alias (flag, env, or a
|
|
1348
|
+
// bb_agent_ token value), emitted BEFORE any subcommand dispatch so the
|
|
1349
|
+
// credentialed recovery actions (resume/cancel/acknowledge) warn too (Codex R7).
|
|
1350
|
+
// STDERR only, so a machine-parseable stdout receipt is byte-identical. Skipped
|
|
1351
|
+
// on the internal recovery re-entry (resumeSavedWait recurses into runWait with
|
|
1352
|
+
// recoveryLocalWaitId set) so a resume never nudges twice (Codex R8).
|
|
1353
|
+
if (recoveryLocalWaitId == null) {
|
|
1354
|
+
const aliasHint = legacyAgentAliasHint({ argv, env: process.env });
|
|
1355
|
+
if (aliasHint) process.stderr.write(`botbuddy wait: ${aliasHint}\n`);
|
|
1356
|
+
}
|
|
1347
1357
|
if (argv[0] === "resume") {
|
|
1348
1358
|
const id = argv[1];
|
|
1349
1359
|
if (!id || argv.length > 2) return emitSavedWaitError(new WaitCheckpointError("wait_not_resumable", "select exactly one saved wait: bb wait resume <local_wait_id>"));
|