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.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. package/package.json +58 -0
@@ -0,0 +1,915 @@
1
+ import { BROWSER_CHOICE_GUIDANCE } from "../../browser/driver.js";
2
+ import { CallRefusal } from "../refusals.js";
3
+ import { append } from "../events.js";
4
+ import { extendLease, resolveLease } from "../leases.js";
5
+ import { classifySignIn, processIsRunning, SIGN_IN_OWNER_UNKNOWN_REMEDY, } from "../signin-recovery.js";
6
+ /**
7
+ * `broker login` — **the one time a person drives** (`SCHEMA.md` §5.5.1).
8
+ *
9
+ * ── Why this is a service operation and not a command that opens a window ──
10
+ *
11
+ * The obvious shape for "let a person sign in" is a command that launches a
12
+ * browser and gets out of the way. §5.5.1 rejects that shape explicitly, and
13
+ * the reason is the first of its four steps: it **refuses if any live lease
14
+ * holds a tab on that browser**, naming them. *"Somebody is about to drive
15
+ * the window by hand, and doing that underneath a caller's work would corrupt
16
+ * it. That refusal is why signing in is a service operation and not something
17
+ * a person does to the browser directly."*
18
+ *
19
+ * A command that reached for the browser itself could not make that check
20
+ * mean anything. Leases live in the store, liveness is **derived rather than
21
+ * stored** (§2.4), and the only place a lapsed lease is reconciled is inside
22
+ * the arbitration transaction. So a check made outside it would read rows
23
+ * that may have expired — refusing a person over a caller that is already
24
+ * gone — or miss one that went live between the read and the hand-over. Both
25
+ * halves are wrong in the direction that costs somebody their work.
26
+ *
27
+ * ── Nothing is stopped and nothing is relaunched ────────────────────────
28
+ *
29
+ * §5.5.1 is emphatic and the reason is the keeper-tab measurement (§3.15):
30
+ * **the signed-in browser runs headed and stays headed**, so the window a
31
+ * person signs into is the window that is already there. *"Nothing is stopped
32
+ * and nothing is relaunched, which removes the step where a sign-in could be
33
+ * lost."*
34
+ *
35
+ * That is why this operation moves a **state** and does no browser work at
36
+ * all. It does not launch, attach, navigate or close anything — those are
37
+ * browser calls, and browser calls never happen inside the arbitration
38
+ * transaction (§2.4b). What it does is claim the browser for the person, so
39
+ * that whoever hands them the window is handing them one nobody else is about
40
+ * to touch.
41
+ *
42
+ * ── The interval, and why both edges are recorded ───────────────────────
43
+ *
44
+ * Between the two calls the browser is in `signing-in`, and §5.5.1's third
45
+ * step is that *"requests for it are refused with a retry hint"* while
46
+ * **"queued callers keep their places and their timers, because a sign-in is
47
+ * a pause and not a cancellation"**. So this is the one interval in which the
48
+ * service turns callers away on purpose, and §1.6 requires every decision
49
+ * recorded. Both edges get a ledger row (schema step six) so that a run of
50
+ * denials reads as a person signing in rather than as a browser fault.
51
+ */
52
+ /**
53
+ * The refusal rules this module raises, **spelled as §7.1 spells them**.
54
+ *
55
+ * Named from the design's own table rather than invented here, which is what
56
+ * §8's fourth parity assertion counts over: *every rule in §7 appears in at
57
+ * least one refusal the service actually produced*. A rule this file made up
58
+ * would satisfy nothing and would be invisible to that count — and the build
59
+ * check that reconciles cited rules against the design refuses it outright.
60
+ */
61
+ export const SIGN_IN_RULES = {
62
+ /**
63
+ * §7.1 `browser.busy_for_login`: *"Signing in is refused while any live
64
+ * lease holds a tab on that browser"*, refused *"naming the leases"*.
65
+ */
66
+ busyForLogin: 'browser.busy_for_login',
67
+ /**
68
+ * §7.1 `browser.serving`, whose entry says outright that it *"covers
69
+ * signing-in"* — so a browser handed to a person is refused by the same
70
+ * rule as one that is failed, starting or stopped. That is the design's
71
+ * grouping and not this module's: from a caller's side all four are *the
72
+ * browser is not available right now*, and the retry hint is what
73
+ * distinguishes the pause from the fault.
74
+ */
75
+ serving: 'browser.serving',
76
+ /**
77
+ * §7.1 `signin.what_bounded`. The sentence a person reads to know which
78
+ * sign-in wall this is, bounded exactly as `claims.purpose` is (§1.3).
79
+ *
80
+ * **A rule of its own rather than a reuse of `claim.purpose_bounded`**, for
81
+ * the reason `refusals.ts` gives about those two being the same *shape* of
82
+ * defect and not the same refusal: a caller branching on the purpose rule
83
+ * would go and rewrite a purpose that was never wrong.
84
+ */
85
+ whatBounded: 'signin.what_bounded',
86
+ /**
87
+ * §7.1 `signin.requester_holds_tab`. A request comes from a lease that is
88
+ * holding an open tab, because the person signs in **on that tab** — there
89
+ * is nothing to hand them otherwise.
90
+ */
91
+ requesterHoldsTab: 'signin.requester_holds_tab',
92
+ /**
93
+ * §7.1 `signin.finish_owned`. Only the lease that asked may finish the
94
+ * request it made. Without it, a caller could end a person's `broker login`
95
+ * mid-password by naming a browser, which is the browser-scoped destructive
96
+ * verb §3.13 says must never exist on this surface.
97
+ */
98
+ finishOwned: 'signin.finish_owned',
99
+ };
100
+ /**
101
+ * The browser a person can sign into.
102
+ *
103
+ * **Not a preference and not a policy — a fact about where a sign-in goes.**
104
+ * §5.5.1: *"Refused on the private browser. Signing into an ephemeral profile
105
+ * produces nothing that outlives the browser, so the command would appear to
106
+ * work and quietly do nothing — the worst of the available failures."*
107
+ *
108
+ * The private browser is the headless one (`modeFor`), and everything a
109
+ * sign-in produces is written into the profile directory (§1.2). A private
110
+ * profile is discarded, so the person would type a password, see it accepted,
111
+ * and have nothing to show for it — a success that did nothing, which is the
112
+ * failure this design is least able to detect after the fact.
113
+ */
114
+ export const SIGNABLE_BROWSER = 'regular';
115
+ /**
116
+ * The kind of browser a person can sign into.
117
+ *
118
+ * The kind rather than the name, because a name says nothing about the kind
119
+ * once names are configured (`DECISIONS.md` §13i). This is what the store's
120
+ * `kind` column is compared against, and the reasoning for *why* clean-room
121
+ * browsers are refused is on {@link SIGNABLE_BROWSER} above — it is a fact
122
+ * about ephemeral profiles, which is a property of the kind and never of the
123
+ * word.
124
+ */
125
+ export const SIGNABLE_KIND = 'regular';
126
+ function readBrowser(scope, browser) {
127
+ return scope.db
128
+ .prepare('SELECT id, state, pid, signin_owner_pid, signin_deadline, signin_claim_id FROM browsers WHERE id = @id')
129
+ .get({ id: browser });
130
+ }
131
+ /**
132
+ * Refuse anything that is not a browser with a profile to sign into.
133
+ *
134
+ * Two refusals rather than one, because they are two different mistakes and
135
+ * merging them sends the second person hunting for a typo they did not make:
136
+ * a name that is not a browser at all, and a clean-room browser — which *is*
137
+ * a browser, and is the one where signing in would appear to work.
138
+ *
139
+ * ── Why the store answers this rather than a constant ───────────────────
140
+ *
141
+ * A browser's name does not say what kind it is (`DECISIONS.md` §13i), so
142
+ * *"is this signable"* is a question about the browser rather than a
143
+ * comparison against the word `regular` — which the store answers from
144
+ * the `kind` column schema step nine added, under the same check constraint
145
+ * that makes the kind total. **The row is the right authority here** for the
146
+ * same reason §1.2 gives about `pid`: the service acts on what it has
147
+ * recorded, and a browser with no row is a browser this service does not
148
+ * manage, whatever a configuration elsewhere on the machine may say.
149
+ *
150
+ * A browser configured but never launched therefore has no row and is
151
+ * refused as unknown. That is the honest answer rather than a gap: signing in
152
+ * is a claim over a **profile**, and §5.5.1 has the caller establish the
153
+ * profile and the row before it asks — `login-command.ts` runs the setup
154
+ * handshake first for exactly this reason.
155
+ */
156
+ function resolveSignableBrowser(scope, requested) {
157
+ const { adapter } = scope;
158
+ const known = scope.db
159
+ .prepare('SELECT id, kind FROM browsers ORDER BY id')
160
+ .all();
161
+ const match = known.find((row) => row.id === requested);
162
+ if (match === undefined) {
163
+ const names = known.map((row) => row.id);
164
+ scope.recordRefusal({
165
+ kind: 'browser_signin_began',
166
+ outcome: 'deny',
167
+ guard: 'claim.browser_known',
168
+ adapter,
169
+ detail: { requested, known: names },
170
+ });
171
+ throw new CallRefusal('unknown_browser', `There is no browser named ${JSON.stringify(requested)}. This service has ${names.join(' and ')}. ${BROWSER_CHOICE_GUIDANCE}`, { detail: { requested, known: names } });
172
+ }
173
+ if (match.kind !== SIGNABLE_KIND) {
174
+ const signable = known.filter((row) => row.kind === SIGNABLE_KIND).map((row) => row.id);
175
+ scope.recordRefusal({
176
+ kind: 'browser_signin_began',
177
+ outcome: 'deny',
178
+ guard: SIGN_IN_RULES.serving,
179
+ adapter,
180
+ browserId: requested,
181
+ detail: { requested, signable },
182
+ });
183
+ throw new CallRefusal(
184
+ // **Not `unknown_browser`.** A clean-room browser is a real browser, and
185
+ // refusing it with the code for a name that does not exist made the
186
+ // command report `claim.browser_known` — telling a person their browser
187
+ // name was wrong when it was right. See the taxonomy entry.
188
+ 'cannot_sign_in', `The ${requested} browser cannot be signed into. Its profile is ephemeral, so everything a sign-in produces is discarded with the browser — the command would appear to work and leave you signed into nothing. Sign in to ${signable.join(' or ')}, whose profile persists and is an identity callers share.`, { detail: { requested, signable } });
189
+ }
190
+ return requested;
191
+ }
192
+ /**
193
+ * Begin: claim the browser for the person (§5.5.1 steps 1 and 2).
194
+ *
195
+ * **The live-lease check runs against the state this transaction's own sweep
196
+ * reconciled**, which is the whole reason it is here rather than in a
197
+ * command. A lease that lapsed a second ago has already been expired by the
198
+ * runner before this handler was called, so it does not refuse a person over
199
+ * a caller that is gone; and one granted a moment ago is committed and
200
+ * visible, so it does not miss a caller that is live.
201
+ */
202
+ export function decideBeginSignIn(scope, input) {
203
+ const { db, adapter, swept } = scope;
204
+ const browser = resolveSignableBrowser(scope, input.browser);
205
+ const row = readBrowser(scope, browser);
206
+ // ── Already signing in: whose, and are they still there? ──────────────
207
+ //
208
+ // **Refusing unconditionally here is what made an interrupted `broker login`
209
+ // unrecoverable.** A `finally` does not run on a signal, so a person who
210
+ // pressed Ctrl-C left this state behind with nothing able to move it; this
211
+ // refusal then turned away every caller *and* the second `broker login` that
212
+ // would have ended it. The only exit was editing the database by hand.
213
+ //
214
+ // So the question asked is **"is anybody still signing in"** rather than
215
+ // merely "is it signing in", and the two answers are kept apart:
216
+ //
217
+ // - **Owner still running** — refused, exactly as before and for the
218
+ // original reason: two people handed the same window would each believe
219
+ // they had it to themselves, and whichever finished first would end the
220
+ // other's by moving the state back underneath them.
221
+ // - **Owner gone** — reclaimed. Nothing is being taken from anybody,
222
+ // because the process that was holding it does not exist. Recorded in the
223
+ // ledger as its own decision so a reclamation is legible afterwards rather
224
+ // than looking like a sign-in that ended itself.
225
+ // - **Owner unknown** — refused, and this is deliberate. A row written
226
+ // before the owner column existed records nobody, and reclaiming on the
227
+ // strength of a missing record would end a live sign-in because an old
228
+ // build did not write down who started it. The refusal says so and says
229
+ // what to do.
230
+ if (row.state === 'signing-in') {
231
+ const owner = classifySignIn(row, input.isRunning ?? processIsRunning);
232
+ if (owner.kind === 'owner-running') {
233
+ scope.recordRefusal({
234
+ kind: 'browser_signin_began',
235
+ outcome: 'deny',
236
+ guard: SIGN_IN_RULES.serving,
237
+ adapter,
238
+ browserId: browser,
239
+ detail: { state: row.state, ownerPid: owner.pid, owner: 'running' },
240
+ });
241
+ throw new CallRefusal('browser_unavailable', `The ${browser} browser is already being signed into, by a process that is still running. Only one sign-in happens at a time — a second would hand the same window to two people, and whichever finished first would end the other's. Finish that one, or stop it.`, { detail: { browser, state: row.state, ownerPid: owner.pid } });
242
+ }
243
+ if (owner.kind === 'owner-unknown') {
244
+ scope.recordRefusal({
245
+ kind: 'browser_signin_began',
246
+ outcome: 'deny',
247
+ guard: SIGN_IN_RULES.serving,
248
+ adapter,
249
+ browserId: browser,
250
+ detail: { state: row.state, owner: 'unknown' },
251
+ });
252
+ throw new CallRefusal('browser_unavailable', `The ${browser} browser is recorded as being signed into, but this store does not say which process began it — so it cannot be confirmed abandoned and is not ended on a guess. ${SIGN_IN_OWNER_UNKNOWN_REMEDY}`, { detail: { browser, state: row.state } });
253
+ }
254
+ // `owner-gone`. Reclaimed, and recorded as a reclamation on its own row:
255
+ // §1.6 keeps one row per decision, and a reclamation is a different
256
+ // decision from a person finishing. A run of these reads as a command
257
+ // being interrupted repeatedly, which is exactly the pattern somebody
258
+ // debugging would want to see.
259
+ append(db, {
260
+ kind: 'browser_signin_ended',
261
+ outcome: 'allow',
262
+ adapter,
263
+ browserId: browser,
264
+ detail: {
265
+ reclaimed: true,
266
+ ownerPid: owner.kind === 'owner-gone' ? owner.pid : null,
267
+ reason: 'the process holding this sign-in has gone',
268
+ },
269
+ });
270
+ }
271
+ // §5.5.1 step 1, and the reason this is a service operation at all. The
272
+ // rows read here are the ones this transaction's sweep has already
273
+ // reconciled, so `active` means active now rather than active when
274
+ // somebody last looked.
275
+ //
276
+ // ── What the two conditions each carry, stated because one of them is
277
+ // not falsifiable by any test this build can write ─────────────────
278
+ //
279
+ // The query requires **both** a live claim and an open tab. In this build
280
+ // those two move together on every path the product can reach — a grant
281
+ // makes the pair (`active`, `opening`), a release makes it (`released`,
282
+ // `closed`), and an expiry makes it (`expired`, `closed`) — so **no
283
+ // reachable state separates them**, and a mutation that drops either
284
+ // condition alone survives the suite. That was measured rather than
285
+ // assumed, by removing each in turn and watching the tests stay green.
286
+ //
287
+ // Both are kept anyway, and the reason is that the redundancy is a
288
+ // property of this build rather than of the design. A lease is one tab
289
+ // (§2.3), and the tab-addressed operations that give a tab up and take a
290
+ // fresh one need a browser to run at all — so the state where a live claim
291
+ // has no open tab is unreachable while no browser runs, and becomes
292
+ // reachable once one does. Narrowing the query to whichever half suffices
293
+ // for the reachable states would be correct against those states and wrong
294
+ // against the rest, and the failure it produces is a person handed a
295
+ // window a caller is holding.
296
+ //
297
+ // This is written down rather than left as a surviving mutation somebody
298
+ // rediscovers: the condition is deliberate, it is not covered, and the
299
+ // reason it is not covered is that the product cannot yet produce the
300
+ // state that would cover it.
301
+ const holders = db
302
+ .prepare(`SELECT c.id AS claimId, c.session_id AS sessionId, c.purpose AS purpose,
303
+ c.expires_at AS expiresAt
304
+ FROM claims c
305
+ JOIN tabs t ON t.claim_id = c.id
306
+ WHERE c.browser_id = @browserId
307
+ AND c.state = 'active'
308
+ AND t.state IN ('opening', 'open')
309
+ ORDER BY c.id`)
310
+ .all({ browserId: browser });
311
+ if (holders.length > 0) {
312
+ scope.recordRefusal({
313
+ kind: 'browser_signin_began',
314
+ outcome: 'deny',
315
+ guard: SIGN_IN_RULES.busyForLogin,
316
+ adapter,
317
+ browserId: browser,
318
+ // Which leases, so a person knows who they would interrupt — and
319
+ // deliberately not the keys, which §5.6 says are never printed by any
320
+ // surface. A claim identifier addresses nothing without its key.
321
+ detail: {
322
+ holders: holders.map((holder) => ({
323
+ claimId: holder.claimId,
324
+ sessionId: holder.sessionId,
325
+ purpose: holder.purpose,
326
+ expiresAt: holder.expiresAt,
327
+ })),
328
+ },
329
+ });
330
+ const described = holders
331
+ .map((holder) => `${holder.claimId} (session ${holder.sessionId}, ${holder.purpose}, until ${holder.expiresAt})`)
332
+ .join('; ');
333
+ throw new CallRefusal('browser_unavailable', `The ${browser} browser has ${String(holders.length)} live lease(s) holding a tab, so it cannot be handed to a person right now: driving the window by hand underneath a caller's work would corrupt it. Waiting is enough — every lease expires on its own if its holder stops calling in. Holding: ${described}`, { detail: { browser, holders: [...holders] } });
334
+ }
335
+ // §5.5.1 step 2. The state is the whole mechanism: it is what later callers
336
+ // are refused against, and moving it is the only thing this operation does
337
+ // to the world.
338
+ //
339
+ // **The process identifier is left exactly as it is**, which is what keeps
340
+ // "nothing is stopped and nothing is relaunched" true through this path. A
341
+ // browser that is running stays running and keeps its row's pid; one that
342
+ // is not stays stopped. The table's own check constraint ties `stopped` to
343
+ // a null pid, so this preserves the pid rather than setting it.
344
+ const browserWasRunning = row.pid !== null;
345
+ // **The owner is written in the same statement that moves the state**, so
346
+ // there is no instant at which a browser is `signing-in` with nobody
347
+ // recorded against it. Two statements would leave exactly that window, and
348
+ // a process killed inside it would produce the unrecoverable row this whole
349
+ // mechanism exists to remove — rarely, which is the worst frequency for a
350
+ // defect of this kind.
351
+ const ownerPid = input.ownerPid ?? null;
352
+ // **The request columns are cleared as the owner is written**, so a browser
353
+ // is never held by a process *and* a deadline at once. This path is reached
354
+ // after a lapsed or reclaimed request as well as from a clean state, and a
355
+ // deadline left behind from one would lapse a person's `broker login` out
356
+ // from under them at a moment nothing here chose.
357
+ db.prepare(`UPDATE browsers
358
+ SET state = 'signing-in', signin_owner_pid = @ownerPid,
359
+ signin_deadline = NULL, signin_claim_id = NULL, updated_at = @now
360
+ WHERE id = @id`).run({
361
+ id: browser,
362
+ ownerPid,
363
+ now: swept.sweptAt,
364
+ });
365
+ append(db, {
366
+ kind: 'browser_signin_began',
367
+ outcome: 'allow',
368
+ adapter,
369
+ browserId: browser,
370
+ // Which browser and when, and nothing a person typed. See step six's
371
+ // header: this row is built by hand, and a well-meaning addition is how
372
+ // it would stop being true.
373
+ detail: { browserWasRunning, previousState: row.state },
374
+ });
375
+ return {
376
+ value: {
377
+ browser,
378
+ state: 'signing-in',
379
+ // Relative, per §1.7a. The root is configuration the reader already has.
380
+ profileRelativePath: browser,
381
+ browserWasRunning,
382
+ },
383
+ };
384
+ }
385
+ /**
386
+ * End: give the browser back (§5.5.1 step 4).
387
+ *
388
+ * *"On their confirmation, the browser goes back to `running` and the queue
389
+ * is swept."* The sweep is the runner's, and it has already happened by the
390
+ * time this is called — which is the point: **queued callers kept their
391
+ * places and their timers throughout**, because nothing in this file touches
392
+ * a claim row. A sign-in is a pause, not a cancellation.
393
+ */
394
+ export function decideEndSignIn(scope, input) {
395
+ const { db, adapter, swept } = scope;
396
+ const browser = resolveSignableBrowser(scope, input.browser);
397
+ const row = readBrowser(scope, browser);
398
+ if (row.state !== 'signing-in') {
399
+ scope.recordRefusal({
400
+ kind: 'browser_signin_ended',
401
+ outcome: 'deny',
402
+ guard: SIGN_IN_RULES.serving,
403
+ adapter,
404
+ browserId: browser,
405
+ detail: { state: row.state },
406
+ });
407
+ throw new CallRefusal('browser_unavailable', `The ${browser} browser is not being signed into — it is ${row.state}. Ending a sign-in that never began would move the browser's state on the strength of a call that corresponds to nothing.`, { detail: { browser, state: row.state } });
408
+ }
409
+ // Back to what the pid says it is, rather than to a fixed value. The
410
+ // table's check constraint requires `stopped` to have no process and every
411
+ // other state to have one, so deriving the destination from the pid is what
412
+ // makes this legal for both cases — a browser that was running when the
413
+ // person started, and one that was never up.
414
+ const state = row.pid === null ? 'stopped' : 'running';
415
+ // **The owner is cleared with the state**, for the same reason it was set
416
+ // with it: a browser that is not signing in has no owner, and a stale
417
+ // identifier left behind would be a record of a process that is not holding
418
+ // anything — the kind of leftover a later reader trusts.
419
+ // **All three sign-in columns cleared, not just the owner.** Step ten added
420
+ // a deadline and an asking lease for a *requested* sign-in, and this command
421
+ // path can meet one: `broker login` reclaims a sign-in whose owner is gone,
422
+ // and a request that lapsed leaves rows this same statement has to clean.
423
+ // Leaving them set would record a deadline and a claim against a browser
424
+ // that is serving — exactly the leftover step eight's own header warns a
425
+ // later reader will trust.
426
+ db.prepare(`UPDATE browsers
427
+ SET state = @state, signin_owner_pid = NULL, signin_deadline = NULL,
428
+ signin_claim_id = NULL, updated_at = @now
429
+ WHERE id = @id`).run({
430
+ id: browser,
431
+ state,
432
+ now: swept.sweptAt,
433
+ });
434
+ const queued = db
435
+ .prepare(`SELECT COUNT(*) AS depth FROM claims WHERE state = 'queued'`)
436
+ .get();
437
+ append(db, {
438
+ kind: 'browser_signin_ended',
439
+ outcome: 'allow',
440
+ adapter,
441
+ browserId: browser,
442
+ detail: { state, queueDepth: queued.depth },
443
+ });
444
+ return { value: { browser, state, queueDepth: queued.depth } };
445
+ }
446
+ /**
447
+ * ════════════════════════════════════════════════════════════════════════
448
+ * REQUESTING A SIGN-IN — the operation a caller can actually reach
449
+ * ════════════════════════════════════════════════════════════════════════
450
+ *
451
+ * `SCHEMA.md` §5.5.2, `DECISIONS.md` §13j.
452
+ *
453
+ * ── The gap, stated as the measurement rather than as a feature ─────────
454
+ *
455
+ * The two operations above are complete, and **neither is reachable by an
456
+ * agent**. `begin_sign_in` and `end_sign_in` are called by `broker login`,
457
+ * which is a command a person types. Nothing on the tool surface moves a
458
+ * browser into `signing-in`, so a caller that navigated to a page and got a
459
+ * login form had two moves available: abandon the task, or fabricate a
460
+ * session.
461
+ *
462
+ * §1.2 measured which one they took. **25 sessions in one month hand-seeded
463
+ * authentication tokens into an isolated browser while the signed-in browser
464
+ * sat unused.** That is usually read as a browser-choice failure and §13i
465
+ * treats it as one — but a caller that chose correctly and still hit a login
466
+ * wall was in exactly the same position, and that position had no exit that
467
+ * involved asking. **This is the exit that involves asking.**
468
+ *
469
+ * ── Why this is not `begin_sign_in` with a different caller ─────────────
470
+ *
471
+ * It reuses the state machine and deliberately does not reuse the entry
472
+ * point, because the two differ on the one refusal that makes
473
+ * `begin_sign_in` a service operation at all.
474
+ *
475
+ * §5.5.1 step 1 refuses a sign-in **while any live lease holds a tab on that
476
+ * browser**, and the reason is exact: *"somebody is about to drive the window
477
+ * by hand, and doing that underneath a caller's work would corrupt it."*
478
+ * **The requesting caller is such a lease.** It is holding the very tab
479
+ * sitting on the login page — that is the point of the request, since the
480
+ * person has to sign in where the work already is.
481
+ *
482
+ * So an agent calling `begin_sign_in` is refused by its own request, every
483
+ * time, naming itself as the obstacle. The refusal is not wrong; it is
484
+ * answering a question about a different situation.
485
+ *
486
+ * **The exemption is exactly one lease wide, and that is the design.** The
487
+ * asking lease is skipped and every other live lease still refuses, because
488
+ * the corruption §5.5.1 protects against is real for all of them and is not
489
+ * real for the one whose work the sign-in is for. Expressing this as a flag
490
+ * on the browser — *"this sign-in was requested, so skip the check"* — would
491
+ * exempt all of them, which is why the asking lease's identifier goes into
492
+ * the row (schema step ten) rather than a boolean.
493
+ */
494
+ /**
495
+ * How long a requested sign-in holds the browser before it lapses.
496
+ *
497
+ * ── Why there is a number here when §5.5.1 says there is not ────────────
498
+ *
499
+ * §5.5.1: *"A sign-in has no expiry — a person takes as long as they take,
500
+ * and a timeout would end a sign-in that was going fine."* **That is right
501
+ * for the command and wrong for the request**, and the difference is what is
502
+ * on the other end.
503
+ *
504
+ * `broker login` is a person at a keyboard with a process blocked on them.
505
+ * That process is the evidence somebody is still there, which is why step
506
+ * eight can recover an abandoned sign-in by asking whether it is running.
507
+ *
508
+ * A requested sign-in has **no such process**. The service returns
509
+ * immediately, and the request is relayed onward by the calling agent to a
510
+ * person who may be away from the machine, may not read it for an hour, and
511
+ * may never read it. Nothing on this host can be asked whether they are
512
+ * coming. Left unbounded, one unanswered request holds the browser against
513
+ * every other caller forever — the same unrecoverable state step eight was
514
+ * written to remove, arriving through a door step eight cannot watch.
515
+ *
516
+ * **So the deadline is the evidence-substitute:** no process to interrogate,
517
+ * so a number instead.
518
+ *
519
+ * ── Why fifteen minutes ─────────────────────────────────────────────────
520
+ *
521
+ * It is `BROKER_LEASE_SECONDS` (ten minutes) plus a margin, and the
522
+ * relationship is the reason rather than the roundness. The requesting lease
523
+ * survives the wait by being renewed — every keyed call extends it (§3.1) —
524
+ * so the caller polls throughout. A deadline **shorter** than the lease
525
+ * lifetime would let the sign-in lapse while the caller that asked for it was
526
+ * still healthy and still waiting, which is the confusing failure: the agent
527
+ * is told to keep waiting by its own live lease and told the request is gone
528
+ * by the browser. A deadline longer than the lease gives the caller room to
529
+ * notice its own expiry first, which is the failure it can explain.
530
+ *
531
+ * **What it is not:** a promise that a person answers within fifteen minutes,
532
+ * and it is not tuned to human behaviour at all. It bounds how long an
533
+ * *unanswered* request may cost every other caller, and the cost of getting
534
+ * it wrong in the generous direction is a browser nobody can use. A person
535
+ * who arrives late asks the agent to request again; nothing is lost but the
536
+ * request.
537
+ */
538
+ export const SIGN_IN_REQUEST_SECONDS = 900;
539
+ /**
540
+ * How close to the deadline a caller is told to check back.
541
+ *
542
+ * The same shape `checkBackSeconds` gives a queued caller, and for the same
543
+ * reason: *"a check made exactly at the deadline races the reclamation and
544
+ * loses about half the time."*
545
+ */
546
+ export const SIGN_IN_REQUEST_CHECK_BACK_SECONDS = 30;
547
+ /**
548
+ * The bounds on what a caller says is being signed into.
549
+ *
550
+ * The same three-to-two-hundred bound `claims.purpose` carries (§1.3), and
551
+ * deliberately the same numbers rather than new ones: it is the same kind of
552
+ * field — a short human sentence a person reads to decide what to do — and a
553
+ * second set of limits would be a second thing to remember for no gain.
554
+ */
555
+ export const SIGN_IN_WHAT_MIN = 3;
556
+ export const SIGN_IN_WHAT_MAX = 200;
557
+ /**
558
+ * What the calling agent says to the person, and what it does next.
559
+ *
560
+ * ── Why the service writes this sentence at all ─────────────────────────
561
+ *
562
+ * **The service never speaks to a person; the calling agent does.** This is
563
+ * the only place in the design where that indirection matters, because
564
+ * everywhere else the audience for a message is whoever made the call. Here
565
+ * the message has to survive being read by an agent and repeated to a human
566
+ * who has none of the context — so it names the browser, says what is being
567
+ * signed into, says the tab is already open and waiting, and says the person
568
+ * should confirm when they are done.
569
+ *
570
+ * Assembled here rather than at each surface for the reason
571
+ * `SIGN_IN_OWNER_UNKNOWN_REMEDY` is: two spellings of one instruction is how
572
+ * they come to disagree, and the disagreement is found by somebody who is
573
+ * already stuck.
574
+ *
575
+ * **It is not a refusal message and is not worded like one.** §3.14 says
576
+ * refusal sentences are worded per transport and never compared between them;
577
+ * this is a *result* field, identical on every surface, and a caller relays
578
+ * it rather than reading it.
579
+ */
580
+ export function relaySentence(options) {
581
+ const minutes = Math.round(options.requestSeconds / 60);
582
+ return (`Tell the person: the ${options.browser} browser has a tab open on the sign-in page for ${options.what} — ` +
583
+ `please sign in there, then say when you are done. The tab is already on the page, so nothing needs opening. ` +
584
+ `While you wait, keep calling browser_status to hold your lease; your lease and your tab are untouched by the sign-in. ` +
585
+ `Call browser_sign_in_done once they confirm. If nobody answers within about ${String(minutes)} minutes the request lapses ` +
586
+ `and the browser serves other callers again — ask again if that happens.`);
587
+ }
588
+ /**
589
+ * Ask a person to sign in, on the tab this lease already holds.
590
+ *
591
+ * **Keyed, unlike the two operations above**, and that is what makes the
592
+ * exemption expressible: the key resolves to exactly one lease, so the
593
+ * operation knows which lease is asking and can protect every other one.
594
+ */
595
+ export function decideRequestSignIn(scope, input) {
596
+ const { db, adapter, swept } = scope;
597
+ // The lease first, and the renewal with it. **Before any other check**, for
598
+ // the reason `claim.ts` gives about the position of its own refusals: a
599
+ // caller whose key is wrong should hear about the key, not about a browser
600
+ // it was never going to be allowed to touch. Renewing first also means a
601
+ // caller refused for any reason below still had its lease extended by the
602
+ // call — §3.1's rule that there is no keyed call that does not extend, and
603
+ // the rule that keeps a caller from expiring while being told why it cannot
604
+ // have something.
605
+ const lease = resolveLease(db, input.key, {
606
+ adapter,
607
+ kind: 'claim_renewed',
608
+ recordRefusal: scope.recordRefusal,
609
+ });
610
+ const leaseExpiresAt = extendLease(db, lease, { adapter, now: swept.sweptAt });
611
+ // The sentence a person will read, bounded exactly as a purpose is (§1.3).
612
+ // Checked here rather than left to a column, because **there is no column**:
613
+ // a request is not a row, so nothing downstream would refuse an empty one
614
+ // and a person would be handed a window with no idea what it is for.
615
+ const what = typeof input.what === 'string' ? input.what.trim() : '';
616
+ if (what.length < SIGN_IN_WHAT_MIN || what.length > SIGN_IN_WHAT_MAX) {
617
+ scope.recordRefusal({
618
+ kind: 'browser_signin_began',
619
+ outcome: 'deny',
620
+ guard: SIGN_IN_RULES.whatBounded,
621
+ adapter,
622
+ sessionId: lease.sessionId,
623
+ browserId: lease.browserId,
624
+ detail: { length: what.length },
625
+ });
626
+ throw new CallRefusal('sign_in_what_out_of_bounds', `A sign-in request says what is being signed into, ${String(SIGN_IN_WHAT_MIN)} to ${String(SIGN_IN_WHAT_MAX)} characters — it is relayed to a person verbatim and is the only thing telling them which sign-in wall this is. Name the site or the account rather than the task: "the account dashboard" rather than "step three".`, { detail: { length: what.length } });
627
+ }
628
+ // A queued lease has never had a tab (§1.3), so it has no page to be stuck
629
+ // on. Refused rather than allowed to move a browser it is not yet using.
630
+ //
631
+ // ── This check is redundant in this build, and is kept anyway ──────────
632
+ //
633
+ // **Measured rather than assumed**, by deleting it and watching the suite
634
+ // stay green: a queued lease reaches the tab lookup below, finds nothing
635
+ // open, and is refused there — under the same rule, with a different code.
636
+ // So no reachable state separates the two, and a mutation removing this
637
+ // branch survives.
638
+ //
639
+ // It is kept for the reason `decideBeginSignIn`'s holder query gives about
640
+ // its own surviving condition: **the redundancy is a property of this build
641
+ // rather than of the design.** The two refusals answer different questions —
642
+ // *you are not holding anything yet* against *the thing you were holding is
643
+ // gone* — and they send a caller to different next actions: wait for the
644
+ // queue, or replace a wedged tab. Collapsing them because they presently
645
+ // coincide would give a queued caller advice about a tab it never had.
646
+ //
647
+ // Written down rather than left as a surviving mutation somebody
648
+ // rediscovers: the branch is deliberate, it is not independently covered,
649
+ // and the reason it is not covered is that the product cannot produce a
650
+ // state that separates it from the one below.
651
+ if (lease.state !== 'active') {
652
+ scope.recordRefusal({
653
+ kind: 'browser_signin_began',
654
+ outcome: 'deny',
655
+ guard: SIGN_IN_RULES.requesterHoldsTab,
656
+ adapter,
657
+ sessionId: lease.sessionId,
658
+ browserId: lease.browserId,
659
+ detail: { state: lease.state },
660
+ });
661
+ throw new CallRefusal('browser_unavailable', 'That lease is queued rather than active, so it holds no tab and there is no page for anybody to sign in on. Poll browser_status until it turns active, open the page that wants a sign-in, and ask then.', { detail: { state: lease.state } });
662
+ }
663
+ const browser = resolveSignableBrowser(scope, lease.browserId);
664
+ const row = readBrowser(scope, browser);
665
+ // The tab this lease holds. **Read rather than taken from the caller**, for
666
+ // the reason `bridge.ts` gives about `tabForKey`: a lease is one tab (§2.3),
667
+ // so the tab is a fact about the lease, and a caller naming one could only
668
+ // ever be naming a different one.
669
+ const tab = db
670
+ .prepare(`SELECT id AS tabId FROM tabs
671
+ WHERE claim_id = @claimId AND state IN ('opening', 'open')
672
+ ORDER BY id LIMIT 1`)
673
+ .get({ claimId: lease.claimId });
674
+ if (tab === undefined) {
675
+ // Active with no open tab. The note on `decideBeginSignIn`'s holder query
676
+ // records that this pair does not come apart on any path this build can
677
+ // reach — so this is the honest refusal for a state that should not occur,
678
+ // rather than a branch with a scenario behind it, and it refuses rather
679
+ // than handing a person a window with no page in it.
680
+ scope.recordRefusal({
681
+ kind: 'browser_signin_began',
682
+ outcome: 'deny',
683
+ guard: SIGN_IN_RULES.requesterHoldsTab,
684
+ adapter,
685
+ sessionId: lease.sessionId,
686
+ browserId: browser,
687
+ detail: { claimId: lease.claimId, state: lease.state },
688
+ });
689
+ throw new CallRefusal('tab_not_found', 'That lease is active but holds no open tab, so there is no page to sign in on. Call browser_tab_replace to take a fresh one, open the page that wants a sign-in, and ask again.', { detail: { claimId: lease.claimId } });
690
+ }
691
+ // ── Already signing in ────────────────────────────────────────────────
692
+ //
693
+ // Refused, and **not reclaimed here even when the deadline has passed**.
694
+ // The lapse is the sweep's job, and the sweep has already run by the time
695
+ // this handler executes — so a browser still reading `signing-in` here is
696
+ // one whose sign-in is genuinely live, and reclaiming it would take a
697
+ // window out from under whoever has it.
698
+ if (row.state === 'signing-in') {
699
+ scope.recordRefusal({
700
+ kind: 'browser_signin_began',
701
+ outcome: 'deny',
702
+ guard: SIGN_IN_RULES.serving,
703
+ adapter,
704
+ sessionId: lease.sessionId,
705
+ browserId: browser,
706
+ detail: { state: row.state, requestedBy: row.signin_claim_id },
707
+ });
708
+ throw new CallRefusal('browser_unavailable', row.signin_claim_id === lease.claimId
709
+ ? `You have already asked for a sign-in on the ${browser} browser and it is still open. Keep calling browser_status to hold your lease, and tell the person the tab is waiting for them — asking twice does not reach them twice.`
710
+ : `The ${browser} browser is already being signed into, so a second person cannot be handed the same window. This is a pause rather than a fault: it serves again as soon as they are finished. Keep calling browser_status to hold your lease, and ask again once it is serving.`, { detail: { browser, state: row.state } });
711
+ }
712
+ // ── §5.5.1 step 1, with the requester exempted ────────────────────────
713
+ //
714
+ // **The same query as `decideBeginSignIn`'s, minus this one lease.** It is
715
+ // written out rather than shared with that function, and the duplication is
716
+ // deliberate: the two ask different questions, and a shared helper taking
717
+ // an "except this one" argument would make the exemption look like a
718
+ // parameter of the original rule rather than a second rule. The original
719
+ // must keep refusing every live lease without exception, because a person
720
+ // typing `broker login` holds no lease and is exempt from nothing.
721
+ const holders = db
722
+ .prepare(`SELECT c.id AS claimId, c.session_id AS sessionId, c.purpose AS purpose,
723
+ c.expires_at AS expiresAt
724
+ FROM claims c
725
+ JOIN tabs t ON t.claim_id = c.id
726
+ WHERE c.browser_id = @browserId
727
+ AND c.state = 'active'
728
+ AND t.state IN ('opening', 'open')
729
+ AND c.id != @claimId
730
+ ORDER BY c.id`)
731
+ .all({ browserId: browser, claimId: lease.claimId });
732
+ if (holders.length > 0) {
733
+ scope.recordRefusal({
734
+ kind: 'browser_signin_began',
735
+ outcome: 'deny',
736
+ guard: SIGN_IN_RULES.busyForLogin,
737
+ adapter,
738
+ sessionId: lease.sessionId,
739
+ browserId: browser,
740
+ // The same detail shape `decideBeginSignIn` records, and deliberately
741
+ // never the keys (§5.6).
742
+ detail: {
743
+ holders: holders.map((holder) => ({
744
+ claimId: holder.claimId,
745
+ sessionId: holder.sessionId,
746
+ purpose: holder.purpose,
747
+ expiresAt: holder.expiresAt,
748
+ })),
749
+ },
750
+ });
751
+ const described = holders
752
+ .map((holder) => `${holder.claimId} (session ${holder.sessionId}, ${holder.purpose}, until ${holder.expiresAt})`)
753
+ .join('; ');
754
+ throw new CallRefusal('browser_unavailable', `The ${browser} browser has ${String(holders.length)} other live lease(s) holding a tab, so a person cannot be handed the window yet: driving it by hand underneath somebody else's work would corrupt it. Your own lease is not the obstacle and is untouched. Waiting is enough — every lease expires on its own if its holder stops calling in. Holding: ${described}`, { detail: { browser, holders: [...holders] } });
755
+ }
756
+ // ── The request is granted ────────────────────────────────────────────
757
+ //
758
+ // The state moves and **nothing about the asking lease moves with it**. No
759
+ // claim row is touched beyond the renewal above, no tab is closed, no
760
+ // capacity is returned. That is §5.5.1's *"a sign-in is a pause and not a
761
+ // cancellation"* extended to the requester — the property most likely to be
762
+ // got wrong here, because a caller that lost its work by asking for help is
763
+ // a caller that never asks again.
764
+ // Clamped, never widened. See {@link RequestSignInInput.requestSeconds} for
765
+ // why this is a clamp rather than a refusal, and why the ceiling is not a
766
+ // caller's to move.
767
+ const asked = input.requestSeconds;
768
+ const requestSeconds = typeof asked === 'number' && Number.isFinite(asked) && asked > 0
769
+ ? Math.min(Math.floor(asked), SIGN_IN_REQUEST_SECONDS)
770
+ : SIGN_IN_REQUEST_SECONDS;
771
+ const deadlineRow = db
772
+ .prepare(`UPDATE browsers
773
+ SET state = 'signing-in',
774
+ signin_owner_pid = NULL,
775
+ signin_deadline = strftime('%Y-%m-%dT%H:%M:%fZ', @now, @extend),
776
+ signin_claim_id = @claimId,
777
+ updated_at = @now
778
+ WHERE id = @id
779
+ RETURNING signin_deadline AS deadline`)
780
+ .get({
781
+ id: browser,
782
+ now: swept.sweptAt,
783
+ claimId: lease.claimId,
784
+ // Assembled from a number this build owns, never from caller text.
785
+ extend: `+${String(requestSeconds)} seconds`,
786
+ });
787
+ append(db, {
788
+ kind: 'browser_signin_began',
789
+ outcome: 'allow',
790
+ adapter,
791
+ sessionId: lease.sessionId,
792
+ browserId: browser,
793
+ // **Which lease asked and when it lapses, and nothing a person typed.**
794
+ // `what` is deliberately absent: it is free text from a caller, it is
795
+ // relayed to a person rather than acted on, and step six's header is
796
+ // explicit that this row is built by hand and that a well-meaning
797
+ // addition is how it stops being true.
798
+ detail: {
799
+ requested: true,
800
+ claimId: lease.claimId,
801
+ deadline: deadlineRow.deadline,
802
+ previousState: row.state,
803
+ browserWasRunning: row.pid !== null,
804
+ },
805
+ });
806
+ return {
807
+ value: {
808
+ browser,
809
+ state: 'signing-in',
810
+ claimId: lease.claimId,
811
+ tabId: tab.tabId,
812
+ what,
813
+ deadline: deadlineRow.deadline,
814
+ requestSeconds,
815
+ leaseExpiresAt,
816
+ checkBackSeconds: SIGN_IN_REQUEST_CHECK_BACK_SECONDS,
817
+ relay: relaySentence({ browser, what, requestSeconds }),
818
+ },
819
+ };
820
+ }
821
+ /**
822
+ * The person is done — give the browser back, keeping the lease.
823
+ *
824
+ * ── Why this is not `end_sign_in` ───────────────────────────────────────
825
+ *
826
+ * `end_sign_in` takes a browser name and no key, because the thing calling it
827
+ * is a command a person ran and there is no lease in the picture. Exposing
828
+ * *that* on the tool surface would hand every caller a verb that ends
829
+ * **somebody else's** sign-in by naming a browser — including a person's
830
+ * `broker login`, mid-password.
831
+ *
832
+ * So the caller-facing half is keyed, and the key must be the one that asked.
833
+ * **This is the same reasoning §3.13 gives for there being no browser-scoped
834
+ * destructive verb on the surface**: the worst thing an agent can do through
835
+ * this surface is give back something it asked for itself.
836
+ */
837
+ export function decideFinishSignIn(scope, input) {
838
+ const { db, adapter, swept } = scope;
839
+ const lease = resolveLease(db, input.key, {
840
+ adapter,
841
+ kind: 'claim_renewed',
842
+ recordRefusal: scope.recordRefusal,
843
+ });
844
+ const leaseExpiresAt = extendLease(db, lease, { adapter, now: swept.sweptAt });
845
+ const browser = resolveSignableBrowser(scope, lease.browserId);
846
+ const row = readBrowser(scope, browser);
847
+ if (row.state !== 'signing-in') {
848
+ scope.recordRefusal({
849
+ kind: 'browser_signin_ended',
850
+ outcome: 'deny',
851
+ guard: SIGN_IN_RULES.serving,
852
+ adapter,
853
+ sessionId: lease.sessionId,
854
+ browserId: browser,
855
+ detail: { state: row.state },
856
+ });
857
+ throw new CallRefusal('browser_unavailable', `The ${browser} browser is not being signed into — it is ${row.state}. Your request may have lapsed while nobody answered it, in which case the browser is already serving and your lease and tab are untouched: look at the page, and ask again if it is still showing a sign-in wall.`, { detail: { browser, state: row.state } });
858
+ }
859
+ // **Only the lease that asked may finish it**, which is the whole reason
860
+ // this operation is keyed. A different lease calling this would be ending a
861
+ // sign-in it did not ask for — and a sign-in begun by `broker login` records
862
+ // no claim at all, so no lease can end one and a person's command keeps the
863
+ // window until they close it.
864
+ if (row.signin_claim_id !== lease.claimId) {
865
+ scope.recordRefusal({
866
+ kind: 'browser_signin_ended',
867
+ outcome: 'deny',
868
+ guard: SIGN_IN_RULES.finishOwned,
869
+ adapter,
870
+ sessionId: lease.sessionId,
871
+ browserId: browser,
872
+ detail: { requestedBy: row.signin_claim_id, asking: lease.claimId },
873
+ });
874
+ throw new CallRefusal('browser_unavailable', row.signin_claim_id === null
875
+ ? `The ${browser} browser is being signed into by a person who ran the sign-in command directly, so there is no request of yours to finish — and ending theirs from here would take the window out from under them mid-password. Keep calling browser_status to hold your lease; it serves again when they close it.`
876
+ : `The ${browser} browser is being signed into at another lease's request, so it is not yours to finish. Keep calling browser_status to hold your lease, and try your page again once it is serving.`, { detail: { browser } });
877
+ }
878
+ const tab = db
879
+ .prepare(`SELECT id AS tabId FROM tabs
880
+ WHERE claim_id = @claimId AND state IN ('opening', 'open')
881
+ ORDER BY id LIMIT 1`)
882
+ .get({ claimId: lease.claimId });
883
+ // Back to what the pid says it is, exactly as `decideEndSignIn` does and for
884
+ // the same constraint: `stopped` requires a null pid and every other state
885
+ // requires one.
886
+ const state = row.pid === null ? 'stopped' : 'running';
887
+ // **All three sign-in columns cleared together.** A browser that is not
888
+ // signing in has no owner, no deadline and no asking lease, and a stale
889
+ // value in any of them is a leftover a later reader trusts.
890
+ db.prepare(`UPDATE browsers
891
+ SET state = @state, signin_owner_pid = NULL, signin_deadline = NULL,
892
+ signin_claim_id = NULL, updated_at = @now
893
+ WHERE id = @id`).run({ id: browser, state, now: swept.sweptAt });
894
+ const queued = db
895
+ .prepare(`SELECT COUNT(*) AS depth FROM claims WHERE state = 'queued'`)
896
+ .get();
897
+ append(db, {
898
+ kind: 'browser_signin_ended',
899
+ outcome: 'allow',
900
+ adapter,
901
+ sessionId: lease.sessionId,
902
+ browserId: browser,
903
+ detail: { state, queueDepth: queued.depth, claimId: lease.claimId, confirmed: true },
904
+ });
905
+ return {
906
+ value: {
907
+ browser,
908
+ state,
909
+ claimId: lease.claimId,
910
+ tabId: tab?.tabId ?? '',
911
+ leaseExpiresAt,
912
+ queueDepth: queued.depth,
913
+ },
914
+ };
915
+ }