hilos-agent 0.11.15 → 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 +52 -1
- package/bin/hilos-agent.mjs +55 -35
- package/package.json +2 -1
- package/skills/hilos-connect/SKILL.md +41 -0
- package/src/cli.mjs +3 -1
- package/src/daemon-log.mjs +84 -0
- package/src/doctor.mjs +119 -0
- package/src/install-hint.mjs +8 -0
- package/src/mcp.mjs +2 -1
- package/src/run.mjs +14 -9
- package/src/web-doctor.mjs +49 -0
- package/src/webmcp-bridge.mjs +17 -12
- package/src/webmcp-init.js +61 -40
package/README.md
CHANGED
|
@@ -28,6 +28,11 @@ after you Approve.
|
|
|
28
28
|
|
|
29
29
|
## Quick start
|
|
30
30
|
|
|
31
|
+
Run `npx hilos-agent@latest doctor` first if you already have a saved config.
|
|
32
|
+
For a new connection, use `npx hilos-agent@latest doctor --join-stdin` with the
|
|
33
|
+
private join code and the same command settings you will use to run the daemon.
|
|
34
|
+
Read the JSON and resolve each failed check before starting it.
|
|
35
|
+
|
|
31
36
|
In hilos: open your agent's profile → **Connect agent** → **Run in channel**. Copy the
|
|
32
37
|
terminal command and run it from inside your repo's folder. It waits at a hidden
|
|
33
38
|
prompt; copy the private join code from Hilos and paste it there. The daemon
|
|
@@ -36,12 +41,29 @@ credential never enters shell history or the process list:
|
|
|
36
41
|
|
|
37
42
|
```sh
|
|
38
43
|
cd ~/code/your-repo
|
|
44
|
+
npx hilos-agent@latest doctor --join-stdin # check setup with the private join code
|
|
39
45
|
npx hilos-agent@latest --join-stdin # then paste the private join code when asked
|
|
40
46
|
```
|
|
41
47
|
|
|
42
48
|
Previously copied `--join <blob>` commands remain compatible. New commands use
|
|
43
49
|
stdin because the blob contains the agent token and should not live in argv.
|
|
44
50
|
|
|
51
|
+
The daemon prints `log: <path>` when it starts. Its local diagnostic log lives
|
|
52
|
+
at `~/.hilos/logs/<handle>.log`, with one previous file at `<handle>.log.1`.
|
|
53
|
+
Each file is capped at 2 MB, private to your user, and redacted for
|
|
54
|
+
credential-shaped text. Before identity discovery, the name is `agent-<pid>`;
|
|
55
|
+
those startup lines move to the handle's log after connection. The final
|
|
56
|
+
`exit:` line records a signal, authentication stop, fatal error with its stack,
|
|
57
|
+
or normal shutdown. A forced kill or power loss cannot write an exit line.
|
|
58
|
+
An IDE agent can read these files when diagnosing a daemon that went quiet.
|
|
59
|
+
|
|
60
|
+
After three minutes without a daemon heartbeat, hilos posts one notice in the
|
|
61
|
+
last room that mentioned the agent (falling back to its latest run's room).
|
|
62
|
+
The notice names who can restart it with the same join code. For hilos-hosted
|
|
63
|
+
agents, it also explains that hosted mentions use workspace credits until the
|
|
64
|
+
daemon returns. The owner gets the existing quick-connect card; everyone in
|
|
65
|
+
the room can read the notice.
|
|
66
|
+
|
|
45
67
|
Running from elsewhere, or want to map several repos explicitly? Save this as
|
|
46
68
|
`~/.hilos/agent.json` (or `./hilos-agent.json`). The file is strict JSON, so it
|
|
47
69
|
cannot contain comments:
|
|
@@ -485,7 +507,20 @@ arguments can be visible to other processes on the machine, so use
|
|
|
485
507
|
Likewise, prefer `--join-stdin` over the legacy `--join <blob>` form for a new
|
|
486
508
|
connection.
|
|
487
509
|
|
|
488
|
-
|
|
510
|
+
`hilos-agent doctor` checks Node against the package's minimum version, both
|
|
511
|
+
configured CLIs, native web configuration, the hilos connection, the local seat
|
|
512
|
+
lock, and the config file and working folder. It returns JSON and exits with
|
|
513
|
+
code 1 if any check fails; add `--human` for one readable line per check. It
|
|
514
|
+
accepts the same join code, `--config`, and command overrides as the daemon.
|
|
515
|
+
Missing CLI checks include an install hint. The Cursor CLI (`cursor-agent`)
|
|
516
|
+
is installed separately from the Cursor app. The lock is released immediately
|
|
517
|
+
after the check; `contended` means another daemon holds that seat.
|
|
518
|
+
|
|
519
|
+
Keep the daemon in a separate terminal or a detached process that survives
|
|
520
|
+
your IDE agent's tool call. Confirm `watching: @-mentions` before calling it
|
|
521
|
+
online. The `hilos-connect` skill walks IDE agents through this setup.
|
|
522
|
+
|
|
523
|
+
See `hilos-agent --help` for the `doctor`, `init`, `webmcp`, `web doctor`, and `hooks`
|
|
489
524
|
subcommands.
|
|
490
525
|
|
|
491
526
|
Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`,
|
|
@@ -509,3 +544,19 @@ real tagged release proves OIDC end to end. After that proof, remove the
|
|
|
509
544
|
workflow fallback, revoke the npm token, and delete the GitHub secret (1094,
|
|
510
545
|
1211, 1228). Because the source repository is private, npm will not attach a
|
|
511
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/bin/hilos-agent.mjs
CHANGED
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
// codingCmd in hilos-agent.json to change it and the daemon picks it up on its
|
|
20
20
|
// next poll — no restart needed.
|
|
21
21
|
|
|
22
|
-
import { spawnSync } from "node:child_process";
|
|
23
22
|
import { readFileSync } from "node:fs";
|
|
24
23
|
import { fileURLToPath } from "node:url";
|
|
25
24
|
|
|
@@ -28,9 +27,10 @@ import { readPrivateJoin } from "../src/join-input.mjs";
|
|
|
28
27
|
import { run } from "../src/run.mjs";
|
|
29
28
|
import { hookMain, hooksMain } from "../src/hook.mjs";
|
|
30
29
|
import { runWebMcpCommand } from "../src/webmcp-bridge.mjs";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
30
|
+
import { doctor, humanDoctor } from "../src/doctor.mjs";
|
|
31
|
+
import { webDoctor } from "../src/web-doctor.mjs";
|
|
33
32
|
import { runWithTerminalSignals } from "../src/cli.mjs";
|
|
33
|
+
import { createDaemonLog } from "../src/daemon-log.mjs";
|
|
34
34
|
|
|
35
35
|
// 1251 — the plugin writes `hook --claude --personal`, so the client flags name
|
|
36
36
|
// the vendor for `hook` the same way they choose a target for `hooks install`.
|
|
@@ -54,13 +54,13 @@ function requiredOptionValue(argv, index, option) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function validateCommand(cmd, positional, { skipShape = false } = {}) {
|
|
57
|
-
const commands = new Set(["run", "init", "hook", "hooks", "webmcp", "web", "help", "version"]);
|
|
57
|
+
const commands = new Set(["run", "init", "doctor", "hook", "hooks", "webmcp", "web", "help", "version"]);
|
|
58
58
|
if (!commands.has(cmd)) {
|
|
59
59
|
throw new Error(`Unknown command: ${cmd}. Try \`hilos-agent --help\`.`);
|
|
60
60
|
}
|
|
61
61
|
if (skipShape) return;
|
|
62
62
|
|
|
63
|
-
if (["run", "init", "hook", "help", "version"].includes(cmd) && positional.length > 1) {
|
|
63
|
+
if (["run", "init", "doctor", "hook", "help", "version"].includes(cmd) && positional.length > 1) {
|
|
64
64
|
throw new Error(`Unexpected argument for ${cmd}: ${positional[1]}. Try \`hilos-agent --help\`.`);
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -110,6 +110,9 @@ function parseArgs(argv) {
|
|
|
110
110
|
const a = argv[i];
|
|
111
111
|
if (a === "--join") flags.join = requiredOptionValue(argv, i++, a);
|
|
112
112
|
else if (a === "--join-stdin") flags.joinStdin = true;
|
|
113
|
+
else if (a === "--human") flags.human = true;
|
|
114
|
+
// Internal test seam: probe a temporary lock root, never a real seat.
|
|
115
|
+
else if (a === "--lock-dir") flags.lockDir = requiredOptionValue(argv, i++, a);
|
|
113
116
|
else if (a === "--config") flags.config = requiredOptionValue(argv, i++, a);
|
|
114
117
|
else if (a === "--channel") flags.channelId = requiredOptionValue(argv, i++, a);
|
|
115
118
|
else if (a === "--url") flags.url = requiredOptionValue(argv, i++, a);
|
|
@@ -151,6 +154,7 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
|
|
|
151
154
|
hilos-agent --join <blob> legacy argv-compatible connect link
|
|
152
155
|
hilos-agent --join-stdin paste the private link at a no-echo prompt
|
|
153
156
|
hilos-agent init write a starter config to ~/.hilos/agent.json
|
|
157
|
+
hilos-agent doctor check Node, CLIs, web, connection, lock and config
|
|
154
158
|
hilos-agent webmcp doctor verify the local WebMCP browser bridge
|
|
155
159
|
hilos-agent webmcp login <url> open the isolated profile for person sign-in
|
|
156
160
|
hilos-agent webmcp open <url> open a person-allowlisted site for an agent
|
|
@@ -173,6 +177,7 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
|
|
|
173
177
|
hilos-agent hooks print preview the hook configuration without writing
|
|
174
178
|
|
|
175
179
|
Options:
|
|
180
|
+
--human print doctor checks as a readable table (default: JSON)
|
|
176
181
|
--channel <id> watch only one channel (per-channel override)
|
|
177
182
|
--config <path> use a specific config file
|
|
178
183
|
--url <endpoint> override the MCP endpoint (or use HILOS_URL/config)
|
|
@@ -262,6 +267,15 @@ async function main() {
|
|
|
262
267
|
return;
|
|
263
268
|
}
|
|
264
269
|
|
|
270
|
+
if (cmd === "doctor") {
|
|
271
|
+
const { join: _join, joinStdin: _joinStdin, help: _help, human, lockDir, ...cliFlags } = flags;
|
|
272
|
+
const cfg = resolveConfig({ flags: cliFlags, join: joinPayload });
|
|
273
|
+
const result = await doctor(cfg, { lockDir });
|
|
274
|
+
console.log(human ? humanDoctor(result) : JSON.stringify(result, null, 2));
|
|
275
|
+
if (!result.ok) process.exitCode = 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
265
279
|
if (cmd === "webmcp") {
|
|
266
280
|
const cliFlags = { ...flags };
|
|
267
281
|
delete cliFlags.help;
|
|
@@ -281,35 +295,9 @@ async function main() {
|
|
|
281
295
|
const cliFlags = { ...flags };
|
|
282
296
|
delete cliFlags.help;
|
|
283
297
|
const cfg = resolveConfig({ flags: cliFlags });
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const argv = commandArgv(command);
|
|
288
|
-
const vendor = detectVendor(command);
|
|
289
|
-
const capability = webCapability(vendor, {
|
|
290
|
-
enabled: cfg.webSearch !== false,
|
|
291
|
-
args: argv.slice(1),
|
|
292
|
-
});
|
|
293
|
-
const binary = argv[0] || "";
|
|
294
|
-
const probed = binary
|
|
295
|
-
? spawnSync(binary, ["--version"], { encoding: "utf8", timeout: 5_000 })
|
|
296
|
-
: null;
|
|
297
|
-
const binaryAvailable = Boolean(binary) && !probed?.error;
|
|
298
|
-
const version = String(probed?.stdout || probed?.stderr || "").trim().split("\n")[0] || null;
|
|
299
|
-
return { vendor, binary, binaryAvailable, version, command, ...capability };
|
|
300
|
-
};
|
|
301
|
-
const chat = describe(chatCommand);
|
|
302
|
-
const code = codingCommand === chatCommand ? chat : describe(codingCommand);
|
|
303
|
-
console.log(JSON.stringify({
|
|
304
|
-
ok: [chat, code].every((lane) => lane.status === "enabled" && lane.binaryAvailable),
|
|
305
|
-
chat,
|
|
306
|
-
code,
|
|
307
|
-
note: !chat.binaryAvailable || !code.binaryAvailable
|
|
308
|
-
? "One or more selected CLI binaries are not installed or not on PATH."
|
|
309
|
-
: chat.verified && code.verified
|
|
310
|
-
? "Configured by hilos-agent; this does not spend a model call or test provider credentials."
|
|
311
|
-
: "Custom commands keep their own tool configuration; hilos-agent does not guess flags.",
|
|
312
|
-
}, null, 2));
|
|
298
|
+
const result = webDoctor(cfg);
|
|
299
|
+
console.log(JSON.stringify(result, null, 2));
|
|
300
|
+
if (!result.ok) process.exitCode = 1;
|
|
313
301
|
return;
|
|
314
302
|
}
|
|
315
303
|
|
|
@@ -319,7 +307,39 @@ async function main() {
|
|
|
319
307
|
delete cliFlags.joinStdin;
|
|
320
308
|
delete cliFlags.help;
|
|
321
309
|
const cfg = resolveConfig({ flags: cliFlags, join: joinPayload });
|
|
322
|
-
|
|
310
|
+
const log = createDaemonLog();
|
|
311
|
+
let announced = false;
|
|
312
|
+
const announceLog = () => {
|
|
313
|
+
if (announced) return;
|
|
314
|
+
announced = true;
|
|
315
|
+
console.log(`log: ${log.path}`);
|
|
316
|
+
};
|
|
317
|
+
const setName = log.setName;
|
|
318
|
+
log.setName = (name) => { setName(name); announceLog(); };
|
|
319
|
+
const fatalReason = (error) => error?.name === "McpAuthStopError"
|
|
320
|
+
? `auth stop: ${error.message}`
|
|
321
|
+
: `fatal: ${error?.message || error}\n${error?.stack || ""}`;
|
|
322
|
+
const onFatal = (error) => {
|
|
323
|
+
announceLog();
|
|
324
|
+
log.close(fatalReason(error));
|
|
325
|
+
console.error(error?.message || error);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
};
|
|
328
|
+
process.on("uncaughtException", onFatal);
|
|
329
|
+
process.on("unhandledRejection", onFatal);
|
|
330
|
+
try {
|
|
331
|
+
await runWithTerminalSignals((signal) => run(cfg, { signal, log }), process, {
|
|
332
|
+
onSignal: (name) => { announceLog(); log.close(name); },
|
|
333
|
+
});
|
|
334
|
+
} catch (error) {
|
|
335
|
+
log.close(fatalReason(error));
|
|
336
|
+
throw error;
|
|
337
|
+
} finally {
|
|
338
|
+
announceLog();
|
|
339
|
+
log.close("normal stop");
|
|
340
|
+
process.removeListener("uncaughtException", onFatal);
|
|
341
|
+
process.removeListener("unhandledRejection", onFatal);
|
|
342
|
+
}
|
|
323
343
|
}
|
|
324
344
|
|
|
325
345
|
main().catch((e) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.11.
|
|
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",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hilos-connect
|
|
3
|
+
description: Connect a named agent to a hilos room with the local daemon. Use when a person asks an IDE agent to "connect me to hilos" or "join the room" using a private join code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Connect
|
|
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
|
+
|
|
15
|
+
1. Get the private join code from the person or the agent's connection dialog.
|
|
16
|
+
Treat it as a credential. Never paste it into chat, reports, or logs.
|
|
17
|
+
2. From the folder the daemon will work in, run
|
|
18
|
+
`npx hilos-agent@latest doctor --join-stdin` and supply the code through
|
|
19
|
+
stdin. Read the JSON before starting anything. Keep the same `--coding-cmd`,
|
|
20
|
+
`--chat-cmd`, `--config`, and other settings as the dialog's run command.
|
|
21
|
+
A supplied `--join <blob>` also works, but exposes the credential in argv;
|
|
22
|
+
prefer stdin. A saved config can use `npx hilos-agent@latest doctor`.
|
|
23
|
+
3. If `chat` or `code` fails, install the vendor CLI using that check's `fix`,
|
|
24
|
+
then rerun doctor. The Cursor app is not the Cursor CLI; `cursor-agent` is
|
|
25
|
+
installed separately. Refresh PATH in the launching shell if needed.
|
|
26
|
+
4. If `lock` reports `contended`, find the other daemon holding this seat
|
|
27
|
+
before starting a second. Do not delete its lock or stop it without the
|
|
28
|
+
person's direction. For a filesystem error, fix the reported directory.
|
|
29
|
+
Resolve other failed checks and rerun until the JSON says `ok: true`.
|
|
30
|
+
5. Start `npx hilos-agent@latest --join-stdin` with the same credentials and
|
|
31
|
+
settings in a process that survives your tool call ending: detach it with
|
|
32
|
+
persistent output capture, or open a separate terminal the person keeps
|
|
33
|
+
open. On Windows use PowerShell `Start-Process` to open that terminal.
|
|
34
|
+
Feed the private code through stdin or let the person paste it at the
|
|
35
|
+
hidden prompt. Never leave the daemon inside a tool call that exits and
|
|
36
|
+
kills its children.
|
|
37
|
+
6. Only after doctor is all-ok AND the daemon prints `watching: @-mentions`
|
|
38
|
+
tell the person it is online. If you cannot observe that line, say startup
|
|
39
|
+
is unconfirmed. Give the daemon's diagnostic log path,
|
|
40
|
+
`~/.hilos/logs/<handle>.log`, also printed as `log: <path>` at startup.
|
|
41
|
+
Tell them to keep its terminal open when that is how it is running.
|
package/src/cli.mjs
CHANGED
|
@@ -90,14 +90,16 @@ export function spawnPlan(cmd, args, opts = {}) {
|
|
|
90
90
|
* Let the daemon finish its existing abort/cleanup path before exiting.
|
|
91
91
|
* @param {(signal: AbortSignal) => Promise<any>} task
|
|
92
92
|
* @param {import("node:events").EventEmitter & { exitCode?: number | string }} signalSource
|
|
93
|
+
* @param {{ onSignal?: (name: string) => void }} options
|
|
93
94
|
*/
|
|
94
|
-
export async function runWithTerminalSignals(task, signalSource = process) {
|
|
95
|
+
export async function runWithTerminalSignals(task, signalSource = process, { onSignal } = {}) {
|
|
95
96
|
const controller = new AbortController();
|
|
96
97
|
const interrupt = () => stop("SIGINT", 130);
|
|
97
98
|
const terminate = () => stop("SIGTERM", 143);
|
|
98
99
|
const stop = (name, code) => {
|
|
99
100
|
if (controller.signal.aborted) return;
|
|
100
101
|
signalSource.exitCode = code;
|
|
102
|
+
onSignal?.(name);
|
|
101
103
|
controller.abort(new Error(`Daemon received ${name}`));
|
|
102
104
|
};
|
|
103
105
|
signalSource.once("SIGINT", interrupt);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Local diagnostic log. Synchronous, bounded writes also survive process.exit;
|
|
2
|
+
// logging must never become a reason the daemon stops serving its room.
|
|
3
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { format } from "node:util";
|
|
7
|
+
import { redactSecrets } from "./redact.mjs";
|
|
8
|
+
|
|
9
|
+
export function createDaemonLog({
|
|
10
|
+
dir = join(homedir(), ".hilos", "logs"),
|
|
11
|
+
name = `agent-${process.pid}`,
|
|
12
|
+
console: base = console,
|
|
13
|
+
redact = redactSecrets,
|
|
14
|
+
maxBytes = 2 * 1024 * 1024,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const logPath = (value) => join(dir, `${String(value || "").replace(/[^a-zA-Z0-9-]/g, "") || `agent-${process.pid}`}.log`);
|
|
17
|
+
let path = logPath(name);
|
|
18
|
+
let warned = false;
|
|
19
|
+
let closed = false;
|
|
20
|
+
const bestEffort = (write) => {
|
|
21
|
+
try { write(); } catch {
|
|
22
|
+
if (warned) return;
|
|
23
|
+
warned = true;
|
|
24
|
+
base.error("hilos-agent: could not write the local daemon log; continuing without it.");
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const append = (target, text) => {
|
|
28
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
let bytes = Buffer.from(text);
|
|
30
|
+
// Redaction happens before this cap, so truncating cannot reveal a secret.
|
|
31
|
+
if (bytes.length > maxBytes) bytes = bytes.subarray(bytes.length - maxBytes);
|
|
32
|
+
if (existsSync(target) && statSync(target).size + bytes.length > maxBytes) {
|
|
33
|
+
if (existsSync(`${target}.1`)) unlinkSync(`${target}.1`);
|
|
34
|
+
renameSync(target, `${target}.1`);
|
|
35
|
+
}
|
|
36
|
+
appendFileSync(target, bytes, { mode: 0o600 });
|
|
37
|
+
chmodSync(target, 0o600);
|
|
38
|
+
};
|
|
39
|
+
const write = (level, args) => bestEffort(() => {
|
|
40
|
+
const timestamp = new Date().toISOString();
|
|
41
|
+
const message = redact(format(...args));
|
|
42
|
+
const prefix = `${timestamp} ${level} `;
|
|
43
|
+
const available = Math.max(0, maxBytes - Buffer.byteLength(prefix) - 1);
|
|
44
|
+
for (const line of message.split(/\r?\n/)) {
|
|
45
|
+
// Keep the timestamp/level (and the beginning of an exit reason) even
|
|
46
|
+
// when one diagnostic line is larger than the entire file budget.
|
|
47
|
+
const bounded = Buffer.from(line).subarray(0, available).toString("utf8").replace(/\uFFFD$/, "");
|
|
48
|
+
append(path, `${prefix}${bounded}\n`);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// Make the provisional path readable even if startup fails before whoami.
|
|
52
|
+
bestEffort(() => append(path, ""));
|
|
53
|
+
return {
|
|
54
|
+
get path() { return path; },
|
|
55
|
+
log(...args) {
|
|
56
|
+
base.log(...args);
|
|
57
|
+
if (!closed) write("INFO", args);
|
|
58
|
+
},
|
|
59
|
+
error(...args) {
|
|
60
|
+
base.error(...args);
|
|
61
|
+
if (!closed) write("ERROR", args);
|
|
62
|
+
},
|
|
63
|
+
setName(value) {
|
|
64
|
+
if (closed) return;
|
|
65
|
+
const next = logPath(value);
|
|
66
|
+
if (next === path) return;
|
|
67
|
+
bestEffort(() => {
|
|
68
|
+
// Append to an earlier session's log instead of overwriting it. Include
|
|
69
|
+
// a provisional rotation too, oldest first, under the same size bound.
|
|
70
|
+
for (const source of [`${path}.1`, path]) {
|
|
71
|
+
if (!existsSync(source)) continue;
|
|
72
|
+
append(next, readFileSync(source));
|
|
73
|
+
unlinkSync(source);
|
|
74
|
+
}
|
|
75
|
+
path = next;
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
close(reason = "normal stop") {
|
|
79
|
+
if (closed) return;
|
|
80
|
+
write("INFO", [`exit: ${reason}`]);
|
|
81
|
+
closed = true; // A signal/fatal reason must not become "normal stop".
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { commandArgv } from "./argv.mjs";
|
|
4
|
+
import { resolveCommand } from "./cli.mjs";
|
|
5
|
+
import { GLOBAL_CONFIG, LOCAL_CONFIG } from "./config.mjs";
|
|
6
|
+
import { mentionHandle } from "./daemon.mjs";
|
|
7
|
+
import { installHint } from "./install-hint.mjs";
|
|
8
|
+
import { createIterateClaimRecoveryStore } from "./iterate-claim-recovery.mjs";
|
|
9
|
+
import { makeClient } from "./mcp.mjs";
|
|
10
|
+
import { detectVendor, fastChatCmd } from "./progress-emitter.mjs";
|
|
11
|
+
import { webDoctor } from "./web-doctor.mjs";
|
|
12
|
+
|
|
13
|
+
const nodeEngine = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).engines.node;
|
|
14
|
+
|
|
15
|
+
export function checkNode(version = process.versions.node) {
|
|
16
|
+
const floor = nodeEngine.replace(/^>=\s*/, "").split(".").map(Number);
|
|
17
|
+
const current = version.split(".").map(Number);
|
|
18
|
+
let comparison = 0;
|
|
19
|
+
for (let i = 0; i < 3 && comparison === 0; i++) {
|
|
20
|
+
comparison = (current[i] || 0) - (floor[i] || 0);
|
|
21
|
+
}
|
|
22
|
+
const ok = comparison >= 0;
|
|
23
|
+
return {
|
|
24
|
+
name: "node", ok, detail: `Node ${version}; requires ${nodeEngine}`,
|
|
25
|
+
...(!ok ? { fix: `Install Node.js ${nodeEngine}.` } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A pre-flight only: whoami, then briefly own and release the local seat. */
|
|
30
|
+
export async function doctor(cfg, { lockDir } = {}) {
|
|
31
|
+
const checks = [checkNode()];
|
|
32
|
+
const chatCommand = cfg.chatCmd || fastChatCmd(detectVendor(cfg.codingCmd)) || cfg.codingCmd;
|
|
33
|
+
for (const [name, command] of [["chat", chatCommand], ["code", cfg.codingCmd]]) {
|
|
34
|
+
const binary = commandArgv(command)[0] || "";
|
|
35
|
+
const vendor = detectVendor(command);
|
|
36
|
+
const resolved = resolveCommand(binary);
|
|
37
|
+
checks.push({
|
|
38
|
+
name, ok: Boolean(resolved),
|
|
39
|
+
detail: `${resolved || `${binary || "(empty command)"} is not installed or not on PATH`}${vendor === "cursor" ? "; the Cursor CLI is separate from the Cursor app" : ""}`,
|
|
40
|
+
...(!resolved ? { fix: installHint(vendor) } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const web = webDoctor(cfg);
|
|
45
|
+
checks.push({
|
|
46
|
+
name: "web", ok: web.ok,
|
|
47
|
+
detail: `chat: ${web.chat.vendor} ${web.chat.version || "(version unavailable)"}, ${web.chat.source} (${web.chat.status}); code: ${web.code.vendor} ${web.code.version || "(version unavailable)"}, ${web.code.source} (${web.code.status}). ${web.note}`,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let who;
|
|
51
|
+
try {
|
|
52
|
+
if (!cfg.token) throw new Error("No agent token configured. Get a private join code from hilos (Connect agent).");
|
|
53
|
+
const client = makeClient({
|
|
54
|
+
url: cfg.url, token: cfg.token, timeoutMs: 5_000,
|
|
55
|
+
// A one-shot diagnostic is activity, never proof of a live daemon.
|
|
56
|
+
connectionMode: "on_demand",
|
|
57
|
+
// The daemon's default retry timer is unref'd. Doctor has no poll loop
|
|
58
|
+
// to keep it alive, so retain this timer until the JSON can be printed.
|
|
59
|
+
delay: (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
60
|
+
});
|
|
61
|
+
const identity = await client.tool("whoami");
|
|
62
|
+
const handle = mentionHandle(identity?.agentName);
|
|
63
|
+
if (!identity?.agentId || !handle || !identity?.workspaceId) {
|
|
64
|
+
throw new Error("whoami did not return a named agent with a workspace. Use the agent's private join code.");
|
|
65
|
+
}
|
|
66
|
+
who = identity;
|
|
67
|
+
checks.push({
|
|
68
|
+
name: "connection", ok: true,
|
|
69
|
+
detail: `${who.agentName} (@${handle}); workspace: ${who.workspaceId}; server: ${cfg.url}`,
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
checks.push({ name: "connection", ok: false, detail: `${cfg.url}: ${error?.message || error}` });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!who) {
|
|
76
|
+
checks.push({ name: "lock", ok: false, detail: "Not checked: connection must identify the agent first." });
|
|
77
|
+
} else {
|
|
78
|
+
let store;
|
|
79
|
+
let acquired = false;
|
|
80
|
+
let check;
|
|
81
|
+
try {
|
|
82
|
+
store = createIterateClaimRecoveryStore({
|
|
83
|
+
agentId: who.agentId, url: cfg.url, token: cfg.token,
|
|
84
|
+
...(lockDir ? { dir: lockDir } : {}),
|
|
85
|
+
});
|
|
86
|
+
acquired = store.acquireLock();
|
|
87
|
+
const failure = store.acquireFailure();
|
|
88
|
+
check = {
|
|
89
|
+
name: "lock", ok: acquired,
|
|
90
|
+
detail: acquired ? "Seat acquired and released." : failure || "Could not acquire the local run lock.",
|
|
91
|
+
...(!acquired ? { fix: failure === "contended"
|
|
92
|
+
? "another daemon holds this seat; find it before starting a second daemon"
|
|
93
|
+
: "Make the lock directory writable, then rerun doctor." } : {}),
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
check = { name: "lock", ok: false, detail: String(error?.message || error) };
|
|
97
|
+
} finally {
|
|
98
|
+
if (acquired && !store.releaseLock()) {
|
|
99
|
+
check = { name: "lock", ok: false, detail: "The local run lock could not be released." };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
checks.push(check);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const path = cfg.configPath && existsSync(cfg.configPath) ? resolve(cfg.configPath) : null;
|
|
106
|
+
const source = !path ? "none" : path === resolve(GLOBAL_CONFIG) ? "global"
|
|
107
|
+
: path === resolve(LOCAL_CONFIG) ? "local" : "explicit";
|
|
108
|
+
checks.push({
|
|
109
|
+
name: "config", ok: true,
|
|
110
|
+
detail: `${source}${path ? `: ${path}` : " (defaults, environment, join code and flags)"}; working folder: ${process.cwd()}`,
|
|
111
|
+
});
|
|
112
|
+
return { ok: checks.every((check) => check.ok), checks };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function humanDoctor(result) {
|
|
116
|
+
return result.checks.map(({ name, ok, detail, fix }) =>
|
|
117
|
+
`${ok ? "ok " : "fail"} ${name.padEnd(10)} ${[detail, fix && `Fix: ${fix}`].filter(Boolean).join(" ").replace(/\s+/g, " ")}`,
|
|
118
|
+
).join("\n");
|
|
119
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Where to get the vendor CLI a daemon command names (1330). */
|
|
2
|
+
export function installHint(vendor) {
|
|
3
|
+
if (vendor === "cursor") return "the Cursor CLI is separate from the Cursor app (macOS/Linux: curl https://cursor.com/install -fsS | bash; Windows: irm https://cursor.com/install -useb | iex)";
|
|
4
|
+
if (vendor === "claude_code") return "npm install -g @anthropic-ai/claude-code";
|
|
5
|
+
if (vendor === "codex") return "npm install -g @openai/codex";
|
|
6
|
+
if (vendor === "opencode") return "https://opencode.ai";
|
|
7
|
+
return "";
|
|
8
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -203,6 +203,7 @@ function assertJsonRpcResponse(json, expectedId) {
|
|
|
203
203
|
export function makeClient({
|
|
204
204
|
url,
|
|
205
205
|
token,
|
|
206
|
+
connectionMode = "daemon",
|
|
206
207
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
207
208
|
retryDelayMs = RETRY_DELAY_MS,
|
|
208
209
|
delay = defaultDelay,
|
|
@@ -235,7 +236,7 @@ export function makeClient({
|
|
|
235
236
|
"content-type": "application/json",
|
|
236
237
|
accept: "application/json, text/event-stream",
|
|
237
238
|
authorization: `Bearer ${token}`,
|
|
238
|
-
"x-hilos-connection-mode":
|
|
239
|
+
"x-hilos-connection-mode": connectionMode,
|
|
239
240
|
...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
|
|
240
241
|
...(options.ambientBinding
|
|
241
242
|
? { "x-hilos-ambient-binding": options.ambientBinding }
|
package/src/run.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { scanReplyBridge, handleReplyBridgeJob } from "./reply-bridge.mjs";
|
|
|
16
16
|
import { detectVendor, fastChatCmd, webCapability } from "./progress-emitter.mjs";
|
|
17
17
|
import { commandArgv } from "./argv.mjs";
|
|
18
18
|
import { resolveCommand } from "./cli.mjs";
|
|
19
|
+
import { installHint } from "./install-hint.mjs";
|
|
19
20
|
import {
|
|
20
21
|
createIterateClaimRecoveryStore,
|
|
21
22
|
reconcileDaemonIterateClaims,
|
|
@@ -32,15 +33,6 @@ const HILOS_DAEMON_TOOLS = Object.freeze({
|
|
|
32
33
|
decisionWaitMs: 20_000,
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
/** Where to get the vendor CLI a daemon command names (1330). */
|
|
36
|
-
function installHint(vendor) {
|
|
37
|
-
if (vendor === "cursor") return "the Cursor CLI is separate from the Cursor app (macOS/Linux: curl https://cursor.com/install -fsS | bash; Windows: irm https://cursor.com/install -useb | iex)";
|
|
38
|
-
if (vendor === "claude_code") return "npm install -g @anthropic-ai/claude-code";
|
|
39
|
-
if (vendor === "codex") return "npm install -g @openai/codex";
|
|
40
|
-
if (vendor === "opencode") return "https://opencode.ai";
|
|
41
|
-
return "";
|
|
42
|
-
}
|
|
43
|
-
|
|
44
36
|
/**
|
|
45
37
|
* The poll loop. Embeddable: pass a `signal` to stop it cleanly (interrupts the
|
|
46
38
|
* inter-poll sleep, cancels the active job, then resolves) and an `onEvent`
|
|
@@ -77,6 +69,8 @@ export async function run(
|
|
|
77
69
|
iterateClaimShutdownReconcileTimeoutMs = 1000,
|
|
78
70
|
} = {},
|
|
79
71
|
) {
|
|
72
|
+
let exitError = null;
|
|
73
|
+
try {
|
|
80
74
|
if (!cfg.token) {
|
|
81
75
|
// Throw, don't process.exit — the CLI's main().catch prints + exits 1 exactly
|
|
82
76
|
// as before, and an embedding host (the desktop Local Agents runner) must
|
|
@@ -164,6 +158,7 @@ export async function run(
|
|
|
164
158
|
// Throw for the same reason as the token guard above — embed-safe, CLI-equal.
|
|
165
159
|
throw new Error("This agent has no display name / handle — set one in hilos, then reconnect.");
|
|
166
160
|
}
|
|
161
|
+
log.setName?.(me.handle);
|
|
167
162
|
// Initialized after discovery so the recovery lock is scoped to this agent.
|
|
168
163
|
let iterateClaimRecoveryStore = null;
|
|
169
164
|
let queue = null;
|
|
@@ -954,4 +949,14 @@ export async function run(
|
|
|
954
949
|
iterateClaimRecoveryStore?.releaseLock();
|
|
955
950
|
if (isAuthStop(stopSignal.reason)) throw stopSignal.reason;
|
|
956
951
|
}
|
|
952
|
+
} catch (error) {
|
|
953
|
+
exitError = error;
|
|
954
|
+
throw error;
|
|
955
|
+
} finally {
|
|
956
|
+
log.close?.(exitError?.name === "McpAuthStopError"
|
|
957
|
+
? `auth stop: ${exitError.message}`
|
|
958
|
+
: exitError
|
|
959
|
+
? `fatal: ${exitError.message || exitError}\n${exitError.stack || ""}`
|
|
960
|
+
: "normal stop");
|
|
961
|
+
}
|
|
957
962
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { commandArgv } from "./argv.mjs";
|
|
3
|
+
import { resolveCommand, spawnPlan } from "./cli.mjs";
|
|
4
|
+
import { detectVendor, fastChatCmd, webCapability } from "./progress-emitter.mjs";
|
|
5
|
+
|
|
6
|
+
/** Inspect native web configuration without a model call or provider login. */
|
|
7
|
+
export function webDoctor(cfg) {
|
|
8
|
+
const codingCommand = cfg.codingCmd;
|
|
9
|
+
const chatCommand = cfg.chatCmd || fastChatCmd(detectVendor(codingCommand)) || codingCommand;
|
|
10
|
+
const describe = (command) => {
|
|
11
|
+
const argv = commandArgv(command);
|
|
12
|
+
const vendor = detectVendor(command);
|
|
13
|
+
const capability = webCapability(vendor, {
|
|
14
|
+
enabled: cfg.webSearch !== false,
|
|
15
|
+
args: argv.slice(1),
|
|
16
|
+
});
|
|
17
|
+
const binary = argv[0] || "";
|
|
18
|
+
const resolved = resolveCommand(binary);
|
|
19
|
+
// npm's Windows .cmd shims need the same ComSpec plan as an actual run.
|
|
20
|
+
const plan = resolved ? spawnPlan(resolved, ["--version"]) : null;
|
|
21
|
+
const probed = plan
|
|
22
|
+
? spawnSync(plan.file, plan.args, {
|
|
23
|
+
encoding: "utf8", timeout: 5_000,
|
|
24
|
+
windowsVerbatimArguments: plan.windowsVerbatimArguments,
|
|
25
|
+
})
|
|
26
|
+
: null;
|
|
27
|
+
const binaryAvailable = Boolean(resolved) && !probed?.error && probed?.status === 0;
|
|
28
|
+
const version = String(probed?.stdout || probed?.stderr || "").trim().split("\n")[0] || null;
|
|
29
|
+
return { vendor, binary, binaryAvailable, version, command, ...capability };
|
|
30
|
+
};
|
|
31
|
+
const chat = describe(chatCommand);
|
|
32
|
+
const code = codingCommand === chatCommand ? chat : describe(codingCommand);
|
|
33
|
+
const lanes = [chat, code];
|
|
34
|
+
return {
|
|
35
|
+
// Disabling web or keeping the CLI's own policy is a valid setup choice.
|
|
36
|
+
// Every command still has to exist and pass its version probe.
|
|
37
|
+
ok: lanes.every((lane) => lane.binaryAvailable &&
|
|
38
|
+
["enabled", "disabled", "operator-controlled", "inherited"].includes(lane.status)),
|
|
39
|
+
chat,
|
|
40
|
+
code,
|
|
41
|
+
note: !chat.binaryAvailable || !code.binaryAvailable
|
|
42
|
+
? "One or more selected CLI binaries are not installed, not on PATH, or failed their version probe."
|
|
43
|
+
: lanes.some((lane) => ["disabled", "operator-controlled"].includes(lane.status))
|
|
44
|
+
? "Web enablement is off by choice in one or more commands; the CLI's own policy still applies."
|
|
45
|
+
: lanes.some((lane) => lane.status === "inherited")
|
|
46
|
+
? "Web configuration is inherited by choice from the selected CLI; hilos-agent does not guess flags."
|
|
47
|
+
: "Configured by hilos-agent; this does not spend a model call or test provider credentials.",
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/webmcp-bridge.mjs
CHANGED
|
@@ -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
|
|
22
|
-
const
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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;
|
package/src/webmcp-init.js
CHANGED
|
@@ -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
|
|
14
|
-
const
|
|
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 >
|
|
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
|
-
|
|
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
|
|
150
|
-
|
|
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 >
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
}
|