@botbuddy/cli 1.60.9 → 1.61.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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.9",
3
- "source_commit": "460663423af03d399c5b15a72a0a493bdb888813",
4
- "source_sha256": "bb012b774ec1728801e48d3239f0a0aecd4e948d21fae5ab866d91935a4d566f",
2
+ "release_version": "1.61.0",
3
+ "source_commit": "5661e7d4d29c975fc925b0a6c47741db33598642",
4
+ "source_sha256": "c5c57ed492234165bee179842456b3bebce505b36145e96551c0ddbd0cdf605a",
5
5
  "target": "darwin-arm64",
6
- "sha256": "64623b249bafec25024c52e1953382ee6d15e09652fd8450593275a0504059c0"
6
+ "sha256": "afadb8e224f68209f4a2a0f8367d4aa289a76ac21f2ee57cbd376b90ab569469"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.9",
3
- "source_commit": "460663423af03d399c5b15a72a0a493bdb888813",
4
- "source_sha256": "bb012b774ec1728801e48d3239f0a0aecd4e948d21fae5ab866d91935a4d566f",
2
+ "release_version": "1.61.0",
3
+ "source_commit": "5661e7d4d29c975fc925b0a6c47741db33598642",
4
+ "source_sha256": "c5c57ed492234165bee179842456b3bebce505b36145e96551c0ddbd0cdf605a",
5
5
  "target": "linux-x64",
6
- "sha256": "2b75efb4413d95344478fdf9506679e435f48ef2f048b44236f00e941bcad445"
6
+ "sha256": "830ff851e5d9d85957855af52a7c372cde56e2f9ddb315a31f5d2477c1c7d9d7"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.9",
4
- "source_commit": "460663423af03d399c5b15a72a0a493bdb888813",
5
- "source_sha256": "bb012b774ec1728801e48d3239f0a0aecd4e948d21fae5ab866d91935a4d566f",
3
+ "release_version": "1.61.0",
4
+ "source_commit": "5661e7d4d29c975fc925b0a6c47741db33598642",
5
+ "source_sha256": "c5c57ed492234165bee179842456b3bebce505b36145e96551c0ddbd0cdf605a",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "64623b249bafec25024c52e1953382ee6d15e09652fd8450593275a0504059c0",
11
+ "sha256": "afadb8e224f68209f4a2a0f8367d4aa289a76ac21f2ee57cbd376b90ab569469",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "2b75efb4413d95344478fdf9506679e435f48ef2f048b44236f00e941bcad445",
18
+ "sha256": "830ff851e5d9d85957855af52a7c372cde56e2f9ddb315a31f5d2477c1c7d9d7",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.60.9",
3
+ "version": "1.61.0",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,9 +19,7 @@
19
19
  "daemon/",
20
20
  "daemon-release/",
21
21
  "src/",
22
- "!src/**/*.test.mjs",
23
- "!src/staging-pool/test-helpers/",
24
- "!src/staging-pool/fixtures/"
22
+ "!src/**/*.test.mjs"
25
23
  ],
