@basein/runner 0.2.11 → 0.3.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.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -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
- /** Persist the session with mode 0600. */
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
@@ -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, writeFileSync } from "node:fs";
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
- /** Persist the session with mode 0600. */
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
- writeFileSync(credentialsPath(), JSON.stringify(session, null, 2), { mode: 0o600 });
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
- /** True when a cached session may be used against `authUrl`. */
119
- function issuedFor(session, authUrl) {
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
- writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
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
- res = await fetchImpl(`${base}/health`, { method: "GET", redirect: "manual" });
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
- return base; // unreachable is the caller's problem, not ours to diagnose here
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
- const res = await fetch(url, {
345
- method: "POST",
346
- headers: { "content-type": "application/json" },
347
- body: JSON.stringify(body),
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
- const text = await res.text().catch(() => "");
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.length > 300 ? `${text.slice(0, 300)}…` : text}`
483
+ ? ` — ${describeBody(text, type)}`
356
484
  : "";
357
- throw new Error(`HTTP ${res.status} ${res.statusText}${detail}`);
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
- /** Exchange a refresh token for a new session. Undefined on failure. */
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 toSession(await postJson(`${authUrl}/auth/refresh`, { refreshToken }), authUrl);
506
+ return {
507
+ session: toSession(await postJson(`${authUrl}/auth/refresh`, { refreshToken }), authUrl),
508
+ rejected: false,
509
+ };
365
510
  }
366
- catch {
367
- return undefined;
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
- saveCredentials(refreshed);
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
- const res = await fetchImpl(`${authUrl}/auth/device/code`, {
442
- method: "POST",
443
- headers: { "content-type": "application/json" },
444
- body: JSON.stringify({ clientName: client.name, clientVersion: client.version }),
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
- const res = await fetchImpl(`${authUrl}/auth/device/token`, {
492
- method: "POST",
493
- headers: { "content-type": "application/json" },
494
- body: JSON.stringify({ deviceCode }),
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] = process.platform === "win32"
526
- ? // The empty string is `start`'s title argument. Without it, a quoted
527
- // URL is taken *as* the title and nothing opens.
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
- if (wantsBrowser) {
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(link);
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
- const res = await fetchImpl(`${authUrl}/auth/device/token`, {
600
- method: "POST",
601
- headers: { "content-type": "application/json" },
602
- body: JSON.stringify({ deviceCode: token.trim() }),
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