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,351 @@
1
+ import { toolStdioAdapter } from "./adapter.js";
2
+ import { decodeMessage, encodeMessage, METHODS, negotiateProtocolVersion, NOTIFICATIONS, } from "./protocol.js";
3
+ import { TOOLS_BY_NAME, TOOL_DEFINITIONS } from "./tools.js";
4
+ /**
5
+ * What this surface calls itself during the handshake.
6
+ *
7
+ * The version was a literal `0.0.0` for as long as the package was private
8
+ * and unversioned: reading a manifest that says "unset" would have reported
9
+ * that as though it were a release. **That reservation expired when the
10
+ * package was versioned and published**, which the comment here anticipated
11
+ * as the one line that would have to agree with it — so it now reads the
12
+ * manifest for the same reason the command line does, and the two cannot
13
+ * disagree because there is only one of them.
14
+ *
15
+ * Read once at module load rather than per handshake: it cannot change while
16
+ * the process runs, and a session serves one client.
17
+ */
18
+ const manifest = await import('../../package.json', { with: { type: 'json' } });
19
+ export const SERVER_INFO = {
20
+ name: 'browser-broker',
21
+ version: typeof manifest.default.version === 'string'
22
+ ? manifest.default.version
23
+ : '0.0.0',
24
+ };
25
+ /**
26
+ * What `initialize` returns: the negotiated revision, what this server can
27
+ * do, and what it is.
28
+ *
29
+ * **`capabilities` announces `tools` and nothing else, because there is
30
+ * nothing else.** This surface serves no resources and no prompts, and
31
+ * claiming either would make a client offer its user a menu that answers
32
+ * `method_not_found` when chosen. An empty object is the specification's way
33
+ * of saying "this capability, with no optional extras" — notably not
34
+ * `listChanged`, since the twelve tools are fixed at build time and a surface
35
+ * that promised change notifications would owe notifications it can never
36
+ * have a reason to send.
37
+ */
38
+ export function initializeResult(params) {
39
+ return {
40
+ protocolVersion: negotiateProtocolVersion(params['protocolVersion']),
41
+ capabilities: { tools: {} },
42
+ serverInfo: SERVER_INFO,
43
+ };
44
+ }
45
+ /** What `tools/list` returns: the twelve, with their descriptions and schemas. */
46
+ export function listTools() {
47
+ return {
48
+ tools: TOOL_DEFINITIONS.map((tool) => ({
49
+ name: tool.name,
50
+ description: tool.description,
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: Object.fromEntries(tool.arguments.map((argument) => [
54
+ argument.name,
55
+ { type: argument.type, description: argument.description },
56
+ ])),
57
+ required: tool.arguments
58
+ .filter((argument) => argument.required)
59
+ .map((argument) => argument.name),
60
+ },
61
+ })),
62
+ };
63
+ }
64
+ /**
65
+ * Fields never written to the protocol stream, on any message.
66
+ *
67
+ * §5.6 states it for the command line — "the lease key is never printed by
68
+ * any command, including in error output and in the machine-readable mode,
69
+ * where the field is absent rather than masked". **The same rule holds here,
70
+ * and holding it on one route only would be exactly the drift the parity
71
+ * claim exists to prevent.**
72
+ *
73
+ * There is one deliberate exception, and it is the reason this list is not
74
+ * simply reused from the command line: `browser_claim` **has to** return the
75
+ * key it just issued, or the lease it granted is unreachable. So the rule is
76
+ * enforced on the way out of every *other* tool, and the grant is the single
77
+ * named hole.
78
+ */
79
+ export const NEVER_RETURNED = ['lease_key', 'leaseKey', 'key'];
80
+ /** Strip anything that must never be returned, at every depth. */
81
+ export function withoutSecrets(value) {
82
+ if (Array.isArray(value)) {
83
+ return value.map((entry) => withoutSecrets(entry));
84
+ }
85
+ if (value !== null && typeof value === 'object') {
86
+ const output = {};
87
+ for (const [key, entry] of Object.entries(value)) {
88
+ if (NEVER_RETURNED.includes(key)) {
89
+ continue;
90
+ }
91
+ output[key] = withoutSecrets(entry);
92
+ }
93
+ return output;
94
+ }
95
+ return value;
96
+ }
97
+ /**
98
+ * Render a value as the text a client shows, and carry it structured beside.
99
+ *
100
+ * ── Both, not either, and the specification asks for exactly that ───────
101
+ *
102
+ * "For backwards compatibility, a tool that returns structured content SHOULD
103
+ * also return the serialized JSON in a TextContent block." A client too old
104
+ * to know about `structuredContent` still has something to render, and one
105
+ * that does know reads the object without parsing the string back.
106
+ *
107
+ * **The structured half is what keeps the refusal taxonomy machine-readable.**
108
+ * `outcome`, `code` and `rule` are the fields a caller branches on — retry a
109
+ * capacity refusal, do not retry a typo — and flattening them into a sentence
110
+ * would leave every caller matching on English. That taxonomy is the best
111
+ * thing on this surface and it survives this change intact: the same four
112
+ * fields, in the same spellings, one level further in.
113
+ *
114
+ * Indented rather than dense, because the text block is the half a person
115
+ * reads.
116
+ */
117
+ function asContent(value) {
118
+ return [{ type: 'text', text: JSON.stringify(value, null, 2) }];
119
+ }
120
+ /**
121
+ * A refusal, shaped for the wire.
122
+ *
123
+ * ── Why a refusal is `isError: true` and still a *result* ───────────────
124
+ *
125
+ * It stays a JSON-RPC result — that part is unchanged and the reasoning below
126
+ * it is unchanged. What is new is the flag, and the specification is explicit
127
+ * about which way it goes: "Any errors that originate from the tool SHOULD be
128
+ * reported inside the result object, with `isError` set to true, *not* as an
129
+ * MCP protocol-level error response. Otherwise, the LLM would not be able to
130
+ * see that an error occurred and self-correct."
131
+ *
132
+ * Self-correction is precisely what this service's refusals are for. They
133
+ * name a rule and say what to do instead — `act.ref_resolves` tells a caller
134
+ * to read the page again, `claim.browser_known` lists the browsers by name —
135
+ * and a caller that acts on one recovers in a single attempt. Marking them as
136
+ * errors is what puts them in front of the model rather than leaving them to
137
+ * be mistaken for success.
138
+ *
139
+ * **`isError` is not a demotion of the taxonomy.** It is one boolean added
140
+ * beside four fields that all survive; a caller reading `code` and `rule` out
141
+ * of `structuredContent` gets everything it got before.
142
+ *
143
+ * What does *not* become `isError` is a protocol failure — an unknown tool,
144
+ * an unknown method, a malformed call. The specification puts "errors in
145
+ * finding the tool" on the protocol side, and this surface already answered
146
+ * them there with its own name carried alongside the integer. That design is
147
+ * untouched.
148
+ */
149
+ function refusalResult(refusal) {
150
+ const structured = {
151
+ outcome: 'refused',
152
+ code: refusal.code,
153
+ rule: refusal.rule,
154
+ message: refusal.message,
155
+ ...(refusal.details === undefined ? {} : { details: withoutSecrets(refusal.details) }),
156
+ };
157
+ return {
158
+ // **The sentence alone, not the serialised object.** A refusal's message
159
+ // is written to be read — it says what was refused and what to do next —
160
+ // and wrapping it in JSON braces would bury the one part of this result a
161
+ // person or a model acts on. The machine-readable half is directly below
162
+ // it, so nothing is lost by rendering this half plainly.
163
+ content: [{ type: 'text', text: `refused (${refusal.rule}): ${refusal.message}` }],
164
+ structuredContent: structured,
165
+ isError: true,
166
+ };
167
+ }
168
+ /**
169
+ * Answer one request.
170
+ *
171
+ * Exported so a test can drive a single exchange, and so the conformance
172
+ * driver can go through the real handler rather than around it.
173
+ */
174
+ export async function handleRequest(request, options) {
175
+ if (request.method === METHODS.initialize) {
176
+ return { id: request.id, result: initializeResult(request.params ?? {}) };
177
+ }
178
+ if (request.method === METHODS.listTools) {
179
+ return { id: request.id, result: listTools() };
180
+ }
181
+ if (request.method !== METHODS.callTool) {
182
+ return {
183
+ id: request.id,
184
+ error: {
185
+ code: 'method_not_found',
186
+ message: `This surface answers ${METHODS.initialize}, ${METHODS.listTools} and ${METHODS.callTool}. It has no "${request.method}".`,
187
+ },
188
+ };
189
+ }
190
+ const params = request.params ?? {};
191
+ const name = params['name'];
192
+ if (typeof name !== 'string') {
193
+ return {
194
+ id: request.id,
195
+ error: { code: 'malformed_call', message: 'A tool call names the tool it is calling.' },
196
+ };
197
+ }
198
+ const tool = TOOLS_BY_NAME.get(name);
199
+ if (tool === undefined) {
200
+ return {
201
+ id: request.id,
202
+ error: {
203
+ code: 'tool_not_found',
204
+ message: `There is no tool named "${name}". Call ${METHODS.listTools} for the twelve this surface offers.`,
205
+ },
206
+ };
207
+ }
208
+ const outcome = await toolStdioAdapter.invoke(options.service, tool.operation, {
209
+ name,
210
+ arguments: params['arguments'],
211
+ });
212
+ if (outcome.outcome === 'accepted') {
213
+ // A grant has to carry the key it issued or the lease is unreachable;
214
+ // everything else is stripped. The exception is named rather than
215
+ // implicit, so widening it is a visible change.
216
+ const value = tool.operation === 'claim' ? outcome.value : withoutSecrets(outcome.value);
217
+ const structured = { outcome: 'accepted', value };
218
+ const result = {
219
+ // The whole outcome is serialised, `outcome` and `value` together,
220
+ // rather than the value alone. A client showing only the inner object
221
+ // would leave a caller unable to tell an acceptance from a refusal
222
+ // without reading `isError`, and the two spellings agreeing is what
223
+ // makes the text and the structure the same answer.
224
+ content: asContent(structured),
225
+ structuredContent: structured,
226
+ };
227
+ return { id: request.id, result };
228
+ }
229
+ // **A refusal is a successful response carrying a refusal, not a protocol
230
+ // error.** `SCHEMA.md` §5.6: a refusal is the service working, and the
231
+ // command line gives it its own exit code rather than the malformed-command
232
+ // one for exactly this reason. A caller can retry a capacity refusal
233
+ // intelligently; it cannot retry a typo. Collapsing the two would take that
234
+ // distinction away from every caller on this route and from nobody on the
235
+ // other, which is the drift the parity claim exists to catch.
236
+ //
237
+ // It is marked `isError` so a model sees it and self-corrects — see
238
+ // {@link refusalResult}, which is also where the taxonomy is kept intact.
239
+ return {
240
+ id: request.id,
241
+ result: refusalResult({
242
+ code: outcome.code,
243
+ rule: outcome.rule,
244
+ message: outcome.message,
245
+ ...(outcome.details === undefined ? {} : { details: outcome.details }),
246
+ }),
247
+ };
248
+ }
249
+ /**
250
+ * Serve one session: answer every line until the input ends, then return.
251
+ *
252
+ * Returns the number of requests answered, so a caller — or a test — can tell
253
+ * a session that served nothing from one that served something without
254
+ * parsing what it wrote.
255
+ */
256
+ export async function serveSession(lines, options) {
257
+ let answered = 0;
258
+ for await (const rawLine of lines) {
259
+ const line = rawLine.trim();
260
+ if (line === '') {
261
+ continue;
262
+ }
263
+ const decoded = decodeMessage(line);
264
+ // **A notification draws no response, and that is the whole handling.**
265
+ // `notifications/initialized` is the client saying the handshake is
266
+ // complete; there is nothing to do with it and — the part that matters —
267
+ // nothing to send back. A strict client that receives a reply to a
268
+ // message it sent without an identifier is entitled to treat the stream
269
+ // as broken, so this branch deliberately writes nothing and does not
270
+ // count toward the answered total, which is what makes "answered
271
+ // nothing" assertable by a test.
272
+ if (decoded.kind === 'notification') {
273
+ const known = Object.values(NOTIFICATIONS).includes(decoded.notification.method);
274
+ options.streams.log?.(known
275
+ ? `noted ${decoded.notification.method}`
276
+ : // An unknown notification is still a notification. Answering it
277
+ // with `method_not_found` would be a reply to something that must
278
+ // not be replied to, so it is logged and dropped — which is what
279
+ // the specification asks of a receiver that does not recognise
280
+ // one.
281
+ `ignored an unrecognised notification: ${decoded.notification.method}`);
282
+ continue;
283
+ }
284
+ if (decoded.kind === 'malformed') {
285
+ // `decoded.id === undefined` means the id KEY itself was absent — a
286
+ // notification-shaped line this surface could not otherwise read —
287
+ // so there is nobody to answer, and it is reported on the log stream
288
+ // rather than dropped in silence. `decoded.id === null` is different:
289
+ // the key was present but unusable (not a number or a string), which
290
+ // is a request with an id this surface cannot echo back — answered
291
+ // below with `id: null`, JSON-RPC's own way of saying so, rather than
292
+ // silently read as the notification it is not.
293
+ if (decoded.id === undefined) {
294
+ options.streams.log?.(`ignored a message that could not be answered: ${decoded.why}`);
295
+ continue;
296
+ }
297
+ options.streams.write(encodeMessage({
298
+ id: decoded.id,
299
+ error: { code: 'malformed_message', message: decoded.why },
300
+ }));
301
+ answered += 1;
302
+ continue;
303
+ }
304
+ const response = await answerOrReportFailure(decoded.request, options);
305
+ options.streams.write(encodeMessage(response));
306
+ answered += 1;
307
+ }
308
+ return answered;
309
+ }
310
+ /**
311
+ * Answer a request, turning an unexpected throw into a protocol error.
312
+ *
313
+ * **A session that died on one bad call would take the caller's whole session
314
+ * with it**, including a lease it is holding, which the caller then cannot
315
+ * release. So an unexpected failure is answered and the loop continues. This
316
+ * is not swallowing: the caller is told, by identifier, that the call failed.
317
+ */
318
+ async function answerOrReportFailure(request, options) {
319
+ try {
320
+ return await handleRequest(request, options);
321
+ }
322
+ catch (error) {
323
+ return {
324
+ id: request.id,
325
+ error: {
326
+ code: 'unexpected_failure',
327
+ message: error instanceof Error ? error.message : String(error),
328
+ },
329
+ };
330
+ }
331
+ }
332
+ /** Split a byte stream into lines, for the real standard input. */
333
+ export async function* linesFrom(input) {
334
+ let buffer = '';
335
+ for await (const chunk of input) {
336
+ buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
337
+ let newline = buffer.indexOf('\n');
338
+ while (newline !== -1) {
339
+ yield buffer.slice(0, newline);
340
+ buffer = buffer.slice(newline + 1);
341
+ newline = buffer.indexOf('\n');
342
+ }
343
+ }
344
+ // A final line with no trailing newline is still a message. Dropping it
345
+ // would make the surface's behaviour depend on whether the caller's last
346
+ // write happened to end in a newline, which is not a distinction any caller
347
+ // knows it is making.
348
+ if (buffer.trim() !== '') {
349
+ yield buffer;
350
+ }
351
+ }
@@ -0,0 +1,310 @@
1
+ import { OPERATION_NAMES } from "../adapter/operations.js";
2
+ import { BROWSER_CHOICE_GUIDANCE } from "../browser/driver.js";
3
+ /** Every tool takes the key except the first and the last (§3.1). */
4
+ const LEASE_KEY = {
5
+ name: 'lease_key',
6
+ type: 'string',
7
+ required: true,
8
+ description: 'Your lease key, from browser_claim. Every call carrying it extends the lease.',
9
+ };
10
+ /**
11
+ * The twelve, in §3.1's order.
12
+ *
13
+ * The list is data rather than a switch statement for the same reason the
14
+ * command table is: the conformance driver reads it to translate a neutral
15
+ * case input into a tool call, so the tool a caller invokes and the tool the
16
+ * parity suite drives are the same string.
17
+ */
18
+ export const TOOL_DEFINITIONS = [
19
+ {
20
+ name: 'browser_claim',
21
+ operation: 'claim',
22
+ description: 'Ask for a lease on one browser tab. You get a tab, or a place in the queue — queued is an ' +
23
+ 'outcome, not a failure; poll browser_status until it turns active. One lease is one tab: ' +
24
+ 'call this again for a second.',
25
+ arguments: [
26
+ {
27
+ name: 'session_id',
28
+ type: 'string',
29
+ required: true,
30
+ description: 'Who you are. Attributed on captures and in the ledger; it is not a limit.',
31
+ },
32
+ {
33
+ name: 'browser',
34
+ type: 'string',
35
+ // **Optional** (`DECISIONS.md` §13i): unstated resolves to the first
36
+ // signed-in browser. The two wrong guesses are not symmetric —
37
+ // defaulting to clean-room when a sign-in was wanted returns a login
38
+ // redirect, a wrong page that looks like a right one, while
39
+ // defaulting to signed-in returns a personalised page, which is the
40
+ // page most callers were asking for.
41
+ required: false,
42
+ // **Row #66 lands here.** The description is the only place a calling
43
+ // agent reliably reads, and 25 measured sessions hand-seeded tokens
44
+ // into an isolated browser while the signed-in one sat unused. The
45
+ // cookie-jar caveat is carried in the same string rather than left to
46
+ // a refusal — and the default makes that more load-bearing, not less,
47
+ // because a caller that states nothing never sees a refusal at all.
48
+ description: BROWSER_CHOICE_GUIDANCE,
49
+ },
50
+ {
51
+ name: 'purpose',
52
+ type: 'string',
53
+ required: true,
54
+ description: 'What this lease is for, in human words, 3-200 characters. Read by an operator ' +
55
+ 'deciding whether to revoke it.',
56
+ },
57
+ {
58
+ name: 'storage_seed',
59
+ type: 'array',
60
+ required: false,
61
+ description: 'Up to 16 storage entries written into the origin before the first load. For a token ' +
62
+ 'obtained from an API rather than a login form. Written as data, never evaluated.',
63
+ },
64
+ ],
65
+ },
66
+ {
67
+ name: 'browser_status',
68
+ operation: 'status',
69
+ description: 'Where your lease stands — and the call that renews it. There is deliberately no separate ' +
70
+ 'renew tool: every keyed call extends the lease, and this is the one that does nothing ' +
71
+ 'else. Call it to keep a lease alive, and to poll a queued one.',
72
+ arguments: [LEASE_KEY],
73
+ },
74
+ {
75
+ name: 'browser_release',
76
+ operation: 'release',
77
+ description: 'Give back whatever you hold — your tab, or your place in the queue. Do this when you are ' +
78
+ 'done rather than letting the lease lapse: it frees capacity for the next caller ' +
79
+ 'immediately. Releasing twice is fine.',
80
+ arguments: [LEASE_KEY],
81
+ },
82
+ {
83
+ name: 'browser_tab_replace',
84
+ operation: 'tab_replace',
85
+ description: 'Discard this lease’s tab and open a fresh one, keeping the lease and its expiry. ' +
86
+ 'For a tab that has stopped responding — a wedged page cannot be fixed by navigating, ' +
87
+ 'because navigating is a request to that page. For a working tab use browser_navigate, ' +
88
+ 'which is cheaper.',
89
+ arguments: [LEASE_KEY],
90
+ },
91
+ {
92
+ name: 'browser_navigate',
93
+ operation: 'navigate',
94
+ description: 'Point your tab at an address. Returns the final address after redirects, the title, the ' +
95
+ 'status, and a path to the accessibility snapshot taken on arrival — a path, because a ' +
96
+ 'snapshot is thousands of tokens and you usually want one part of it.',
97
+ arguments: [
98
+ LEASE_KEY,
99
+ {
100
+ name: 'url',
101
+ type: 'string',
102
+ required: true,
103
+ description: 'Ordinary web traffic or a blank page. A local-file address is refused: it would turn ' +
104
+ 'a browser lease into a read of the machine’s filesystem.',
105
+ },
106
+ {
107
+ name: 'wait_ms',
108
+ type: 'integer',
109
+ required: false,
110
+ description: 'How long to wait for the page, in milliseconds.',
111
+ },
112
+ ],
113
+ },
114
+ {
115
+ name: 'browser_act',
116
+ operation: 'act',
117
+ description: 'Do one thing to the page: click, type, fill, press, select, hover, check, scroll, resize, ' +
118
+ 'emulate, dialog. Element references come from a snapshot. Returns a fresh snapshot after ' +
119
+ 'every change, because your next reference has to come from the page as it is now.',
120
+ arguments: [
121
+ LEASE_KEY,
122
+ {
123
+ name: 'action',
124
+ type: 'string',
125
+ required: true,
126
+ description: 'One of the verbs above. An unknown verb is refused with the full list.',
127
+ },
128
+ {
129
+ name: 'target',
130
+ type: 'string',
131
+ required: false,
132
+ description: 'The element reference, from a snapshot, where the action needs one.',
133
+ },
134
+ {
135
+ name: 'value',
136
+ type: 'string',
137
+ required: false,
138
+ description: 'What to type, select, or answer a dialog with.',
139
+ },
140
+ ],
141
+ },
142
+ {
143
+ name: 'browser_read',
144
+ operation: 'read',
145
+ description: 'Read the page: the accessibility snapshot by default, or the console, network or cookies ' +
146
+ 'on request. Written to disk and returned as a path, so you pay for the part you open ' +
147
+ 'rather than for all of it.',
148
+ arguments: [
149
+ LEASE_KEY,
150
+ {
151
+ name: 'what',
152
+ type: 'string',
153
+ required: false,
154
+ description: '"snapshot" (default), "console", "network" or "cookies".',
155
+ },
156
+ ],
157
+ },
158
+ {
159
+ name: 'browser_evaluate',
160
+ operation: 'evaluate',
161
+ description: 'Evaluate an expression in the page and get its value back. For a fact about the page that ' +
162
+ 'the snapshot does not carry.',
163
+ arguments: [
164
+ LEASE_KEY,
165
+ {
166
+ name: 'expression',
167
+ type: 'string',
168
+ required: true,
169
+ description: 'The expression. Bounded in length; an over-long one is refused.',
170
+ },
171
+ ],
172
+ },
173
+ {
174
+ name: 'browser_capture',
175
+ operation: 'capture',
176
+ description: 'Take a picture of the page — and, if you name an earlier capture, what changed since it. ' +
177
+ 'Returns paths, never the image itself. A selector and a full page cannot both be asked ' +
178
+ 'for. Never refused for cost.',
179
+ arguments: [
180
+ LEASE_KEY,
181
+ {
182
+ name: 'selector',
183
+ type: 'string',
184
+ required: false,
185
+ description: 'Capture one element rather than the viewport.',
186
+ },
187
+ {
188
+ name: 'full_page',
189
+ type: 'boolean',
190
+ required: false,
191
+ description: 'Capture the whole scrollable page. Not combinable with a selector.',
192
+ },
193
+ {
194
+ name: 'compare_to',
195
+ type: 'string',
196
+ required: false,
197
+ description: 'An earlier capture to diff against. The diff rides here rather than being its own tool.',
198
+ },
199
+ {
200
+ name: 'reason',
201
+ type: 'string',
202
+ required: false,
203
+ description: 'Free text, recorded, never refused — why this capture needed more than the default tier.',
204
+ },
205
+ ],
206
+ },
207
+ {
208
+ name: 'browser_sign_in',
209
+ operation: 'sign_in',
210
+ // **Row #67 lands here, and §3.2's sentence is the reason.** The
211
+ // description is the only place a calling agent reliably reads, and the
212
+ // measured failure this tool exists to end is one no refusal can reach: a
213
+ // caller that hits a login wall and never learns it could ask. §1.2
214
+ // counted 25 sessions hand-seeding tokens into an isolated browser rather
215
+ // than asking, so the alternative is named explicitly — the way the
216
+ // refusals do — rather than left to be inferred from the tool existing.
217
+ description: 'Hit a login wall? Ask the person to sign in, on the tab you already have. Do NOT abandon ' +
218
+ 'the task and do NOT fabricate a session by seeding tokens or cookies — ask. Your lease and ' +
219
+ 'your tab survive the wait: keep calling browser_status while the person signs in, then ' +
220
+ 'call browser_sign_in_done when they say they are finished. The result carries a sentence ' +
221
+ 'to relay to them. If nobody answers, the request lapses and the browser serves others ' +
222
+ 'again — you keep your tab either way.',
223
+ arguments: [
224
+ LEASE_KEY,
225
+ {
226
+ name: 'what',
227
+ type: 'string',
228
+ required: true,
229
+ description: 'What they are signing into, 3-200 characters, relayed to them verbatim. Name the site ' +
230
+ 'or account — "the account dashboard" — not the step you are on.',
231
+ },
232
+ {
233
+ name: 'request_seconds',
234
+ type: 'integer',
235
+ required: false,
236
+ description: 'Ask for a shorter wait than the default. Capped, never extended; the wait you actually ' +
237
+ 'got comes back on the response.',
238
+ },
239
+ ],
240
+ },
241
+ {
242
+ name: 'browser_sign_in_done',
243
+ operation: 'sign_in_done',
244
+ description: 'The person says they have signed in. Gives the browser back to other callers and keeps ' +
245
+ 'your lease and your tab exactly as they were, so carry straight on. Only the lease that ' +
246
+ 'asked can call this.',
247
+ arguments: [LEASE_KEY],
248
+ },
249
+ {
250
+ name: 'browser_feedback',
251
+ operation: 'feedback',
252
+ description: 'Tell this service that something helped or got in the way. No lease needed — if your ' +
253
+ 'claim was just refused, you are exactly who this is for. Rate 1 (it stalled my work) to ' +
254
+ '5 (it made my work faster); 3 is neutral. The lease, your last operation and the refusal ' +
255
+ 'you hit are captured for you: say what you were trying to achieve and what you expected. ' +
256
+ 'Written locally, never transmitted.',
257
+ arguments: [
258
+ {
259
+ name: 'rating',
260
+ type: 'integer',
261
+ required: true,
262
+ description: '1 it stalled the work · 2 substantial friction · 3 neutral · 4 it helped, with a ' +
263
+ 'rough edge · 5 it made the work faster. The axis is help versus hinder, not liking.',
264
+ },
265
+ {
266
+ name: 'category',
267
+ type: 'string',
268
+ required: true,
269
+ description: 'refusal-unclear · no-path · worked-around · surprised-me · worked-well. One of those five.',
270
+ },
271
+ {
272
+ name: 'note',
273
+ type: 'string',
274
+ required: true,
275
+ description: '20-2000 characters: what you were trying to achieve, what you expected, and what you ' +
276
+ 'did instead. Do not type the lease id, the operation or the rule — those are captured.',
277
+ },
278
+ {
279
+ name: 'session_id',
280
+ type: 'string',
281
+ required: false,
282
+ description: 'Your identity, when there is no lease to read one from.',
283
+ },
284
+ {
285
+ name: 'lease_key',
286
+ type: 'string',
287
+ required: false,
288
+ description: 'When you hold one. It only attaches the row to a lease; it authorises nothing.',
289
+ },
290
+ ],
291
+ },
292
+ ];
293
+ /**
294
+ * The tools, by name.
295
+ *
296
+ * Built once rather than searched linearly per call, and exported so the
297
+ * surface and its tests look a tool up the same way.
298
+ */
299
+ export const TOOLS_BY_NAME = new Map(TOOL_DEFINITIONS.map((tool) => [tool.name, tool]));
300
+ /** Every operation this surface offers, read from the tool table. */
301
+ export const TOOL_OPERATIONS = TOOL_DEFINITIONS.map((tool) => tool.operation);
302
+ /**
303
+ * Operations the surface offers, named so a test can compare by name.
304
+ *
305
+ * `MILESTONES.md` records a hollow test that "iterated a list rather than
306
+ * naming its entries, so deleting an entry stayed green". A test comparing
307
+ * this against {@link OPERATION_NAMES} by name catches a deletion; one
308
+ * comparing lengths does not.
309
+ */
310
+ export const OPERATIONS_WITH_TOOLS = OPERATION_NAMES.filter((operation) => TOOL_DEFINITIONS.some((tool) => tool.operation === operation));