@botbuddy/cli 1.33.10 → 1.34.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/api.mjs +194 -22
- package/src/stack.mjs +66 -7
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { AGENT_KEY_RE } from "./agent-key.mjs";
|
|
|
5
5
|
// BOT-1864: api.mjs ↔ agent-session.mjs is a deliberate import cycle — each side
|
|
6
6
|
// only CALLS the other's functions at request time, never at module evaluation.
|
|
7
7
|
import { resolveAgentSessionCredential } from "./agent-session.mjs";
|
|
8
|
-
import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
|
|
8
|
+
import { die, red, cyan, dim, yellow, prettyJson } from "./utils.mjs";
|
|
9
9
|
|
|
10
10
|
// Same slug shape the server accepts on `?tenant=`.
|
|
11
11
|
const TENANT_PIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
@@ -57,7 +57,7 @@ async function genericEndpoint({ tenant = null, resolvePin = resolveDefaultTenan
|
|
|
57
57
|
// tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
|
|
58
58
|
// BOT-1864: the generic commands (`bb call`, resources, heartbeat/locks/tasks)
|
|
59
59
|
// first honour this worktree's tier-3 session — see resolveCallAuth.
|
|
60
|
-
async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {}) {
|
|
60
|
+
async function authHeader(toolName = null, { resolveAuth = resolveCallAuth, exitCode = 1 } = {}) {
|
|
61
61
|
const resolved = await resolveAuth({ toolName, preferSession: true });
|
|
62
62
|
if (resolved.auth) {
|
|
63
63
|
const expiresAt = resolved.ownerExpiresAt ?? null;
|
|
@@ -66,12 +66,15 @@ async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {
|
|
|
66
66
|
}
|
|
67
67
|
return resolved;
|
|
68
68
|
}
|
|
69
|
+
// BOT-1896: a missing/expired local credential is auth-class — callTool passes
|
|
70
|
+
// CALL_EXIT.AUTH so it never collapses into the generic exit 1.
|
|
71
|
+
const fail = (msg) => { console.error(`${red("✗")} ${msg}`); process.exit(exitCode); };
|
|
69
72
|
const code = resolved.error?.error;
|
|
70
|
-
if (code === "token_expired")
|
|
73
|
+
if (code === "token_expired") fail(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
|
|
71
74
|
if (code === "session_token_malformed") {
|
|
72
|
-
|
|
75
|
+
fail(`The agent session token has an unexpected shape (source: ${resolved.error.source ?? "unknown"}). Run: ${cyan("bb doctor --fix")} in this checkout.`);
|
|
73
76
|
}
|
|
74
|
-
|
|
77
|
+
fail(`Not authenticated. Run: ${cyan("botbuddy login")}`);
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
// BOT-1864: a cached session the server rejects (revoked by a concurrent
|
|
@@ -96,39 +99,200 @@ async function requestEndpoint(resolved, { tenant, resolvePin }) {
|
|
|
96
99
|
return SESSION_SOURCES.has(resolved?.source) ? toolEndpoint(tenant) : genericEndpoint({ tenant, resolvePin });
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
|
|
102
|
+
// BOT-1896: `botbuddy call` (and every command riding callTool) exits with a
|
|
103
|
+
// code that tells a tenant gate WHY it failed: AUTH when BotBuddy refused the
|
|
104
|
+
// credential (fail closed), TRANSPORT when BotBuddy could not be reached or
|
|
105
|
+
// answered with a gateway/5xx failure (the only case that may degrade to a
|
|
106
|
+
// local-only lock). The values reuse `botbuddy stack`'s EXIT table (stack.mjs:
|
|
107
|
+
// AUTH 3, BACKEND 5) — restated rather than imported because stack.mjs imports
|
|
108
|
+
// this module; bot1896-call-auth-exit.test.mjs pins them equal.
|
|
109
|
+
// ERROR (1) is the generic failure: a reachable server said something other than
|
|
110
|
+
// "ok" or "auth" (a proxy 404, a 429 page, …). A gate must fail closed on it —
|
|
111
|
+
// only TRANSPORT licenses a local-only fallback.
|
|
112
|
+
export const CALL_EXIT = Object.freeze({ OK: 0, ERROR: 1, AUTH: 3, TRANSPORT: 5 });
|
|
113
|
+
|
|
114
|
+
// BOT-1896: mirror of the server's AUTH_CLASS_ERROR_CODES
|
|
115
|
+
// (supabase/functions/_shared/lib/authDenial.ts), used when an older server
|
|
116
|
+
// sends a typed code without `error_class`. Pinned equal by the same test.
|
|
117
|
+
export const CALL_AUTH_ERROR_CODES = new Set([
|
|
118
|
+
"scope_denied",
|
|
119
|
+
"session_token_revoked",
|
|
120
|
+
"session_token_expired",
|
|
121
|
+
"session_token_scope",
|
|
122
|
+
"credential_unknown",
|
|
123
|
+
"credential_missing",
|
|
124
|
+
"MCP_REAUTH_TENANT_REQUIRED",
|
|
125
|
+
"MCP_TENANT_PIN_REQUIRED",
|
|
126
|
+
"MCP_TENANT_SESSION_MISMATCH",
|
|
127
|
+
"MCP_TENANT_MEMBERSHIP_REQUIRED",
|
|
128
|
+
"MCP_TENANT_PIN_UNSUPPORTED",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
// BOT-1896: the codes the server's 401 gates emit when a request authenticated
|
|
132
|
+
// as nothing — a dead/absent credential, never a tenant refusal.
|
|
133
|
+
const UNAUTHENTICATED_CODES = new Set(["credential_unknown", "credential_missing", "session_token_revoked", "session_token_expired"]);
|
|
134
|
+
|
|
135
|
+
function typedCodeOf(fields) {
|
|
136
|
+
if (!fields || typeof fields !== "object") return null;
|
|
137
|
+
const raw = fields.error_code ?? fields.code;
|
|
138
|
+
return typeof raw === "string" && raw ? raw : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isAuthClass(fields) {
|
|
142
|
+
return Boolean(fields && typeof fields === "object" &&
|
|
143
|
+
(fields.error_class === "auth" || CALL_AUTH_ERROR_CODES.has(typedCodeOf(fields))));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* BOT-1896: classify one tools/call exchange. Pure — no I/O.
|
|
148
|
+
* kind "ok" — a normal result (printed as before, exit 0)
|
|
149
|
+
* kind "tool_error" — a non-auth isError result (printed as before, exit 0)
|
|
150
|
+
* kind "server_error" — a non-auth JSON-RPC error (die as before, exit 1)
|
|
151
|
+
* kind "auth" — BotBuddy refused the credential → receipt, CALL_EXIT.AUTH
|
|
152
|
+
* kind "transport" — no HTTP response, or a 5xx → receipt, CALL_EXIT.TRANSPORT
|
|
153
|
+
* kind "http_error" — any other HTTP failure (non-JSON or error-less 4xx,
|
|
154
|
+
* a non-JSON 2xx) → receipt, CALL_EXIT.ERROR (fail closed)
|
|
155
|
+
* The receipt carries the server's `error_code` and typed fields UNCHANGED. A
|
|
156
|
+
* 401/403 is auth even with no typed code (an older server), never transport.
|
|
157
|
+
*/
|
|
158
|
+
export function classifyCallResponse({ status = null, data = null, transportError = null, parseError = null } = {}) {
|
|
159
|
+
if (transportError) {
|
|
160
|
+
return {
|
|
161
|
+
kind: "transport",
|
|
162
|
+
receipt: { ok: false, error_class: "transport", error_code: "transport_error", http_status: null, message: String(transportError?.message ?? transportError) },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const authStatus = status === 401 || status === 403;
|
|
166
|
+
if (parseError || !data || typeof data !== "object") {
|
|
167
|
+
if (authStatus) {
|
|
168
|
+
return { kind: "auth", receipt: { ok: false, error_class: "auth", error_code: "unauthorized", http_status: status, message: "BotBuddy refused the credential (no typed body)." } };
|
|
169
|
+
}
|
|
170
|
+
// Codex P1 (PR #970): only a 5xx gateway page reads as transport. A reachable
|
|
171
|
+
// endpoint answering non-JSON with anything else (a proxy 404 from a wrong
|
|
172
|
+
// URL, a 429 rate-limit page) must fail closed, never license a fallback.
|
|
173
|
+
return typeof status === "number" && status >= 500
|
|
174
|
+
? { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "invalid_response", http_status: status, message: `BotBuddy returned a non-JSON response (HTTP ${status}).` } }
|
|
175
|
+
: httpError(status, `BotBuddy returned a non-JSON response (HTTP ${status ?? "?"}).`);
|
|
176
|
+
}
|
|
177
|
+
const rpcError = data.error && typeof data.error === "object" ? data.error : null;
|
|
178
|
+
if (rpcError) {
|
|
179
|
+
const detail = rpcError.data && typeof rpcError.data === "object" ? rpcError.data : {};
|
|
180
|
+
if (isAuthClass(detail) || authStatus || rpcError.code === -32001) {
|
|
181
|
+
return {
|
|
182
|
+
kind: "auth",
|
|
183
|
+
receipt: { ok: false, ...detail, error_class: "auth", error_code: typedCodeOf(detail) ?? "unauthorized", http_status: status, message: rpcError.message ?? null },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (typeof status === "number" && status >= 500) {
|
|
187
|
+
return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: rpcError.message ?? null } };
|
|
188
|
+
}
|
|
189
|
+
return { kind: "server_error", receipt: null };
|
|
190
|
+
}
|
|
191
|
+
// A bare REST-style `{ error }` body (not JSON-RPC).
|
|
192
|
+
if (typeof data.error === "string") {
|
|
193
|
+
if (isAuthClass(data) || authStatus) {
|
|
194
|
+
return { kind: "auth", receipt: { ok: false, ...data, error_class: "auth", error_code: typedCodeOf(data) ?? "unauthorized", http_status: status, message: data.error } };
|
|
195
|
+
}
|
|
196
|
+
if (typeof status === "number" && status >= 500) {
|
|
197
|
+
return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: data.error } };
|
|
198
|
+
}
|
|
199
|
+
return { kind: "server_error", receipt: null };
|
|
200
|
+
}
|
|
201
|
+
if (authStatus) {
|
|
202
|
+
return { kind: "auth", receipt: { ok: false, error_class: "auth", error_code: "unauthorized", http_status: status, message: null } };
|
|
203
|
+
}
|
|
204
|
+
if (typeof status === "number" && status >= 500) {
|
|
205
|
+
return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: null } };
|
|
206
|
+
}
|
|
207
|
+
if (typeof status === "number" && status >= 400) {
|
|
208
|
+
return httpError(status, null);
|
|
209
|
+
}
|
|
210
|
+
if (data.result?.isError === true) {
|
|
211
|
+
const first = data.result?.content?.[0]?.text;
|
|
212
|
+
let envelope = null;
|
|
213
|
+
try { envelope = typeof first === "string" ? JSON.parse(first) : null; } catch { envelope = null; }
|
|
214
|
+
if (isAuthClass(envelope)) {
|
|
215
|
+
const { success: _success, ...fields } = envelope;
|
|
216
|
+
return { kind: "auth", receipt: { ok: false, ...fields, error_class: "auth", error_code: typedCodeOf(envelope), http_status: status } };
|
|
217
|
+
}
|
|
218
|
+
return { kind: "tool_error", receipt: null };
|
|
219
|
+
}
|
|
220
|
+
return { kind: "ok", receipt: null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function httpError(status, message) {
|
|
224
|
+
return { kind: "http_error", receipt: { ok: false, error_class: "http", error_code: `http_${status ?? "unknown"}`, http_status: status, message } };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function authFailureLine(receipt) {
|
|
228
|
+
const what = receipt.error_code === "scope_denied" && receipt.tool && receipt.required_scope
|
|
229
|
+
? `scope_denied: \`${receipt.tool}\` requires the \`${receipt.required_scope}\` scope${receipt.key_prefix ? ` (presented a ${receipt.key_prefix} key)` : ""}`
|
|
230
|
+
: receipt.error_code;
|
|
231
|
+
return `BotBuddy refused the credential (${what}).${receipt.remedy ? ` ${receipt.remedy}` : ""}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, resolveAuth = resolveCallAuth, log = (line) => console.log(line), errorLog = (line) => console.error(line) } = {}) {
|
|
100
235
|
const body = {
|
|
101
236
|
jsonrpc: "2.0",
|
|
102
237
|
id: 1,
|
|
103
238
|
method: "tools/call",
|
|
104
239
|
params: { name: toolName, arguments: args },
|
|
105
240
|
};
|
|
241
|
+
// BOT-1896: never throw on the wire — a fetch rejection or a non-JSON body is
|
|
242
|
+
// returned as a classified transport failure instead of an uncaught exit 1.
|
|
106
243
|
const send = async (resolved) => {
|
|
107
244
|
const headers = { "Content-Type": "application/json", ...resolved.auth };
|
|
108
|
-
|
|
109
|
-
|
|
245
|
+
let res;
|
|
246
|
+
try {
|
|
247
|
+
res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
|
|
248
|
+
} catch (transportError) {
|
|
249
|
+
return { status: null, data: null, transportError };
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return { status: res.status, data: await res.json() };
|
|
253
|
+
} catch (parseError) {
|
|
254
|
+
return { status: res.status, data: null, parseError };
|
|
255
|
+
}
|
|
110
256
|
};
|
|
111
257
|
|
|
112
|
-
const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth });
|
|
113
|
-
let
|
|
258
|
+
const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth, exitCode: CALL_EXIT.AUTH });
|
|
259
|
+
let exchange = await send(first);
|
|
114
260
|
let staleSession = false;
|
|
115
|
-
if (!auth && isRejectedCachedSessionResponse(first, status, data)) {
|
|
261
|
+
if (!auth && isRejectedCachedSessionResponse(first, exchange.status, exchange.data)) {
|
|
116
262
|
const durable = await resolveAuth({ toolName, preferSession: false });
|
|
117
|
-
if (durable.auth)
|
|
263
|
+
if (durable.auth) exchange = await send(durable);
|
|
118
264
|
else staleSession = true;
|
|
119
265
|
}
|
|
266
|
+
const { status, data } = exchange;
|
|
120
267
|
const exportedRejected = !auth && (first.source === "env" || first.source === "flag")
|
|
121
268
|
&& (status === 401 || status === 403 || data?.error?.code === -32001);
|
|
122
269
|
|
|
270
|
+
const code = data?.error?.data?.code;
|
|
271
|
+
const hint = code === "MCP_TENANT_PIN_REQUIRED"
|
|
272
|
+
? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
|
|
273
|
+
: staleSession
|
|
274
|
+
? ` The cached agent session in this checkout was rejected; run ${cyan("bb doctor --fix")} here.`
|
|
275
|
+
: exportedRejected
|
|
276
|
+
? ` The exported agent session token was rejected; unset ${cyan("BOTBUDDY_AGENT_SESSION_TOKEN")} (and its legacy aliases) to use this checkout's session or your login.`
|
|
277
|
+
: "";
|
|
278
|
+
|
|
279
|
+
const outcome = classifyCallResponse(exchange);
|
|
280
|
+
if (outcome.kind === "auth") {
|
|
281
|
+
log(JSON.stringify(outcome.receipt, null, 2));
|
|
282
|
+
errorLog(`${red("✗")} ${authFailureLine(outcome.receipt)}${hint}`);
|
|
283
|
+
return CALL_EXIT.AUTH;
|
|
284
|
+
}
|
|
285
|
+
if (outcome.kind === "transport") {
|
|
286
|
+
log(JSON.stringify(outcome.receipt, null, 2));
|
|
287
|
+
errorLog(`${red("✗")} BotBuddy unreachable (${outcome.receipt.error_code}${outcome.receipt.http_status ? `, HTTP ${outcome.receipt.http_status}` : ""}): ${outcome.receipt.message ?? ""}`);
|
|
288
|
+
return CALL_EXIT.TRANSPORT;
|
|
289
|
+
}
|
|
290
|
+
if (outcome.kind === "http_error") {
|
|
291
|
+
log(JSON.stringify(outcome.receipt, null, 2));
|
|
292
|
+
errorLog(`${red("✗")} BotBuddy answered HTTP ${outcome.receipt.http_status ?? "?"} (${outcome.receipt.error_code}); not treating this as unreachable.${hint}`);
|
|
293
|
+
return CALL_EXIT.ERROR;
|
|
294
|
+
}
|
|
123
295
|
if (data.error?.message) {
|
|
124
|
-
const code = data.error?.data?.code;
|
|
125
|
-
const hint = code === "MCP_TENANT_PIN_REQUIRED"
|
|
126
|
-
? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
|
|
127
|
-
: staleSession
|
|
128
|
-
? ` The cached agent session in this checkout was rejected; run ${cyan("bb doctor --fix")} here.`
|
|
129
|
-
: exportedRejected
|
|
130
|
-
? ` The exported agent session token was rejected; unset ${cyan("BOTBUDDY_AGENT_SESSION_TOKEN")} (and its legacy aliases) to use this checkout's session or your login.`
|
|
131
|
-
: "";
|
|
132
296
|
die(`Server error: ${data.error.message}${hint}`);
|
|
133
297
|
}
|
|
134
298
|
|
|
@@ -260,13 +424,21 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
|
|
|
260
424
|
// { error: { code: -32002, data: { code } } }). Surface that `code` so a
|
|
261
425
|
// caller can tell a tenant refusal apart from a plain credential failure.
|
|
262
426
|
// Best-effort: an opaque/bodyless 401/403 keeps the generic shape.
|
|
427
|
+
// BOT-1896: the unauthenticated 401 now carries a typed code too
|
|
428
|
+
// (credential_unknown / session_token_revoked / …). Those are exposed as
|
|
429
|
+
// `error_code` but NOT lifted into `code`, which callers (mcp-key,
|
|
430
|
+
// isRejectedCachedMcpSession) read as "a structured refusal other than a dead
|
|
431
|
+
// credential" — so a bad key keeps its not-authenticated / self-heal path.
|
|
263
432
|
let code = null;
|
|
433
|
+
let errorCode = null;
|
|
264
434
|
try {
|
|
265
435
|
const body = await res.json();
|
|
266
436
|
const raw = body?.error?.data?.code ?? body?.error?.code ?? body?.code ?? null;
|
|
267
|
-
if (typeof raw === "string" && raw) code = raw;
|
|
437
|
+
if (typeof raw === "string" && raw && !UNAUTHENTICATED_CODES.has(raw)) code = raw;
|
|
438
|
+
const typed = body?.error?.data?.error_code ?? body?.error_code ?? null;
|
|
439
|
+
if (typeof typed === "string" && typed) errorCode = typed;
|
|
268
440
|
} catch { /* opaque body */ }
|
|
269
|
-
return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}) };
|
|
441
|
+
return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}), ...(errorCode ? { error_code: errorCode } : {}) };
|
|
270
442
|
}
|
|
271
443
|
let payload;
|
|
272
444
|
try { payload = await res.json(); } catch { return { ok: false, status: res.status, error: "invalid_json" }; }
|
package/src/stack.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { open, unlink, readFile } from "fs/promises";
|
|
|
27
27
|
import { tmpdir } from "os";
|
|
28
28
|
import { basename, dirname, join, relative, isAbsolute } from "path";
|
|
29
29
|
import { randomUUID } from "crypto";
|
|
30
|
-
import { callToolJson } from "./api.mjs";
|
|
30
|
+
import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
|
|
31
31
|
import { SERVER_URL, getConfig } from "./config.mjs";
|
|
32
32
|
import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
|
|
33
33
|
import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
|
|
@@ -696,6 +696,9 @@ export function stackPathRootRefusalNotice(code, message) {
|
|
|
696
696
|
* reads as "the control plane is unhealthy, retry". Every other refusal keeps BACKEND (5).
|
|
697
697
|
*/
|
|
698
698
|
export function leaseRefusalExit(code) {
|
|
699
|
+
// BOT-1896: an auth-class refusal (scope_denied, a tenant mismatch, …) is the
|
|
700
|
+
// server refusing the credential — never BACKEND, which a gate reads as "down".
|
|
701
|
+
if (CALL_AUTH_ERROR_CODES.has(code)) return EXIT.AUTH;
|
|
699
702
|
return code === STACK_PATH_ROOT_REFUSED_CODE ? EXIT.INVALID : EXIT.BACKEND;
|
|
700
703
|
}
|
|
701
704
|
|
|
@@ -1264,10 +1267,25 @@ export function localProvision(opts, dockerTarget, execution, spawn = spawnSync,
|
|
|
1264
1267
|
return conn;
|
|
1265
1268
|
}
|
|
1266
1269
|
|
|
1267
|
-
/**
|
|
1268
|
-
export
|
|
1269
|
-
|
|
1270
|
-
|
|
1270
|
+
/** Release dispositions `stack done` accepts (`--destroy` is the default, `--stop` keeps volumes). */
|
|
1271
|
+
export const STACK_DISPOSITIONS = Object.freeze(["destroy", "stop"]);
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* LOUD local-exec fallback: tear the stack down in `stackDir`. Returns true iff it succeeded.
|
|
1275
|
+
*
|
|
1276
|
+
* BOT-1887: the teardown honours the lease's release `disposition`. `destroy` (the `done`
|
|
1277
|
+
* default, and what the receipt reports) runs `supabase stop --no-backup`, which also deletes
|
|
1278
|
+
* the project's data volumes (`supabase_db_/storage_/edge_runtime_<project_id>`); `--workdir`
|
|
1279
|
+
* scopes that to the stack's own project_id, never `--all`. `stop` keeps the volumes for a
|
|
1280
|
+
* cheap re-provision. An unknown disposition throws before Docker is touched.
|
|
1281
|
+
*/
|
|
1282
|
+
export function localTeardown(stackDir, dockerTarget, disposition, spawn = spawnSync, env = process.env) {
|
|
1283
|
+
if (!STACK_DISPOSITIONS.includes(disposition)) {
|
|
1284
|
+
throw new Error(`localTeardown: unknown disposition ${JSON.stringify(disposition)} (expected one of ${STACK_DISPOSITIONS.join(", ")})`);
|
|
1285
|
+
}
|
|
1286
|
+
const args = ["stop", "--workdir", stackDir, ...(disposition === "destroy" ? ["--no-backup"] : [])];
|
|
1287
|
+
process.stderr.write(`${yellow("⚠ LOCAL-EXEC FALLBACK")} — running ${bold(`supabase ${args.join(" ")}`)} (disposition ${disposition}).\n`);
|
|
1288
|
+
const res = spawn("supabase", args, {
|
|
1271
1289
|
encoding: "utf8",
|
|
1272
1290
|
env: dockerEnvForTarget(dockerTarget, env),
|
|
1273
1291
|
});
|
|
@@ -1608,7 +1626,14 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1608
1626
|
let dockerTarget = null;
|
|
1609
1627
|
let legacyTargetProof = null;
|
|
1610
1628
|
let teardownDir = process.cwd();
|
|
1629
|
+
let leaseBefore = null;
|
|
1611
1630
|
if (opts.localExec) {
|
|
1631
|
+
// BOT-1887: the disposition picks the local teardown (destroy deletes the project's
|
|
1632
|
+
// volumes); refuse an unknown one BEFORE the lease is released into `reaping`.
|
|
1633
|
+
if (!STACK_DISPOSITIONS.includes(opts.disposition)) {
|
|
1634
|
+
return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
|
|
1635
|
+
error: `unknown disposition ${JSON.stringify(opts.disposition)} (expected one of ${STACK_DISPOSITIONS.join(", ")})` }), opts, EXIT.INVALID);
|
|
1636
|
+
}
|
|
1612
1637
|
const current = await call("get_stack_lease", { lease_id: leaseId });
|
|
1613
1638
|
if (!current.ok || !current.data?.success) {
|
|
1614
1639
|
return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId,
|
|
@@ -1618,6 +1643,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1618
1643
|
// BOT-1711: tear down in the directory the stack was PROVISIONED in (the lease's
|
|
1619
1644
|
// recorded worktree_root/stack_path), not the invoking cwd — otherwise `supabase
|
|
1620
1645
|
// stop` at the worktree root would stop the shared canonical stack.
|
|
1646
|
+
leaseBefore = current.data;
|
|
1621
1647
|
teardownDir = leaseStackDir(current.data);
|
|
1622
1648
|
// BOT-1711 (Codex P1, R4): refuse local-exec teardown of ANY lease whose target is the
|
|
1623
1649
|
// worktree root / default project — BEFORE branching on the persisted Docker target.
|
|
@@ -1706,13 +1732,46 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1706
1732
|
if (!r.ok) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, error: r.error }), opts, r.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
1707
1733
|
if (!r.data.success) return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, code: r.data.code, error: r.data.code }), opts, EXIT.BACKEND);
|
|
1708
1734
|
let state = r.data.state;
|
|
1735
|
+
// BOT-1887 (Codex #972 P1): a lease that is ALREADY reaping (an idle reap that
|
|
1736
|
+
// established `stop`, or a retried `destroy`) keeps its in-flight teardown contract —
|
|
1737
|
+
// `release_stack_lease` returns that established `reap_disposition` and ignores the
|
|
1738
|
+
// requested one. Tear down (and report) by the established contract, never the flag,
|
|
1739
|
+
// so a retry can neither delete volumes a `stop` meant to keep nor keep a `destroy`'s.
|
|
1740
|
+
// Codex #972 R2 (P2): a lease that was ALREADY reaped answers `noop` with no
|
|
1741
|
+
// reap_disposition — nothing was torn down by this call, so report the disposition it
|
|
1742
|
+
// was actually reaped with (the lease row carries it), never the newly requested flag;
|
|
1743
|
+
// unknown → null. A never-provisioned lease is terminalized as `destroy` server-side.
|
|
1744
|
+
let established = r.data.reap_disposition;
|
|
1745
|
+
const alreadyReaped = established == null && state === "reaped" && r.data.noop === true;
|
|
1746
|
+
if (alreadyReaped) {
|
|
1747
|
+
// Codex #972 R3: the pre-release read is only authoritative if it ALREADY saw the
|
|
1748
|
+
// lease reaped — a Helper can finalize it between that read and the release. Otherwise
|
|
1749
|
+
// re-read, and refuse to emit an unverifiable receipt if that read fails.
|
|
1750
|
+
let stored = leaseBefore?.state === "reaped" ? leaseBefore : null;
|
|
1751
|
+
if (!stored) {
|
|
1752
|
+
const got = await call("get_stack_lease", { lease_id: leaseId });
|
|
1753
|
+
stored = got.ok && got.data?.success ? got.data : null;
|
|
1754
|
+
if (!stored) {
|
|
1755
|
+
return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, state,
|
|
1756
|
+
error: `the lease was already reaped but its reap disposition could not be read (${got.error || got.data?.code || "unreadable"}); nothing was torn down` }), opts, got.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
established = stored.reap_disposition ?? null;
|
|
1760
|
+
} else if (established == null && r.data.never_provisioned === true) {
|
|
1761
|
+
established = "destroy";
|
|
1762
|
+
}
|
|
1763
|
+
if (established != null && !STACK_DISPOSITIONS.includes(established)) {
|
|
1764
|
+
return emitResult(buildReceipt({ command: "done", outcome: "error", lease_id: leaseId, state,
|
|
1765
|
+
error: `the lease's established reap disposition ${JSON.stringify(established)} is unknown — NOT tearing down; the slot stays fenced` }), opts, EXIT.BACKEND);
|
|
1766
|
+
}
|
|
1767
|
+
const disposition = alreadyReaped ? established : (established ?? opts.disposition);
|
|
1709
1768
|
if (opts.localExec && state === "reaping") {
|
|
1710
1769
|
// Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
|
|
1711
1770
|
// unavailable, etc.) must NOT finalize — finalize frees the slot lock and promotes
|
|
1712
1771
|
// the next queued lease, which would collide with containers still running on this
|
|
1713
1772
|
// slot (Codex P1). Leave the lease in `reaping` (slot stays fenced) for a retry /
|
|
1714
1773
|
// the reaper. Fail with a non-zero exit so the caller knows teardown is incomplete.
|
|
1715
|
-
if (!localTeardownFn(teardownDir, dockerTarget)) {
|
|
1774
|
+
if (!localTeardownFn(teardownDir, dockerTarget, disposition)) {
|
|
1716
1775
|
return emitResult(buildReceipt({
|
|
1717
1776
|
command: "done", outcome: "error", lease_id: leaseId, state,
|
|
1718
1777
|
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.",
|
|
@@ -1722,7 +1781,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1722
1781
|
if (fin.ok && fin.data?.success) state = fin.data.state;
|
|
1723
1782
|
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
1724
1783
|
}
|
|
1725
|
-
return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition
|
|
1784
|
+
return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition,
|
|
1726
1785
|
...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
|
|
1727
1786
|
}
|
|
1728
1787
|
|