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 CHANGED
@@ -1,13 +1,16 @@
1
1
  # hilos-agent
2
2
 
3
- Run **your own** coding agent — Claude Code, Codex, Cursor, opencode, Hermes, or any command — as
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**. Your code and
10
- your git/`gh` credentials never leave your machine hilos only relays messages.
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 then posts the
23
- proposed diff as a card and pushes only after you Approve (nothing leaves your
24
- machine until then).
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? Use a config:
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
- ```jsonc
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", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "opencode run", "agy -p", any command
51
- "codingModel": "", // Codex tier ("most-capable" | "balanced" | "fastest"), resolved against this account's own model list; "" = the tool's default
52
- "chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
53
- "webSearch": true, // native public-web tools are on by default; false stops Hilos enablement/instructions
54
+ "codingCmd": "claude -p --permission-mode acceptEdits",
55
+ "codingModel": "",
56
+ "chatCmd": "",
57
+ "webSearch": true,
54
58
  "defaultBranch": "main",
55
- "gate": false, // default: open a PR directly. true = approve-before-push
56
- "heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
57
- "chatTimeoutMs": 90000, // cap a chat reply / plan-ack so a stalled model can't go silent
58
- "longPollMs": 20000, // ask the server to HOLD the mention poll and answer the moment work lands (0 = plain 5s polling; older servers fall back automatically)
59
- "catchupMs": 86400000 // how far back a restart replays mentions from the persisted cursor (default 24h; 0 = restart at "now", the pre-0866 behavior)
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 `chatCmd` is
65
- set a quick **plan** ("On it I'll do X, then open a PR") edits into it. On a
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
- ```jsonc
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 branches off `defaultBranch` (refuses a dirty tree), runs
156
- `codingCmd` with the task, and stages the result.
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
- ```jsonc
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
- ```jsonc
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
- `codingCmd` decides how much the coding agent can do on its own. Three levels,
286
- safest first:
287
-
288
- - **`opencode run` (runtime-gated).** When the connected hilos server advertises
289
- runtime permissions, the daemon runs OpenCode through an authenticated
290
- loopback server and becomes its sole permission responder. A tool ask pauses
291
- mechanically, posts a card in the run thread, and resumes only after a channel
292
- member chooses **Allow once**, an exact harness-suggested **Always** rule, or
293
- **Deny**. Missing transport, expiry, and cancellation all reject the tool
294
- call. `opencode run --auto` deliberately bypasses these cards and keeps
295
- OpenCode's dangerous auto-approve behavior.
296
- - **`--permission-mode acceptEdits` (default).** The agent edits files without
297
- prompting, but in headless `claude -p` a step that needs bash — run the tests,
298
- install a dep has no interactive prompt to grant, so the task can **stall**.
299
- Good when the work is edit-only; frustrating for anything that needs to run
300
- commands.
301
- - **`--dangerously-skip-permissions` (recommended for independent agents).** Full
302
- autonomy: the agent can run the tests, install deps, and finish hands-off.
303
- Caution: it can run **any** command in the repo you point it at — only use it
304
- on a repo and machine where that's acceptable. This is the option to pick if
305
- you want the agent to actually work on its own.
306
-
307
- ```jsonc
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. When the workspace grants runtime approvals,
318
- OpenCode, Cursor, Claude Code, and Codex all pause on the same durable hilos
319
- permission card. A refusal or broken decision path fails closed.
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 alone still stream live
369
- steps, but MCP has no server-push channel that can wake an idle local client.
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 own stream is **not** part of that by default — the live
426
- card is an eight-step window and nothing keeps it after the run.
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
- Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`, `HILOS_ONCE=1`,
461
- `HILOS_BACKFILL=1`, `HILOS_UPLOAD_TRANSCRIPTS=1|0`.
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
 
@@ -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; --gate adds a human
4
- // approval checkpoint before the push. Your code + credentials never leave
5
- // your machine.
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.2",
4
- "description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. It picks up mentions, makes the change locally, and opens a PR for human review.",
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. Your code + credentials stay local.
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";
@@ -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
- * channelId?: string,
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, channelId = "", fetchImpl = fetch } = {}) {
61
- if (!url || !token) return null;
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(url, {
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
- signal: options.signal,
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 { rpc, tool, listTools, listToolNames, stopSignal: stopController.signal };
511
+ return {
512
+ rpc,
513
+ tool,
514
+ bindTool,
515
+ listTools,
516
+ listToolNames,
517
+ stopSignal: stopController.signal,
518
+ };
444
519
  }
@@ -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 { startHilosMcpLoopback } from "./mcp-loopback.mjs";
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 one-way credential scope only after the real hilos server accepts
259
- * the hook's signed binding claim. Re-read after the best-effort write so an IO
260
- * failure stays fail-closed. */
261
- function claimVerifiedSession(binding, connectionKey, stateDir) {
262
- if (!binding?.sessionId || !binding?.bindingClaim || !connectionKey) return false;
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
- state.connectionKey = connectionKey;
280
+ current.bindingClaim = nextClaim;
281
+ if (connectionKey) state.connectionKey = connectionKey;
274
282
  writeState(binding.sessionId, state, stateDir);
275
- return readState(binding.sessionId, stateDir)?.connectionKey === connectionKey;
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
- claimVerifiedSession(binding, authority.connectionKey, stateDir);
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
- { tool, run = runCli, stateDir = HOOK_STATE_DIR, now = Date.now, log = console, signal },
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 = currentBinding ? { ...binding, ...currentBinding } : binding;
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
- const loopback = liveBinding.vendor === "codex"
737
- ? await startHilosMcpLoopback({
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
- channelId: liveBinding.channelId,
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 tool("post_message", {
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
- { message: withImages, channelId, tool, me, caps, iterateClaimRecoveryStore },
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
- await tool("post_message", {
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