@basein/runner 0.1.1 → 0.2.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/README.md +10 -1
- package/dist/auth/client.d.ts +141 -13
- package/dist/auth/client.js +301 -14
- package/dist/bin/bir-hooks.d.ts +13 -0
- package/dist/bin/bir-hooks.js +58 -0
- package/dist/bin/bir.js +99 -17
- package/dist/control/server.d.ts +84 -1
- package/dist/control/server.js +546 -51
- package/dist/control/transcript.d.ts +40 -0
- package/dist/control/transcript.js +105 -0
- package/dist/proxy/session.js +5 -4
- package/dist/record/recorder.d.ts +178 -4
- package/dist/record/recorder.js +6 -0
- package/dist/record/remote-recorder.d.ts +20 -2
- package/dist/record/remote-recorder.js +66 -6
- package/dist/replay/bundle.d.ts +10 -1
- package/dist/replay/bundle.js +41 -3
- package/dist/replay/controller.d.ts +164 -5
- package/dist/replay/controller.js +556 -54
- package/dist/replay/coverage.js +2 -2
- package/dist/replay/derive.d.ts +20 -1
- package/dist/replay/derive.js +69 -12
- package/dist/replay/flatten.d.ts +125 -0
- package/dist/replay/flatten.js +182 -0
- package/dist/replay/handover.d.ts +60 -0
- package/dist/replay/handover.js +82 -0
- package/dist/replay/logic.d.ts +11 -0
- package/dist/replay/logic.js +17 -0
- package/dist/replay/plan.d.ts +105 -8
- package/dist/replay/plan.js +309 -47
- package/dist/replay/source-run.d.ts +24 -10
- package/dist/replay/source-run.js +65 -30
- package/dist/replay/types.d.ts +108 -5
- package/dist/replay/types.js +33 -3
- 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 +28 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,11 +51,20 @@ byte-for-byte.
|
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
53
|
export BIR_AUTH_URL=https://your-basein-service # required to record anything
|
|
54
|
-
bir login # once
|
|
54
|
+
bir login # once; approve it in the browser
|
|
55
55
|
bir-hooks # leave running; Ctrl-C to stop
|
|
56
56
|
claude # your session, now recorded
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
`bir login` prints a link and a short code and waits for you to approve it in
|
|
60
|
+
the browser — the OAuth device grant, the same shape `gh auth login` uses. It
|
|
61
|
+
never handles a password, so it works for accounts that only sign in with
|
|
62
|
+
Google, and the link can be opened on any device, which is what makes it usable
|
|
63
|
+
on a headless runner. `--no-browser` prints the link without opening one;
|
|
64
|
+
`--token <value>` redeems a one-time setup token made on the console's *Set up
|
|
65
|
+
the runner* page, with no browser step at all (service 2026-09 or newer);
|
|
66
|
+
`--password` is the deprecated email-and-password prompt, removed next release.
|
|
67
|
+
|
|
59
68
|
Check it:
|
|
60
69
|
|
|
61
70
|
```bash
|
package/dist/auth/client.d.ts
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
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).
|
|
@@ -36,8 +50,13 @@ export interface AuthSession {
|
|
|
36
50
|
export interface AuthenticateOptions {
|
|
37
51
|
/** Base URL of the auth service. Defaults to BIR_AUTH_URL. */
|
|
38
52
|
authUrl?: string;
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
}
|
|
54
|
+
interface AuthResponse {
|
|
55
|
+
accessToken: string;
|
|
56
|
+
refreshToken: string;
|
|
57
|
+
/** Access token lifetime in seconds. */
|
|
58
|
+
expiresIn: number;
|
|
59
|
+
user: AuthUser;
|
|
41
60
|
}
|
|
42
61
|
/**
|
|
43
62
|
* Where the cached session lives.
|
|
@@ -105,4 +124,113 @@ export declare function describeAuthService(baseUrl: string, fetchImpl?: typeof
|
|
|
105
124
|
* obtained. Callers degrade to not-recording rather than failing the session.
|
|
106
125
|
*/
|
|
107
126
|
export declare function authenticate(opts?: AuthenticateOptions): Promise<AuthSession | undefined>;
|
|
127
|
+
/**
|
|
128
|
+
* The service has no device endpoints — it predates browser sign-in.
|
|
129
|
+
*
|
|
130
|
+
* Thrown so `bir login` can fall back to the password flow rather than leaving
|
|
131
|
+
* someone stranded on a runner that is newer than the service it talks to.
|
|
132
|
+
*/
|
|
133
|
+
export declare class DeviceFlowUnsupported extends Error {
|
|
134
|
+
constructor();
|
|
135
|
+
}
|
|
136
|
+
/** The CLI's view of a started device authorization. */
|
|
137
|
+
export interface DeviceCodeGrant {
|
|
138
|
+
deviceCode: string;
|
|
139
|
+
userCode: string;
|
|
140
|
+
verificationUri: string;
|
|
141
|
+
verificationUriComplete?: string;
|
|
142
|
+
expiresIn: number;
|
|
143
|
+
interval: number;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Start a sign-in. Returns the codes; the caller shows one and polls with the
|
|
147
|
+
* other.
|
|
148
|
+
*
|
|
149
|
+
* `clientName`/`clientVersion` are shown on the approval page so the person can
|
|
150
|
+
* tell their own `bir login` from somebody else's — the one defence a device
|
|
151
|
+
* flow has against being talked into approving a stranger's code.
|
|
152
|
+
*/
|
|
153
|
+
export declare function startDeviceCode(authUrl: string, client: {
|
|
154
|
+
name?: string;
|
|
155
|
+
version?: string;
|
|
156
|
+
}, fetchImpl?: typeof fetch): Promise<DeviceCodeGrant>;
|
|
157
|
+
/** The user gave up, or the code died before anyone approved it. */
|
|
158
|
+
export declare class DeviceFlowAborted extends Error {
|
|
159
|
+
readonly reason: "access_denied" | "expired_token" | "timeout" | "cancelled";
|
|
160
|
+
constructor(message: string, reason: "access_denied" | "expired_token" | "timeout" | "cancelled");
|
|
161
|
+
}
|
|
162
|
+
export interface PollOptions {
|
|
163
|
+
interval: number;
|
|
164
|
+
expiresIn: number;
|
|
165
|
+
fetchImpl?: typeof fetch;
|
|
166
|
+
/** Injected so tests run in milliseconds rather than minutes. */
|
|
167
|
+
sleep?: (ms: number) => Promise<void>;
|
|
168
|
+
signal?: AbortSignal;
|
|
169
|
+
now?: () => number;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Poll until the sign-in is approved, refused, or dies.
|
|
173
|
+
*
|
|
174
|
+
* RFC 8628 §3.5's loop, including the part people skip: `slow_down` is not an
|
|
175
|
+
* error, it is the server asking for a longer gap, and the interval must
|
|
176
|
+
* *stay* longer afterwards. Ignoring it turns a slow client into a blocked one.
|
|
177
|
+
*/
|
|
178
|
+
export declare function pollDeviceToken(authUrl: string, deviceCode: string, opts: PollOptions): Promise<AuthResponse>;
|
|
179
|
+
/**
|
|
180
|
+
* Open a URL in the user's browser. Best effort, and never fatal.
|
|
181
|
+
*
|
|
182
|
+
* No dependency and no shell: this package ships zero runtime dependencies, and
|
|
183
|
+
* passing a URL through a shell is how a URL becomes a command. The link is
|
|
184
|
+
* printed either way, so a failure here costs nothing.
|
|
185
|
+
*/
|
|
186
|
+
export declare function openBrowser(url: string): void;
|
|
187
|
+
export interface DeviceLoginOptions {
|
|
188
|
+
/** `--no-browser`, or a machine where opening one is wrong. */
|
|
189
|
+
openBrowser?: boolean;
|
|
190
|
+
fetchImpl?: typeof fetch;
|
|
191
|
+
sleep?: (ms: number) => Promise<void>;
|
|
192
|
+
signal?: AbortSignal;
|
|
193
|
+
/** Where the human-readable lines go. stderr, so stdout stays parseable. */
|
|
194
|
+
write?: (text: string) => void;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Sign in through the browser and cache the session.
|
|
198
|
+
*
|
|
199
|
+
* Everything a person sees goes to stderr: `bir login`'s stdout is one line
|
|
200
|
+
* that a script may read, and the code block is not it.
|
|
201
|
+
*/
|
|
202
|
+
export declare function deviceLogin(authUrl: string, opts?: DeviceLoginOptions): Promise<AuthSession | undefined>;
|
|
203
|
+
/**
|
|
204
|
+
* Sign in with a setup token and cache the session.
|
|
205
|
+
*
|
|
206
|
+
* A setup token is a device code the console minted already approved for the
|
|
207
|
+
* person who was signed in there (`POST /auth/device/setup-token`), so this is
|
|
208
|
+
* the browser flow with the waiting removed: one redemption, no polling. It is
|
|
209
|
+
* the secret that opens a session, so it is never printed or logged here, for
|
|
210
|
+
* the same reason a device code never is.
|
|
211
|
+
*/
|
|
212
|
+
export declare function tokenLogin(authUrl: string, token: string, opts?: {
|
|
213
|
+
fetchImpl?: typeof fetch;
|
|
214
|
+
}): Promise<AuthSession>;
|
|
215
|
+
/**
|
|
216
|
+
* Sign in with an email and a password.
|
|
217
|
+
*
|
|
218
|
+
* DEPRECATED, and kept for exactly two cases: `bir login --password`, and a
|
|
219
|
+
* runner talking to a service that predates the device endpoints. Both go in
|
|
220
|
+
* the release after this one, and this function goes with them.
|
|
221
|
+
*
|
|
222
|
+
* It cannot work at all for an account created through Google — there is no
|
|
223
|
+
* password on it to send — which is the main reason sign-in moved.
|
|
224
|
+
*/
|
|
225
|
+
export declare function legacyPasswordLogin(authUrl: string): Promise<AuthSession | undefined>;
|
|
226
|
+
/**
|
|
227
|
+
* Sign out: revoke the refresh token server-side, then forget it locally.
|
|
228
|
+
*
|
|
229
|
+
* Deleting the file alone left the token live for its full 30 days, which made
|
|
230
|
+
* `bir logout` a tidy-up rather than a revocation. The endpoint is idempotent
|
|
231
|
+
* and its failure is not worth blocking on — the local half must happen either
|
|
232
|
+
* way, or "logged out" would be a lie on a machine with no network.
|
|
233
|
+
*/
|
|
234
|
+
export declare function logout(authUrl?: string): Promise<void>;
|
|
235
|
+
export {};
|
|
108
236
|
//# sourceMappingURL=client.d.ts.map
|
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
|
*
|
|
@@ -323,10 +340,253 @@ export async function authenticate(opts) {
|
|
|
323
340
|
return refreshed;
|
|
324
341
|
}
|
|
325
342
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 */
|
|
329
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
|
+
* Sign in with a setup token and cache the session.
|
|
522
|
+
*
|
|
523
|
+
* A setup token is a device code the console minted already approved for the
|
|
524
|
+
* person who was signed in there (`POST /auth/device/setup-token`), so this is
|
|
525
|
+
* the browser flow with the waiting removed: one redemption, no polling. It is
|
|
526
|
+
* the secret that opens a session, so it is never printed or logged here, for
|
|
527
|
+
* the same reason a device code never is.
|
|
528
|
+
*/
|
|
529
|
+
export async function tokenLogin(authUrl, token, opts = {}) {
|
|
530
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
531
|
+
const res = await fetchImpl(`${authUrl}/auth/device/token`, {
|
|
532
|
+
method: "POST",
|
|
533
|
+
headers: { "content-type": "application/json" },
|
|
534
|
+
body: JSON.stringify({ deviceCode: token.trim() }),
|
|
535
|
+
});
|
|
536
|
+
if (res.status === 404)
|
|
537
|
+
throw new DeviceFlowUnsupported();
|
|
538
|
+
if (res.ok) {
|
|
539
|
+
const session = toSession((await res.json()));
|
|
540
|
+
saveCredentials(session);
|
|
541
|
+
logLine("auth.token_login", { url: authUrl });
|
|
542
|
+
return session;
|
|
543
|
+
}
|
|
544
|
+
const body = (await res.json().catch(() => ({})));
|
|
545
|
+
switch (body.error) {
|
|
546
|
+
case "expired_token":
|
|
547
|
+
throw new DeviceFlowAborted("this setup token has expired or was already used — make a new one on the console's " +
|
|
548
|
+
"Set up the runner page and paste it within ten minutes", "expired_token");
|
|
549
|
+
case "invalid_grant":
|
|
550
|
+
case "authorization_pending":
|
|
551
|
+
case "slow_down":
|
|
552
|
+
case "access_denied":
|
|
553
|
+
// A pending or unknown code is not a setup token: the console only hands
|
|
554
|
+
// out codes that are already approved.
|
|
555
|
+
throw new DeviceFlowAborted("that is not a valid setup token — copy the whole token from the console's " +
|
|
556
|
+
"Set up the runner page", "access_denied");
|
|
557
|
+
default:
|
|
558
|
+
throw new Error(`sign-in failed: ${body.error ?? `HTTP ${res.status}`}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* A name for this machine, shown on the approval page.
|
|
563
|
+
*
|
|
564
|
+
* This is the string someone reads to decide whether the request is theirs, so
|
|
565
|
+
* it names the tool and the host. Never fatal: an unnamed client still works,
|
|
566
|
+
* it just tells the approver less.
|
|
567
|
+
*/
|
|
568
|
+
function clientName() {
|
|
569
|
+
try {
|
|
570
|
+
return `bir on ${hostname()}`;
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return "bir";
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// ── The password flow, on its way out ──────────────────────────────────────
|
|
577
|
+
/**
|
|
578
|
+
* Sign in with an email and a password.
|
|
579
|
+
*
|
|
580
|
+
* DEPRECATED, and kept for exactly two cases: `bir login --password`, and a
|
|
581
|
+
* runner talking to a service that predates the device endpoints. Both go in
|
|
582
|
+
* the release after this one, and this function goes with them.
|
|
583
|
+
*
|
|
584
|
+
* It cannot work at all for an account created through Google — there is no
|
|
585
|
+
* password on it to send — which is the main reason sign-in moved.
|
|
586
|
+
*/
|
|
587
|
+
export async function legacyPasswordLogin(authUrl) {
|
|
588
|
+
if (!process.stdin.isTTY) {
|
|
589
|
+
logLine("auth.no_session", { why: "no terminal to read a password on" });
|
|
330
590
|
return undefined;
|
|
331
591
|
}
|
|
332
592
|
// Never send a password to something that is not the service. The website
|
|
@@ -353,4 +613,31 @@ export async function authenticate(opts) {
|
|
|
353
613
|
return undefined;
|
|
354
614
|
}
|
|
355
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Sign out: revoke the refresh token server-side, then forget it locally.
|
|
618
|
+
*
|
|
619
|
+
* Deleting the file alone left the token live for its full 30 days, which made
|
|
620
|
+
* `bir logout` a tidy-up rather than a revocation. The endpoint is idempotent
|
|
621
|
+
* and its failure is not worth blocking on — the local half must happen either
|
|
622
|
+
* way, or "logged out" would be a lie on a machine with no network.
|
|
623
|
+
*/
|
|
624
|
+
export async function logout(authUrl) {
|
|
625
|
+
const cached = loadCredentials();
|
|
626
|
+
if (authUrl && cached?.refreshToken) {
|
|
627
|
+
try {
|
|
628
|
+
await fetch(`${authUrl}/auth/logout`, {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: { "content-type": "application/json" },
|
|
631
|
+
body: JSON.stringify({ refreshToken: cached.refreshToken }),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
logLine("auth.logout_remote_failed", {
|
|
636
|
+
error: errText(err),
|
|
637
|
+
why: "the local credentials are cleared regardless",
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
clearCredentials();
|
|
642
|
+
}
|
|
356
643
|
//# sourceMappingURL=client.js.map
|
package/dist/bin/bir-hooks.d.ts
CHANGED
|
@@ -33,6 +33,19 @@
|
|
|
33
33
|
* BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
|
|
34
34
|
* BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
|
|
35
35
|
* BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
|
|
36
|
+
* BIR_DERIVE_RECENT_RESULTS how many of this prompt's tool results derivation
|
|
37
|
+
* reads, so a target named by an earlier step can be
|
|
38
|
+
* found (default 5; 0 off)
|
|
39
|
+
* BIR_INTENT_SOURCES which text a step's intent may come from
|
|
40
|
+
* (default text,thinking,tool)
|
|
41
|
+
* BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40).
|
|
42
|
+
* Separate from BIR_INTENT_MATCHES_PER_TURN: arming
|
|
43
|
+
* is a claim on the turn, counting hits is not
|
|
44
|
+
* BIR_SEGMENT_ARM 1 lets a handed-out segment actually run mid-task.
|
|
45
|
+
* Unset is observe-only: what would have armed is
|
|
46
|
+
* logged and nothing is replaced
|
|
47
|
+
* BIR_MIN_SEGMENT_STEER_SIMILARITY minimum similarity to steer a segment
|
|
48
|
+
* (default 0.95)
|
|
36
49
|
* BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (default 120000)
|
|
37
50
|
* BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
|
|
38
51
|
*
|
package/dist/bin/bir-hooks.js
CHANGED
|
@@ -33,6 +33,19 @@
|
|
|
33
33
|
* BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
|
|
34
34
|
* BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
|
|
35
35
|
* BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
|
|
36
|
+
* BIR_DERIVE_RECENT_RESULTS how many of this prompt's tool results derivation
|
|
37
|
+
* reads, so a target named by an earlier step can be
|
|
38
|
+
* found (default 5; 0 off)
|
|
39
|
+
* BIR_INTENT_SOURCES which text a step's intent may come from
|
|
40
|
+
* (default text,thinking,tool)
|
|
41
|
+
* BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40).
|
|
42
|
+
* Separate from BIR_INTENT_MATCHES_PER_TURN: arming
|
|
43
|
+
* is a claim on the turn, counting hits is not
|
|
44
|
+
* BIR_SEGMENT_ARM 1 lets a handed-out segment actually run mid-task.
|
|
45
|
+
* Unset is observe-only: what would have armed is
|
|
46
|
+
* logged and nothing is replaced
|
|
47
|
+
* BIR_MIN_SEGMENT_STEER_SIMILARITY minimum similarity to steer a segment
|
|
48
|
+
* (default 0.95)
|
|
36
49
|
* BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (default 120000)
|
|
37
50
|
* BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
|
|
38
51
|
*
|
|
@@ -93,6 +106,29 @@ function positiveInt(value, fallback) {
|
|
|
93
106
|
const n = Number(value);
|
|
94
107
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
95
108
|
}
|
|
109
|
+
/** Like {@link positiveInt}, but 0 is a meaningful setting rather than unset. */
|
|
110
|
+
function nonNegativeInt(value, fallback) {
|
|
111
|
+
const n = Number(value);
|
|
112
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* `BIR_INTENT_SOURCES` — which text a step's intent may be read from
|
|
116
|
+
* (segmented.md R-INTENT-3). Unset or unparseable means all three.
|
|
117
|
+
*
|
|
118
|
+
* It governs only what is *sent*. Turning every source off does not stop a
|
|
119
|
+
* probe: the request still goes out with an empty text and the server builds a
|
|
120
|
+
* line from the tool name and arguments (R-HIT-4).
|
|
121
|
+
*/
|
|
122
|
+
function parseIntentSources(value) {
|
|
123
|
+
if (value === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
const allowed = ["text", "thinking", "tool"];
|
|
126
|
+
const wanted = value
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((s) => s.trim().toLowerCase())
|
|
129
|
+
.filter((s) => allowed.includes(s));
|
|
130
|
+
return new Set(wanted);
|
|
131
|
+
}
|
|
96
132
|
/**
|
|
97
133
|
* Assemble the replay configuration (docs/calculatedReplay.md §5.3 of the guide).
|
|
98
134
|
*
|
|
@@ -116,6 +152,25 @@ function buildReplayOptions(auth) {
|
|
|
116
152
|
planMs: positiveInt(process.env.BIR_REPLAY_BUDGET_MS, 120_000),
|
|
117
153
|
stepMs: positiveInt(process.env.BIR_STEP_TIMEOUT_MS, 60_000),
|
|
118
154
|
},
|
|
155
|
+
// Intent matching in the ReAct loop (fallbk.md §Runner 4). On with replay;
|
|
156
|
+
// BIR_INTENT_MATCH=0 turns it off on its own.
|
|
157
|
+
intentMatch: {
|
|
158
|
+
enabled: process.env.BIR_INTENT_MATCH !== "0",
|
|
159
|
+
// 4 000, not 1 500: a request whose best segment lands in the not-clear
|
|
160
|
+
// band has one live question to ask under the server's own 2 500 ms
|
|
161
|
+
// attempt, and must still answer inside the hook's 30 s (segmented.md
|
|
162
|
+
// R-HIT-11).
|
|
163
|
+
budgetMs: positiveInt(process.env.BIR_INTENT_MATCH_BUDGET_MS, 4_000),
|
|
164
|
+
maxPerTurn: positiveInt(process.env.BIR_INTENT_MATCHES_PER_TURN, 3),
|
|
165
|
+
maxRequestsPerTurn: positiveInt(process.env.BIR_INTENT_REQUESTS_PER_TURN, 40),
|
|
166
|
+
},
|
|
167
|
+
// Observe-only until an operator has read the logs and turned it on
|
|
168
|
+
// (segmented.md R-OUT-10, R-LIFE-7). Arming is never the way to find out.
|
|
169
|
+
segmentArm: process.env.BIR_SEGMENT_ARM === "1",
|
|
170
|
+
minSegmentSimilarity: (() => {
|
|
171
|
+
const n = Number(process.env.BIR_MIN_SEGMENT_STEER_SIMILARITY);
|
|
172
|
+
return Number.isFinite(n) ? n : 0.95;
|
|
173
|
+
})(),
|
|
119
174
|
authUrl: auth.baseUrl,
|
|
120
175
|
// Read late, not captured: `RemoteRecorder` refreshes the access token as the
|
|
121
176
|
// session outlives it, and a snapshot taken here would go stale mid-run.
|
|
@@ -128,6 +183,7 @@ function buildReplayOptions(auth) {
|
|
|
128
183
|
minSimilarity: opts.minSimilarity,
|
|
129
184
|
allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
|
|
130
185
|
derive: process.env.ANTHROPIC_API_KEY ? "anthropic" : "recorded sample values",
|
|
186
|
+
intentMatch: opts.intentMatch?.enabled ? "on" : "off",
|
|
131
187
|
why: "matched prompts will run their calculated scenario — steered steps are auto-approved",
|
|
132
188
|
});
|
|
133
189
|
}
|
|
@@ -152,6 +208,8 @@ async function main() {
|
|
|
152
208
|
host: { app: "claude-code" },
|
|
153
209
|
correlationDecision: process.env.BIR_CORRELATION_DECISION === "ask" ? "ask" : "allow",
|
|
154
210
|
noCorrelation: process.env.BIR_NO_CORRELATION === "1",
|
|
211
|
+
deriveRecentResults: nonNegativeInt(process.env.BIR_DERIVE_RECENT_RESULTS, 5),
|
|
212
|
+
intentSources: parseIntentSources(process.env.BIR_INTENT_SOURCES),
|
|
155
213
|
replay: buildReplayOptions(auth),
|
|
156
214
|
});
|
|
157
215
|
const address = await server.listen();
|