@basein/runner 0.2.12 → 0.3.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 +540 -369
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -21
- package/dist/bin/bir-scenario.js +593 -122
- package/dist/bin/bir.d.ts +12 -6
- package/dist/bin/bir.js +2935 -489
- package/dist/bin/investigate.js +40 -15
- package/dist/bin/scenario-edit.d.ts +7 -1
- package/dist/bin/scenario-edit.js +43 -21
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2787 -302
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +369 -20
- package/dist/replay/controller.js +1176 -107
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1304
- package/docs/calculatedReplayGuide.md +1212 -1087
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -311
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/auth/client.d.ts
CHANGED
|
@@ -62,8 +62,25 @@ export interface AuthSession {
|
|
|
62
62
|
export interface AuthenticateOptions {
|
|
63
63
|
/** Base URL of the auth service. Defaults to BIR_AUTH_URL. */
|
|
64
64
|
authUrl?: string;
|
|
65
|
+
/**
|
|
66
|
+
* When the access token has expired and the refresh gets *no answer* (the
|
|
67
|
+
* service is down, blackholed, or answering 5xx) — as opposed to refusing
|
|
68
|
+
* the refresh token — return the expired session anyway.
|
|
69
|
+
*
|
|
70
|
+
* For a long-lived recorder: without it, a service outage at the moment a
|
|
71
|
+
* recorder starts disables recording for the recorder's whole life (days),
|
|
72
|
+
* although the refresh token is fine. `RemoteRecorder` renews the session in
|
|
73
|
+
* the background once the service answers. Not for the CLI, where "signed
|
|
74
|
+
* in" must mean a working token.
|
|
75
|
+
*/
|
|
76
|
+
keepStaleOnNoAnswer?: boolean;
|
|
77
|
+
}
|
|
78
|
+
/** A non-2xx answer from the service, with its status. */
|
|
79
|
+
export declare class AuthHttpError extends Error {
|
|
80
|
+
readonly status: number;
|
|
81
|
+
constructor(message: string, status: number);
|
|
65
82
|
}
|
|
66
|
-
interface AuthResponse {
|
|
83
|
+
export interface AuthResponse {
|
|
67
84
|
accessToken: string;
|
|
68
85
|
refreshToken: string;
|
|
69
86
|
/** Access token lifetime in seconds. */
|
|
@@ -81,10 +98,32 @@ interface AuthResponse {
|
|
|
81
98
|
export declare function credentialsPath(): string;
|
|
82
99
|
/** Load the cached session, or undefined if missing/unreadable/malformed. */
|
|
83
100
|
export declare function loadCredentials(): AuthSession | undefined;
|
|
84
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Persist the session with mode 0600.
|
|
103
|
+
*
|
|
104
|
+
* Atomically: every sibling process (`bir-hooks`, each `bir-proxy`, the CLI)
|
|
105
|
+
* reads this file while others write it, and a truncate-then-write left a
|
|
106
|
+
* window in which a reader saw an empty file — "no session" — and ran signed
|
|
107
|
+
* out for its whole life. Worse, a crash in that window after a rotating
|
|
108
|
+
* refresh left the only valid refresh token nowhere.
|
|
109
|
+
*/
|
|
85
110
|
export declare function saveCredentials(session: AuthSession): void;
|
|
86
111
|
/** Remove the cached session (`bir logout`). No-op if absent. */
|
|
87
112
|
export declare function clearCredentials(): void;
|
|
113
|
+
/**
|
|
114
|
+
* Is `body` a usable sign-in or refresh answer? A 200 of some other shape — a
|
|
115
|
+
* proxy's JSON error, a newer service's envelope — must never be saved: the
|
|
116
|
+
* next process would load `accessToken: undefined`, call the file corrupt, and
|
|
117
|
+
* the refresh token that was just rotated would be lost with it.
|
|
118
|
+
*/
|
|
119
|
+
export declare function isAuthResponse(body: unknown): body is AuthResponse;
|
|
120
|
+
/**
|
|
121
|
+
* True when a cached session may be used against `authUrl`. Exported so the
|
|
122
|
+
* recorder applies the same rule when it adopts a session a sibling wrote.
|
|
123
|
+
*/
|
|
124
|
+
export declare function issuedFor(session: AuthSession, authUrl: string): boolean;
|
|
125
|
+
/** One bounded line about a body that was not the API's: never a whole page. */
|
|
126
|
+
export declare function describeBody(text: string, contentType?: string): string;
|
|
88
127
|
/** The persisted settings in ~/.baseinstrunner/config.json. */
|
|
89
128
|
export interface StoredConfig {
|
|
90
129
|
/** The service address 'bir setup' or 'bir login' last signed in against. */
|
|
@@ -215,6 +254,25 @@ export interface PollOptions {
|
|
|
215
254
|
* *stay* longer afterwards. Ignoring it turns a slow client into a blocked one.
|
|
216
255
|
*/
|
|
217
256
|
export declare function pollDeviceToken(authUrl: string, deviceCode: string, opts: PollOptions): Promise<AuthResponse>;
|
|
257
|
+
/**
|
|
258
|
+
* The link to open for a sign-in, or undefined when it must not be opened.
|
|
259
|
+
*
|
|
260
|
+
* It comes from the service's answer, so it is checked before anything hands
|
|
261
|
+
* it to the operating system: an `https:` URL (plain `http:` only when the
|
|
262
|
+
* service itself is `http:`, a loopback test server), never `file:` or any
|
|
263
|
+
* other scheme a URL handler would run. Returned normalised, which
|
|
264
|
+
* percent-encodes spaces and quotes.
|
|
265
|
+
*/
|
|
266
|
+
export declare function browserSafeLink(link: string, authUrl: string): string | undefined;
|
|
267
|
+
/**
|
|
268
|
+
* The argument vector that opens `url` in the browser on `platform`.
|
|
269
|
+
*
|
|
270
|
+
* Never a shell. On Windows that rules out `cmd /c start`: cmd.exe parses the
|
|
271
|
+
* whole line itself, so an `&` in a link a service sent ran whatever followed
|
|
272
|
+
* it as a second command. `rundll32 url.dll,FileProtocolHandler <url>` hands
|
|
273
|
+
* the URL to the default browser as one argument, with nothing in between.
|
|
274
|
+
*/
|
|
275
|
+
export declare function browserCommand(url: string, platform?: NodeJS.Platform): [string, string[]];
|
|
218
276
|
/**
|
|
219
277
|
* Open a URL in the user's browser. Best effort, and never fatal.
|
|
220
278
|
*
|
|
@@ -269,7 +327,11 @@ export declare function legacyPasswordLogin(authUrl: string): Promise<AuthSessio
|
|
|
269
327
|
* `bir logout` a tidy-up rather than a revocation. The endpoint is idempotent
|
|
270
328
|
* and its failure is not worth blocking on — the local half must happen either
|
|
271
329
|
* way, or "logged out" would be a lie on a machine with no network.
|
|
330
|
+
*
|
|
331
|
+
* LOCAL FIRST. The file goes before the network call, so a hung service or a
|
|
332
|
+
* Ctrl-C during the revoke can never leave the machine signed in; running
|
|
333
|
+
* recorders notice the missing file within seconds and stop sending. The
|
|
334
|
+
* revoke itself is bounded to {@link LOGOUT_TIMEOUT_MS}.
|
|
272
335
|
*/
|
|
273
336
|
export declare function logout(authUrl?: string): Promise<void>;
|
|
274
|
-
export {};
|
|
275
337
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/auth/client.js
CHANGED
|
@@ -40,12 +40,23 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { spawn } from "node:child_process";
|
|
42
42
|
import { createInterface } from "node:readline";
|
|
43
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync
|
|
43
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
44
44
|
import { hostname } from "node:os";
|
|
45
45
|
import { configDir, configPath, ensureDir } from "../control/paths.js";
|
|
46
46
|
import { join } from "node:path";
|
|
47
47
|
import { logLine, errText } from "../util/log.js";
|
|
48
48
|
import { packageVersion } from "../util/version.js";
|
|
49
|
+
import { writeFileAtomic } from "../util/atomic-write.js";
|
|
50
|
+
import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal } from "../util/service-health.js";
|
|
51
|
+
/** A non-2xx answer from the service, with its status. */
|
|
52
|
+
export class AuthHttpError extends Error {
|
|
53
|
+
status;
|
|
54
|
+
constructor(message, status) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.status = status;
|
|
57
|
+
this.name = "AuthHttpError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
49
60
|
/**
|
|
50
61
|
* Where the cached session lives.
|
|
51
62
|
*
|
|
@@ -86,12 +97,31 @@ export function loadCredentials() {
|
|
|
86
97
|
}
|
|
87
98
|
return undefined;
|
|
88
99
|
}
|
|
89
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Persist the session with mode 0600.
|
|
102
|
+
*
|
|
103
|
+
* Atomically: every sibling process (`bir-hooks`, each `bir-proxy`, the CLI)
|
|
104
|
+
* reads this file while others write it, and a truncate-then-write left a
|
|
105
|
+
* window in which a reader saw an empty file — "no session" — and ran signed
|
|
106
|
+
* out for its whole life. Worse, a crash in that window after a rotating
|
|
107
|
+
* refresh left the only valid refresh token nowhere.
|
|
108
|
+
*/
|
|
90
109
|
export function saveCredentials(session) {
|
|
91
110
|
const dir = configDir();
|
|
92
111
|
if (!existsSync(dir))
|
|
93
112
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
94
|
-
|
|
113
|
+
const path = credentialsPath();
|
|
114
|
+
writeFileAtomic(path, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
115
|
+
// writeFileAtomic keeps an existing file's mode; a session file is private
|
|
116
|
+
// whatever mode an older version (or a hand edit) left it with.
|
|
117
|
+
if (process.platform !== "win32") {
|
|
118
|
+
try {
|
|
119
|
+
chmodSync(path, 0o600);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* best effort */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
95
125
|
}
|
|
96
126
|
/** Remove the cached session (`bir logout`). No-op if absent. */
|
|
97
127
|
export function clearCredentials() {
|
|
@@ -106,7 +136,31 @@ export function clearCredentials() {
|
|
|
106
136
|
function isAccessValid(session) {
|
|
107
137
|
return session.accessExpiresAt - EXPIRY_SKEW_MS > Date.now();
|
|
108
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Is `body` a usable sign-in or refresh answer? A 200 of some other shape — a
|
|
141
|
+
* proxy's JSON error, a newer service's envelope — must never be saved: the
|
|
142
|
+
* next process would load `accessToken: undefined`, call the file corrupt, and
|
|
143
|
+
* the refresh token that was just rotated would be lost with it.
|
|
144
|
+
*/
|
|
145
|
+
export function isAuthResponse(body) {
|
|
146
|
+
const r = body;
|
|
147
|
+
return (!!r &&
|
|
148
|
+
typeof r === "object" &&
|
|
149
|
+
typeof r.accessToken === "string" &&
|
|
150
|
+
r.accessToken.length > 0 &&
|
|
151
|
+
typeof r.refreshToken === "string" &&
|
|
152
|
+
r.refreshToken.length > 0 &&
|
|
153
|
+
typeof r.expiresIn === "number" &&
|
|
154
|
+
Number.isFinite(r.expiresIn) &&
|
|
155
|
+
r.expiresIn > 0 &&
|
|
156
|
+
!!r.user &&
|
|
157
|
+
typeof r.user === "object" &&
|
|
158
|
+
typeof r.user.id === "string");
|
|
159
|
+
}
|
|
109
160
|
function toSession(res, authUrl) {
|
|
161
|
+
if (!isAuthResponse(res)) {
|
|
162
|
+
throw new Error("the service answered sign-in with an unexpected body — nothing was saved");
|
|
163
|
+
}
|
|
110
164
|
return {
|
|
111
165
|
accessToken: res.accessToken,
|
|
112
166
|
refreshToken: res.refreshToken,
|
|
@@ -115,11 +169,53 @@ function toSession(res, authUrl) {
|
|
|
115
169
|
...(authUrl ? { authUrl: authUrl.replace(/\/+$/, "") } : {}),
|
|
116
170
|
};
|
|
117
171
|
}
|
|
118
|
-
/**
|
|
119
|
-
|
|
172
|
+
/**
|
|
173
|
+
* True when a cached session may be used against `authUrl`. Exported so the
|
|
174
|
+
* recorder applies the same rule when it adopts a session a sibling wrote.
|
|
175
|
+
*/
|
|
176
|
+
export function issuedFor(session, authUrl) {
|
|
120
177
|
if (!session.authUrl)
|
|
121
178
|
return true; // pre-binding file: trusted as before
|
|
122
|
-
return session.authUrl === authUrl.replace(/\/+$/, "");
|
|
179
|
+
return session.authUrl.replace(/\/+$/, "") === authUrl.replace(/\/+$/, "");
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Report one answer from the service to the process-wide health verdict, with
|
|
183
|
+
* the serviceHealth.stamp() its request was sent at: a late answer to a
|
|
184
|
+
* request a hook already gave up on does not end that cool-down.
|
|
185
|
+
*/
|
|
186
|
+
function reportAnswer(res, startedAt) {
|
|
187
|
+
if (isUnhealthyStatus(res.status))
|
|
188
|
+
serviceHealth.fail(`HTTP ${res.status}`);
|
|
189
|
+
else
|
|
190
|
+
serviceHealth.ok(startedAt);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Report a request that got no answer. A caller's own abort (Ctrl-C) says
|
|
194
|
+
* nothing about the service, so it is not counted.
|
|
195
|
+
*/
|
|
196
|
+
function reportNoAnswer(err, callerSignal) {
|
|
197
|
+
if (callerSignal?.aborted)
|
|
198
|
+
return;
|
|
199
|
+
serviceHealth.fail(causeText(err));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A body that is a web page rather than the API's answer. Judged by the body:
|
|
203
|
+
* a framework's `res.send("ok")` is `text/html` too, and that is not a portal.
|
|
204
|
+
*/
|
|
205
|
+
function isWebPage(text, contentType) {
|
|
206
|
+
if (/^\s*<(!doctype|html|head|body)\b/i.test(text))
|
|
207
|
+
return true;
|
|
208
|
+
return contentType.toLowerCase().includes("html") && /^\s*</.test(text);
|
|
209
|
+
}
|
|
210
|
+
/** One bounded line about a body that was not the API's: never a whole page. */
|
|
211
|
+
export function describeBody(text, contentType = "") {
|
|
212
|
+
if (!text)
|
|
213
|
+
return "";
|
|
214
|
+
if (contentType.toLowerCase().includes("html") || /^\s*<(!doctype|html|head|body)/i.test(text)) {
|
|
215
|
+
return "a web page, not JSON";
|
|
216
|
+
}
|
|
217
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
218
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
|
123
219
|
}
|
|
124
220
|
/** Read the stored settings; an absent or unreadable file is an empty one. */
|
|
125
221
|
export function loadStoredConfig() {
|
|
@@ -137,7 +233,8 @@ export function loadStoredConfig() {
|
|
|
137
233
|
export function saveStoredConfig(patch) {
|
|
138
234
|
const next = { ...loadStoredConfig(), ...patch };
|
|
139
235
|
ensureDir(configDir());
|
|
140
|
-
|
|
236
|
+
// Atomic for the same reason as the credentials: every process reads it.
|
|
237
|
+
writeFileAtomic(configPath(), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
141
238
|
return next;
|
|
142
239
|
}
|
|
143
240
|
/**
|
|
@@ -197,12 +294,26 @@ export async function normalizeAuthUrl(baseUrl, fetchImpl = fetch) {
|
|
|
197
294
|
if (!base)
|
|
198
295
|
return base;
|
|
199
296
|
let res;
|
|
297
|
+
const started = serviceHealth.stamp();
|
|
200
298
|
try {
|
|
201
|
-
|
|
299
|
+
// Bounded: this runs before the recorder listens and before `bir logout`
|
|
300
|
+
// clears anything. Unbounded, a blackholed host cost the OS connect
|
|
301
|
+
// timeout (10-20 s) and a host that accepts and never answers cost 300 s.
|
|
302
|
+
res = await fetchImpl(`${base}/health`, {
|
|
303
|
+
method: "GET",
|
|
304
|
+
redirect: "manual",
|
|
305
|
+
signal: timeoutSignal(PROBE_TIMEOUT_MS),
|
|
306
|
+
});
|
|
202
307
|
}
|
|
203
|
-
catch {
|
|
204
|
-
|
|
308
|
+
catch (err) {
|
|
309
|
+
// Unreachable is the caller's problem, not ours to diagnose here — but it
|
|
310
|
+
// is the health verdict's: the recorder that starts next should not make
|
|
311
|
+
// its first hooks wait on a service that just failed to answer.
|
|
312
|
+
reportNoAnswer(err);
|
|
313
|
+
return base;
|
|
205
314
|
}
|
|
315
|
+
reportAnswer(res, started);
|
|
316
|
+
res.body?.cancel().catch(() => undefined);
|
|
206
317
|
if (res.status < 300 || res.status >= 400)
|
|
207
318
|
return base;
|
|
208
319
|
const location = res.headers.get("location");
|
|
@@ -267,6 +378,9 @@ export async function describeAuthService(baseUrl, fetchImpl = fetch) {
|
|
|
267
378
|
const base = baseUrl.replace(/\/+$/, "");
|
|
268
379
|
if (!base)
|
|
269
380
|
return "no URL configured";
|
|
381
|
+
// Not reported to the health verdict, unlike every other request here: this
|
|
382
|
+
// is asked of *candidate* addresses (a website someone pasted, a typo), and
|
|
383
|
+
// their failure says nothing about the service the runner actually uses.
|
|
270
384
|
let res;
|
|
271
385
|
try {
|
|
272
386
|
res = await fetchImpl(`${base}/health`, {
|
|
@@ -341,30 +455,63 @@ function promptPassword(question) {
|
|
|
341
455
|
});
|
|
342
456
|
}
|
|
343
457
|
async function postJson(url, body) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
458
|
+
let res;
|
|
459
|
+
const started = serviceHealth.stamp();
|
|
460
|
+
try {
|
|
461
|
+
// Bounded (BIR_SERVICE_TIMEOUT_MS): `authenticate()` runs this at the
|
|
462
|
+
// start of every recorder and proxy, and a hung service must cost those
|
|
463
|
+
// seconds, not minutes.
|
|
464
|
+
res = await fetch(url, {
|
|
465
|
+
method: "POST",
|
|
466
|
+
headers: { "content-type": "application/json" },
|
|
467
|
+
body: JSON.stringify(body),
|
|
468
|
+
signal: timeoutSignal(serviceTimeoutMs()),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
reportNoAnswer(err);
|
|
473
|
+
throw err;
|
|
474
|
+
}
|
|
475
|
+
const type = res.headers.get("content-type") ?? "";
|
|
476
|
+
const text = await res.text().catch(() => "");
|
|
477
|
+
const isHtml = type.toLowerCase().includes("html") || /^\s*<(!doctype|html)/i.test(text);
|
|
349
478
|
if (!res.ok) {
|
|
350
|
-
|
|
351
|
-
const isHtml = (res.headers.get("content-type") ?? "").includes("html") || /^\s*<(!doctype|html)/i.test(text);
|
|
479
|
+
reportAnswer(res, started);
|
|
352
480
|
const detail = isHtml
|
|
353
481
|
? " — the answer was a web page, not JSON: this URL is a website, not the BaseIn API"
|
|
354
482
|
: text
|
|
355
|
-
? ` — ${text
|
|
483
|
+
? ` — ${describeBody(text, type)}`
|
|
356
484
|
: "";
|
|
357
|
-
throw new
|
|
485
|
+
throw new AuthHttpError(`HTTP ${res.status} ${res.statusText}${detail}`, res.status);
|
|
486
|
+
}
|
|
487
|
+
if (isHtml) {
|
|
488
|
+
// A 200 web page is a captive portal or a website, not the API answering.
|
|
489
|
+
serviceHealth.fail("html");
|
|
490
|
+
throw new Error(`HTTP ${res.status} — the answer was a web page, not JSON: this URL is a website, not the BaseIn API`);
|
|
491
|
+
}
|
|
492
|
+
serviceHealth.ok(started);
|
|
493
|
+
try {
|
|
494
|
+
return JSON.parse(text);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
throw new Error(`HTTP ${res.status} — the answer was not JSON`);
|
|
358
498
|
}
|
|
359
|
-
return (await res.json());
|
|
360
499
|
}
|
|
361
|
-
/**
|
|
500
|
+
/**
|
|
501
|
+
* Exchange a refresh token for a new session. `rejected` says the service
|
|
502
|
+
* answered and refused (a 4xx), as opposed to not answering usefully.
|
|
503
|
+
*/
|
|
362
504
|
async function tryRefresh(authUrl, refreshToken) {
|
|
363
505
|
try {
|
|
364
|
-
return
|
|
506
|
+
return {
|
|
507
|
+
session: toSession(await postJson(`${authUrl}/auth/refresh`, { refreshToken }), authUrl),
|
|
508
|
+
rejected: false,
|
|
509
|
+
};
|
|
365
510
|
}
|
|
366
|
-
catch {
|
|
367
|
-
|
|
511
|
+
catch (err) {
|
|
512
|
+
logLine("auth.refresh_failed", { error: errText(err), why: "the session could not be renewed" });
|
|
513
|
+
const rejected = err instanceof AuthHttpError && err.status >= 400 && !isUnhealthyStatus(err.status);
|
|
514
|
+
return { rejected };
|
|
368
515
|
}
|
|
369
516
|
}
|
|
370
517
|
/**
|
|
@@ -402,9 +549,24 @@ export async function authenticate(opts) {
|
|
|
402
549
|
if (cached && isAccessValid(cached))
|
|
403
550
|
return cached;
|
|
404
551
|
if (cached?.refreshToken) {
|
|
405
|
-
const refreshed = await tryRefresh(authUrl, cached.refreshToken);
|
|
552
|
+
const { session: refreshed, rejected } = await tryRefresh(authUrl, cached.refreshToken);
|
|
553
|
+
if (!refreshed && !rejected && opts?.keepStaleOnNoAnswer) {
|
|
554
|
+
logLine("auth.refresh_deferred", {
|
|
555
|
+
why: "the service did not answer the refresh — starting with the expired session; it is renewed once the service is back",
|
|
556
|
+
});
|
|
557
|
+
return cached;
|
|
558
|
+
}
|
|
406
559
|
if (refreshed) {
|
|
407
|
-
|
|
560
|
+
try {
|
|
561
|
+
saveCredentials(refreshed);
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
// The service has already rotated the refresh token, so the one on
|
|
565
|
+
// disk is dead either way. Throwing here would also lose the new one
|
|
566
|
+
// and kill the recorder at start; returning it keeps this process
|
|
567
|
+
// recording, and the next save (a later refresh) repairs the file.
|
|
568
|
+
logLine("auth.save_failed", { error: errText(err), why: "the new session still works in this process" });
|
|
569
|
+
}
|
|
408
570
|
return refreshed;
|
|
409
571
|
}
|
|
410
572
|
}
|
|
@@ -438,11 +600,20 @@ export class DeviceFlowUnsupported extends Error {
|
|
|
438
600
|
* flow has against being talked into approving a stranger's code.
|
|
439
601
|
*/
|
|
440
602
|
export async function startDeviceCode(authUrl, client, fetchImpl = fetch) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
603
|
+
let res;
|
|
604
|
+
try {
|
|
605
|
+
res = await fetchImpl(`${authUrl}/auth/device/code`, {
|
|
606
|
+
method: "POST",
|
|
607
|
+
headers: { "content-type": "application/json" },
|
|
608
|
+
body: JSON.stringify({ clientName: client.name, clientVersion: client.version }),
|
|
609
|
+
signal: timeoutSignal(serviceTimeoutMs()),
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
reportNoAnswer(err);
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
reportAnswer(res);
|
|
446
617
|
// A service without the endpoint 404s. Anything else is a real failure.
|
|
447
618
|
if (res.status === 404)
|
|
448
619
|
throw new DeviceFlowUnsupported();
|
|
@@ -480,6 +651,7 @@ export async function pollDeviceToken(authUrl, deviceCode, opts) {
|
|
|
480
651
|
const now = opts.now ?? Date.now;
|
|
481
652
|
const deadline = now() + opts.expiresIn * 1000;
|
|
482
653
|
let interval = opts.interval;
|
|
654
|
+
let networkFailures = 0;
|
|
483
655
|
for (;;) {
|
|
484
656
|
if (opts.signal?.aborted) {
|
|
485
657
|
throw new DeviceFlowAborted("sign-in cancelled", "cancelled");
|
|
@@ -488,11 +660,30 @@ export async function pollDeviceToken(authUrl, deviceCode, opts) {
|
|
|
488
660
|
throw new DeviceFlowAborted("the code expired before it was approved — run `bir login` again", "timeout");
|
|
489
661
|
}
|
|
490
662
|
await sleep(interval * 1000);
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
663
|
+
let res;
|
|
664
|
+
try {
|
|
665
|
+
// Each poll is bounded, and follows the caller's signal so Ctrl-C stops
|
|
666
|
+
// the request in flight rather than after the service answers it.
|
|
667
|
+
res = await fetchImpl(`${authUrl}/auth/device/token`, {
|
|
668
|
+
method: "POST",
|
|
669
|
+
headers: { "content-type": "application/json" },
|
|
670
|
+
body: JSON.stringify({ deviceCode }),
|
|
671
|
+
signal: timeoutSignal(serviceTimeoutMs(), opts.signal),
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
if (opts.signal?.aborted)
|
|
676
|
+
throw new DeviceFlowAborted("sign-in cancelled", "cancelled");
|
|
677
|
+
reportNoAnswer(err, opts.signal);
|
|
678
|
+
// A blip in a wait that can last minutes is not worth losing the code
|
|
679
|
+
// over; a service that stays unreachable is.
|
|
680
|
+
networkFailures += 1;
|
|
681
|
+
if (networkFailures >= 3)
|
|
682
|
+
throw err;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
reportAnswer(res);
|
|
686
|
+
networkFailures = 0;
|
|
496
687
|
if (res.ok)
|
|
497
688
|
return (await res.json());
|
|
498
689
|
const body = (await res.json().catch(() => ({})));
|
|
@@ -513,6 +704,48 @@ export async function pollDeviceToken(authUrl, deviceCode, opts) {
|
|
|
513
704
|
}
|
|
514
705
|
}
|
|
515
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* The link to open for a sign-in, or undefined when it must not be opened.
|
|
709
|
+
*
|
|
710
|
+
* It comes from the service's answer, so it is checked before anything hands
|
|
711
|
+
* it to the operating system: an `https:` URL (plain `http:` only when the
|
|
712
|
+
* service itself is `http:`, a loopback test server), never `file:` or any
|
|
713
|
+
* other scheme a URL handler would run. Returned normalised, which
|
|
714
|
+
* percent-encodes spaces and quotes.
|
|
715
|
+
*/
|
|
716
|
+
export function browserSafeLink(link, authUrl) {
|
|
717
|
+
try {
|
|
718
|
+
const url = new URL(link);
|
|
719
|
+
let serviceProtocol = "https:";
|
|
720
|
+
try {
|
|
721
|
+
serviceProtocol = new URL(authUrl).protocol;
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
/* no service URL to compare with: https only */
|
|
725
|
+
}
|
|
726
|
+
if (url.protocol === "https:" || (url.protocol === "http:" && serviceProtocol === "http:"))
|
|
727
|
+
return url.href;
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* The argument vector that opens `url` in the browser on `platform`.
|
|
736
|
+
*
|
|
737
|
+
* Never a shell. On Windows that rules out `cmd /c start`: cmd.exe parses the
|
|
738
|
+
* whole line itself, so an `&` in a link a service sent ran whatever followed
|
|
739
|
+
* it as a second command. `rundll32 url.dll,FileProtocolHandler <url>` hands
|
|
740
|
+
* the URL to the default browser as one argument, with nothing in between.
|
|
741
|
+
*/
|
|
742
|
+
export function browserCommand(url, platform = process.platform) {
|
|
743
|
+
if (platform === "win32")
|
|
744
|
+
return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
745
|
+
if (platform === "darwin")
|
|
746
|
+
return ["open", [url]];
|
|
747
|
+
return ["xdg-open", [url]];
|
|
748
|
+
}
|
|
516
749
|
/**
|
|
517
750
|
* Open a URL in the user's browser. Best effort, and never fatal.
|
|
518
751
|
*
|
|
@@ -522,14 +755,9 @@ export async function pollDeviceToken(authUrl, deviceCode, opts) {
|
|
|
522
755
|
*/
|
|
523
756
|
export function openBrowser(url) {
|
|
524
757
|
try {
|
|
525
|
-
const [command, args] =
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
["cmd", ["/c", "start", "", url]]
|
|
529
|
-
: process.platform === "darwin"
|
|
530
|
-
? ["open", [url]]
|
|
531
|
-
: ["xdg-open", [url]];
|
|
532
|
-
const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
|
|
758
|
+
const [command, args] = browserCommand(url);
|
|
759
|
+
// No windowsHide: rundll32 passes its show state on to the browser it starts.
|
|
760
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
533
761
|
child.on("error", () => {
|
|
534
762
|
/* no browser here; the printed link is the fallback */
|
|
535
763
|
});
|
|
@@ -565,9 +793,13 @@ export async function deviceLogin(authUrl, opts = {}) {
|
|
|
565
793
|
write(` Open ${link}\n`);
|
|
566
794
|
write(` Code ${grant.userCode}\n\n`);
|
|
567
795
|
const wantsBrowser = opts.openBrowser ?? shouldOpenBrowser();
|
|
568
|
-
|
|
796
|
+
const safe = browserSafeLink(link, authUrl);
|
|
797
|
+
if (wantsBrowser && !safe) {
|
|
798
|
+
write(" Not opening that link: it is not an https address. Check it before you open it yourself.\n");
|
|
799
|
+
}
|
|
800
|
+
else if (wantsBrowser && safe) {
|
|
569
801
|
write(" Opening your browser…\n");
|
|
570
|
-
openBrowser(
|
|
802
|
+
openBrowser(safe);
|
|
571
803
|
}
|
|
572
804
|
else {
|
|
573
805
|
write(" Open that link on any device — a phone is fine.\n");
|
|
@@ -596,11 +828,20 @@ export async function deviceLogin(authUrl, opts = {}) {
|
|
|
596
828
|
*/
|
|
597
829
|
export async function tokenLogin(authUrl, token, opts = {}) {
|
|
598
830
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
831
|
+
let res;
|
|
832
|
+
try {
|
|
833
|
+
res = await fetchImpl(`${authUrl}/auth/device/token`, {
|
|
834
|
+
method: "POST",
|
|
835
|
+
headers: { "content-type": "application/json" },
|
|
836
|
+
body: JSON.stringify({ deviceCode: token.trim() }),
|
|
837
|
+
signal: timeoutSignal(serviceTimeoutMs()),
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
reportNoAnswer(err);
|
|
842
|
+
throw err;
|
|
843
|
+
}
|
|
844
|
+
reportAnswer(res);
|
|
604
845
|
if (res.status === 404)
|
|
605
846
|
throw new DeviceFlowUnsupported();
|
|
606
847
|
if (res.ok) {
|
|
@@ -688,24 +929,35 @@ export async function legacyPasswordLogin(authUrl) {
|
|
|
688
929
|
* `bir logout` a tidy-up rather than a revocation. The endpoint is idempotent
|
|
689
930
|
* and its failure is not worth blocking on — the local half must happen either
|
|
690
931
|
* way, or "logged out" would be a lie on a machine with no network.
|
|
932
|
+
*
|
|
933
|
+
* LOCAL FIRST. The file goes before the network call, so a hung service or a
|
|
934
|
+
* Ctrl-C during the revoke can never leave the machine signed in; running
|
|
935
|
+
* recorders notice the missing file within seconds and stop sending. The
|
|
936
|
+
* revoke itself is bounded to {@link LOGOUT_TIMEOUT_MS}.
|
|
691
937
|
*/
|
|
692
938
|
export async function logout(authUrl) {
|
|
693
939
|
const cached = loadCredentials();
|
|
940
|
+
clearCredentials();
|
|
694
941
|
if (authUrl && cached?.refreshToken) {
|
|
695
942
|
try {
|
|
696
|
-
await fetch(`${authUrl}/auth/logout`, {
|
|
943
|
+
const res = await fetch(`${authUrl}/auth/logout`, {
|
|
697
944
|
method: "POST",
|
|
698
945
|
headers: { "content-type": "application/json" },
|
|
699
946
|
body: JSON.stringify({ refreshToken: cached.refreshToken }),
|
|
947
|
+
signal: timeoutSignal(LOGOUT_TIMEOUT_MS),
|
|
700
948
|
});
|
|
949
|
+
reportAnswer(res);
|
|
950
|
+
res.body?.cancel().catch(() => undefined);
|
|
701
951
|
}
|
|
702
952
|
catch (err) {
|
|
953
|
+
reportNoAnswer(err);
|
|
703
954
|
logLine("auth.logout_remote_failed", {
|
|
704
955
|
error: errText(err),
|
|
705
956
|
why: "the local credentials are cleared regardless",
|
|
706
957
|
});
|
|
707
958
|
}
|
|
708
959
|
}
|
|
709
|
-
clearCredentials();
|
|
710
960
|
}
|
|
961
|
+
/** How long `bir logout` waits for the service to revoke the refresh token. */
|
|
962
|
+
const LOGOUT_TIMEOUT_MS = 5_000;
|
|
711
963
|
//# sourceMappingURL=client.js.map
|