@botbuddy/cli 1.8.6 → 1.8.8

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.8.6",
3
+ "version": "1.8.8",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,8 +12,20 @@ const LOCK_MAX_ATTEMPTS = 100;
12
12
  const STALE_LOCK_MS = 30_000;
13
13
  const execFileAsync = promisify(execFile);
14
14
 
15
+ // BOT-1520: is the macOS Keychain backend usable on this host? The CLI stores
16
+ // durable secrets (profile agent keys, and the owner OAuth token) in the
17
+ // Keychain on darwin; elsewhere callers fall back to a documented minimal
18
+ // footprint. Keep this a pure predicate so both the profile store and the
19
+ // owner-token store share one definition of "keychain available".
20
+ export function keychainAvailable(platform = process.platform, exists = existsSync, env = process.env) {
21
+ // Escape hatch (headless CI where the login keychain can't unlock, or a
22
+ // deterministic test): force the documented non-Keychain fallback.
23
+ if (env?.BOTBUDDY_NO_KEYCHAIN === "1") return false;
24
+ return platform === "darwin" && exists("/usr/bin/security");
25
+ }
26
+
15
27
  export function ensureProfileCredentialBackend({ platform = process.platform, exists = existsSync } = {}) {
16
- if (platform !== "darwin" || !exists("/usr/bin/security")) {
28
+ if (!keychainAvailable(platform, exists)) {
17
29
  throw new Error("profile setup requires the macOS Keychain credential backend");
18
30
  }
19
31
  }
@@ -58,9 +70,12 @@ function writePasswordPrompt(command, args, password, spawnProcess) {
58
70
  });
59
71
  }
60
72
 
