browser-broker 0.1.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/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { browserIsRunning, RealBrowserDriver, modeFor } from "../browser/real.js";
|
|
3
|
+
import { DEFAULT_BROWSER_IDS } from "../browser/driver.js";
|
|
4
|
+
import { BrokerError } from "../errors.js";
|
|
5
|
+
import { SIGNABLE_BROWSER } from "../service/operations/sign-in.js";
|
|
6
|
+
import { runSetupHandshake } from "../browser/setup.js";
|
|
7
|
+
import { COLLISION_HINT, NO_BROWSER_NOTE, relativeProfilePath, signInCompletion, signInInstructions, signInProfileDirectory, } from "./sign-in.js";
|
|
8
|
+
/**
|
|
9
|
+
* `broker login` — open the signed-in browser for a person, and wait.
|
|
10
|
+
*
|
|
11
|
+
* ── What this command is, in one sentence ───────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* It is the **only** part of this design a person has to perform by hand, and
|
|
14
|
+
* everything about its shape follows from that: it takes no lease, spends no
|
|
15
|
+
* tab budget, drives nothing, and its entire output is instructions.
|
|
16
|
+
*
|
|
17
|
+
* ── The four steps, and which of them are refusals ──────────────────────
|
|
18
|
+
*
|
|
19
|
+
* `SCHEMA.md` §5.5.1 gives the sequence and this file performs it in order:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Claim the browser through the service** — which is where the refusals
|
|
22
|
+
* live: a live lease holding a tab, the private browser, a sign-in already
|
|
23
|
+
* in progress. None of them is checked here, deliberately: they are facts
|
|
24
|
+
* about leases and lease liveness is derived inside the arbitration
|
|
25
|
+
* transaction (§2.4). A check made in this file would read rows the sweep
|
|
26
|
+
* had not reconciled.
|
|
27
|
+
* 2. **Get them a window**, against the configured profile.
|
|
28
|
+
* 3. **Wait**, while they sign in.
|
|
29
|
+
* 4. **Give the browser back** on every path out of the command, and on an
|
|
30
|
+
* interruption — by a signal handler, because a `finally` does not run on
|
|
31
|
+
* a signal. What that does and does not cover is stated exactly at step
|
|
32
|
+
* four's own comment below; the short version is that a handler covers the
|
|
33
|
+
* deaths that let a process run code and nothing else, so the store also
|
|
34
|
+
* records who holds a sign-in and a later `broker login` reclaims one whose
|
|
35
|
+
* process is gone.
|
|
36
|
+
*
|
|
37
|
+
* ── Why the profile is asserted rather than trusted ─────────────────────
|
|
38
|
+
*
|
|
39
|
+
* **Measured, and it is the failure this command exists to prevent.** A
|
|
40
|
+
* second browser launched against a profile directory already in use does not
|
|
41
|
+
* report a lock error: it **hands its address to the browser already holding
|
|
42
|
+
* the profile**, with nothing on the error stream and no endpoint of its own.
|
|
43
|
+
* A launcher waiting on its own endpoint therefore waits for something that
|
|
44
|
+
* will never appear. (The losing process does not exit on its own either;
|
|
45
|
+
* `launch.ts` ends it by the identifier it spawned.)
|
|
46
|
+
*
|
|
47
|
+
* And the obvious guard does not work: the single-instance lock file a POSIX
|
|
48
|
+
* system leaves in a profile directory **does not exist on Windows**, so a
|
|
49
|
+
* cross-platform check for it does not report *no lock* there — it **always
|
|
50
|
+
* passes**. A guard that cannot fail on one platform is worse than no guard.
|
|
51
|
+
*
|
|
52
|
+
* So this file makes **no** negative inference. It asks `browserIsRunning`,
|
|
53
|
+
* which verifies an endpoint answers *and* identifies itself as the expected
|
|
54
|
+
* browser, and when it starts one it goes through `coldStart`, whose contract
|
|
55
|
+
* is that success is an endpoint that answered — never a command that exited.
|
|
56
|
+
* The positive assertion is the whole mechanism; there is no path here that
|
|
57
|
+
* concludes a browser is available because nothing said otherwise.
|
|
58
|
+
*
|
|
59
|
+
* ── Why the profile is never recreated ──────────────────────────────────
|
|
60
|
+
*
|
|
61
|
+
* `runSetupHandshake` is called rather than respelled, and that is not
|
|
62
|
+
* economy: it is the one implementation of **setup may create, and may never
|
|
63
|
+
* destroy** (`setup.profile_never_destroyed`, §7.2). The profile holds a
|
|
64
|
+
* sign-in a person put there by hand, and a second implementation of
|
|
65
|
+
* "establish a profile" is exactly where the branch that clears a directory
|
|
66
|
+
* because it looks unfamiliar would eventually be written.
|
|
67
|
+
*/
|
|
68
|
+
/** How the person's window is watched for, and how often. */
|
|
69
|
+
export const CLOSE_POLL_INTERVAL_MS = 1_000;
|
|
70
|
+
/**
|
|
71
|
+
* The signals a person's interruption arrives as.
|
|
72
|
+
*
|
|
73
|
+
* `SIGINT` is Ctrl-C, which is the one that matters: it is how anybody stops
|
|
74
|
+
* a command that is sitting there waiting, and it is the keystroke that used
|
|
75
|
+
* to strand the browser. `SIGTERM` is the ordinary polite termination — what
|
|
76
|
+
* a supervisor, a shell logout or a `taskkill` without `/F` sends.
|
|
77
|
+
*
|
|
78
|
+
* **`SIGKILL` is deliberately absent and cannot be added.** It is not
|
|
79
|
+
* deliverable to a handler by design, which is precisely why the recovery
|
|
80
|
+
* path in `service/signin-recovery.ts` exists rather than this list being
|
|
81
|
+
* extended until it feels complete.
|
|
82
|
+
*/
|
|
83
|
+
export const INTERRUPT_SIGNALS = ['SIGINT', 'SIGTERM'];
|
|
84
|
+
/**
|
|
85
|
+
* The real one: process signal handlers, and an exit once the browser is back.
|
|
86
|
+
*
|
|
87
|
+
* ── Why this exits rather than letting the process continue ─────────────
|
|
88
|
+
*
|
|
89
|
+
* Installing a handler for `SIGINT` **takes over the default disposition**: a
|
|
90
|
+
* process carrying one runs the handler and carries on waiting rather than
|
|
91
|
+
* ending, which from a person's side is a command that has stopped responding
|
|
92
|
+
* to Ctrl-C. That would trade one bad outcome for another, so the handler does
|
|
93
|
+
* the work and then ends the process itself.
|
|
94
|
+
*
|
|
95
|
+
* The exit code is the conventional `128 + signal number`, which is what a
|
|
96
|
+
* shell reports for a process killed by that signal — so a script watching
|
|
97
|
+
* this command sees what it saw before rather than a new number to learn.
|
|
98
|
+
*/
|
|
99
|
+
export function realInterruptHandling() {
|
|
100
|
+
return {
|
|
101
|
+
install: (onInterrupt) => {
|
|
102
|
+
const registered = INTERRUPT_SIGNALS.map((signal) => {
|
|
103
|
+
const listener = () => {
|
|
104
|
+
onInterrupt(signal);
|
|
105
|
+
};
|
|
106
|
+
process.on(signal, listener);
|
|
107
|
+
return { signal, listener };
|
|
108
|
+
});
|
|
109
|
+
return () => {
|
|
110
|
+
for (const { signal, listener } of registered) {
|
|
111
|
+
process.off(signal, listener);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** `128 + n`, the exit code a shell reports for a death by signal. */
|
|
118
|
+
export const SIGNAL_EXIT_CODES = {
|
|
119
|
+
SIGINT: 130,
|
|
120
|
+
SIGTERM: 143,
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The exit code when an interruption was caught and the browser could **not**
|
|
124
|
+
* be given back.
|
|
125
|
+
*
|
|
126
|
+
* Distinct from the signal codes on purpose: those say "this process was
|
|
127
|
+
* interrupted", which is ordinary and is what a script expects. This one says
|
|
128
|
+
* the interruption was handled and the cleanup it exists to perform failed, so
|
|
129
|
+
* something is left behind — a different fact, and one a script watching this
|
|
130
|
+
* command should be able to tell apart without parsing English.
|
|
131
|
+
*/
|
|
132
|
+
export const EXIT_INTERRUPT_INCOMPLETE = 70;
|
|
133
|
+
/**
|
|
134
|
+
* The real window: a headed browser against the configured profile.
|
|
135
|
+
*
|
|
136
|
+
* It attaches to one that is already running and starts one otherwise, which
|
|
137
|
+
* is the arrangement §5.5.1 describes — *"nothing is stopped and nothing is
|
|
138
|
+
* relaunched"*, because relaunching is a chance to lose the very thing being
|
|
139
|
+
* protected.
|
|
140
|
+
*/
|
|
141
|
+
export function realSignInWindow(options = {}) {
|
|
142
|
+
const driver = new RealBrowserDriver(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl });
|
|
143
|
+
return {
|
|
144
|
+
open: async (request) => {
|
|
145
|
+
if (request.alreadyRunning) {
|
|
146
|
+
const record = await browserIsRunning(request.profileDirectory, options);
|
|
147
|
+
if (record === undefined) {
|
|
148
|
+
// The record stopped checking out between the caller's look and
|
|
149
|
+
// this one. Treated as not running rather than as an error, because
|
|
150
|
+
// that is exactly what a stale record means (§1.2c).
|
|
151
|
+
const session = await driver.coldStart({
|
|
152
|
+
browser: request.browser,
|
|
153
|
+
profileDirectory: request.profileDirectory,
|
|
154
|
+
mode: modeFor(request.browser),
|
|
155
|
+
});
|
|
156
|
+
return finishOpen(session, true);
|
|
157
|
+
}
|
|
158
|
+
const session = await driver.attach(request.browser, record);
|
|
159
|
+
// Attaching is non-destructive and the browser was not started here,
|
|
160
|
+
// so the person is being handed a window that already existed.
|
|
161
|
+
return finishOpen(session, false);
|
|
162
|
+
}
|
|
163
|
+
const session = await driver.coldStart({
|
|
164
|
+
browser: request.browser,
|
|
165
|
+
profileDirectory: request.profileDirectory,
|
|
166
|
+
mode: modeFor(request.browser),
|
|
167
|
+
});
|
|
168
|
+
return finishOpen(session, true);
|
|
169
|
+
},
|
|
170
|
+
waitForClose: async (request) => {
|
|
171
|
+
// **Watched by asking the endpoint, not by watching the process.** A
|
|
172
|
+
// process identifier can be reused, and a browser that is exiting holds
|
|
173
|
+
// its identifier for a moment after its window has gone. The endpoint
|
|
174
|
+
// answering with the expected identity is the same positive test
|
|
175
|
+
// everything else here uses, and its *absence* is the only negative
|
|
176
|
+
// conclusion this file draws — which is sound in this direction: an
|
|
177
|
+
// endpoint that does not answer is not serving anybody.
|
|
178
|
+
for (;;) {
|
|
179
|
+
const record = await browserIsRunning(request.profileDirectory, options);
|
|
180
|
+
if (record === undefined) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, CLOSE_POLL_INTERVAL_MS));
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Release this process's connection and report the browser's identifier.
|
|
190
|
+
*
|
|
191
|
+
* **Detaching is not closing.** Measured (`real.ts`): attaching and detaching
|
|
192
|
+
* are non-destructive to tabs and cookies, and the browser is adopted rather
|
|
193
|
+
* than owned — so letting go of the connection leaves the person's window
|
|
194
|
+
* exactly where it was, which is the point. This process must not be holding
|
|
195
|
+
* a connection while a person drives the window, because this process is
|
|
196
|
+
* going to exit.
|
|
197
|
+
*/
|
|
198
|
+
async function finishOpen(session, startedIt) {
|
|
199
|
+
const { pid } = session.describe();
|
|
200
|
+
await session.detach();
|
|
201
|
+
return { pid, startedIt };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Run the command.
|
|
205
|
+
*
|
|
206
|
+
* Returns an exit code rather than calling out to the process, like every
|
|
207
|
+
* other command here, so the whole of it is reachable from a test.
|
|
208
|
+
*/
|
|
209
|
+
export async function runLoginCommand(options) {
|
|
210
|
+
const { broker, environment, streams, json } = options;
|
|
211
|
+
const requested = options.browser ?? SIGNABLE_BROWSER;
|
|
212
|
+
// Establish the profile **before** claiming the browser, because a claim
|
|
213
|
+
// that succeeded and then failed to find a profile would leave the browser
|
|
214
|
+
// in `signing-in` over a directory that was never there. It creates what is
|
|
215
|
+
// absent and leaves alone what is present — never recreating one that
|
|
216
|
+
// exists, which is the whole of `setup.profile_never_destroyed`.
|
|
217
|
+
await runSetupHandshake(options.store, environment.profileRoot, {
|
|
218
|
+
browsers: [...environment.regularBrowsers, ...environment.privateBrowsers],
|
|
219
|
+
});
|
|
220
|
+
// Step 1. Every refusal is the service's; this route adds none of its own.
|
|
221
|
+
//
|
|
222
|
+
// **The owner is recorded as part of taking the claim**, so a sign-in is
|
|
223
|
+
// never held by a process the store cannot name. See step eight of the
|
|
224
|
+
// schema for why this is the command's identifier rather than the browser's.
|
|
225
|
+
const began = await broker.begin_sign_in({
|
|
226
|
+
browser: requested,
|
|
227
|
+
ownerPid: options.ownerPid ?? process.pid,
|
|
228
|
+
});
|
|
229
|
+
const browser = began.browser;
|
|
230
|
+
const profileDir = signInProfileDirectory(environment.profileRoot, browser);
|
|
231
|
+
// ── The interruption handler, installed as soon as there is something to
|
|
232
|
+
// give back and not one line earlier ──────────────────────────────────
|
|
233
|
+
//
|
|
234
|
+
// **Ordering is the whole correctness argument here.** Installed before the
|
|
235
|
+
// claim, it could fire when there is no claim to release and would call
|
|
236
|
+
// `end_sign_in` against a browser that is not signing in — which the service
|
|
237
|
+
// refuses, so a person interrupting an early failure would be handed a
|
|
238
|
+
// confusing refusal on their way out. Installed after the window opens, a
|
|
239
|
+
// Ctrl-C during the launch — which is a slow step and therefore a likely
|
|
240
|
+
// moment to press it — would strand exactly the state this is here to
|
|
241
|
+
// prevent.
|
|
242
|
+
//
|
|
243
|
+
// So it goes immediately after the claim is taken and immediately before
|
|
244
|
+
// anything slow, and it is removed in the `finally` so a completed command
|
|
245
|
+
// leaves no listener behind.
|
|
246
|
+
const handling = options.interrupts ?? { install: () => () => { } };
|
|
247
|
+
const endProcess = options.exit ?? ((code) => process.exit(code));
|
|
248
|
+
let interrupted = false;
|
|
249
|
+
const remove = handling.install((signal) => {
|
|
250
|
+
// **Re-entrancy matters more than it looks.** A person who presses Ctrl-C
|
|
251
|
+
// and sees nothing happen immediately presses it again, and a second run
|
|
252
|
+
// through here would call `end_sign_in` twice — the second against a
|
|
253
|
+
// browser already given back, which refuses. Latching means the extra
|
|
254
|
+
// presses are ignored rather than producing a refusal on the way out.
|
|
255
|
+
if (interrupted) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
interrupted = true;
|
|
259
|
+
streams.err('');
|
|
260
|
+
streams.err(`Interrupted. Giving the ${browser} browser back before exiting — it would otherwise refuse every caller until somebody intervened.`);
|
|
261
|
+
void (async () => {
|
|
262
|
+
let code = SIGNAL_EXIT_CODES[signal];
|
|
263
|
+
try {
|
|
264
|
+
await broker.end_sign_in({ browser });
|
|
265
|
+
streams.err(`The ${browser} browser is serving again.`);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
// Reported, and it changes the exit code: a person whose browser was
|
|
269
|
+
// **not** given back needs to know that the thing this message
|
|
270
|
+
// promised did not happen. Exiting zero-shaped here would be the same
|
|
271
|
+
// class of defect as the comment that used to claim a `finally`
|
|
272
|
+
// covered a signal.
|
|
273
|
+
streams.err(`The browser could not be returned to service: ${error instanceof Error ? error.message : String(error)}`);
|
|
274
|
+
streams.err('It is recorded as signing-in and this process is about to exit. The next `broker login` will reclaim it, because the sign-in records which process was holding it.');
|
|
275
|
+
code = EXIT_INTERRUPT_INCOMPLETE;
|
|
276
|
+
}
|
|
277
|
+
endProcess(code);
|
|
278
|
+
})();
|
|
279
|
+
});
|
|
280
|
+
const signals = { dispose: remove };
|
|
281
|
+
let opened;
|
|
282
|
+
try {
|
|
283
|
+
const window = options.window ?? realSignInWindow();
|
|
284
|
+
// Asked positively, and the answer is used only in the direction where it
|
|
285
|
+
// is meaningful: a verified record means a browser is there to attach to.
|
|
286
|
+
// A missing one means *this call could not verify one*, which is why the
|
|
287
|
+
// branch it leads to starts a browser and asserts an endpoint rather than
|
|
288
|
+
// assuming the profile is free.
|
|
289
|
+
const running = await browserIsRunning(profileDir);
|
|
290
|
+
opened = await window.open({
|
|
291
|
+
browser,
|
|
292
|
+
profileDirectory: profileDir,
|
|
293
|
+
alreadyRunning: running !== undefined,
|
|
294
|
+
});
|
|
295
|
+
const relative = relativeProfilePath(environment.profileRoot, browser);
|
|
296
|
+
if (json) {
|
|
297
|
+
// One document, and the human text goes to the error stream (§5.6).
|
|
298
|
+
streams.out(JSON.stringify({
|
|
299
|
+
outcome: 'accepted',
|
|
300
|
+
value: {
|
|
301
|
+
browser,
|
|
302
|
+
state: began.state,
|
|
303
|
+
profileRelativePath: relative,
|
|
304
|
+
startedBrowser: opened.startedIt,
|
|
305
|
+
pid: opened.pid,
|
|
306
|
+
},
|
|
307
|
+
}));
|
|
308
|
+
for (const line of signInInstructions(browser, relative)) {
|
|
309
|
+
streams.err(line);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
if (opened.startedIt) {
|
|
314
|
+
streams.out(NO_BROWSER_NOTE);
|
|
315
|
+
streams.out('');
|
|
316
|
+
}
|
|
317
|
+
for (const line of signInInstructions(browser, relative)) {
|
|
318
|
+
streams.out(line);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Step 3. The person signs in. Nothing happens here until they close it.
|
|
322
|
+
await window.waitForClose({ profileDirectory: profileDir, pid: opened.pid });
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
// ── Step 4, and what this `finally` actually guarantees ─────────────
|
|
326
|
+
//
|
|
327
|
+
// A browser left in `signing-in` refuses **every** caller with a message
|
|
328
|
+
// about a person who has walked away, so the browser is given back on
|
|
329
|
+
// every path out of here — a normal completion and a refusal from the
|
|
330
|
+
// window alike.
|
|
331
|
+
//
|
|
332
|
+
// **It does not cover an interruption, and it never did.** A `finally`
|
|
333
|
+
// is ordinary control flow: the runtime unwinds to it when a call
|
|
334
|
+
// returns or throws. A signal is not either of those, and with no
|
|
335
|
+
// handler installed the default disposition for `SIGINT` terminates the
|
|
336
|
+
// process without unwinding anything — so this block does not run, and
|
|
337
|
+
// before the handler below existed a Ctrl-C left the browser
|
|
338
|
+
// unrecoverable. That is why the handler is installed rather than being
|
|
339
|
+
// relied upon from here, and it is why the store records an owner as
|
|
340
|
+
// well: a handler cannot run on `SIGKILL` or on a power cut.
|
|
341
|
+
signals.dispose();
|
|
342
|
+
// **Nothing is given back twice.** When the handler ran it has already
|
|
343
|
+
// called `end_sign_in`, and calling it again would hit the service's own
|
|
344
|
+
// refusal for ending a sign-in that never began — turning a clean
|
|
345
|
+
// interruption into an error message on the way out. The handler owns the
|
|
346
|
+
// release from the moment it fires, and this block owns every other path.
|
|
347
|
+
//
|
|
348
|
+
// Written as a condition around the work rather than as an early `return`,
|
|
349
|
+
// deliberately: a `return` inside a `finally` **discards an exception the
|
|
350
|
+
// `try` was throwing**, so the one shape that reads most naturally here is
|
|
351
|
+
// the one that would silently swallow a genuine launch failure.
|
|
352
|
+
if (!interrupted) {
|
|
353
|
+
try {
|
|
354
|
+
const ended = await broker.end_sign_in({ browser });
|
|
355
|
+
if (!json) {
|
|
356
|
+
streams.out('');
|
|
357
|
+
for (const line of signInCompletion(browser, ended.queueDepth)) {
|
|
358
|
+
streams.out(line);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
streams.err(`The ${browser} browser is serving again.`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
// Reported rather than swallowed and never allowed to replace the
|
|
367
|
+
// original failure: if this is running because something above threw,
|
|
368
|
+
// that is the thing the person needs to read.
|
|
369
|
+
streams.err(`The browser could not be returned to service: ${error instanceof Error ? error.message : String(error)}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Turn a launch refusal into something a person can act on.
|
|
377
|
+
*
|
|
378
|
+
* The launch's own message is accurate and is about endpoints; this adds what
|
|
379
|
+
* to *do*, which is the part a person needs and the part a message about
|
|
380
|
+
* endpoints cannot supply.
|
|
381
|
+
*/
|
|
382
|
+
export function explainLoginFailure(error) {
|
|
383
|
+
if (error instanceof BrokerError && error.message.includes('already running against this')) {
|
|
384
|
+
return `${error.message}\n\n${COLLISION_HINT}`;
|
|
385
|
+
}
|
|
386
|
+
return error instanceof Error ? error.message : String(error);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Whether a profile directory exists at all, for the caller that wants to
|
|
390
|
+
* report rather than create.
|
|
391
|
+
*/
|
|
392
|
+
export function profileExists(profileRoot, browser) {
|
|
393
|
+
try {
|
|
394
|
+
return fs.statSync(signInProfileDirectory(profileRoot, browser)).isDirectory();
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** The browsers this command will answer for, for a usage message. */
|
|
401
|
+
export const LOGIN_BROWSERS = DEFAULT_BROWSER_IDS;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { resolveAutomationProbe } from "../browser/automation-probe.js";
|
|
2
|
+
import { runDoctor, formatReport } from "../doctor/report.js";
|
|
3
|
+
import { EXIT } from "./adapter.js";
|
|
4
|
+
import { readLedger } from "../operations/ledger.js";
|
|
5
|
+
import { writeSnapshot } from "../report/snapshot.js";
|
|
6
|
+
/**
|
|
7
|
+
* Exit codes, chosen so situations wanting opposite responses are
|
|
8
|
+
* distinguishable without parsing anything (§5.6).
|
|
9
|
+
*
|
|
10
|
+
* Re-exported from the adapter that owns them rather than restated, so there
|
|
11
|
+
* is one set of numbers in this build and no second copy to drift. The
|
|
12
|
+
* re-export exists because these three commands are not adapter operations —
|
|
13
|
+
* they have no service call behind them — and importing the adapter's own
|
|
14
|
+
* constant is how they stay in step with the route that does.
|
|
15
|
+
*/
|
|
16
|
+
export const COMMAND_EXIT = EXIT;
|
|
17
|
+
/** `--name value` and `--name=value`, plus bare `--flag`. */
|
|
18
|
+
export function parseFlags(rest) {
|
|
19
|
+
const parsed = {};
|
|
20
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
21
|
+
const word = rest[index];
|
|
22
|
+
if (word === undefined || !word.startsWith('--')) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const body = word.slice(2);
|
|
26
|
+
const equals = body.indexOf('=');
|
|
27
|
+
if (equals !== -1) {
|
|
28
|
+
parsed[body.slice(0, equals)] = body.slice(equals + 1);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const next = rest[index + 1];
|
|
32
|
+
if (next === undefined || next.startsWith('--')) {
|
|
33
|
+
parsed[body] = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
parsed[body] = next;
|
|
37
|
+
index += 1;
|
|
38
|
+
}
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
function asString(value) {
|
|
42
|
+
return typeof value === 'string' ? value : undefined;
|
|
43
|
+
}
|
|
44
|
+
function asNumber(value) {
|
|
45
|
+
const text = asString(value);
|
|
46
|
+
if (text === undefined) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const parsed = Number(text);
|
|
50
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `broker snapshot --out <path>`.
|
|
54
|
+
*
|
|
55
|
+
* **No browser connection is supplied by this route**, and that is stated
|
|
56
|
+
* rather than hidden: the command line does not attach to a browser, so every
|
|
57
|
+
* address in the document it writes reads as `unreachable` with a note giving
|
|
58
|
+
* the single reason. The row that brings the real driver is the row that
|
|
59
|
+
* passes a source through. §4.2a's requirement is about what the document
|
|
60
|
+
* says when a browser does not answer, and this route satisfies it in the
|
|
61
|
+
* most literal way available — it asks nothing and says so, rather than
|
|
62
|
+
* leaving a blank.
|
|
63
|
+
*/
|
|
64
|
+
export async function runSnapshotCommand(rest, options) {
|
|
65
|
+
const flags = parseFlags(rest);
|
|
66
|
+
const outputPath = asString(flags.out) ?? asString(flags.output) ?? asString(flags.path);
|
|
67
|
+
if (outputPath === undefined) {
|
|
68
|
+
options.streams.err('broker snapshot needs somewhere to write: --out <path>. It writes one self-contained HTML file and exits.');
|
|
69
|
+
return COMMAND_EXIT.malformed;
|
|
70
|
+
}
|
|
71
|
+
const result = await writeSnapshot(options.db, {
|
|
72
|
+
outputPath,
|
|
73
|
+
eventLimit: asNumber(flags.events),
|
|
74
|
+
feedbackLimit: asNumber(flags.feedback),
|
|
75
|
+
version: options.version,
|
|
76
|
+
});
|
|
77
|
+
if (options.json) {
|
|
78
|
+
options.streams.out(JSON.stringify({
|
|
79
|
+
path: result.path,
|
|
80
|
+
bytes: result.bytes,
|
|
81
|
+
at: result.at,
|
|
82
|
+
tabs_asked: result.tabsAsked,
|
|
83
|
+
tabs_unreachable: result.tabsUnreachable,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
options.streams.out(`snapshot: ${result.path}`);
|
|
88
|
+
options.streams.out(`taken at: ${result.at}`);
|
|
89
|
+
// Said on every run, not only when something is wrong. §4.1's rule is
|
|
90
|
+
// that the document must not be mistaken for a window, and the person
|
|
91
|
+
// most likely to make that mistake is the one who just generated it.
|
|
92
|
+
options.streams.out('This is a photograph, not a window: it does not refresh. Generate another to see the current picture.');
|
|
93
|
+
if (result.tabsUnreachable > 0) {
|
|
94
|
+
options.streams.out(`${String(result.tabsUnreachable)} of ${String(result.tabsAsked + result.tabsUnreachable)} tab address(es) could not be read and are shown as unreachable.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return COMMAND_EXIT.accepted;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `broker doctor`.
|
|
101
|
+
*
|
|
102
|
+
* **Reports and changes nothing** (§5.5), and exits with a distinct code on
|
|
103
|
+
* any failure, so it is usable exactly where a readiness check would have
|
|
104
|
+
* been used. The full list is on the output stream in both modes — the
|
|
105
|
+
* machine-readable one is one document, per §5.6.
|
|
106
|
+
*/
|
|
107
|
+
export function runDoctorCommand(options) {
|
|
108
|
+
// **The configured budget is passed, and until it was, the check could not
|
|
109
|
+
// fail.** `checkTabBudget` compares the stored value against this process's
|
|
110
|
+
// own; with nothing supplied it reported `unknown` regardless of what the
|
|
111
|
+
// store held. The only callers ever passing it were the doctor's own tests,
|
|
112
|
+
// which is why the gap survived — see `operations/status.ts` for the other
|
|
113
|
+
// half of the same failure.
|
|
114
|
+
//
|
|
115
|
+
// **The automation probe is passed the same way, for the same reason.**
|
|
116
|
+
// `checkAutomation` compares what it is told against nothing on its own;
|
|
117
|
+
// with no probe supplied it reported `unknown` regardless of whether a
|
|
118
|
+
// browser binary was actually resolvable. The only callers ever passing
|
|
119
|
+
// one were the doctor's own tests — see `resolveAutomationProbe` in
|
|
120
|
+
// `browser/automation-probe.ts` for the real answer this call site now
|
|
121
|
+
// supplies by default.
|
|
122
|
+
const report = runDoctor(options.environment, options.db, {
|
|
123
|
+
configuredTabBudget: options.environment.tabBudget,
|
|
124
|
+
automation: options.automationProbe ?? resolveAutomationProbe(),
|
|
125
|
+
});
|
|
126
|
+
if (options.json) {
|
|
127
|
+
options.streams.out(JSON.stringify({
|
|
128
|
+
store: report.storeLocation,
|
|
129
|
+
exit_code: report.exitCode,
|
|
130
|
+
checks: report.checks.map((check) => ({
|
|
131
|
+
id: check.id,
|
|
132
|
+
group: check.group,
|
|
133
|
+
status: check.status,
|
|
134
|
+
detail: check.detail,
|
|
135
|
+
remedy: check.remedy,
|
|
136
|
+
})),
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
for (const line of formatReport(report)) {
|
|
141
|
+
options.streams.out(line);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return report.exitCode;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* `broker events` — a slice of the ledger (`MILESTONES.md` #47).
|
|
148
|
+
*
|
|
149
|
+
* Sliced by kind, outcome and rule, with the cursor the counter primary key
|
|
150
|
+
* already provides (§1.6). Every filter is a bound parameter; nothing a
|
|
151
|
+
* caller types reaches the SQL text.
|
|
152
|
+
*/
|
|
153
|
+
export function runEventsCommand(rest, options) {
|
|
154
|
+
const flags = parseFlags(rest);
|
|
155
|
+
const query = {
|
|
156
|
+
kinds: asString(flags.kind)?.split(',') ?? undefined,
|
|
157
|
+
outcome: asString(flags.outcome),
|
|
158
|
+
guard: asString(flags.guard),
|
|
159
|
+
sessionId: asString(flags['session-id']),
|
|
160
|
+
claimId: asString(flags['claim-id']),
|
|
161
|
+
since: asNumber(flags.since),
|
|
162
|
+
before: asNumber(flags.before),
|
|
163
|
+
limit: asNumber(flags.limit),
|
|
164
|
+
order: flags.since === undefined ? 'newest' : 'oldest',
|
|
165
|
+
};
|
|
166
|
+
const slice = readLedger(options.db, query);
|
|
167
|
+
if (options.json) {
|
|
168
|
+
options.streams.out(JSON.stringify({
|
|
169
|
+
entries: slice.entries,
|
|
170
|
+
cursor: slice.cursor,
|
|
171
|
+
total: slice.total,
|
|
172
|
+
}));
|
|
173
|
+
return COMMAND_EXIT.accepted;
|
|
174
|
+
}
|
|
175
|
+
if (slice.entries.length === 0) {
|
|
176
|
+
options.streams.out('No ledger entries match.');
|
|
177
|
+
return COMMAND_EXIT.accepted;
|
|
178
|
+
}
|
|
179
|
+
for (const entry of slice.entries) {
|
|
180
|
+
const guard = entry.guard === null ? '' : ` guard=${entry.guard}`;
|
|
181
|
+
const session = entry.sessionId === null ? '' : ` session=${entry.sessionId}`;
|
|
182
|
+
options.streams.out(`#${String(entry.id)} ${entry.at} ${entry.kind} ${entry.outcome} via=${entry.adapter}${guard}${session}`);
|
|
183
|
+
}
|
|
184
|
+
options.streams.out(`${String(slice.entries.length)} of ${String(slice.total)} matching entries.${slice.cursor === null ? '' : ` Read on with --since ${String(slice.cursor)}.`}`);
|
|
185
|
+
return COMMAND_EXIT.accepted;
|
|
186
|
+
}
|