@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.39.1",
3
+ "version": "1.40.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
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 = createLoopbackReceiver({ expectedState: state });
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
- const expiresAt = tokenData.expires_in
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 (opens browser + localhost callback)
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
- await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant, ...caller });
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({ noBrowser: options.noBrowser, ...callerResolver(options, env) });
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:")} the callback URL is ${dim("http://127.0.0.1:<port>/callback")}, which
615
- always points at the machine whose browser opens it. If you run login on a
616
- remote host but sign in from your laptop's browser, first forward that exact
617
- port back to the host, e.g. ${cyan("ssh -L <port>:127.0.0.1:<port> <host>")}
618
- (the port is printed in the URL). Without the tunnel the callback can't
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
+ }