26
24
  "keywords": [
27
25
  "mcp",
package/src/commands.mjs CHANGED
@@ -278,12 +278,13 @@ ${bold("STAGING SIGN-IN")} (BOT-2084)
278
278
  persona, no password (${cyan("bb pw auth --help")})
279
279
  staging-pool status [--check]
280
280
  Pool health: one reason, who acts, the next command
281
- staging-pool install|tick|mint-one|uninstall
282
- Human only, from Terminal.app (${cyan("bb staging-pool --help")})
281
+ staging-pool install --tenant <slug> | tick | mint-one | uninstall
282
+ Human only, any directory (${cyan("bb staging-pool --help")})
283
283
  help symptom staging-pool Every refusal code with its cause and next command
284
284
 
285
285
  ${bold("OTHER")}
286
286
  locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
287
+ evidence require <file> Record an exact-head evidence disposition → botbuddy/ready
287
288
  evidence publish <file> Publish a local evidence statement → botbuddy/ready check-run
288
289
  update Install the latest @botbuddy/cli in the background via npm
289
290
  help Show this help
package/src/evidence.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  // BOT-2341: `bb evidence publish <statement.json>` — post a local-E2E evidence
2
2
  // statement to the control plane (MCP `publish_evidence`), which stores it and
3
- // creates/updates the advisory `botbuddy/ready` check-run on the PR head.
3
+ // creates/updates the `botbuddy/ready` check-run on the PR head.
4
+ // BOT-786: `bb evidence require <requirement.json>` — record the impact
5
+ // producer's exact-head disposition (MCP `publish_evidence_requirement`):
6
+ // not_required completes botbuddy/ready as success on that head, e2e queues it.
4
7
  //
5
8
  // Auth prefers this worktree's agent session (the tenant the repo is bound to),
6
9
  // falling back to the owner/MCP-key ladder like every other `bb` call. Prints
@@ -11,14 +14,25 @@ import { CALL_EXIT, callToolJson as defaultCallToolJson, resolveCallAuth } from
11
14
 
12
15
  export const EVIDENCE_EXIT = Object.freeze({ ...CALL_EXIT, USAGE: 2, REFUSED: 4 });
13
16
 
14
- const USAGE = `Usage: bb evidence publish <statement.json>
17
+ const USAGE = `Usage: bb evidence require <requirement.json>
18
+ bb evidence publish <statement.json>
15
19
 
16
- Publish a local evidence statement for a PR head. BotBuddy stores it and posts
17
- the advisory botbuddy/ready check-run. The statement is a JSON object with:
18
- repo, pr, headSha, mainSha, base, fingerprint, requirement, runs[],
19
- verdict ("pass" | "fail"), fixCommand
20
+ require Record the impact producer's decision for one exact PR head. The file
21
+ is a JSON object with: repo, pr, headSha, disposition
22
+ ("not_required" | "e2e"), reason, command (required for e2e).
23
+ not_required completes botbuddy/ready as success on that head; e2e
24
+ queues it with the command to run.
25
+ publish Publish a local evidence statement for a PR head (after an e2e
26
+ requirement). The statement is a JSON object with:
27
+ repo, pr, headSha, mainSha, base, fingerprint, requirement, runs[]
28
+ (each with the BotBuddy runId), verdict ("pass" | "fail"), fixCommand
20
29
 
21
- Exit codes: 0 published · 2 usage · 3 auth · 4 refused · 5 unreachable`;
30
+ Exit codes: 0 recorded · 2 usage · 3 auth · 4 refused · 5 unreachable`;
31
+
32
+ const SUBCOMMANDS = Object.freeze({
33
+ require: { tool: "publish_evidence_requirement", argName: "requirement" },
34
+ publish: { tool: "publish_evidence", argName: "statement" },
35
+ });
22
36
 
23
37
  export async function cmdEvidence(args, {
24
38
  resolveAuth = resolveCallAuth,
@@ -31,7 +45,8 @@ export async function cmdEvidence(args, {
31
45
  log(USAGE);
32
46
  return sub === undefined ? EVIDENCE_EXIT.USAGE : EVIDENCE_EXIT.OK;
33
47
  }
34
- if (sub !== "publish") {
48
+ const spec = Object.hasOwn(SUBCOMMANDS, sub) ? SUBCOMMANDS[sub] : null;
49
+ if (!spec) {
35
50
  errorLog(`Unknown evidence subcommand: ${sub}\n\n${USAGE}`);
36
51
  return EVIDENCE_EXIT.USAGE;
37
52
  }
@@ -40,22 +55,22 @@ export async function cmdEvidence(args, {
40
55
  return EVIDENCE_EXIT.USAGE;
41
56
  }
42
57
 
43
- let statement;
58
+ let body;
44
59
  try {
45
- statement = JSON.parse(readFileSync(file, "utf8"));
60
+ body = JSON.parse(readFileSync(file, "utf8"));
46
61
  } catch (error) {
47
62
  errorLog(`✗ Could not read ${file} as JSON: ${error?.message ?? error}`);
48
63
  return EVIDENCE_EXIT.USAGE;
49
64
  }
50
65
 
51
- const resolved = await resolveAuth({ toolName: "publish_evidence", preferSession: true });
66
+ const resolved = await resolveAuth({ toolName: spec.tool, preferSession: true });
52
67
  if (resolved?.error || !resolved?.auth) {
53
68
  log(JSON.stringify(resolved?.error ?? { ok: false, auth: true, error: "not_authenticated" }, null, 2));
54
69
  errorLog("✗ Not authenticated — run `bb doctor --fix` in this worktree.");
55
70
  return EVIDENCE_EXIT.AUTH;
56
71
  }
57
72
 
58
- const res = await callToolJson("publish_evidence", { statement }, { auth: resolved.auth });
73
+ const res = await callToolJson(spec.tool, { [spec.argName]: body }, { auth: resolved.auth });
59
74
  if (!res.ok) {
60
75
  log(JSON.stringify(res, null, 2));
61
76
  if (res.auth) {
@@ -66,15 +81,24 @@ export async function cmdEvidence(args, {
66
81
  errorLog(`✗ BotBuddy unreachable: ${res.error}`);
67
82
  return EVIDENCE_EXIT.TRANSPORT;
68
83
  }
69
- errorLog(`✗ publish_evidence failed: ${res.error}`);
84
+ errorLog(`✗ ${spec.tool} failed: ${res.error}`);
70
85
  return EVIDENCE_EXIT.ERROR;
71
86
  }
72
87
 
73
88
  log(JSON.stringify(res.data, null, 2));
74
89
  if (res.isError || res.data?.ok === false) {
75
- errorLog(`✗ ${res.data?.error ?? "publish_evidence refused"}: ${res.data?.message ?? ""}`.trimEnd());
90
+ errorLog(`✗ ${res.data?.error ?? `${spec.tool} refused`}: ${res.data?.message ?? ""}`.trimEnd());
76
91
  return EVIDENCE_EXIT.REFUSED;
77
92
  }
93
+ if (sub === "require") {
94
+ // Success means the control plane echoed the recorded disposition — an
95
+ // outage body that normalises to an empty result is never "recorded".
96
+ if (res.data?.ok !== true || typeof res.data?.disposition !== "string") {
97
+ errorLog("✗ BotBuddy returned no disposition — nothing is known to be recorded; retry.");
98
+ return EVIDENCE_EXIT.ERROR;
99
+ }
100
+ return EVIDENCE_EXIT.OK;
101
+ }
78
102
  // Codex #1169 R4: success means a receipt came back — an outage body that
79
103
  // normalises to an empty result is never "published".
80
104
  if (res.data?.ok !== true || typeof res.data?.receipt_id !== "string") {
package/src/pw/run.mjs CHANGED
@@ -11,6 +11,7 @@ import { VERSION } from "../version.mjs";
11
11
  import { readAgentSessionTokenEnv, AGENT_KEY_RE, legacyAgentAliasHint } from "../agent-key.mjs";
12
12
  import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
13
13
  import * as stagingAuth from "./staging-auth.mjs";
14
+ import { DOC_SECTIONS, DOC_URL } from "../staging-pool/errors.mjs";
14
15
  // BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
15
16
  // server-side, so the lane name bb-pw builds/matches/prints is the one the lock
16
17
  // kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
@@ -134,7 +135,12 @@ export async function runPw(argv, deps = {}) {
134
135
  // while-loop in runPwInner). Everything after the first non-option (the lane) is a
135
136
  // Playwright workload argument and is never a BotBuddy credential.
136
137
  const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token", "--environment"];
137
- const selfHealErrorLine = (error) => `bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`;
138
+ // BOT-2648 acceptance: with no tenant binding, `bb doctor --fix` cannot help: the
139
+ // caller is outside a registered agent worktree, which lane locks need.
140
+ const NO_BINDING_NEXT = "Agent: run this from your registered project worktree (the checkout with .botbuddy-agent.json; run `bb setup` there once), then retry. bb help symptom tenant_binding_required";
141
+ const selfHealErrorLine = (error) => error?.code === "tenant_binding_required"
142
+ ? `bb-pw: tenant_binding_required — ${error?.message ?? "no BotBuddy tenant binding here"} ${NO_BINDING_NEXT}. Docs: ${DOC_URL}#${DOC_SECTIONS.take}\n`
143
+ : `bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`;
138
144
  // The leading credential options, NORMALIZED to split `flag value` pairs so an
139
145
  // inline `--agent-key=<token>` is recognized by legacyAgentAliasHint (which
140
146
  // matches exact flag tokens) exactly like the split form. Credential flags are
@@ -247,6 +253,8 @@ async function runPwInner(argv, deps = {}) {
247
253
  // BOT-2648 (Codex P2, #1323): the credential's tenant picks the installed staging pool.
248
254
  deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null, credentialAgentId: credential.agentId ?? null, credentialTenant: credential.identity?.tenant ?? null };
249
255
  } catch (error) {
256
+ // BOT-2648 acceptance: an auth refusal is the standard JSON receipt (actor, next, doc, symptom).
257
+ if (plan.mode === "auth" && error?.code === "tenant_binding_required") return stagingAuth.refuse({ stdout, stderr }, "tenant_binding_required", 3, {}, authVars(plan, deps));
250
258
  stderr.write(selfHealErrorLine(error)); return 3;
251
259
  }
252
260
  }
@@ -52,6 +52,13 @@ export const ERROR_CODES = Object.freeze({
52
52
  "The lane lock is held by another agent session of your owner, not by this session.",
53
53
  "The lane was claimed from a different session or sub-agent; auth needs the exact holder.",
54
54
  `bb locks -m --lane {lane}{ticketFlag} # from THIS session, then: ${AUTH}`),
55
+ // BOT-2648 acceptance: the take runs from a registered agent worktree. Lane locks
56
+ // are per agent worktree, so a directory with no .botbuddy-agent.json has no
57
+ // agent identity to hold the lane (the pool itself never needs a checkout).
58
+ tenant_binding_required: entry("agent", "take",
59
+ "bb pw needs a registered agent worktree, and this directory has no BotBuddy tenant binding.",
60
+ "No .botbuddy-agent.json was found here or above, so there is no per-worktree agent identity to hold the lane lock.",
61
+ `bb setup # run inside your registered project worktree (the checkout with .botbuddy-agent.json); then from there: bb locks -m --lane {lane}{ticketFlag} && ${AUTH}`),
55
62
  persona_not_allowlisted: entry("agent", "take",
56
63
  "Persona \"{persona}\" is not one of this repository's staging_session_pool personas.",
57
64
  "A typo, or an email where the alias is expected. Allowed aliases: {allowed}.",
@@ -310,7 +317,7 @@ export const ERROR_CODES = Object.freeze({
310
317
  // Codes only `bb pw … auth` can raise; every other code can come from `bb staging-pool`.
311
318
  export const AUTH_ONLY_CODES = Object.freeze(new Set([
312
319
  "auth_requires_staging_environment", "auth_requires_lane_lock", "lane_not_claimed", "exact_lane_holder_required",
313
- "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
320
+ "tenant_binding_required", "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
314
321
  "session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
315
322
  "session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
316
323
  "daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty",
@@ -0,0 +1,11 @@
1
+ {
2
+ "_captured": "2026-10-05 from `botbuddy call get_totp_code --json {app_slug:supplyspark-ent, seed_user_slug:seed-super-admin@supplyguard.local, environment:local}` (@botbuddy/cli 1.40.4, launchd-like env -i). Key set and value types are real; the code value is replaced and remaining_seconds is illustrative.",
3
+ "app_slug": "supplyspark-ent",
4
+ "seed_user_slug": "seed-super-admin@supplyguard.local",
5
+ "environment": "local",
6
+ "authz": "org",
7
+ "code": "000000",
8
+ "remaining_seconds": 17,
9
+ "digits": 6,
10
+ "period": 30
11
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "_captured": "2026-10-05 against the ENT-5120 isolated local stack (GoTrue v2.196.0; staging reported v2.197.0 the same day). Error bodies show the error_code and msg fields as returned; tokens, which are never recorded. Experiment: scratch rotation-experiment.mjs, recorded on ENT-5120.",
3
+ "challengeAfterAal1SessionDeleted": {
4
+ "status": 403,
5
+ "body": {
6
+ "error_code": "session_not_found",
7
+ "msg": "Session from session_id claim in JWT does not exist"
8
+ }
9
+ },
10
+ "refreshAfterSessionDeleted": {
11
+ "status": 400,
12
+ "body": {
13
+ "error_code": "refresh_token_not_found",
14
+ "msg": "Invalid Refresh Token: Refresh Token Not Found"
15
+ }
16
+ },
17
+ "aal2SessionRefreshAfterAnotherMfaVerify": {
18
+ "status": 200,
19
+ "aal": "aal2"
20
+ },
21
+ "adminDeleteFactor": {
22
+ "status": 200,
23
+ "sessionsBefore": "4 sessions, all aal2",
24
+ "sessionsAfter": "4 sessions, all aal1 (downgraded, not deleted)"
25
+ },
26
+ "refreshAal2SessionAfterAdminDeleteFactor": {
27
+ "status": 200,
28
+ "aal": "aal1"
29
+ },
30
+ "userEndpointWithOldAal2JwtAfterAdminDeleteFactor": {
31
+ "status": 200
32
+ },
33
+ "reEnrolVerifyAfterDowngrade": {
34
+ "sessionsBefore": "4 sessions, all aal1 (downgraded)",
35
+ "sessionsAfter": "1 session (aal2): the downgraded aal1 sessions were deleted by the ensure script re-enrol + MFA verify"
36
+ }
37
+ }
@@ -0,0 +1,41 @@
1
+ // BOT-2084 test helper: pool bindings. SG_* mirrors supplyspark-ent's staging
2
+ // values (the pool's original consumer, ENT-5120) so the lifted tests keep their
3
+ // meaning; BOTBUDDY_* is BotBuddy's registered binding (its registration input).
4
+ import { readFileSync } from "node:fs";
5
+
6
+ import { parsePoolBinding } from "../binding.mjs";
7
+ import { stagingTargetFromBinding } from "../policy.mjs";
8
+
9
+ export const SG_RAW_BINDING = Object.freeze({
10
+ schema_version: 1,
11
+ tenant: "supply-guard",
12
+ staging_session_pool: {
13
+ schema_version: 1,
14
+ pool_id: "supplyspark-ent-staging",
15
+ environment: "staging",
16
+ app_slug: "supplyspark-ent",
17
+ adapter: "supabase-gotrue-totp",
18
+ app_origin: "https://enterprise-staging.vercel.app",
19
+ supabase_url: "https://ikdoydgubernmxzdqtxz.supabase.co",
20
+ publishable_key: "sb_publishable_gysKy2GcBNO0Gfv3d_p60Q_yMbCMUPD",
21
+ storage_key: "sb-ikdoydgubernmxzdqtxz-auth-token",
22
+ production_origins: ["https://app.supply-guard.io", "https://enterprise.vercel.app"],
23
+ password_env_file: "~/supply-guard/shared/.env.staging-personas",
24
+ personas: {
25
+ "super-admin": { email: "seed-super-admin@supplyguard.local", password_env: "SEED_PERSONA_PASSWORD", default_count: 2 },
26
+ "acme-admin": { email: "seed-customer-admin@acme-industrial.test", password_env: "SEED_PERSONA_PASSWORD", default_count: 0 },
27
+ "msp-admin": { email: "seed-msp-admin@supplyguard.local", password_env: "SEED_PERSONA_PASSWORD", default_count: 0 },
28
+ },
29
+ launchd_label: "com.supplyguard.staging-session-pool",
30
+ docs: "docs/2026-07-25-agent-workstation-notes.md#staging-session-pool",
31
+ install_command: "cd ~/supply-guard/development/supplyspark-ent && bb staging-pool install --botbuddy-bin \"$(command -v botbuddy)\"",
32
+ },
33
+ });
34
+ export const SG_BINDING = parsePoolBinding(SG_RAW_BINDING);
35
+ export const SG_TARGET = stagingTargetFromBinding(SG_BINDING);
36
+
37
+ // BOT-2648: BotBuddy's registration input (the staging seed job registers it in
38
+ // the control plane); the repo's .botbuddy-agent.json no longer carries a pool block.
39
+ const { _comment: _note, ...BOTBUDDY_REGISTRATION } = JSON.parse(readFileSync(new URL("../../../../scripts/staging-session-pool.botbuddy.json", import.meta.url), "utf8"));
40
+ export const BOTBUDDY_RAW_BINDING = Object.freeze({ schema_version: 1, ...BOTBUDDY_REGISTRATION });
41
+ export const BOTBUDDY_BINDING = parsePoolBinding(BOTBUDDY_RAW_BINDING);
@@ -0,0 +1,60 @@
1
+ // BOT-2084 (lifted from supplyspark-ent ENT-5120) test helper: an in-memory GoTrue stand-in for pool tests. Every mint
2
+ // returns a fresh aal2 session; refresh rotates the refresh token and enforces the
3
+ // single-holder rule (a refresh token is usable once).
4
+
5
+ let counter = 0;
6
+
7
+ export function fakeJwt(claims) {
8
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
9
+ return `${encode({ alg: "HS256" })}.${encode(claims)}.sig`;
10
+ }
11
+
12
+ export function fakeStoredSession({ persona, aal = "aal2", sessionId, nowMs = Date.now(), refreshToken } = {}) {
13
+ counter += 1;
14
+ const exp = Math.floor(nowMs / 1000) + 3600;
15
+ return {
16
+ access_token: fakeJwt({ aal, email: persona, exp, session_id: sessionId ?? `sess-${counter}`, amr: [{ method: "totp" }] }),
17
+ token_type: "bearer",
18
+ expires_in: 3600,
19
+ expires_at: exp,
20
+ refresh_token: refreshToken ?? `rt-${process.pid}-${counter}-${Math.random().toString(36).slice(2)}`,
21
+ user: { id: `user-${persona}`, email: persona },
22
+ };
23
+ }
24
+
25
+ export function createFakeSignIn({ nowMs = () => Date.now(), onRefresh, onMint } = {}) {
26
+ const live = new Map(); // refresh_token -> { persona, sessionId, aal }
27
+ const calls = { mint: 0, refresh: 0 };
28
+ return {
29
+ calls,
30
+ live,
31
+ downgrade(sessionId) {
32
+ for (const entry of live.values()) if (entry.sessionId === sessionId) entry.aal = "aal1";
33
+ },
34
+ revokeAll() {
35
+ live.clear();
36
+ },
37
+ async mint({ persona }) {
38
+ calls.mint += 1;
39
+ if (onMint) await onMint({ persona });
40
+ const session = fakeStoredSession({ persona, nowMs: nowMs() });
41
+ const sessionId = JSON.parse(Buffer.from(session.access_token.split(".")[1], "base64url")).session_id;
42
+ live.set(session.refresh_token, { persona, sessionId, aal: "aal2" });
43
+ return session;
44
+ },
45
+ async refresh({ storedSession, persona }) {
46
+ calls.refresh += 1;
47
+ if (onRefresh) {
48
+ const injected = await onRefresh({ storedSession, persona });
49
+ if (injected) return injected;
50
+ }
51
+ const entry = live.get(storedSession.refresh_token);
52
+ if (!entry) return { ok: false, transient: false, reason: "refresh_token_not_found" };
53
+ live.delete(storedSession.refresh_token);
54
+ if (entry.aal !== "aal2") return { ok: false, transient: false, reason: "aal_downgraded" };
55
+ const next = fakeStoredSession({ persona: entry.persona, sessionId: entry.sessionId, nowMs: nowMs() });
56
+ live.set(next.refresh_token, entry);
57
+ return { ok: true, storedSession: next };
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,28 @@
1
+ // BOT-2084 (lifted from supplyspark-ent ENT-5120) test helper: one OS process racing
2
+ // `take` against others on a shared pool dir.
3
+ import { createSessionPool } from "../pool.mjs";
4
+ import { SG_TARGET } from "./bindings.mjs";
5
+ import { fakeStoredSession } from "./fake-signin.mjs";
6
+
7
+ const [poolDir, persona, startAtMs] = process.argv.slice(2);
8
+ const pool = createSessionPool({
9
+ poolDir,
10
+ target: SG_TARGET,
11
+ signIn: {
12
+ mint: async () => {
13
+ throw new Error("workers never mint");
14
+ },
15
+ refresh: async () => ({ ok: true, storedSession: fakeStoredSession({ persona }) }),
16
+ },
17
+ logger: () => {},
18
+ });
19
+
20
+ // Line up all workers on the same instant so the renames genuinely race.
21
+ const delay = Number(startAtMs) - Date.now();
22
+ if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
23
+ try {
24
+ const taken = await pool.take({ persona, owner: `worker-${process.pid}` });
25
+ process.stdout.write(JSON.stringify({ slotId: taken.slotId }));
26
+ } catch (error) {
27
+ process.stdout.write(JSON.stringify({ error: error.code ?? error.message }));
28
+ }