@botbuddy/cli 1.60.5 → 1.60.7

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,393 @@
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 { execFileSync } from "node:child_process";
19
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+
23
+ export const SOURCE_NAME = "staging-session-pool";
24
+
25
+ // Every refusal's text (what happened, cause, who acts, next command, docs) comes
26
+ // from the shared registry, cli/src/staging-pool/errors.mjs (`bb help symptom <code>`).
27
+ import { formatGuidance, guidance, isKnownCode, remedyLine } from "../staging-pool/errors.mjs";
28
+
29
+ // ---------------------------------------------------------------- redaction
30
+ // Defense in depth: nothing this command writes may look like a bearer. The
31
+ // daemon never returns one; this guard turns a regression into a refusal.
32
+ const JWT_RE = /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/;
33
+ const SECRET_KEY_RE = /"(access_token|refresh_token|password|totp_secret|otp|code_verifier)"\s*:/i;
34
+ export function containsBearerMaterial(text) { const value = String(text ?? ""); return JWT_RE.test(value) || SECRET_KEY_RE.test(value); }
35
+ function safeWrite(stream, text) { stream.write(containsBearerMaterial(text) ? "bb-pw: auth output withheld (bearer-shaped content detected).\n" : text); }
36
+
37
+ // ---------------------------------------------------------------- ledger
38
+ // Non-secret install records only — never a token or slot path. One file per
39
+ // host+lane, mode 0600 in a 0700 directory, replaced atomically.
40
+ export const stagingAuthDir = (env = process.env) => env.BB_PW_STAGING_AUTH_DIR || join(homedir(), ".botbuddy", "pw-staging-auth");
41
+ const safeSegment = (value) => String(value).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "_";
42
+ export const ledgerPath = (env, host, slot) => join(stagingAuthDir(env), safeSegment(host), `lane-${safeSegment(slot)}.json`);
43
+ const REF_RE = /^[0-9a-f]{12}$/;
44
+ export function readLedger(env, host, slot) {
45
+ const file = ledgerPath(env, host, slot);
46
+ if (!existsSync(file)) return { version: 2, entries: [] };
47
+ try {
48
+ const data = JSON.parse(readFileSync(file, "utf8"));
49
+ return { version: 2, entries: Array.isArray(data?.entries) ? data.entries.filter((entry) => REF_RE.test(String(entry?.session_ref ?? ""))) : [] };
50
+ } catch {
51
+ return { version: 2, entries: [] };
52
+ }
53
+ }
54
+ export function writeLedger(env, host, slot, entries) {
55
+ const file = ledgerPath(env, host, slot);
56
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
57
+ try { chmodSync(dirname(file), 0o700); } catch { /* best effort */ }
58
+ const clean = entries.map((entry) => ({
59
+ session_ref: entry.session_ref, persona: entry.persona, generation: entry.generation ?? null, installed_at: entry.installed_at,
60
+ holder: entry.holder ?? null,
61
+ origin: entry.origin ?? null, storage_key: entry.storage_key ?? null,
62
+ access_expires_at: entry.access_expires_at ?? null, ...(entry.clear_pending === true ? { clear_pending: true } : {}),
63
+ }));
64
+ const tmp = `${file}.${process.pid}.tmp`;
65
+ writeFileSync(tmp, JSON.stringify({ version: 2, entries: clean }, null, 2) + "\n", { mode: 0o600 });
66
+ renameSync(tmp, file);
67
+ }
68
+
69
+ // ---------------------------------------------------------------- daemon code
70
+ const j = (value) => JSON.stringify(value);
71
+ const realm = 'const G = page.constructor.constructor("return globalThis")();';
72
+
73
+ // Runs inside the lane's Playwright daemon: read the claimed slot's state file
74
+ // (a Playwright storage state holding exactly the bound origin + one supabase-js
75
+ // key), check it is the persona's unexpired aal2 session, write it to the origin's
76
+ // localStorage and reload the lane's pages on it. Returns no session material.
77
+ export function installCode({ statePath, origin, storageKey, email }) {
78
+ const args = { statePath, origin, storageKey, email };
79
+ return `async (page) => {
80
+ ${realm}
81
+ const args = ${j(args)};
82
+ const fs = typeof G.process.getBuiltinModule === "function" ? G.process.getBuiltinModule("node:fs") : null;
83
+ if (!fs) return { ok: false, code: "daemon_node_unsupported" };
84
+ let value;
85
+ let claims;
86
+ try {
87
+ const state = JSON.parse(fs.readFileSync(args.statePath, "utf8"));
88
+ const origins = Array.isArray(state.origins) ? state.origins : [];
89
+ if ((state.cookies ?? []).length !== 0 || origins.length !== 1 || origins[0].origin !== args.origin) return { ok: false, code: "slot_state_invalid" };
90
+ const items = origins[0].localStorage ?? [];
91
+ if (items.length !== 1 || items[0].name !== args.storageKey) return { ok: false, code: "slot_state_invalid" };
92
+ value = items[0].value;
93
+ const session = JSON.parse(value);
94
+ const segment = String(session.access_token ?? "").split(".")[1] ?? "";
95
+ claims = JSON.parse(G.Buffer.from(segment, "base64url").toString("utf8"));
96
+ if (typeof session.refresh_token !== "string" || !session.refresh_token) return { ok: false, code: "slot_state_invalid" };
97
+ } catch {
98
+ return { ok: false, code: "slot_state_unreadable" };
99
+ }
100
+ if (claims.aal !== "aal2") return { ok: false, code: "session_not_aal2" };
101
+ if (String(claims.email ?? "").toLowerCase() !== args.email) return { ok: false, code: "session_persona_mismatch" };
102
+ if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now() + 60000) return { ok: false, code: "session_expired" };
103
+ try {
104
+ const sameOrigin = (url) => { try { return new G.URL(url).origin === args.origin; } catch { return false; } };
105
+ if (!sameOrigin(page.url())) await page.goto(args.origin + "/", { waitUntil: "domcontentloaded" });
106
+ await page.evaluate(([name, item]) => { window.localStorage.setItem(name, item); }, [args.storageKey, value]);
107
+ value = null;
108
+ const ctx = page.context();
109
+ for (const other of ctx.pages()) if (sameOrigin(other.url())) await other.reload({ waitUntil: "domcontentloaded" });
110
+ return { ok: true, path: new G.URL(page.url()).pathname, access_expires_at: new Date(claims.exp * 1000).toISOString() };
111
+ } catch {
112
+ return { ok: false, code: "session_install_failed" };
113
+ }
114
+ }`;
115
+ }
116
+
117
+ // Local storage outlives navigation, so clearing must not depend on a lane tab
118
+ // still being on the origin. A temporary page is pointed at the origin with every
119
+ // request fulfilled by a blank document (the app never runs, so it cannot write
120
+ // the session back), the key is removed there, and lane tabs still on the origin
121
+ // are reloaded (or closed) so the app drops its in-memory copy.
122
+ export function clearCode({ origin, storageKey }) {
123
+ const args = { origin, storageKey };
124
+ return `async (page) => {
125
+ ${realm}
126
+ const args = ${j(args)};
127
+ const ctx = page.context();
128
+ const onOrigin = (candidate) => { try { return new G.URL(candidate.url()).origin === args.origin; } catch { return false; } };
129
+ const lanePages = ctx.pages().filter(onOrigin);
130
+ const scratch = await ctx.newPage();
131
+ try {
132
+ await scratch.route("**/*", (route) => route.fulfill({ status: 200, contentType: "text/html", body: "<!doctype html><title>bb-pw cleanup</title>" }));
133
+ await scratch.goto(args.origin + "/", { waitUntil: "domcontentloaded" });
134
+ await scratch.evaluate((name) => { window.localStorage.removeItem(name); }, args.storageKey);
135
+ } finally {
136
+ await scratch.close();
137
+ }
138
+ // Removing the key clears storage, but a tab still on the origin keeps the
139
+ // app's in-memory session until it reloads. A tab that cannot reload is closed;
140
+ // one that can neither reload nor close leaves the cleanup unresolved.
141
+ let reloaded = 0;
142
+ let closed = 0;
143
+ let stuck = 0;
144
+ for (const other of lanePages) {
145
+ try {
146
+ await other.reload({ waitUntil: "domcontentloaded" });
147
+ reloaded++;
148
+ } catch {
149
+ try { await other.close(); closed++; } catch { stuck++; }
150
+ }
151
+ }
152
+ return { ok: stuck === 0, reloaded, closed, stuck };
153
+ }`;
154
+ }
155
+
156
+ // `run-code` replies "### Result\n<JSON>\n…"; only that line is read, never echoed.
157
+ export function parseRunCodeResult(reply) {
158
+ if (!reply?.ok) return { ok: false, code: "daemon_run_failed" };
159
+ const match = /### Result\n([^\n]*)/.exec(String(reply.text ?? ""));
160
+ if (!match) return { ok: false, code: "daemon_result_missing" };
161
+ try {
162
+ const value = JSON.parse(match[1]);
163
+ return value && typeof value === "object" ? value : { ok: false, code: "daemon_result_invalid" };
164
+ } catch {
165
+ return { ok: false, code: "daemon_result_invalid" };
166
+ }
167
+ }
168
+
169
+ // One browser launch. The pinned @playwright/cli derives the socket path from
170
+ // the workspace and session name, so a restarted daemon reuses it; the session
171
+ // record's launch `timestamp` is what changes.
172
+ export const generationOf = (session) => session ? `${String(session.socketPath ?? "")}#${String(session.timestamp ?? "")}` : null;
173
+
174
+ export const isPersistentLane = (session) => session?.cli?.persistent === true ||
175
+ (typeof session?.browser?.userDataDir === "string" && session.browser.userDataDir.length > 0);
176
+
177
+ // ---------------------------------------------------------------- lifecycle
178
+ function context({ plan, host, env, socketRun, target }) {
179
+ const run = async (code) => parseRunCodeResult(await socketRun({ session: plan.session, socketArgs: ["run-code", code] }, env));
180
+ return { run, env, host, slot: plan.lane, target };
181
+ }
182
+
183
+ // The bound origin/storage key for cleanup is recorded on each ledger entry, so a
184
+ // cleanup never needs the repo binding (it may run from another directory).
185
+ const targetOf = (entry) => entry?.origin && entry?.storage_key ? { origin: entry.origin, storageKey: entry.storage_key } : null;
186
+
187
+ // Clear sessions from the lane's live browser, or record that they may still be
188
+ // there. A browser holds one session key per origin+storage key, so entries are
189
+ // grouped by that target and each target is cleared once. Only same-generation
190
+ // entries chosen by `select` are touched: other generations lived in a browser
191
+ // that is gone, and other holders' installs are not this caller's to clear.
192
+ const targetKey = (target) => `${target.origin}\n${target.storageKey}`;
193
+ async function clearBrowserOrMark(ctx, generation, select = () => true) {
194
+ const entries = readLedger(ctx.env, ctx.host, ctx.slot).entries;
195
+ const chosen = new Set(entries.filter((entry) => generation && entry.generation === generation && select(entry)));
196
+ if (!chosen.size) return true;
197
+ const cleared = new Map();
198
+ for (const entry of chosen) {
199
+ const target = targetOf(entry) ?? ctx.target;
200
+ if (!target || cleared.has(targetKey(target))) continue;
201
+ cleared.set(targetKey(target), (await ctx.run(clearCode(target)).catch(() => ({ ok: false }))).ok === true);
202
+ }
203
+ let all = true;
204
+ writeLedger(ctx.env, ctx.host, ctx.slot, entries.flatMap((entry) => {
205
+ if (!chosen.has(entry)) return [entry];
206
+ const target = targetOf(entry) ?? ctx.target;
207
+ if (target && cleared.get(targetKey(target)) === true) return [];
208
+ all = false;
209
+ return [{ ...entry, clear_pending: true }];
210
+ }));
211
+ return all;
212
+ }
213
+
214
+ // Drop entries whose browser is gone (crash, close, new launch).
215
+ function dropGone(ctx, generation) {
216
+ const entries = readLedger(ctx.env, ctx.host, ctx.slot).entries;
217
+ const kept = entries.filter((entry) => generation && entry.generation === generation);
218
+ const dropped = entries.length - kept.length;
219
+ if (dropped) writeLedger(ctx.env, ctx.host, ctx.slot, kept);
220
+ return dropped;
221
+ }
222
+
223
+ function receipt(stdout, body) { safeWrite(stdout, `${JSON.stringify({ staging_auth: body }, null, 2)}\n`); }
224
+
225
+ const GUIDED_FIELDS = ["message", "cause", "actor", "next", "doc", "help", "agentAction", "remedy"];
226
+
227
+ /**
228
+ * Refuse with self-guiding text: the receipt (stdout) and the stderr block carry
229
+ * what happened, the likely cause, who acts, the exact next command and the doc.
230
+ * `extra` fields already rendered by the pool (an empty pool's health reason) win.
231
+ * `vars` are non-secret only: lane, persona alias, allowed aliases, checkout.
232
+ */
233
+ /**
234
+ * ` --ticket BOT-123` from the checkout's branch name (`…/bot-123-…`), or "" so a
235
+ * lane-claim command never prints a <ticket> placeholder. Best effort, refusal-only.
236
+ */
237
+ export function ticketFlagFor(cwd, { exec = execFileSync } = {}) {
238
+ try {
239
+ // symbolic-ref also names an unborn branch; a detached HEAD has no ticket.
240
+ const branch = String(exec("git", ["-C", cwd, "symbolic-ref", "--short", "-q", "HEAD"], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] })).trim();
241
+ const match = /(?:^|[/_-])((?:bot|ent)-\d+)(?=$|[/_-])/i.exec(branch);
242
+ return match ? ` --ticket ${match[1].toUpperCase()}` : "";
243
+ } catch {
244
+ return "";
245
+ }
246
+ }
247
+
248
+ export function refuse({ stdout, stderr }, code, exitCode = 3, extra = {}, vars = {}) {
249
+ // A real refusal: never a <placeholder> (AC-8 follow-up).
250
+ const g = guidance(isKnownCode(code) ? code : "pool_take_failed", { consequence: "bb pw auth has no pool to take a session from", ...vars }, { real: true });
251
+ 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) };
252
+ for (const field of GUIDED_FIELDS) if (extra[field] !== undefined) guided[field] = extra[field];
253
+ const text = {
254
+ ...g, code: extra.reason && extra.reason !== code ? extra.reason : code,
255
+ message: guided.message, cause: guided.cause, next: guided.next, doc: guided.doc, help: guided.help,
256
+ actor_label: guided.actor, actor: guided.actor === g.actor_label ? g.actor : (/^Human/.test(guided.actor) ? "human" : "agent"),
257
+ };
258
+ safeWrite(stderr, `${formatGuidance(text, { prefix: "bb pw auth refused" })}\n`);
259
+ receipt(stdout, { ok: false, code, ...guided, source: SOURCE_NAME, ...Object.fromEntries(Object.entries(extra).filter(([key]) => !GUIDED_FIELDS.includes(key))) });
260
+ return exitCode;
261
+ }
262
+
263
+ async function loadPool() { return import("../staging-pool/cli.mjs"); }
264
+ async function loadBinding() { return import("../staging-pool/binding.mjs"); }
265
+
266
+ /**
267
+ * `auth --persona <alias>` after the wrapper has proven: environment=staging, a
268
+ * lock-gated lane held by this exact agent, and a staging (or untracked) browser
269
+ * session. Returns the process exit code.
270
+ */
271
+ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolveSession, stdout, stderr, holder = null }) {
272
+ const io = { stdout, stderr };
273
+ const poolModule = deps.stagingPoolModule ?? await loadPool();
274
+ const poolOptions = deps.stagingPool ?? {};
275
+ const cwd = deps.cwd ?? process.cwd();
276
+ const vars = { lane: plan.lane, persona: plan.persona, cwd, ticketFlag: ticketFlagFor(cwd) };
277
+ let found;
278
+ try {
279
+ found = await (await loadBinding()).readRepoPoolBinding(cwd);
280
+ } catch (error) {
281
+ return refuse(io, error?.code === "pool_binding_invalid" ? "pool_binding_invalid" : "pool_not_configured", 3, { detail: String(error?.message ?? error) }, vars);
282
+ }
283
+ if (!found) {
284
+ // Name a stable checkout that declares a pool, if this Mac has one on record.
285
+ const { findKnownCheckouts } = await import("../staging-pool/checkouts.mjs");
286
+ const [candidate] = await findKnownCheckouts({ ...(poolOptions.home ? { home: poolOptions.home } : {}), ...(poolOptions.isStableCheckout ? { isStable: poolOptions.isStableCheckout } : {}) }).catch(() => []);
287
+ return refuse(io, "pool_not_configured", 3, {}, { ...vars, checkout: candidate?.path });
288
+ }
289
+ Object.assign(vars, { allowed: Object.keys(found.binding.personas ?? {}) });
290
+ const { findPersona } = await loadBinding();
291
+ const persona = findPersona(found.binding, plan.persona);
292
+ if (!persona) return refuse(io, "persona_not_allowlisted", 2, {}, vars);
293
+ const { stagingTargetFromBinding } = await import("../staging-pool/policy.mjs");
294
+ const bound = stagingTargetFromBinding(found.binding);
295
+ // deps.stagingAuthTarget is a test-only seam (a loopback page for the real-daemon
296
+ // test); no flag or environment variable can redirect the install origin.
297
+ const target = deps.stagingAuthTarget ?? { origin: bound.appOrigin, storageKey: bound.storageKey };
298
+ const { session } = await resolveSession(plan.session, env);
299
+ if (!session) return refuse(io, "lane_browser_not_open", 2, {}, vars);
300
+ if (isPersistentLane(session)) return refuse(io, "persistent_profile_refused", 3, {}, vars);
301
+ const generation = generationOf(session);
302
+ const ctx = context({ plan, host, env, socketRun, target });
303
+ dropGone(ctx, generation);
304
+ let claim;
305
+ try {
306
+ claim = await poolModule.takeForLane({ persona: persona.email, owner: `lane-${plan.lane}@${host}`, cwd, ...poolOptions });
307
+ } catch (error) {
308
+ if (error?.code === "pool_empty") {
309
+ return refuse(io, "pool_empty", 3, Object.fromEntries(["reason", "detail", ...GUIDED_FIELDS].filter((key) => error[key] !== undefined).map((key) => [key, error[key]])), vars);
310
+ }
311
+ const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "pool_take_failed";
312
+ return refuse(io, code, 3, { detail: String(error?.message ?? error).slice(0, 300) }, vars);
313
+ }
314
+ let installed;
315
+ try {
316
+ installed = await ctx.run(installCode({ statePath: claim.taken.statePath, origin: target.origin, storageKey: target.storageKey, email: persona.email }));
317
+ } finally {
318
+ // Single use either way: the slot never outlives this command.
319
+ await claim.discard().catch(() => {});
320
+ }
321
+ const sessionRef = claim.taken.meta.sessionRef;
322
+ if (!installed.ok) {
323
+ // The install may have written storage before failing: clear that target
324
+ // (its key held at most one session, so any earlier entry for it goes too).
325
+ const entries = readLedger(env, host, plan.lane).entries;
326
+ 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 }]);
327
+ const cleared = await clearBrowserOrMark(ctx, generation, (entry) => entry.origin === target.origin && entry.storage_key === target.storageKey);
328
+ return refuse(io, isKnownCode(installed.code) ? installed.code : "session_install_failed", 3, { cleared }, vars);
329
+ }
330
+ // Re-auth replaces the previous session under the same key: one entry per
331
+ // browser and target. Sessions for other origins in this browser stay tracked.
332
+ const others = readLedger(env, host, plan.lane).entries.filter((entry) => entry.generation === generation && !(entry.origin === target.origin && entry.storage_key === target.storageKey));
333
+ 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 }]);
334
+ receipt(stdout, {
335
+ ok: true, source: SOURCE_NAME, environment: "staging", origin: target.origin, persona: persona.alias,
336
+ lane: plan.lane, host, aal: claim.taken.meta.aal, session_ref: sessionRef, access_expires_at: installed.access_expires_at,
337
+ path: installed.path, slot: "deleted after install",
338
+ on_close: "browser closed, no sign-out (the refresh lineage stays valid server-side; see docs/agent-staging-acceptance-mfa.md)",
339
+ ledger: ledgerPath(env, host, plan.lane),
340
+ });
341
+ return 0;
342
+ }
343
+
344
+ /**
345
+ * Before any other gate-passed lane command: drop entries whose browser is gone,
346
+ * settle a pending clear, and refuse while a same-browser clear is unresolved.
347
+ * Returns null to proceed, or an exit code to stop the command.
348
+ */
349
+ export async function beforeLaneCommand({ plan, host, env, socketRun, resolveSession, stdout, stderr }) {
350
+ if (!readLedger(env, host, plan.lane).entries.length) return null;
351
+ const { session } = await resolveSession(plan.session, env).catch(() => ({ session: null }));
352
+ const generation = generationOf(session);
353
+ const ctx = context({ plan, host, env, socketRun, target: null });
354
+ dropGone(ctx, generation);
355
+ const pending = readLedger(env, host, plan.lane).entries.some((entry) => entry.clear_pending === true);
356
+ if (!pending) return null;
357
+ if (await clearBrowserOrMark(ctx, generation, (entry) => entry.clear_pending === true)) return null;
358
+ if (plan.verb === "close") return null;
359
+ return refuse({ stdout, stderr }, "staging_session_cleanup_pending", 3, { lane: plan.lane, host }, { lane: plan.lane });
360
+ }
361
+
362
+ /** After `close` (successful or not): a browser that survived is cleared, or its cleanup kept. */
363
+ export async function afterLaneClose({ plan, host, env, socketRun, resolveSession, stderr }) {
364
+ if (!readLedger(env, host, plan.lane).entries.length) return;
365
+ const { session } = await resolveSession(plan.session, env).catch(() => ({ session: null }));
366
+ const generation = generationOf(session);
367
+ const ctx = context({ plan, host, env, socketRun, target: null });
368
+ const cleared = await clearBrowserOrMark(ctx, generation);
369
+ dropGone(ctx, generation);
370
+ safeWrite(stderr, `bb-pw: staging auth cleanup ${JSON.stringify({ browser: generation ? (cleared ? "cleared" : "clear_pending") : "closed", sign_out: "none" })}\n`);
371
+ }
372
+
373
+ /**
374
+ * The lane lock is provably no longer this agent's: clear the sessions THIS
375
+ * caller installed from the same browser. A session the current holder installed
376
+ * is not the stale caller's to clear (an entry with no recorded holder is).
377
+ */
378
+ export async function onLaneAuthorityLost({ plan, host, env, socketRun, resolveSession, stderr, callerIds = [], resolveCallerId = null }) {
379
+ const recorded = readLedger(env, host, plan.lane).entries;
380
+ if (!recorded.length) return;
381
+ // A token-only caller has no local agent id; resolve it only when an entry needs it.
382
+ if (resolveCallerId && recorded.some((entry) => entry.holder && !callerIds.includes(entry.holder))) {
383
+ const me = await resolveCallerId().catch(() => null);
384
+ if (me) callerIds = [...callerIds, me];
385
+ }
386
+ const { session } = await resolveSession(plan.session, env).catch(() => ({ session: null }));
387
+ const generation = generationOf(session);
388
+ const ctx = context({ plan, host, env, socketRun, target: null });
389
+ dropGone(ctx, generation);
390
+ const mine = (entry) => !entry.holder || callerIds.includes(entry.holder);
391
+ const cleared = await clearBrowserOrMark(ctx, generation, mine);
392
+ safeWrite(stderr, `bb-pw: lane authority lost; staging auth cleanup ${JSON.stringify({ browser: generation ? (cleared ? "cleared" : "clear_pending") : "closed" })}\n`);
393
+ }
@@ -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
+ }
@@ -0,0 +1,120 @@
1
+ // BOT-2084 (AC-8 follow-up): which repository checkouts on this Mac declare a
2
+ // staging_session_pool, so a refusal run from the wrong directory (e.g. `~`) can
3
+ // name a concrete `cd <path>` instead of a placeholder.
4
+ //
5
+ // Sources, both non-secret paths only:
6
+ // * every installed pool's config.json `checkout` (written by install);
7
+ // * ~/.botbuddy/staging-session-pool/known-checkouts.json (0600), updated each
8
+ // time a bb staging-pool / bb pw auth command resolves a binding from a cwd.
9
+ // A candidate counts only if it still exists and still declares a pool binding.
10
+ // Node-stdlib-only and self-contained (bundled with the installed job).
11
+ import { chmodSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { join, sep } from "node:path";
14
+
15
+ import { readRepoPoolBinding } from "./binding.mjs";
16
+
17
+ const MAX_KNOWN = 20;
18
+
19
+ const linkedWorktree = (path) => {
20
+ try {
21
+ return statSync(join(path, ".git")).isFile();
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+ const TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
27
+ // Path segments that mark agent, scratch or disposable checkouts.
28
+ const DISPOSABLE_SEGMENTS = new Set(["worktrees", "scratch-workspaces"]);
29
+
30
+ /**
31
+ * A checkout a human can be told to `cd` into: not under a temp dir, not an agent
32
+ * or scratch worktree (a `worktrees` / `scratch-workspaces` path segment), and not
33
+ * a linked git worktree (`.git` is a file). Remedies addressed to a human never
34
+ * embed any other path (AC-8 follow-up, found on staging acceptance).
35
+ */
36
+ export function isStableCheckout(path, { tmp = tmpdir(), gitFileIsWorktree = linkedWorktree } = {}) {
37
+ if (typeof path !== "string" || !path.startsWith("/")) return false;
38
+ const roots = [...TEMP_ROOTS, tmp];
39
+ try {
40
+ roots.push(realpathSync(tmp));
41
+ } catch {
42
+ // tmp may not resolve in a test sandbox
43
+ }
44
+ for (const root of roots) {
45
+ const base = String(root).replace(/\/+$/, "");
46
+ if (base && (path === base || path.startsWith(`${base}/`))) return false;
47
+ }
48
+ if (path.split(sep).some((segment) => DISPOSABLE_SEGMENTS.has(segment))) return false;
49
+ return !gitFileIsWorktree(path);
50
+ }
51
+ const poolsRoot = (home) => join(home, ".botbuddy", "staging-session-pool");
52
+ export const knownCheckoutsPath = (home = homedir()) => join(poolsRoot(home), "known-checkouts.json");
53
+
54
+ function readKnown(home) {
55
+ try {
56
+ const data = JSON.parse(readFileSync(knownCheckoutsPath(home), "utf8"));
57
+ return Array.isArray(data?.checkouts) ? data.checkouts.filter((entry) => typeof entry?.path === "string" && entry.path.startsWith("/")) : [];
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ /** Best effort: remember a checkout that resolved a binding. Never throws. */
64
+ export function recordCheckout({ home = homedir(), path, poolId, now = Date.now() } = {}) {
65
+ try {
66
+ if (typeof path !== "string" || !path.startsWith("/")) return;
67
+ const entries = [{ path, pool_id: poolId ?? null, seen_at: new Date(now).toISOString() }, ...readKnown(home).filter((entry) => entry.path !== path)].slice(0, MAX_KNOWN);
68
+ mkdirSync(poolsRoot(home), { recursive: true, mode: 0o700 });
69
+ const file = knownCheckoutsPath(home);
70
+ const tmp = `${file}.${process.pid}.tmp`;
71
+ writeFileSync(tmp, `${JSON.stringify({ version: 1, checkouts: entries }, null, 2)}\n`, { mode: 0o600 });
72
+ chmodSync(tmp, 0o600);
73
+ renameSync(tmp, file);
74
+ } catch {
75
+ // A refusal's hint must never fail the command that produced it.
76
+ }
77
+ }
78
+
79
+ function installedCheckouts(home) {
80
+ const found = [];
81
+ let names = [];
82
+ try {
83
+ names = readdirSync(poolsRoot(home));
84
+ } catch {
85
+ return found;
86
+ }
87
+ for (const name of names) {
88
+ try {
89
+ const config = JSON.parse(readFileSync(join(poolsRoot(home), name, "config.json"), "utf8"));
90
+ if (typeof config?.checkout === "string" && config.checkout.startsWith("/")) found.push({ path: config.checkout, pool_id: name, seen_at: config.installedAt ?? null });
91
+ } catch {
92
+ // not installed, or unreadable: not a candidate
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+
98
+ /**
99
+ * Checkouts that still declare a staging_session_pool, most relevant first:
100
+ * installed pools' checkouts, then the most recently seen. `poolId` narrows to
101
+ * one pool when it is known.
102
+ */
103
+ export async function findKnownCheckouts({ home = homedir(), poolId = null, readBinding = readRepoPoolBinding, isStable = isStableCheckout } = {}) {
104
+ const seen = new Set();
105
+ const result = [];
106
+ for (const entry of [...installedCheckouts(home), ...readKnown(home)]) {
107
+ if (seen.has(entry.path)) continue;
108
+ seen.add(entry.path);
109
+ if (!isStable(entry.path)) continue;
110
+ try {
111
+ const found = await readBinding(entry.path);
112
+ if (!found || found.repoRoot !== entry.path) continue;
113
+ if (poolId && found.binding.poolId !== poolId) continue;
114
+ result.push({ path: entry.path, poolId: found.binding.poolId });
115
+ } catch {
116
+ // gone, or no longer a valid binding
117
+ }
118
+ }
119
+ return result;
120
+ }