@basein/runner 0.1.0 → 0.2.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/LICENSE +201 -201
- package/README.md +9 -2
- package/dist/auth/client.d.ts +152 -13
- package/dist/auth/client.js +335 -17
- package/dist/bin/bir.js +87 -9
- package/dist/proxy/session.js +5 -4
- package/docs/calculatedReplayGuide.md +1 -1
- package/docs/installRun.md +28 -15
- package/docs/loginWeb.md +607 -0
- package/docs/my-first-sample.md +545 -0
- package/docs/quickstart.md +36 -3
- package/package.json +1 -1
package/dist/auth/client.js
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* auth client —
|
|
2
|
+
* auth client — the CLI's session with the BaseIn service.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Every recording call carries an access token; the recording's owner is the
|
|
5
|
+
* JWT subject on the server side, which is what makes tenancy work without
|
|
6
|
+
* BaseInstRunner knowing anything about it.
|
|
7
|
+
*
|
|
8
|
+
* TWO HALVES, AND ONLY ONE OF THEM MAY PROMPT.
|
|
9
|
+
*
|
|
10
|
+
* `authenticate()` is the silent half, and every process calls it — `bir-hooks`
|
|
11
|
+
* at startup, each `bir-proxy`, and the CLI:
|
|
8
12
|
*
|
|
9
|
-
* Flow:
|
|
10
13
|
* 1. Load the cached session from ~/.baseinstrunner/credentials.json.
|
|
11
14
|
* 2. If the access token is still valid, reuse it.
|
|
12
15
|
* 3. Else if a refresh token is present, POST /auth/refresh (silent).
|
|
13
|
-
* 4. Else
|
|
14
|
-
*
|
|
16
|
+
* 4. Else give up and return undefined.
|
|
17
|
+
*
|
|
18
|
+
* It used to have a fifth step — prompt for a password — and that had to go
|
|
19
|
+
* when sign-in moved to the browser. A device flow prints a code and waits up
|
|
20
|
+
* to ten minutes for someone to approve it in a browser; a control server doing
|
|
21
|
+
* that at startup, possibly under a supervisor where nobody can see the code,
|
|
22
|
+
* would hang instead of degrading. So interaction lives in `deviceLogin()`,
|
|
23
|
+
* which only `bir login` calls.
|
|
15
24
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* the
|
|
25
|
+
* `deviceLogin()` is the interactive half: the OAuth 2.0 device authorization
|
|
26
|
+
* grant (RFC 8628). It mints a code, prints a link, and polls until the person
|
|
27
|
+
* approves in the console. It never handles a password, which is what makes it
|
|
28
|
+
* work for Google-only accounts — they have no password to type.
|
|
29
|
+
*
|
|
30
|
+
* WHY NOTHING HERE `process.exit`s: `bir-proxy` runs inside the host's process
|
|
31
|
+
* tree. Failing to authenticate must degrade to not-recording (§10), not kill a
|
|
32
|
+
* server the host is waiting on. Callers get `undefined` and decide.
|
|
19
33
|
*
|
|
20
34
|
* Config:
|
|
21
35
|
* BIR_AUTH_URL base URL of the BaseIn auth-service (required).
|
|
22
36
|
* BIR_AUTH_DISABLE "1" to skip auth entirely (local dev / CI).
|
|
23
37
|
*/
|
|
38
|
+
import { spawn } from "node:child_process";
|
|
24
39
|
import { createInterface } from "node:readline";
|
|
25
40
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
41
|
+
import { hostname } from "node:os";
|
|
26
42
|
import { configDir } from "../control/paths.js";
|
|
27
43
|
import { join } from "node:path";
|
|
28
44
|
import { logLine, errText } from "../util/log.js";
|
|
45
|
+
import { packageVersion } from "../util/version.js";
|
|
29
46
|
/**
|
|
30
47
|
* Where the cached session lives.
|
|
31
48
|
*
|
|
@@ -159,6 +176,65 @@ export async function normalizeAuthUrl(baseUrl, fetchImpl = fetch) {
|
|
|
159
176
|
});
|
|
160
177
|
return base;
|
|
161
178
|
}
|
|
179
|
+
/** How long the pre-flight `/health` probe may take. It is one small GET. */
|
|
180
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
181
|
+
/**
|
|
182
|
+
* The hint printed whenever `BIR_AUTH_URL` turns out not to be the API. Kept in
|
|
183
|
+
* one place so `bir login`, `bir doctor` and the interactive prompt agree.
|
|
184
|
+
*/
|
|
185
|
+
export const AUTH_URL_HINT = "BIR_AUTH_URL must be the BaseIn API address (for example https://api.example.com), " +
|
|
186
|
+
"not the documentation or app website. Set it, then open a fresh terminal — " +
|
|
187
|
+
"an existing one keeps the value it started with";
|
|
188
|
+
function causeText(err) {
|
|
189
|
+
const cause = err?.cause;
|
|
190
|
+
return cause?.code ?? cause?.message ?? errText(err);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Is there a BaseIn service at `baseUrl`? Returns `undefined` when there is,
|
|
194
|
+
* else one line saying what answered instead.
|
|
195
|
+
*
|
|
196
|
+
* WHY THIS EXISTS. Pointing `BIR_AUTH_URL` at the *website* instead of the API
|
|
197
|
+
* (`https://example.com` is the docs, `https://api.example.com` the service) is
|
|
198
|
+
* the easiest mistake to make and the hardest to read back: the static site
|
|
199
|
+
* answers `GET /health` with 200 and a page, so nothing objects until the
|
|
200
|
+
* password has already been POSTed to it and it says `405 Method Not Allowed`
|
|
201
|
+
* — a message about HTTP verbs, for a problem about hosts. Observed on a first
|
|
202
|
+
* sign-in; the user tried the app URL next and got a redirect.
|
|
203
|
+
*
|
|
204
|
+
* So, before anyone types a password: expect the health endpoint to answer
|
|
205
|
+
* with JSON. A website answers with HTML; a wrong port answers with nothing.
|
|
206
|
+
* The check is deliberately loose about the body — deployments differ — and
|
|
207
|
+
* strict about the content type, which is what separates a service from a page.
|
|
208
|
+
*/
|
|
209
|
+
export async function describeAuthService(baseUrl, fetchImpl = fetch) {
|
|
210
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
211
|
+
if (!base)
|
|
212
|
+
return "no URL configured";
|
|
213
|
+
let res;
|
|
214
|
+
try {
|
|
215
|
+
res = await fetchImpl(`${base}/health`, {
|
|
216
|
+
method: "GET",
|
|
217
|
+
headers: { accept: "application/json" },
|
|
218
|
+
redirect: "manual",
|
|
219
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
return `unreachable (${causeText(err)})`;
|
|
224
|
+
}
|
|
225
|
+
if (res.status >= 300 && res.status < 400) {
|
|
226
|
+
return `redirects to ${res.headers.get("location") ?? "somewhere else"} — use the final address`;
|
|
227
|
+
}
|
|
228
|
+
const type = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
229
|
+
if (!res.ok) {
|
|
230
|
+
return `GET /health answered HTTP ${res.status}${type.includes("html") ? " with a web page" : ""}`;
|
|
231
|
+
}
|
|
232
|
+
if (!type.includes("json")) {
|
|
233
|
+
const what = type.includes("html") ? "a web page" : type ? `'${type}'` : "no content type";
|
|
234
|
+
return `GET /health answered ${what} instead of JSON — that is a website, not the BaseIn API`;
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
162
238
|
function prompt(question) {
|
|
163
239
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
164
240
|
return new Promise((resolve) => {
|
|
@@ -215,7 +291,13 @@ async function postJson(url, body) {
|
|
|
215
291
|
});
|
|
216
292
|
if (!res.ok) {
|
|
217
293
|
const text = await res.text().catch(() => "");
|
|
218
|
-
|
|
294
|
+
const isHtml = (res.headers.get("content-type") ?? "").includes("html") || /^\s*<(!doctype|html)/i.test(text);
|
|
295
|
+
const detail = isHtml
|
|
296
|
+
? " — the answer was a web page, not JSON: this URL is a website, not the BaseIn API"
|
|
297
|
+
: text
|
|
298
|
+
? ` — ${text.length > 300 ? `${text.slice(0, 300)}…` : text}`
|
|
299
|
+
: "";
|
|
300
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}${detail}`);
|
|
219
301
|
}
|
|
220
302
|
return (await res.json());
|
|
221
303
|
}
|
|
@@ -258,13 +340,222 @@ export async function authenticate(opts) {
|
|
|
258
340
|
return refreshed;
|
|
259
341
|
}
|
|
260
342
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
343
|
+
// No session, and this function does not make one. Signing in is a browser
|
|
344
|
+
// round trip that can take minutes; a proxy or control server blocking on it
|
|
345
|
+
// would hang the host instead of degrading to not-recording.
|
|
346
|
+
logLine("auth.no_session", {
|
|
347
|
+
why: "no valid session — run `bir login`",
|
|
348
|
+
});
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
// ── Device authorization grant (RFC 8628) ──────────────────────────────────
|
|
352
|
+
/**
|
|
353
|
+
* The service has no device endpoints — it predates browser sign-in.
|
|
354
|
+
*
|
|
355
|
+
* Thrown so `bir login` can fall back to the password flow rather than leaving
|
|
356
|
+
* someone stranded on a runner that is newer than the service it talks to.
|
|
357
|
+
*/
|
|
358
|
+
export class DeviceFlowUnsupported extends Error {
|
|
359
|
+
constructor() {
|
|
360
|
+
super("this BaseIn service does not offer browser sign-in");
|
|
361
|
+
this.name = "DeviceFlowUnsupported";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Start a sign-in. Returns the codes; the caller shows one and polls with the
|
|
366
|
+
* other.
|
|
367
|
+
*
|
|
368
|
+
* `clientName`/`clientVersion` are shown on the approval page so the person can
|
|
369
|
+
* tell their own `bir login` from somebody else's — the one defence a device
|
|
370
|
+
* flow has against being talked into approving a stranger's code.
|
|
371
|
+
*/
|
|
372
|
+
export async function startDeviceCode(authUrl, client, fetchImpl = fetch) {
|
|
373
|
+
const res = await fetchImpl(`${authUrl}/auth/device/code`, {
|
|
374
|
+
method: "POST",
|
|
375
|
+
headers: { "content-type": "application/json" },
|
|
376
|
+
body: JSON.stringify({ clientName: client.name, clientVersion: client.version }),
|
|
377
|
+
});
|
|
378
|
+
// A service without the endpoint 404s. Anything else is a real failure.
|
|
379
|
+
if (res.status === 404)
|
|
380
|
+
throw new DeviceFlowUnsupported();
|
|
381
|
+
if (!res.ok) {
|
|
382
|
+
const text = await res.text().catch(() => "");
|
|
383
|
+
const isHtml = (res.headers.get("content-type") ?? "").includes("html") || /^\s*<(!doctype|html)/i.test(text);
|
|
384
|
+
throw new Error(`HTTP ${res.status}${isHtml
|
|
385
|
+
? " — the answer was a web page, not JSON: this URL is a website, not the BaseIn API"
|
|
386
|
+
: text
|
|
387
|
+
? ` — ${text.slice(0, 300)}`
|
|
388
|
+
: ""}`);
|
|
389
|
+
}
|
|
390
|
+
return (await res.json());
|
|
391
|
+
}
|
|
392
|
+
/** The user gave up, or the code died before anyone approved it. */
|
|
393
|
+
export class DeviceFlowAborted extends Error {
|
|
394
|
+
reason;
|
|
395
|
+
constructor(message, reason) {
|
|
396
|
+
super(message);
|
|
397
|
+
this.reason = reason;
|
|
398
|
+
this.name = "DeviceFlowAborted";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
402
|
+
/**
|
|
403
|
+
* Poll until the sign-in is approved, refused, or dies.
|
|
404
|
+
*
|
|
405
|
+
* RFC 8628 §3.5's loop, including the part people skip: `slow_down` is not an
|
|
406
|
+
* error, it is the server asking for a longer gap, and the interval must
|
|
407
|
+
* *stay* longer afterwards. Ignoring it turns a slow client into a blocked one.
|
|
408
|
+
*/
|
|
409
|
+
export async function pollDeviceToken(authUrl, deviceCode, opts) {
|
|
410
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
411
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
412
|
+
const now = opts.now ?? Date.now;
|
|
413
|
+
const deadline = now() + opts.expiresIn * 1000;
|
|
414
|
+
let interval = opts.interval;
|
|
415
|
+
for (;;) {
|
|
416
|
+
if (opts.signal?.aborted) {
|
|
417
|
+
throw new DeviceFlowAborted("sign-in cancelled", "cancelled");
|
|
418
|
+
}
|
|
419
|
+
if (now() >= deadline) {
|
|
420
|
+
throw new DeviceFlowAborted("the code expired before it was approved — run `bir login` again", "timeout");
|
|
421
|
+
}
|
|
422
|
+
await sleep(interval * 1000);
|
|
423
|
+
const res = await fetchImpl(`${authUrl}/auth/device/token`, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { "content-type": "application/json" },
|
|
426
|
+
body: JSON.stringify({ deviceCode }),
|
|
427
|
+
});
|
|
428
|
+
if (res.ok)
|
|
429
|
+
return (await res.json());
|
|
430
|
+
const body = (await res.json().catch(() => ({})));
|
|
431
|
+
switch (body.error) {
|
|
432
|
+
case "authorization_pending":
|
|
433
|
+
continue;
|
|
434
|
+
case "slow_down":
|
|
435
|
+
// Adopt the server's number when it sends one; otherwise back off the
|
|
436
|
+
// way the RFC suggests. Either way the wider gap persists.
|
|
437
|
+
interval = typeof body.interval === "number" ? body.interval : interval + 5;
|
|
438
|
+
continue;
|
|
439
|
+
case "access_denied":
|
|
440
|
+
throw new DeviceFlowAborted("the sign-in was refused in the browser", "access_denied");
|
|
441
|
+
case "expired_token":
|
|
442
|
+
throw new DeviceFlowAborted("the code expired before it was approved — run `bir login` again", "expired_token");
|
|
443
|
+
default:
|
|
444
|
+
throw new Error(`sign-in failed: ${body.error ?? `HTTP ${res.status}`}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Open a URL in the user's browser. Best effort, and never fatal.
|
|
450
|
+
*
|
|
451
|
+
* No dependency and no shell: this package ships zero runtime dependencies, and
|
|
452
|
+
* passing a URL through a shell is how a URL becomes a command. The link is
|
|
453
|
+
* printed either way, so a failure here costs nothing.
|
|
454
|
+
*/
|
|
455
|
+
export function openBrowser(url) {
|
|
456
|
+
try {
|
|
457
|
+
const [command, args] = process.platform === "win32"
|
|
458
|
+
? // The empty string is `start`'s title argument. Without it, a quoted
|
|
459
|
+
// URL is taken *as* the title and nothing opens.
|
|
460
|
+
["cmd", ["/c", "start", "", url]]
|
|
461
|
+
: process.platform === "darwin"
|
|
462
|
+
? ["open", [url]]
|
|
463
|
+
: ["xdg-open", [url]];
|
|
464
|
+
const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
|
|
465
|
+
child.on("error", () => {
|
|
466
|
+
/* no browser here; the printed link is the fallback */
|
|
264
467
|
});
|
|
468
|
+
child.unref();
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
/* see above */
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Should we try to open a browser at all?
|
|
476
|
+
*
|
|
477
|
+
* Over SSH the browser would open on the wrong machine, and with no terminal
|
|
478
|
+
* there is nobody watching. In both cases the link still gets printed — it can
|
|
479
|
+
* be opened on a phone, which is the point of this grant.
|
|
480
|
+
*/
|
|
481
|
+
function shouldOpenBrowser() {
|
|
482
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
|
|
483
|
+
return false;
|
|
484
|
+
return Boolean(process.stderr.isTTY);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Sign in through the browser and cache the session.
|
|
488
|
+
*
|
|
489
|
+
* Everything a person sees goes to stderr: `bir login`'s stdout is one line
|
|
490
|
+
* that a script may read, and the code block is not it.
|
|
491
|
+
*/
|
|
492
|
+
export async function deviceLogin(authUrl, opts = {}) {
|
|
493
|
+
const write = opts.write ?? ((text) => process.stderr.write(text));
|
|
494
|
+
const grant = await startDeviceCode(authUrl, { name: clientName(), version: packageVersion() }, opts.fetchImpl);
|
|
495
|
+
const link = grant.verificationUriComplete ?? grant.verificationUri;
|
|
496
|
+
write(`\n[bir] Sign in to ${authUrl}\n\n`);
|
|
497
|
+
write(` Open ${link}\n`);
|
|
498
|
+
write(` Code ${grant.userCode}\n\n`);
|
|
499
|
+
const wantsBrowser = opts.openBrowser ?? shouldOpenBrowser();
|
|
500
|
+
if (wantsBrowser) {
|
|
501
|
+
write(" Opening your browser…\n");
|
|
502
|
+
openBrowser(link);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
write(" Open that link on any device — a phone is fine.\n");
|
|
506
|
+
}
|
|
507
|
+
write(" Waiting for approval… (Ctrl-C to cancel)\n\n");
|
|
508
|
+
logLine("auth.device_started", { url: authUrl, expiresIn: grant.expiresIn });
|
|
509
|
+
const res = await pollDeviceToken(authUrl, grant.deviceCode, {
|
|
510
|
+
interval: grant.interval,
|
|
511
|
+
expiresIn: grant.expiresIn,
|
|
512
|
+
fetchImpl: opts.fetchImpl,
|
|
513
|
+
sleep: opts.sleep,
|
|
514
|
+
signal: opts.signal,
|
|
515
|
+
});
|
|
516
|
+
const session = toSession(res);
|
|
517
|
+
saveCredentials(session);
|
|
518
|
+
return session;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* A name for this machine, shown on the approval page.
|
|
522
|
+
*
|
|
523
|
+
* This is the string someone reads to decide whether the request is theirs, so
|
|
524
|
+
* it names the tool and the host. Never fatal: an unnamed client still works,
|
|
525
|
+
* it just tells the approver less.
|
|
526
|
+
*/
|
|
527
|
+
function clientName() {
|
|
528
|
+
try {
|
|
529
|
+
return `bir on ${hostname()}`;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return "bir";
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// ── The password flow, on its way out ──────────────────────────────────────
|
|
536
|
+
/**
|
|
537
|
+
* Sign in with an email and a password.
|
|
538
|
+
*
|
|
539
|
+
* DEPRECATED, and kept for exactly two cases: `bir login --password`, and a
|
|
540
|
+
* runner talking to a service that predates the device endpoints. Both go in
|
|
541
|
+
* the release after this one, and this function goes with them.
|
|
542
|
+
*
|
|
543
|
+
* It cannot work at all for an account created through Google — there is no
|
|
544
|
+
* password on it to send — which is the main reason sign-in moved.
|
|
545
|
+
*/
|
|
546
|
+
export async function legacyPasswordLogin(authUrl) {
|
|
547
|
+
if (!process.stdin.isTTY) {
|
|
548
|
+
logLine("auth.no_session", { why: "no terminal to read a password on" });
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
// Never send a password to something that is not the service. The website
|
|
552
|
+
// answers 405 *after* the credentials have been posted to it; check first.
|
|
553
|
+
const problem = await describeAuthService(authUrl);
|
|
554
|
+
if (problem) {
|
|
555
|
+
logLine("auth.not_a_service", { url: authUrl, problem, why: AUTH_URL_HINT });
|
|
265
556
|
return undefined;
|
|
266
557
|
}
|
|
267
|
-
process.stderr.write(`[bir] Sign in to ${authUrl}\n`);
|
|
558
|
+
process.stderr.write(`[bir] Sign in to ${authUrl} (BIR_AUTH_URL)\n`);
|
|
268
559
|
const email = await prompt("Email: ");
|
|
269
560
|
const password = await promptPassword("Password: ");
|
|
270
561
|
if (!email || !password) {
|
|
@@ -277,8 +568,35 @@ export async function authenticate(opts) {
|
|
|
277
568
|
return session;
|
|
278
569
|
}
|
|
279
570
|
catch (err) {
|
|
280
|
-
logLine("auth.login_failed", { error: errText(err) });
|
|
571
|
+
logLine("auth.login_failed", { url: `${authUrl}/auth/login`, error: errText(err) });
|
|
281
572
|
return undefined;
|
|
282
573
|
}
|
|
283
574
|
}
|
|
575
|
+
/**
|
|
576
|
+
* Sign out: revoke the refresh token server-side, then forget it locally.
|
|
577
|
+
*
|
|
578
|
+
* Deleting the file alone left the token live for its full 30 days, which made
|
|
579
|
+
* `bir logout` a tidy-up rather than a revocation. The endpoint is idempotent
|
|
580
|
+
* and its failure is not worth blocking on — the local half must happen either
|
|
581
|
+
* way, or "logged out" would be a lie on a machine with no network.
|
|
582
|
+
*/
|
|
583
|
+
export async function logout(authUrl) {
|
|
584
|
+
const cached = loadCredentials();
|
|
585
|
+
if (authUrl && cached?.refreshToken) {
|
|
586
|
+
try {
|
|
587
|
+
await fetch(`${authUrl}/auth/logout`, {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: { "content-type": "application/json" },
|
|
590
|
+
body: JSON.stringify({ refreshToken: cached.refreshToken }),
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
logLine("auth.logout_remote_failed", {
|
|
595
|
+
error: errText(err),
|
|
596
|
+
why: "the local credentials are cleared regardless",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
clearCredentials();
|
|
601
|
+
}
|
|
284
602
|
//# sourceMappingURL=client.js.map
|
package/dist/bin/bir.js
CHANGED
|
@@ -28,7 +28,7 @@ import { isScenarioServer, isWrapped, PACKAGE_NAME, readSidecar, scenarioEntry,
|
|
|
28
28
|
import { isRemote, resolveServers } from "../config/resolve.js";
|
|
29
29
|
import { buildHooksBlock, claudeCodePaths, fileForScope, installHooks, readTextOrNull, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
|
|
30
30
|
import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
|
|
31
|
-
import {
|
|
31
|
+
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, logout, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
|
|
32
32
|
import { DEFAULT_CONTROL_PORT } from "../control/server.js";
|
|
33
33
|
import { errText } from "../util/log.js";
|
|
34
34
|
import { packageVersion } from "../util/version.js";
|
|
@@ -45,8 +45,8 @@ Commands:
|
|
|
45
45
|
status what is installed for this directory
|
|
46
46
|
doctor is it actually working right now?
|
|
47
47
|
wrap print a proxied entry for one server (any MCP client)
|
|
48
|
-
login sign in to the BaseIn service
|
|
49
|
-
logout
|
|
48
|
+
login sign in to the BaseIn service (opens your browser)
|
|
49
|
+
logout revoke this machine's session and forget it
|
|
50
50
|
|
|
51
51
|
scenario list recorded runs and their calculated scenarios
|
|
52
52
|
scenario show <runId> a run's scenario: intent, params, steps
|
|
@@ -65,7 +65,9 @@ Options:
|
|
|
65
65
|
--replay install/remove the scenario server, enabling calculated replay
|
|
66
66
|
--port <n> control-server port to write into the hook URLs (default ${DEFAULT_CONTROL_PORT})
|
|
67
67
|
--json machine-readable output for status / doctor
|
|
68
|
-
--dry replay against recorded outputs only; run no real tools
|
|
68
|
+
--dry replay against recorded outputs only; run no real tools
|
|
69
|
+
--no-browser login: print the link and code, open nothing (SSH, headless)
|
|
70
|
+
--password login: use the old email/password prompt (deprecated)`);
|
|
69
71
|
process.exit(code);
|
|
70
72
|
}
|
|
71
73
|
function parseArgs(argv) {
|
|
@@ -83,6 +85,7 @@ function parseArgs(argv) {
|
|
|
83
85
|
positionals: [],
|
|
84
86
|
dry: false,
|
|
85
87
|
force: false,
|
|
88
|
+
password: false,
|
|
86
89
|
};
|
|
87
90
|
for (let i = 1; i < argv.length; i += 1) {
|
|
88
91
|
const arg = argv[i];
|
|
@@ -106,6 +109,12 @@ function parseArgs(argv) {
|
|
|
106
109
|
case "--force":
|
|
107
110
|
args.force = true;
|
|
108
111
|
break;
|
|
112
|
+
case "--password":
|
|
113
|
+
args.password = true;
|
|
114
|
+
break;
|
|
115
|
+
case "--no-browser":
|
|
116
|
+
args.browser = false;
|
|
117
|
+
break;
|
|
109
118
|
case "--config":
|
|
110
119
|
args.configPath = argv[++i];
|
|
111
120
|
break;
|
|
@@ -538,12 +547,31 @@ async function doctor(args) {
|
|
|
538
547
|
notes.push("could not verify recording: no control server to ask, and BIR_AUTH_URL is " +
|
|
539
548
|
"unset in this shell (which is not where the proxies read it from)");
|
|
540
549
|
}
|
|
550
|
+
// WHAT *THIS* SHELL WOULD TALK TO. `bir login` and `bir scenario` run here,
|
|
551
|
+
// so this shell's BIR_AUTH_URL is evidence about them, if not about the
|
|
552
|
+
// recorder. The one mistake worth catching is a URL that is a website rather
|
|
553
|
+
// than the API: it answers `GET /health` with a page, so nothing else notices
|
|
554
|
+
// until a password has been posted to it and it says `405`.
|
|
555
|
+
const shellAuthUrl = resolveAuthUrl();
|
|
556
|
+
const shellAuthProblem = shellAuthUrl ? await describeAuthService(shellAuthUrl) : undefined;
|
|
557
|
+
if (shellAuthProblem) {
|
|
558
|
+
problems.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
|
|
559
|
+
`\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
|
|
560
|
+
}
|
|
541
561
|
const sidecar = readSidecar();
|
|
542
562
|
if (sidecar.controlPort && discovery && !discovery.url.endsWith(`:${sidecar.controlPort}`)) {
|
|
543
563
|
problems.push(`hooks were installed for port ${sidecar.controlPort} but the control server is on ${discovery.url}`);
|
|
544
564
|
}
|
|
545
565
|
if (args.json) {
|
|
546
|
-
out(JSON.stringify({
|
|
566
|
+
out(JSON.stringify({
|
|
567
|
+
ok: problems.length === 0,
|
|
568
|
+
problems,
|
|
569
|
+
notes,
|
|
570
|
+
health,
|
|
571
|
+
wrapped: wantWrapped,
|
|
572
|
+
shellAuthUrl: shellAuthUrl || null,
|
|
573
|
+
shellAuthProblem: shellAuthProblem ?? null,
|
|
574
|
+
}, null, 2));
|
|
547
575
|
return problems.length === 0 ? 0 : 1;
|
|
548
576
|
}
|
|
549
577
|
// Replay first, and loudly. It auto-approves tool calls in the session and
|
|
@@ -567,6 +595,9 @@ async function doctor(args) {
|
|
|
567
595
|
out(`Wrapped in config : ${wantWrapped.join(", ") || "(none)"}`);
|
|
568
596
|
out(`Registered proxies: ${[...registered].join(", ") || "(none)"}`);
|
|
569
597
|
out(`Control server : ${discovery?.url ?? "not running"}`);
|
|
598
|
+
out(`BaseIn (shell) : ${shellAuthUrl
|
|
599
|
+
? `${shellAuthUrl}${shellAuthProblem ? " — NOT a BaseIn service, see below" : " (answers /health)"}`
|
|
600
|
+
: "(BIR_AUTH_URL not set in this shell)"}`);
|
|
570
601
|
out(`Recording tier : ${discovery && health ? "bound (Tier 1)" : "standalone (Tier 2)"}`);
|
|
571
602
|
out(`Recording : ${health ? (health.recording === false ? "no — see below" : "yes") : "unknown (no control server to ask)"}`);
|
|
572
603
|
if (health?.lossy)
|
|
@@ -853,16 +884,63 @@ async function main() {
|
|
|
853
884
|
case "replay":
|
|
854
885
|
return replayCommand(args);
|
|
855
886
|
case "login": {
|
|
856
|
-
|
|
887
|
+
// Settle the URL the way every other command does, then refuse to go on
|
|
888
|
+
// when what is there is not the service. "Signed in" must mean the service
|
|
889
|
+
// at BIR_AUTH_URL issued this session — not that a cached token exists for
|
|
890
|
+
// some other address.
|
|
891
|
+
const configured = resolveAuthUrl();
|
|
892
|
+
if (!configured) {
|
|
893
|
+
process.stderr.write("[bir] BIR_AUTH_URL is not set — there is nothing to sign in to.\n");
|
|
894
|
+
process.stderr.write(`[bir] ${AUTH_URL_HINT}\n`);
|
|
895
|
+
return 1;
|
|
896
|
+
}
|
|
897
|
+
const baseUrl = await normalizeAuthUrl(configured);
|
|
898
|
+
const problem = await describeAuthService(baseUrl);
|
|
899
|
+
if (problem) {
|
|
900
|
+
process.stderr.write(`[bir] ${baseUrl} is not a BaseIn service: ${problem}\n`);
|
|
901
|
+
process.stderr.write(`[bir] ${AUTH_URL_HINT}\n`);
|
|
902
|
+
return 1;
|
|
903
|
+
}
|
|
904
|
+
let session;
|
|
905
|
+
if (args.password) {
|
|
906
|
+
process.stderr.write("[bir] --password is deprecated and will be removed in the next release. " +
|
|
907
|
+
"Browser sign-in works for every account, including Google-only ones.\n");
|
|
908
|
+
session = await legacyPasswordLogin(baseUrl);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
try {
|
|
912
|
+
session = await deviceLogin(baseUrl, { openBrowser: args.browser });
|
|
913
|
+
}
|
|
914
|
+
catch (err) {
|
|
915
|
+
if (err instanceof DeviceFlowUnsupported) {
|
|
916
|
+
// A runner newer than its service. Falling back beats stranding
|
|
917
|
+
// someone on an install that used to work.
|
|
918
|
+
process.stderr.write("[bir] this service does not offer browser sign-in yet; " +
|
|
919
|
+
"falling back to email and password.\n");
|
|
920
|
+
session = await legacyPasswordLogin(baseUrl);
|
|
921
|
+
}
|
|
922
|
+
else if (err instanceof DeviceFlowAborted) {
|
|
923
|
+
process.stderr.write(`[bir] ${err.message}\n`);
|
|
924
|
+
return 1;
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
throw err;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
857
931
|
if (!session)
|
|
858
932
|
return 1;
|
|
859
|
-
out(`Signed in as ${session.user.email}.`);
|
|
933
|
+
out(`Signed in to ${baseUrl} as ${session.user.email}.`);
|
|
860
934
|
return 0;
|
|
861
935
|
}
|
|
862
|
-
case "logout":
|
|
863
|
-
|
|
936
|
+
case "logout": {
|
|
937
|
+
// Revoke server-side before forgetting locally, so a stolen credentials
|
|
938
|
+
// file is dead rather than merely absent from this machine.
|
|
939
|
+
const configured = resolveAuthUrl();
|
|
940
|
+
await logout(configured ? await normalizeAuthUrl(configured) : undefined);
|
|
864
941
|
out("Logged out — cached credentials cleared.");
|
|
865
942
|
return 0;
|
|
943
|
+
}
|
|
866
944
|
default:
|
|
867
945
|
usage(2);
|
|
868
946
|
}
|
package/dist/proxy/session.js
CHANGED
|
@@ -214,10 +214,11 @@ export class ProxySession {
|
|
|
214
214
|
logLine("recorder.disabled", { why: "BIR_AUTH_URL is not set" });
|
|
215
215
|
return new NullRecorder();
|
|
216
216
|
}
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
217
|
+
// A proxy runs inside the host's process tree with its stdio bound to the
|
|
218
|
+
// JSON-RPC stream: there is nowhere to prompt, and blocking on one would
|
|
219
|
+
// hang the host's server startup. `authenticate` never interacts — signing
|
|
220
|
+
// in lives in `bir login` alone — so that is guaranteed here, not requested.
|
|
221
|
+
const session = await authenticate({ authUrl: baseUrl });
|
|
221
222
|
if (!session) {
|
|
222
223
|
logLine("recorder.disabled", { why: "no BaseIn session — run `bir login`" });
|
|
223
224
|
return new NullRecorder();
|
|
@@ -44,7 +44,7 @@ honest ceiling rather than a bug.
|
|
|
44
44
|
export BIR_AUTH_URL=https://your-basein-service
|
|
45
45
|
npm install && npm run build
|
|
46
46
|
node dist/bin/bir.js install # or `bir install` once linked/published
|
|
47
|
-
bir login
|
|
47
|
+
bir login # prints a link and a code; approve in the browser
|
|
48
48
|
bir-hooks # leave running in its own terminal
|
|
49
49
|
```
|
|
50
50
|
|
package/docs/installRun.md
CHANGED
|
@@ -189,7 +189,7 @@ runs":
|
|
|
189
189
|
| Scope | How often | What it does |
|
|
190
190
|
|---|---|---|
|
|
191
191
|
| **machine** | once | Node ≥ 20, and the package installed globally |
|
|
192
|
-
| **account** | once per user | `BIR_AUTH_URL`, then `bir login` → `~/.baseinstrunner/credentials.json` (0600) |
|
|
192
|
+
| **account** | once per user | `BIR_AUTH_URL`, then `bir login` (approved in a browser) → `~/.baseinstrunner/credentials.json` (0600) |
|
|
193
193
|
| **project** | once per repo | `bir install --global` rewrites that project's MCP entries and wires the hooks |
|
|
194
194
|
| **session** | while recording | `bir-hooks` running, discovered by `sha256(normalised cwd)` |
|
|
195
195
|
|
|
@@ -288,22 +288,35 @@ so the tarball has to be carried to the runner by whatever means you already use
|
|
|
288
288
|
|
|
289
289
|
---
|
|
290
290
|
|
|
291
|
-
## 7. Headless authentication
|
|
291
|
+
## 7. Headless authentication
|
|
292
292
|
|
|
293
|
-
`bir login`
|
|
293
|
+
`bir login` signs in through the browser — the OAuth 2.0 device grant (RFC 8628,
|
|
294
|
+
[loginWeb.md](loginWeb.md)) — and caches
|
|
294
295
|
`{accessToken, refreshToken, accessExpiresAt, user}` at
|
|
295
|
-
`~/.baseinstrunner/credentials.json`, mode 0600.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
296
|
+
`~/.baseinstrunner/credentials.json`, mode 0600.
|
|
297
|
+
|
|
298
|
+
**For a machine with a person attached, headless is no longer a problem.** The
|
|
299
|
+
runner prints a link and a short code and waits; the link opens on *any* device,
|
|
300
|
+
so a box with no browser, no display, or only an SSH session is signed in from a
|
|
301
|
+
phone. Nothing is typed into the terminal, so nothing lands in a shell history or
|
|
302
|
+
a CI transcript. `bir login --no-browser` skips even trying to open one, which is
|
|
303
|
+
the right flag in a provisioning script (it is also the default over SSH).
|
|
304
|
+
|
|
305
|
+
For machines with **no person attached**, in order of preference:
|
|
306
|
+
|
|
307
|
+
1. **A service account, signed in once.** Approve the device code for a dedicated
|
|
308
|
+
account, then copy `credentials.json` to each machine at 0600. The rotating
|
|
309
|
+
refresh token keeps it alive and nothing ever prompts. It is still a shared
|
|
310
|
+
long-lived secret, so never a person's account.
|
|
311
|
+
2. **`BIR_AUTH_DISABLE=1`** for runners that should record nothing. Everything
|
|
312
|
+
else still works.
|
|
313
|
+
3. **Ask the service for a non-interactive grant** — a long-lived runner token,
|
|
314
|
+
created in the console and read from an env var. This remains the right answer
|
|
315
|
+
for a large fleet and is still a service-side change; the device grant makes
|
|
316
|
+
it less urgent, not unnecessary.
|
|
317
|
+
|
|
318
|
+
`bir logout` now revokes the session server-side before clearing the file, so
|
|
319
|
+
decommissioning a runner ends its access rather than merely tidying the disk.
|
|
307
320
|
|
|
308
321
|
`BIR_HOME` relocates all of this if state belongs somewhere other than
|
|
309
322
|
`~/.baseinstrunner`.
|