@botbuddy/cli 1.60.4 → 1.60.6

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.
@@ -0,0 +1,371 @@
1
+ // BOT-2084: `bb pw --environment staging <lane> auth --persona <alias>` — install
2
+ // one signed-in session from the human-installed staging session pool
3
+ // (cli/src/staging-pool, lifted from Supply Guard's ENT-5120 pool) into the
4
+ // claimed lane's browser.
5
+ //
6
+ // * The pool claims one slot by atomic rename and re-verifies it through the
7
+ // refresh grant (aal2, same persona); no two lanes ever hold one session.
8
+ // * The lane's Playwright daemon reads the claimed slot file itself and writes
9
+ // the session into the bound origin's storage, then the slot is deleted
10
+ // whatever happens. The session never passes through this process's output,
11
+ // argv, the run-code source or the ledger.
12
+ // * Close discards the browser's copy with no sign-out (as Supply Guard does);
13
+ // see docs/agent-staging-acceptance-mfa.md for the residual window. A crashed
14
+ // browser took its copy with it; a lost lane lock clears what is left, and a
15
+ // clear that fails blocks the lane until it is resolved.
16
+ // * The ledger (~/.botbuddy/pw-staging-auth/<host>/lane-<n>.json, 0600) holds
17
+ // non-secret install records only.
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+
22
+ export const SOURCE_NAME = "staging-session-pool";
23
+
24
+ // Every refusal's text (what happened, cause, who acts, next command, docs) comes
25
+ // from the shared registry, cli/src/staging-pool/errors.mjs (`bb help symptom <code>`).
26
+ import { formatGuidance, guidance, isKnownCode, remedyLine } from "../staging-pool/errors.mjs";
27
+
28
+ // ---------------------------------------------------------------- redaction
29
+ // Defense in depth: nothing this command writes may look like a bearer. The
30
+ // daemon never returns one; this guard turns a regression into a refusal.
31
+ const JWT_RE = /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/;
32
+ const SECRET_KEY_RE = /"(access_token|refresh_token|password|totp_secret|otp|code_verifier)"\s*:/i;
33
+ export function containsBearerMaterial(text) { const value = String(text ?? ""); return JWT_RE.test(value) || SECRET_KEY_RE.test(value); }
34
+ function safeWrite(stream, text) { stream.write(containsBearerMaterial(text) ? "bb-pw: auth output withheld (bearer-shaped content detected).\n" : text); }
35
+
36
+ // ---------------------------------------------------------------- ledger
37
+ // Non-secret install records only — never a token or slot path. One file per
38
+ // host+lane, mode 0600 in a 0700 directory, replaced atomically.
39
+ export const stagingAuthDir = (env = process.env) => env.BB_PW_STAGING_AUTH_DIR || join(homedir(), ".botbuddy", "pw-staging-auth");
40
+ const safeSegment = (value) => String(value).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "_";
41
+ export const ledgerPath = (env, host, slot) => join(stagingAuthDir(env), safeSegment(host), `lane-${safeSegment(slot)}.json`);
42
+ const REF_RE = /^[0-9a-f]{12}$/;
43
+ export function readLedger(env, host, slot) {
44
+ const file = ledgerPath(env, host, slot);
45
+ if (!existsSync(file)) return { version: 2, entries: [] };
46
+ try {
47
+ const data = JSON.parse(readFileSync(file, "utf8"));
48
+ return { version: 2, entries: Array.isArray(data?.entries) ? data.entries.filter((entry) => REF_RE.test(String(entry?.session_ref ?? ""))) : [] };
49
+ } catch {
50
+ return { version: 2, entries: [] };
51
+ }
52
+ }
53
+ export function writeLedger(env, host, slot, entries) {
54
+ const file = ledgerPath(env, host, slot);
55
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
56
+ try { chmodSync(dirname(file), 0o700); } catch { /* best effort */ }
57
+ const clean = entries.map((entry) => ({
58
+ session_ref: entry.session_ref, persona: entry.persona, generation: entry.generation ?? null, installed_at: entry.installed_at,
59
+ holder: entry.holder ?? null,
60
+ origin: entry.origin ?? null, storage_key: entry.storage_key ?? null,
61
+ access_expires_at: entry.access_expires_at ?? null, ...(entry.clear_pending === true ? { clear_pending: true } : {}),
62
+ }));
63
+ const tmp = `${file}.${process.pid}.tmp`;
64
+ writeFileSync(tmp, JSON.stringify({ version: 2, entries: clean }, null, 2) + "\n", { mode: 0o600 });
65
+ renameSync(tmp, file);
66
+ }
67
+
68
+ // ---------------------------------------------------------------- daemon code
69
+ const j = (value) => JSON.stringify(value);
70
+ const realm = 'const G = page.constructor.constructor("return globalThis")();';
71
+
72
+ // Runs inside the lane's Playwright daemon: read the claimed slot's state file
73
+ // (a Playwright storage state holding exactly the bound origin + one supabase-js
74
+ // key), check it is the persona's unexpired aal2 session, write it to the origin's
75
+ // localStorage and reload the lane's pages on it. Returns no session material.
76
+ export function installCode({ statePath, origin, storageKey, email }) {
77
+ const args = { statePath, origin, storageKey, email };
78
+ return `async (page) => {
79
+ ${realm}
80
+ const args = ${j(args)};
81
+ const fs = typeof G.process.getBuiltinModule === "function" ? G.process.getBuiltinModule("node:fs") : null;
82
+ if (!fs) return { ok: false, code: "daemon_node_unsupported" };
83
+ let value;
84
+ let claims;
85
+ try {
86
+ const state = JSON.parse(fs.readFileSync(args.statePath, "utf8"));
87
+ const origins = Array.isArray(state.origins) ? state.origins : [];
88
+ if ((state.cookies ?? []).length !== 0 || origins.length !== 1 || origins[0].origin !== args.origin) return { ok: false, code: "slot_state_invalid" };
89
+ const items = origins[0].localStorage ?? [];
90
+ if (items.length !== 1 || items[0].name !== args.storageKey) return { ok: false, code: "slot_state_invalid" };
91
+ value = items[0].value;
92
+ const session = JSON.parse(value);
93
+ const segment = String(session.access_token ?? "").split(".")[1] ?? "";
94
+ claims = JSON.parse(G.Buffer.from(segment, "base64url").toString("utf8"));
95
+ if (typeof session.refresh_token !== "string" || !session.refresh_token) return { ok: false, code: "slot_state_invalid" };
96
+ } catch {
97
+ return { ok: false, code: "slot_state_unreadable" };
98
+ }
99
+ if (claims.aal !== "aal2") return { ok: false, code: "session_not_aal2" };
100
+ if (String(claims.email ?? "").toLowerCase() !== args.email) return { ok: false, code: "session_persona_mismatch" };
101
+ if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now() + 60000) return { ok: false, code: "session_expired" };
102
+ try {
103
+ const sameOrigin = (url) => { try { return new G.URL(url).origin === args.origin; } catch { return false; } };
104
+ if (!sameOrigin(page.url())) await page.goto(args.origin + "/", { waitUntil: "domcontentloaded" });
105
+ await page.evaluate(([name, item]) => { window.localStorage.setItem(name, item); }, [args.storageKey, value]);
106
+ value = null;
107
+ const ctx = page.context();
108
+ for (const other of ctx.pages()) if (sameOrigin(other.url())) await other.reload({ waitUntil: "domcontentloaded" });
109
+ return { ok: true, path: new G.URL(page.url()).pathname, access_expires_at: new Date(claims.exp * 1000).toISOString() };
110
+ } catch {
111
+ return { ok: false, code: "session_install_failed" };
112
+ }
113
+ }`;
114
+ }
115
+
116
+ // Local storage outlives navigation, so clearing must not depend on a lane tab
117
+ // still being on the origin. A temporary page is pointed at the origin with every
118
+ // request fulfilled by a blank document (the app never runs, so it cannot write
119
+ // the session back), the key is removed there, and lane tabs still on the origin
120
+ // are reloaded (or closed) so the app drops its in-memory copy.
121
+ export function clearCode({ origin, storageKey }) {
122
+ const args = { origin, storageKey };
123
+ return `async (page) => {
124
+ ${realm}
125
+ const args = ${j(args)};
126
+ const ctx = page.context();
127
+ const onOrigin = (candidate) => { try { return new G.URL(candidate.url()).origin === args.origin; } catch { return false; } };
128
+ const lanePages = ctx.pages().filter(onOrigin);
129
+ const scratch = await ctx.newPage();
130
+ try {
131
+ await scratch.route("**/*", (route) => route.fulfill({ status: 200, contentType: "text/html", body: "<!doctype html><title>bb-pw cleanup</title>" }));
132
+ await scratch.goto(args.origin + "/", { waitUntil: "domcontentloaded" });
133
+ await scratch.evaluate((name) => { window.localStorage.removeItem(name); }, args.storageKey);
134
+ } finally {
135
+ await scratch.close();
136
+ }
137
+ // Removing the key clears storage, but a tab still on the origin keeps the
138
+ // app's in-memory session until it reloads. A tab that cannot reload is closed;
139
+ // one that can neither reload nor close leaves the cleanup unresolved.
140
+ let reloaded = 0;
141
+ let closed = 0;
142
+ let stuck = 0;
143
+ for (const other of lanePages) {
144
+ try {
145
+ await other.reload({ waitUntil: "domcontentloaded" });
146
+ reloaded++;
147
+ } catch {
148
+ try { await other.close(); closed++; } catch { stuck++; }
149
+ }
150
+ }
151
+ return { ok: stuck === 0, reloaded, closed, stuck };
152
+ }`;
153
+ }
154
+
155
+ // `run-code` replies "### Result\n<JSON>\n…"; only that line is read, never echoed.
156
+ export function parseRunCodeResult(reply) {
157
+ if (!reply?.ok) return { ok: false, code: "daemon_run_failed" };
158
+ const match = /### Result\n([^\n]*)/.exec(String(reply.text ?? ""));
159
+ if (!match) return { ok: false, code: "daemon_result_missing" };
160
+ try {
161
+ const value = JSON.parse(match[1]);
162
+ return value && typeof value === "object" ? value : { ok: false, code: "daemon_result_invalid" };
163
+ } catch {
164
+ return { ok: false, code: "daemon_result_invalid" };
165
+ }
166
+ }
167
+
168
+ // One browser launch. The pinned @playwright/cli derives the socket path from
169
+ // the workspace and session name, so a restarted daemon reuses it; the session
170
+ // record's launch `timestamp` is what changes.
171
+ export const generationOf = (session) => session ? `${String(session.socketPath ?? "")}#${String(session.timestamp ?? "")}` : null;
172
+
173
+ export const isPersistentLane = (session) => session?.cli?.persistent === true ||
174
+ (typeof session?.browser?.userDataDir === "string" && session.browser.userDataDir.length > 0);
175
+
176
+ // ---------------------------------------------------------------- lifecycle
177
+ function context({ plan, host, env, socketRun, target }) {
178
+ const run = async (code) => parseRunCodeResult(await socketRun({ session: plan.session, socketArgs: ["run-code", code] }, env));
179
+ return { run, env, host, slot: plan.lane, target };
180
+ }
181
+
182
+ // The bound origin/storage key for cleanup is recorded on each ledger entry, so a
183
+ // cleanup never needs the repo binding (it may run from another directory).
184
+ const targetOf = (entry) => entry?.origin && entry?.storage_key ? { origin: entry.origin, storageKey: entry.storage_key } : null;
185
+
186
+ // Clear sessions from the lane's live browser, or record that they may still be
187
+ // there. A browser holds one session key per origin+storage key, so entries are
188
+ // grouped by that target and each target is cleared once. Only same-generation
189
+ // entries chosen by `select` are touched: other generations lived in a browser
190
+ // that is gone, and other holders' installs are not this caller's to clear.
191
+ const targetKey = (target) => `${target.origin}\n${target.storageKey}`;
192
+ async function clearBrowserOrMark(ctx, generation, select = () => true) {
193
+ const entries = readLedger(ctx.env, ctx.host, ctx.slot).entries;
194
+ const chosen = new Set(entries.filter((entry) => generation && entry.generation === generation && select(entry)));
195
+ if (!chosen.size) return true;
196
+ const cleared = new Map();
197
+ for (const entry of chosen) {
198
+ const target = targetOf(entry) ?? ctx.target;
199
+ if (!target || cleared.has(targetKey(target))) continue;
200
+ cleared.set(targetKey(target), (await ctx.run(clearCode(target)).catch(() => ({ ok: false }))).ok === true);
201
+ }
202
+ let all = true;
203
+ writeLedger(ctx.env, ctx.host, ctx.slot, entries.flatMap((entry) => {
204
+ if (!chosen.has(entry)) return [entry];
205
+ const target = targetOf(entry) ?? ctx.target;
206
+ if (target && cleared.get(targetKey(target)) === true) return [];
207
+ all = false;
208
+ return [{ ...entry, clear_pending: true }];
209
+ }));
210
+ return all;
211
+ }
212
+
213
+ // Drop entries whose browser is gone (crash, close, new launch).
214
+ function dropGone(ctx, generation) {
215
+ const entries = readLedger(ctx.env, ctx.host, ctx.slot).entries;
216
+ const kept = entries.filter((entry) => generation && entry.generation === generation);
217
+ const dropped = entries.length - kept.length;
218
+ if (dropped) writeLedger(ctx.env, ctx.host, ctx.slot, kept);
219
+ return dropped;
220
+ }
221
+
222
+ function receipt(stdout, body) { safeWrite(stdout, `${JSON.stringify({ staging_auth: body }, null, 2)}\n`); }
223
+
224
+ const GUIDED_FIELDS = ["message", "cause", "actor", "next", "doc", "help", "agentAction", "remedy"];
225
+
226
+ /**
227
+ * Refuse with self-guiding text: the receipt (stdout) and the stderr block carry
228
+ * what happened, the likely cause, who acts, the exact next command and the doc.
229
+ * `extra` fields already rendered by the pool (an empty pool's health reason) win.
230
+ * `vars` are non-secret only: lane, persona alias, allowed aliases, checkout.
231
+ */
232
+ export function refuse({ stdout, stderr }, code, exitCode = 3, extra = {}, vars = {}) {
233
+ const g = guidance(isKnownCode(code) ? code : "pool_take_failed", vars);
234
+ const guided = { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help, agentAction: g.agent_action, remedy: remedyLine(g) };
235
+ for (const field of GUIDED_FIELDS) if (extra[field] !== undefined) guided[field] = extra[field];
236
+ const text = {
237
+ ...g, code: extra.reason && extra.reason !== code ? extra.reason : code,
238
+ message: guided.message, cause: guided.cause, next: guided.next, doc: guided.doc, help: guided.help,
239
+ actor_label: guided.actor, actor: guided.actor === g.actor_label ? g.actor : (/^Human/.test(guided.actor) ? "human" : "agent"),
240
+ };
241
+ safeWrite(stderr, `${formatGuidance(text, { prefix: "bb pw auth refused" })}\n`);
242
+ receipt(stdout, { ok: false, code, ...guided, source: SOURCE_NAME, ...Object.fromEntries(Object.entries(extra).filter(([key]) => !GUIDED_FIELDS.includes(key))) });
243
+ return exitCode;
244
+ }
245
+
246
+ async function loadPool() { return import("../staging-pool/cli.mjs"); }
247
+ async function loadBinding() { return import("../staging-pool/binding.mjs"); }
248
+
249
+ /**
250
+ * `auth --persona <alias>` after the wrapper has proven: environment=staging, a
251
+ * lock-gated lane held by this exact agent, and a staging (or untracked) browser
252
+ * session. Returns the process exit code.
253
+ */
254
+ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolveSession, stdout, stderr, holder = null }) {
255
+ const io = { stdout, stderr };
256
+ const poolModule = deps.stagingPoolModule ?? await loadPool();
257
+ const poolOptions = deps.stagingPool ?? {};
258
+ const cwd = deps.cwd ?? process.cwd();
259
+ const vars = { lane: plan.lane, persona: plan.persona, checkout: cwd };
260
+ let found;
261
+ try {
262
+ found = await (await loadBinding()).readRepoPoolBinding(cwd);
263
+ } catch (error) {
264
+ return refuse(io, error?.code === "pool_binding_invalid" ? "pool_binding_invalid" : "pool_not_configured", 3, { detail: String(error?.message ?? error) }, vars);
265
+ }
266
+ if (!found) return refuse(io, "pool_not_configured", 3, {}, vars);
267
+ Object.assign(vars, { checkout: found.repoRoot ?? cwd, allowed: Object.keys(found.binding.personas ?? {}) });
268
+ const { findPersona } = await loadBinding();
269
+ const persona = findPersona(found.binding, plan.persona);
270
+ if (!persona) return refuse(io, "persona_not_allowlisted", 2, {}, vars);
271
+ const { stagingTargetFromBinding } = await import("../staging-pool/policy.mjs");
272
+ const bound = stagingTargetFromBinding(found.binding);
273
+ // deps.stagingAuthTarget is a test-only seam (a loopback page for the real-daemon
274
+ // test); no flag or environment variable can redirect the install origin.
275
+ const target = deps.stagingAuthTarget ?? { origin: bound.appOrigin, storageKey: bound.storageKey };
276
+ const { session } = await resolveSession(plan.session, env);
277
+ if (!session) return refuse(io, "lane_browser_not_open", 2, {}, vars);
278
+ if (isPersistentLane(session)) return refuse(io, "persistent_profile_refused", 3, {}, vars);
279
+ const generation = generationOf(session);
280
+ const ctx = context({ plan, host, env, socketRun, target });
281
+ dropGone(ctx, generation);
282
+ let claim;
283
+ try {
284
+ claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, cwd, ...poolOptions });
285
+ } catch (error) {
286
+ if (error?.code === "pool_empty") {
287
+ return refuse(io, "pool_empty", 3, Object.fromEntries(["reason", "detail", ...GUIDED_FIELDS].filter((key) => error[key] !== undefined).map((key) => [key, error[key]])), vars);
288
+ }
289
+ const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "pool_take_failed";
290
+ return refuse(io, code, 3, { detail: String(error?.message ?? error).slice(0, 300) }, vars);
291
+ }
292
+ let installed;
293
+ try {
294
+ installed = await ctx.run(installCode({ statePath: claim.taken.statePath, origin: target.origin, storageKey: target.storageKey, email: persona.email }));
295
+ } finally {
296
+ // Single use either way: the slot never outlives this command.
297
+ await claim.discard().catch(() => {});
298
+ }
299
+ const sessionRef = claim.taken.meta.sessionRef;
300
+ if (!installed.ok) {
301
+ // The install may have written storage before failing: clear that target
302
+ // (its key held at most one session, so any earlier entry for it goes too).
303
+ const entries = readLedger(env, host, plan.lane).entries;
304
+ writeLedger(env, host, plan.lane, [...entries, { session_ref: sessionRef, persona: persona.alias, generation, installed_at: new Date().toISOString(), origin: target.origin, storage_key: target.storageKey, holder }]);
305
+ const cleared = await clearBrowserOrMark(ctx, generation, (entry) => entry.origin === target.origin && entry.storage_key === target.storageKey);
306
+ return refuse(io, isKnownCode(installed.code) ? installed.code : "session_install_failed", 3, { cleared }, vars);
307
+ }
308
+ // Re-auth replaces the previous session under the same key: one entry per
309
+ // browser and target. Sessions for other origins in this browser stay tracked.
310
+ const others = readLedger(env, host, plan.lane).entries.filter((entry) => entry.generation === generation && !(entry.origin === target.origin && entry.storage_key === target.storageKey));
311
+ writeLedger(env, host, plan.lane, [...others, { session_ref: sessionRef, persona: persona.alias, generation, installed_at: new Date().toISOString(), origin: target.origin, storage_key: target.storageKey, access_expires_at: installed.access_expires_at, holder }]);
312
+ receipt(stdout, {
313
+ ok: true, source: SOURCE_NAME, environment: "staging", origin: target.origin, persona: persona.alias,
314
+ lane: plan.lane, host, aal: claim.taken.meta.aal, session_ref: sessionRef, access_expires_at: installed.access_expires_at,
315
+ path: installed.path, slot: "deleted after install",
316
+ on_close: "browser closed, no sign-out (the refresh lineage stays valid server-side; see docs/agent-staging-acceptance-mfa.md)",
317
+ ledger: ledgerPath(env, host, plan.lane),
318
+ });
319
+ return 0;
320
+ }
321
+
322
+ /**
323
+ * Before any other gate-passed lane command: drop entries whose browser is gone,
324
+ * settle a pending clear, and refuse while a same-browser clear is unresolved.
325
+ * Returns null to proceed, or an exit code to stop the command.
326
+ */
327
+ export async function beforeLaneCommand({ plan, host, env, socketRun, resolveSession, stdout, stderr }) {
328
+ if (!readLedger(env, host, plan.lane).entries.length) return null;
329
+ const { session } = await resolveSession(plan.session, env).catch(() => ({ session: null }));
330
+ const generation = generationOf(session);
331
+ const ctx = context({ plan, host, env, socketRun, target: null });
332
+ dropGone(ctx, generation);
333
+ const pending = readLedger(env, host, plan.lane).entries.some((entry) => entry.clear_pending === true);
334
+ if (!pending) return null;
335
+ if (await clearBrowserOrMark(ctx, generation, (entry) => entry.clear_pending === true)) return null;
336
+ if (plan.verb === "close") return null;
337
+ return refuse({ stdout, stderr }, "staging_session_cleanup_pending", 3, { lane: plan.lane, host }, { lane: plan.lane });
338
+ }
339
+
340
+ /** After `close` (successful or not): a browser that survived is cleared, or its cleanup kept. */
341
+ export async function afterLaneClose({ plan, host, env, socketRun, resolveSession, stderr }) {
342
+ if (!readLedger(env, host, plan.lane).entries.length) return;
343
+ const { session } = await resolveSession(plan.session, env).catch(() => ({ session: null }));
344
+ const generation = generationOf(session);
345
+ const ctx = context({ plan, host, env, socketRun, target: null });
346
+ const cleared = await clearBrowserOrMark(ctx, generation);
347
+ dropGone(ctx, generation);
348
+ safeWrite(stderr, `bb-pw: staging auth cleanup ${JSON.stringify({ browser: generation ? (cleared ? "cleared" : "clear_pending") : "closed", sign_out: "none" })}\n`);
349
+ }
350
+
351
+ /**
352
+ * The lane lock is provably no longer this agent's: clear the sessions THIS
353
+ * caller installed from the same browser. A session the current holder installed
354
+ * is not the stale caller's to clear (an entry with no recorded holder is).
355
+ */
356
+ export async function onLaneAuthorityLost({ plan, host, env, socketRun, resolveSession, stderr, callerIds = [], resolveCallerId = null }) {
357
+ const recorded = readLedger(env, host, plan.lane).entries;
358
+ if (!recorded.length) return;
359
+ // A token-only caller has no local agent id; resolve it only when an entry needs it.
360
+ if (resolveCallerId && recorded.some((entry) => entry.holder && !callerIds.includes(entry.holder))) {
361
+ const me = await resolveCallerId().catch(() => null);
362
+ if (me) callerIds = [...callerIds, me];
363
+ }
364
+ const { session } = await resolveSession(plan.session, env).catch(() => ({ session: null }));
365
+ const generation = generationOf(session);
366
+ const ctx = context({ plan, host, env, socketRun, target: null });
367
+ dropGone(ctx, generation);
368
+ const mine = (entry) => !entry.holder || callerIds.includes(entry.holder);
369
+ const cleared = await clearBrowserOrMark(ctx, generation, mine);
370
+ safeWrite(stderr, `bb-pw: lane authority lost; staging auth cleanup ${JSON.stringify({ browser: generation ? (cleared ? "cleared" : "clear_pending") : "closed" })}\n`);
371
+ }
@@ -0,0 +1,122 @@
1
+ // BOT-2084: the per-repo staging session pool binding.
2
+ //
3
+ // A repository opts in by declaring `staging_session_pool` in its committed
4
+ // `.botbuddy-agent.json` (next to its tenant binding). This module parses and
5
+ // validates that section into the camelCase shape the pool uses. Node-stdlib-only
6
+ // and self-contained: the installed job keeps a validated snapshot in its config.
7
+
8
+ import { readFile } from "node:fs/promises";
9
+ import { dirname, join, parse } from "node:path";
10
+
11
+ export const AGENT_BINDING_FILE = ".botbuddy-agent.json";
12
+
13
+ export class BindingError extends Error {
14
+ constructor(message, code = "pool_binding_invalid") {
15
+ super(message);
16
+ this.name = "BindingError";
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
22
+ const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
23
+ const EMAIL_RE = /^[a-z0-9._+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
24
+ const ENV_RE = /^[A-Z][A-Z0-9_]*$/;
25
+ const LABEL_RE = /^[a-z0-9][a-z0-9.-]{2,127}$/;
26
+ const STORAGE_KEY_RE = /^sb-[A-Za-z0-9-]{1,80}-auth-token$/;
27
+ // Supabase publishable keys and legacy anon JWTs are public, browser-shipped values.
28
+ const PUBLIC_KEY_RE = /^(sb_publishable_[A-Za-z0-9_-]{16,128}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)$/;
29
+ export const ADAPTERS = Object.freeze(["supabase-gotrue-totp"]);
30
+ export const MAX_POOL_SIZE = 4;
31
+
32
+ function need(condition, message) {
33
+ if (!condition) throw new BindingError(`${AGENT_BINDING_FILE} staging_session_pool: ${message}`);
34
+ }
35
+
36
+ // raw: the parsed .botbuddy-agent.json object. Returns null when the repo does not
37
+ // opt in; throws BindingError on a present but invalid section (fail closed).
38
+ export function parsePoolBinding(raw) {
39
+ const section = raw?.staging_session_pool;
40
+ if (section === undefined || section === null) return null;
41
+ need(typeof section === "object" && !Array.isArray(section), "must be an object");
42
+ need(section.schema_version === 1, "schema_version must be 1");
43
+ need(typeof raw.tenant === "string" && SLUG_RE.test(raw.tenant), "the binding's tenant slug is required (it pins get_totp_code)");
44
+ need(typeof section.pool_id === "string" && SLUG_RE.test(section.pool_id), "pool_id must be a lowercase slug");
45
+ need(section.environment === "staging", 'environment must be "staging"');
46
+ need(typeof section.app_slug === "string" && SLUG_RE.test(section.app_slug), "app_slug must be a lowercase slug");
47
+ need(ADAPTERS.includes(section.adapter), `adapter must be one of ${ADAPTERS.join(", ")}`);
48
+ need(typeof section.app_origin === "string", "app_origin is required");
49
+ need(typeof section.supabase_url === "string", "supabase_url is required");
50
+ need(typeof section.publishable_key === "string" && PUBLIC_KEY_RE.test(section.publishable_key), "publishable_key must be the public publishable/anon key");
51
+ need(typeof section.storage_key === "string" && STORAGE_KEY_RE.test(section.storage_key), "storage_key must be the app's supabase-js auth storage key");
52
+ need(Array.isArray(section.production_origins ?? []), "production_origins must be an array");
53
+ need(typeof section.password_env_file === "string" && /^(~\/|\/)/.test(section.password_env_file), "password_env_file must be an absolute or ~/ path");
54
+ need(typeof section.launchd_label === "string" && LABEL_RE.test(section.launchd_label), "launchd_label must be a reverse-DNS label");
55
+ need(typeof section.docs === "string" && section.docs.length > 0, "docs (where the pool is documented) is required");
56
+ need(typeof section.install_command === "string" && section.install_command.length > 0, "install_command (the human's install line) is required");
57
+ const personas = section.personas;
58
+ need(personas && typeof personas === "object" && Object.keys(personas).length > 0, "personas must name at least one persona");
59
+ const parsedPersonas = {};
60
+ const emails = new Set();
61
+ for (const [alias, persona] of Object.entries(personas)) {
62
+ need(ALIAS_RE.test(alias), `persona alias "${alias}" must be a short lowercase slug`);
63
+ need(persona && typeof persona.email === "string" && EMAIL_RE.test(persona.email), `persona ${alias} needs a seeded email`);
64
+ need(/\.(local|test)$/i.test(persona.email), `persona ${alias} must use a non-routable .local/.test seeded email`);
65
+ need(!emails.has(persona.email.toLowerCase()), `persona email ${persona.email} is listed twice`);
66
+ emails.add(persona.email.toLowerCase());
67
+ need(typeof persona.password_env === "string" && ENV_RE.test(persona.password_env), `persona ${alias} needs password_env (an UPPER_SNAKE env var name in password_env_file)`);
68
+ const count = persona.default_count ?? 1;
69
+ need(Number.isInteger(count) && count >= 0 && count <= MAX_POOL_SIZE, `persona ${alias} default_count must be 0-${MAX_POOL_SIZE}`);
70
+ parsedPersonas[alias] = Object.freeze({ alias, email: persona.email, passwordEnv: persona.password_env, defaultCount: count });
71
+ }
72
+ return Object.freeze({
73
+ tenant: raw.tenant,
74
+ poolId: section.pool_id,
75
+ environment: section.environment,
76
+ appSlug: section.app_slug,
77
+ adapter: section.adapter,
78
+ appOrigin: section.app_origin,
79
+ supabaseUrl: section.supabase_url,
80
+ publishableKey: section.publishable_key,
81
+ storageKey: section.storage_key,
82
+ productionOrigins: Object.freeze([...(section.production_origins ?? [])].map(String)),
83
+ passwordEnvFile: section.password_env_file,
84
+ personas: Object.freeze(parsedPersonas),
85
+ launchdLabel: section.launchd_label,
86
+ docs: section.docs,
87
+ installCommand: section.install_command,
88
+ });
89
+ }
90
+
91
+ // alias or email → persona entry; null when not allowlisted.
92
+ export function findPersona(binding, aliasOrEmail) {
93
+ const key = String(aliasOrEmail ?? "");
94
+ if (Object.hasOwn(binding.personas, key)) return binding.personas[key];
95
+ return Object.values(binding.personas).find((persona) => persona.email === key) ?? null;
96
+ }
97
+
98
+ // Walk up from cwd to the committed binding file; null when none or no section.
99
+ export async function readRepoPoolBinding(cwd = process.cwd(), { read = readFile } = {}) {
100
+ let dir = cwd;
101
+ const root = parse(dir).root;
102
+ while (true) {
103
+ let text = null;
104
+ try {
105
+ text = await read(join(dir, AGENT_BINDING_FILE), "utf8");
106
+ } catch (error) {
107
+ if (error?.code !== "ENOENT") throw error;
108
+ }
109
+ if (text !== null) {
110
+ let raw;
111
+ try {
112
+ raw = JSON.parse(text);
113
+ } catch {
114
+ throw new BindingError(`${AGENT_BINDING_FILE} is not valid JSON`);
115
+ }
116
+ const binding = parsePoolBinding(raw);
117
+ return binding ? { binding, repoRoot: dir } : null;
118
+ }
119
+ if (dir === root) return null;
120
+ dir = dirname(dir);
121
+ }
122
+ }