@botbuddy/cli 1.13.2 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.13.2",
3
+ "version": "1.14.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -93,20 +93,59 @@ function writePasswordPrompt(command, args, password, spawnProcess) {
93
93
  export async function writeKeychainSecret(service, token, {
94
94
  spawnProcess = spawn,
95
95
  execFileImpl = execFileAsync,
96
- readSecret = (svc) => readKeychainSecret(svc, { execFileImpl }),
96
+ keychain = null,
97
+ createOnly = false,
98
+ readSecret = (svc) => readKeychainSecret(svc, { execFileImpl, keychain }),
97
99
  } = {}) {
98
100
  if (!service) throw new ProfileCredentialStoreError("keychain write requires a service name");
101
+ // BOT-1569 (Codex P2): `-U` makes `add-generic-password` UPDATE an existing
102
+ // item. `login` wants that (re-login overwrites the owner token), but the
103
+ // config→Keychain migration must NOT: between its empty-slot read and this
104
+ // write, another process could complete a fresh login, and `-U` would then
105
+ // overwrite that newly-issued token with the stale config value. `createOnly`
106
+ // drops `-U` so the write CREATES only — `security` fails with "already exists"
107
+ // (mapped to `keychain_item_exists`) if the slot filled concurrently, and the
108
+ // migration refuses instead of downgrading.
109
+ const update = createOnly ? [] : ["-U"];
99
110
  try {
100
- await writePasswordPrompt(
101
- "security",
102
- ["add-generic-password", "-U", "-s", service, "-a", userInfo().username, "-w"],
103
- token,
104
- spawnProcess,
105
- );
111
+ if (keychain) {
112
+ // BOT-1570: an explicit keychain path is used only by the integration tests
113
+ // against a scratch `security create-keychain` (never the login keychain).
114
+ // `security -w` PROMPT mode reads the secret from stdin, but its optional
115
+ // argument greedily consumes the trailing [keychain] positional — so prompt
116
+ // mode and an explicit keychain cannot coexist. With a keychain path the
117
+ // value is written inline (throwaway scratch token); production (no keychain
118
+ // path) keeps the detached stdin double-feed that keeps the real credential
119
+ // off argv.
120
+ // `-A` grants every application access to the item without an ACL prompt.
121
+ // That is deliberately insecure and reserved for the throwaway scratch
122
+ // keychain the integration tests target — it keeps `security` fully
123
+ // headless (no SecurityAgent GUI dialog on the read-back). Production never
124
+ // takes this branch, so the login keychain is never written with `-A`.
125
+ await execFileImpl(
126
+ "security",
127
+ ["add-generic-password", ...update, "-A", "-s", service, "-a", userInfo().username, "-w", token, keychain],
128
+ );
129
+ } else {
130
+ await writePasswordPrompt(
131
+ "security",
132
+ ["add-generic-password", ...update, "-s", service, "-a", userInfo().username, "-w"],
133
+ token,
134
+ spawnProcess,
135
+ );
136
+ }
106
137
  } catch (error) {
138
+ const detail = String(error?.stderr ?? error?.message ?? error);
139
+ // createOnly: a concurrent writer already filled the slot. Surface a typed
140
+ // code so the migration refuses rather than overwriting the newer token.
141
+ if (createOnly && /already exists/i.test(detail)) {
142
+ throw new ProfileCredentialStoreError(
143
+ `the macOS Keychain already holds an item for ${service}`,
144
+ { cause: error, code: "keychain_item_exists" },
145
+ );
146
+ }
107
147
  // `security` cancels its authorization when the login keychain is locked in
108
148
  // a headless session; guide the user to unlock it rather than re-login.
109
- const detail = String(error?.message ?? error);
110
149
  if (/authorization was cancell?ed/i.test(detail)) {
111
150
  throw new ProfileCredentialStoreError(
112
151
  "macOS Keychain authorization was canceled — unlock your login keychain (`security unlock-keychain`) and retry; a headless session needs it already unlocked",
@@ -128,20 +167,46 @@ export async function writeKeychainSecret(service, token, {
128
167
  }
129
168
  }
130
169
 
131
- export async function readKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
170
+ // `security find-generic-password` exits non-zero both when the item is genuinely
171
+ // absent (exit 44 / "could not be found") AND on a real failure (locked keychain,
172
+ // authorization temporarily unavailable). The two must be distinguished by any
173
+ // caller that would WRITE based on "absent" — treating a transient read failure
174
+ // as "empty" can overwrite a live credential (BOT-1569 Codex P2).
175
+ function isKeychainItemNotFound(error) {
176
+ // Only the ACTUAL not-found indicators: exit 44, or the "could not be found"
177
+ // message. The `SecKeychainSearchCopyNext` API-function prefix alone is NOT a
178
+ // sufficient signal — it can front other diagnostics — so every other failure
179
+ // stays fail-closed (Codex P2).
180
+ if (Number(error?.code) === 44) return true;
181
+ const detail = String(error?.stderr ?? error?.message ?? error ?? "");
182
+ return /could not be found/i.test(detail);
183
+ }
184
+
185
+ export async function readKeychainSecret(service, { execFileImpl = execFileAsync, keychain = null, strict = false } = {}) {
132
186
  if (!service) return null;
187
+ // BOT-1570: an explicit keychain path (scratch keychain, tests only) is the
188
+ // trailing positional `security` searches; production omits it and searches
189
+ // the default search list.
190
+ const args = ["find-generic-password", "-s", service, "-a", userInfo().username, "-w"];
191
+ if (keychain) args.push(keychain);
133
192
  try {
134
- const { stdout } = await execFileImpl("security", ["find-generic-password", "-s", service, "-a", userInfo().username, "-w"]);
193
+ const { stdout } = await execFileImpl("security", args);
135
194
  return stdout.trim() || null;
136
- } catch {
195
+ } catch (error) {
196
+ // Non-strict (default, back-compat): any failure reads as "no value". Strict:
197
+ // only a genuine not-found is null; a real read failure is re-thrown so the
198
+ // caller can fail closed instead of mistaking it for an empty slot.
199
+ if (strict && !isKeychainItemNotFound(error)) throw error;
137
200
  return null;
138
201
  }
139
202
  }
140
203
 
141
- export async function deleteKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
204
+ export async function deleteKeychainSecret(service, { execFileImpl = execFileAsync, keychain = null } = {}) {
142
205
  if (!service) return false;
206
+ const args = ["delete-generic-password", "-s", service, "-a", userInfo().username];
207
+ if (keychain) args.push(keychain); // BOT-1570: scoped to the scratch keychain in tests
143
208
  try {
144
- await execFileImpl("security", ["delete-generic-password", "-s", service, "-a", userInfo().username]);
209
+ await execFileImpl("security", args);
145
210
  return true;
146
211
  } catch {
147
212
  return false;
@@ -50,6 +50,119 @@ export function sanitizeConfigSecrets(config, { platform = process.platform, exi
50
50
  return { config: out, changed };
51
51
  }
52
52
 
53
+ // BOT-1569: migrate, never drop. `sanitizeConfigSecrets` only ever *removed* a
54
+ // config.json `access_token` on a Keychain-available host — it never copied the
55
+ // value into the Keychain first. So a `login` that stored the token in
56
+ // config.json (Keychain unavailable/headless) lost the credential the next time
57
+ // any command ran where the Keychain was reachable. This async variant is what
58
+ // `loadConfig` runs: on a Keychain-available host it COPIES a config.json owner
59
+ // token into the Keychain and read-back verifies it BEFORE removing it from
60
+ // config.json; if the migration can't be verified (locked/failing Keychain) the
61
+ // config copy is preserved so the sole live credential is never deleted. On a
62
+ // host without a Keychain the token stays put (the documented fallback store).
63
+ // `api_key` (written by the retired `register`) has no Keychain home and is
64
+ // always dropped.
65
+ export async function migrateConfigSecrets(config, {
66
+ platform = process.platform,
67
+ exists,
68
+ // `createOnly`: the migration write must not overwrite a token another process
69
+ // wrote after our empty-slot read (BOT-1569 Codex P2 TOCTOU).
70
+ keychainWrite = (value) => writeKeychainSecret(OWNER_TOKEN_SERVICE, value, { createOnly: true }),
71
+ // `strict`: a transient READ failure throws (fail closed) instead of reading as
72
+ // an empty slot, so the migration never overwrites a live stored token because
73
+ // the lookup momentarily failed (BOT-1569 Codex P2).
74
+ keychainRead = () => readKeychainSecret(OWNER_TOKEN_SERVICE, { strict: true }),
75
+ warn = (message) => console.error(message),
76
+ } = {}) {
77
+ const out = { ...(config ?? {}) };
78
+ let changed = false;
79
+ let migrated = false;
80
+
81
+ if ("api_key" in out) { delete out.api_key; changed = true; }
82
+
83
+ const keychain = keychainAvailable(platform, exists);
84
+ if ("access_token" in out) {
85
+ const token = out.access_token;
86
+ if (!keychain) {
87
+ // Documented fallback store: keep the token in the 0600 config.json. This
88
+ // is the BOT-1569 fix — no BOTBUDDY_NO_KEYCHAIN needed to avoid data loss.
89
+ } else if (typeof token !== "string" || token.length === 0) {
90
+ // Not a live credential (null/empty/garbage) — safe to drop.
91
+ delete out.access_token; changed = true;
92
+ } else {
93
+ let existing = null;
94
+ let readFailed = false;
95
+ try {
96
+ existing = await keychainRead();
97
+ } catch (error) {
98
+ // Codex P2: a transient Keychain READ failure (locked keychain,
99
+ // authorization momentarily unavailable) must NOT be misread as an empty
100
+ // slot — that would let the migration overwrite a possibly-newer stored
101
+ // token with the config value. Fail closed: preserve both copies and try
102
+ // again on a later command once the Keychain is readable.
103
+ readFailed = true;
104
+ warn(
105
+ `⚠ Could not read the macOS Keychain to check for an existing owner token (${error?.message ?? error}); leaving the ~/.botbuddy/config.json copy in place. It will migrate once the Keychain is readable.`,
106
+ );
107
+ }
108
+ if (readFailed) {
109
+ // no write, no delete — nothing changes for access_token this pass.
110
+ } else if (existing === token) {
111
+ // Already migrated (this exact token is in the Keychain): drop the
112
+ // config copy without a redundant write.
113
+ delete out.access_token; changed = true; migrated = true;
114
+ } else if (typeof existing === "string" && existing.length > 0) {
115
+ // Codex P2: the Keychain already holds a DIFFERENT owner token. We cannot
116
+ // prove which is newer — and `login` always writes the Keychain FIRST, so
117
+ // overwriting it with the config value could DOWNGRADE a freshly-issued
118
+ // credential to an older one left on disk by a failed metadata save. Fail
119
+ // closed: never overwrite the Keychain, and keep the config copy too so
120
+ // NEITHER token is lost. resolveOwnerToken prefers the Keychain on darwin,
121
+ // so which token authenticates stays deterministic.
122
+ //
123
+ // Codex P2 (follow-up): the preserved metadata — `token_expires_at` — was
124
+ // written for the OLD (config) token. resolveOwnerToken pairs the Keychain
125
+ // token with config's expiry, so a stale past expiry from the old
126
+ // credential would make api.mjs/stack.mjs reject the still-valid Keychain
127
+ // token as expired. Disassociate the expiry (a null expiry skips the client
128
+ // precheck; the Keychain token's real lifetime is enforced server-side).
129
+ if ("token_expires_at" in out) { delete out.token_expires_at; changed = true; }
130
+ warn(
131
+ "⚠ A different owner token is already stored in the macOS Keychain; leaving the ~/.botbuddy/config.json copy in place (and clearing its stale expiry so the Keychain token is used) rather than overwriting it. Run `botbuddy logout` then `botbuddy login` to settle on a single authoritative credential.",
132
+ );
133
+ } else {
134
+ // Keychain empty: migrate the config token in and verify the read-back
135
+ // (AC2) BEFORE removing it from config.json.
136
+ try {
137
+ await keychainWrite(token);
138
+ if ((await keychainRead()) === token) {
139
+ delete out.access_token; changed = true; migrated = true;
140
+ } else {
141
+ warn(
142
+ "⚠ Could not migrate the owner token from ~/.botbuddy/config.json into the macOS Keychain (read-back did not match); leaving it in config.json. Run `botbuddy login` to re-establish it in the Keychain.",
143
+ );
144
+ }
145
+ } catch (error) {
146
+ if (error?.code === "keychain_item_exists") {
147
+ // Codex P2 TOCTOU: another process wrote a token between our empty-slot
148
+ // read and this create-only write. Never overwrite it — preserve the
149
+ // config copy so the concurrently-issued credential wins.
150
+ warn(
151
+ "⚠ Another owner token was written to the macOS Keychain concurrently; leaving the ~/.botbuddy/config.json copy in place rather than overwriting it. Re-run `botbuddy login` to settle on a single credential.",
152
+ );
153
+ } else {
154
+ // Fail-safe: a locked or failing Keychain must never cost the credential.
155
+ warn(
156
+ `⚠ Could not migrate the owner token from ~/.botbuddy/config.json into the macOS Keychain (${error?.message ?? error}); leaving it in config.json for now.`,
157
+ );
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ return { config: out, changed, migrated };
164
+ }
165
+
53
166
  // Persist the owner OAuth token. Non-secret metadata always goes to config.json;
54
167
  // the token itself goes to the Keychain on darwin, or (with a warning) stays in
55
168
  // the 0600 config.json where no Keychain exists.
package/src/commands.mjs CHANGED
@@ -31,7 +31,7 @@ export async function run(argv, {
31
31
  warnStale = maybeWarnStale,
32
32
  errorLog = (line) => console.error(line),
33
33
  } = {}) {
34
- load();
34
+ await load();
35
35
  const [command, ...args] = argv;
36
36
  if (shouldCheckForUpdates(argv)) await warnStale({ version: VERSION });
37
37
 
package/src/config.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, chmodSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
- import { sanitizeConfigSecrets } from "./cli-credentials.mjs";
4
+ import { migrateConfigSecrets } from "./cli-credentials.mjs";
5
5
 
6
6
  const CONFIG_DIR = join(homedir(), ".botbuddy");
7
7
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -14,28 +14,60 @@ export const SERVER_URL = process.env.BOTBUDDY_SERVER_URL
14
14
 
15
15
  let config = {};
16
16
 
17
- export function loadConfig({ platform = process.platform, warn = (m) => console.error(m) } = {}) {
17
+ export async function loadConfig({ platform = process.platform, warn = (m) => console.error(m) } = {}) {
18
18
  let raw = {};
19
+ let rawText = null;
19
20
  try {
20
21
  if (existsSync(CONFIG_FILE)) {
21
- raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
22
+ rawText = readFileSync(CONFIG_FILE, "utf-8");
23
+ raw = JSON.parse(rawText);
22
24
  }
23
25
  } catch {
24
26
  raw = {};
25
27
  }
26
- // BOT-1520 migration: never silently trust secrets left in a plaintext
27
- // config.json by an older CLI. `api_key` (written by the retired `register`)
28
- // is always dropped; `access_token` is dropped where a Keychain can hold it.
29
- // Clear them at rest by rewriting the file, and tell the operator once.
30
- const { config: sanitized, changed } = sanitizeConfigSecrets(raw, { platform });
31
- // Adopt the sanitized config in memory unconditionally a failed best-effort
28
+ // BOT-1520/BOT-1569 migration: never silently trust or silently DROP
29
+ // secrets left in a plaintext config.json. `api_key` (written by the retired
30
+ // `register`) is always dropped; a config.json owner `access_token` is MIGRATED
31
+ // into the Keychain (write + read-back) before removal, and preserved in
32
+ // config.json if the migration can't be verified or no Keychain is available.
33
+ const { config: sanitized, changed, migrated } = await migrateConfigSecrets(raw, { platform, warn });
34
+ // Adopt the migrated config in memory unconditionally — a failed best-effort
32
35
  // rewrite (read-only FS, permissions) must not drop the non-secret metadata.
33
36
  config = sanitized;
34
37
  if (changed) {
35
- try { saveConfig(sanitized); } catch {} // best-effort: clear the secret at rest
36
- warn(
37
- "⚠ Removed a legacy plaintext credential from ~/.botbuddy/config.json. Run `botbuddy login` (and `botbuddy profile setup <profile>` for agents) to re-establish credentials in the Keychain.",
38
- );
38
+ // BOT-1569 (Codex P2): the migration above can span real Keychain I/O, so a
39
+ // concurrent `botbuddy login` may have rewritten config.json (fresh
40
+ // client_id / token_expires_at) since our initial read. An unconditional
41
+ // whole-file save would clobber that newer metadata with our stale snapshot,
42
+ // and resolveOwnerToken would then pair the new Keychain token with an old
43
+ // expiry. Compare-and-swap: only rewrite if config.json is still byte-for-byte
44
+ // what we read; otherwise the newer on-disk file wins and the (idempotent)
45
+ // migration re-runs on the next command. Not a full lock — it narrows the
46
+ // window to an in-process read→write with no I/O between — but it closes the
47
+ // credential/metadata-clobber race.
48
+ let persisted = false;
49
+ try {
50
+ const nowText = existsSync(CONFIG_FILE) ? readFileSync(CONFIG_FILE, "utf-8") : null;
51
+ if (nowText === rawText) {
52
+ saveConfig(sanitized);
53
+ persisted = true;
54
+ } else if (nowText) {
55
+ // BOT-1569 (Codex P2): a concurrent writer (e.g. a `login` saving fresh
56
+ // client_id / token_expires_at) won the file. Don't keep our stale
57
+ // migration snapshot as the process-wide config — adopt the newer on-disk
58
+ // file so THIS command uses the fresh metadata (e.g. resolveOwnerToken
59
+ // pairs the new Keychain token with the new expiry). The idempotent
60
+ // migration re-runs on the next command to finish clearing any plaintext.
61
+ try { config = JSON.parse(nowText); } catch { /* malformed: keep sanitized */ }
62
+ }
63
+ } catch { /* best-effort: clear the secret at rest */ }
64
+ if (persisted) {
65
+ warn(
66
+ migrated
67
+ ? "✓ Migrated the owner token from ~/.botbuddy/config.json into the macOS Keychain."
68
+ : "⚠ Removed a legacy plaintext credential from ~/.botbuddy/config.json. Run `botbuddy login` (and `botbuddy profile setup <profile>` for agents) to re-establish credentials in the Keychain.",
69
+ );
70
+ }
39
71
  }
40
72
  return config;
41
73
  }
package/src/pw/run.mjs CHANGED
@@ -20,7 +20,7 @@ function redact(value, secretValues = []) { return secretValues.reduce((text, se
20
20
  // profile agent the gate historically demanded. bb-pw is not always launched
21
21
  // through the `botbuddy` entrypoint (bb-pw.mjs imports runPw directly), so load
22
22
  // the config here rather than assuming it is already in memory.
23
- function readRegisteredAgentId() { try { loadConfig(); const id = getConfig()?.agent_id; return typeof id === "string" && id ? id : null; } catch { return null; } }
23
+ async function readRegisteredAgentId() { try { await loadConfig(); const id = getConfig()?.agent_id; return typeof id === "string" && id ? id : null; } catch { return null; } }
24
24
  async function gate({ env, host, lane, deps }) {
25
25
  if (env.BB_PW_NO_LOCK === "1") return { allowed: true };
26
26
  let profile, identity;
@@ -39,7 +39,7 @@ async function gate({ env, host, lane, deps }) {
39
39
  // identity. A foreign operator's agent is in none of these, so the gate stays a
40
40
  // real refusal (AC-3).
41
41
  const sessionAgentId = deps.sessionId ?? env.BOTBUDDY_SESSION_ID ?? null;
42
- const registeredAgentId = (deps.readSessionAgentId ?? readRegisteredAgentId)();
42
+ const registeredAgentId = await (deps.readSessionAgentId ?? readRegisteredAgentId)();
43
43
  const selfAgentIds = new Set([identity.agentId, sessionAgentId, registeredAgentId].filter(Boolean));
44
44
  try {
45
45
  const status = await coordinator.status({ host, slot: lane });
package/src/wait.mjs CHANGED
@@ -22,9 +22,13 @@ import { latestPublicCliCommand } from "./public-invocation.mjs";
22
22
  // A protocol is deliberately distinct from package semver: compatible pinned
23
23
  // clients keep working until the server raises this minimum, while a stale
24
24
  // implementation gets a typed, safe upgrade instruction.
25
- export const WAIT_PROTOCOL_VERSION = 1;
25
+ // BOT-1554: protocol 2 makes --session-id mandatory for every non-timer wait (the
26
+ // server keeps MINIMUM_WAIT_PROTOCOL=1 so pre-2 installs are not 426'd).
27
+ export const WAIT_PROTOCOL_VERSION = 2;
26
28
  const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
27
29
  const MIN_RECEIPT_MAX_BYTES = 512;
30
+ // BOT-1554: identical to the server's session-id shape check.
31
+ const SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
32
 
29
33
  const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
30
34
 
@@ -104,7 +108,8 @@ OPTIONS
104
108
  --heartbeat keep this agent session alive while waiting (so it is not reaped)
105
109
  --url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
106
110
  --profile <name> tenant-bound machine profile (normally read from .botbuddy-agent.json)
107
- --session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID
111
+ --session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
112
+ REQUIRED for any non-timer wait (a wait must name the work agent that armed it).
108
113
  --token <key> explicit agent key override; otherwise the profile-specific env is used
109
114
  --help show this help
110
115
 
@@ -248,6 +253,10 @@ async function registerWait(opts, conditions, deadlineIso) {
248
253
  // only ever park to timeout, so it's a hard stop, not the live-only fallback.
249
254
  "invalid_session_agent",
250
255
  "session_agent_requires_profile",
256
+ // BOT-1554: the server refuses a protocol-2 register with no session id (the
257
+ // CLI fails fast before this, but a mismatched/forced body could reach it) —
258
+ // a hard stop, never the untracked live-only fallback.
259
+ "session_id_required",
251
260
  ]);
252
261
  if (INVALID_CONDITION_CODES.has(body.error)) {
253
262
  const err = new Error(body.detail || body.error);
@@ -264,6 +273,16 @@ async function registerWait(opts, conditions, deadlineIso) {
264
273
  err.errorCode = body.error;
265
274
  throw err;
266
275
  }
276
+ // BOT-1554: the credential (or its session's agent) is a shared type='service'
277
+ // carrier — it can hold locks/MCP sessions but cannot be a wait's actor. Carry
278
+ // the carrier id so runWait can name it in the fix. Auth-shaped → exit 3.
279
+ if (body.error === "wait_actor_required") {
280
+ const err = new Error(body.detail || body.error);
281
+ err.auth = true;
282
+ err.errorCode = "wait_actor_required";
283
+ err.carrierAgentId = body.carrier_agent_id ?? null;
284
+ throw err;
285
+ }
267
286
  const err = new Error(body.detail || body.message || body.error || "forbidden");
268
287
  err.auth = true;
269
288
  err.errorCode = body.error || "forbidden";
@@ -637,6 +656,27 @@ export async function runWait(argv) {
637
656
 
638
657
  const needsRelay = conditions.some((c) => c.type !== "timer");
639
658
  if (needsRelay) {
659
+ // BOT-1554: a relay wait MUST name its arming work-graph session. Fail fast —
660
+ // before any profile resolution or network call — so a wait can never be filed
661
+ // under whatever credential the machine holds (the "/waits all Megan" bug). A
662
+ // timer-only wait needs no relay and no session id (handled by !needsRelay).
663
+ if (!opts.sessionId) {
664
+ process.stderr.write(
665
+ "botbuddy wait: --session-id is required (or set $BOTBUDDY_SESSION_ID) — the work-graph session id returned by register_agent\n",
666
+ );
667
+ emitReceipt({
668
+ schema_version: 1,
669
+ outcome: "error",
670
+ error: "session_id_required",
671
+ recovery: "register_agent → export BOTBUDDY_SESSION_ID=<session_id>",
672
+ });
673
+ process.exit(EXIT.INVALID);
674
+ }
675
+ if (!SESSION_UUID.test(opts.sessionId)) {
676
+ process.stderr.write(`botbuddy wait: --session-id must be a uuid (got '${opts.sessionId}')\n`);
677
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_agent", detail: "session_id must be a uuid" });
678
+ process.exit(EXIT.INVALID);
679
+ }
640
680
  try {
641
681
  opts.agentProfile = await resolveAgentProfile({
642
682
  explicitProfile: opts.profile,
@@ -759,6 +799,33 @@ export async function runWait(argv) {
759
799
  process.exit(EXIT.INVALID);
760
800
  }
761
801
  if (err && err.auth) {
802
+ // BOT-1554: these are all SESSION-IDENTITY failures — the supplied session id
803
+ // (or the credential behind it) is a shared service carrier, an
804
+ // ended/foreign session, or a wrong-tenant session. Re-running profile setup
805
+ // and retrying with the SAME $BOTBUDDY_SESSION_ID just repeats the 403 (that
806
+ // command can't replace the parent shell's session variable), so lead with the
807
+ // reliable fix: register a WORK agent and export its NEW session id.
808
+ const SESSION_IDENTITY_ERRORS = new Set([
809
+ "wait_actor_required", "session_agent_forbidden", "session_agent_tenant_mismatch",
810
+ ]);
811
+ if (SESSION_IDENTITY_ERRORS.has(err.errorCode)) {
812
+ const cause = err.errorCode === "wait_actor_required"
813
+ ? `the wait was armed by a shared service carrier (${err.carrierAgentId})`
814
+ : err.errorCode === "session_agent_tenant_mismatch"
815
+ ? "the session id ($BOTBUDDY_SESSION_ID) resolves to a different tenant than this wait"
816
+ : "the session id ($BOTBUDDY_SESSION_ID) is not a live agent session you own";
817
+ process.stderr.write(
818
+ `botbuddy wait: ${cause}; register a work agent (register_agent) and export the returned id as $BOTBUDDY_SESSION_ID — re-running '${profileRecovery(opts.agentProfile)}' will not replace the shell's session variable\n`,
819
+ );
820
+ emitReceipt(withPrincipalReceipt({
821
+ schema_version: 1,
822
+ outcome: "error",
823
+ error: err.errorCode,
824
+ ...(err.carrierAgentId ? { carrier_agent_id: err.carrierAgentId } : {}),
825
+ recovery: "register_agent → export BOTBUDDY_SESSION_ID=<new session_id>",
826
+ }, opts.agentProfile, { sessionTenant: opts.agentProfile.tenant, agentId: null }));
827
+ process.exit(EXIT.AUTH);
828
+ }
762
829
  const error = typedProfileError(err.errorCode);
763
830
  process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run '${profileRecovery(opts.agentProfile)}'\n`);
764
831
  emitReceipt(profileErrorReceipt(opts.agentProfile, error));