hilos-agent 0.11.2 → 0.11.4
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 +95 -61
- package/bin/hilos-agent.mjs +3 -3
- package/package.json +2 -2
- package/src/handler.mjs +2 -1
- package/src/mcp-loopback.mjs +25 -8
- package/src/mcp.mjs +78 -3
- package/src/reply-bridge.mjs +80 -20
- package/src/run.mjs +32 -4
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# hilos-agent
|
|
2
2
|
|
|
3
|
-
Run **your own** coding agent — Claude Code, Codex, Cursor, opencode,
|
|
3
|
+
Run **your own** coding agent — Claude Code, Codex, Cursor, opencode,
|
|
4
|
+
Antigravity, Hermes, or any command — as
|
|
4
5
|
an autonomous teammate inside a [hilos](https://hilos.sh) channel.
|
|
5
6
|
|
|
6
7
|
It connects to hilos over MCP, watches for `@mentions` of your agent in a
|
|
7
8
|
git-linked channel (including thread replies) and for **every message in a DM
|
|
8
9
|
with your agent — no tag needed there**, runs your coding agent in a
|
|
9
|
-
**local** checkout, and — by default — **opens a PR for review**.
|
|
10
|
-
your git
|
|
10
|
+
**local** checkout, and — by default — **opens a PR for review**. The checkout
|
|
11
|
+
and your coding-provider/git credentials stay on your machine. Changes go to the
|
|
12
|
+
configured Git remote; hilos receives bounded plans, progress, reports, and—when
|
|
13
|
+
the pre-push gate is enabled—the proposal diff.
|
|
11
14
|
|
|
12
15
|
```
|
|
13
16
|
hilos channel ──MCP/HTTPS──▶ hilos-agent (your laptop)
|
|
@@ -19,13 +22,13 @@ hilos channel ──MCP/HTTPS──▶ hilos-agent (your laptop)
|
|
|
19
22
|
Changes ▶ agent re-works with your note
|
|
20
23
|
```
|
|
21
24
|
|
|
22
|
-
Prefer **approve-before-push**? Set `"gate": true` — the agent
|
|
23
|
-
proposed diff as a card
|
|
24
|
-
|
|
25
|
+
Prefer **approve-before-push**? Set `"gate": true` — the agent posts a bounded
|
|
26
|
+
proposed diff to hilos as a card, then pushes the change to the Git remote only
|
|
27
|
+
after you Approve.
|
|
25
28
|
|
|
26
29
|
## Quick start
|
|
27
30
|
|
|
28
|
-
In hilos: open your agent's profile → **Connect** → **Run in channel**. Copy the
|
|
31
|
+
In hilos: open your agent's profile → **Connect agent** → **Run in channel**. Copy the
|
|
29
32
|
terminal command and run it from inside your repo's folder. It waits at a hidden
|
|
30
33
|
prompt; copy the private join code from Hilos and paste it there. The daemon
|
|
31
34
|
matches the repo by its git remote, so no config is needed, and the reusable
|
|
@@ -39,30 +42,42 @@ npx hilos-agent@latest --join-stdin # then paste the private join code when ask
|
|
|
39
42
|
Previously copied `--join <blob>` commands remain compatible. New commands use
|
|
40
43
|
stdin because the blob contains the agent token and should not live in argv.
|
|
41
44
|
|
|
42
|
-
Running from elsewhere, or want to map several repos explicitly?
|
|
45
|
+
Running from elsewhere, or want to map several repos explicitly? Save this as
|
|
46
|
+
`~/.hilos/agent.json` (or `./hilos-agent.json`). The file is strict JSON, so it
|
|
47
|
+
cannot contain comments:
|
|
43
48
|
|
|
44
|
-
```
|
|
45
|
-
// ~/.hilos/agent.json (or ./hilos-agent.json)
|
|
49
|
+
```json
|
|
46
50
|
{
|
|
47
51
|
"url": "https://hilos.sh/api/mcp",
|
|
48
52
|
"token": "mgo_…",
|
|
49
53
|
"repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
|
|
50
|
-
"codingCmd": "claude -p --permission-mode acceptEdits",
|
|
51
|
-
"codingModel": "",
|
|
52
|
-
"chatCmd": "",
|
|
53
|
-
"webSearch": true,
|
|
54
|
+
"codingCmd": "claude -p --permission-mode acceptEdits",
|
|
55
|
+
"codingModel": "",
|
|
56
|
+
"chatCmd": "",
|
|
57
|
+
"webSearch": true,
|
|
54
58
|
"defaultBranch": "main",
|
|
55
|
-
"gate": false,
|
|
56
|
-
"heartbeatMs": 180000,
|
|
57
|
-
"chatTimeoutMs": 90000,
|
|
58
|
-
"longPollMs": 20000,
|
|
59
|
-
"catchupMs": 86400000
|
|
59
|
+
"gate": false,
|
|
60
|
+
"heartbeatMs": 180000,
|
|
61
|
+
"chatTimeoutMs": 90000,
|
|
62
|
+
"longPollMs": 20000,
|
|
63
|
+
"catchupMs": 86400000
|
|
60
64
|
}
|
|
61
65
|
```
|
|
62
66
|
|
|
67
|
+
`codingCmd` above is the safe default; it can also be `codex exec`,
|
|
68
|
+
`cursor-agent -p --output-format text --trust`, `opencode run`, `agy -p`, or a
|
|
69
|
+
custom command. `codingModel` accepts the Codex tiers `most-capable`, `balanced`,
|
|
70
|
+
or `fastest`; empty uses the tool's default. Empty `chatCmd` derives a fast chat
|
|
71
|
+
command from `codingCmd`. `webSearch: false` disables hilos's web-search
|
|
72
|
+
enablement, and `gate: true` switches to approve-before-push. `heartbeatMs: 0`
|
|
73
|
+
disables the heartbeat (minimum nonzero value 15 seconds); `longPollMs: 0` uses
|
|
74
|
+
plain five-second polling; `catchupMs: 0` restarts at now instead of replaying
|
|
75
|
+
the default 24-hour window.
|
|
76
|
+
|
|
63
77
|
**Staying responsive.** Every code task posts an **instant acknowledgement**
|
|
64
|
-
(under a second), and — if your server exposes `edit_message` and a
|
|
65
|
-
|
|
78
|
+
(under a second), and — if your server exposes `edit_message` and a fast chat
|
|
79
|
+
command can be resolved (explicit `chatCmd` or one derived from `codingCmd`) —
|
|
80
|
+
a quick **plan** ("On it — I'll do X, then open a PR") edits into it. On a
|
|
66
81
|
run longer than `heartbeatMs` (default 3 min; env `HILOS_HEARTBEAT_MS`, `0`
|
|
67
82
|
disables, clamped to ≥15s) the agent posts **one progress reply** in the thread
|
|
68
83
|
then edits it in place with elapsed time + the CLI's latest line — so the channel
|
|
@@ -98,7 +113,7 @@ The local daemon can give its coding and chat agents a narrow bridge to
|
|
|
98
113
|
third-party WebMCP sites. It is off until you name exact origins and exact read
|
|
99
114
|
tools in the machine's config:
|
|
100
115
|
|
|
101
|
-
```
|
|
116
|
+
```json
|
|
102
117
|
{
|
|
103
118
|
"webMcp": {
|
|
104
119
|
"origins": {
|
|
@@ -152,8 +167,10 @@ access its operator granted. See the complete contract in
|
|
|
152
167
|
it", "approved", or "go for it" after a request, in any language → a code run.
|
|
153
168
|
- **Repo resolution** — the channel's linked repo is mapped to a local path via
|
|
154
169
|
`repos`. No mapping → the agent says so and stops.
|
|
155
|
-
- **Run** — it
|
|
156
|
-
`codingCmd` with the task,
|
|
170
|
+
- **Run** — it temporarily stashes a dirty checkout, branches off
|
|
171
|
+
`defaultBranch`, runs `codingCmd` with the task, stages the result, and attempts
|
|
172
|
+
to restore the person's pre-existing work on every exit path. If `stash pop`
|
|
173
|
+
conflicts, the stash stays intact and the room gets the recovery command.
|
|
157
174
|
- **Continue the thread's PR** — when the mention lands in a thread hilos says is
|
|
158
175
|
about a pull request, the daemon works on *that* PR instead of opening a second
|
|
159
176
|
one: it fetches the PR's head branch, commits there, and the same PR updates.
|
|
@@ -192,8 +209,7 @@ to a plain **local folder** and the agent works in it **directly** — it edits
|
|
|
192
209
|
files in place (no branch, no commit, no PR), then posts a report of what changed.
|
|
193
210
|
It's your own machine, so this is the same trust as running the CLI yourself.
|
|
194
211
|
|
|
195
|
-
```
|
|
196
|
-
// hilos-agent.json
|
|
212
|
+
```json
|
|
197
213
|
{
|
|
198
214
|
"folders": { "<channelId>": "/Users/you/notes-site" }
|
|
199
215
|
}
|
|
@@ -227,7 +243,7 @@ It's your own machine, so this is the same trust as running the CLI yourself.
|
|
|
227
243
|
|
|
228
244
|
Add an optional per-channel deploy target next to `folders`:
|
|
229
245
|
|
|
230
|
-
```
|
|
246
|
+
```json
|
|
231
247
|
{
|
|
232
248
|
"folders": { "<channelId>": "/Users/you/notes-site" },
|
|
233
249
|
"deploy": { "<channelId>": { "provider": "vercel", "prod": false } }
|
|
@@ -282,30 +298,29 @@ touching the token.
|
|
|
282
298
|
|
|
283
299
|
## Permissions / autonomy
|
|
284
300
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
"codingCmd": "claude -p --dangerously-skip-permissions"
|
|
301
|
+
There are two separate gates:
|
|
302
|
+
|
|
303
|
+
- **Room runtime permissions.** When the workspace has granted runtime
|
|
304
|
+
permissions, current daemons put Claude Code, Codex, Cursor, and OpenCode
|
|
305
|
+
behind the same server-authorized card flow. A tool ask pauses mechanically
|
|
306
|
+
and resumes only after an eligible channel member chooses **Allow once**, an
|
|
307
|
+
exact harness-suggested **Always** rule, or **Deny**. Cursor and OpenCode use
|
|
308
|
+
ACP; Claude Code uses its permission-prompt tool; Codex uses `codex
|
|
309
|
+
mcp-server` because `codex exec` has no approval transport. Once a relay is
|
|
310
|
+
active, authority loss, expiry, cancellation, and transport failure all fail
|
|
311
|
+
closed. A positively unavailable old-CLI transport is the compatibility
|
|
312
|
+
exception: the daemon warns the room, then starts the ungated fallback.
|
|
313
|
+
Explicit vendor bypass modes such as `opencode run --auto` or Claude Code's
|
|
314
|
+
`--dangerously-skip-permissions` deliberately opt out of the cards.
|
|
315
|
+
- **The CLI's own mode.** Without a workspace runtime grant, `codingCmd` remains
|
|
316
|
+
authoritative. The default Claude Code command uses
|
|
317
|
+
`--permission-mode acceptEdits`: it may edit without prompting, but a headless
|
|
318
|
+
command that needs additional approval can stall. For a fully independent
|
|
319
|
+
local agent, `--dangerously-skip-permissions` can run any command in the repo
|
|
320
|
+
you point it at, so use it only where that trust is acceptable.
|
|
321
|
+
|
|
322
|
+
```json
|
|
323
|
+
{ "codingCmd": "claude -p --dangerously-skip-permissions" }
|
|
309
324
|
```
|
|
310
325
|
|
|
311
326
|
- **Approve-before-push (`gate:true`), most cautious.** Independent of the two
|
|
@@ -314,9 +329,9 @@ safest first:
|
|
|
314
329
|
|
|
315
330
|
The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
|
|
316
331
|
you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
|
|
317
|
-
before anything is pushed.
|
|
318
|
-
|
|
319
|
-
|
|
332
|
+
before anything is pushed. Runtime cards and the push gate are independent: one
|
|
333
|
+
governs tools during the run, the other governs when the resulting code leaves
|
|
334
|
+
the machine.
|
|
320
335
|
|
|
321
336
|
## Hooks — keep a raw Codex, Claude Code, or Cursor session in the room
|
|
322
337
|
|
|
@@ -365,8 +380,9 @@ npx hilos-agent@latest hooks print
|
|
|
365
380
|
live card. The normal one-line `--join` flow does not need that file for
|
|
366
381
|
replies: its token stays in daemon memory, and Hilos signs each session
|
|
367
382
|
binding so the daemon can verify it before resuming local code.
|
|
368
|
-
- Keep `hilos-agent` running to pick replies up. Hooks
|
|
369
|
-
|
|
383
|
+
- Keep `hilos-agent` running to pick replies up. Hooks only emit lifecycle
|
|
384
|
+
events; the daemon is the consumer that waits on the server's wake/long-poll
|
|
385
|
+
path and resumes the local session.
|
|
370
386
|
|
|
371
387
|
**What you get:**
|
|
372
388
|
- Team members see "Editing lib/x.ts" or "Running pnpm test" on the agent's live
|
|
@@ -376,12 +392,23 @@ npx hilos-agent@latest hooks print
|
|
|
376
392
|
`hilos-agent` is running. Ambient messages, older thread history, and agent
|
|
377
393
|
replies do not wake it. Current workspace roles are checked at pickup: guests,
|
|
378
394
|
removed people, and unknown authors remain advisory and cannot start code.
|
|
395
|
+
- Every initial mention job is also signed for its exact agent-token row and
|
|
396
|
+
answer room after final server revalidation. The daemon keeps that proof on a
|
|
397
|
+
job-local MCP client, so concurrent rooms cannot exchange project context. A
|
|
398
|
+
guest-visible room may be explicitly allowed to run code, but its task still
|
|
399
|
+
cannot pull workspace memory, Docs, Tasks, sibling conversations, or external
|
|
400
|
+
context into the answer.
|
|
379
401
|
- The resumed turn receives bounded thread, room, and member context. Codex also
|
|
380
402
|
gets a random loopback-only Hilos MCP URL for that turn, so it can search any
|
|
381
403
|
room its linked owner can access when that owner minted the current key,
|
|
382
404
|
without receiving the bearer token. Regenerate an older key from Connect to
|
|
383
405
|
enable that inheritance. Admin-issued keys and other people's private rooms
|
|
384
|
-
stay outside the context.
|
|
406
|
+
stay outside the context. Hilos signs the turn's room into a short-lived
|
|
407
|
+
claim tied to that exact agent-token row; the loopback fixes the claim in an
|
|
408
|
+
upstream header that the coding child cannot replace. A missing, malformed,
|
|
409
|
+
cross-token, or conflicting room claim is refused, and a guest-visible turn
|
|
410
|
+
cannot use workspace-wide tools to pull private sibling context into its
|
|
411
|
+
answer.
|
|
385
412
|
- The room's normal execution gate still applies; a chat-only guest room cannot
|
|
386
413
|
resume local code.
|
|
387
414
|
- Steps are coalesced into ~2s batches to keep traffic light.
|
|
@@ -422,8 +449,9 @@ An older server simply leaves it off.
|
|
|
422
449
|
### Run transcripts are opt-in
|
|
423
450
|
|
|
424
451
|
The room gets what a teammate needs to see: a plan, live progress, a report, a
|
|
425
|
-
PR. The coding tool's
|
|
426
|
-
|
|
452
|
+
PR. The coding tool's raw stream is **not** part of that by default. Current
|
|
453
|
+
servers do keep a bounded, redacted set of structured activity facts through
|
|
454
|
+
the runtime-neutral event protocol described above; that is not a transcript.
|
|
427
455
|
|
|
428
456
|
Set `"uploadTranscripts": true` and the daemon, after posting its report, sends
|
|
429
457
|
the tail of that run's stream to hilos, where it becomes the same transcript a
|
|
@@ -457,8 +485,14 @@ arguments can be visible to other processes on the machine, so use
|
|
|
457
485
|
Likewise, prefer `--join-stdin` over the legacy `--join <blob>` form for a new
|
|
458
486
|
connection.
|
|
459
487
|
|
|
460
|
-
|
|
461
|
-
|
|
488
|
+
See `hilos-agent --help` for the `init`, `webmcp`, `web doctor`, and `hooks`
|
|
489
|
+
subcommands.
|
|
490
|
+
|
|
491
|
+
Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`,
|
|
492
|
+
`HILOS_CODING_MODEL`, `HILOS_CHAT_CMD`, `HILOS_WEB_SEARCH=1|0`,
|
|
493
|
+
`HILOS_ACP=1|0`, `HILOS_HEARTBEAT_MS`, `HILOS_PROGRESS_MS`,
|
|
494
|
+
`HILOS_CHAT_TIMEOUT_MS`, `HILOS_REPLY_BRIDGE=1|0`, `HILOS_ONCE=1`,
|
|
495
|
+
`HILOS_BACKFILL=1`, and `HILOS_UPLOAD_TRANSCRIPTS=1|0`.
|
|
462
496
|
|
|
463
497
|
## Releasing
|
|
464
498
|
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// hilos-agent — run your coding agent as an autonomous teammate in a hilos
|
|
3
|
-
// channel. Picks up @mentions and opens a PR by default;
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// channel. Picks up @mentions and opens a PR by default; an optional pre-push
|
|
4
|
+
// gate waits for a person's approval. The checkout and credentials stay local;
|
|
5
|
+
// changes go to the configured Git remote and bounded run state goes to hilos.
|
|
6
6
|
//
|
|
7
7
|
// Usage:
|
|
8
8
|
// hilos-agent --join <blob> connect with a copy-paste link from hilos
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.11.
|
|
4
|
-
"description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room.
|
|
3
|
+
"version": "0.11.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": {
|
|
7
7
|
"hilos-agent": "bin/hilos-agent.mjs"
|
package/src/handler.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Turn an @mention in a git-linked channel into a branch + a coding-agent run.
|
|
7
7
|
// By default it opens a PR for review; with gate:true it posts a proposed diff
|
|
8
|
-
// and pushes only after approval.
|
|
8
|
+
// and pushes only after approval. The checkout and credentials stay local;
|
|
9
|
+
// bounded progress/reports go to hilos and changes go to the Git remote.
|
|
9
10
|
|
|
10
11
|
import { spawnSync } from "node:child_process";
|
|
11
12
|
import { randomBytes, randomUUID } from "node:crypto";
|
package/src/mcp-loopback.mjs
CHANGED
|
@@ -28,6 +28,22 @@ const FORWARDED_REQUEST_HEADERS = [
|
|
|
28
28
|
];
|
|
29
29
|
const MAX_HEADER_LENGTH = 4096;
|
|
30
30
|
|
|
31
|
+
/** A v2 claim is the only format the server may use as ambient authority. This
|
|
32
|
+
* local shape check is not verification — the upstream server still checks the
|
|
33
|
+
* HMAC and exact token id — but it keeps 48-hour v1 state from advertising a
|
|
34
|
+
* loopback whose every call would be rejected after the rollout. */
|
|
35
|
+
export function isV2AmbientBindingClaim(claim) {
|
|
36
|
+
if (typeof claim !== "string" || !claim || claim.length > MAX_HEADER_LENGTH) return false;
|
|
37
|
+
const [payload, signature, extra] = claim.split(".");
|
|
38
|
+
if (!payload || !signature || extra) return false;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
41
|
+
return parsed?.v === 2 && typeof parsed?.tokenId === "string" && Boolean(parsed.tokenId);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
31
47
|
function readBody(request) {
|
|
32
48
|
return new Promise((resolve, reject) => {
|
|
33
49
|
let size = 0;
|
|
@@ -52,21 +68,19 @@ function readBody(request) {
|
|
|
52
68
|
* @param {{
|
|
53
69
|
* url?: string,
|
|
54
70
|
* token?: string,
|
|
55
|
-
*
|
|
71
|
+
* bindingClaim?: string,
|
|
56
72
|
* fetchImpl?: typeof fetch,
|
|
57
73
|
* }} [options]
|
|
58
74
|
* @returns {Promise<{url: string, close: () => Promise<void>} | null>}
|
|
59
75
|
*/
|
|
60
|
-
export async function startHilosMcpLoopback({ url, token,
|
|
61
|
-
|
|
76
|
+
export async function startHilosMcpLoopback({ url, token, bindingClaim = "", fetchImpl = fetch } = {}) {
|
|
77
|
+
const ambientBinding = typeof bindingClaim === "string" ? bindingClaim.trim() : "";
|
|
78
|
+
// A continuation without its server-signed binding must not get a workspace
|
|
79
|
+
// MCP bridge whose guest-sensitive reads have no authoritative room context.
|
|
80
|
+
if (!url || !token || !isV2AmbientBindingClaim(ambientBinding)) return null;
|
|
62
81
|
const nonce = randomBytes(24).toString("hex");
|
|
63
82
|
const path = `/mcp/${nonce}`;
|
|
64
83
|
const upstream = new URL(url);
|
|
65
|
-
// Preserve the room that caused this turn as MCP's effective context. This
|
|
66
|
-
// does not widen or narrow token access, but it keeps guest-room memory gates
|
|
67
|
-
// and other context-sensitive server policy active even when the model calls
|
|
68
|
-
// a workspace-scoped tool without a channelId argument.
|
|
69
|
-
if (channelId) upstream.searchParams.set("channelId", channelId);
|
|
70
84
|
const upstreamRequests = new Set();
|
|
71
85
|
|
|
72
86
|
const server = createServer(async (request, response) => {
|
|
@@ -83,6 +97,9 @@ export async function startHilosMcpLoopback({ url, token, channelId = "", fetchI
|
|
|
83
97
|
const body = request.method === "POST" ? await readBody(request) : undefined;
|
|
84
98
|
const headers = {
|
|
85
99
|
authorization: `Bearer ${token}`,
|
|
100
|
+
// 1215 — fixed daemon-owned authority. This header is intentionally not
|
|
101
|
+
// in FORWARDED_REQUEST_HEADERS, so a coding child cannot replace it.
|
|
102
|
+
"x-hilos-ambient-binding": ambientBinding,
|
|
86
103
|
// This short-lived proxy is part of the persistent daemon's return
|
|
87
104
|
// path. Marking it on-demand would temporarily relabel a live daemon
|
|
88
105
|
// and could suppress the offline/wakeup behavior after the turn.
|
package/src/mcp.mjs
CHANGED
|
@@ -11,6 +11,7 @@ const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo";
|
|
|
11
11
|
const DEFAULT_TIMEOUT_MS = 35_000;
|
|
12
12
|
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
13
13
|
const RETRY_DELAY_MS = 250;
|
|
14
|
+
const MAX_AMBIENT_BINDING_LENGTH = 4_096;
|
|
14
15
|
|
|
15
16
|
function installedVersion() {
|
|
16
17
|
try {
|
|
@@ -51,6 +52,13 @@ export class McpAuthStopError extends McpRequestError {
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
export class McpAmbientBindingError extends McpRequestError {
|
|
56
|
+
constructor(message, details = {}) {
|
|
57
|
+
super(message, { ...details, ambientBindingRejected: true });
|
|
58
|
+
this.name = "McpAmbientBindingError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
function headerValue(value) {
|
|
55
63
|
const text = String(value);
|
|
56
64
|
return /^[\x20-\x7e]+$/.test(text)
|
|
@@ -233,6 +241,9 @@ export function makeClient({
|
|
|
233
241
|
authorization: `Bearer ${token}`,
|
|
234
242
|
"x-hilos-connection-mode": "daemon",
|
|
235
243
|
...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
|
|
244
|
+
...(options.ambientBinding
|
|
245
|
+
? { "x-hilos-ambient-binding": options.ambientBinding }
|
|
246
|
+
: {}),
|
|
236
247
|
...(modern
|
|
237
248
|
? {
|
|
238
249
|
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
|
|
@@ -256,9 +267,23 @@ export function makeClient({
|
|
|
256
267
|
let res;
|
|
257
268
|
let json = null;
|
|
258
269
|
let transportError = null;
|
|
270
|
+
let requestUrl = url;
|
|
271
|
+
if (options.ambientChannelId) {
|
|
272
|
+
try {
|
|
273
|
+
const scopedUrl = new URL(url);
|
|
274
|
+
scopedUrl.searchParams.set("channelId", options.ambientChannelId);
|
|
275
|
+
requestUrl = scopedUrl.toString();
|
|
276
|
+
} catch (error) {
|
|
277
|
+
bounded.cleanup();
|
|
278
|
+
throw new McpRequestError("hilos has an invalid MCP URL", {
|
|
279
|
+
cause: error,
|
|
280
|
+
malformedResponse: true,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
259
284
|
try {
|
|
260
285
|
try {
|
|
261
|
-
res = await fetchImpl(
|
|
286
|
+
res = await fetchImpl(requestUrl, {
|
|
262
287
|
method: "POST",
|
|
263
288
|
headers,
|
|
264
289
|
body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params: requestParams }),
|
|
@@ -347,7 +372,7 @@ export function makeClient({
|
|
|
347
372
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
348
373
|
try {
|
|
349
374
|
const result = await request("server/discover", undefined, {
|
|
350
|
-
|
|
375
|
+
...options,
|
|
351
376
|
timeoutMs: Math.min(timeoutMs, DISCOVERY_TIMEOUT_MS),
|
|
352
377
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
353
378
|
});
|
|
@@ -411,6 +436,49 @@ export function makeClient({
|
|
|
411
436
|
}
|
|
412
437
|
}
|
|
413
438
|
|
|
439
|
+
/**
|
|
440
|
+
* Pin every call made by one queued daemon job to the room claim returned
|
|
441
|
+
* with that job. This is a closure, not mutable client state: concurrent
|
|
442
|
+
* work from different rooms can never overwrite or inherit another job's
|
|
443
|
+
* ambient privacy boundary.
|
|
444
|
+
*/
|
|
445
|
+
function bindTool(bindingClaim, channelId) {
|
|
446
|
+
const bindingClaimAbsent = bindingClaim === undefined;
|
|
447
|
+
const ambientBinding =
|
|
448
|
+
typeof bindingClaim === "string" ? bindingClaim.trim() : "";
|
|
449
|
+
const ambientChannelId = typeof channelId === "string" ? channelId.trim() : "";
|
|
450
|
+
return async (name, args = {}, options = {}) => {
|
|
451
|
+
if (
|
|
452
|
+
!ambientChannelId ||
|
|
453
|
+
(!bindingClaimAbsent &&
|
|
454
|
+
(!ambientBinding || ambientBinding.length > MAX_AMBIENT_BINDING_LENGTH))
|
|
455
|
+
) {
|
|
456
|
+
throw new McpAmbientBindingError("hilos returned an invalid ambient room binding", {
|
|
457
|
+
malformedResponse: true,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
return await tool(name, args, {
|
|
462
|
+
...options,
|
|
463
|
+
ambientChannelId,
|
|
464
|
+
...(ambientBinding ? { ambientBinding } : {}),
|
|
465
|
+
});
|
|
466
|
+
} catch (error) {
|
|
467
|
+
// A scoped 401 without the terminal-token bit means the bearer token
|
|
468
|
+
// still exists but this queued job's room proof was rejected (expired,
|
|
469
|
+
// rotated, or mismatched). Surface a distinct failure so the daemon can
|
|
470
|
+
// restart from its durable cursor instead of acknowledging lost work.
|
|
471
|
+
if (error?.status === 401 && error?.stop !== true) {
|
|
472
|
+
throw new McpAmbientBindingError(error.message, {
|
|
473
|
+
...error,
|
|
474
|
+
cause: error,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
414
482
|
// Full tool objects, schemas included. Never turn discovery errors into an
|
|
415
483
|
// empty registry: that would silently downgrade a current daemon until its
|
|
416
484
|
// next restart. One bounded retry handles a transient read failure.
|
|
@@ -440,5 +508,12 @@ export function makeClient({
|
|
|
440
508
|
return (await listTools(options)).map((tool) => tool.name);
|
|
441
509
|
}
|
|
442
510
|
|
|
443
|
-
return {
|
|
511
|
+
return {
|
|
512
|
+
rpc,
|
|
513
|
+
tool,
|
|
514
|
+
bindTool,
|
|
515
|
+
listTools,
|
|
516
|
+
listToolNames,
|
|
517
|
+
stopSignal: stopController.signal,
|
|
518
|
+
};
|
|
444
519
|
}
|
package/src/reply-bridge.mjs
CHANGED
|
@@ -17,7 +17,10 @@ import { makeStreamParser } from "./agent-events.mjs";
|
|
|
17
17
|
import { detectVendor, codeStreamArgs, createProgressEmitter } from "./progress-emitter.mjs";
|
|
18
18
|
import { buildResumeArgs } from "./resume.mjs";
|
|
19
19
|
import { commandArgv } from "./argv.mjs";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
isV2AmbientBindingClaim,
|
|
22
|
+
startHilosMcpLoopback,
|
|
23
|
+
} from "./mcp-loopback.mjs";
|
|
21
24
|
import {
|
|
22
25
|
HOOK_STATE_DIR,
|
|
23
26
|
hookConnectionKey,
|
|
@@ -255,11 +258,15 @@ function matchingSessions(sessions, { connectionKey = "" } = {}) {
|
|
|
255
258
|
);
|
|
256
259
|
}
|
|
257
260
|
|
|
258
|
-
/** Persist a
|
|
259
|
-
*
|
|
260
|
-
* failure stays fail-closed. */
|
|
261
|
-
function
|
|
262
|
-
if (
|
|
261
|
+
/** Persist a freshly server-verified v2 claim, optionally claiming a tokenless
|
|
262
|
+
* hook session for this credential at the same time. Re-read after the write so
|
|
263
|
+
* an IO failure or concurrent replacement stays fail-closed. */
|
|
264
|
+
function persistVerifiedBinding(binding, nextClaim, stateDir, connectionKey = "") {
|
|
265
|
+
if (
|
|
266
|
+
!binding?.sessionId ||
|
|
267
|
+
!binding?.bindingClaim ||
|
|
268
|
+
!isV2AmbientBindingClaim(nextClaim)
|
|
269
|
+
) return false;
|
|
263
270
|
const state = readState(binding.sessionId, stateDir);
|
|
264
271
|
if (!state || (state.connectionKey && state.connectionKey !== connectionKey)) return false;
|
|
265
272
|
const current = (Array.isArray(state.bindings) ? state.bindings : []).find(
|
|
@@ -270,9 +277,27 @@ function claimVerifiedSession(binding, connectionKey, stateDir) {
|
|
|
270
277
|
item?.agentId === binding.agentId,
|
|
271
278
|
);
|
|
272
279
|
if (!current) return false;
|
|
273
|
-
|
|
280
|
+
current.bindingClaim = nextClaim;
|
|
281
|
+
if (connectionKey) state.connectionKey = connectionKey;
|
|
274
282
|
writeState(binding.sessionId, state, stateDir);
|
|
275
|
-
|
|
283
|
+
const saved = readState(binding.sessionId, stateDir);
|
|
284
|
+
const savedBinding = saved?.bindings?.find(
|
|
285
|
+
(item) =>
|
|
286
|
+
item?.threadRootId === binding.threadRootId &&
|
|
287
|
+
item?.anchorMessageId === binding.anchorMessageId &&
|
|
288
|
+
item?.agentId === binding.agentId,
|
|
289
|
+
);
|
|
290
|
+
return savedBinding?.bindingClaim === nextClaim &&
|
|
291
|
+
(!connectionKey || saved?.connectionKey === connectionKey);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function bindingVerificationArgs(binding) {
|
|
295
|
+
return {
|
|
296
|
+
claim: binding.bindingClaim,
|
|
297
|
+
channelId: binding.channelId,
|
|
298
|
+
threadRootId: binding.threadRootId,
|
|
299
|
+
messageId: binding.anchorMessageId,
|
|
300
|
+
};
|
|
276
301
|
}
|
|
277
302
|
|
|
278
303
|
async function verifyBindingAuthority(binding, authority, tool, stateDir) {
|
|
@@ -284,14 +309,14 @@ async function verifyBindingAuthority(binding, authority, tool, stateDir) {
|
|
|
284
309
|
!authority.agentId ||
|
|
285
310
|
binding.agentId !== authority.agentId
|
|
286
311
|
) return false;
|
|
287
|
-
const verified = await tool("verify_session_binding",
|
|
288
|
-
claim: binding.bindingClaim,
|
|
289
|
-
channelId: binding.channelId,
|
|
290
|
-
threadRootId: binding.threadRootId,
|
|
291
|
-
messageId: binding.anchorMessageId,
|
|
292
|
-
});
|
|
312
|
+
const verified = await tool("verify_session_binding", bindingVerificationArgs(binding));
|
|
293
313
|
return verified?.valid === true &&
|
|
294
|
-
|
|
314
|
+
persistVerifiedBinding(
|
|
315
|
+
binding,
|
|
316
|
+
verified.bindingClaim,
|
|
317
|
+
stateDir,
|
|
318
|
+
authority.connectionKey,
|
|
319
|
+
);
|
|
295
320
|
}
|
|
296
321
|
|
|
297
322
|
function clearPendingDelivery(sessionId, deliveryId, stateDir = HOOK_STATE_DIR) {
|
|
@@ -675,12 +700,21 @@ function vendorLabel(vendor) {
|
|
|
675
700
|
* now?: () => number,
|
|
676
701
|
* log?: { log: (...args: any[]) => void },
|
|
677
702
|
* signal?: AbortSignal,
|
|
703
|
+
* startLoopback?: typeof startHilosMcpLoopback,
|
|
678
704
|
* }} deps
|
|
679
705
|
*/
|
|
680
706
|
export async function handleReplyBridgeJob(
|
|
681
707
|
{ binding, replies: queuedReplies, context },
|
|
682
708
|
cfg,
|
|
683
|
-
{
|
|
709
|
+
{
|
|
710
|
+
tool,
|
|
711
|
+
run = runCli,
|
|
712
|
+
stateDir = HOOK_STATE_DIR,
|
|
713
|
+
now = Date.now,
|
|
714
|
+
log = console,
|
|
715
|
+
signal,
|
|
716
|
+
startLoopback = startHilosMcpLoopback,
|
|
717
|
+
},
|
|
684
718
|
) {
|
|
685
719
|
// A second batch may have queued while the first continuation was running.
|
|
686
720
|
// Re-read the durable processed set at execution time so the overlap is
|
|
@@ -692,7 +726,13 @@ export async function handleReplyBridgeJob(
|
|
|
692
726
|
const processed = new Set(
|
|
693
727
|
Array.isArray(currentBinding?.processedReplyIds) ? currentBinding.processedReplyIds : [],
|
|
694
728
|
);
|
|
695
|
-
const liveBinding =
|
|
729
|
+
const liveBinding = {
|
|
730
|
+
...binding,
|
|
731
|
+
...(currentBinding ?? {}),
|
|
732
|
+
// scanReplyBridge may have just claimed a tokenless hook session by writing
|
|
733
|
+
// the top-level key; carry that fresh authority into this already-built job.
|
|
734
|
+
connectionKey: currentState?.connectionKey || binding.connectionKey || "",
|
|
735
|
+
};
|
|
696
736
|
const queuedIds = new Set(
|
|
697
737
|
(queuedReplies || []).map((reply) => reply?.id).filter((id) => id && !processed.has(id)),
|
|
698
738
|
);
|
|
@@ -733,11 +773,31 @@ export async function handleReplyBridgeJob(
|
|
|
733
773
|
return { status: "skipped" };
|
|
734
774
|
}
|
|
735
775
|
|
|
736
|
-
|
|
737
|
-
|
|
776
|
+
// Verify and rotate the claim against the current server immediately before
|
|
777
|
+
// exposing MCP. This upgrades persisted v1 state, prevents a new daemon from
|
|
778
|
+
// trusting an older server, and keeps active bindings ahead of the 48-hour
|
|
779
|
+
// claim expiry that successful continuations also renew locally.
|
|
780
|
+
const ambientVerification =
|
|
781
|
+
liveBinding.vendor === "codex" && liveBinding.bindingClaim
|
|
782
|
+
? await tool("verify_session_binding", bindingVerificationArgs(liveBinding))
|
|
783
|
+
.catch(() => null)
|
|
784
|
+
: null;
|
|
785
|
+
const refreshedAmbientClaim =
|
|
786
|
+
ambientVerification?.valid === true &&
|
|
787
|
+
isV2AmbientBindingClaim(ambientVerification.bindingClaim) &&
|
|
788
|
+
persistVerifiedBinding(
|
|
789
|
+
liveBinding,
|
|
790
|
+
ambientVerification.bindingClaim,
|
|
791
|
+
stateDir,
|
|
792
|
+
liveBinding.connectionKey,
|
|
793
|
+
)
|
|
794
|
+
? ambientVerification.bindingClaim
|
|
795
|
+
: "";
|
|
796
|
+
const loopback = refreshedAmbientClaim
|
|
797
|
+
? await startLoopback({
|
|
738
798
|
url: cfg?.url,
|
|
739
799
|
token: cfg?.token,
|
|
740
|
-
|
|
800
|
+
bindingClaim: refreshedAmbientClaim,
|
|
741
801
|
}).catch(() => null)
|
|
742
802
|
: null;
|
|
743
803
|
const prompt = bridgePrompt(replies, context, { hilosMcp: Boolean(loopback) });
|
package/src/run.mjs
CHANGED
|
@@ -104,6 +104,9 @@ export async function run(
|
|
|
104
104
|
// Match the typed client error by its stable cross-realm name. This also
|
|
105
105
|
// works for embedding hosts and test doubles that do not share a constructor.
|
|
106
106
|
const isAuthStop = (error) => error?.name === "McpAuthStopError";
|
|
107
|
+
const isAmbientBindingRejection = (error) =>
|
|
108
|
+
error?.name === "McpAmbientBindingError" ||
|
|
109
|
+
error?.ambientBindingRejected === true;
|
|
107
110
|
const reportAuthStop = (error) => {
|
|
108
111
|
if (!isAuthStop(error)) return false;
|
|
109
112
|
emitStopping();
|
|
@@ -113,7 +116,7 @@ export async function run(
|
|
|
113
116
|
const client = makeClient({ url: cfg.url, token: cfg.token });
|
|
114
117
|
// Keep injected/older test clients compatible; the real makeClient always
|
|
115
118
|
// provides this lifecycle signal and terminal auth still latches through it.
|
|
116
|
-
const { tool, listTools, listToolNames } = client;
|
|
119
|
+
const { tool, bindTool, listTools, listToolNames } = client;
|
|
117
120
|
const stopSignal = client.stopSignal ?? new AbortController().signal;
|
|
118
121
|
const abortFromAuthStop = () => runController.abort(stopSignal.reason);
|
|
119
122
|
stopSignal.addEventListener("abort", abortFromAuthStop, { once: true });
|
|
@@ -145,6 +148,7 @@ export async function run(
|
|
|
145
148
|
let iterateClaimRecoveryStore = null;
|
|
146
149
|
let queue = null;
|
|
147
150
|
let wake = null;
|
|
151
|
+
let fatalJobError = null;
|
|
148
152
|
let reconcileClaimsOnce = async () => true;
|
|
149
153
|
const withinShutdownReconcileBound = async (promise) => {
|
|
150
154
|
let timer;
|
|
@@ -522,6 +526,10 @@ export async function run(
|
|
|
522
526
|
|
|
523
527
|
async function safeHandle(message, channelId, jobSignal) {
|
|
524
528
|
emit({ type: "task-start", channelId, messageId: message.id, text: (message.body || "").slice(0, 200) });
|
|
529
|
+
const taskTool =
|
|
530
|
+
typeof bindTool === "function"
|
|
531
|
+
? bindTool(message.bindingClaim, channelId)
|
|
532
|
+
: tool;
|
|
525
533
|
// 0779 — pull this mention's images to disk HERE, as the job starts, not
|
|
526
534
|
// when it was enqueued. Downloading at enqueue meant a burst of image
|
|
527
535
|
// mentions held every job's bytes (up to 4 x 10MB each) on the user's disk
|
|
@@ -548,7 +556,7 @@ export async function run(
|
|
|
548
556
|
if (stopNoticed) return;
|
|
549
557
|
stopNoticed = true;
|
|
550
558
|
const who = typeof by === "string" && by.trim() ? by.trim() : null;
|
|
551
|
-
void
|
|
559
|
+
void taskTool("post_message", {
|
|
552
560
|
channelId,
|
|
553
561
|
parentId: message.parentId ?? null,
|
|
554
562
|
body: who ? `Stopped by ${who} from the room.` : "Stopped from the room.",
|
|
@@ -559,7 +567,14 @@ export async function run(
|
|
|
559
567
|
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
560
568
|
// liveCfg so a job uses the latest model/permission/codingCmd at run time.
|
|
561
569
|
const result = await handler(
|
|
562
|
-
{
|
|
570
|
+
{
|
|
571
|
+
message: withImages,
|
|
572
|
+
channelId,
|
|
573
|
+
tool: taskTool,
|
|
574
|
+
me,
|
|
575
|
+
caps,
|
|
576
|
+
iterateClaimRecoveryStore,
|
|
577
|
+
},
|
|
563
578
|
liveCfg,
|
|
564
579
|
undefined,
|
|
565
580
|
{ signal: jobSignal, onStopRequested },
|
|
@@ -568,7 +583,18 @@ export async function run(
|
|
|
568
583
|
} catch (e) {
|
|
569
584
|
emit({ type: "task-error", channelId, error: e?.message || String(e) });
|
|
570
585
|
log.error(`handler error: ${e.message}`);
|
|
571
|
-
|
|
586
|
+
if (isAmbientBindingRejection(e)) {
|
|
587
|
+
// The job's room proof is no longer usable. Close intake synchronously
|
|
588
|
+
// before runJob unwinds so createQueue skips afterJob (and therefore
|
|
589
|
+
// cannot persist this mention's cursor), then restart from the durable
|
|
590
|
+
// cursor. Posting with the same rejected proof would only hide the
|
|
591
|
+
// failure behind a second 401.
|
|
592
|
+
fatalJobError ??= e;
|
|
593
|
+
runController.abort(e);
|
|
594
|
+
void queue.shutdown("ambient room binding rejected");
|
|
595
|
+
throw e;
|
|
596
|
+
}
|
|
597
|
+
await taskTool("post_message", {
|
|
572
598
|
channelId,
|
|
573
599
|
body: `Hit an error working on that: ${e.message}`,
|
|
574
600
|
}).catch(() => {});
|
|
@@ -877,6 +903,8 @@ export async function run(
|
|
|
877
903
|
await interruptibleSleep(pollAgainMs != null ? Math.min(base, pollAgainMs) : base);
|
|
878
904
|
} while (!runSignal.aborted);
|
|
879
905
|
|
|
906
|
+
if (fatalJobError) throw fatalJobError;
|
|
907
|
+
|
|
880
908
|
// A terminal response may first surface inside a concurrently running
|
|
881
909
|
// handler while the poll loop is sleeping. The client latch aborts the run
|
|
882
910
|
// immediately; preserve the original typed error after the loop has fenced
|