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,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));
|