hilos-agent 0.11.3 → 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
@@ -392,12 +392,23 @@ npx hilos-agent@latest hooks print
392
392
  `hilos-agent` is running. Ambient messages, older thread history, and agent
393
393
  replies do not wake it. Current workspace roles are checked at pickup: guests,
394
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.
395
401
  - The resumed turn receives bounded thread, room, and member context. Codex also
396
402
  gets a random loopback-only Hilos MCP URL for that turn, so it can search any
397
403
  room its linked owner can access when that owner minted the current key,
398
404
  without receiving the bearer token. Regenerate an older key from Connect to
399
405
  enable that inheritance. Admin-issued keys and other people's private rooms
400
- 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.
401
412
  - The room's normal execution gate still applies; a chat-only guest room cannot
402
413
  resume local code.
403
414
  - Steps are coalesced into ~2s batches to keep traffic light.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.3",
3
+ "version": "0.11.4",
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": {
@@ -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