@botbuddy/cli 1.39.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.mjs +169 -34
- package/src/commands.mjs +36 -15
- package/src/oauth-device.mjs +163 -0
- package/src/stack.mjs +38 -4
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)) {
|
|
@@ -463,7 +477,8 @@ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {})
|
|
|
463
477
|
// BOT-1580: merge caller flags with the BOTBUDDY_CALLER* env fallback.
|
|
464
478
|
const caller = resolveCaller(options, process.env);
|
|
465
479
|
try {
|
|
466
|
-
|
|
480
|
+
const device = shouldUseDeviceFlow({ env: process.env, platform: process.platform, flag: options.device });
|
|
481
|
+
await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant, device, ...caller });
|
|
467
482
|
} catch (err) {
|
|
468
483
|
die(err.message);
|
|
469
484
|
}
|
|
@@ -479,6 +494,7 @@ export async function cmdSetup(args, {
|
|
|
479
494
|
usageAgent = installUsageAgent,
|
|
480
495
|
cwd = process.cwd(),
|
|
481
496
|
env = process.env,
|
|
497
|
+
platform = process.platform,
|
|
482
498
|
log = (line) => console.log(line),
|
|
483
499
|
errorLog = (line) => console.error(line),
|
|
484
500
|
} = {}) {
|
|
@@ -491,7 +507,7 @@ export async function cmdSetup(args, {
|
|
|
491
507
|
return error.exitCode === 2 ? 2 : 4;
|
|
492
508
|
}
|
|
493
509
|
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.");
|
|
510
|
+
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). After it succeeds, use `bb wait <condition>` with no credential flags or environment variables.");
|
|
495
511
|
return 0;
|
|
496
512
|
}
|
|
497
513
|
let bound;
|
|
@@ -515,7 +531,11 @@ export async function cmdSetup(args, {
|
|
|
515
531
|
// The committed binding below is the only place setup scopes the minted
|
|
516
532
|
// session. Forwarding --tenant here would overwrite a multi-tenant user's
|
|
517
533
|
// reusable client key with a tenant-sealed one.
|
|
518
|
-
await login({
|
|
534
|
+
await login({
|
|
535
|
+
noBrowser: options.noBrowser,
|
|
536
|
+
device: shouldUseDeviceFlow({ env, platform, flag: options.device }),
|
|
537
|
+
...callerResolver(options, env),
|
|
538
|
+
});
|
|
519
539
|
// Setup is an explicit session bootstrap/repair command. Rotate before
|
|
520
540
|
// resolving so it cannot merely report an old cache as ready.
|
|
521
541
|
await clearState(cwd);
|
|
@@ -560,7 +580,7 @@ export function loginHelp(log = console.log) {
|
|
|
560
580
|
log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
|
|
561
581
|
|
|
562
582
|
${bold("USAGE")}
|
|
563
|
-
botbuddy login [--no-browser] [--tenant <slug>]
|
|
583
|
+
botbuddy login [--no-browser] [--tenant <slug>] [--device | --loopback]
|
|
564
584
|
[--caller <name>] [--caller-harness <harness>] [--caller-ticket <ref>]
|
|
565
585
|
|
|
566
586
|
${bold("TOKEN SCOPE")}
|
|
@@ -585,6 +605,11 @@ ${bold("OPTIONS")}
|
|
|
585
605
|
--no-browser Don't launch a browser; print the authorization URL to open
|
|
586
606
|
in a browser on THIS machine yourself. The callback listener
|
|
587
607
|
still runs on this machine's localhost.
|
|
608
|
+
--device Sign in with a device code: print a link and a short code,
|
|
609
|
+
approve it in a browser on ANY machine (laptop, phone), and
|
|
610
|
+
the login finishes on its own. No browser or listener here.
|
|
611
|
+
--loopback Force the browser + localhost callback flow, even over SSH.
|
|
612
|
+
${dim("--device and --loopback cannot be combined.")}
|
|
588
613
|
--tenant <slug>
|
|
589
614
|
Mint a token SEALED to one tenant (e.g. ${cyan("supply-guard")}); the
|
|
590
615
|
sign-in page shows it as requested by the CLI. Must be a lowercase
|
|
@@ -611,12 +636,8 @@ ${bold("NOTES")}
|
|
|
611
636
|
saves nothing.
|
|
612
637
|
• If sign-in fails, just re-run ${cyan("botbuddy login")} — each attempt uses a fresh
|
|
613
638
|
listener and state.
|
|
614
|
-
• ${bold("Remote / SSH:")}
|
|
615
|
-
|
|
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.
|
|
639
|
+
• ${bold("Remote / SSH:")} over SSH (or on Linux with no display) login uses the device
|
|
640
|
+
code automatically: open the printed link on any machine and approve.
|
|
620
641
|
|
|
621
642
|
${bold("RECOVERY")}
|
|
622
643
|
If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
|
|
@@ -642,10 +663,10 @@ async function cmdStart(args) {
|
|
|
642
663
|
const agentKey = owner ? null : await resolveAgentKey();
|
|
643
664
|
if (!owner && !agentKey) {
|
|
644
665
|
console.log(dim("→ No credentials found. Starting login...\n"));
|
|
645
|
-
await doLogin({ ...caller });
|
|
666
|
+
await doLogin({ ...caller, device: shouldUseDeviceFlow({ env: process.env, platform: process.platform }) });
|
|
646
667
|
} else if (owner && owner.expiresAt && owner.expiresAt <= Date.now() && !(await resolveAgentKey())) {
|
|
647
668
|
console.log(dim("→ Token expired. Re-authenticating...\n"));
|
|
648
|
-
await doLogin({ ...caller });
|
|
669
|
+
await doLogin({ ...caller, device: shouldUseDeviceFlow({ env: process.env, platform: process.platform }) });
|
|
649
670
|
}
|
|
650
671
|
} catch (err) {
|
|
651
672
|
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
|
+
}
|
package/src/stack.mjs
CHANGED
|
@@ -331,6 +331,29 @@ export function parseStackArgs(argv) {
|
|
|
331
331
|
return { command, leaseId, opts, errors, childArgv };
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/**
|
|
335
|
+
* BOT-2019: the human line for a queued lease's server position. `queue_position` is the
|
|
336
|
+
* server's dispatch rank (stack_lease_dispatch_queue — the order leases are considered in,
|
|
337
|
+
* not a guaranteed start order); a held / owner-offline lease has none and the server says
|
|
338
|
+
* why in `queue_position_reason`. `scored_ticket_id` is the ticket the lease is scored under
|
|
339
|
+
* (its own, or the requesting agent's when it has none). Older servers send neither field.
|
|
340
|
+
*/
|
|
341
|
+
export function describeQueuePosition(d) {
|
|
342
|
+
const reason = d?.queue_position_reason ? ` (${d.queue_position_reason})` : "";
|
|
343
|
+
const pos = d?.queue_position != null ? `queue position ${d.queue_position}` : `position: none${reason}`;
|
|
344
|
+
const where = [pos, d?.host_key ? `on ${d.host_key}` : null].filter(Boolean).join(" ");
|
|
345
|
+
return d?.scored_ticket_id ? `${where}, scored under ${d.scored_ticket_id}` : where;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** BOT-2019: the queue fields a server returned for a queued lease, for a receipt. */
|
|
349
|
+
function queueReceiptFields(d) {
|
|
350
|
+
return {
|
|
351
|
+
queue_position: d?.queue_position ?? null,
|
|
352
|
+
...(d?.queue_position_reason !== undefined ? { queue_position_reason: d.queue_position_reason } : {}),
|
|
353
|
+
...(d?.scored_ticket_id !== undefined ? { scored_ticket_id: d.scored_ticket_id } : {}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
334
357
|
/** Build the canonical receipt object (single JSON line to stdout). */
|
|
335
358
|
export function buildReceipt(fields) {
|
|
336
359
|
return { schema_version: STACK_SCHEMA_VERSION, ...fields };
|
|
@@ -1649,10 +1672,12 @@ export async function cmdUp(opts, {
|
|
|
1649
1672
|
|
|
1650
1673
|
if (state === "queued") {
|
|
1651
1674
|
// BOT-1319: a wrapped lefthook lane records this park as waiting for capacity.
|
|
1652
|
-
|
|
1675
|
+
// BOT-2019: the server's dispatch rank, or `position: none (<reason>)`, never a crash on null.
|
|
1676
|
+
const where = describeQueuePosition(d);
|
|
1653
1677
|
reportLaneStatus({ phase: "waiting", reason: "queued_for_capacity", detail: where || "host at capacity" });
|
|
1678
|
+
process.stderr.write(`stack: lease ${leaseId} queued — ${where}\n`);
|
|
1654
1679
|
if (opts.noWait) {
|
|
1655
|
-
return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true,
|
|
1680
|
+
return emitResult(buildReceipt({ command: "up", outcome: "queued", lease_id: leaseId, state, host_key: d.host_key, slot, queued: true, ...queueReceiptFields(d), holders: d.holders }), opts, EXIT.QUEUED);
|
|
1656
1681
|
}
|
|
1657
1682
|
// Park zero-poll until the lease leaves the queue (granted in BOT-1187 order).
|
|
1658
1683
|
// BOT-2013: a lease reaped while queued (orphaned_agent, …) is lost, not "left the queue".
|
|
@@ -1837,7 +1862,9 @@ export async function cmdUp(opts, {
|
|
|
1837
1862
|
}), opts, EXIT.OK);
|
|
1838
1863
|
}
|
|
1839
1864
|
|
|
1840
|
-
export async function cmdStatus(leaseId, opts, {
|
|
1865
|
+
export async function cmdStatus(leaseId, opts, {
|
|
1866
|
+
rpc = retryingStackCall(), emitResult = emit, stderr = (line) => process.stderr.write(`${line}\n`),
|
|
1867
|
+
} = {}) {
|
|
1841
1868
|
const auth = await rpc.init();
|
|
1842
1869
|
if (!auth) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: "not authenticated — run `botbuddy login`" }), opts, EXIT.AUTH);
|
|
1843
1870
|
const got = await rpc.call("get_stack_lease", { lease_id: leaseId });
|
|
@@ -1846,10 +1873,15 @@ export async function cmdStatus(leaseId, opts, { rpc = retryingStackCall(), emit
|
|
|
1846
1873
|
: emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, error: got.error }), opts, EXIT.BACKEND);
|
|
1847
1874
|
const g = got.data;
|
|
1848
1875
|
if (!g.success) return emitResult(buildReceipt({ command: "status", outcome: "error", lease_id: leaseId, code: g.code, error: g.code }), opts, EXIT.BACKEND);
|
|
1876
|
+
// BOT-2019: a queued lease names its server dispatch rank (or why it has none) and the
|
|
1877
|
+
// ticket it is scored under; a null position is reported, never a crash.
|
|
1878
|
+
const queued = g.state === "queued";
|
|
1879
|
+
if (queued) stderr(`stack: lease ${leaseId} queued — ${describeQueuePosition(g)}`);
|
|
1849
1880
|
return emitResult(buildReceipt({
|
|
1850
1881
|
command: "status", outcome: g.state, lease_id: leaseId, state: g.state,
|
|
1851
1882
|
host_key: g.host_key, slot: g.slot, connection: g.connection,
|
|
1852
|
-
|
|
1883
|
+
...(queued ? queueReceiptFields(g) : { queue_position: g.queue_position }),
|
|
1884
|
+
idle_ttl_secs: g.idle_ttl_secs, queued: g.queued,
|
|
1853
1885
|
// BOT-1960: a lease fenced in `reaping` says since when, and — once the server gave up
|
|
1854
1886
|
// retrying its failed reap — that it is reap_failed and why.
|
|
1855
1887
|
...(g.reap_requested_at ? { reap_requested_at: g.reap_requested_at } : {}),
|
|
@@ -2447,6 +2479,8 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
|
|
|
2447
2479
|
return { exitCode: SIGNAL_EXIT[receivedSignal], outcome: "interrupted", leaseId, cleanup };
|
|
2448
2480
|
}
|
|
2449
2481
|
if (state === "queued") {
|
|
2482
|
+
// BOT-2019: say where the lease sits (server dispatch rank, or why it has none) before parking.
|
|
2483
|
+
stderrLine(`stack: lease ${leaseId} queued — ${describeQueuePosition(request.data)}`);
|
|
2450
2484
|
activeWaitAbort = new AbortController();
|
|
2451
2485
|
// BOT-2013: every reap state (reaping / reaped / reap_failed) means the queued lease is lost.
|
|
2452
2486
|
const parked = await wait(leaseId, (s) => s !== "queued" && s != null, isReaped, { timeoutSec: opts.timeout, auth: rpc.auth, signal: activeWaitAbort.signal });
|