@botbuddy/cli 1.64.33 → 1.65.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.64.33",
3
- "source_commit": "644a0f64f3b74a556a821c8a8997ea4e8910f282",
4
- "source_sha256": "1da5f11e7e2200acf29d273111d3550497ea0fa16ea3a570083273995c9d22de",
2
+ "release_version": "1.65.0",
3
+ "source_commit": "be92b3792490c1097c679ddd9a8ae52b3e5e6bf2",
4
+ "source_sha256": "74e6ce7639343fa57b8faa8d4082eeb7bdd0267b8335b2768c09477fd3e032e0",
5
5
  "target": "darwin-arm64",
6
- "sha256": "49b0b67a0e368a26b4f205881120a4dbc114e246e84413d77c108adbbd0ec8de"
6
+ "sha256": "94a46209aebe5086ddf8dbd6c6c6588fa700bc56ec9b013ff3a56aba08cafbc0"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.33",
3
- "source_commit": "644a0f64f3b74a556a821c8a8997ea4e8910f282",
4
- "source_sha256": "1da5f11e7e2200acf29d273111d3550497ea0fa16ea3a570083273995c9d22de",
2
+ "release_version": "1.65.0",
3
+ "source_commit": "be92b3792490c1097c679ddd9a8ae52b3e5e6bf2",
4
+ "source_sha256": "74e6ce7639343fa57b8faa8d4082eeb7bdd0267b8335b2768c09477fd3e032e0",
5
5
  "target": "linux-x64",