61
- export async function writeKeychain(profile, token, { spawnProcess = spawn } = {}) {
62
- const service = keychainService(profile);
63
- if (!service) throw new ProfileCredentialStoreError(`unknown profile keychain service for "${profile}"`);
73
+ // BOT-1520: generic Keychain secret primitives keyed by an explicit service
74
+ // name, so both the profile agent-key store and the owner OAuth-token store
75
+ // share the single implementation of the `security -w` double-read prompt and
76
+ // the actionable error mapping — no second copy to drift.
77
+ export async function writeKeychainSecret(service, token, { spawnProcess = spawn } = {}) {
78
+ if (!service) throw new ProfileCredentialStoreError("keychain write requires a service name");
64
79
  try {
65
80
  await writePasswordPrompt(
66
81
  "security",
@@ -78,21 +93,43 @@ export async function writeKeychain(profile, token, { spawnProcess = spawn } = {
78
93
  { cause: error },
79
94
  );
80
95
  }
81
- throw new ProfileCredentialStoreError(`could not write the profile credential to the macOS Keychain: ${detail}`, { cause: error });
96
+ throw new ProfileCredentialStoreError(`could not write the credential to the macOS Keychain: ${detail}`, { cause: error });
82
97
  }
83
98
  }
84
99
 
85
- async function readKeychain(profile) {
86
- const service = keychainService(profile);
100
+ export async function readKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
87
101
  if (!service) return null;
88
102
  try {
89
- const { stdout } = await execFileAsync("security", ["find-generic-password", "-s", service, "-a", userInfo().username, "-w"]);
103
+ const { stdout } = await execFileImpl("security", ["find-generic-password", "-s", service, "-a", userInfo().username, "-w"]);
90
104
  return stdout.trim() || null;
91
105
  } catch {
92
106
  return null;
93
107
  }
94
108
  }
95
109
 
110
+ export async function deleteKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
111
+ if (!service) return false;
112
+ try {
113
+ await execFileImpl("security", ["delete-generic-password", "-s", service, "-a", userInfo().username]);
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ export async function writeKeychain(profile, token, { spawnProcess = spawn } = {}) {
121
+ const service = keychainService(profile);
122
+ if (!service) throw new ProfileCredentialStoreError(`unknown profile keychain service for "${profile}"`);
123
+ return writeKeychainSecret(service, token, { spawnProcess });
124
+ }
125
+
126
+ async function readKeychain(profile) {
127
+ // Honor the keychain escape hatch (BOTBUDDY_NO_KEYCHAIN / non-darwin) so the
128
+ // per-profile environment key is the sole credential source there.
129
+ if (!keychainAvailable()) return null;
130
+ return readKeychainSecret(keychainService(profile));
131
+ }
132
+
96
133
  function profileCredentialStorePath(home = homedir()) {
97
134
  return join(home, ".botbuddy", "agent-profiles.json");
98
135
  }
package/src/api.mjs CHANGED
@@ -1,23 +1,30 @@
1
1
  import { getConfig, SERVER_URL } from "./config.mjs";
2
+ import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
2
3
  import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
3
4
 
4
- function authHeader() {
5
- const cfg = getConfig();
6
- if (cfg.access_token) {
7
- if (cfg.token_expires_at && Date.now() >= cfg.token_expires_at) {
5
+ // BOT-1520: auth headers come from the Keychain, never a plaintext config.json
6
+ // secret. The owner OAuth token (from `botbuddy login`) is preferred; the
7
+ // tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
8
+ async function authHeader() {
9
+ const owner = await resolveOwnerToken({ getConfig });
10
+ if (owner) {
11
+ if (owner.expiresAt && Date.now() >= owner.expiresAt) {
12
+ const agentKey = await resolveAgentKey();
13
+ if (agentKey) return { "x-agent-api-key": agentKey };
8
14
  die(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
9
15
  }
10
- if (cfg.token_expires_at && cfg.token_expires_at - Date.now() < 5 * 60 * 1000) {
16
+ if (owner.expiresAt && owner.expiresAt - Date.now() < 5 * 60 * 1000) {
11
17
  console.error(`${yellow("⚠")} Token expires in <5 minutes. Run ${cyan("botbuddy login")} soon.`);
12
18
  }
13
- return { Authorization: `Bearer ${cfg.access_token}` };
19
+ return { Authorization: `Bearer ${owner.token}` };
14
20
  }
15
- if (cfg.api_key) return { "x-agent-api-key": cfg.api_key };
21
+ const agentKey = await resolveAgentKey();
22
+ if (agentKey) return { "x-agent-api-key": agentKey };
16
23
  die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
17
24
  }
18
25
 
19
26
  export async function callTool(toolName, args = {}) {
20
- const headers = { "Content-Type": "application/json", ...authHeader() };
27
+ const headers = { "Content-Type": "application/json", ...(await authHeader()) };
21
28
  const body = {
22
29
  jsonrpc: "2.0",
23
30
  id: 1,
@@ -47,15 +54,19 @@ export async function callTool(toolName, args = {}) {
47
54
  // * { ok:false, auth:true } — not authenticated / token expired
48
55
  // * { ok:false, status } — HTTP/JSON-RPC/transport error (status may be null)
49
56
  export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, signal } = {}) {
50
- const cfg = getConfig();
57
+ const owner = await resolveOwnerToken({ getConfig });
51
58
  let auth;
52
- if (cfg.access_token) {
53
- if (cfg.token_expires_at && Date.now() >= cfg.token_expires_at) return { ok: false, auth: true, error: "token_expired" };
54
- auth = { Authorization: `Bearer ${cfg.access_token}` };
55
- } else if (cfg.api_key) {
56
- auth = { "x-agent-api-key": cfg.api_key };
59
+ if (owner && !(owner.expiresAt && Date.now() >= owner.expiresAt)) {
60
+ auth = { Authorization: `Bearer ${owner.token}` };
57
61
  } else {
58
- return { ok: false, auth: true, error: "not_authenticated" };
62
+ const agentKey = await resolveAgentKey();
63
+ if (agentKey) {
64
+ auth = { "x-agent-api-key": agentKey };
65
+ } else if (owner) {
66
+ return { ok: false, auth: true, error: "token_expired" };
67
+ } else {
68
+ return { ok: false, auth: true, error: "not_authenticated" };
69
+ }
59
70
  }
60
71
  const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
61
72
  let res;
@@ -80,7 +91,7 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
80
91
  }
81
92
 
82
93
  export async function listResources() {
83
- const headers = { "Content-Type": "application/json", ...authHeader() };
94
+ const headers = { "Content-Type": "application/json", ...(await authHeader()) };
84
95
  const body = { jsonrpc: "2.0", id: 1, method: "resources/list", params: {} };
85
96
  const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
86
97
  const data = await res.json();
@@ -88,7 +99,7 @@ export async function listResources() {
88
99
  }
89
100
 
90
101
  export async function readResource(uri) {
91
- const headers = { "Content-Type": "application/json", ...authHeader() };
102
+ const headers = { "Content-Type": "application/json", ...(await authHeader()) };
92
103
  const body = { jsonrpc: "2.0", id: 1, method: "resources/read", params: { uri } };
93
104
  const res = await fetch(SERVER_URL, { method: "POST", headers, body: JSON.stringify(body) });
94
105
  const data = await res.json();
package/src/auth.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SERVER_URL, saveConfig as realSaveConfig, getConfig as realGetConfig } from "./config.mjs";
2
+ import { persistOwnerToken } from "./cli-credentials.mjs";
2
3
  import { green, dim, cyan, yellow, bold, red } from "./utils.mjs";
3
4
  import {
4
5
  createLoopbackReceiver,
@@ -63,6 +64,7 @@ export async function doLogin(options = {}, deps = {}) {
63
64
  errorLog = (s) => console.error(s),
64
65
  timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
65
66
  now = () => Date.now(),
67
+ persist = persistOwnerToken,
66
68
  } = deps;
67
69
  const noBrowser = Boolean(options.noBrowser);
68
70
 
@@ -154,14 +156,19 @@ export async function doLogin(options = {}, deps = {}) {
154
156
  ? now() + tokenData.expires_in * 1000
155
157
  : now() + 24 * 60 * 60 * 1000; // default 24h if server omits expires_in
156
158
 
157
- saveConfig({
158
- ...getConfig(),
159
- access_token: tokenData.access_token,
160
- client_id: clientId,
161
- token_expires_at: expiresAt,
162
- });
163
-
164
- log(`\n${green("✓")} Logged in successfully! Token saved to ${dim("~/.botbuddy/config.json")}`);
159
+ // BOT-1520: the durable secret goes to the Keychain (darwin); config.json
160
+ // keeps only non-secret metadata. On a non-darwin host without a Keychain,
161
+ // persistOwnerToken falls back to the 0600 config.json and warns.
162
+ const { storedInKeychain } = await persist(
163
+ { token: tokenData.access_token, expiresAt, clientId },
164
+ { getConfig, saveConfig, warn: errorLog },
165
+ );
166
+
167
+ log(
168
+ `\n${green("✓")} Logged in successfully! Token stored in ${
169
+ storedInKeychain ? "the macOS Keychain" : dim("~/.botbuddy/config.json")
170
+ }.`,
171
+ );
165
172
  return { clientId, redirectUri };
166
173
  } finally {
167
174
  // AC-11/AC-13: always release the socket so re-running login starts clean.
@@ -1 +1 @@
1
- {"schema_version":1,"source_version":"1.8.3","source_identity":"8304ea6fbec329d54d6361cb6cbf5a6789331b0851d09caf151f0663ade18ec2"}
1
+ {"schema_version":1,"source_version":"1.8.7","source_identity":"e6b611000678281287808866482334e5a40e265ef3c0328c4e85f53a5bf23cd0"}
@@ -0,0 +1,135 @@
1
+ // BOT-1520: single source of truth for reading and writing the CLI's durable
2
+ // secrets, so no secret ever lands in plaintext ~/.botbuddy/config.json on a
3
+ // host with a Keychain.
4
+ //
5
+ // There are two secrets the CLI handles:
6
+ //
7
+ // 1. The owner OAuth access token minted by `botbuddy login`. On darwin it is
8
+ // stored in the login Keychain under a dedicated service; config.json keeps
9
+ // only its non-secret metadata (client_id, token_expires_at).
10
+ // 2. The tenant-bound agent key minted by `botbuddy profile setup`, already
11
+ // stored in the profile Keychain store (agent-credential-store.mjs). The
12
+ // retired `botbuddy register` used to write this as a no-expiry `api_key`
13
+ // into config.json; it no longer exists.
14
+ //
15
+ // On a non-darwin host (no Keychain today) `login` falls back to a documented
16
+ // minimal footprint: the token stays in the 0600 config.json and a warning is
17
+ // printed. Agent/CI usage on any host works from the profile Keychain or the
18
+ // per-profile environment key, never from a config.json secret.
19
+
20
+ import {
21
+ keychainAvailable,
22
+ writeKeychainSecret,
23
+ readKeychainSecret,
24
+ deleteKeychainSecret,
25
+ } from "./agent-credential-store.mjs";
26
+ import { resolveAgentProfile } from "./wait-profile.mjs";
27
+
28
+ // Dedicated Keychain service for the owner OAuth token. Distinct from the
29
+ // per-profile agent-key services (BOTBUDDY_BB_AGENT_KEY / BOTBUDDY_SG_AGENT_KEY)
30
+ // so the human owner session and the machine agent identity never collide.
31
+ export const OWNER_TOKEN_SERVICE = "BOTBUDDY_OWNER_TOKEN";
32
+
33
+ // Config keys that must never carry secret material at rest.
34
+ const SECRET_CONFIG_KEYS = ["api_key", "access_token"];
35
+
36
+ // Strip secret material from a config object. `api_key` is always removed (the
37
+ // `register` path that wrote it is retired). `access_token` is removed only when
38
+ // a Keychain is available to hold it instead; on a non-darwin host it remains
39
+ // the documented fallback store, so it is preserved there.
40
+ export function sanitizeConfigSecrets(config, { platform = process.platform, exists } = {}) {
41
+ const out = { ...(config ?? {}) };
42
+ let changed = false;
43
+ const keychain = keychainAvailable(platform, exists);
44
+ for (const key of SECRET_CONFIG_KEYS) {
45
+ if (!(key in out)) continue;
46
+ if (key === "access_token" && !keychain) continue; // documented non-darwin fallback
47
+ delete out[key];
48
+ changed = true;
49
+ }
50
+ return { config: out, changed };
51
+ }
52
+
53
+ // Persist the owner OAuth token. Non-secret metadata always goes to config.json;
54
+ // the token itself goes to the Keychain on darwin, or (with a warning) stays in
55
+ // the 0600 config.json where no Keychain exists.
56
+ export async function persistOwnerToken(
57
+ { token, expiresAt, clientId },
58
+ {
59
+ platform = process.platform,
60
+ exists,
61
+ getConfig,
62
+ saveConfig,
63
+ keychainWrite = (value) => writeKeychainSecret(OWNER_TOKEN_SERVICE, value),
64
+ warn = (message) => console.error(message),
65
+ } = {},
66
+ ) {
67
+ if (typeof token !== "string" || token.length === 0) {
68
+ throw new Error("persistOwnerToken requires a non-empty token");
69
+ }
70
+ // Start from a sanitized copy so a stale on-disk secret is never carried
71
+ // forward, then layer on the fresh non-secret metadata.
72
+ const keychain = keychainAvailable(platform, exists);
73
+ const { config: base } = sanitizeConfigSecrets(getConfig(), { platform, exists });
74
+ const next = { ...base };
75
+ if (clientId !== undefined) next.client_id = clientId;
76
+ if (expiresAt !== undefined) next.token_expires_at = expiresAt;
77
+
78
+ if (keychain) {
79
+ await keychainWrite(token);
80
+ delete next.access_token;
81
+ } else {
82
+ warn(
83
+ "⚠ macOS Keychain unavailable on this host — storing the OAuth token in ~/.botbuddy/config.json (0600). This is the documented non-darwin fallback.",
84
+ );
85
+ next.access_token = token;
86
+ }
87
+ saveConfig(next);
88
+ return { storedInKeychain: keychain };
89
+ }
90
+
91
+ // Resolve the owner OAuth token for outbound auth. On darwin it comes from the
92
+ // Keychain only — a stale config.json access_token is ignored, never silently
93
+ // trusted (BOT-1520 AC5). On a non-darwin host the config.json fallback is the
94
+ // legitimate store. Expiry metadata always comes from config.json.
95
+ export async function resolveOwnerToken({
96
+ platform = process.platform,
97
+ exists,
98
+ getConfig,
99
+ keychainRead = () => readKeychainSecret(OWNER_TOKEN_SERVICE),
100
+ } = {}) {
101
+ const cfg = getConfig() ?? {};
102
+ const expiresAt = typeof cfg.token_expires_at === "number" ? cfg.token_expires_at : null;
103
+ let token = null;
104
+ if (keychainAvailable(platform, exists)) {
105
+ token = await keychainRead();
106
+ } else if (typeof cfg.access_token === "string" && cfg.access_token) {
107
+ token = cfg.access_token;
108
+ }
109
+ if (!token) return null;
110
+ return { token, expiresAt };
111
+ }
112
+
113
+ // Remove the owner OAuth token from every store (`botbuddy logout`).
114
+ export async function clearOwnerToken({
115
+ platform = process.platform,
116
+ exists,
117
+ keychainDelete = () => deleteKeychainSecret(OWNER_TOKEN_SERVICE),
118
+ } = {}) {
119
+ if (keychainAvailable(platform, exists)) {
120
+ await keychainDelete();
121
+ }
122
+ }
123
+
124
+ // Resolve the tenant-bound agent key from the profile Keychain store (or the
125
+ // per-profile environment key), without throwing when no profile is configured.
126
+ // This is the sole source of the agent credential now that `register` is gone.
127
+ export async function resolveAgentKey(options = {}) {
128
+ try {
129
+ const { token } = await resolveAgentProfile(options);
130
+ return token || null;
131
+ } catch (error) {
132
+ if (error?.code === "profile_required" || error?.code === "unknown_profile") return null;
133
+ throw error;
134
+ }
135
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { createHash, randomBytes } from "crypto";
16
16
  import { getConfig, SERVER_URL } from "./config.mjs";
17
+ import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
17
18
  import { green, red, cyan, dim, bold, yellow, die } from "./utils.mjs";
18
19
  import { VERSION } from "./version.mjs";
19
20
 
@@ -64,10 +65,17 @@ function isAddressInUseError(text) {
64
65
  }
65
66
 
66
67
  export async function runBridge(args) {
67
- const cfg = getConfig();
68
- if (!cfg.api_key && !cfg.access_token) {
69
- die(`Not authenticated. Run: ${cyan("botbuddy start")} or ${cyan("botbuddy login")}`);
68
+ const cfg = getConfig(); // non-secret metadata only (agent_name)
69
+ // BOT-1520: authenticate from the Keychain — the profile agent key
70
+ // (`botbuddy profile setup`) or the owner OAuth token (`botbuddy login`).
71
+ // Resolve ONCE here for the life of the bridge and cache it, so the relay
72
+ // helpers don't shell out to `security` on every request.
73
+ const agentKey = await resolveAgentKey();
74
+ const owner = agentKey ? null : await resolveOwnerToken({ getConfig });
75
+ if (!agentKey && !owner) {
76
+ die(`Not authenticated. Run: ${cyan("botbuddy start")} or ${cyan("botbuddy login")} (agents: ${cyan("botbuddy profile setup <profile>")}).`);
70
77
  }
78
+ bridgeAuth = { agentKey, owner };
71
79
 
72
80
  // Parse args
73
81
  let wsPort = 4500;
@@ -519,16 +527,27 @@ export async function runBridge(args) {
519
527
 
520
528
  // ─── HTTP helpers ───
521
529
 
522
- function authHeaders() {
523
- const cfg = getConfig();
530
+ // BOT-1520: resolved once by runBridge and reused, so the relay helpers never
531
+ // shell out to `security` per request. Null until the bridge starts (or in a
532
+ // direct unit-test call), in which case authHeaders resolves live.
533
+ let bridgeAuth = null;
534
+
535
+ // BOT-1520: the bridge is agent-context, so it prefers the tenant-bound agent
536
+ // key from the profile Keychain store and falls back to the owner OAuth token.
537
+ export async function authHeaders() {
524
538
  const headers = { "Content-Type": "application/json" };
525
- if (cfg.api_key) headers["x-agent-api-key"] = cfg.api_key;
526
- else if (cfg.access_token) headers["Authorization"] = `Bearer ${cfg.access_token}`;
539
+ const agentKey = bridgeAuth ? bridgeAuth.agentKey : await resolveAgentKey();
540
+ if (agentKey) {
541
+ headers["x-agent-api-key"] = agentKey;
542
+ } else {
543
+ const owner = bridgeAuth ? bridgeAuth.owner : await resolveOwnerToken({ getConfig });
544
+ if (owner) headers["Authorization"] = `Bearer ${owner.token}`;
545
+ }
527
546
  return headers;
528
547
  }
529
548
 
530
549
  async function relayPost(path, body) {
531
- const headers = authHeaders();
550
+ const headers = await authHeaders();
532
551
  // Add HMAC signing if we have a session secret and session_id
533
552
  if (sessionSecret && body?.session_id) {
534
553
  const hmacHeaders = await signRequest(body.session_id);
@@ -545,7 +564,7 @@ async function relayPost(path, body) {
545
564
  async function relayGet(path) {
546
565
  const res = await fetch(`${RELAY_URL}${path}`, {
547
566
  method: "GET",
548
- headers: authHeaders(),
567
+ headers: await authHeaders(),
549
568
  });
550
569
  return res.json();
551
570
  }
package/src/commands.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { callTool, callToolJson, readResource } from "./api.mjs";
2
2
  import { doLogin } from "./auth.mjs";
3
3
  import { runBridge } from "./codex-bridge.mjs";
4
- import { loadConfig, getConfig, clearConfig, saveConfig, getConfigPath, SERVER_URL } from "./config.mjs";
4
+ import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
5
+ import { resolveOwnerToken, resolveAgentKey, clearOwnerToken } from "./cli-credentials.mjs";
5
6
  import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
6
7
  import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
7
8
  import { cmdStack } from "./stack.mjs";
@@ -24,7 +25,6 @@ export async function run(argv) {
24
25
  case "logout": return cmdLogout();
25
26
  case "status": return cmdStatus();
26
27
  // Agent-only commands (used by MCP agents, not humans)
27
- case "register": return cmdRegister(args);
28
28
  case "heartbeat": return cmdHeartbeat(args);
29
29
  case "lock": return cmdLock(args);
30
30
  case "locks": return cmdLocks(args);
@@ -164,8 +164,10 @@ ${bold("USAGE")}
164
164
  ${bold("HOW IT WORKS")}
165
165
  Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
166
166
  your default browser at the BotBuddy authorization page, and waits for you to
167
- sign in as an allowed user. On success the token is saved to
168
- ${dim("~/.botbuddy/config.json")} and the browser tab can be closed.
167
+ sign in as an allowed user. On success the token is stored in the macOS
168
+ Keychain (only non-secret metadata is kept in ${dim("~/.botbuddy/config.json")})
169
+ and the browser tab can be closed. On a host without a Keychain the token
170
+ falls back to the 0600 config file and the tool tells you so.
169
171
 
170
172
  ${bold("OPTIONS")}
171
173
  --no-browser Don't launch a browser; print the authorization URL to open
@@ -192,14 +194,17 @@ ${bold("RECOVERY")}
192
194
 
193
195
  async function cmdStart(args) {
194
196
  console.log(`${bold("botbuddy")} ${dim(`v${VERSION}`)}\n`);
195
- const cfg = getConfig();
196
197
 
197
- // Auto-login if not authenticated
198
+ // Auto-login if not authenticated. BOT-1520: credentials live in the Keychain
199
+ // (owner token from `login`, agent key from `profile setup`), so probe those
200
+ // rather than a plaintext config secret.
198
201
  try {
199
- if (!cfg.access_token && !cfg.api_key) {
202
+ const owner = await resolveOwnerToken({ getConfig });
203
+ const agentKey = owner ? null : await resolveAgentKey();
204
+ if (!owner && !agentKey) {
200
205
  console.log(dim("→ No credentials found. Starting login...\n"));
201
206
  await doLogin();
202
- } else if (cfg.token_expires_at && cfg.token_expires_at <= Date.now()) {
207
+ } else if (owner && owner.expiresAt && owner.expiresAt <= Date.now() && !(await resolveAgentKey())) {
203
208
  console.log(dim("→ Token expired. Re-authenticating...\n"));
204
209
  await doLogin();
205
210
  }
@@ -211,14 +216,15 @@ async function cmdStart(args) {
211
216
  return runBridge(args);
212
217
  }
213
218
 
214
- function cmdStatus() {
219
+ async function cmdStatus() {
215
220
  const cfg = getConfig();
216
- if (cfg.access_token) {
217
- console.log(`${green("✓")} Authenticated via OAuth`);
221
+ const owner = await resolveOwnerToken({ getConfig });
222
+ if (owner) {
223
+ console.log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
218
224
  if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
219
225
  if (cfg.client_id) console.log(` Client: ${dim(cfg.client_id)}`);
220
- if (cfg.token_expires_at) {
221
- const remaining = cfg.token_expires_at - Date.now();
226
+ if (owner.expiresAt) {
227
+ const remaining = owner.expiresAt - Date.now();
222
228
  if (remaining <= 0) {
223
229
  console.log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy start")}`);
224
230
  } else {
@@ -228,17 +234,21 @@ function cmdStatus() {
228
234
  }
229
235
  }
230
236
  console.log(` Config: ${dim(getConfigPath())}`);
231
- } else if (cfg.api_key) {
232
- console.log(`${green("✓")} Authenticated via API key`);
237
+ return;
238
+ }
239
+ const agentKey = await resolveAgentKey();
240
+ if (agentKey) {
241
+ console.log(`${green("✓")} Authenticated via agent key ${dim("(Keychain profile store)")}`);
233
242
  if (cfg.agent_name) console.log(` Agent: ${cyan(cfg.agent_name)}`);
234
- } else {
235
- console.log(`${red("✗")} Not authenticated`);
236
- console.log(` Run: ${cyan("botbuddy start")}`);
243
+ return;
237
244
  }
245
+ console.log(`${red("✗")} Not authenticated`);
246
+ console.log(` Run: ${cyan("botbuddy start")}`);
238
247
  }
239
248
 
240
- function cmdLogout() {
249
+ async function cmdLogout() {
241
250
  clearConfig();
251
+ await clearOwnerToken();
242
252
  console.log(`${green("✓")} Logged out. Credentials removed.`);
243
253
  }
244
254
 
@@ -280,22 +290,6 @@ async function cmdAgentAuth(args) {
280
290
  console.log(`${green("✓")} ${result.profile} (${result.tenant}) machine credential ${result.removed ? "removed" : "was not present"}.`);
281
291
  }
282
292
 
283
- async function cmdRegister(args) {
284
- const name = args[0];
285
- if (!name) die("Usage: botbuddy register <name> [type]");
286
- const type = args[1] || "custom";
287
- const data = await callTool("register_agent", { name, type });
288
- const text = data?.result?.content?.[0]?.text;
289
- if (text) {
290
- try {
291
- const parsed = JSON.parse(text);
292
- if (parsed.api_key) {
293
- saveConfig({ ...getConfig(), api_key: parsed.api_key, agent_id: parsed.agent_id, agent_name: name });
294
- }
295
- } catch {}
296
- }
297
- }
298
-
299
293
  function cmdHeartbeat(args) {
300
294
  return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
301
295
  }
package/src/config.mjs CHANGED
@@ -1,6 +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
5
 
5
6
  const CONFIG_DIR = join(homedir(), ".botbuddy");
6
7
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
@@ -13,13 +14,28 @@ export const SERVER_URL = process.env.BOTBUDDY_SERVER_URL
13
14
 
14
15
  let config = {};
15
16
 
16
- export function loadConfig() {
17
+ export function loadConfig({ platform = process.platform, warn = (m) => console.error(m) } = {}) {
18
+ let raw = {};
17
19
  try {
18
20
  if (existsSync(CONFIG_FILE)) {
19
- config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
21
+ raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
20
22
  }
21
23
  } catch {
22
- config = {};
24
+ raw = {};
25
+ }
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
32
+ // rewrite (read-only FS, permissions) must not drop the non-secret metadata.
33
+ config = sanitized;
34
+ 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
+ );
23
39
  }
24
40
  return config;
25
41
  }
package/src/pw/daemon.mjs CHANGED
@@ -1,13 +1,62 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import net from "node:net";
3
3
  import { createRequire } from "node:module";
4
- import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
  import { homedir } from "node:os";
7
+ import { normaliseLane } from "./args.mjs";
7
8
  export const daemonDir = (env = process.env) => env.BB_PW_DAEMON_DIR || join(homedir(), ".botbuddy", "pw-daemon");
8
9
  export function resolveCliBin(env = process.env) { if (env.BB_PW_CLI_BIN) return { cmd: "node", args: [env.BB_PW_CLI_BIN] }; const require = createRequire(import.meta.url); const pkg = require.resolve("@playwright/cli/package.json"), meta = JSON.parse(readFileSync(pkg, "utf8")), bin = typeof meta.bin === "string" ? meta.bin : meta.bin["playwright-cli"] || Object.values(meta.bin)[0]; return { cmd: "node", args: [join(dirname(pkg), bin)] }; }
9
10
  export function spawnExec(plan, env = process.env) { const { cmd, args } = resolveCliBin(env); return new Promise((resolve) => { const child = spawn(cmd, [...args, ...plan.execArgv], { stdio: "inherit", env: { ...env, PWTEST_DAEMON_SESSION_DIR: daemonDir(env) } }); child.on("exit", (code) => resolve(code ?? 1)); child.on("error", () => resolve(127)); }); }
10
- export function readSession(session, env = process.env) { const root = daemonDir(env); if (!existsSync(root)) return null; for (const item of readdirSync(root)) { try { return JSON.parse(readFileSync(join(root, item, `${session}.session`), "utf8")); } catch {} } return null; }
11
- export function sendToDaemon(socketPath, positional, { connect = net.createConnection, cwd = process.cwd(), timeoutMs = 30000 } = {}) { return new Promise((resolve) => { let done = false, buffer = "", socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => socket.write(JSON.stringify({ id: 1, method: "run", params: { args: { _: positional }, cwd } }) + "\n")); socket.on("data", (data) => { buffer += String(data); const newline = buffer.indexOf("\n"); if (newline < 0) return; try { const reply = JSON.parse(buffer.slice(0, newline)), text = reply.result?.text ?? ""; finish(reply.error || /^### Error\b/m.test(text) ? { ok: false, error: reply.error?.message ?? reply.error ?? text } : { ok: true, text }); } catch { finish({ ok: false, error: "bb-pw: malformed daemon reply" }); } }); socket.on("error", (error) => finish({ ok: false, error: error.message })); setTimeout(() => finish({ ok: false, error: "bb-pw: daemon socket timeout" }), timeoutMs).unref(); }); }
11
+ // BOT-1522: playwright-cli keeps one sub-dir per invocation context under the
12
+ // daemon dir, and only ever cleans up its OWN sub-dir's `<session>.session`. A
13
+ // lane opened from another worktree whose daemon has since exited leaves a file
14
+ // pointing at a socket that no longer exists — and it can sort before today's
15
+ // live one. Selection is therefore by liveness, never by readdir order, and the
16
+ // caller gets a remediation naming the lane and the command, never a raw syscall.
17
+ export const laneOf = (session) => normaliseLane(session ?? "");
18
+ // Any connect-phase failure means the daemon is not there to answer; name the
19
+ // shape in prose so the syscall code never reaches the operator (AC-3/AC-6).
20
+ const CONNECT_FAILURE = { ENOENT: "its socket is gone", ECONNREFUSED: "its socket refused the connection", ENOTSOCK: "its socket refused the connection", ECONNRESET: "the connection dropped", EPIPE: "the connection dropped" };
21
+ const connectFailure = (error) => (error?.code ? CONNECT_FAILURE[error.code] ?? "the connection failed" : null);
22
+ export function notRunningMessage(session, ignored = [], detail = null) {
23
+ const why = ignored.length ? `ignored stale session file(s): ${ignored.map((entry) => `${entry.file} [${entry.reason}]`).join(", ")}` : detail ?? "no session file found";
24
+ return `bb-pw: ${session} daemon is not running (${why}). Run \`bb-pw ${laneOf(session) || "<lane>"} open <url>\` first, then retry.`;
25
+ }
26
+ // Every `<root>/<sub>/<session>.session`, newest first. A file whose socket path
27
+ // is ABSENT on disk is provably dead (the daemon's tmp dir is gone) and is
28
+ // pruned; one whose socket EXISTS is kept — the daemon may be starting — and
29
+ // liveness is decided by connecting (resolveSession), not by the file. Corrupt
30
+ // JSON is left alone: it is not provably dead. Only this session's file is read,
31
+ // so another lane's live daemon in the same sub-dir is never touched.
32
+ export function scanSessions(session, env = process.env, { prune = true } = {}) {
33
+ const root = daemonDir(env), candidates = [], ignored = [];
34
+ if (!existsSync(root)) return { candidates, ignored };
35
+ for (const item of readdirSync(root)) {
36
+ const file = join(root, item, `${session}.session`);
37
+ let data; try { data = JSON.parse(readFileSync(file, "utf8")); } catch { continue; }
38
+ if (!data?.socketPath) { ignored.push({ file, reason: "no socket path" }); continue; }
39
+ if (!existsSync(data.socketPath)) { ignored.push({ file, reason: "socket missing" }); if (prune) { try { unlinkSync(file); } catch {} } continue; }
40
+ let mtimeMs = 0; try { mtimeMs = statSync(file).mtimeMs; } catch {}
41
+ candidates.push({ file, data, mtimeMs });
42
+ }
43
+ // Newest first; a deterministic path tiebreak so an mtime tie never falls back to readdir order.
44
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || b.file.localeCompare(a.file));
45
+ return { candidates, ignored };
46
+ }
47
+ // Synchronous, back-compatible, READ-ONLY reader (reap.mjs): the newest session
48
+ // whose socket path exists on disk. Pruning belongs to the socket path (socketRun).
49
+ export function readSession(session, env = process.env) { return scanSessions(session, env, { prune: false }).candidates[0]?.data ?? null; }
50
+ export function sendToDaemon(socketPath, positional, { connect = net.createConnection, cwd = process.cwd(), timeoutMs = 30000, session = null } = {}) { return new Promise((resolve) => { let done = false, buffer = "", socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => socket.write(JSON.stringify({ id: 1, method: "run", params: { args: { _: positional }, cwd } }) + "\n")); socket.on("data", (data) => { buffer += String(data); const newline = buffer.indexOf("\n"); if (newline < 0) return; try { const reply = JSON.parse(buffer.slice(0, newline)), text = reply.result?.text ?? ""; finish(reply.error || /^### Error\b/m.test(text) ? { ok: false, error: reply.error?.message ?? reply.error ?? text } : { ok: true, text }); } catch { finish({ ok: false, error: "bb-pw: malformed daemon reply" }); } }); socket.on("error", (error) => { const why = session ? connectFailure(error) : null; finish({ ok: false, error: why ? notRunningMessage(session, [], why) : error.message }); }); setTimeout(() => finish({ ok: false, error: "bb-pw: daemon socket timeout" }), timeoutMs).unref(); }); }
12
51
  export function socketAlive(socketPath, { connect = net.createConnection, timeoutMs = 1000 } = {}) { return new Promise((resolve) => { if (!socketPath) return resolve(false); let done = false, socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => finish(true)); socket.on("error", () => finish(false)); setTimeout(() => finish(false), timeoutMs).unref(); }); }
13
- export async function socketRun(plan, env = process.env) { const session = readSession(plan.session, env); return session?.socketPath ? sendToDaemon(session.socketPath, plan.socketArgs) : { ok: false, error: `bb-pw: no open daemon for ${plan.session}; open it first` }; }
52
+ // Authoritative, async: the newest candidate whose socket actually ANSWERS.
53
+ export async function resolveSession(session, env = process.env, { alive = socketAlive } = {}) {
54
+ const { candidates, ignored } = scanSessions(session, env);
55
+ for (const candidate of candidates) { if (await alive(candidate.data.socketPath)) return { session: candidate.data, ignored }; ignored.push({ file: candidate.file, reason: "did not answer" }); }
56
+ return { session: null, ignored };
57
+ }
58
+ export async function socketRun(plan, env = process.env, { alive, send = sendToDaemon } = {}) {
59
+ const { session, ignored } = await resolveSession(plan.session, env, { alive });
60
+ if (!session) return { ok: false, error: notRunningMessage(plan.session, ignored) };
61
+ return send(session.socketPath, plan.socketArgs, { session: plan.session });
62
+ }
package/src/pw/run.mjs CHANGED
@@ -7,6 +7,7 @@ import { canonicalizeHostString } from "./host.mjs";
7
7
  import { resolveAgentProfile } from "../wait-profile.mjs";
8
8
  import { readProfileIdentity } from "../agent-credential-store.mjs";
9
9
  import { loadConfig, getConfig } from "../config.mjs";
10
+ import { VERSION } from "../version.mjs";
10
11
  // BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
11
12
  // server-side, so the lane name bb-pw builds/matches/prints is the one the lock
12
13
  // kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
@@ -68,8 +69,22 @@ async function gate({ env, host, lane, deps }) {
68
69
  return { allowed: false, message: `bb-pw: could not verify lane lock ${laneName} (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` };
69
70
  }
70
71
  }
72
+ // BOT-1522: a stale global install (1.6.1 on the reporting host) replayed the
73
+ // pre-BOT-1488 refusal and was indistinguishable from a code regression. Every
74
+ // non-zero exit now names the bundled version, and --version exists at all, so
75
+ // an agent can tell "old binary" from "real bug" without reading the source.
76
+ const versionLine = () => `bb-pw (@botbuddy/cli v${VERSION})`;
71
77
  export async function runPw(argv, deps = {}) {
78
+ const stderr = deps.stderr ?? process.stderr;
79
+ const code = await runPwInner(argv, deps);
80
+ // Neutral: a refusal may be correct policy, not staleness — the line only lets
81
+ // the reader compare this binary against cli/package.json in their checkout.
82
+ if (code !== 0) stderr.write(`${versionLine()} — if your checkout's cli/package.json is newer, this global is stale: npm i -g @botbuddy/cli@latest, or run node cli/bin/bb-pw.mjs from the repo.\n`);
83
+ return code;
84
+ }
85
+ async function runPwInner(argv, deps = {}) {
72
86
  const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; }
87
+ if (["--version", "-v"].includes(args[0])) { stdout.write(`${versionLine()}\n`); return 0; }
73
88
  while (args[0] === "--profile" || args[0] === "--session-id") { const flag = args[0]; if (!args[1]) { stderr.write(`bb-pw: ${flag} needs a value\n`); return 2; } deps = flag === "--profile" ? { ...deps, profile: args[1] } : { ...deps, sessionId: args[1] }; args = args.slice(2); }
74
89
  let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
75
90
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
package/src/stack.mjs CHANGED
@@ -29,6 +29,7 @@ import { basename, dirname, join, relative, isAbsolute } from "path";
29
29
  import { randomUUID } from "crypto";
30
30
  import { callToolJson } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
+ import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
32
33
  import { runDockerWorkflow } from "./docker-hygiene.mjs";
33
34
  import { bold, dim, yellow } from "./utils.mjs";
34
35
 
@@ -350,13 +351,20 @@ export function parseSupabaseStatus(text) {
350
351
 
351
352
  // ── runtime (network / process) ──────────────────────────────────────────────
352
353
 
353
- function stackAuthHeader() {
354
- const cfg = getConfig();
355
- if (cfg.access_token) {
356
- if (cfg.token_expires_at && Date.now() >= cfg.token_expires_at) return null;
357
- return { Authorization: `Bearer ${cfg.access_token}`, "x-agent-api-key": cfg.api_key || "" };
354
+ // BOT-1520: source both auth headers from the Keychain — the owner OAuth token
355
+ // (`botbuddy login`) is preferred, the tenant-bound agent key
356
+ // (`botbuddy profile setup`) is the fallback. No plaintext config.json secret.
357
+ export async function stackAuthHeader() {
358
+ const agentKey = await resolveAgentKey();
359
+ const owner = await resolveOwnerToken({ getConfig });
360
+ if (owner) {
361
+ if (owner.expiresAt && Date.now() >= owner.expiresAt) {
362
+ if (agentKey) return { Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey };
363
+ return null;
364
+ }
365
+ return { Authorization: `Bearer ${owner.token}`, "x-agent-api-key": agentKey || "" };
358
366
  }
359
- if (cfg.api_key) return { Authorization: `Bearer ${cfg.api_key}`, "x-agent-api-key": cfg.api_key };
367
+ if (agentKey) return { Authorization: `Bearer ${agentKey}`, "x-agent-api-key": agentKey };
360
368
  return null;
361
369
  }
362
370
 
@@ -676,7 +684,7 @@ export async function cmdUp(opts, {
676
684
  process.stderr.write(`${yellow("⚠")} stack: OrbStack preflight warns of interface pressure; cleanup is recommended before another stack.\n`);
677
685
  }
678
686
  }
679
- const auth = authProvider();
687
+ const auth = await authProvider();
680
688
  if (!auth) return emitResult(buildReceipt({ command: "up", outcome: "error", error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
681
689
 
682
690
  const req = await callTool("request_stack_lease", {
@@ -966,7 +974,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
966
974
  touch: (leaseId) => callToolJson("touch_stack_lease", { lease_id: leaseId }),
967
975
  release: (leaseId, signal) => callToolJson("release_stack_lease", { lease_id: leaseId, disposition: "destroy" }, { signal }),
968
976
  };
969
- const auth = adapters.auth ?? stackAuthHeader();
977
+ const auth = adapters.auth ?? await stackAuthHeader();
970
978
  const wait = adapters.wait ?? ((leaseId, done, failed, options) => waitForLease(leaseId, done, failed, options));
971
979
  // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- validated executable + argv only; shell is never used.
972
980
  const startChild = adapters.startChild ?? ((argv, env, cwd) => spawn(argv[0], argv.slice(1), { cwd, env, stdio: "inherit", detached: true }));
package/src/wait.mjs CHANGED
@@ -273,6 +273,24 @@ async function registerWait(opts, conditions, deadlineIso) {
273
273
  err.minimumProtocol = body.minimum_wait_protocol ?? null;
274
274
  throw err;
275
275
  }
276
+ if (res.status === 409 || res.status === 404) {
277
+ // BOT-1523: `unblocked:<KEY>` hydration at registration could not arm the wait
278
+ // truthfully. These are NOT the untracked-live-only fallback: arming a live-only
279
+ // unblocked wait would skip the server's initial evaluation and could resume a
280
+ // coding session on a false already_unblocked. Surface the typed receipt and a
281
+ // concrete exit code (5=backend/retryable, 4=invalid/not-found).
282
+ const body = await res.json().catch(() => ({}));
283
+ if (body.error === "unblocked_projection_unresolved" || body.error === "unblocked_ticket_not_found") {
284
+ const err = new Error(body.detail || body.error);
285
+ err.hydrationRegister = true;
286
+ err.errorCode = body.error;
287
+ err.ticket = body.ticket ?? null;
288
+ err.retryable = body.retryable ?? null;
289
+ err.hydration = body.hydration ?? null;
290
+ err.notFound = body.error === "unblocked_ticket_not_found";
291
+ throw err;
292
+ }
293
+ }
276
294
  if (!res.ok) {
277
295
  const body = await res.json().catch(() => ({}));
278
296
  const err = new Error(body.detail || body.message || body.error || `register responded ${res.status}`);
@@ -678,6 +696,30 @@ export async function runWait(argv) {
678
696
  });
679
697
  process.exit(EXIT.INVALID);
680
698
  }
699
+ if (err && err.hydrationRegister) {
700
+ // BOT-1523: `unblocked:<KEY>` could not be hydrated from Linear at
701
+ // registration. Emit the typed receipt and a concrete exit: 4 (INVALID)
702
+ // for a not-found key, 5 (BACKEND) for a retryable backend/config reason.
703
+ const reason = err.hydration?.reason ?? (err.notFound ? "issue_not_found" : "unknown");
704
+ const tenant = opts.agentProfile?.tenant ?? "your-tenant";
705
+ const fix = err.notFound
706
+ ? "no Linear issue with that key exists in this workspace"
707
+ : reason === "no_linear_api_key"
708
+ ? `add LINEAR_API_KEY for tenant ${tenant} at /settings/integrations#linear`
709
+ : reason === "org_mismatch"
710
+ ? "the Linear issue belongs to a different Linear workspace than this tenant"
711
+ : "retry once Linear is reachable";
712
+ process.stderr.write(`bb-wait: unblocked:${err.ticket ?? "?"} cannot register — ${reason}; ${fix}\n`);
713
+ emitReceipt({
714
+ schema_version: 1,
715
+ outcome: "error",
716
+ error: err.errorCode,
717
+ ticket: err.ticket,
718
+ retryable: err.retryable,
719
+ hydration: err.hydration,
720
+ });
721
+ process.exit(err.notFound ? EXIT.INVALID : EXIT.BACKEND);
722
+ }
681
723
  if (err && err.invalidCondition) {
682
724
  // A rejected condition set is a configuration error, not a wait — fail closed
683
725
  // rather than arming an untracked wait that skips the server's initial