hilos-agent 0.11.16 → 0.11.17

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
@@ -544,3 +544,19 @@ real tagged release proves OIDC end to end. After that proof, remove the
544
544
  workflow fallback, revoke the npm token, and delete the GitHub secret (1094,
545
545
  1211, 1228). Because the source repository is private, npm will not attach a
546
546
  public provenance attestation even when the publish itself uses OIDC.
547
+
548
+ ## Browser agents and WebMCP
549
+
550
+ A WebMCP-enabled browser can use the signed-in hilos workspace directly.
551
+ Open Settings → Connections to check page-tool registration. Start with
552
+ `get_hilos_context`, then `navigate_hilos`; Docs, Tasks, rooms, activity and
553
+ onboarding register their own commands. No personal token or plugin is needed
554
+ for that browser session. See [WebMCP setup](https://hilos.sh/docs/webmcp).
555
+
556
+ The plugin and coding-client skills use remote MCP. They do not make Claude
557
+ Code, Codex, or Cursor into browser WebMCP consumers. For background work, keep
558
+ the personal or named-agent MCP connection. The local daemon's optional
559
+ `hilos-agent webmcp` bridge uses an isolated signed-in profile and exact
560
+ person-approved read tools. Its `tools` response distinguishes native from
561
+ limited imperative compatibility mode. Rediscover after navigation; calls bind
562
+ to the inspected page and never retry on site-provided error text.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.16",
3
+ "version": "0.11.17",
4
4
  "description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. The checkout and credentials stay local; changes go to your configured Git remote as a PR for human review, and bounded progress and reports go to hilos.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "keywords": [
28
28
  "hilos",
29
29
  "mcp",
30
+ "webmcp",
30
31
  "agent",
31
32
  "claude-code",
32
33
  "codex",
@@ -5,6 +5,13 @@ description: Connect a named agent to a hilos room with the local daemon. Use wh
5
5
 
6
6
  # Connect
7
7
 
8
+ If the person wants a browser agent to use the hilos page already open, use
9
+ [WebMCP setup](https://hilos.sh/docs/webmcp): sign in in a supported browser,
10
+ check Settings → Connections, then discover `get_hilos_context` and
11
+ `navigate_hilos`. No token or daemon is needed for that page session. Do not
12
+ claim this plugin adds browser WebMCP to a coding client. Personal remote MCP
13
+ uses Settings → Connections; a named local agent uses the steps below.
14
+
8
15
  1. Get the private join code from the person or the agent's connection dialog.
9
16
  Treat it as a credential. Never paste it into chat, reports, or logs.
10
17
  2. From the folder the daemon will work in, run
@@ -18,8 +18,8 @@ const AGENT_BROWSER_BIN = "agent-browser/bin/agent-browser.js";
18
18
  const AGENT_BROWSER_MISSING =
19
19
  "WebMCP needs Node.js 24 or newer and agent-browser. Upgrade Node.js, reinstall hilos-agent, then run `agent-browser install`.";
20
20
  const MAX_CAPTURE_CHARS = 256 * 1024;
21
- const MAX_INPUT_CHARS = 16 * 1024;
22
- const MAX_RESULT_CHARS = 64 * 1024;
21
+ const MAX_INPUT_BYTES = 16 * 1024;
22
+ const MAX_RESULT_BYTES = 64 * 1024;
23
23
  const MAX_ORIGINS = 32;
24
24
  const MAX_READ_TOOLS_PER_ORIGIN = 64;
25
25
  const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
@@ -130,7 +130,8 @@ const SCHEMA_KEYS = new Set([
130
130
 
131
131
  /** Strip prose-bearing schema fields so tool poisoning never becomes instructions. */
132
132
  export function sanitizeWebMcpSchema(value, depth = 0) {
133
- if (depth > 12 || value == null || typeof value !== "object") return value;
133
+ if (depth > 12) return undefined;
134
+ if (value == null || typeof value !== "object") return value;
134
135
  if (Array.isArray(value)) return value.slice(0, 64).map((item) => sanitizeWebMcpSchema(item, depth + 1));
135
136
  const out = {};
136
137
  for (const [key, child] of Object.entries(value).slice(0, 256)) {
@@ -144,6 +145,7 @@ export function sanitizeWebMcpSchema(value, depth = 0) {
144
145
  continue;
145
146
  }
146
147
  if (!SCHEMA_KEYS.has(key)) continue;
148
+ if (key === "$ref" && (typeof child !== "string" || !child.startsWith("#"))) continue;
147
149
  out[key] = sanitizeWebMcpSchema(child, depth + 1);
148
150
  }
149
151
  return out;
@@ -293,9 +295,9 @@ function evalResult(run) {
293
295
  return run?.json?.data?.result;
294
296
  }
295
297
 
296
- function encodedCall(name, input) {
297
- const payload = Buffer.from(JSON.stringify({ name, input }), "utf8").toString("base64");
298
- return `(() => { const p = JSON.parse(atob("${payload}")); return window.__hilosWebMcpBridge.call(p.name, p.input); })()`;
298
+ function encodedCall(name, input, expected) {
299
+ const payload = Buffer.from(JSON.stringify({ name, input, expected }), "utf8").toString("base64");
300
+ return `(() => { const bytes = Uint8Array.from(atob("${payload}"), c => c.charCodeAt(0)); const p = JSON.parse(new TextDecoder().decode(bytes)); return window.__hilosWebMcpBridge.call(p.name, p.input, p.expected); })()`;
299
301
  }
300
302
 
301
303
  async function listTools(policy, runner) {
@@ -315,16 +317,18 @@ async function listTools(policy, runner) {
315
317
  : "/";
316
318
  const registered = Array.isArray(raw?.tools) ? raw.tools : [];
317
319
  const allowed = registered
318
- .filter((tool) => originPolicy.readTools.has(tool?.name))
320
+ .filter((tool) => originPolicy.readTools.has(tool?.name) && !tool.schemaOmitted && tool.annotations?.consequentialHint !== true)
319
321
  .map((tool) => ({
320
322
  name: tool.name,
321
- inputSchema: tool.schemaOmitted ? undefined : sanitizeWebMcpSchema(tool.inputSchema || {}),
323
+ inputSchema: sanitizeWebMcpSchema(tool.inputSchema || {}),
322
324
  personApprovedRisk: "read",
323
325
  siteReadOnlyHint: tool.annotations?.readOnlyHint === true,
324
326
  }));
325
327
  return {
326
328
  ok: true,
327
329
  source: { origin, pagePath },
330
+ discoveryId: typeof raw?.discoveryId === "string" ? raw.discoveryId : null,
331
+ mode: raw?.mode === "native" ? "native" : "compatibility",
328
332
  tools: allowed,
329
333
  blockedToolCount: Math.max(0, registered.length - allowed.length),
330
334
  untrusted: true,
@@ -377,7 +381,7 @@ export async function runWebMcpCommand(cfg, operation, args = [], options = {})
377
381
  let input;
378
382
  try {
379
383
  const text = args[1] == null ? "{}" : String(args[1]);
380
- if (text.length > MAX_INPUT_CHARS) throw new Error("too large");
384
+ if (Buffer.byteLength(text, "utf8") > MAX_INPUT_BYTES) throw new Error("too large");
381
385
  input = JSON.parse(text);
382
386
  if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("not an object");
383
387
  } catch {
@@ -395,9 +399,10 @@ export async function runWebMcpCommand(cfg, operation, args = [], options = {})
395
399
  tool: name,
396
400
  };
397
401
  }
402
+ if (!available.discoveryId) return { ok: false, code: "stale_bridge", error: "Close and reopen the WebMCP browser to load the current bridge." };
398
403
  const run = await runner(
399
404
  policy.browserCommand,
400
- browserArgs(policy, ["eval", encodedCall(name, input)]),
405
+ browserArgs(policy, ["eval", encodedCall(name, input, { ...available.source, discoveryId: available.discoveryId })]),
401
406
  );
402
407
  if (!run.ok) {
403
408
  return {
@@ -414,12 +419,12 @@ export async function runWebMcpCommand(cfg, operation, args = [], options = {})
414
419
  if (
415
420
  origin !== available.source.origin ||
416
421
  resultPagePath !== available.source.pagePath ||
417
- raw?.tool !== name
422
+ raw?.tool !== name || raw?.discoveryId !== available.discoveryId
418
423
  ) {
419
424
  return { ok: false, code: "provenance_mismatch", error: "The page changed while the WebMCP tool was running; its result was discarded." };
420
425
  }
421
426
  const resultJson = typeof raw?.resultJson === "string" ? raw.resultJson : "null";
422
- if (resultJson.length > MAX_RESULT_CHARS) {
427
+ if (Buffer.byteLength(resultJson, "utf8") > MAX_RESULT_BYTES) {
423
428
  return { ok: false, code: "result_too_large", error: "The WebMCP tool result exceeded the 64 KB limit." };
424
429
  }
425
430
  let result;
@@ -10,8 +10,8 @@
10
10
 
11
11
  const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
12
12
  const MAX_TOOLS = 64;
13
- const MAX_SCHEMA_CHARS = 32 * 1024;
14
- const MAX_RESULT_CHARS = 64 * 1024;
13
+ const MAX_SCHEMA_BYTES = 32 * 1024;
14
+ const MAX_RESULT_BYTES = 64 * 1024;
15
15
 
16
16
  const copyJson = (value) => {
17
17
  if (value === undefined) return undefined;
@@ -29,12 +29,7 @@
29
29
  };
30
30
 
31
31
  function installFallbackModelContext() {
32
- if (
33
- document.modelContext &&
34
- typeof document.modelContext.registerTool === "function" &&
35
- typeof document.modelContext.getTools === "function" &&
36
- typeof document.modelContext.executeTool === "function"
37
- ) {
32
+ if (document.modelContext && typeof document.modelContext.registerTool === "function") {
38
33
  return document.modelContext;
39
34
  }
40
35
 
@@ -78,9 +73,13 @@
78
73
  throw options.signal.reason || domError("Registration aborted", "AbortError");
79
74
  }
80
75
 
76
+ if (tools.size >= MAX_TOOLS) throw new RangeError("Too many WebMCP tools");
81
77
  const inputSchema = copyJson(tool.inputSchema);
78
+ if (inputSchema !== undefined && (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema) || (inputSchema.type && inputSchema.type !== "object"))) {
79
+ throw new TypeError("WebMCP input schema must describe an object");
80
+ }
82
81
  const schemaText = JSON.stringify(inputSchema ?? {});
83
- if (schemaText.length > MAX_SCHEMA_CHARS) {
82
+ if (new TextEncoder().encode(schemaText).length > MAX_SCHEMA_BYTES) {
84
83
  throw new TypeError("WebMCP input schema is too large");
85
84
  }
86
85
  const entry = {
@@ -92,6 +91,7 @@
92
91
  ? {
93
92
  readOnlyHint: tool.annotations.readOnlyHint === true,
94
93
  untrustedContentHint: tool.annotations.untrustedContentHint === true,
94
+ consequentialHint: tool.annotations.consequentialHint === true,
95
95
  }
96
96
  : undefined,
97
97
  execute: tool.execute,
@@ -117,7 +117,10 @@
117
117
  }
118
118
  },
119
119
 
120
- async getTools() {
120
+ async getTools(options = {}) {
121
+ if (options.fromOrigins?.some((origin) => origin !== location.origin)) {
122
+ throw domError("The hilos compatibility bridge supports this document only", "NotSupportedError");
123
+ }
121
124
  return [...tools.values()]
122
125
  .sort((a, b) => a.name.localeCompare(b.name))
123
126
  .map((tool) => ({
@@ -142,12 +145,26 @@
142
145
  const tool = tools.get(name);
143
146
  if (!tool) throw domError(`WebMCP tool ${name} is not registered`, "NotFoundError");
144
147
 
148
+ if (registeredTool.origin !== location.origin || registeredTool.window !== window) {
149
+ throw domError("WebMCP tool belongs to another document", "SecurityError");
150
+ }
145
151
  const controller = new AbortController();
146
- const onAbort = () => controller.abort(options.signal?.reason);
152
+ let rejectAbort;
153
+ const aborted = new Promise((_, reject) => { rejectAbort = reject; });
154
+ const onAbort = () => {
155
+ controller.abort(options.signal?.reason);
156
+ rejectAbort(controller.signal.reason);
157
+ };
147
158
  options.signal?.addEventListener("abort", onAbort, { once: true });
148
159
  try {
149
- const value = await tool.execute(copyJson(inputObject), { signal: controller.signal });
150
- return JSON.stringify(value);
160
+ const value = await Promise.race([
161
+ Promise.resolve().then(() => {
162
+ controller.signal.throwIfAborted();
163
+ return tool.execute(copyJson(inputObject), { signal: controller.signal });
164
+ }),
165
+ aborted,
166
+ ]);
167
+ return JSON.stringify(value) ?? "null";
151
168
  } finally {
152
169
  options.signal?.removeEventListener("abort", onAbort);
153
170
  }
@@ -179,7 +196,11 @@
179
196
  return context;
180
197
  }
181
198
 
199
+ const native = Boolean(document.modelContext?.registerTool);
182
200
  const modelContext = installFallbackModelContext();
201
+ let epoch = 0;
202
+ let discovery = null;
203
+ modelContext.addEventListener?.("toolchange", () => { epoch++; });
183
204
 
184
205
  const source = () => ({
185
206
  origin: location.origin,
@@ -187,24 +208,30 @@
187
208
  });
188
209
 
189
210
  async function registeredTools() {
211
+ if (typeof modelContext.getTools !== "function" || typeof modelContext.executeTool !== "function") {
212
+ throw domError("This native WebMCP build has no in-page consumer API. Update the browser; hilos will not replace its native provider.", "NotSupportedError");
213
+ }
190
214
  const tools = await modelContext.getTools();
191
215
  return (Array.isArray(tools) ? tools : [])
192
- .filter((tool) => tool && tool.origin === location.origin)
216
+ .filter((tool) => tool && tool.origin === location.origin && tool.window === window)
193
217
  .slice(0, MAX_TOOLS);
194
218
  }
195
219
 
196
220
  const bridge = Object.freeze({
197
221
  version: 1,
198
- mode:
199
- document.modelContext === modelContext &&
200
- Object.prototype.hasOwnProperty.call(document, "modelContext")
201
- ? "polyfill"
202
- : "native",
222
+ mode: native ? "native" : "compatibility",
203
223
 
204
224
  async list() {
225
+ const before = { href: location.href, epoch };
205
226
  const tools = await registeredTools();
227
+ if (before.href !== location.href || before.epoch !== epoch) {
228
+ throw domError("The page changed during discovery. List its tools again.", "InvalidStateError");
229
+ }
230
+ discovery = { id: crypto.randomUUID(), href: before.href, epoch: before.epoch, tools };
206
231
  return {
207
232
  ...source(),
233
+ discoveryId: discovery.id,
234
+ mode: native ? "native" : "compatibility",
208
235
  tools: tools.map((tool) => {
209
236
  let inputSchema = tool.inputSchema;
210
237
  let schemaOmitted = false;
@@ -213,7 +240,7 @@
213
240
  // schema as the draft's serialized JSON string; the current report
214
241
  // exposes an object. Normalize both without passing site prose on.
215
242
  if (typeof inputSchema === "string") inputSchema = JSON.parse(inputSchema);
216
- if (JSON.stringify(inputSchema ?? {}).length > MAX_SCHEMA_CHARS) {
243
+ if (new TextEncoder().encode(JSON.stringify(inputSchema ?? {})).length > MAX_SCHEMA_BYTES) {
217
244
  inputSchema = undefined;
218
245
  schemaOmitted = true;
219
246
  }
@@ -229,6 +256,7 @@
229
256
  ? {
230
257
  readOnlyHint: tool.annotations.readOnlyHint === true,
231
258
  untrustedContentHint: tool.annotations.untrustedContentHint === true,
259
+ consequentialHint: tool.annotations.consequentialHint === true,
232
260
  }
233
261
  : undefined,
234
262
  };
@@ -236,9 +264,11 @@
236
264
  };
237
265
  },
238
266
 
239
- async call(name, inputObject, timeoutMs = 30_000) {
240
- const tools = await registeredTools();
241
- const tool = tools.find((candidate) => candidate.name === name);
267
+ async call(name, inputObject, expected, timeoutMs = 30_000) {
268
+ if (!expected || expected.origin !== location.origin || expected.pagePath !== location.pathname || !discovery || expected.discoveryId !== discovery.id || discovery.href !== location.href || discovery.epoch !== epoch) {
269
+ throw domError("The page or tool inventory changed. Discover tools again before calling.", "InvalidStateError");
270
+ }
271
+ const tool = discovery.tools.find((candidate) => candidate.name === name);
242
272
  if (!tool) throw domError(`WebMCP tool ${name} is not registered`, "NotFoundError");
243
273
 
244
274
  const controller = new AbortController();
@@ -247,25 +277,16 @@
247
277
  Math.max(1_000, Math.min(Number(timeoutMs) || 30_000, 60_000)),
248
278
  );
249
279
  try {
250
- let result;
251
- try {
252
- result = await modelContext.executeTool(tool, inputObject, {
253
- signal: controller.signal,
254
- });
255
- } catch (error) {
256
- // Chromium's early draft expected a serialized input string. Only
257
- // retry the pre-execution parse failure; an arbitrary tool failure
258
- // might follow a side effect and must never be executed twice.
259
- if (!/failed to parse input arguments/i.test(String(error?.message || error))) throw error;
260
- result = await modelContext.executeTool(tool, JSON.stringify(inputObject), {
261
- signal: controller.signal,
262
- });
263
- }
264
- const resultJson = typeof result === "string" ? result : JSON.stringify(result);
265
- if (resultJson.length > MAX_RESULT_CHARS) {
280
+ // Never infer a protocol version from a site's error message and retry:
281
+ // that error may have followed a side effect.
282
+ const result = await modelContext.executeTool(tool, inputObject, {
283
+ signal: controller.signal,
284
+ });
285
+ const resultJson = (typeof result === "string" ? result : JSON.stringify(result)) ?? "null";
286
+ if (new TextEncoder().encode(resultJson).length > MAX_RESULT_BYTES) {
266
287
  throw new RangeError("WebMCP tool result exceeds the 64 KB bridge limit");
267
288
  }
268
- return { ...source(), tool: name, resultJson };
289
+ return { ...source(), discoveryId: expected.discoveryId, tool: name, resultJson };
269
290
  } finally {
270
291
  clearTimeout(timer);
271
292
  }