6
- "sha256": "75b79c530c451304354c4bdffe23f373a7de6824f203cba9a7acb7e1bc0cfb2e"
6
+ "sha256": "7efa17f7d96f9be166a0589f8634a72825e8886707a92b837b98731a81fdb5de"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.64.33",
4
- "source_commit": "644a0f64f3b74a556a821c8a8997ea4e8910f282",
5
- "source_sha256": "1da5f11e7e2200acf29d273111d3550497ea0fa16ea3a570083273995c9d22de",
3
+ "release_version": "1.65.0",
4
+ "source_commit": "be92b3792490c1097c679ddd9a8ae52b3e5e6bf2",
5
+ "source_sha256": "74e6ce7639343fa57b8faa8d4082eeb7bdd0267b8335b2768c09477fd3e032e0",
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": "49b0b67a0e368a26b4f205881120a4dbc114e246e84413d77c108adbbd0ec8de",
11
+ "sha256": "94a46209aebe5086ddf8dbd6c6c6588fa700bc56ec9b013ff3a56aba08cafbc0",
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": "75b79c530c451304354c4bdffe23f373a7de6824f203cba9a7acb7e1bc0cfb2e",
18
+ "sha256": "7efa17f7d96f9be166a0589f8634a72825e8886707a92b837b98731a81fdb5de",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.64.33",
4
- "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
3
+ "version": "1.65.0",
4
+ "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "bb": "./bin/botbuddy.mjs",
package/src/api.mjs CHANGED
@@ -415,6 +415,8 @@ export function containerHostRegisterEndpoint(serverUrl = SERVER_URL) {
415
415
  */
416
416
  export function registerContainerHost(body, options = {}) { return postContainerHostControl(body, options, "register", "container_host_register"); }
417
417
  export function daemonHandoverRequest(body, options = {}) { return postContainerHostControl(body, options, "handover", "daemon_handover"); }
418
+ // BOT-3127: the owner-only automatic-update describe read (same auth as handover).
419
+ export function daemonAutomaticUpdateRequest(body, options = {}) { return postContainerHostControl(body, options, "automatic-update", "daemon_handover"); }
418
420
  async function postContainerHostControl(body, {
419
421
  fetchImpl = fetch,
420
422
  auth: pinnedAuth = null,
@@ -1,4 +1,4 @@
1
- import { callToolJson, resolveCallAuth } from "../api.mjs";
1
+ import { callToolJson, daemonAutomaticUpdateRequest, resolveCallAuth } from "../api.mjs";
2
2
  import {
3
3
  assertSshHost,
4
4
  installedBinaryPath,
@@ -11,13 +11,20 @@ const fail = (code) => {
11
11
  };
12
12
  // One explicit administrator installation grants only the fixed native
13
13
  // coordinator/schedule. Persisted operator opt-in remains a separate drawer act.
14
+ // BOT-3127: user-scoped. The spec names no tenant; the host's owner asks the
15
+ // server for the host's inert rollout label and the root binding pins that.
14
16
  export async function installAutomaticDaemonUpdater(spec, {
15
17
  validateRelease = validateDaemonRelease,
16
18
  resolveAuth = resolveCallAuth,
17
19
  identity = callToolJson,
20
+ describe = daemonAutomaticUpdateRequest,
18
21
  stage = stageRemoteDaemonRelease,
19
22
  native = nativeHandoverCommand,
20
23
  } = {}) {
24
+ if (spec && typeof spec === "object" && Object.hasOwn(spec, "tenant")) {
25
+ // Automatic updates belong to the host's owner, not a project.
26
+ fail("daemon_automatic_install_tenant_not_used");
27
+ }
21
28
  if (
22
29
  !spec || typeof spec !== "object" || Array.isArray(spec) ||
23
30
  Object.keys(spec).some((key) =>
@@ -28,13 +35,11 @@ export async function installAutomaticDaemonUpdater(spec, {
28
35
  "serviceUser",
29
36
  "releaseRoot",
30
37
  "expectedHostKey",
31
- "tenant",
32
38
  ].includes(key)
33
39
  ) || !["linux", "darwin"].includes(spec.platform) ||
34
40
  typeof spec.serviceUser !== "string" || spec.serviceUser === "root" ||
35
41
  !/^[a-z_][a-z0-9_-]*$/i.test(spec.serviceUser) ||
36
- !/^bbh-[0-9a-f]{24}$/.test(spec.expectedHostKey) ||
37
- typeof spec.tenant !== "string" || !spec.tenant
42
+ !/^bbh-[0-9a-f]{24}$/.test(spec.expectedHostKey)
38
43
  ) fail("daemon_automatic_install_spec_invalid");
39
44
  assertSshHost(spec.sshHost);
40
45
  const release = await validateRelease({ releaseRoot: spec.releaseRoot }, {
@@ -48,14 +53,30 @@ export async function installAutomaticDaemonUpdater(spec, {
48
53
  if (resolved?.error || !resolved?.auth) {
49
54
  fail("daemon_automatic_install_auth_required");
50
55
  }
51
- const who = await identity("whoami", {}, {
52
- auth: resolved.auth,
53
- tenant: spec.tenant,
54
- });
56
+ const who = await identity("whoami", {}, { auth: resolved.auth });
55
57
  if (
56
58
  !who?.ok || who.isError || typeof who.data?.owner_email !== "string" ||
57
- !who.data.owner_email.includes("@") || who.data.tenant_id !== spec.tenant
59
+ !who.data.owner_email.includes("@")
58
60
  ) fail("daemon_automatic_install_owner_unavailable");
61
+ const described = await describe({
62
+ action: "describe",
63
+ host_key: spec.expectedHostKey,
64
+ }, { auth: resolved.auth });
65
+ const owner = described?.data?.owner_email, label = described?.data?.tenant_id;
66
+ if (
67
+ !described?.ok || described.data?.success !== true ||
68
+ described.data.host_key !== spec.expectedHostKey ||
69
+ typeof owner !== "string" || owner.toLowerCase() !== who.data.owner_email.toLowerCase() ||
70
+ typeof label !== "string" || !label
71
+ ) {
72
+ // A 4xx answer carries its code in `error` (e.g. daemon_host_scope).
73
+ const code = described?.data?.code ?? described?.error;
74
+ fail(
75
+ typeof code === "string" && /^(?:[A-Z_]{1,64}|[a-z_]{1,64})$/.test(code)
76
+ ? `daemon_automatic_install_owner_refused: ${code}`
77
+ : "daemon_automatic_install_owner_refused",
78
+ );
79
+ }
59
80
  const staged = await stage({
60
81
  platform: spec.platform,
61
82
  host: spec.sshHost,
@@ -70,8 +91,8 @@ export async function installAutomaticDaemonUpdater(spec, {
70
91
  mode: "automatic-install",
71
92
  input: {
72
93
  host_key: spec.expectedHostKey,
73
- owner_email: who.data.owner_email,
74
- tenant_id: who.data.tenant_id,
94
+ owner_email: owner,
95
+ tenant_id: label,
75
96
  },
76
97
  });
77
98
  if (result?.installed !== true || result.protocol !== 2) {
@@ -74,8 +74,10 @@ const realm = 'const G = page.constructor.constructor("return globalThis")();';
74
74
  // (a Playwright storage state holding exactly the bound origin + one supabase-js
75
75
  // key), check it is the persona's unexpired aal2 session, write it to the origin's
76
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 };
77
+ // BOT-3145: requiredAuth comes from the persona's binding sign-in kind (sign-in-kinds.mjs
78
+ // requiredAuthFor): { aal: "aal2" } for password + TOTP, { amr: "sso/saml" } for okta-saml.
79
+ export function installCode({ statePath, origin, storageKey, email, requiredAuth = { aal: "aal2" } }) {
80
+ const args = { statePath, origin, storageKey, email, requiredAuth };
79
81
  return `async (page) => {
80
82
  ${realm}
81
83
  const args = ${j(args)};
@@ -97,7 +99,10 @@ export function installCode({ statePath, origin, storageKey, email }) {
97
99
  } catch {
98
100
  return { ok: false, code: "slot_state_unreadable" };
99
101
  }
100
- if (claims.aal !== "aal2") return { ok: false, code: "session_not_aal2" };
102
+ if (args.requiredAuth.amr) {
103
+ const methods = Array.isArray(claims.amr) ? claims.amr.map((entry) => entry && entry.method ? entry.method : String(entry)) : [];
104
+ if (!methods.includes(args.requiredAuth.amr)) return { ok: false, code: "session_not_sso" };
105
+ } else if (claims.aal !== "aal2") return { ok: false, code: "session_not_aal2" };
101
106
  if (String(claims.email ?? "").toLowerCase() !== args.email) return { ok: false, code: "session_persona_mismatch" };
102
107
  if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now() + 60000) return { ok: false, code: "session_expired" };
103
108
  try {
@@ -330,6 +335,7 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
330
335
  const persona = findPersona(found.binding, plan.persona);
331
336
  if (!persona) return refuse(io, "persona_not_allowlisted", 2, {}, vars);
332
337
  const { stagingTargetFromBinding } = await import("../staging-pool/policy.mjs");
338
+ const { requiredAuthFor } = await import("../staging-pool/sign-in-kinds.mjs");
333
339
  const bound = stagingTargetFromBinding(found.binding);
334
340
  // deps.stagingAuthTarget is a test-only seam (a loopback page for the real-daemon
335
341
  // test); no flag or environment variable can redirect the install origin.
@@ -375,7 +381,7 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
375
381
  }
376
382
  let installed;
377
383
  try {
378
- installed = await ctx.run(installCode({ statePath: claim.taken.statePath, origin: target.origin, storageKey: target.storageKey, email: persona.email }));
384
+ installed = await ctx.run(installCode({ statePath: claim.taken.statePath, origin: target.origin, storageKey: target.storageKey, email: persona.email, requiredAuth: requiredAuthFor(persona.signIn) }));
379
385
  } finally {
380
386
  // Single use either way: the slot never outlives this command.
381
387
  await claim.discard().catch(() => {});
@@ -6,6 +6,8 @@
6
6
  // is never read from a repository checkout. Node-stdlib-only and self-contained:
7
7
  // the installed job keeps a validated snapshot in its config.
8
8
 
9
+ import { parseSignIn } from "./sign-in-kinds.mjs";
10
+
9
11
  export class BindingError extends Error {
10
12
  constructor(message, code = "pool_binding_invalid") {
11
13
  super(message);
@@ -84,7 +86,10 @@ export function parsePoolBinding(raw) {
84
86
  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)`);
85
87
  const count = persona.default_count ?? 1;
86
88
  need(Number.isInteger(count) && count >= 0 && count <= MAX_POOL_SIZE, `persona ${alias} default_count must be 0-${MAX_POOL_SIZE}`);
87
- parsedPersonas[alias] = Object.freeze({ alias, email: persona.email, passwordEnv: persona.password_env, defaultCount: count });
89
+ // BOT-3145: an optional sign_in block (absent = password + TOTP, unchanged).
90
+ const signIn = parseSignIn(persona.sign_in);
91
+ need(signIn.ok, `persona ${alias} ${signIn.ok ? "" : signIn.message}`);
92
+ parsedPersonas[alias] = Object.freeze({ alias, email: persona.email, passwordEnv: persona.password_env, defaultCount: count, signIn: signIn.signIn });
88
93
  }
89
94
  return Object.freeze({
90
95
  tenant: raw.tenant,
@@ -25,6 +25,7 @@ import * as nodeFs from "node:fs/promises";
25
25
  import { readFileSync, readdirSync, realpathSync } from "node:fs";
26
26
  import { homedir, userInfo } from "node:os";
27
27
  import { dirname, join } from "node:path";
28
+ import { createRequire } from "node:module";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import { promisify } from "node:util";
30
31
 
@@ -35,7 +36,10 @@ import { buildLaunchAgentPlist, createLaunchctl } from "./launchd.mjs";
35
36
  import { createEnvFilePasswordSource } from "./password-source.mjs";
36
37
  import { MintRefusedError, assertMintAllowed, assertPersonaAllowed, listProcessAncestors, stagingTargetFromBinding } from "./policy.mjs";
37
38
  import { PoolEmptyError, createSessionPool } from "./pool.mjs";
39
+ import { SIGN_IN_KINDS } from "./sign-in-kinds.mjs";
40
+ import { createBindingSignIn, signInLookup } from "./signin-dispatch.mjs";
38
41
  import { createGoTrueTotpSignIn } from "./signin-gotrue-totp.mjs";
42
+ import { createOktaSamlSignIn, loadOrCreateHoldKey } from "./signin-okta-saml.mjs";
39
43
  import { createRedactor } from "./state-file.mjs";
40
44
  import { createBotBuddyTotpProvider, probeBotBuddy } from "./totp-botbuddy.mjs";
41
45
  import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, installLine, isKnownCode, remedyLine } from "./errors.mjs";
@@ -58,14 +62,18 @@ export const BUNDLE_FILES = Object.freeze([
58
62
  "pool.mjs",
59
63
  "registry.mjs",
60
64
  "refresh-runtime.mjs",
65
+ "okta-steps.mjs",
66
+ "sign-in-kinds.mjs",
67
+ "signin-dispatch.mjs",
61
68
  "signin-gotrue-totp.mjs",
69
+ "signin-okta-saml.mjs",
62
70
  "state-file.mjs",
63
71
  "take-outbox.mjs",
64
72
  "totp-botbuddy.mjs",
65
73
  ]);
66
74
 
67
75
  const VALUE_FLAGS = new Set(["persona", "home", "botbuddy-bin", "interval", "owner", "tenant", "pool"]);
68
- const BOOLEAN_FLAGS = new Set(["keep-sessions", "help", "check"]);
76
+ const BOOLEAN_FLAGS = new Set(["keep-sessions", "help", "check", "retry-held"]);
69
77
 
70
78
  export function parseArgs(argv) {
71
79
  const [command, ...rest] = argv;
@@ -174,22 +182,55 @@ function reasonsFor(ctx) {
174
182
  return healthReasons(guidanceVars(ctx));
175
183
  }
176
184
 
185
+ const neverMint = async () => {
186
+ throw new Error("take/status never mint");
187
+ };
188
+
189
+ // BOT-3145: true when the binding names at least one okta-saml persona.
190
+ function hasOktaPersonas(binding) {
191
+ return Object.values(binding?.personas ?? {}).some((persona) => persona.signIn?.kind === SIGN_IN_KINDS.OKTA_SAML);
192
+ }
193
+
194
+ // take/status refresh slots (take-time liveness) but never sign in: every adapter's
195
+ // credential and browser inputs throw.
196
+ function refreshOnlySignIn({ ctx, fetchImpl, logger, sleep, now }) {
197
+ const binding = ctx.poolBinding;
198
+ return createBindingSignIn({
199
+ binding,
200
+ totp: createGoTrueTotpSignIn({ fetchImpl, totpProvider: neverMint, passwordFor: neverMint, logger, sleep, nowMs: now }),
201
+ okta: hasOktaPersonas(binding)
202
+ ? createOktaSamlSignIn({
203
+ fetchImpl,
204
+ launchBrowser: neverMint,
205
+ passwordFor: neverMint,
206
+ signInFor: signInLookup(binding),
207
+ holdSecret: () => {
208
+ throw new Error("take/status never mint");
209
+ },
210
+ logger,
211
+ sleep,
212
+ nowMs: now,
213
+ })
214
+ : undefined,
215
+ });
216
+ }
217
+
218
+ // BOT-3145: the job's Chromium for an okta-saml sign-in. The installed runtime copy has no
219
+ // node_modules (the bundle stays builtins-only), so playwright is required at sign-in time from
220
+ // the installed @botbuddy/cli package (the botbuddyBin the human installed with), which declares
221
+ // it as a dependency. Nothing a caller passes chooses the module.
222
+ export function createPackagedBrowserLauncher({ botbuddyBin, requireFrom = createRequire }) {
223
+ return async function launchBrowser() {
224
+ const { chromium } = requireFrom(botbuddyBin)("playwright");
225
+ return chromium.launch({ headless: true });
226
+ };
227
+ }
228
+
177
229
  function buildPool({ ctx, target, signIn, now, logger, intervalSeconds, fetchImpl, sleep }) {
178
230
  return createSessionPool({
179
231
  poolDir: ctx.paths.pool,
180
232
  target,
181
- signIn: signIn ?? createGoTrueTotpSignIn({
182
- fetchImpl,
183
- totpProvider: async () => {
184
- throw new Error("take/status never mint");
185
- },
186
- passwordFor: async () => {
187
- throw new Error("take/status never mint");
188
- },
189
- logger,
190
- sleep,
191
- nowMs: now,
192
- }),
233
+ signIn: signIn ?? refreshOnlySignIn({ ctx, fetchImpl, logger, sleep, now }),
193
234
  now,
194
235
  logger,
195
236
  ...(intervalSeconds ? { intervalMs: intervalSeconds * 1000 } : {}),
@@ -493,7 +534,7 @@ export async function runStagingPool(argv, deps = {}) {
493
534
  redactor.register(value);
494
535
  return value;
495
536
  };
496
- return createGoTrueTotpSignIn({
537
+ const totp = createGoTrueTotpSignIn({
497
538
  fetchImpl,
498
539
  totpProvider: createBotBuddyTotpProvider({ exec, botbuddyBin, env, tenant: binding.tenant, appSlug: binding.appSlug }),
499
540
  passwordFor,
@@ -501,6 +542,20 @@ export async function runStagingPool(argv, deps = {}) {
501
542
  sleep,
502
543
  nowMs: now,
503
544
  });
545
+ // BOT-3145: okta-saml personas sign in through the IdP in the job's headless Chromium.
546
+ const okta = hasOktaPersonas(binding)
547
+ ? createOktaSamlSignIn({
548
+ fetchImpl,
549
+ launchBrowser: deps.launchBrowser ?? createPackagedBrowserLauncher({ botbuddyBin }),
550
+ passwordFor,
551
+ signInFor: signInLookup(binding),
552
+ holdSecret: () => loadOrCreateHoldKey(join(ctx.paths.root, "credential-hold.key")),
553
+ logger: emit,
554
+ sleep,
555
+ nowMs: now,
556
+ })
557
+ : undefined;
558
+ return createBindingSignIn({ binding, totp, okta });
504
559
  }
505
560
 
506
561
  function requireBotbuddyBin(options) {
@@ -642,7 +697,7 @@ export async function runStagingPool(argv, deps = {}) {
642
697
  const persona = entry ? entry.email : assertPersonaAllowed(target, options.persona);
643
698
  if (!ctx.installed || ctx.installed.error) throw ctx.installed?.error ?? new ConfigError("The pool job is not installed; the human runs the install first.", "pool_not_installed");
644
699
  const pool = buildPool({ ctx, target, signIn: minterFor(ctx, target), now, logger: emit, intervalSeconds: ctx.installed.intervalSeconds, fetchImpl, sleep });
645
- const minted = await pool.mintOne({ persona });
700
+ const minted = await pool.mintOne({ persona, retryHeld: options["retry-held"] === true });
646
701
  emit({ ok: true, command: "mint-one", target: target.name, signIns: 1, ...minted.meta });
647
702
  return 0;
648
703
  },
@@ -93,6 +93,10 @@ export const ERROR_CODES = Object.freeze({
93
93
  "The pool's session for {persona} is no longer AAL2, so it was discarded.",
94
94
  "The persona's TOTP factor was rotated (the staging seed job ran) after the session was minted.",
95
95
  `${AUTH} # once; the next ready slot is fresh. If it repeats, report this code`, "retry_once"),
96
+ session_not_sso: entry("agent", "take",
97
+ "The pool's session for {persona} is not an SSO (sso/saml) session, so it was discarded.",
98
+ "The persona's binding says okta-saml, but the slot holds another kind of session (the binding changed after it was minted).",
99
+ `${AUTH} # once; the next ready slot is fresh. If it repeats, report this code`, "retry_once"),
96
100
  session_expired: entry("agent", "take",
97
101
  "The pool's session for {persona} had expired, so it was discarded.",
98
102
  "The pool job has not refreshed recently (the Mac slept or the job stopped).",
@@ -177,6 +181,18 @@ export const ERROR_CODES = Object.freeze({
177
181
  "Staging sign-in for {persona} keeps failing, so the job is backing off.",
178
182
  "The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; mint_retries_exhausted = a staging Auth outage, so wait for the next attempt; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
179
183
  "grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
184
+ credential_held: entry("human", "repair",
185
+ "The job holds {persona}: its IdP refused, or never confirmed, the configured password, so the job will not submit it again.",
186
+ "The password in {envFile} is wrong or stale. Re-submitting it would lock the shared IdP test user out (and the tenant's CI with it). The last mint_failed line in {log} names okta_password_rejected or okta_password_unconfirmed.",
187
+ "grep mint_failed {log} | tail -1 # fix the persona's password variable in {envFile} (the hold clears when the value changes); if the password was right, check the IdP user, then retry once: bb staging-pool mint-one --persona {persona} --retry-held"),
188
+ credential_hold_unavailable: entry("human", "repair",
189
+ "The job could not compute a credential hold for {persona}, so it signed nothing in (a refused password must always be holdable).",
190
+ "The pool's credential-hold.key is unreadable or corrupt (under 32 bytes).",
191
+ "ls -l {poolHome}/credential-hold.key # restore read access; if it is corrupt, remove it (recorded holds are then lost: check the IdP user first), then: bb staging-pool mint-one --persona {persona}"),
192
+ health_unreadable: entry("human", "repair",
193
+ "The pool's health.json is unreadable, so the job signs nothing in: it may hold a credential hold.",
194
+ "The file was truncated or its permissions changed.",
195
+ "ls -l {poolHome}/pool/health.json # repair or restore it (removing it drops any credential hold: check the IdP user first), then: {tickCommand}"),
180
196
  last_tick_failed: entry("human", "repair",
181
197
  "The pool job's last tick had sign-in (mint) failures.",
182
198
  "The last mint_failed line in {log} names the cause.",
@@ -334,10 +350,88 @@ export const ERROR_CODES = Object.freeze({
334
350
  "The persona's factor is not verified, or Auth's MFA settings changed.",
335
351
  "bb staging-pool probe # after re-running the staging persona seed"),
336
352
  persona_mismatch: entry("human", "repair",
337
- "MFA verify returned a session for a different user.",
353
+ "Sign-in returned a session for a different user.",
338
354
  "The binding's persona email does not match the seeded user.",
339
355
  "botbuddy call get_staging_pool --pin {tenant} # check the registered personas, then: bb staging-pool mint-one --persona {persona}"),
340
356
 
357
+ // ── BOT-3145: okta-saml personas (signin-okta-saml.mjs). The job, never an agent, signs in.
358
+ okta_password_rejected: entry("human", "repair",
359
+ "Okta rejected {persona}'s configured password; the job holds the persona until the value changes.",
360
+ "The password variable in {envFile} is wrong or stale (the IdP test user's password was rotated).",
361
+ "grep mint_failed {log} | tail -1 # update the variable in {envFile}, then: bb staging-pool mint-one --persona {persona}"),
362
+ okta_password_unconfirmed: entry("human", "repair",
363
+ "Okta kept its sign-in form after {persona}'s password was submitted, with no error the job recognises; it is held like a rejection.",
364
+ "A wrong password in markup the job has not seen, or an Okta page change. The okta_page_state line in {log} shows the value-free screen.",
365
+ "grep okta_page_state {log} | tail -1 # check the password in {envFile} (the hold clears when it changes), then: bb staging-pool mint-one --persona {persona}"),
366
+ okta_account_locked: entry("human", "repair",
367
+ "Okta reports {persona} as locked or suspended.",
368
+ "Too many failed sign-ins (from any client), or an Okta admin suspended the user.",
369
+ "grep mint_failed {log} | tail -1 # unlock the user in the Okta admin console, then retry once: bb staging-pool mint-one --persona {persona} --retry-held"),
370
+ okta_mfa_prompt: entry("human", "repair",
371
+ "Okta asked {persona} for a second factor; the pool signs in with a password only.",
372
+ "The Okta app's authentication policy now requires MFA for this user.",
373
+ "grep okta_page_state {log} | tail -1 # make the Okta app's sign-on policy password-only for the test user, then: bb staging-pool mint-one --persona {persona}"),
374
+ okta_saml_relay_expired: entry("human", "repair",
375
+ "GoTrue refused the SAML callback because its RelayState expired.",
376
+ "Okta answered too slowly (over GoTrue's relay window). The job retries on its next attempt.",
377
+ "grep mint_failed {log} | tail -1 # repeated: bb staging-pool mint-one --persona {persona}"),
378
+ okta_timeout: entry("human", "repair",
379
+ "No SAML callback reached GoTrue's ACS in time after the Okta steps.",
380
+ "Okta or staging Auth is slow or down. The okta_page_state line in {log} shows where it stopped.",
381
+ "grep okta_page_state {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
382
+ okta_steps_failed: entry("human", "repair",
383
+ "The Okta sign-in steps did not complete (the username or password field never appeared).",
384
+ "An Okta outage, or an Okta sign-in widget change the pinned selectors do not match.",
385
+ "grep okta_page_state {log} | tail -1 # report it with the CLI version (bb version)"),
386
+ okta_unreachable: entry("human", "repair",
387
+ "The job's browser could not reach Okta.",
388
+ "No network, a proxy, or an Okta outage.",
389
+ "grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
390
+ okta_unexpected_host: entry("human", "repair",
391
+ "The SSO sign-in led to a host that is not the registered IdP host, so the job typed nothing.",
392
+ "The tenant's SSO provider for this domain changed, or the binding's sign_in.idp_host is wrong.",
393
+ "botbuddy call get_staging_pool --pin {tenant} # compare sign_in.idp_host with the provider; the tenant re-registers its binding"),
394
+ okta_browser_unavailable: entry("human", "repair",
395
+ "The job could not start headless Chromium for an Okta sign-in.",
396
+ "Playwright's Chromium is not installed for the CLI's playwright version.",
397
+ "npm exec --yes --package=playwright@\"$(npm view @botbuddy/cli dependencies.playwright)\" -- playwright install chromium # then: bb staging-pool mint-one --persona {persona}"),
398
+ okta_target_unsupported: entry("human", "repair",
399
+ "Okta SAML personas sign in on staging only.",
400
+ "A local (loopback) target has no SAML service provider.",
401
+ "bb staging-pool mint-one --persona {persona} # against the installed staging pool"),
402
+ okta_adapter_unavailable: entry("human", "repair",
403
+ "This job was built without the Okta sign-in adapter.",
404
+ "A runtime older than the binding's okta-saml persona.",
405
+ "bb update # refreshes the pool runtime, then: bb staging-pool mint-one --persona {persona}"),
406
+ wrong_sign_in_kind: entry("human", "repair",
407
+ "The Okta adapter was asked to sign in a persona that is not okta-saml.",
408
+ "An internal routing error.",
409
+ "bb staging-pool status # report this code with the CLI version (bb version)"),
410
+ sso_begin_failed: entry("human", "repair",
411
+ "Staging Auth refused to start SSO for the persona's domain.",
412
+ "No SSO provider is assigned to sign_in.sso_domain on staging, or Auth is down; {log} has the status.",
413
+ "grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
414
+ sso_callback_error: entry("human", "repair",
415
+ "GoTrue answered the SAML callback with an error or without a session.",
416
+ "The SSO provider's certificate or attribute mapping changed on staging; {log} has GoTrue's error code.",
417
+ "grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
418
+ sso_acs_not_intercepted: entry("human", "repair",
419
+ "The SAML callback reached the app without passing GoTrue's ACS route, so the session was not stored.",
420
+ "Staging Auth's ACS path changed.",
421
+ "grep mint_failed {log} | tail -1 # report this code with the CLI version (bb version)"),
422
+ sso_user_unreadable: entry("human", "repair",
423
+ "Staging Auth would not return the user for the new SSO session.",
424
+ "An Auth outage or a revoked session; {log} has the status.",
425
+ "grep mint_failed {log} | tail -1 # then: bb staging-pool mint-one --persona {persona}"),
426
+ sso_admission_failed: entry("human", "repair",
427
+ "The tenant's admission function did not admit {persona} as a member.",
428
+ "The persona lost its organisation membership on staging, or the function's contract changed; {log} has its outcome and route.",
429
+ "grep mint_failed {log} | tail -1 # restore the persona's membership, then: bb staging-pool mint-one --persona {persona}"),
430
+ not_sso_session: entry("human", "repair",
431
+ "The SSO sign-in returned a session without the sso/saml method.",
432
+ "Staging Auth's SSO configuration changed.",
433
+ "grep mint_failed {log} | tail -1 # report this code with the CLI version (bb version)"),
434
+
341
435
  unexpected_error: entry("agent", "codes",
342
436
  "bb staging-pool failed unexpectedly (see detail).",
343
437
  "An error outside the known cases.",
@@ -348,7 +442,7 @@ export const ERROR_CODES = Object.freeze({
348
442
  export const AUTH_ONLY_CODES = Object.freeze(new Set([
349
443
  "auth_requires_staging_environment", "auth_requires_lane_lock", "lane_not_claimed", "exact_lane_holder_required",
350
444
  "tenant_binding_required", "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
351
- "session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
445
+ "session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_not_sso", "session_expired",
352
446
  "session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
353
447
  "daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty", "pool_refill_unavailable",
354
448
  ]));
@@ -0,0 +1,68 @@
1
+ {
2
+ "_captured": "2026-10-14 by ENT-5553 (agent session, read-only: no credential, username or OTP was typed anywhere). Two real sources: (1) the staging GoTrue + edge logs and auth schema for the green daily real-Okta CI run 38518046301 (sso-staging-regression.yml, 2026-10-14T05:42Z), which signed sso-convert-acceptance in through Okta; (2) a headless, input-free load of the Okta tenant's public sign-in page and its /.well-known/okta-organization. The password, error and factor-challenge screens cannot be captured without typing a credential, so they are NOT in this file: their selectors are the ones the shared okta-steps.mjs already drives green every day in CI, and every classified or unclassified Okta failure in the pool job logs a value-free okta_page_state line so the first real failure supplies its capture. Recapture when okta.widgetVersion or the GoTrue version changes.",
3
+ "okta": {
4
+ "host": "integrator-4707698.okta.com",
5
+ "wellKnown": { "status": 200, "pipeline": "idx", "cell": "ok14" },
6
+ "widgetVersion": "7.50.0",
7
+ "identifierScreen": {
8
+ "title": "supply-guard-integrator-4707698 - Sign In",
9
+ "inputs": [
10
+ { "type": "hidden", "name": "fromURI" },
11
+ { "type": "text", "name": "identifier" },
12
+ { "type": "checkbox", "name": "rememberMe" },
13
+ { "type": "submit", "value": "Next" }
14
+ ],
15
+ "dataSe": [
16
+ "auth-container",
17
+ "o-form",
18
+ "o-form-content",
19
+ "o-form-head",
20
+ "o-form-error-container",
21
+ "o-form-fieldset-container",
22
+ "o-form-fieldset-identifier",
23
+ "o-form-input-identifier",
24
+ "o-form-fieldset-rememberMe",
25
+ "help"
26
+ ],
27
+ "visibleHeading": "Sign In",
28
+ "hiddenHeadingsInDom": ["The page has timed out"]
29
+ }
30
+ },
31
+ "gotrue": {
32
+ "version": "staging, 2026-10-14",
33
+ "ssoBegin": {
34
+ "request": "POST https://ikdoydgubernmxzdqtxz.supabase.co/auth/v1/sso",
35
+ "status": 200,
36
+ "at": "2026-10-14T05:43:32.877Z",
37
+ "notFoundError": {
38
+ "status": 404,
39
+ "body": { "error_code": "sso_provider_not_found", "msg": "No SSO provider assigned for this domain" }
40
+ }
41
+ },
42
+ "acs": {
43
+ "request": "POST https://staging-api.supply-guard.io/auth/v1/sso/saml/acs",
44
+ "status": 302,
45
+ "at": "2026-10-14T05:43:37.323Z",
46
+ "secondsAfterBegin": 4.4,
47
+ "errorRedirect": {
48
+ "_source": "staging auth + edge logs 2026-10-14T16:48:04Z-16:48:38Z: three POSTs of a junk SAMLResponse with a non-UUID RelayState, sent by mistake by ENT-5553's loopback acceptance harness before it pinned Playwright's route.fetch to loopback. GoTrue rejected each before any flow or session state.",
49
+ "status": 303,
50
+ "authLogError": "400: SAML RelayState is not a valid UUID or URL"
51
+ },
52
+ "authEvent": {
53
+ "action": "login",
54
+ "actor_username": "sso-convert-acceptance@okta-e2e.supplyguard.test",
55
+ "actor_via_sso": true,
56
+ "traits": { "provider": "sso:c3580884-a8f1-4288-b018-3ee8495d6fdb" }
57
+ }
58
+ },
59
+ "persona": {
60
+ "email": "sso-convert-acceptance@okta-e2e.supplyguard.test",
61
+ "is_sso_user": true,
62
+ "provider": "sso:c3580884-a8f1-4288-b018-3ee8495d6fdb",
63
+ "mfaFactors": 0,
64
+ "membership": { "organization_id": "34650000-0000-4000-8000-000000000001", "role": "admin" },
65
+ "session": { "aal": "aal1", "amr": ["sso/saml"] }
66
+ }
67
+ }
68
+ }
@@ -9,11 +9,13 @@ import { guidance, remedyLine } from "./errors.mjs";
9
9
  export const HEALTH_REASON_CODES = Object.freeze([
10
10
  "config_invalid", "persona_not_configured", "job_not_loaded", "no_tick_yet", "tick_stale",
11
11
  "mint_backing_off", "last_tick_failed", "ticks_failing", "persona_below_target",
12
+ // BOT-3145: an IdP-refused password held until it changes; a health.json that cannot be read.
13
+ "credential_held", "health_unreadable",
12
14
  ]);
13
15
 
14
16
  // report_blocker → state the reason + remedy to the human ONCE and stop.
15
- export function healthReasons({ label, log, installCommand, docs, configPath, tickCommand, checkout, persona }) {
16
- const vars = { label, log, installCommand, docs, configPath, tickCommand, checkout, persona };
17
+ export function healthReasons({ label, log, installCommand, docs, configPath, tickCommand, checkout, persona, envFile, poolHome }) {
18
+ const vars = { label, log, installCommand, docs, configPath, tickCommand, checkout, persona, envFile, poolHome };
17
19
  return Object.freeze(Object.fromEntries(HEALTH_REASON_CODES.map((code) => {
18
20
  const g = guidance(code, vars, { real: true });
19
21
  return [code, Object.freeze({ agentAction: g.agent_action, remedy: remedyLine(g), vars })];
@@ -48,6 +50,8 @@ export function assessPoolHealth({ status, personas, intervalSeconds, nowMs, job
48
50
  if (requestedPersona && Object.keys(personas).length > 0 && !(personas[requestedPersona] > 0)) {
49
51
  return unhealthy("persona_not_configured", { persona: requestedPersona });
50
52
  }
53
+ // BOT-3145: a health.json the job cannot read stops every okta-saml sign-in (fail closed).
54
+ if (status?.health?.unreadable) return unhealthy("health_unreadable");
51
55
  const lastTick = status?.lastTick;
52
56
  if (!lastTick?.at) return unhealthy("no_tick_yet");
53
57
  if (nowMs - Date.parse(lastTick.at) > STALE_INTERVALS * intervalMs) {
@@ -58,6 +62,11 @@ export function assessPoolHealth({ status, personas, intervalSeconds, nowMs, job
58
62
  const scope = requestedPersona ? [requestedPersona] : Object.keys(personas);
59
63
  for (const persona of scope) {
60
64
  const entry = personaHealth[persona];
65
+ // BOT-3145: the IdP refused (or never confirmed) this persona's password; nothing retries
66
+ // it until the human changes the value, so waiting cannot fix it.
67
+ if (entry?.credentialHeld) {
68
+ return unhealthy("credential_held", { persona, detail: `last ${entry.lastMintError ?? "unknown"}` });
69
+ }
61
70
  if (entry?.nextMintAttemptAt && Date.parse(entry.nextMintAttemptAt) > nowMs) {
62
71
  return unhealthy("mint_backing_off", {
63
72
  persona,