@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.
- package/README.md +540 -350
- 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 -10
- package/dist/bin/bir-scenario.js +704 -135
- package/dist/bin/bir.d.ts +14 -8
- package/dist/bin/bir.js +2937 -488
- package/dist/bin/investigate.js +41 -16
- package/dist/bin/scenario-edit.d.ts +43 -10
- package/dist/bin/scenario-edit.js +281 -80
- 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 +2803 -306
- 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/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- 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 +371 -20
- package/dist/replay/controller.js +1185 -111
- 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 -1244
- package/docs/calculatedReplayGuide.md +1212 -879
- 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 -271
- 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
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recorder-keeper — the background recorder's sign-in, kept current without
|
|
3
|
+
* ever standing between Claude Code and a hook.
|
|
4
|
+
*
|
|
5
|
+
* WHAT WAS WRONG. `bir-hooks` used to settle its sign-in *before* it listened:
|
|
6
|
+
* a `GET /health` to check the address, then — whenever the access token had
|
|
7
|
+
* expired, which is every first session of the day — a `POST /auth/refresh`,
|
|
8
|
+
* neither with a timeout. A service that was slow, blackholed, or behind a
|
|
9
|
+
* proxy that accepts and never answers held the port closed for 10-300 s, and
|
|
10
|
+
* every hook of every session in that window was refused (Claude Code shows
|
|
11
|
+
* each as a hook error). Worse, a refresh that failed for *any* reason — the
|
|
12
|
+
* Wi-Fi not up yet after a wake — was read as "not signed in": the recorder
|
|
13
|
+
* built a `NullRecorder`, logged "run `bir login`", and recorded nothing for
|
|
14
|
+
* the rest of its life, which is days. Following that advice changed nothing.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IT DOES NOW. The recorder is decided from disk alone — the stored
|
|
17
|
+
* address and credentials.json — so it exists before the port is bound, with
|
|
18
|
+
* no network at all. An expired access token is not a reason to stop: the
|
|
19
|
+
* `RemoteRecorder` refreshes on its first 401 and adopts a sibling's refresh.
|
|
20
|
+
* Then, in the background and bounded by timeouts, this keeper:
|
|
21
|
+
*
|
|
22
|
+
* - refreshes the token a little before it expires (and right away when it
|
|
23
|
+
* already has), so the first prompt of the day does not pay for it;
|
|
24
|
+
* - notices a sign-in appearing, changing or being removed on disk — `bir
|
|
25
|
+
* login`, `bir setup`, `bir logout`, a sibling's refresh — and swaps the
|
|
26
|
+
* backend of the {@link SwitchableRecorder} the control server holds. No
|
|
27
|
+
* restart. A signed-out recorder checks again the moment a prompt arrives,
|
|
28
|
+
* so the prompt after `bir login` is already recorded;
|
|
29
|
+
* - tells a *rejected* sign-in (the API answered 400/401/403 with its own
|
|
30
|
+
* JSON error) from a *missing* service (no answer, a timeout, 5xx, 429, or
|
|
31
|
+
* a page that is not the API — a WAF's or a captive portal's HTML 403 is
|
|
32
|
+
* not a verdict on the sign-in). Only the first stops recording and says
|
|
33
|
+
* "run `bir login`", and even then the refusal is asked once more half an
|
|
34
|
+
* hour later before it is final; the second keeps the saved sign-in,
|
|
35
|
+
* retries with backoff, and says so.
|
|
36
|
+
*
|
|
37
|
+
* NOTHING HERE IS ON A HOT PATH. Every network call is in a timer or behind a
|
|
38
|
+
* hook's answer, has a timeout, and reports to `serviceHealth`, so a hook that
|
|
39
|
+
* *does* wait for the service (a prompt match) knows not to while it is down.
|
|
40
|
+
* While the runner is switched off (`bir off`) the keeper makes no calls.
|
|
41
|
+
*/
|
|
42
|
+
import { existsSync, statSync } from "node:fs";
|
|
43
|
+
import { credentialsPath, loadCredentials, normalizeAuthUrl, resolveAuthUrl, saveCredentials, } from "../auth/client.js";
|
|
44
|
+
import { NullRecorder } from "../record/recorder.js";
|
|
45
|
+
import { RemoteRecorder } from "../record/remote-recorder.js";
|
|
46
|
+
import { SwitchableRecorder } from "../record/switchable-recorder.js";
|
|
47
|
+
import { configPath } from "./paths.js";
|
|
48
|
+
import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal } from "../util/service-health.js";
|
|
49
|
+
import { isRunnerOff } from "../util/off-switch.js";
|
|
50
|
+
import { errText, logDetail, logLine } from "../util/log.js";
|
|
51
|
+
/** What `authenticate()` hands out under `BIR_AUTH_DISABLE=1` (auth/client.ts). */
|
|
52
|
+
const LOCAL_DEV_SESSION = {
|
|
53
|
+
accessToken: "local-dev",
|
|
54
|
+
refreshToken: "local-dev",
|
|
55
|
+
accessExpiresAt: Number.MAX_SAFE_INTEGER,
|
|
56
|
+
user: { id: "local", email: "local@baseinstrunner.dev", name: "Local Dev" },
|
|
57
|
+
};
|
|
58
|
+
/** Same margin as auth/client.ts: a token this close to expiry is treated as expired. */
|
|
59
|
+
const EXPIRY_SKEW_MS = 30_000;
|
|
60
|
+
/** How long the http→https check may take. One small GET. */
|
|
61
|
+
const SETTLE_TIMEOUT_MS = 4_000;
|
|
62
|
+
/** Longest wait between refresh attempts while the service does not answer. */
|
|
63
|
+
const MAX_REFRESH_BACKOFF_MS = 10 * 60 * 1000;
|
|
64
|
+
/** After a refusal, when the refresh is tried once more before the refusal is final. */
|
|
65
|
+
const REJECT_RETRY_MS = 30 * 60 * 1000;
|
|
66
|
+
/** The most of a 4xx body read to tell the API's refusal from somebody else's page. */
|
|
67
|
+
const MAX_REFUSAL_BODY = 16 * 1024;
|
|
68
|
+
function trimUrl(url) {
|
|
69
|
+
return url.replace(/\/+$/, "");
|
|
70
|
+
}
|
|
71
|
+
function isValid(session, now) {
|
|
72
|
+
return session.accessExpiresAt - EXPIRY_SKEW_MS > now;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* True for a plain-http address that is not this machine. Such an address may
|
|
76
|
+
* sit behind a TLS proxy that answers `308 → https`, and following that drops
|
|
77
|
+
* the bearer token (auth/client.ts, normalizeAuthUrl) — so nothing is sent to
|
|
78
|
+
* it until that has been checked. https needs no check, and a loopback address
|
|
79
|
+
* is neither redirected by a TLS front nor readable on the network.
|
|
80
|
+
*/
|
|
81
|
+
export function needsSettling(url) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(url);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (parsed.protocol !== "http:")
|
|
90
|
+
return false;
|
|
91
|
+
const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
92
|
+
return !(host === "localhost" || host.endsWith(".localhost") || host === "::1" || /^127\./.test(host));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The address to use `session` against when `configured` is set, or undefined
|
|
96
|
+
* when it was issued by another service. `bir login` stores the address it
|
|
97
|
+
* settled, so an `http://host` configuration with an `https://host` session is
|
|
98
|
+
* the same service, already upgraded.
|
|
99
|
+
*/
|
|
100
|
+
function baseFor(configured, session) {
|
|
101
|
+
const base = trimUrl(configured);
|
|
102
|
+
if (!session.authUrl)
|
|
103
|
+
return base; // written before sessions were bound: trusted as before
|
|
104
|
+
const issuer = trimUrl(session.authUrl);
|
|
105
|
+
if (issuer === base)
|
|
106
|
+
return base;
|
|
107
|
+
try {
|
|
108
|
+
const from = new URL(base);
|
|
109
|
+
const to = new URL(issuer);
|
|
110
|
+
if (from.protocol === "http:" && to.protocol === "https:" && from.host === to.host)
|
|
111
|
+
return issuer;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* not URLs: not the same service */
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* What the files on disk say the recorder should be. No network.
|
|
120
|
+
*
|
|
121
|
+
* `settled` maps a configured plain-http address to what its check found.
|
|
122
|
+
*/
|
|
123
|
+
export function decideFromDisk(settled = new Map()) {
|
|
124
|
+
const configured = resolveAuthUrl();
|
|
125
|
+
if (!configured)
|
|
126
|
+
return { state: "no_address" };
|
|
127
|
+
const effective = settled.get(configured) ?? configured;
|
|
128
|
+
if (process.env.BIR_AUTH_DISABLE === "1") {
|
|
129
|
+
return { state: "recording", baseUrl: effective, session: LOCAL_DEV_SESSION };
|
|
130
|
+
}
|
|
131
|
+
const cached = loadCredentials();
|
|
132
|
+
if (!cached)
|
|
133
|
+
return { state: "signed_out", baseUrl: effective };
|
|
134
|
+
const base = baseFor(effective, cached);
|
|
135
|
+
if (!base)
|
|
136
|
+
return { state: "other_service", baseUrl: effective, issuer: cached.authUrl };
|
|
137
|
+
return { state: "recording", baseUrl: base, session: cached };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Whether a 400/401/403 is the API refusing the token, rather than something
|
|
141
|
+
* in front of it (a WAF, a CDN's bot check, a hotel's captive portal, a
|
|
142
|
+
* corporate proxy) answering for it. The API answers with a JSON error object
|
|
143
|
+
* (`{"error":"invalid_grant"}`, docs/loginWeb.md); those others answer with an
|
|
144
|
+
* HTML page. Only a JSON object counts: taking a portal's "403 Forbidden"
|
|
145
|
+
* page for a refusal stopped recording for the recorder's whole life.
|
|
146
|
+
*/
|
|
147
|
+
async function isApiRefusal(res) {
|
|
148
|
+
const type = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
149
|
+
if (type.includes("html")) {
|
|
150
|
+
await res.body?.cancel().catch(() => undefined);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
let text;
|
|
154
|
+
try {
|
|
155
|
+
text = await res.text();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (text.length > MAX_REFUSAL_BODY)
|
|
161
|
+
return false;
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(text);
|
|
164
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function causeText(err) {
|
|
171
|
+
const e = err;
|
|
172
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError")
|
|
173
|
+
return "timed out";
|
|
174
|
+
return e?.cause?.code ?? e?.cause?.message ?? errText(err);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* One `POST /auth/refresh`, bounded, reported to `serviceHealth`.
|
|
178
|
+
*
|
|
179
|
+
* `authenticate()` answers "no session" for every failure alike, which is what
|
|
180
|
+
* turned a network blip into "run `bir login`". This one says which it was.
|
|
181
|
+
*/
|
|
182
|
+
export async function refreshSession(baseUrl, session, fetchImpl = fetch, timeoutMs = serviceTimeoutMs(), now = Date.now) {
|
|
183
|
+
// When this attempt was sent: a late answer to a request that started before
|
|
184
|
+
// a hot path gave up on the service must not close that cool-down.
|
|
185
|
+
const started = serviceHealth.stamp();
|
|
186
|
+
let res;
|
|
187
|
+
try {
|
|
188
|
+
res = await fetchImpl(`${trimUrl(baseUrl)}/auth/refresh`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "content-type": "application/json" },
|
|
191
|
+
body: JSON.stringify({ refreshToken: session.refreshToken }),
|
|
192
|
+
signal: timeoutSignal(timeoutMs),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
const reason = causeText(err);
|
|
197
|
+
serviceHealth.fail(`refresh: ${reason}`);
|
|
198
|
+
return { kind: "unreachable", reason };
|
|
199
|
+
}
|
|
200
|
+
if (isUnhealthyStatus(res.status)) {
|
|
201
|
+
await res.body?.cancel().catch(() => undefined);
|
|
202
|
+
serviceHealth.fail(`refresh: HTTP ${res.status}`);
|
|
203
|
+
return { kind: "unreachable", reason: `HTTP ${res.status}` };
|
|
204
|
+
}
|
|
205
|
+
if (res.status === 400 || res.status === 401 || res.status === 403) {
|
|
206
|
+
if (await isApiRefusal(res)) {
|
|
207
|
+
serviceHealth.ok(started);
|
|
208
|
+
return { kind: "rejected", status: res.status };
|
|
209
|
+
}
|
|
210
|
+
// Somebody else's page in the API's place: the service is not reachable
|
|
211
|
+
// from here right now, which says nothing about the saved sign-in.
|
|
212
|
+
serviceHealth.fail(`refresh: HTTP ${res.status} that is not the API's answer`);
|
|
213
|
+
return { kind: "unreachable", reason: `HTTP ${res.status}, not from the API` };
|
|
214
|
+
}
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
// A 404 or the like: an answer, but not the API's. Not a verdict on the sign-in.
|
|
217
|
+
await res.body?.cancel().catch(() => undefined);
|
|
218
|
+
serviceHealth.ok(started);
|
|
219
|
+
return { kind: "unreachable", reason: `HTTP ${res.status}` };
|
|
220
|
+
}
|
|
221
|
+
let body;
|
|
222
|
+
try {
|
|
223
|
+
body = (await res.json());
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
serviceHealth.fail("refresh: the answer was not JSON");
|
|
227
|
+
return { kind: "unreachable", reason: `not JSON (${causeText(err)})` };
|
|
228
|
+
}
|
|
229
|
+
if (typeof body?.accessToken !== "string" ||
|
|
230
|
+
typeof body.refreshToken !== "string" ||
|
|
231
|
+
typeof body.expiresIn !== "number" ||
|
|
232
|
+
typeof body.user?.id !== "string") {
|
|
233
|
+
serviceHealth.fail("refresh: unexpected answer");
|
|
234
|
+
return { kind: "unreachable", reason: "unexpected answer" };
|
|
235
|
+
}
|
|
236
|
+
serviceHealth.ok(started);
|
|
237
|
+
return {
|
|
238
|
+
kind: "ok",
|
|
239
|
+
session: {
|
|
240
|
+
accessToken: body.accessToken,
|
|
241
|
+
refreshToken: body.refreshToken,
|
|
242
|
+
accessExpiresAt: now() + body.expiresIn * 1000,
|
|
243
|
+
user: body.user,
|
|
244
|
+
authUrl: trimUrl(baseUrl),
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function fileStamp(path) {
|
|
249
|
+
try {
|
|
250
|
+
const s = statSync(path);
|
|
251
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return "-";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function sleep(ms) {
|
|
258
|
+
return new Promise((resolve) => {
|
|
259
|
+
const t = setTimeout(resolve, ms);
|
|
260
|
+
t.unref?.();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
export class RecorderKeeper {
|
|
264
|
+
recorder;
|
|
265
|
+
/**
|
|
266
|
+
* The one backend used whenever nothing is recorded. One per process, not
|
|
267
|
+
* one per sign-out: a `NullRecorder` numbers its runs itself, and a fresh
|
|
268
|
+
* one after every logout or settle handed out `run_local_1` again — two
|
|
269
|
+
* runs under one id in the journal `bir investigate` reads.
|
|
270
|
+
*/
|
|
271
|
+
idle = new NullRecorder();
|
|
272
|
+
opts;
|
|
273
|
+
now;
|
|
274
|
+
fetchImpl;
|
|
275
|
+
stateNow;
|
|
276
|
+
/** The address and session the current backend was built from (when recording). */
|
|
277
|
+
baseUrl;
|
|
278
|
+
session;
|
|
279
|
+
/** The decision waiting for its plain-http address to be checked. */
|
|
280
|
+
pending;
|
|
281
|
+
/** configured http address → what the check found. */
|
|
282
|
+
settled = new Map();
|
|
283
|
+
settling;
|
|
284
|
+
diskStamp = "";
|
|
285
|
+
/** A refresh token the service refused; a file still holding it is not a sign-in. */
|
|
286
|
+
rejectedRefreshToken;
|
|
287
|
+
/** The refusal waiting for its one more try (see {@link RecorderKeeperOptions.rejectRetryMs}). */
|
|
288
|
+
refused;
|
|
289
|
+
nextRefreshAt = 0;
|
|
290
|
+
refreshFailures = 0;
|
|
291
|
+
lastRunStartedAt = 0;
|
|
292
|
+
ticking = false;
|
|
293
|
+
timers = [];
|
|
294
|
+
constructor(opts) {
|
|
295
|
+
this.opts = opts;
|
|
296
|
+
this.now = opts.now ?? Date.now;
|
|
297
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
298
|
+
this.recorder = new SwitchableRecorder(this.idle, {
|
|
299
|
+
// Two `stat`s per prompt while nothing is recorded: the prompt right
|
|
300
|
+
// after `bir login` is the one that should be recorded.
|
|
301
|
+
beforeStartRunWhileIdle: () => this.checkDisk(),
|
|
302
|
+
onStartRun: () => {
|
|
303
|
+
this.lastRunStartedAt = this.now();
|
|
304
|
+
},
|
|
305
|
+
// Why nothing is sent, for `recorderHealth()` while the backend is idle.
|
|
306
|
+
// Settling has a sign-in and records from the moment the address
|
|
307
|
+
// answers, as `recording` below says too.
|
|
308
|
+
idleHealthState: () => this.stateNow === "rejected"
|
|
309
|
+
? "auth_rejected"
|
|
310
|
+
: this.stateNow === "other_service"
|
|
311
|
+
? "other_service"
|
|
312
|
+
: this.stateNow === "settling" || this.stateNow === "recording"
|
|
313
|
+
? "recording"
|
|
314
|
+
: "signed_out",
|
|
315
|
+
});
|
|
316
|
+
this.stateNow = "no_address";
|
|
317
|
+
this.diskStamp = this.stamp();
|
|
318
|
+
const first = decideFromDisk(this.settled);
|
|
319
|
+
// Said once at start either way: which of the states this recorder is in.
|
|
320
|
+
if (first.state === "recording")
|
|
321
|
+
this.apply(first, "start");
|
|
322
|
+
else
|
|
323
|
+
this.goIdle(first, "start");
|
|
324
|
+
}
|
|
325
|
+
/** Why the recorder is or is not recording. */
|
|
326
|
+
get state() {
|
|
327
|
+
return this.stateNow;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* What `/health` reports as `recording`. True while a plain-http address is
|
|
331
|
+
* being checked too: there is a sign-in, and recording starts the moment the
|
|
332
|
+
* address answers — a "no session" there would send people to `bir login`
|
|
333
|
+
* for nothing.
|
|
334
|
+
*/
|
|
335
|
+
get recording() {
|
|
336
|
+
return this.stateNow === "recording" || this.stateNow === "settling";
|
|
337
|
+
}
|
|
338
|
+
/** The account recordings land in, for `/health`. */
|
|
339
|
+
get account() {
|
|
340
|
+
return this.recording ? (this.session ?? this.pending?.session)?.user.email : undefined;
|
|
341
|
+
}
|
|
342
|
+
/** The service address replay should call, while there is a session for it. */
|
|
343
|
+
get authUrl() {
|
|
344
|
+
return this.stateNow === "recording" ? this.baseUrl : undefined;
|
|
345
|
+
}
|
|
346
|
+
/** The live access token (C5): read on every use, never kept. */
|
|
347
|
+
currentAccessToken() {
|
|
348
|
+
return this.stateNow === "recording" ? this.recorder.currentAccessToken() : undefined;
|
|
349
|
+
}
|
|
350
|
+
/** Start the background checks. Timers are unref'd: they never keep the process up. */
|
|
351
|
+
start() {
|
|
352
|
+
const first = setTimeout(() => void this.tick(), this.opts.firstTickMs ?? 250);
|
|
353
|
+
first.unref?.();
|
|
354
|
+
const every = setInterval(() => void this.tick(), this.opts.tickMs ?? 60_000);
|
|
355
|
+
every.unref?.();
|
|
356
|
+
this.timers.push(first, every);
|
|
357
|
+
}
|
|
358
|
+
stop() {
|
|
359
|
+
for (const t of this.timers)
|
|
360
|
+
clearTimeout(t);
|
|
361
|
+
this.timers = [];
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* One background pass: look at the files, check a plain-http address,
|
|
365
|
+
* refresh a token that is due. Never throws; never overlaps itself.
|
|
366
|
+
*/
|
|
367
|
+
async tick() {
|
|
368
|
+
if (this.ticking)
|
|
369
|
+
return;
|
|
370
|
+
this.ticking = true;
|
|
371
|
+
try {
|
|
372
|
+
this.checkDisk();
|
|
373
|
+
if (isRunnerOff(this.opts.cwd))
|
|
374
|
+
return;
|
|
375
|
+
await this.settle();
|
|
376
|
+
await this.maybeRefresh();
|
|
377
|
+
await this.maybeRetryRefused();
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
logLine("recorder.keeper_failed", { error: errText(err), why: "recording carries on as it was" });
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
this.ticking = false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Re-read the sign-in and the address if either file changed. Local and
|
|
388
|
+
* cheap: two `stat`s, and a read only when one of them moved.
|
|
389
|
+
*/
|
|
390
|
+
checkDisk() {
|
|
391
|
+
const stamp = this.stamp();
|
|
392
|
+
if (stamp === this.diskStamp)
|
|
393
|
+
return;
|
|
394
|
+
// Present but unreadable is a write in progress (credentials are saved
|
|
395
|
+
// with a plain write) or a broken file: decide nothing now, look again.
|
|
396
|
+
if (existsSync(credentialsPath()) && !loadCredentials())
|
|
397
|
+
return;
|
|
398
|
+
this.diskStamp = stamp;
|
|
399
|
+
this.apply(decideFromDisk(this.settled), "disk");
|
|
400
|
+
}
|
|
401
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
402
|
+
stamp() {
|
|
403
|
+
return `${fileStamp(credentialsPath())}|${fileStamp(configPath())}`;
|
|
404
|
+
}
|
|
405
|
+
makeRemote(baseUrl, session) {
|
|
406
|
+
return this.opts.makeRemote
|
|
407
|
+
? this.opts.makeRemote(baseUrl, session)
|
|
408
|
+
: new RemoteRecorder({ baseUrl, session });
|
|
409
|
+
}
|
|
410
|
+
/** Act on a decision: swap the backend when it changes what is recorded where. */
|
|
411
|
+
apply(d, cause) {
|
|
412
|
+
// A file that still holds the refresh token the service refused is not a
|
|
413
|
+
// sign-in to go back to; a new `bir login` writes a new one.
|
|
414
|
+
if (d.state === "recording" &&
|
|
415
|
+
d.session &&
|
|
416
|
+
this.rejectedRefreshToken !== undefined &&
|
|
417
|
+
d.session.refreshToken === this.rejectedRefreshToken &&
|
|
418
|
+
!isValid(d.session, this.now())) {
|
|
419
|
+
if (this.stateNow !== "rejected")
|
|
420
|
+
this.goIdle({ state: "rejected", baseUrl: d.baseUrl }, cause);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (d.state === "recording" && d.baseUrl && d.session) {
|
|
424
|
+
if (needsSettling(d.baseUrl) && !this.settled.has(d.baseUrl)) {
|
|
425
|
+
this.pending = d;
|
|
426
|
+
if (this.stateNow !== "settling") {
|
|
427
|
+
this.recorder.switchTo(this.idle);
|
|
428
|
+
this.stateNow = "settling";
|
|
429
|
+
this.baseUrl = undefined;
|
|
430
|
+
this.session = undefined;
|
|
431
|
+
logLine("auth.settling", {
|
|
432
|
+
service: d.baseUrl,
|
|
433
|
+
user: d.session.user.email,
|
|
434
|
+
why: "a plain-http address: checking it does not redirect to https before any token is sent to it",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (!isRunnerOff(this.opts.cwd))
|
|
438
|
+
void this.settle();
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
this.pending = undefined;
|
|
442
|
+
const same = this.stateNow === "recording" &&
|
|
443
|
+
this.baseUrl === d.baseUrl &&
|
|
444
|
+
this.session?.user.id === d.session.user.id;
|
|
445
|
+
if (same) {
|
|
446
|
+
const holding = this.recorder.currentAccessToken();
|
|
447
|
+
if (d.session.accessToken === holding) {
|
|
448
|
+
this.session = d.session; // our own save, or the backend's: now we know its expiry
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
// A sibling (or `bir login` as the same person) wrote a newer token.
|
|
452
|
+
// Take it only if it is usable; the backend adopts on its own anyway.
|
|
453
|
+
if (!isValid(d.session, this.now()))
|
|
454
|
+
return;
|
|
455
|
+
this.install(d.baseUrl, d.session);
|
|
456
|
+
logDetail("auth.adopted", { why: "a newer sign-in was saved by another process" });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const was = this.stateNow;
|
|
460
|
+
this.install(d.baseUrl, d.session);
|
|
461
|
+
const expired = !isValid(d.session, this.now());
|
|
462
|
+
if (cause === "start" || was === "settling") {
|
|
463
|
+
logLine("auth.ok", {
|
|
464
|
+
user: d.session.user.email,
|
|
465
|
+
service: d.baseUrl,
|
|
466
|
+
token: expired ? "expired — refreshed in the background, no wait" : "valid",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
logLine("recorder.enabled", {
|
|
471
|
+
user: d.session.user.email,
|
|
472
|
+
service: d.baseUrl,
|
|
473
|
+
why: was === "recording"
|
|
474
|
+
? "the saved sign-in changed — recording goes to it from the next prompt"
|
|
475
|
+
: "a sign-in appeared — recording from the next prompt, no restart needed",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
this.pending = undefined;
|
|
481
|
+
if (d.state === this.stateNow && this.stateNow !== "recording")
|
|
482
|
+
return;
|
|
483
|
+
this.goIdle(d, cause);
|
|
484
|
+
}
|
|
485
|
+
install(baseUrl, session) {
|
|
486
|
+
this.recorder.switchTo(this.makeRemote(baseUrl, session));
|
|
487
|
+
this.stateNow = "recording";
|
|
488
|
+
this.baseUrl = baseUrl;
|
|
489
|
+
this.session = session;
|
|
490
|
+
this.rejectedRefreshToken = undefined;
|
|
491
|
+
this.refused = undefined;
|
|
492
|
+
this.nextRefreshAt = 0;
|
|
493
|
+
this.refreshFailures = 0;
|
|
494
|
+
}
|
|
495
|
+
goIdle(d, cause) {
|
|
496
|
+
const was = this.stateNow;
|
|
497
|
+
this.recorder.switchTo(this.idle);
|
|
498
|
+
this.stateNow = d.state;
|
|
499
|
+
if (d.state !== "rejected")
|
|
500
|
+
this.refused = undefined;
|
|
501
|
+
this.baseUrl = undefined;
|
|
502
|
+
this.session = undefined;
|
|
503
|
+
const why = this.explain(d);
|
|
504
|
+
if (was === "recording" && cause !== "start") {
|
|
505
|
+
logLine("recorder.signed_out", { why });
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
logLine("recorder.disabled", { why });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
explain(d) {
|
|
512
|
+
const later = "recording starts by itself after that — no restart needed";
|
|
513
|
+
switch (d.state) {
|
|
514
|
+
case "no_address":
|
|
515
|
+
return `no service address — run \`bir setup\` (or set BIR_AUTH_URL); ${later}`;
|
|
516
|
+
case "signed_out":
|
|
517
|
+
return `not signed in — run \`bir login\`; ${later}`;
|
|
518
|
+
case "other_service":
|
|
519
|
+
return `the saved sign-in was issued by ${d.issuer ?? "another service"}, not ${d.baseUrl ?? "this one"} — run \`bir setup\` (or \`bir login\`) here; ${later}`;
|
|
520
|
+
case "rejected":
|
|
521
|
+
return `the service refused the saved sign-in — run \`bir login\`; ${later}`;
|
|
522
|
+
default:
|
|
523
|
+
return "not recording";
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Check a plain-http address for an https redirect, once it answers. Until
|
|
528
|
+
* then nothing is sent to it: a token sent before the check could go out in
|
|
529
|
+
* the clear to a host that would have redirected it.
|
|
530
|
+
*/
|
|
531
|
+
settle() {
|
|
532
|
+
const d = this.pending;
|
|
533
|
+
if (!d?.baseUrl || this.settled.has(d.baseUrl))
|
|
534
|
+
return Promise.resolve();
|
|
535
|
+
if (this.settling)
|
|
536
|
+
return this.settling;
|
|
537
|
+
const configured = d.baseUrl;
|
|
538
|
+
this.settling = (async () => {
|
|
539
|
+
let answered = false;
|
|
540
|
+
const probe = async (input, init) => {
|
|
541
|
+
const res = await this.fetchImpl(input, { ...init, signal: timeoutSignal(SETTLE_TIMEOUT_MS) });
|
|
542
|
+
answered = true;
|
|
543
|
+
return res;
|
|
544
|
+
};
|
|
545
|
+
const upgraded = await normalizeAuthUrl(configured, probe);
|
|
546
|
+
if (!answered) {
|
|
547
|
+
logDetail("auth.settle_deferred", { service: configured, why: "no answer yet; checking again later" });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
this.settled.set(configured, upgraded);
|
|
551
|
+
this.apply(decideFromDisk(this.settled), "settled");
|
|
552
|
+
})()
|
|
553
|
+
.catch((err) => logDetail("auth.settle_failed", { error: errText(err) }))
|
|
554
|
+
.finally(() => {
|
|
555
|
+
this.settling = undefined;
|
|
556
|
+
});
|
|
557
|
+
return this.settling;
|
|
558
|
+
}
|
|
559
|
+
/** Refresh the access token when it is due, telling a refusal from an outage. */
|
|
560
|
+
async maybeRefresh() {
|
|
561
|
+
if (this.stateNow !== "recording" || !this.baseUrl || !this.session)
|
|
562
|
+
return;
|
|
563
|
+
if (process.env.BIR_AUTH_DISABLE === "1")
|
|
564
|
+
return;
|
|
565
|
+
const now = this.now();
|
|
566
|
+
if (now < this.nextRefreshAt)
|
|
567
|
+
return;
|
|
568
|
+
// Which token does the backend hold, and when does it expire? Ours — or
|
|
569
|
+
// the file's, when the backend refreshed on its own and saved it.
|
|
570
|
+
let session = this.session;
|
|
571
|
+
const holding = this.recorder.currentAccessToken();
|
|
572
|
+
if (holding !== session.accessToken) {
|
|
573
|
+
const file = loadCredentials();
|
|
574
|
+
if (!file || file.accessToken !== holding)
|
|
575
|
+
return; // it manages its own
|
|
576
|
+
session = file;
|
|
577
|
+
this.session = file;
|
|
578
|
+
}
|
|
579
|
+
if (session.accessExpiresAt - (this.opts.refreshAheadMs ?? 10 * 60 * 1000) > now)
|
|
580
|
+
return;
|
|
581
|
+
const expired = !isValid(session, now);
|
|
582
|
+
if (expired && now - this.lastRunStartedAt < (this.opts.quietMs ?? 20_000))
|
|
583
|
+
return;
|
|
584
|
+
const baseUrl = this.baseUrl;
|
|
585
|
+
const outcome = await refreshSession(baseUrl, session, this.fetchImpl, serviceTimeoutMs(), this.now);
|
|
586
|
+
if (outcome.kind === "ok") {
|
|
587
|
+
// The service has rotated the refresh token: the one on disk is dead now.
|
|
588
|
+
const saved = this.saveRefreshed(session, outcome.session);
|
|
589
|
+
// The world may have moved while we waited (a logout, a new sign-in).
|
|
590
|
+
if (this.stateNow !== "recording" || this.baseUrl !== baseUrl)
|
|
591
|
+
return;
|
|
592
|
+
if (saved === "not_ours") {
|
|
593
|
+
// The file no longer holds the token this refresh spent: `bir logout`
|
|
594
|
+
// removed it, or `bir login` (as anyone) replaced it, while the
|
|
595
|
+
// request was out. What is on disk decides, not this answer — installing
|
|
596
|
+
// it recorded the next prompts to the account just left.
|
|
597
|
+
this.decideFromDiskNow();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (this.refreshFailures > 0)
|
|
601
|
+
logLine("auth.recovered", { after: this.refreshFailures });
|
|
602
|
+
this.install(baseUrl, outcome.session);
|
|
603
|
+
logDetail("auth.refreshed", { user: outcome.session.user.email });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (this.stateNow !== "recording" || this.baseUrl !== baseUrl)
|
|
607
|
+
return;
|
|
608
|
+
if (outcome.kind === "rejected") {
|
|
609
|
+
// Rotating refresh tokens: the process that beat us to it has already
|
|
610
|
+
// written a good one. Look before concluding anything.
|
|
611
|
+
await sleep(this.opts.rejectRecheckMs ?? 1_000);
|
|
612
|
+
const fresh = decideFromDisk(this.settled);
|
|
613
|
+
if (fresh.state === "recording" &&
|
|
614
|
+
fresh.baseUrl &&
|
|
615
|
+
fresh.session &&
|
|
616
|
+
fresh.session.refreshToken !== session.refreshToken) {
|
|
617
|
+
this.install(fresh.baseUrl, fresh.session);
|
|
618
|
+
this.diskStamp = this.stamp();
|
|
619
|
+
logDetail("auth.adopted", { why: "another process refreshed first" });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (isValid(session, this.now())) {
|
|
623
|
+
// Still usable for a while; ask again when it expires rather than stop now.
|
|
624
|
+
this.nextRefreshAt = session.accessExpiresAt - EXPIRY_SKEW_MS;
|
|
625
|
+
logLine("auth.refresh_refused", {
|
|
626
|
+
status: outcome.status,
|
|
627
|
+
why: "the service refused the refresh; recording continues until the current token expires",
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
this.rejectedRefreshToken = session.refreshToken;
|
|
632
|
+
this.goIdle({ state: "rejected", baseUrl }, "refresh");
|
|
633
|
+
this.refused = { baseUrl, session, at: this.now(), final: false };
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// No answer. Not a verdict on the sign-in: keep it and try again later.
|
|
637
|
+
this.refreshFailures += 1;
|
|
638
|
+
const backoff = Math.min(MAX_REFRESH_BACKOFF_MS, 30_000 * 2 ** Math.min(this.refreshFailures - 1, 10));
|
|
639
|
+
this.nextRefreshAt = this.now() + backoff;
|
|
640
|
+
if (this.refreshFailures === 1) {
|
|
641
|
+
logLine("auth.refresh_deferred", {
|
|
642
|
+
reason: outcome.reason,
|
|
643
|
+
retry_s: Math.round(backoff / 1000),
|
|
644
|
+
why: "the service did not answer — still recording with the saved sign-in, and retrying; nothing to do",
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Save a rotated session whatever happened meanwhile — the service has spent
|
|
650
|
+
* the refresh token on disk — unless the file no longer holds the one spent
|
|
651
|
+
* (a logout removed it, or a new sign-in replaced it), which is not ours to
|
|
652
|
+
* overwrite.
|
|
653
|
+
*/
|
|
654
|
+
saveRefreshed(spent, fresh) {
|
|
655
|
+
const onDisk = loadCredentials();
|
|
656
|
+
if (!onDisk || onDisk.refreshToken !== spent.refreshToken)
|
|
657
|
+
return "not_ours";
|
|
658
|
+
try {
|
|
659
|
+
saveCredentials(fresh); // so the proxies and the next process reuse it
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
logLine("auth.save_failed", { error: errText(err), why: "the token still works in this process" });
|
|
663
|
+
this.diskStamp = this.stamp();
|
|
664
|
+
return "save_failed";
|
|
665
|
+
}
|
|
666
|
+
this.diskStamp = this.stamp();
|
|
667
|
+
return "saved";
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Decide from the files as they are now, whatever the last stamp said: after
|
|
671
|
+
* a logout this goes idle, after a sign-in as someone else it records to
|
|
672
|
+
* them, and after a sibling's refresh of the same sign-in it adopts the
|
|
673
|
+
* sibling's token (the `same` branch of {@link apply}).
|
|
674
|
+
*/
|
|
675
|
+
decideFromDiskNow() {
|
|
676
|
+
this.diskStamp = "";
|
|
677
|
+
this.checkDisk();
|
|
678
|
+
logDetail("auth.refresh_superseded", {
|
|
679
|
+
why: "the sign-in on disk changed while a refresh was out; what is on disk now decides",
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* A refusal is asked once more, {@link REJECT_RETRY_MS} later, before it is
|
|
684
|
+
* final: whatever stands in front of the API can answer for it in JSON too,
|
|
685
|
+
* and a sign-in that was never really refused should not cost more than
|
|
686
|
+
* that half hour. A second refusal is final until the file changes (`bir
|
|
687
|
+
* login`); no answer at all is asked again after another interval.
|
|
688
|
+
*/
|
|
689
|
+
async maybeRetryRefused() {
|
|
690
|
+
const r = this.refused;
|
|
691
|
+
if (this.stateNow !== "rejected" || !r || r.final)
|
|
692
|
+
return;
|
|
693
|
+
if (this.now() - r.at < (this.opts.rejectRetryMs ?? REJECT_RETRY_MS))
|
|
694
|
+
return;
|
|
695
|
+
const outcome = await refreshSession(r.baseUrl, r.session, this.fetchImpl, serviceTimeoutMs(), this.now);
|
|
696
|
+
// A sign-in or sign-out while it ran decides instead.
|
|
697
|
+
if (this.stateNow !== "rejected" || this.refused !== r)
|
|
698
|
+
return;
|
|
699
|
+
if (outcome.kind === "ok") {
|
|
700
|
+
if (this.saveRefreshed(r.session, outcome.session) === "not_ours") {
|
|
701
|
+
// Signed out or in again meanwhile: the files decide, not this answer.
|
|
702
|
+
this.decideFromDiskNow();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
this.install(r.baseUrl, outcome.session);
|
|
706
|
+
logLine("recorder.enabled", {
|
|
707
|
+
user: outcome.session.user.email,
|
|
708
|
+
service: r.baseUrl,
|
|
709
|
+
why: "the service accepted the saved sign-in when asked again — recording from the next prompt",
|
|
710
|
+
});
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (outcome.kind === "rejected") {
|
|
714
|
+
r.final = true;
|
|
715
|
+
logDetail("auth.refresh_refused_again", { status: outcome.status });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
r.at = this.now();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
//# sourceMappingURL=recorder-keeper.js.map
|