@botbuddy/cli 1.39.1 → 1.40.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/auth.mjs +169 -34
- package/src/commands.mjs +52 -16
- package/src/oauth-device.mjs +163 -0
package/package.json
CHANGED
package/src/auth.mjs
CHANGED
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
DEFAULT_LOGIN_TIMEOUT_MS,
|
|
11
11
|
OAuthCallbackError,
|
|
12
12
|
} from "./oauth-loopback.mjs";
|
|
13
|
+
import {
|
|
14
|
+
DEVICE_CODE_GRANT_TYPE,
|
|
15
|
+
REQUESTING_HOST_MAX,
|
|
16
|
+
requestDeviceAuthorization,
|
|
17
|
+
pollDeviceToken,
|
|
18
|
+
} from "./oauth-device.mjs";
|
|
19
|
+
import { hostname as osHostname } from "node:os";
|
|
13
20
|
|
|
14
21
|
// A login that could not complete. commands.mjs turns this into one terminal
|
|
15
22
|
// error line; the core never calls process.exit so it stays unit-testable.
|
|
@@ -48,11 +55,72 @@ function callbackErrorMessage(err) {
|
|
|
48
55
|
}
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
// BOT-2030: one actionable terminal message per device-flow failure.
|
|
59
|
+
export const DEVICE_UNSUPPORTED_MESSAGE = "This server does not support device sign-in yet; use `--loopback` with an SSH tunnel";
|
|
60
|
+
function deviceErrorMessage(err) {
|
|
61
|
+
if (!(err instanceof OAuthCallbackError)) {
|
|
62
|
+
return err?.message || "Device sign-in failed";
|
|
63
|
+
}
|
|
64
|
+
switch (err.code) {
|
|
65
|
+
case "expired_token":
|
|
66
|
+
return "The code expired before it was approved. Re-run `botbuddy login`.";
|
|
67
|
+
case "access_denied":
|
|
68
|
+
return "The sign-in was denied in the browser.";
|
|
69
|
+
case "device_unsupported":
|
|
70
|
+
return DEVICE_UNSUPPORTED_MESSAGE;
|
|
71
|
+
case "canceled":
|
|
72
|
+
return "Login canceled.";
|
|
73
|
+
case "invalid_grant":
|
|
74
|
+
return "The sign-in code is no longer valid. Re-run `botbuddy login`.";
|
|
75
|
+
default:
|
|
76
|
+
return `Device sign-in failed: ${err.message}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Store a minted owner token. The ONE storage path for both login flows.
|
|
81
|
+
async function storeOwnerToken(tokenData, { clientId, now, persist, getConfig, saveConfig, log, errorLog }) {
|
|
82
|
+
const expiresAt = tokenData.expires_in
|
|
83
|
+
? now() + tokenData.expires_in * 1000
|
|
84
|
+
: now() + 24 * 60 * 60 * 1000; // default 24h if server omits expires_in
|
|
85
|
+
|
|
86
|
+
// BOT-1520: the durable secret goes to the Keychain (darwin); config.json
|
|
87
|
+
// keeps only non-secret metadata. On a non-darwin host without a Keychain,
|
|
88
|
+
// persistOwnerToken falls back to the 0600 config.json and warns.
|
|
89
|
+
// BOT-1566: a Keychain read-back mismatch (`keychain_readback_mismatch`)
|
|
90
|
+
// propagates from here, so login exits non-zero and the success line below
|
|
91
|
+
// is never printed for a credential that did not verifiably land.
|
|
92
|
+
// BOT-1571: record the token's scope. A server that predates tenant modes
|
|
93
|
+
// omits `tenant_mode`; such a token is sealed (or legacy), never assumed
|
|
94
|
+
// to be a user token.
|
|
95
|
+
const tenantMode = tokenData.tenant_mode === "user" ? "user" : "tenant";
|
|
96
|
+
const tenantId = typeof tokenData.tenant_id === "string" && tokenData.tenant_id ? tokenData.tenant_id : null;
|
|
97
|
+
const tenants = Array.isArray(tokenData.tenants) ? tokenData.tenants.filter((t) => typeof t === "string" && t) : [];
|
|
98
|
+
const { storedInKeychain } = await persist(
|
|
99
|
+
{ token: tokenData.access_token, expiresAt, clientId, tenantMode, tenantId, tenants },
|
|
100
|
+
{ getConfig, saveConfig, warn: errorLog },
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
log(
|
|
104
|
+
`\n${green("✓")} Logged in successfully! Token stored in ${
|
|
105
|
+
storedInKeychain ? "the macOS Keychain" : dim("~/.botbuddy/config.json")
|
|
106
|
+
}.`,
|
|
107
|
+
);
|
|
108
|
+
if (tenantMode === "user") {
|
|
109
|
+
log(` Token scope: ${cyan("user")} — works for every tenant you belong to${tenants.length ? ` (${tenants.join(", ")})` : ""}; each ${cyan("botbuddy mcp setup --tenant <slug>")} mints a key pinned to its own.`);
|
|
110
|
+
} else if (tenantId) {
|
|
111
|
+
log(` Token scope: ${cyan("tenant")} — sealed to ${cyan(tenantId)}. Run ${cyan("botbuddy login")} without ${dim("--tenant")} for a token that reaches all your tenants.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
51
115
|
// AC-1..AC-13: orchestrate the loopback authorization-code flow.
|
|
116
|
+
// BOT-2030: `options.device === true` runs the device flow instead (the
|
|
117
|
+
// command layer decides with shouldUseDeviceFlow, so this core stays
|
|
118
|
+
// deterministic for callers and tests that never ask for it).
|
|
52
119
|
//
|
|
53
|
-
// options: { noBrowser, tenant }
|
|
120
|
+
// options: { noBrowser, tenant, device, caller, callerHarness, callerTicket }
|
|
54
121
|
// deps (all injectable for tests): serverUrl, fetch, openBrowser, saveConfig,
|
|
55
|
-
// getConfig, log, errorLog, timeoutMs, now
|
|
122
|
+
// getConfig, log, errorLog, timeoutMs, now, persist; device mode also takes
|
|
123
|
+
// sleep, hostname, isTTY, exit, createReceiver.
|
|
56
124
|
export async function doLogin(options = {}, deps = {}) {
|
|
57
125
|
const {
|
|
58
126
|
serverUrl = SERVER_URL,
|
|
@@ -65,7 +133,11 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
65
133
|
timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
|
|
66
134
|
now = () => Date.now(),
|
|
67
135
|
persist = persistOwnerToken,
|
|
136
|
+
createReceiver = createLoopbackReceiver,
|
|
68
137
|
} = deps;
|
|
138
|
+
if (options.device === true) {
|
|
139
|
+
return doDeviceLogin(options, { ...deps, serverUrl, fetchImpl, saveConfig, getConfig, log, errorLog, now, persist });
|
|
140
|
+
}
|
|
69
141
|
const noBrowser = Boolean(options.noBrowser);
|
|
70
142
|
// BOT-1566: optional tenant preselection (validated by the argument parser).
|
|
71
143
|
const tenant = typeof options.tenant === "string" && options.tenant ? options.tenant : null;
|
|
@@ -84,7 +156,7 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
84
156
|
|
|
85
157
|
// AC-1/AC-2: start the loopback listener BEFORE registration or browser launch,
|
|
86
158
|
// on 127.0.0.1 with an OS-assigned ephemeral port.
|
|
87
|
-
const receiver =
|
|
159
|
+
const receiver = createReceiver({ expectedState: state });
|
|
88
160
|
const { redirectUri } = await receiver.listen();
|
|
89
161
|
|
|
90
162
|
// AC-11: Ctrl-C must close the listener and exit non-zero without saving.
|
|
@@ -160,37 +232,7 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
160
232
|
if (!tokenData.access_token) throw new LoginError(`Token exchange failed: ${describeResponse(tokenData)}`);
|
|
161
233
|
log(` ${green("✓")} Access token received`);
|
|
162
234
|
|
|
163
|
-
|
|
164
|
-
? now() + tokenData.expires_in * 1000
|
|
165
|
-
: now() + 24 * 60 * 60 * 1000; // default 24h if server omits expires_in
|
|
166
|
-
|
|
167
|
-
// BOT-1520: the durable secret goes to the Keychain (darwin); config.json
|
|
168
|
-
// keeps only non-secret metadata. On a non-darwin host without a Keychain,
|
|
169
|
-
// persistOwnerToken falls back to the 0600 config.json and warns.
|
|
170
|
-
// BOT-1566: a Keychain read-back mismatch (`keychain_readback_mismatch`)
|
|
171
|
-
// propagates from here, so login exits non-zero and the success line below
|
|
172
|
-
// is never printed for a credential that did not verifiably land.
|
|
173
|
-
// BOT-1571: record the token's scope. A server that predates tenant modes
|
|
174
|
-
// omits `tenant_mode`; such a token is sealed (or legacy), never assumed
|
|
175
|
-
// to be a user token.
|
|
176
|
-
const tenantMode = tokenData.tenant_mode === "user" ? "user" : "tenant";
|
|
177
|
-
const tenantId = typeof tokenData.tenant_id === "string" && tokenData.tenant_id ? tokenData.tenant_id : null;
|
|
178
|
-
const tenants = Array.isArray(tokenData.tenants) ? tokenData.tenants.filter((t) => typeof t === "string" && t) : [];
|
|
179
|
-
const { storedInKeychain } = await persist(
|
|
180
|
-
{ token: tokenData.access_token, expiresAt, clientId, tenantMode, tenantId, tenants },
|
|
181
|
-
{ getConfig, saveConfig, warn: errorLog },
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
log(
|
|
185
|
-
`\n${green("✓")} Logged in successfully! Token stored in ${
|
|
186
|
-
storedInKeychain ? "the macOS Keychain" : dim("~/.botbuddy/config.json")
|
|
187
|
-
}.`,
|
|
188
|
-
);
|
|
189
|
-
if (tenantMode === "user") {
|
|
190
|
-
log(` Token scope: ${cyan("user")} — works for every tenant you belong to${tenants.length ? ` (${tenants.join(", ")})` : ""}; each ${cyan("botbuddy mcp setup --tenant <slug>")} mints a key pinned to its own.`);
|
|
191
|
-
} else if (tenantId) {
|
|
192
|
-
log(` Token scope: ${cyan("tenant")} — sealed to ${cyan(tenantId)}. Run ${cyan("botbuddy login")} without ${dim("--tenant")} for a token that reaches all your tenants.`);
|
|
193
|
-
}
|
|
235
|
+
await storeOwnerToken(tokenData, { clientId, now, persist, getConfig, saveConfig, log, errorLog });
|
|
194
236
|
return { clientId, redirectUri };
|
|
195
237
|
} finally {
|
|
196
238
|
// AC-11/AC-13: always release the socket so re-running login starts clean.
|
|
@@ -198,3 +240,96 @@ export async function doLogin(options = {}, deps = {}) {
|
|
|
198
240
|
await receiver.close();
|
|
199
241
|
}
|
|
200
242
|
}
|
|
243
|
+
|
|
244
|
+
// BOT-2030: the device authorization grant (RFC 8628). No loopback listener
|
|
245
|
+
// and no browser launch on this host: print the link + code, poll /token, then
|
|
246
|
+
// store through the same storeOwnerToken as the loopback flow.
|
|
247
|
+
async function doDeviceLogin(options, deps) {
|
|
248
|
+
const {
|
|
249
|
+
serverUrl, fetchImpl, saveConfig, getConfig, log, errorLog, now, persist,
|
|
250
|
+
sleep,
|
|
251
|
+
hostname = osHostname,
|
|
252
|
+
isTTY = Boolean(process.stdout.isTTY),
|
|
253
|
+
exit = (code) => process.exit(code),
|
|
254
|
+
} = deps;
|
|
255
|
+
const tenant = typeof options.tenant === "string" && options.tenant ? options.tenant : null;
|
|
256
|
+
const pick = (v) => (typeof v === "string" && v ? v : null);
|
|
257
|
+
|
|
258
|
+
log(`${bold("BotBuddy OAuth Login")}\n`);
|
|
259
|
+
|
|
260
|
+
// Ctrl-C while waiting: store nothing, exit 130 (128 + SIGINT).
|
|
261
|
+
const abort = new AbortController();
|
|
262
|
+
const onSigint = () => {
|
|
263
|
+
abort.abort();
|
|
264
|
+
errorLog(`\n${red("✗")} Login canceled.`);
|
|
265
|
+
exit(130);
|
|
266
|
+
};
|
|
267
|
+
process.once("SIGINT", onSigint);
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
log(dim("→ Registering client..."));
|
|
271
|
+
const regRes = await fetchImpl(`${serverUrl}/register`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "Content-Type": "application/json" },
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
client_name: "botbuddy-cli-node",
|
|
276
|
+
grant_types: [DEVICE_CODE_GRANT_TYPE],
|
|
277
|
+
token_endpoint_auth_method: "none",
|
|
278
|
+
}),
|
|
279
|
+
});
|
|
280
|
+
const regData = await regRes.json().catch(() => ({}));
|
|
281
|
+
const clientId = regData.client_id;
|
|
282
|
+
if (!clientId) throw new LoginError(`Client registration failed: ${describeResponse(regData)}`);
|
|
283
|
+
log(` ${green("✓")} Client registered: ${dim(clientId)}`);
|
|
284
|
+
|
|
285
|
+
let grant;
|
|
286
|
+
try {
|
|
287
|
+
grant = await requestDeviceAuthorization({
|
|
288
|
+
serverUrl,
|
|
289
|
+
clientId,
|
|
290
|
+
tenant,
|
|
291
|
+
caller: pick(options.caller),
|
|
292
|
+
callerHarness: pick(options.callerHarness),
|
|
293
|
+
callerTicket: pick(options.callerTicket),
|
|
294
|
+
requestingHost: String(hostname() ?? "").slice(0, REQUESTING_HOST_MAX),
|
|
295
|
+
fetch: fetchImpl,
|
|
296
|
+
});
|
|
297
|
+
} catch (err) {
|
|
298
|
+
throw new LoginError(deviceErrorMessage(err));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// The code is printed once, here, and nowhere else. With no TTY (piped
|
|
302
|
+
// stdout) it goes to stderr so a pipe never swallows the sign-in prompt.
|
|
303
|
+
const prompt = isTTY ? log : errorLog;
|
|
304
|
+
const minutes = Math.max(1, Math.round((Number(grant.expires_in) || 600) / 60));
|
|
305
|
+
prompt("");
|
|
306
|
+
prompt("→ Sign in on any device:");
|
|
307
|
+
prompt(` ${grant.verification_uri_complete || grant.verification_uri}`);
|
|
308
|
+
prompt(` Code: ${grant.user_code} (expires in ${minutes} minute${minutes === 1 ? "" : "s"})`);
|
|
309
|
+
prompt(" Waiting for approval…");
|
|
310
|
+
|
|
311
|
+
let tokenData;
|
|
312
|
+
try {
|
|
313
|
+
tokenData = await pollDeviceToken({
|
|
314
|
+
serverUrl,
|
|
315
|
+
clientId,
|
|
316
|
+
deviceCode: grant.device_code,
|
|
317
|
+
intervalSec: Number(grant.interval),
|
|
318
|
+
expiresInSec: Number(grant.expires_in),
|
|
319
|
+
fetch: fetchImpl,
|
|
320
|
+
...(sleep ? { sleep } : {}),
|
|
321
|
+
now,
|
|
322
|
+
signal: abort.signal,
|
|
323
|
+
});
|
|
324
|
+
} catch (err) {
|
|
325
|
+
throw new LoginError(deviceErrorMessage(err));
|
|
326
|
+
}
|
|
327
|
+
if (abort.signal.aborted) throw new LoginError(deviceErrorMessage(new OAuthCallbackError("canceled", "Login canceled")));
|
|
328
|
+
log(` ${green("✓")} Approved — access token received`);
|
|
329
|
+
|
|
330
|
+
await storeOwnerToken(tokenData, { clientId, now, persist, getConfig, saveConfig, log, errorLog });
|
|
331
|
+
return { clientId, device: true };
|
|
332
|
+
} finally {
|
|
333
|
+
process.removeListener("SIGINT", onSigint);
|
|
334
|
+
}
|
|
335
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { callTool, callToolJson, readResource } from "./api.mjs";
|
|
6
6
|
import { doLogin } from "./auth.mjs";
|
|
7
|
+
import { shouldUseDeviceFlow } from "./oauth-device.mjs";
|
|
7
8
|
import { runBridge } from "./codex-bridge.mjs";
|
|
8
9
|
import { bootstrapHarnessSession, clearHarnessSession, doctorBootstrapState, formatBootstrapLine, loadHarnessSession, withHarnessBootstrapLock } from "./session-bootstrap.mjs";
|
|
9
10
|
import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
|
|
@@ -166,9 +167,9 @@ ${bold("OPTIONS")}
|
|
|
166
167
|
--no-server Don't auto-start codex app-server
|
|
167
168
|
|
|
168
169
|
${bold("AUTH")}
|
|
169
|
-
login [--no-browser] [--tenant <slug>] [--caller <name>] [--caller-harness <h>] [--caller-ticket <ref>]
|
|
170
|
-
Authenticate via OAuth (
|
|
171
|
-
setup [--no-browser] [--tenant <slug>]
|
|
170
|
+
login [--no-browser] [--tenant <slug>] [--device | --loopback] [--caller <name>] [--caller-harness <h>] [--caller-ticket <ref>]
|
|
171
|
+
Authenticate via OAuth (browser + localhost callback; a device code over SSH)
|
|
172
|
+
setup [--no-browser] [--tenant <slug>] [--device | --loopback]
|
|
172
173
|
Bootstrap this bound worktree for variable-free waits
|
|
173
174
|
logout Remove saved credentials
|
|
174
175
|
status Show current auth status (local metadata + server check)
|
|
@@ -341,10 +342,23 @@ export function parseLoginArgs(args) {
|
|
|
341
342
|
"--caller-harness": "callerHarness",
|
|
342
343
|
"--caller-ticket": "callerTicket",
|
|
343
344
|
};
|
|
345
|
+
// BOT-2030: `--device` forces the device sign-in, `--loopback` forces the
|
|
346
|
+
// browser + localhost callback. With neither, `out.device` stays absent and
|
|
347
|
+
// shouldUseDeviceFlow decides (device over SSH / on a display-less Linux).
|
|
348
|
+
let sawDevice = false;
|
|
349
|
+
let sawLoopback = false;
|
|
344
350
|
for (let i = 0; i < args.length; i++) {
|
|
345
351
|
const arg = args[i];
|
|
346
352
|
if (arg === "--help" || arg === "-h") { out.help = true; continue; }
|
|
347
353
|
if (arg === "--no-browser") { out.noBrowser = true; continue; }
|
|
354
|
+
if (arg === "--device" || arg === "--loopback") {
|
|
355
|
+
if (arg === "--device") sawDevice = true; else sawLoopback = true;
|
|
356
|
+
if (sawDevice && sawLoopback) {
|
|
357
|
+
throw new LoginUsageError("error: --device and --loopback cannot be used together", { exitCode: 2 });
|
|
358
|
+
}
|
|
359
|
+
out.device = arg === "--device";
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
348
362
|
if (arg === "--tenant" || arg.startsWith("--tenant=")) {
|
|
349
363
|
const value = arg.startsWith("--tenant=") ? arg.slice("--tenant=".length) : args[++i];
|
|
350
364
|
if (typeof value !== "string" || !TENANT_SLUG_RE.test(value)) {
|
|
@@ -385,6 +399,17 @@ export function parseLoginArgs(args) {
|
|
|
385
399
|
return out;
|
|
386
400
|
}
|
|
387
401
|
|
|
402
|
+
/**
|
|
403
|
+
* BOT-2030/BOT-2044: the sign-in mode for parsed login options. `--device` /
|
|
404
|
+
* `--loopback` win; `--no-browser` is a loopback-only option (print the URL,
|
|
405
|
+
* keep the localhost listener), so on its own it is an explicit loopback
|
|
406
|
+
* choice; otherwise shouldUseDeviceFlow auto-detects (SSH, display-less Linux).
|
|
407
|
+
*/
|
|
408
|
+
export function resolveLoginDevice(options = {}, { env = process.env, platform = process.platform } = {}) {
|
|
409
|
+
const flag = typeof options.device === "boolean" ? options.device : options.noBrowser ? false : undefined;
|
|
410
|
+
return shouldUseDeviceFlow({ env, platform, flag });
|
|
411
|
+
}
|
|
412
|
+
|
|
388
413
|
// BOT-1580: resolve the caller identity from parsed flags, falling back to the
|
|
389
414
|
// BOTBUDDY_CALLER* environment variables so an agent harness can advertise who it
|
|
390
415
|
// is without every wrapper having to pass flags. A flag always wins over the env.
|
|
@@ -463,7 +488,8 @@ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {})
|
|
|
463
488
|
// BOT-1580: merge caller flags with the BOTBUDDY_CALLER* env fallback.
|
|
464
489
|
const caller = resolveCaller(options, process.env);
|
|
465
490
|
try {
|
|
466
|
-
|
|
491
|
+
const device = resolveLoginDevice(options, { env: process.env, platform: process.platform });
|
|
492
|
+
await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant, device, ...caller });
|
|
467
493
|
} catch (err) {
|
|
468
494
|
die(err.message);
|
|
469
495
|
}
|
|
@@ -479,6 +505,7 @@ export async function cmdSetup(args, {
|
|
|
479
505
|
usageAgent = installUsageAgent,
|
|
480
506
|
cwd = process.cwd(),
|
|
481
507
|
env = process.env,
|
|
508
|
+
platform = process.platform,
|
|
482
509
|
log = (line) => console.log(line),
|
|
483
510
|
errorLog = (line) => console.error(line),
|
|
484
511
|
} = {}) {
|
|
@@ -491,7 +518,7 @@ export async function cmdSetup(args, {
|
|
|
491
518
|
return error.exitCode === 2 ? 2 : 4;
|
|
492
519
|
}
|
|
493
520
|
if (options.help) {
|
|
494
|
-
log("bb setup [--no-browser] [--tenant <slug>]\n\nSigns in, verifies the committed .botbuddy-agent.json binding, and caches this worktree's session. After it succeeds, use `bb wait <condition>` with no credential flags or environment variables.");
|
|
521
|
+
log("bb setup [--no-browser] [--tenant <slug>] [--device | --loopback]\n\nSigns in, verifies the committed .botbuddy-agent.json binding, and caches this worktree's session. Over SSH the sign-in uses a device code you approve from any browser (--device / --loopback force a mode). --loopback or --no-browser over SSH keeps the localhost callback on the remote host, so first forward its port with ssh -L <port>:127.0.0.1:<port> <host> (the port is in the printed URL), or setup times out. After it succeeds, use `bb wait <condition>` with no credential flags or environment variables.");
|
|
495
522
|
return 0;
|
|
496
523
|
}
|
|
497
524
|
let bound;
|
|
@@ -515,7 +542,11 @@ export async function cmdSetup(args, {
|
|
|
515
542
|
// The committed binding below is the only place setup scopes the minted
|
|
516
543
|
// session. Forwarding --tenant here would overwrite a multi-tenant user's
|
|
517
544
|
// reusable client key with a tenant-sealed one.
|
|
518
|
-
await login({
|
|
545
|
+
await login({
|
|
546
|
+
noBrowser: options.noBrowser,
|
|
547
|
+
device: resolveLoginDevice(options, { env, platform }),
|
|
548
|
+
...callerResolver(options, env),
|
|
549
|
+
});
|
|
519
550
|
// Setup is an explicit session bootstrap/repair command. Rotate before
|
|
520
551
|
// resolving so it cannot merely report an old cache as ready.
|
|
521
552
|
await clearState(cwd);
|
|
@@ -560,7 +591,7 @@ export function loginHelp(log = console.log) {
|
|
|
560
591
|
log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
|
|
561
592
|
|
|
562
593
|
${bold("USAGE")}
|
|
563
|
-
botbuddy login [--no-browser] [--tenant <slug>]
|
|
594
|
+
botbuddy login [--no-browser] [--tenant <slug>] [--device | --loopback]
|
|
564
595
|
[--caller <name>] [--caller-harness <harness>] [--caller-ticket <ref>]
|
|
565
596
|
|
|
566
597
|
${bold("TOKEN SCOPE")}
|
|
@@ -584,7 +615,13 @@ ${bold("HOW IT WORKS")}
|
|
|
584
615
|
${bold("OPTIONS")}
|
|
585
616
|
--no-browser Don't launch a browser; print the authorization URL to open
|
|
586
617
|
in a browser on THIS machine yourself. The callback listener
|
|
587
|
-
still runs on this machine's localhost
|
|
618
|
+
still runs on this machine's localhost, so this implies
|
|
619
|
+
${cyan("--loopback")} unless ${cyan("--device")} is also given.
|
|
620
|
+
--device Sign in with a device code: print a link and a short code,
|
|
621
|
+
approve it in a browser on ANY machine (laptop, phone), and
|
|
622
|
+
the login finishes on its own. No browser or listener here.
|
|
623
|
+
--loopback Force the browser + localhost callback flow, even over SSH.
|
|
624
|
+
${dim("--device and --loopback cannot be combined.")}
|
|
588
625
|
--tenant <slug>
|
|
589
626
|
Mint a token SEALED to one tenant (e.g. ${cyan("supply-guard")}); the
|
|
590
627
|
sign-in page shows it as requested by the CLI. Must be a lowercase
|
|
@@ -611,12 +648,11 @@ ${bold("NOTES")}
|
|
|
611
648
|
saves nothing.
|
|
612
649
|
• If sign-in fails, just re-run ${cyan("botbuddy login")} — each attempt uses a fresh
|
|
613
650
|
listener and state.
|
|
614
|
-
• ${bold("Remote / SSH:")}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
(the port is
|
|
619
|
-
reach the listener and login will time out.
|
|
651
|
+
• ${bold("Remote / SSH:")} over SSH (or on Linux with no display) login uses the device
|
|
652
|
+
code automatically: open the printed link on any machine and approve.
|
|
653
|
+
${cyan("--loopback")} or ${cyan("--no-browser")} over SSH keeps the localhost callback on the
|
|
654
|
+
remote host, so first forward its port, e.g. ${cyan("ssh -L <port>:127.0.0.1:<port> <host>")}
|
|
655
|
+
(the port is in the printed URL), or the login times out.
|
|
620
656
|
|
|
621
657
|
${bold("RECOVERY")}
|
|
622
658
|
If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
|
|
@@ -642,10 +678,10 @@ async function cmdStart(args) {
|
|
|
642
678
|
const agentKey = owner ? null : await resolveAgentKey();
|
|
643
679
|
if (!owner && !agentKey) {
|
|
644
680
|
console.log(dim("→ No credentials found. Starting login...\n"));
|
|
645
|
-
await doLogin({ ...caller });
|
|
681
|
+
await doLogin({ ...caller, device: shouldUseDeviceFlow({ env: process.env, platform: process.platform }) });
|
|
646
682
|
} else if (owner && owner.expiresAt && owner.expiresAt <= Date.now() && !(await resolveAgentKey())) {
|
|
647
683
|
console.log(dim("→ Token expired. Re-authenticating...\n"));
|
|
648
|
-
await doLogin({ ...caller });
|
|
684
|
+
await doLogin({ ...caller, device: shouldUseDeviceFlow({ env: process.env, platform: process.platform }) });
|
|
649
685
|
}
|
|
650
686
|
} catch (err) {
|
|
651
687
|
die(err.message);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// BOT-2030: the CLI half of the OAuth device authorization grant (RFC 8628),
|
|
2
|
+
// served by BOT-2029. A host with no usable browser (an SSH session, a headless
|
|
3
|
+
// Linux box) asks the server for a device code + a short user code, prints the
|
|
4
|
+
// verification link, and polls /token while the operator approves the code from
|
|
5
|
+
// the browser on whatever machine they are sitting at. No loopback listener, no
|
|
6
|
+
// browser launch on this host.
|
|
7
|
+
//
|
|
8
|
+
// Everything here is dependency-free and injectable (fetch, sleep, clock) so the
|
|
9
|
+
// poll loop is unit-testable without waiting.
|
|
10
|
+
|
|
11
|
+
import { OAuthCallbackError, sanitizeCallerField, CALLER_FIELD_LIMITS } from "./oauth-loopback.mjs";
|
|
12
|
+
|
|
13
|
+
export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
14
|
+
|
|
15
|
+
// RFC 8628 §3.5: on slow_down the client adds 5 seconds to its interval.
|
|
16
|
+
export const SLOW_DOWN_STEP_SEC = 5;
|
|
17
|
+
// RFC 8628 §3.2: when the server omits `interval`, poll every 5 seconds.
|
|
18
|
+
export const DEFAULT_POLL_INTERVAL_SEC = 5;
|
|
19
|
+
// The approval screen shows the requesting host; keep it short.
|
|
20
|
+
export const REQUESTING_HOST_MAX = 80;
|
|
21
|
+
|
|
22
|
+
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Should `login` use the device flow instead of the loopback browser flow?
|
|
26
|
+
* An explicit flag always wins (`--device` → true, `--loopback` → false);
|
|
27
|
+
* otherwise the device flow is used over SSH, and on Linux with no display.
|
|
28
|
+
*/
|
|
29
|
+
export function shouldUseDeviceFlow({ env = process.env, platform = process.platform, flag } = {}) {
|
|
30
|
+
if (flag === true) return true;
|
|
31
|
+
if (flag === false) return false;
|
|
32
|
+
if (env?.SSH_CONNECTION || env?.SSH_TTY) return true;
|
|
33
|
+
if (platform === "linux" && !env?.DISPLAY && !env?.WAYLAND_DISPLAY) return true;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* POST <server>/device_authorization. Returns the parsed RFC 8628 §3.2 body
|
|
39
|
+
* (`device_code`, `user_code`, `verification_uri`, `verification_uri_complete`,
|
|
40
|
+
* `expires_in`, `interval`).
|
|
41
|
+
*
|
|
42
|
+
* Token scope mirrors the loopback `buildAuthorizeUrl`: a plain login asks for a
|
|
43
|
+
* USER token (`tenant_mode=user`); `--tenant <slug>` asks for one sealed to it.
|
|
44
|
+
*
|
|
45
|
+
* Throws OAuthCallbackError:
|
|
46
|
+
* - `device_unsupported` when the server has no device route (a 404, or the
|
|
47
|
+
* pre-BOT-2029 route table's JSON-RPC fall-through), so the caller can say
|
|
48
|
+
* so instead of silently falling back to the loopback flow;
|
|
49
|
+
* - the server's OAuth `error` code otherwise.
|
|
50
|
+
*/
|
|
51
|
+
export async function requestDeviceAuthorization({
|
|
52
|
+
serverUrl,
|
|
53
|
+
clientId,
|
|
54
|
+
scope = "read write lock",
|
|
55
|
+
tenant = null,
|
|
56
|
+
caller = null,
|
|
57
|
+
callerHarness = null,
|
|
58
|
+
callerTicket = null,
|
|
59
|
+
requestingHost = null,
|
|
60
|
+
fetch: fetchImpl = fetch,
|
|
61
|
+
}) {
|
|
62
|
+
const body = { client_id: clientId, scope };
|
|
63
|
+
if (tenant) {
|
|
64
|
+
body.tenant_mode = "tenant";
|
|
65
|
+
body.tenant = tenant;
|
|
66
|
+
} else {
|
|
67
|
+
body.tenant_mode = "user";
|
|
68
|
+
}
|
|
69
|
+
// Display-only, unverified hints for the approval screen (BOT-1580 rules).
|
|
70
|
+
const callerName = sanitizeCallerField(caller, CALLER_FIELD_LIMITS.name);
|
|
71
|
+
const harness = sanitizeCallerField(callerHarness, CALLER_FIELD_LIMITS.harness);
|
|
72
|
+
const ticket = sanitizeCallerField(callerTicket, CALLER_FIELD_LIMITS.ticket);
|
|
73
|
+
if (callerName) body.caller = callerName;
|
|
74
|
+
if (harness) body.caller_harness = harness;
|
|
75
|
+
if (ticket) body.caller_ticket = ticket;
|
|
76
|
+
const host = sanitizeCallerField(requestingHost, REQUESTING_HOST_MAX);
|
|
77
|
+
if (host) body.requesting_host = host;
|
|
78
|
+
|
|
79
|
+
const res = await fetchImpl(`${serverUrl}/device_authorization`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify(body),
|
|
83
|
+
});
|
|
84
|
+
if (res.status === 404) {
|
|
85
|
+
throw new OAuthCallbackError("device_unsupported", "The server has no device authorization endpoint");
|
|
86
|
+
}
|
|
87
|
+
const data = await res.json().catch(() => null);
|
|
88
|
+
if (res.ok && data && typeof data.device_code === "string" && typeof data.user_code === "string" && typeof data.verification_uri === "string") {
|
|
89
|
+
return data;
|
|
90
|
+
}
|
|
91
|
+
if (data && typeof data.error === "string" && data.error) {
|
|
92
|
+
throw new OAuthCallbackError(data.error, data.error_description || data.error);
|
|
93
|
+
}
|
|
94
|
+
// No OAuth error code: not a device-grant endpoint at all (an older server
|
|
95
|
+
// answers the unknown path from its JSON-RPC handler).
|
|
96
|
+
throw new OAuthCallbackError("device_unsupported", "The server did not answer as a device authorization endpoint");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Poll POST <server>/token with the device code until the operator decides.
|
|
101
|
+
*
|
|
102
|
+
* Waits `intervalSec` before every request. `authorization_pending` → keep
|
|
103
|
+
* polling; `slow_down` → +5 s on the interval, keep polling. `access_denied`,
|
|
104
|
+
* `expired_token` and `invalid_grant` (and any other failure) throw an
|
|
105
|
+
* OAuthCallbackError carrying that code. Gives up with `expired_token` once
|
|
106
|
+
* `expiresInSec` has passed, without another request. An aborted `signal`
|
|
107
|
+
* (Ctrl-C) stops with `canceled` before the next request.
|
|
108
|
+
*
|
|
109
|
+
* Returns the token response body.
|
|
110
|
+
*/
|
|
111
|
+
export async function pollDeviceToken({
|
|
112
|
+
serverUrl,
|
|
113
|
+
clientId,
|
|
114
|
+
deviceCode,
|
|
115
|
+
intervalSec = DEFAULT_POLL_INTERVAL_SEC,
|
|
116
|
+
expiresInSec,
|
|
117
|
+
fetch: fetchImpl = fetch,
|
|
118
|
+
sleep = realSleep,
|
|
119
|
+
now = () => Date.now(),
|
|
120
|
+
signal = null,
|
|
121
|
+
}) {
|
|
122
|
+
let interval = Number.isFinite(intervalSec) && intervalSec > 0 ? intervalSec : DEFAULT_POLL_INTERVAL_SEC;
|
|
123
|
+
const deadline = now() + (Number.isFinite(expiresInSec) && expiresInSec > 0 ? expiresInSec : 600) * 1000;
|
|
124
|
+
const canceled = () => new OAuthCallbackError("canceled", "Login canceled");
|
|
125
|
+
|
|
126
|
+
for (;;) {
|
|
127
|
+
await sleep(interval * 1000);
|
|
128
|
+
if (signal?.aborted) throw canceled();
|
|
129
|
+
if (now() >= deadline) {
|
|
130
|
+
throw new OAuthCallbackError("expired_token", "The device code expired before it was approved");
|
|
131
|
+
}
|
|
132
|
+
const res = await fetchImpl(`${serverUrl}/token`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
135
|
+
body: new URLSearchParams({ grant_type: DEVICE_CODE_GRANT_TYPE, device_code: deviceCode, client_id: clientId }),
|
|
136
|
+
});
|
|
137
|
+
const data = await res.json().catch(() => ({}));
|
|
138
|
+
if (signal?.aborted) throw canceled();
|
|
139
|
+
if (res.ok && data && typeof data.access_token === "string" && data.access_token) return data;
|
|
140
|
+
|
|
141
|
+
const code = typeof data?.error === "string" && data.error ? data.error : null;
|
|
142
|
+
if (code === "authorization_pending") continue;
|
|
143
|
+
if (code === "slow_down") {
|
|
144
|
+
interval += SLOW_DOWN_STEP_SEC;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// access_denied / expired_token / invalid_grant, or anything unexpected
|
|
148
|
+
// (a tenant-state refusal carries its own `code`): stop, keep the reason.
|
|
149
|
+
const reason = (typeof data?.code === "string" && data.code) || code || `http_${res.status}`;
|
|
150
|
+
throw new OAuthCallbackError(reason, describe(data));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Bounded, secret-free rendering of a failed /token body (no token on failure).
|
|
155
|
+
function describe(data) {
|
|
156
|
+
let text;
|
|
157
|
+
try {
|
|
158
|
+
text = JSON.stringify(data);
|
|
159
|
+
} catch {
|
|
160
|
+
text = String(data);
|
|
161
|
+
}
|
|
162
|
+
return text && text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
163
|
+
}
|