browser-broker 0.3.1 → 0.4.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/README.md CHANGED
@@ -92,9 +92,17 @@ every run — so a published release arrives without anything being pulled or re
92
92
  }
93
93
  ```
94
94
 
95
- Weigh that against spawning a checkout directly: `npx` costs a registry round-trip on every spawn,
96
- which is several seconds against a fraction of one for a path on disk. A machine that develops this
97
- service is better off pointing at its own tree; a machine that only *uses* it is better off here.
95
+ `npx` costs a registry round-trip on every spawn — **measured at ~1.1s warm and ~3.7s cold**, against
96
+ ~0.4s for a path on disk. Against a typical 30s MCP connect timeout that is ample headroom, so prefer
97
+ this form even on a machine that develops the service: a config file shared between machines cannot
98
+ carry an absolute path that is correct on all of them.
99
+
100
+ ⚠️ **If handshakes start timing out, prune the npx cache before blaming npx.** An unpruned 837MB
101
+ `_npx` cache once pushed spawn cost to 9.7–33.2s and blew a 30s connect timeout outright. The cost is
102
+ the cache, not the mechanism. Two related traps: `--prefer-offline` can serve a packument that
103
+ predates a release, so npx resolves a version it then cannot fetch (`ETARGET`); and the local npm
104
+ cache lags the registry independently, so `npm cache clean --force` is the fix when `npm view` and
105
+ `npx` disagree about what exists.
98
106
 
99
107
  ### From a checkout
100
108
 
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-broker",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Leases over tabs in a fixed set of browsers: bounded capacity, a queue, reclamation, and an enforced capture policy.",
6
6
  "type": "module",
@@ -70,6 +70,13 @@ export function listTools() {
70
70
  required: tool.arguments
71
71
  .filter((argument) => argument.required)
72
72
  .map((argument) => argument.name),
73
+ // **The schema says what the surface enforces.** `handleRequest`
74
+ // refuses a call carrying a name the tool does not declare, so the
75
+ // schema advertising anything less would describe a more permissive
76
+ // surface than the one that answers — and a validating client would
77
+ // forward a call it could have caught itself. Saying it here lets the
78
+ // contract be read rather than discovered by refusal.
79
+ additionalProperties: false,
73
80
  },
74
81
  })),
75
82
  };
@@ -159,6 +166,88 @@ function asContent(value) {
159
166
  * them there with its own name carried alongside the integer. That design is
160
167
  * untouched.
161
168
  */
169
+ /**
170
+ * Names a tool call carries that the tool does not declare.
171
+ *
172
+ * Returns them in the order the caller wrote them, so the message names what
173
+ * the caller can see in its own request rather than a re-sorted set.
174
+ */
175
+ function undeclaredArguments(tool, args) {
176
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) {
177
+ return [];
178
+ }
179
+ const declared = new Set(tool.arguments.map((argument) => argument.name));
180
+ return Object.keys(args).filter((key) => !declared.has(key));
181
+ }
182
+ /**
183
+ * Refuse a call carrying arguments the tool does not declare.
184
+ *
185
+ * ── Why this is a refusal rather than silent acceptance ─────────────────
186
+ *
187
+ * The record of arguments reaches the bridge as an opaque set of names, and
188
+ * the bridge reads the ones its operation knows. **A name no tool declares is
189
+ * therefore invisible to every layer that could object to it** — so without
190
+ * this guard it is dropped without a word while the call answers `accepted`.
191
+ *
192
+ * The shape that makes the cost concrete: `browser_evaluate` sent a `resize`
193
+ * alongside its `expression`. `resize` is not a capability that failed — the
194
+ * tool declares exactly `lease_key` and `expression`, so it is a name the
195
+ * tool has never had. Unguarded, the evaluation runs against the viewport the
196
+ * page already has, the reply says `accepted`, and the caller reads a number
197
+ * produced by a resize that never happened.
198
+ *
199
+ * That is the defect `scripts/check-argument-reachability.mjs` exists to
200
+ * prevent, arriving from the other side. Its header states the cost in this
201
+ * repository's own words: an inert argument "does not merely fail to help: it
202
+ * **manufactures evidence, and the evidence is not marked as manufactured**".
203
+ * There, a *declared* name was read by nothing; here, an *undeclared* name is
204
+ * accepted by everything. Both hand a caller a truthful-looking success for a
205
+ * request that was partly ignored, and a caller cannot tell either from a
206
+ * call that worked. The reachability check guards the first direction
207
+ * statically; this guards the second at the only point that can see it.
208
+ *
209
+ * **The alternative — accept and warn — was rejected deliberately.** A
210
+ * warning rides on a response whose `outcome` still reads `accepted` and
211
+ * whose `isError` is still false, so a caller that branches on those two
212
+ * fields — which is how a client decides whether its request happened —
213
+ * cannot see the warning at all, and the false conclusion survives.
214
+ * The one caller a warning reaches is a caller already reading the prose,
215
+ * which is the caller least likely to have made the mistake. This surface's
216
+ * premise is that a refusal is the service working (§5.6) and that the tool
217
+ * list is the contract a caller discovers by trying — an argument name that
218
+ * is not rejected cannot be distinguished from one that is supported.
219
+ *
220
+ * **The compatibility cost is real and is accepted.** 0.3.1 is published, so
221
+ * a caller sending a stray key gets a refusal where it got a success. That
222
+ * caller's stray key was doing nothing then either: the behaviour it believed
223
+ * it had is the behaviour it never had, and the refusal is the first time it
224
+ * is told. Nothing that sends only declared names changes at all, and the
225
+ * conformance case table — authored in the service's spelling — is entirely
226
+ * within the declared names, so no in-tree caller moves.
227
+ *
228
+ * ── The message names the keys, because a refusal that does not is the
229
+ * defect ───────────────────────────────────────────────────────────────────
230
+ *
231
+ * It lists the offending names **and** what the tool does declare. A caller
232
+ * that mistyped sees its typo beside the word it meant, and one that invented
233
+ * a capability sees the whole of what is on offer, which is the fact it
234
+ * needed. Neither has to call `tools/list` to act on this.
235
+ */
236
+ function undeclaredArgumentRefusal(tool, offending) {
237
+ const plural = offending.length === 1 ? 'argument' : 'arguments';
238
+ const declared = tool.arguments.map((argument) => argument.name);
239
+ const offered = declared.length === 0
240
+ ? `${tool.name} takes no arguments.`
241
+ : `${tool.name} takes: ${declared.join(', ')}.`;
242
+ return refusalResult({
243
+ code: 'malformed_call',
244
+ rule: 'call.arguments_declared',
245
+ message: `${tool.name} does not take the ${plural} ${offending.map((key) => `"${key}"`).join(', ')}. ` +
246
+ `${offered} An undeclared argument is refused rather than ignored, because a call that ` +
247
+ `silently dropped it would report success for a request that did not happen.`,
248
+ details: { tool: tool.name, undeclared: offending, declared },
249
+ });
250
+ }
162
251
  function refusalResult(refusal) {
163
252
  const structured = {
164
253
  outcome: 'refused',
@@ -218,6 +307,13 @@ export async function handleRequest(request, options) {
218
307
  },
219
308
  };
220
309
  }
310
+ // Checked before the service is called, so an undeclared name cannot reach
311
+ // an operation and be dropped there. See {@link undeclaredArgumentRefusal}
312
+ // for why this refuses rather than warns, and what that costs.
313
+ const undeclared = undeclaredArguments(tool, params['arguments']);
314
+ if (undeclared.length > 0) {
315
+ return { id: request.id, result: undeclaredArgumentRefusal(tool, undeclared) };
316
+ }
221
317
  const outcome = await toolStdioAdapter.invoke(options.service, tool.operation, {
222
318
  name,
223
319
  arguments: params['arguments'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-broker",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Leases over tabs in a fixed set of browsers: bounded capacity, a queue, reclamation, and an enforced capture policy.",
6
6
  "type": "module",