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,616 @@
1
+ import { ARTIFACT_COLLECTION, PAGE_ACTIONS, READ_ARTIFACTS, } from "../browser/driver.js";
2
+ import { BrokerError } from "../errors.js";
3
+ /**
4
+ * The page verbs: what a caller may ask of a tab it owns, and every way that
5
+ * asking is refused (rows #22, #61, #62, #63, #64, #23, #24).
6
+ *
7
+ * โ”€โ”€ What is in this file and what is deliberately not โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
8
+ *
9
+ * Everything here is **argument validation and shaping**. Nothing in this
10
+ * file opens a transaction, reads the store, or talks to a browser, and that
11
+ * is a boundary rather than a coincidence:
12
+ *
13
+ * - Ownership โ€” ยง7.1 `tab.owned` and `tab.open` โ€” is answered in `tabs.ts`,
14
+ * against the store, by a query selecting on both the tab and its lease.
15
+ * Validating an argument cannot establish who owns anything.
16
+ * - Browser work happens after the arbitration transaction commits (ยง2.4b).
17
+ * A validator that could reach a browser is a validator that will
18
+ * eventually be called from inside one.
19
+ *
20
+ * So the functions here take a caller's arguments and either return the
21
+ * typed request the driver seam declares, or throw. **They are the step that
22
+ * turns `unknown` into `ActionRequest`** โ€” the seam's own note says a cast at
23
+ * the boundary makes its union decorative, and this is the code that makes
24
+ * the cast unnecessary.
25
+ *
26
+ * โ”€โ”€ Refusals are the specification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
27
+ *
28
+ * `SCHEMA.md` ยง7's opening line โ€” "a rule that never refuses anything
29
+ * protects nothing, so the refusals are the specification" โ€” is why this file
30
+ * is mostly refusals and why each one carries the ยง7 rule that produced it.
31
+ */
32
+ /**
33
+ * A refusal from one of these operations.
34
+ *
35
+ * **Deliberately not the service layer's `CallRefusal`.** That taxonomy is a
36
+ * closed table of codes owned by the arbitration row, and its own comment
37
+ * states the discipline it keeps: "a code nothing can raise is exactly the
38
+ * assertion-over-an-empty-set that passes forever and silently โ€” add the row
39
+ * with the guard, never before it." This module is the guards; the codes for
40
+ * them are added to that table when the two are wired together, and inventing
41
+ * them here would either duplicate the table or edit another row's file.
42
+ *
43
+ * What is carried now is the part that must not be lost in the meantime: the
44
+ * ยง7 rule name, which is what the ledger is grepped by and what ยง8's parity
45
+ * assertion counts over.
46
+ */
47
+ export class PageRefusal extends BrokerError {
48
+ /** ยง3.14's "any details" โ€” the numbers a caller branches on after the rule. */
49
+ detail;
50
+ constructor(rule, message, detail = {}) {
51
+ super(rule, message);
52
+ this.name = 'PageRefusal';
53
+ this.detail = detail;
54
+ }
55
+ }
56
+ /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ navigate (#22) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
57
+ /**
58
+ * The address schemes a navigation may use.
59
+ *
60
+ * **An allowlist, and the shape of the rule is the rule.** ยง7.1
61
+ * `navigate.scheme_allowed` requires "ordinary web traffic or a blank page",
62
+ * and a denylist of the schemes to refuse would be wrong in the direction
63
+ * that matters: every scheme nobody thought of would be permitted, and
64
+ * browsers carry a great many. The refusal ยง3.7 names explicitly is the local
65
+ * file, because it "turns a browser lease into an arbitrary read of the
66
+ * machine's filesystem, which no part of this contract intends to grant" โ€”
67
+ * but it is refused here by not being on this list, not by being matched.
68
+ */
69
+ const ALLOWED_SCHEMES = ['http:', 'https:'];
70
+ /** The one address that is not a scheme match: a deliberately blank page. */
71
+ const BLANK_PAGE = 'about:blank';
72
+ /**
73
+ * Check an address before anything navigates to it.
74
+ *
75
+ * Returns the address to navigate to. Refuses anything that is not ordinary
76
+ * web traffic or a blank page.
77
+ */
78
+ export function validateNavigationTarget(url) {
79
+ if (typeof url !== 'string' || url.trim() === '') {
80
+ throw new PageRefusal('navigate.scheme_allowed', 'A navigation needs an address: ordinary web traffic, or a blank page.', { allowedSchemes: [...ALLOWED_SCHEMES], blankPage: BLANK_PAGE });
81
+ }
82
+ const candidate = url.trim();
83
+ if (candidate === BLANK_PAGE)
84
+ return candidate;
85
+ let parsed;
86
+ try {
87
+ parsed = new URL(candidate);
88
+ }
89
+ catch {
90
+ throw new PageRefusal('navigate.scheme_allowed', `That is not an address this service can navigate to. Use ordinary web traffic (${ALLOWED_SCHEMES.join(', ')}) or ${BLANK_PAGE}.`, { allowedSchemes: [...ALLOWED_SCHEMES], blankPage: BLANK_PAGE });
91
+ }
92
+ if (!ALLOWED_SCHEMES.includes(parsed.protocol)) {
93
+ // The local-file case lands here, and ยง3.7 refuses it specifically. It is
94
+ // named in the sentence rather than matched in the condition: matching it
95
+ // would suggest the other schemes are fine, and the allowlist above is
96
+ // what actually decides.
97
+ throw new PageRefusal('navigate.scheme_allowed', `The address uses "${parsed.protocol}", which this service does not navigate to. Ordinary web traffic (${ALLOWED_SCHEMES.join(', ')}) or ${BLANK_PAGE} only โ€” a local-file address in particular would turn a browser lease into a read of this machine's filesystem.`, { scheme: parsed.protocol, allowedSchemes: [...ALLOWED_SCHEMES], blankPage: BLANK_PAGE });
98
+ }
99
+ return candidate;
100
+ }
101
+ /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ act (#22, #61โ€“#64) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
102
+ /**
103
+ * The refusal that **lists every action by name**.
104
+ *
105
+ * `SCHEMA.md` ยง3.8: "Refused for an action that is not on the list, **listing
106
+ * every action** โ€” the discoverability cost of folding them into one tool is
107
+ * paid back here or not at all."
108
+ *
109
+ * That last clause is the whole argument and it is worth keeping in front of
110
+ * whoever edits this. Rows #61 to #64 each fold a capability into
111
+ * `browser_act` instead of adding a tool, on the grounds that each is
112
+ * tab-scoped, non-destructive, invisible to other callers and leaves nothing
113
+ * to recover from. **What folding costs is discoverability**: a caller
114
+ * reading a list of tools sees one entry where it would have seen five. This
115
+ * refusal is the entire repayment. A refusal that said "unknown action" would
116
+ * take the saving and default on the debt.
117
+ *
118
+ * The list comes from {@link PAGE_ACTIONS} rather than being written out
119
+ * again here, so a row adding a verb cannot add it to the union and forget
120
+ * the refusal โ€” there is one place, which is what the seam's comment says the
121
+ * closed union is for.
122
+ */
123
+ export function refuseUnknownAction(action) {
124
+ throw new PageRefusal('act.action_known', `There is no "${String(action)}" action. Every action this service performs: ${PAGE_ACTIONS.join(', ')}.`, { action, actions: [...PAGE_ACTIONS] });
125
+ }
126
+ /** Whether a string is one of the verbs. */
127
+ export function isPageAction(action) {
128
+ return typeof action === 'string' && PAGE_ACTIONS.includes(action);
129
+ }
130
+ /** An element reference, refused when it is absent or empty. */
131
+ function requireRef(value, field, action) {
132
+ if (typeof value !== 'string' || value.trim() === '') {
133
+ throw new PageRefusal('act.ref_required', `The "${action}" action addresses an element, so it needs ${field} โ€” a reference taken from the tab's most recent snapshot.`, { action, field });
134
+ }
135
+ return value;
136
+ }
137
+ /** A value, refused when the action needs one and it is absent. */
138
+ function requireValue(value, action) {
139
+ if (typeof value !== 'string') {
140
+ throw new PageRefusal('act.value_required', `The "${action}" action needs a value to apply.`, {
141
+ action,
142
+ });
143
+ }
144
+ return value;
145
+ }
146
+ /**
147
+ * The largest viewport this service will set, per side.
148
+ *
149
+ * A bound rather than none, and the reason is not politeness: a viewport is
150
+ * allocated, so an unbounded one is a memory request from a caller that costs
151
+ * the machine rather than the caller. The number is generous enough that no
152
+ * real review hits it โ€” well past the largest ordinary display โ€” which is the
153
+ * property that makes it a guard against a mistake rather than a limit
154
+ * anybody has to plan around.
155
+ */
156
+ export const MAX_VIEWPORT_SIDE = 16384;
157
+ /** A viewport side: a positive whole number within the bound. */
158
+ function requireViewportSide(value, side) {
159
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
160
+ throw new PageRefusal('act.viewport_bounded', `A viewport ${side} is a whole number of pixels greater than zero.`, { side, value });
161
+ }
162
+ if (value > MAX_VIEWPORT_SIDE) {
163
+ throw new PageRefusal('act.viewport_bounded', `A viewport ${side} of ${String(value)} is past this service's bound of ${String(MAX_VIEWPORT_SIDE)}.`, { side, value, maximum: MAX_VIEWPORT_SIDE });
164
+ }
165
+ return value;
166
+ }
167
+ /** The values each media preference accepts (ยง3.8's table). */
168
+ const MEDIA_PREFERENCES = {
169
+ colourScheme: ['light', 'dark', 'no-preference'],
170
+ reducedMotion: ['reduce', 'no-preference'],
171
+ forcedColours: ['active', 'none'],
172
+ };
173
+ /** The preferences by name, for a refusal that lists them. */
174
+ export const MEDIA_PREFERENCE_NAMES = Object.keys(MEDIA_PREFERENCES);
175
+ /**
176
+ * The most fields one batch fill may carry.
177
+ *
178
+ * Batch fill exists because 78 measured calls across 35 sessions filled
179
+ * several fields in a row (#64); it is not a bulk-data channel, and a bound
180
+ * is what keeps the second reading from becoming available by accident.
181
+ */
182
+ export const MAX_FORM_FIELDS = 64;
183
+ /**
184
+ * Turn a caller's arguments into the typed request the driver takes, or
185
+ * refuse.
186
+ *
187
+ * **The input is `unknown` because that is what a caller's arguments are.**
188
+ * The seam's own note is explicit that a cast at the boundary makes its union
189
+ * decorative โ€” the compiler would then be checking a claim this function made
190
+ * up rather than a fact it established. So every field is examined.
191
+ */
192
+ export function validateAction(raw) {
193
+ if (typeof raw !== 'object' || raw === null) {
194
+ throw new PageRefusal('act.action_known', 'An action names what to do and what to do it to.', {
195
+ actions: [...PAGE_ACTIONS],
196
+ });
197
+ }
198
+ const input = raw;
199
+ const action = input.action;
200
+ if (!isPageAction(action))
201
+ refuseUnknownAction(action);
202
+ switch (action) {
203
+ case 'click':
204
+ case 'hover':
205
+ case 'check':
206
+ return { action, ref: requireRef(input.ref, 'an element reference', action) };
207
+ case 'type':
208
+ case 'fill':
209
+ case 'select':
210
+ return {
211
+ action,
212
+ ref: requireRef(input.ref, 'an element reference', action),
213
+ value: requireValue(input.value, action),
214
+ };
215
+ case 'press':
216
+ // The reference is optional: a press with none goes to whatever the
217
+ // page has focused, which is how a caller sends a key to a page rather
218
+ // than to a particular field.
219
+ return {
220
+ action,
221
+ ...(input.ref === undefined
222
+ ? {}
223
+ : { ref: requireRef(input.ref, 'an element reference', action) }),
224
+ value: requireValue(input.value, action),
225
+ };
226
+ case 'scroll':
227
+ return {
228
+ action,
229
+ ...(input.ref === undefined
230
+ ? {}
231
+ : { ref: requireRef(input.ref, 'an element reference', action) }),
232
+ };
233
+ case 'resize': {
234
+ // #61. Two integers, not a string to be re-parsed โ€” see `Viewport` on
235
+ // the seam for why that shape is the one that carries meaning.
236
+ const viewport = input.viewport;
237
+ if (typeof viewport !== 'object' || viewport === null) {
238
+ // **The syntax, not only the semantics.** The previous wording said
239
+ // what a resize needs and never how to write it, so a caller who had
240
+ // supplied a width and a height in pixels โ€” in one of five reasonable
241
+ // spellings โ€” read a message telling them to supply a width and a
242
+ // height in pixels. There was no way to converge by guessing, and the
243
+ // session that hit it stopped after five attempts. An example ends
244
+ // that in one call.
245
+ throw new PageRefusal('act.viewport_bounded', "A resize sets the tab's viewport, so it needs a width and a height in pixels: " +
246
+ '`--width 390 --height 844`, or `--value 390x844`.', { action });
247
+ }
248
+ const { width, height } = viewport;
249
+ return {
250
+ action,
251
+ viewport: {
252
+ width: requireViewportSide(width, 'width'),
253
+ height: requireViewportSide(height, 'height'),
254
+ },
255
+ };
256
+ }
257
+ case 'emulate': {
258
+ // #62. Every preference is optional independently โ€” a caller switching
259
+ // to dark mode says nothing about motion or contrast โ€” but an emulate
260
+ // naming none of them is a call that means nothing, and that is the
261
+ // refusal.
262
+ const preferences = input.preferences;
263
+ if (typeof preferences !== 'object' || preferences === null) {
264
+ // The names were always here, and they were the good half of this
265
+ // message. What was missing is how to write one โ€” see the resize
266
+ // refusal above for the same fix and the same reason.
267
+ throw new PageRefusal('act.emulate_preference_named', `An emulate sets media preferences, so it names at least one of: ${MEDIA_PREFERENCE_NAMES.join(', ')}. ` +
268
+ 'For example `--colour-scheme dark`.', { action, preferences: MEDIA_PREFERENCE_NAMES });
269
+ }
270
+ const supplied = preferences;
271
+ const validated = {};
272
+ for (const name of MEDIA_PREFERENCE_NAMES) {
273
+ const value = supplied[name];
274
+ if (value === undefined)
275
+ continue;
276
+ const allowed = MEDIA_PREFERENCES[name];
277
+ if (typeof value !== 'string' || !allowed.includes(value)) {
278
+ throw new PageRefusal('act.emulate_preference_named', `"${name}" is one of: ${allowed.join(', ')}.`, { action, preference: name, allowed: [...allowed], value });
279
+ }
280
+ validated[name] = value;
281
+ }
282
+ if (Object.keys(validated).length === 0) {
283
+ throw new PageRefusal('act.emulate_preference_named', `An emulate names at least one preference to set: ${MEDIA_PREFERENCE_NAMES.join(', ')}. ` +
284
+ 'For example `--colour-scheme dark`.', { action, preferences: MEDIA_PREFERENCE_NAMES });
285
+ }
286
+ return { action, preferences: validated };
287
+ }
288
+ case 'dialog': {
289
+ // #63. Here on consequence rather than frequency: an unhandled dialog
290
+ // blocks its tab, so the caller holds a lease it cannot use and burns
291
+ // it.
292
+ const response = input.response;
293
+ if (typeof response !== 'object' || response === null) {
294
+ throw new PageRefusal('act.dialog_answer_named', 'Answering a dialog says whether to accept it or dismiss it.', { action });
295
+ }
296
+ const { accept, promptText } = response;
297
+ if (typeof accept !== 'boolean') {
298
+ throw new PageRefusal('act.dialog_answer_named', 'Answering a dialog says whether to accept it or dismiss it.', { action });
299
+ }
300
+ if (promptText !== undefined && typeof promptText !== 'string') {
301
+ throw new PageRefusal('act.dialog_answer_named', "A dialog's prompt text is text.", {
302
+ action,
303
+ });
304
+ }
305
+ if (promptText !== undefined && !accept) {
306
+ // Text plus a dismissal describes two intentions at once, and
307
+ // guessing which one was meant is how a caller ends up believing it
308
+ // answered a prompt it actually threw away.
309
+ throw new PageRefusal('act.dialog_answer_named', 'Prompt text is what to type before accepting, so it cannot accompany a dismissal. Accept the dialog, or dismiss it without text.', { action });
310
+ }
311
+ return {
312
+ action,
313
+ response: { accept, ...(promptText === undefined ? {} : { promptText }) },
314
+ };
315
+ }
316
+ case 'fill_form': {
317
+ // #64, the measured half: 78 calls across 35 sessions.
318
+ const fields = input.fields;
319
+ if (!Array.isArray(fields) || fields.length === 0) {
320
+ throw new PageRefusal('act.form_fields_bounded', 'A batch fill needs at least one field to fill, each with an element reference and a value.', { action, maximum: MAX_FORM_FIELDS });
321
+ }
322
+ if (fields.length > MAX_FORM_FIELDS) {
323
+ throw new PageRefusal('act.form_fields_bounded', `A batch fill carries at most ${String(MAX_FORM_FIELDS)} fields, and this one carries ${String(fields.length)}.`, { action, count: fields.length, maximum: MAX_FORM_FIELDS });
324
+ }
325
+ return {
326
+ action,
327
+ fields: fields.map((field, index) => {
328
+ const entry = (typeof field === 'object' && field !== null ? field : {});
329
+ if (typeof entry.ref !== 'string' || entry.ref.trim() === '') {
330
+ throw new PageRefusal('act.ref_required', `Field ${String(index)} of the batch fill needs an element reference taken from the tab's most recent snapshot.`, { action, index });
331
+ }
332
+ if (typeof entry.value !== 'string') {
333
+ throw new PageRefusal('act.value_required', `Field ${String(index)} needs a value.`, {
334
+ action,
335
+ index,
336
+ });
337
+ }
338
+ return { ref: entry.ref, value: entry.value };
339
+ }),
340
+ };
341
+ }
342
+ case 'drag': {
343
+ // #64, the unexercised half: **zero calls across 2,007 transcripts**
344
+ // over a month. Folded in at low priority with that number recorded, so
345
+ // that if it turns out to matter it arrives with the number to argue
346
+ // against. In-page, element to element โ€” there is no
347
+ // file-from-the-desktop shape, because a lease is a tab.
348
+ const ref = requireRef(input.ref, 'an element reference for what is being dragged', action);
349
+ const targetRef = requireRef(input.targetRef, 'a second element reference for where it is being dragged to', action);
350
+ if (ref === targetRef) {
351
+ throw new PageRefusal('act.drag_ends_differ', 'A drag moves something from one element to another, so its two references cannot be the same element.', { action, ref });
352
+ }
353
+ return { action, ref, targetRef };
354
+ }
355
+ }
356
+ }
357
+ /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ read (#23) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
358
+ /**
359
+ * Which artefacts a read will write, given what the caller asked for.
360
+ *
361
+ * โ”€โ”€ The default is the snapshot, and the filter is free โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
362
+ *
363
+ * ยง7.1 `read.default_snapshot_only` is a **default, not a refusal**. The
364
+ * snapshot is on because it is the only load-bearing artefact โ€” every element
365
+ * reference `browser_act` takes comes from it (ยง3.8), so a read that omitted
366
+ * it would be useless in the ordinary case. The other three are off.
367
+ *
368
+ * **And the reason the narrow default is cheap rather than a trap is worth
369
+ * stating where the code is, because it is the part a reader gets wrong:**
370
+ * console output and network activity are **accumulated continuously by the
371
+ * browsing context**, from the moment the context exists, whether or not
372
+ * anybody intends to ask. There is no request that starts or stops the
373
+ * collection. So this is a filter on **what gets written to disk**, not on
374
+ * what gets collected, and **the cost of not asking is zero** โ€” a caller that
375
+ * realises afterwards that it wanted the console asks on its next read and
376
+ * gets the accumulated history, not a recording that started when it asked.
377
+ *
378
+ * A default that withheld something expensive to reproduce would push callers
379
+ * into asking for everything defensively. This one withholds nothing that
380
+ * becomes harder to get.
381
+ *
382
+ * **Cookies are the exception and it is named as one.** A cookie summary is a
383
+ * live query against the browsing context, answered at the moment of asking:
384
+ * there is no accumulated log to read from, so asking is a real operation
385
+ * with a real cost โ€” small, but not zero โ€” and the answer describes that
386
+ * instant rather than a history. Off by default for that reason as well as
387
+ * for the obvious one. {@link ARTIFACT_COLLECTION} on the seam is where this
388
+ * per-artefact fact lives so it is not something to reason out each time.
389
+ */
390
+ export function resolveReadArtifacts(requested) {
391
+ if (requested === undefined || requested === null)
392
+ return ['snapshot'];
393
+ if (!Array.isArray(requested)) {
394
+ throw new PageRefusal('read.artifact_known', `A read names which artefacts it wants: ${READ_ARTIFACTS.join(', ')}.`, { artifacts: [...READ_ARTIFACTS] });
395
+ }
396
+ for (const artifact of requested) {
397
+ if (typeof artifact !== 'string' || !READ_ARTIFACTS.includes(artifact)) {
398
+ throw new PageRefusal('read.artifact_known', `There is no "${String(artifact)}" artefact. Every artefact a read can ask for: ${READ_ARTIFACTS.join(', ')}.`, { artifact, artifacts: [...READ_ARTIFACTS] });
399
+ }
400
+ }
401
+ const asked = new Set(requested);
402
+ // The snapshot is added rather than required, because a caller asking only
403
+ // for the console still needs somewhere to take its next element reference
404
+ // from, and a read that handed back a console log and no snapshot would
405
+ // leave the tab unusable until the caller worked out it had to ask again.
406
+ asked.add('snapshot');
407
+ // Returned in the seam's declared order rather than the caller's, so that
408
+ // two callers asking for the same set get the same answer and a test can
409
+ // name the order it expects.
410
+ return READ_ARTIFACTS.filter((artifact) => asked.has(artifact));
411
+ }
412
+ /**
413
+ * Whether asking for an artefact costs anything, which is the honest answer
414
+ * to *"should I ask for this defensively"*.
415
+ */
416
+ export function artifactIsLiveQuery(artifact) {
417
+ return ARTIFACT_COLLECTION[artifact] === 'live';
418
+ }
419
+ /**
420
+ * The fields a cookie summary carries, and the whole of them.
421
+ *
422
+ * Written down as data rather than left to whatever a serialiser happens to
423
+ * emit, because ยง7.1 `read.cookies_no_values` is a **shape** and a shape
424
+ * needs something to be checked against. A test that walks a serialised
425
+ * cookie's keys and compares them to this list fails the moment a field is
426
+ * added, which is the moment a value would arrive if one ever did.
427
+ */
428
+ export const COOKIE_SUMMARY_FIELDS = [
429
+ 'name',
430
+ 'domain',
431
+ 'path',
432
+ 'expires',
433
+ 'httpOnly',
434
+ 'secure',
435
+ 'sameSite',
436
+ ];
437
+ /**
438
+ * Shape a cookie summary for writing, **naming every field that survives**.
439
+ *
440
+ * โ”€โ”€ Why this rebuilds the object instead of passing it through โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
441
+ *
442
+ * Passing a driver's cookie object straight to a serialiser emits **whatever
443
+ * that object happens to have**, which is a different set from what this
444
+ * service has decided to return โ€” and the field it would most plausibly
445
+ * acquire is the value, since every browser automation library's own cookie
446
+ * type carries one. `CookieSummary`
447
+ * has no value field, so a driver implementing this seam has to drop it; this
448
+ * function is the second lock, and it fails closed: a field not named here
449
+ * does not come out, whatever arrived.
450
+ *
451
+ * **A service handing over cookie values is a credential-export feature
452
+ * whatever else it is called** (ยง3.9), and ยง3.13 refuses the write side for
453
+ * the same reason. The value is *absent*, not truncated and not masked โ€”
454
+ * masking implies the value was in this process and got hidden, and the
455
+ * design's claim is stronger than that.
456
+ */
457
+ export function shapeCookieSummary(cookie) {
458
+ return {
459
+ name: cookie.name,
460
+ domain: cookie.domain,
461
+ path: cookie.path,
462
+ expires: cookie.expires,
463
+ httpOnly: cookie.httpOnly,
464
+ secure: cookie.secure,
465
+ sameSite: cookie.sameSite,
466
+ };
467
+ }
468
+ /* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ evaluate (#24) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
469
+ /**
470
+ * The largest expression this service will evaluate.
471
+ *
472
+ * ยง7.1 `evaluate.expression_bounded`, and ยง3.10 gives the reasoning in one
473
+ * line: **"a long expression is a program, and a program wants a capability
474
+ * that is not on offer"** (ยง3.13). The bound is not about cost. It is the
475
+ * line between *"compute this measurement in the page"* โ€” computed styles,
476
+ * contrast ratios, box geometry, spacing, line height, reading width, a few
477
+ * hundred tokens of structured data โ€” and shipping a body of code into a
478
+ * browser to run.
479
+ */
480
+ export const MAX_EXPRESSION_BYTES = 4096;
481
+ /**
482
+ * The largest result returned inline, before it is written to a file instead.
483
+ *
484
+ * ยง3.10: "Returns the value inline when it is small, and a path when it is
485
+ * not." The cap exists for the reason the whole read surface returns paths โ€”
486
+ * a large result entering a conversation is paid for once in money and on
487
+ * every later turn in context โ€” and the spill is what stops that being a
488
+ * refusal: the caller asked a legitimate question and gets its answer, in the
489
+ * place large answers go.
490
+ */
491
+ export const MAX_INLINE_RESULT_BYTES = 8192;
492
+ /**
493
+ * Check an expression before it is evaluated.
494
+ *
495
+ * โ”€โ”€ The thing this must never become โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
496
+ *
497
+ * **Evaluation happens inside the page, sandboxed by the browser, and it must
498
+ * never be widened to run in the automation server's own process.** That is a
499
+ * different capability wearing a similar name: the server's process reaches
500
+ * its own filesystem, its own network, and every browser and every tab it can
501
+ * see โ€” past the caller's own lease entirely.
502
+ *
503
+ * ยง3.10 records what the sampling found when a verb like that existed. Of 328
504
+ * measured calls across 53 sessions, **101 calls across 33 sessions did
505
+ * something a page-scoped expression could not**: 16 calls in one session
506
+ * enumerated other callers' tabs and drove one it did not own; 2 read a local
507
+ * environment file and extracted administrative credentials in cleartext; 49
508
+ * made authenticated outbound network requests from the server process, which
509
+ * is not a browser operation at all.
510
+ *
511
+ * **So the refusal is evidence rather than caution, and it is refused by
512
+ * absence.** There is no argument here selecting a target, no option naming a
513
+ * context, and nothing to widen โ€” the expression goes to the page the tab is,
514
+ * and that is the only place it can go. **Do not reintroduce it by
515
+ * accident**: a `target`, a `context`, a `world` or a `scope` parameter on
516
+ * this path is that capability arriving, whatever it is called.
517
+ *
518
+ * โ”€โ”€ What is deliberately *not* checked โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
519
+ *
520
+ * The expression's **contents**. No allowlist, no fixed vocabulary of
521
+ * permitted measurements, no filtering of what comes back, and this is
522
+ * settled rather than pending (ยง3.10): a lease on the signed-in browser
523
+ * already grants the ability to act as the signed-in user โ€” that is what the
524
+ * lease is *for* โ€” so an expression reading a page's own storage does
525
+ * something strictly smaller than what the same lease can do by driving the
526
+ * page. A restricted vocabulary would have to be guessed in advance, and
527
+ * every measurement nobody guessed becomes a screenshot instead, pushing
528
+ * callers toward the expensive path. **Refusing the obvious storage
529
+ * accessors was considered and rejected as theatre**: it stops nobody who is
530
+ * trying and teaches a reader that a hole is closed when it is not.
531
+ *
532
+ * The exposure is real and it is handled at the artifact-write layer
533
+ * (`artifact.write_scanned`, ยง7.1) โ€” one shape-matcher over everything
534
+ * written to disk, on every path that writes, because **a page snapshot can
535
+ * capture a rendered credential with nobody having chosen to evaluate
536
+ * anything** and a control on this path would not have been near it.
537
+ */
538
+ export function validateExpression(expression) {
539
+ if (typeof expression !== 'string' || expression.trim() === '') {
540
+ throw new PageRefusal('evaluate.expression_bounded', 'An evaluation needs an expression to evaluate in the page.', { maximumBytes: MAX_EXPRESSION_BYTES });
541
+ }
542
+ const bytes = Buffer.byteLength(expression, 'utf8');
543
+ if (bytes > MAX_EXPRESSION_BYTES) {
544
+ throw new PageRefusal('evaluate.expression_bounded', `That expression is ${String(bytes)} bytes and the limit is ${String(MAX_EXPRESSION_BYTES)}. This evaluates an expression in the page โ€” a measurement, a computed style, some geometry โ€” rather than running a program.`, { bytes, maximumBytes: MAX_EXPRESSION_BYTES });
545
+ }
546
+ return expression;
547
+ }
548
+ /**
549
+ * Check a capture's two mode arguments before the shutter is pressed.
550
+ *
551
+ * โ”€โ”€ Why this exists as its own guard, and what it was found by โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
552
+ *
553
+ * `capture.exclusive_mode` is a ยง7.1 rule โ€” *"a selector and a full page are
554
+ * not both asked for"*, refused with *"cannot do both"* โ€” and it had **no
555
+ * implementation anywhere**. A capture naming both was accepted, and what it
556
+ * then did was decided by whichever argument the pipeline happened to read
557
+ * first. That is the shape ยง7's own header calls out: a rule that never
558
+ * refuses anything protects nothing.
559
+ *
560
+ * It went unnoticed because the only thing asserting it was a service double
561
+ * that implemented the rule itself, so the assertion was about the double.
562
+ * Running the same cases against the real service is what surfaced it.
563
+ *
564
+ * โ”€โ”€ Why it is a refusal rather than a precedence rule โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
565
+ *
566
+ * Picking a winner would be the worse answer and ยง1.9's reasoning is the
567
+ * same one: the two arguments express **different intentions**, not different
568
+ * amounts of one. A caller asking for an element and for the whole page has
569
+ * contradicted itself, and any resolution silently gives it a picture of
570
+ * something it did not ask for โ€” which it cannot detect, because a capture
571
+ * comes back as a path and some dimensions rather than as pixels it could
572
+ * check.
573
+ *
574
+ * **This is a refusal about a malformed argument and never about cost**,
575
+ * which `capture.never_refused_for_cost` (ยง7.3) requires be kept true: a
576
+ * capture is never refused for being expensive, and nothing here reads a tier,
577
+ * a size or a count.
578
+ */
579
+ export function validateCaptureMode(options) {
580
+ if (options.fullPage && options.selector !== undefined) {
581
+ throw new PageRefusal('capture.exclusive_mode', 'A capture takes a selector or the whole page, and this call asked for both. They are different pictures rather than different amounts of one, so nothing here can pick for you: ask for the element, or ask for the page.', { fullPage: true, selector: options.selector });
582
+ }
583
+ }
584
+ /**
585
+ * Decide whether a result comes back inline or goes to a file.
586
+ *
587
+ * **Serialising is what measures it**, rather than any estimate from the
588
+ * value's shape: the cap is about what enters a conversation, and what enters
589
+ * a conversation is the serialised bytes. A check against, say, an array's
590
+ * length would let one enormous string through and spill a long list of small
591
+ * numbers.
592
+ *
593
+ * A value that cannot be serialised at all is refused rather than silently
594
+ * becoming `undefined` โ€” a caller told its expression returned nothing, when
595
+ * it actually returned something with a cycle in it, debugs the wrong thing.
596
+ */
597
+ export function disposeEvaluationResult(value) {
598
+ let serialised;
599
+ try {
600
+ // `undefined` has no serialisation, and an expression that genuinely
601
+ // evaluated to it is an ordinary outcome rather than an error, so it is
602
+ // spelled out instead of falling into the catch.
603
+ serialised = value === undefined ? 'null' : JSON.stringify(value);
604
+ }
605
+ catch (error) {
606
+ throw new PageRefusal('evaluate.result_serialisable', 'That expression produced a value this service cannot return โ€” a cycle, or something with no plain representation. Evaluate to plain data: a number, a string, or an object of them.', { reason: error instanceof Error ? error.message : String(error) });
607
+ }
608
+ if (serialised === undefined) {
609
+ // `JSON.stringify` answers `undefined` rather than throwing for a
610
+ // function or a symbol, so the two unserialisable outcomes arrive by
611
+ // different routes and both are refused.
612
+ throw new PageRefusal('evaluate.result_serialisable', 'That expression produced a value this service cannot return. Evaluate to plain data: a number, a string, or an object of them.', {});
613
+ }
614
+ const bytes = Buffer.byteLength(serialised, 'utf8');
615
+ return { serialised, bytes, spill: bytes > MAX_INLINE_RESULT_BYTES };
616
+ }
@@ -0,0 +1,20 @@
1
+ /** A store of pending seeds, scoped to one process. */
2
+ export function createPendingSeeds() {
3
+ const waiting = new Map();
4
+ return {
5
+ put: (claimId, entries) => {
6
+ if (entries.length === 0) {
7
+ return;
8
+ }
9
+ waiting.set(claimId, entries);
10
+ },
11
+ take: (claimId) => {
12
+ const entries = waiting.get(claimId);
13
+ if (entries === undefined) {
14
+ return [];
15
+ }
16
+ waiting.delete(claimId);
17
+ return entries;
18
+ },
19
+ };
20
+ }