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,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
|
+
}
|