@botbuddy/cli 1.12.2 → 1.13.1
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 +1 -1
- package/src/agent-credential-store.mjs +37 -6
- package/src/auth.mjs +7 -2
- package/src/commands.mjs +151 -28
- package/src/oauth-loopback.mjs +5 -1
- package/src/update-check.mjs +127 -0
- package/src/wait.mjs +70 -7
package/package.json
CHANGED
|
@@ -43,17 +43,25 @@ function keychainService(profile) {
|
|
|
43
43
|
// succeeded. It carries its own code so the CLI never mislabels a storage
|
|
44
44
|
// failure as an auth failure (which would tell the user to re-`login`).
|
|
45
45
|
export class ProfileCredentialStoreError extends Error {
|
|
46
|
-
constructor(message, { cause } = {}) {
|
|
46
|
+
constructor(message, { cause, code = "profile_credential_store_failed" } = {}) {
|
|
47
47
|
super(message);
|
|
48
48
|
this.name = "ProfileCredentialStoreError";
|
|
49
|
-
this.code =
|
|
49
|
+
this.code = code;
|
|
50
50
|
if (cause !== undefined) this.cause = cause;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function writePasswordPrompt(command, args, password, spawnProcess) {
|
|
55
55
|
return new Promise((resolve, reject) => {
|
|
56
|
-
|
|
56
|
+
// BOT-1566: `security -w` (prompt mode) reads the secret with
|
|
57
|
+
// readpassphrase(3), which opens the CONTROLLING TERMINAL whenever the
|
|
58
|
+
// process has one — ignoring the piped stdin and silently storing whatever
|
|
59
|
+
// the human types at "password data for new item:". Spawning the child
|
|
60
|
+
// detached puts it in a new session with no controlling TTY, so
|
|
61
|
+
// readpassphrase falls back to stdin and the piped value is what lands.
|
|
62
|
+
// Headless (CI / agent harness) behaviour is unchanged: there was never a
|
|
63
|
+
// TTY to grab.
|
|
64
|
+
const child = spawnProcess(command, args, { stdio: ["pipe", "ignore", "pipe"], detached: true });
|
|
57
65
|
let stderr = "";
|
|
58
66
|
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
59
67
|
child.once("error", reject);
|
|
@@ -74,7 +82,19 @@ function writePasswordPrompt(command, args, password, spawnProcess) {
|
|
|
74
82
|
// name, so both the profile agent-key store and the owner OAuth-token store
|
|
75
83
|
// share the single implementation of the `security -w` double-read prompt and
|
|
76
84
|
// the actionable error mapping — no second copy to drift.
|
|
77
|
-
|
|
85
|
+
//
|
|
86
|
+
// BOT-1566: every write is verified by reading the item back and comparing it
|
|
87
|
+
// byte-for-byte with what was written. `readSecret` is the seam (defaults to
|
|
88
|
+
// the real `security find-generic-password`); a mismatch — the incident shape
|
|
89
|
+
// where the Keychain held a typed password instead of the OAuth token — throws
|
|
90
|
+
// `keychain_readback_mismatch` so callers fail loudly instead of announcing a
|
|
91
|
+
// successful login. Only equality is asserted, never token shape: profile keys
|
|
92
|
+
// (`bb_agent_…`) are not JWTs.
|
|
93
|
+
export async function writeKeychainSecret(service, token, {
|
|
94
|
+
spawnProcess = spawn,
|
|
95
|
+
execFileImpl = execFileAsync,
|
|
96
|
+
readSecret = (svc) => readKeychainSecret(svc, { execFileImpl }),
|
|
97
|
+
} = {}) {
|
|
78
98
|
if (!service) throw new ProfileCredentialStoreError("keychain write requires a service name");
|
|
79
99
|
try {
|
|
80
100
|
await writePasswordPrompt(
|
|
@@ -95,6 +115,17 @@ export async function writeKeychainSecret(service, token, { spawnProcess = spawn
|
|
|
95
115
|
}
|
|
96
116
|
throw new ProfileCredentialStoreError(`could not write the credential to the macOS Keychain: ${detail}`, { cause: error });
|
|
97
117
|
}
|
|
118
|
+
const stored = await readSecret(service);
|
|
119
|
+
if (stored !== token) {
|
|
120
|
+
// Never echo either value: the written token is a credential and the stored
|
|
121
|
+
// one may be a human's typed password.
|
|
122
|
+
throw new ProfileCredentialStoreError(
|
|
123
|
+
`macOS Keychain read-back verification failed for ${service}: the stored value differs from the credential written. `
|
|
124
|
+
+ "If `security` prompted you for a password, delete the item (`security delete-generic-password -s "
|
|
125
|
+
+ `${service}\`) and retry.`,
|
|
126
|
+
{ code: "keychain_readback_mismatch" },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
98
129
|
}
|
|
99
130
|
|
|
100
131
|
export async function readKeychainSecret(service, { execFileImpl = execFileAsync } = {}) {
|
|
@@ -117,10 +148,10 @@ export async function deleteKeychainSecret(service, { execFileImpl = execFileAsy
|
|
|
117
148
|
}
|
|
118
149
|
}
|
|
119
150
|
|
|
120
|
-
export async function writeKeychain(profile, token,
|
|
151
|
+
export async function writeKeychain(profile, token, options = {}) {
|
|
121
152
|
const service = keychainService(profile);
|
|
122
153
|
if (!service) throw new ProfileCredentialStoreError(`unknown profile keychain service for "${profile}"`);
|
|
123
|
-
return writeKeychainSecret(service, token,
|
|
154
|
+
return writeKeychainSecret(service, token, options);
|
|
124
155
|
}
|
|
125
156
|
|
|
126
157
|
async function readKeychain(profile) {
|
package/src/auth.mjs
CHANGED
|
@@ -50,7 +50,7 @@ function callbackErrorMessage(err) {
|
|
|
50
50
|
|
|
51
51
|
// AC-1..AC-13: orchestrate the loopback authorization-code flow.
|
|
52
52
|
//
|
|
53
|
-
// options: { noBrowser }
|
|
53
|
+
// options: { noBrowser, tenant }
|
|
54
54
|
// deps (all injectable for tests): serverUrl, fetch, openBrowser, saveConfig,
|
|
55
55
|
// getConfig, log, errorLog, timeoutMs, now.
|
|
56
56
|
export async function doLogin(options = {}, deps = {}) {
|
|
@@ -67,6 +67,8 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
67
67
|
persist = persistOwnerToken,
|
|
68
68
|
} = deps;
|
|
69
69
|
const noBrowser = Boolean(options.noBrowser);
|
|
70
|
+
// BOT-1566: optional tenant preselection (validated by the argument parser).
|
|
71
|
+
const tenant = typeof options.tenant === "string" && options.tenant ? options.tenant : null;
|
|
70
72
|
|
|
71
73
|
log(`${bold("BotBuddy OAuth Login")}\n`);
|
|
72
74
|
|
|
@@ -106,7 +108,7 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
106
108
|
|
|
107
109
|
// AC-6/AC-7: build the authorization URL and always print it (headless users
|
|
108
110
|
// open it themselves). The CLI never fetches /authorize.
|
|
109
|
-
const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge });
|
|
111
|
+
const authUrl = buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, tenant });
|
|
110
112
|
log("");
|
|
111
113
|
log(dim("→ Authorize BotBuddy in your browser:"));
|
|
112
114
|
log(` ${cyan(authUrl)}`);
|
|
@@ -159,6 +161,9 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
159
161
|
// BOT-1520: the durable secret goes to the Keychain (darwin); config.json
|
|
160
162
|
// keeps only non-secret metadata. On a non-darwin host without a Keychain,
|
|
161
163
|
// persistOwnerToken falls back to the 0600 config.json and warns.
|
|
164
|
+
// BOT-1566: a Keychain read-back mismatch (`keychain_readback_mismatch`)
|
|
165
|
+
// propagates from here, so login exits non-zero and the success line below
|
|
166
|
+
// is never printed for a credential that did not verifiably land.
|
|
162
167
|
const { storedInKeychain } = await persist(
|
|
163
168
|
{ token: tokenData.access_token, expiresAt, clientId },
|
|
164
169
|
{ getConfig, saveConfig, warn: errorLog },
|
package/src/commands.mjs
CHANGED
|
@@ -14,16 +14,33 @@ import { green, red, cyan, dim, bold, die } from "./utils.mjs";
|
|
|
14
14
|
import { VERSION } from "./version.mjs";
|
|
15
15
|
import { bootstrapProfile, ProfileBootstrapError, profileBootstrapRecovery, profileShellRefresh } from "./profile-bootstrap.mjs";
|
|
16
16
|
import { runPw } from "./pw/run.mjs";
|
|
17
|
+
import { maybeWarnStale, cmdUpdate } from "./update-check.mjs";
|
|
18
|
+
|
|
19
|
+
// BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
|
|
20
|
+
// invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
|
|
21
|
+
// must not pay for a registry round-trip.
|
|
22
|
+
export function shouldCheckForUpdates(argv) {
|
|
23
|
+
const [command] = argv;
|
|
24
|
+
if (command === "wait") return false;
|
|
25
|
+
if (argv.includes("--json")) return false;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
17
28
|
|
|
18
|
-
export async function run(argv
|
|
19
|
-
loadConfig
|
|
29
|
+
export async function run(argv, {
|
|
30
|
+
loadConfig: load = loadConfig,
|
|
31
|
+
warnStale = maybeWarnStale,
|
|
32
|
+
errorLog = (line) => console.error(line),
|
|
33
|
+
} = {}) {
|
|
34
|
+
load();
|
|
20
35
|
const [command, ...args] = argv;
|
|
36
|
+
if (shouldCheckForUpdates(argv)) await warnStale({ version: VERSION });
|
|
21
37
|
|
|
22
38
|
switch (command) {
|
|
23
39
|
case "start": return cmdStart(args);
|
|
24
|
-
case "login": return cmdLogin(args);
|
|
40
|
+
case "login": return cmdLogin(args, { errorLog });
|
|
25
41
|
case "logout": return cmdLogout();
|
|
26
42
|
case "status": return cmdStatus();
|
|
43
|
+
case "update": return cmdUpdate();
|
|
27
44
|
// Agent-only commands (used by MCP agents, not humans)
|
|
28
45
|
case "heartbeat": return cmdHeartbeat(args);
|
|
29
46
|
case "lock": return cmdLock(args);
|
|
@@ -74,9 +91,10 @@ ${bold("OPTIONS")}
|
|
|
74
91
|
--no-server Don't auto-start codex app-server
|
|
75
92
|
|
|
76
93
|
${bold("AUTH")}
|
|
77
|
-
login [--no-browser]
|
|
94
|
+
login [--no-browser] [--tenant <slug>]
|
|
95
|
+
Authenticate via OAuth (opens browser + localhost callback)
|
|
78
96
|
logout Remove saved credentials
|
|
79
|
-
status Show current auth status
|
|
97
|
+
status Show current auth status (local metadata + server check)
|
|
80
98
|
profile setup <profile> Mint/reconnect and securely store a tenant-bound agent key
|
|
81
99
|
|
|
82
100
|
${bold("TOOLS")}
|
|
@@ -112,6 +130,7 @@ ${bold("BROWSER LANES")}
|
|
|
112
130
|
|
|
113
131
|
${bold("OTHER")}
|
|
114
132
|
locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
|
|
133
|
+
update Install the latest @botbuddy/cli in the background via npm
|
|
115
134
|
help Show this help
|
|
116
135
|
version Show version`);
|
|
117
136
|
}
|
|
@@ -141,15 +160,57 @@ async function cmdToolHelp(args) {
|
|
|
141
160
|
console.log(formatDiscovery(await res.json(), { tool }));
|
|
142
161
|
}
|
|
143
162
|
|
|
163
|
+
// BOT-1566 C1: a tenant slug as the server accepts it on `?tenant=` — lowercase
|
|
164
|
+
// alphanumerics and hyphens, 2–63 chars, not starting with a hyphen.
|
|
165
|
+
export const TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
166
|
+
|
|
167
|
+
export class LoginUsageError extends Error {
|
|
168
|
+
constructor(message, { exitCode = 1 } = {}) {
|
|
169
|
+
super(message);
|
|
170
|
+
this.name = "LoginUsageError";
|
|
171
|
+
this.exitCode = exitCode;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Parse `botbuddy login` options. `--tenant <slug>` / `--tenant=<slug>`
|
|
176
|
+
// preselects the tenant on the OAuth picker; a malformed slug is a usage
|
|
177
|
+
// error with exit code 2 (never sent to the server).
|
|
178
|
+
export function parseLoginArgs(args) {
|
|
179
|
+
const out = { noBrowser: false, tenant: null, help: false };
|
|
180
|
+
for (let i = 0; i < args.length; i++) {
|
|
181
|
+
const arg = args[i];
|
|
182
|
+
if (arg === "--help" || arg === "-h") { out.help = true; continue; }
|
|
183
|
+
if (arg === "--no-browser") { out.noBrowser = true; continue; }
|
|
184
|
+
if (arg === "--tenant" || arg.startsWith("--tenant=")) {
|
|
185
|
+
const value = arg.startsWith("--tenant=") ? arg.slice("--tenant=".length) : args[++i];
|
|
186
|
+
if (typeof value !== "string" || !TENANT_SLUG_RE.test(value)) {
|
|
187
|
+
throw new LoginUsageError("error: --tenant must be a lowercase slug", { exitCode: 2 });
|
|
188
|
+
}
|
|
189
|
+
out.tenant = value;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (arg.startsWith("-")) {
|
|
193
|
+
throw new LoginUsageError(`Unknown login option: ${arg}. Run ${cyan("botbuddy login --help")}.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
144
199
|
// BOT-1383: login now runs the RFC 8252 loopback flow (opens the browser,
|
|
145
|
-
// waits for the localhost callback). Parse --no-browser and print
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const unknown = args.find((a) => a.startsWith("-") && !["--no-browser", "--help", "-h"].includes(a));
|
|
150
|
-
if (unknown) die(`Unknown login option: ${unknown}. Run ${cyan("botbuddy login --help")}.`);
|
|
200
|
+
// waits for the localhost callback). Parse --no-browser / --tenant and print
|
|
201
|
+
// focused help.
|
|
202
|
+
async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {}) {
|
|
203
|
+
let options;
|
|
151
204
|
try {
|
|
152
|
-
|
|
205
|
+
options = parseLoginArgs(args);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (!(err instanceof LoginUsageError)) throw err;
|
|
208
|
+
if (err.exitCode === 2) { errorLog(err.message); return 2; }
|
|
209
|
+
die(err.message);
|
|
210
|
+
}
|
|
211
|
+
if (options.help) return loginHelp();
|
|
212
|
+
try {
|
|
213
|
+
await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant });
|
|
153
214
|
} catch (err) {
|
|
154
215
|
die(err.message);
|
|
155
216
|
}
|
|
@@ -159,7 +220,7 @@ function loginHelp() {
|
|
|
159
220
|
console.log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
|
|
160
221
|
|
|
161
222
|
${bold("USAGE")}
|
|
162
|
-
botbuddy login [--no-browser]
|
|
223
|
+
botbuddy login [--no-browser] [--tenant <slug>]
|
|
163
224
|
|
|
164
225
|
${bold("HOW IT WORKS")}
|
|
165
226
|
Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
|
|
@@ -173,6 +234,10 @@ ${bold("OPTIONS")}
|
|
|
173
234
|
--no-browser Don't launch a browser; print the authorization URL to open
|
|
174
235
|
in a browser on THIS machine yourself. The callback listener
|
|
175
236
|
still runs on this machine's localhost.
|
|
237
|
+
--tenant <slug>
|
|
238
|
+
Preselect a tenant (e.g. ${cyan("supply-guard")}) on the sign-in page when
|
|
239
|
+
your account belongs to more than one. Must be a lowercase slug;
|
|
240
|
+
membership is still enforced by the server.
|
|
176
241
|
|
|
177
242
|
${bold("NOTES")}
|
|
178
243
|
• The authorization URL is always printed so you can open it manually.
|
|
@@ -216,34 +281,92 @@ async function cmdStart(args) {
|
|
|
216
281
|
return runBridge(args);
|
|
217
282
|
}
|
|
218
283
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
284
|
+
// BOT-1566 A4: after the local-metadata lines, ask the SERVER whether the stored
|
|
285
|
+
// credential is actually accepted. `status` used to decode only local metadata,
|
|
286
|
+
// so a Keychain item holding a typed password (the BOT-1566 incident) was
|
|
287
|
+
// reported as "Authenticated". One extra line, never throws, exit code
|
|
288
|
+
// unchanged:
|
|
289
|
+
// server: authenticated (<tenant slug>) whoami resolved
|
|
290
|
+
// server: rejected (<error message>) the server refused the credential
|
|
291
|
+
// server: unreachable network / transport failure
|
|
292
|
+
//
|
|
293
|
+
// The probe is bounded by STATUS_PROBE_TIMEOUT_MS: a server that accepts the
|
|
294
|
+
// connection but stalls before headers must not hang this formerly local
|
|
295
|
+
// diagnostic — an aborted fetch returns a `transport:` error → `unreachable`
|
|
296
|
+
// (BOT-1566, Codex P2).
|
|
297
|
+
const STATUS_PROBE_TIMEOUT_MS = 4000;
|
|
298
|
+
|
|
299
|
+
async function logServerStatus(call, auth, log) {
|
|
300
|
+
let res;
|
|
301
|
+
try {
|
|
302
|
+
res = await call("whoami", {}, { auth, signal: AbortSignal.timeout(STATUS_PROBE_TIMEOUT_MS) });
|
|
303
|
+
} catch {
|
|
304
|
+
log(` server: ${red("unreachable")}`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (res?.ok && !res.isError) {
|
|
308
|
+
const slug = typeof res.data?.tenant_id === "string" && res.data.tenant_id ? res.data.tenant_id : "tenant unresolved";
|
|
309
|
+
log(` server: ${green("authenticated")} (${slug})`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (res?.ok && res.isError) {
|
|
313
|
+
log(` server: ${red("rejected")} (${res.data?.error ?? res.data?.code ?? "tool error"})`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (res?.status === null && /^transport:/.test(String(res?.error ?? ""))) {
|
|
317
|
+
log(` server: ${red("unreachable")}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
log(` server: ${red("rejected")} (${res?.error ?? "unknown error"})`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function cmdStatus({
|
|
324
|
+
getConfig: getCfg = getConfig,
|
|
325
|
+
resolveOwnerToken: resolveOwner = resolveOwnerToken,
|
|
326
|
+
resolveAgentKey: resolveAgent = resolveAgentKey,
|
|
327
|
+
callToolJson: call = callToolJson,
|
|
328
|
+
log = (line) => console.log(line),
|
|
329
|
+
now = () => Date.now(),
|
|
330
|
+
} = {}) {
|
|
331
|
+
const cfg = getCfg();
|
|
332
|
+
const owner = await resolveOwner({ getConfig: getCfg });
|
|
222
333
|
if (owner) {
|
|
223
|
-
|
|
224
|
-
if (cfg.agent_name)
|
|
225
|
-
if (cfg.client_id)
|
|
334
|
+
log(`${green("✓")} Authenticated via OAuth ${dim("(Keychain)")}`);
|
|
335
|
+
if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
|
|
336
|
+
if (cfg.client_id) log(` Client: ${dim(cfg.client_id)}`);
|
|
226
337
|
if (owner.expiresAt) {
|
|
227
|
-
const remaining = owner.expiresAt -
|
|
338
|
+
const remaining = owner.expiresAt - now();
|
|
228
339
|
if (remaining <= 0) {
|
|
229
|
-
|
|
340
|
+
log(` Token: ${red("EXPIRED")} — run ${cyan("botbuddy start")}`);
|
|
230
341
|
} else {
|
|
231
342
|
const mins = Math.round(remaining / 60000);
|
|
232
343
|
const label = mins > 60 ? `${Math.round(mins / 60)}h ${mins % 60}m` : `${mins}m`;
|
|
233
|
-
|
|
344
|
+
log(` Token expires in: ${dim(label)}`);
|
|
234
345
|
}
|
|
235
346
|
}
|
|
236
|
-
|
|
347
|
+
log(` Config: ${dim(getConfigPath())}`);
|
|
348
|
+
// BOT-1566 (Codex P2): match resolveCallAuth — an expired owner token defers
|
|
349
|
+
// to a valid profile agent key, so probe the credential real commands would
|
|
350
|
+
// actually use instead of reporting a false `server: rejected` on the dead
|
|
351
|
+
// bearer while authenticated commands still succeed via the key.
|
|
352
|
+
const ownerExpired = owner.expiresAt && owner.expiresAt <= now();
|
|
353
|
+
const fallbackKey = ownerExpired ? await resolveAgent() : null;
|
|
354
|
+
await logServerStatus(
|
|
355
|
+
call,
|
|
356
|
+
fallbackKey ? { "x-agent-api-key": fallbackKey } : { Authorization: `Bearer ${owner.token}` },
|
|
357
|
+
log,
|
|
358
|
+
);
|
|
237
359
|
return;
|
|
238
360
|
}
|
|
239
|
-
const agentKey = await
|
|
361
|
+
const agentKey = await resolveAgent();
|
|
240
362
|
if (agentKey) {
|
|
241
|
-
|
|
242
|
-
if (cfg.agent_name)
|
|
363
|
+
log(`${green("✓")} Authenticated via agent key ${dim("(Keychain profile store)")}`);
|
|
364
|
+
if (cfg.agent_name) log(` Agent: ${cyan(cfg.agent_name)}`);
|
|
365
|
+
await logServerStatus(call, { "x-agent-api-key": agentKey }, log);
|
|
243
366
|
return;
|
|
244
367
|
}
|
|
245
|
-
|
|
246
|
-
|
|
368
|
+
log(`${red("✗")} Not authenticated`);
|
|
369
|
+
log(` Run: ${cyan("botbuddy start")}`);
|
|
247
370
|
}
|
|
248
371
|
|
|
249
372
|
async function cmdLogout() {
|
package/src/oauth-loopback.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export function generateState() {
|
|
|
46
46
|
|
|
47
47
|
// AC-6/AC-7: build the complete /authorize URL. The browser follows it; the CLI
|
|
48
48
|
// never fetches it. `redirectUri` must be the exact loopback callback we bound.
|
|
49
|
-
export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, scope = "read write lock" }) {
|
|
49
|
+
export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, codeChallenge, scope = "read write lock", tenant = null }) {
|
|
50
50
|
const url = new URL(`${serverUrl}/authorize`);
|
|
51
51
|
url.searchParams.set("client_id", clientId);
|
|
52
52
|
url.searchParams.set("redirect_uri", redirectUri);
|
|
@@ -55,6 +55,10 @@ export function buildAuthorizeUrl({ serverUrl, clientId, redirectUri, state, cod
|
|
|
55
55
|
url.searchParams.set("state", state);
|
|
56
56
|
url.searchParams.set("code_challenge", codeChallenge);
|
|
57
57
|
url.searchParams.set("code_challenge_method", "S256");
|
|
58
|
+
// BOT-1566: `login --tenant <slug>` preselects the tenant. /authorize forwards
|
|
59
|
+
// it to the /mcp-auth picker as `requestedTenant`; membership is still
|
|
60
|
+
// enforced server-side at authorize_complete — this only preselects.
|
|
61
|
+
if (tenant) url.searchParams.set("tenant", tenant);
|
|
58
62
|
return url.toString();
|
|
59
63
|
}
|
|
60
64
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// BOT-1566 D: stale-CLI warning + `botbuddy update`.
|
|
2
|
+
//
|
|
3
|
+
// The CLI is published to npm; a human running an old build gets no signal
|
|
4
|
+
// that a fix already shipped (the BOT-1566 keychain bug was found on a CLI two
|
|
5
|
+
// releases behind). `maybeWarnStale` prints ONE line to stderr when npm's
|
|
6
|
+
// `latest` is newer than the running version. It is throttled through a 24h
|
|
7
|
+
// JSON cache at ~/.botbuddy/update-check.json, bounded by a 1500 ms registry
|
|
8
|
+
// timeout, and fail-open: any error (offline, registry down, unreadable cache)
|
|
9
|
+
// is silent. It never writes to stdout — JSON receipts own stdout.
|
|
10
|
+
//
|
|
11
|
+
// `cmdUpdate` deliberately does NOT update in-process (BOT-1499: a self-update
|
|
12
|
+
// that replaces its own running files can kill itself mid-install). It hands
|
|
13
|
+
// off to a detached, unref'd `npm install -g` and exits 0.
|
|
14
|
+
|
|
15
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { VERSION } from "./version.mjs";
|
|
21
|
+
|
|
22
|
+
export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
export const NPM_LATEST_URL = "https://registry.npmjs.org/@botbuddy/cli/latest";
|
|
24
|
+
export const NPM_LATEST_TIMEOUT_MS = 1500;
|
|
25
|
+
export const UPDATE_COMMAND = "npm i -g @botbuddy/cli@latest";
|
|
26
|
+
|
|
27
|
+
export function defaultUpdateCheckCachePath(home = homedir()) {
|
|
28
|
+
return join(home, ".botbuddy", "update-check.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseSemver(value) {
|
|
32
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? "").trim());
|
|
33
|
+
if (!match) return null;
|
|
34
|
+
return { parts: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4] ?? null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// True when `latest` is strictly greater than `current`. Numeric per segment;
|
|
38
|
+
// a prerelease of the same core version ranks below the release. Unparseable
|
|
39
|
+
// input is never "newer" — a garbage registry answer must not nag.
|
|
40
|
+
export function isNewerVersion(latest, current) {
|
|
41
|
+
const a = parseSemver(latest);
|
|
42
|
+
const b = parseSemver(current);
|
|
43
|
+
if (!a || !b) return false;
|
|
44
|
+
for (let i = 0; i < 3; i++) {
|
|
45
|
+
if (a.parts[i] !== b.parts[i]) return a.parts[i] > b.parts[i];
|
|
46
|
+
}
|
|
47
|
+
if (a.prerelease && !b.prerelease) return false;
|
|
48
|
+
if (!a.prerelease && b.prerelease) return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fetchLatestFromNpm({ fetchImpl = fetch, timeoutMs = NPM_LATEST_TIMEOUT_MS } = {}) {
|
|
53
|
+
const res = await fetchImpl(NPM_LATEST_URL, {
|
|
54
|
+
headers: { accept: "application/json" },
|
|
55
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) throw new Error(`npm registry responded ${res.status}`);
|
|
58
|
+
const body = await res.json();
|
|
59
|
+
if (typeof body?.version !== "string") throw new Error("npm registry response carried no version");
|
|
60
|
+
return body.version;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readCache(cachePath) {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(await readFile(cachePath, "utf8"));
|
|
66
|
+
if (typeof parsed?.checkedAt !== "number") return null;
|
|
67
|
+
// `latest` is a version string, or null for a recorded-but-failed attempt
|
|
68
|
+
// (offline/registry-down) that still counts toward the 24h throttle.
|
|
69
|
+
if (parsed.latest !== null && typeof parsed.latest !== "string") return null;
|
|
70
|
+
return parsed;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function writeCache(cachePath, entry) {
|
|
77
|
+
await mkdir(dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
78
|
+
await writeFile(cachePath, JSON.stringify(entry), { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function maybeWarnStale({
|
|
82
|
+
version = VERSION,
|
|
83
|
+
fetchLatest = fetchLatestFromNpm,
|
|
84
|
+
now = () => Date.now(),
|
|
85
|
+
cachePath = defaultUpdateCheckCachePath(),
|
|
86
|
+
stderr = process.stderr,
|
|
87
|
+
} = {}) {
|
|
88
|
+
try {
|
|
89
|
+
const cached = await readCache(cachePath);
|
|
90
|
+
let latest;
|
|
91
|
+
if (cached && now() - cached.checkedAt < UPDATE_CHECK_INTERVAL_MS) {
|
|
92
|
+
latest = cached.latest;
|
|
93
|
+
} else {
|
|
94
|
+
// BOT-1566 (Codex P2): run() awaits this for nearly every command, so a
|
|
95
|
+
// failed lookup (offline/registry-down/garbage) MUST still stamp the
|
|
96
|
+
// attempt — otherwise the throttle never engages and every invocation
|
|
97
|
+
// retries and can eat the full timeout. Retain any previously-known
|
|
98
|
+
// latest so we can still warn from it.
|
|
99
|
+
let fetched = null;
|
|
100
|
+
try {
|
|
101
|
+
fetched = await fetchLatest();
|
|
102
|
+
} catch {
|
|
103
|
+
fetched = null;
|
|
104
|
+
}
|
|
105
|
+
latest = typeof fetched === "string" ? fetched : (cached?.latest ?? null);
|
|
106
|
+
await writeCache(cachePath, { checkedAt: now(), latest });
|
|
107
|
+
}
|
|
108
|
+
if (typeof latest !== "string" || !isNewerVersion(latest, version)) return { warned: false, latest: latest ?? null };
|
|
109
|
+
stderr.write(`botbuddy: update available ${version} → ${latest}: ${UPDATE_COMMAND}\n`);
|
|
110
|
+
return { warned: true, latest };
|
|
111
|
+
} catch {
|
|
112
|
+
return { warned: false, latest: null };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// `botbuddy update` — hand off to npm and get out of the way.
|
|
117
|
+
export function cmdUpdate({ version = VERSION, spawnImpl = spawn, log = (line) => console.log(line), platform = process.platform } = {}) {
|
|
118
|
+
log(`current: ${version}`);
|
|
119
|
+
// BOT-1566 (Codex P2): on Windows npm is the `npm.cmd` shim, and a shell-free
|
|
120
|
+
// spawn cannot execute .cmd scripts (Node docs) — it would ENOENT after we
|
|
121
|
+
// already claimed the update started. Select the platform-correct binary.
|
|
122
|
+
const npmBin = platform === "win32" ? "npm.cmd" : "npm";
|
|
123
|
+
const child = spawnImpl(npmBin, ["install", "-g", "@botbuddy/cli@latest"], { stdio: "inherit", detached: true });
|
|
124
|
+
child.unref();
|
|
125
|
+
log("updating in background — re-run botbuddy --version to confirm");
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
package/src/wait.mjs
CHANGED
|
@@ -399,6 +399,14 @@ function makeConnect(opts) {
|
|
|
399
399
|
// the agent (and its locks) aren't reaped during a long wait.
|
|
400
400
|
if (opts.heartbeat) url.searchParams.set("heartbeat", "1");
|
|
401
401
|
|
|
402
|
+
// BOT-1565: abort the fetch itself on idle. A silently half-open SSE body
|
|
403
|
+
// already has an `iterator.next()` pending, and `iterator.return()` queues
|
|
404
|
+
// BEHIND that read — it cannot cancel it until bytes/EOF eventually arrive,
|
|
405
|
+
// so it would leave the response locked and the socket open while we
|
|
406
|
+
// reconnect, leaking a connection per stall (Codex P2). Aborting the fetch's
|
|
407
|
+
// signal tears the stalled socket down immediately, then the read rejects and
|
|
408
|
+
// the stream ends so runWaitLoop reconnects.
|
|
409
|
+
const ac = new AbortController();
|
|
402
410
|
const res = await fetch(url, {
|
|
403
411
|
headers: {
|
|
404
412
|
Authorization: `Bearer ${opts.token}`,
|
|
@@ -407,12 +415,13 @@ function makeConnect(opts) {
|
|
|
407
415
|
// BOT-741: never let a proxy gzip-buffer an SSE stream.
|
|
408
416
|
"Accept-Encoding": "identity",
|
|
409
417
|
},
|
|
418
|
+
signal: ac.signal,
|
|
410
419
|
});
|
|
411
420
|
if (res.status === 401) return errorStream("unauthorized");
|
|
412
421
|
if (res.status === 403) return errorStream("forbidden");
|
|
413
422
|
if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
|
|
414
423
|
|
|
415
|
-
return sseFrameStream(res.body);
|
|
424
|
+
return sseFrameStream(res.body, { onIdle: () => ac.abort() });
|
|
416
425
|
};
|
|
417
426
|
}
|
|
418
427
|
|
|
@@ -468,14 +477,68 @@ function makeFeedLagProbe(opts) {
|
|
|
468
477
|
};
|
|
469
478
|
}
|
|
470
479
|
|
|
471
|
-
|
|
480
|
+
// BOT-1565: a silently half-open SSE socket (a network blip, or a gateway/edge
|
|
481
|
+
// connection-lifetime ceiling ~45–51 min) delivers no bytes AND no end-of-stream,
|
|
482
|
+
// so a bare `for await` here would block forever and never reconnect. Meanwhile
|
|
483
|
+
// the relay's last_seen_at keepalive stops bumping and the wait reaper abandons
|
|
484
|
+
// the still-parked wait after its grace → /waits goes blank (BOT-1565). Guard
|
|
485
|
+
// every read with an IDLE watchdog: the relay sends a keepalive comment every
|
|
486
|
+
// ~30s (EVENT_STREAM_KEEPALIVE_MS), and every chunk (comment or frame) resets
|
|
487
|
+
// the timer, so a healthy-but-quiet connection never trips it. If NOTHING
|
|
488
|
+
// arrives for idleMs the connection is presumed dead: end the stream so
|
|
489
|
+
// runWaitLoop reconnects from the cursor, which re-bumps wait_sessions.last_seen_at
|
|
490
|
+
// (and, with --heartbeat, agents.last_heartbeat) before the reaper's grace.
|
|
491
|
+
// 70s ≈ 2.3 missed keepalives — long enough to never false-trip on jitter, short
|
|
492
|
+
// enough that the reconnect lands inside the 90s last_seen reap window.
|
|
493
|
+
export const DEFAULT_SSE_IDLE_TIMEOUT_MS = 70_000;
|
|
494
|
+
|
|
495
|
+
function sseIdleTimeoutMs() {
|
|
496
|
+
const raw = Number(process.env.BOTBUDDY_WAIT_IDLE_TIMEOUT_MS);
|
|
497
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SSE_IDLE_TIMEOUT_MS;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const SSE_IDLE = Symbol("sse_idle");
|
|
501
|
+
|
|
502
|
+
export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdle = null } = {}) {
|
|
472
503
|
const decoder = new TextDecoder();
|
|
473
504
|
let buf = "";
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
505
|
+
const iterator = body[Symbol.asyncIterator]();
|
|
506
|
+
try {
|
|
507
|
+
while (true) {
|
|
508
|
+
const nextP = iterator.next();
|
|
509
|
+
let timer;
|
|
510
|
+
const idle = new Promise((resolve) => { timer = setTimeout(() => resolve(SSE_IDLE), idleMs); });
|
|
511
|
+
let result;
|
|
512
|
+
try {
|
|
513
|
+
result = await Promise.race([nextP, idle]);
|
|
514
|
+
} finally {
|
|
515
|
+
clearTimeout(timer);
|
|
516
|
+
}
|
|
517
|
+
if (result === SSE_IDLE) {
|
|
518
|
+
// Presumed dead. onIdle() aborts the underlying fetch (makeConnect wires
|
|
519
|
+
// it to the request's AbortController) — the ONLY thing that actually
|
|
520
|
+
// tears a stalled socket down, since iterator.return() would queue behind
|
|
521
|
+
// the pending read and never fire until bytes/EOF arrive (Codex P2). The
|
|
522
|
+
// abort settles the pending read (rejects with AbortError); swallow it so
|
|
523
|
+
// it isn't an unhandled rejection, then end the stream to force a reconnect.
|
|
524
|
+
nextP.then(() => {}, () => {});
|
|
525
|
+
if (onIdle) onIdle();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const { value: chunk, done } = result;
|
|
529
|
+
if (done) return;
|
|
530
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
531
|
+
const { frames, rest } = parseSseFrames(buf);
|
|
532
|
+
buf = rest;
|
|
533
|
+
for (const f of frames) yield f;
|
|
534
|
+
}
|
|
535
|
+
} finally {
|
|
536
|
+
// Belt-and-braces release for the non-idle early-stop path (the consumer
|
|
537
|
+
// stopped iterating, e.g. runWaitLoop matched). On the idle path the fetch
|
|
538
|
+
// has already been aborted above, so the body is torn down regardless; this
|
|
539
|
+
// return() then resolves promptly instead of queuing behind a live read.
|
|
540
|
+
// Fire-and-forget + swallow: never let teardown block the generator's exit.
|
|
541
|
+
Promise.resolve(iterator.return?.()).then(() => {}, () => {});
|
|
479
542
|
}
|
|
480
543
|
}
|
|
481
544
|
|