@securecoms/agent-bridge 0.25.3 → 0.26.0

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
@@ -589,6 +589,21 @@ the agent **plaintext** — message text and, for attachments, a **local file pa
589
589
  This is why Python agents (which have no native MLS library) fully support
590
590
  attachments: the cryptography lives in the daemon, not the agent.
591
591
 
592
+ **Files links (`vault_ref`).** A message can instead point at an item already in
593
+ the enclave's Files (FileSafe). The daemon does **not** fetch it on receipt: the
594
+ delivered message carries the caption as `text` (`"[Files link]"` when there is
595
+ none) and a reference,
596
+ `vault_refs: [{ enclave_id, node_id, content_hash, dek_commit, name_hint?, mime_hint?, size_hint? }]`.
597
+ The `*_hint` fields are the sender's unverified claims; treat them as data, never
598
+ as a path or an instruction. A link whose enclave is not the channel's, or whose
599
+ fields are malformed, is dropped (counted as `refused_vault_refs` on `/healthz`).
600
+ To open one, call `POST /v1/vault-read` with
601
+ `{ enclave_id, node_id, expected_content_hash: content_hash, expected_dek_commit: dek_commit }`.
602
+ The two pins go together: sending only one is a 400.
603
+ If the item changed in Files after it was shared, the daemon refuses with
604
+ `422 {"error":"vault_ref_changed","detail":{"pin":"..."}}` and returns no bytes.
605
+ `/healthz` advertises support as `capabilities.vault_ref: 1`.
606
+
592
607
  ---
593
608
 
594
609
  ## Voice fast path
@@ -10,6 +10,61 @@ export type ArtifactShareBody = {
10
10
  digest: string;
11
11
  text?: string;
12
12
  };
13
+ /**
14
+ * A Files link: a reference to a FileSafe item, opened only through the
15
+ * recipient's own vault access. Carries NO key material and NO download
16
+ * handle. Wire keys are camelCase, the same as `artifact_share`. Schema and
17
+ * rules: docs/architecture/2026-10-01-files-link-message-review.md section 3.
18
+ */
19
+ export type VaultRefBody = {
20
+ v: 1;
21
+ type: "vault_ref";
22
+ /** Canonical lowercase UUID. MUST equal the channel's enclave (rule R2). */
23
+ enclaveId: string;
24
+ /** Canonical lowercase UUID. */
25
+ nodeId: string;
26
+ /** Version pin 1: "blake2b:" + 64 hex over the stored CIPHERTEXT. */
27
+ contentHash: string;
28
+ /** Version pin 2: "blake2b:" + 64 hex commitment to the DEK material (connect-clients-core computeDekCommit). */
29
+ dekCommit: string;
30
+ /** Untrusted sender hints. Never used for authorization or save paths. */
31
+ name?: string;
32
+ mime?: string;
33
+ size?: number;
34
+ /** Optional caption, same meaning as artifact_share.text. */
35
+ text?: string;
36
+ };
37
+ /**
38
+ * What the runtime receives in `BridgeMessage.vault_refs`. The `_hint` fields
39
+ * are UNTRUSTED claims by the sender, data only: the vault's own sealed name
40
+ * and size are the truth, read through `/v1/vault-read` / `/v1/vault-list`.
41
+ * Open it with `/v1/vault-read` passing `expected_content_hash` and
42
+ * `expected_dek_commit` from this entry; the daemon never fetches it on
43
+ * receipt.
44
+ */
45
+ export type VaultRefEntry = {
46
+ enclave_id: string;
47
+ node_id: string;
48
+ content_hash: string;
49
+ dek_commit: string;
50
+ name_hint?: string;
51
+ mime_hint?: string;
52
+ size_hint?: number;
53
+ };
54
+ /** Plaintext ceiling for the size hint. PARITY: connect-web useVault.ts
55
+ * VAULT_MAX_FILE_BYTES (100 MiB object limit minus the 16-byte AEAD tag). */
56
+ export declare const VAULT_REF_MAX_SIZE_HINT: number;
57
+ /** The vault pin format. Exported for the /v1/vault-read expected_* params. */
58
+ export declare const VAULT_PIN: RegExp;
59
+ /**
60
+ * Rule R1: strict parse. Any required field failing returns null (the caller
61
+ * refuses the link). Each hint is dropped on its own when it fails its type or
62
+ * length check; nothing is coerced. Unknown keys are never copied, so smuggled
63
+ * key material or download handles cannot reach the runtime.
64
+ */
65
+ export declare function parseVaultRefBody(raw: unknown): VaultRefBody | null;
66
+ /** The runtime-facing reference for a parsed body (hints renamed `_hint`). */
67
+ export declare function toVaultRefEntry(body: VaultRefBody): VaultRefEntry;
13
68
  export type AttachmentMeta = {
14
69
  filename: string;
15
70
  mime: string;
package/dist/bin.js CHANGED
@@ -8930,6 +8930,31 @@ function unpackDekMaterial(buf) {
8930
8930
  return { fileKey: buf.subarray(0, 32), fileNonce: buf.subarray(32) };
8931
8931
  }
8932
8932
 
8933
+ // ../connect-clients-core/dist/filesafe/dek-commit.js
8934
+ import { blake2b as blake2b2 } from "@noble/hashes/blake2b";
8935
+ var DEK_COMMIT_LABEL = "securecoms/vault_ref/dek/v1";
8936
+ function computeDekCommit(enclaveId, nodeId, fileKey, fileNonce) {
8937
+ const enc3 = new TextEncoder();
8938
+ const sep = new Uint8Array([0]);
8939
+ const parts = [
8940
+ enc3.encode(DEK_COMMIT_LABEL),
8941
+ sep,
8942
+ enc3.encode(enclaveId),
8943
+ sep,
8944
+ enc3.encode(nodeId),
8945
+ sep,
8946
+ packDekMaterial(fileKey, fileNonce)
8947
+ ];
8948
+ const preimage = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
8949
+ let at = 0;
8950
+ for (const p of parts) {
8951
+ preimage.set(p, at);
8952
+ at += p.length;
8953
+ }
8954
+ const digest = blake2b2(preimage, { dkLen: 32 });
8955
+ return `blake2b:${Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("")}`;
8956
+ }
8957
+
8933
8958
  // ../../node_modules/@agentvault/crypto/dist/index.js
8934
8959
  init_sha2();
8935
8960
  init_hmac();
@@ -25828,7 +25853,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25828
25853
  message_id: messageId,
25829
25854
  channel_id: p.channelId,
25830
25855
  enclave_id: p.enclaveId,
25831
- message_type: "text",
25856
+ message_type: p.messageType ?? "text",
25832
25857
  sender_id: p.senderId,
25833
25858
  sender_type: "agent",
25834
25859
  identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
@@ -27726,6 +27751,47 @@ var DecryptErrorLogLimiter = class {
27726
27751
 
27727
27752
  // src/attachments.ts
27728
27753
  import { basename } from "node:path";
27754
+ var VAULT_REF_MAX_SIZE_HINT = 100 * 1024 * 1024 - 16;
27755
+ var VAULT_REF_MAX_NAME = 255;
27756
+ var VAULT_REF_MAX_MIME = 127;
27757
+ var CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
27758
+ var VAULT_PIN = /^blake2b:[0-9a-f]{64}$/;
27759
+ var HINT_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
27760
+ var MIME_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/;
27761
+ function parseVaultRefBody(raw) {
27762
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
27763
+ const r = raw;
27764
+ if (r.type !== "vault_ref" || r.v !== 1) return null;
27765
+ const { enclaveId, nodeId, contentHash, dekCommit } = r;
27766
+ if (typeof enclaveId !== "string" || !CANONICAL_UUID.test(enclaveId)) return null;
27767
+ if (typeof nodeId !== "string" || !CANONICAL_UUID.test(nodeId)) return null;
27768
+ if (typeof contentHash !== "string" || !VAULT_PIN.test(contentHash)) return null;
27769
+ if (typeof dekCommit !== "string" || !VAULT_PIN.test(dekCommit)) return null;
27770
+ const body = { v: 1, type: "vault_ref", enclaveId, nodeId, contentHash, dekCommit };
27771
+ if (typeof r.name === "string" && r.name.length <= VAULT_REF_MAX_NAME && !HINT_FORBIDDEN.test(r.name)) {
27772
+ body.name = r.name;
27773
+ }
27774
+ if (typeof r.mime === "string" && r.mime.length <= VAULT_REF_MAX_MIME && !HINT_FORBIDDEN.test(r.mime) && MIME_TYPE.test(r.mime)) {
27775
+ body.mime = r.mime;
27776
+ }
27777
+ if (typeof r.size === "number" && Number.isInteger(r.size) && r.size >= 0 && r.size <= VAULT_REF_MAX_SIZE_HINT) {
27778
+ body.size = r.size;
27779
+ }
27780
+ if (typeof r.text === "string") body.text = r.text;
27781
+ return body;
27782
+ }
27783
+ function toVaultRefEntry(body) {
27784
+ const entry = {
27785
+ enclave_id: body.enclaveId,
27786
+ node_id: body.nodeId,
27787
+ content_hash: body.contentHash,
27788
+ dek_commit: body.dekCommit
27789
+ };
27790
+ if (body.name !== void 0) entry.name_hint = body.name;
27791
+ if (body.mime !== void 0) entry.mime_hint = body.mime;
27792
+ if (body.size !== void 0) entry.size_hint = body.size;
27793
+ return entry;
27794
+ }
27729
27795
  function sanitizeFilename(name, fallback) {
27730
27796
  const base = basename(name ?? "");
27731
27797
  const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
@@ -28408,6 +28474,15 @@ var VaultIntegrityError = class extends Error {
28408
28474
  if (cause !== void 0) this.cause = cause;
28409
28475
  }
28410
28476
  };
28477
+ var VaultPinMismatchError = class extends VaultIntegrityError {
28478
+ pin;
28479
+ constructor(pin) {
28480
+ super(`vault: pinned ${pin} does not match; the file changed after it was shared, not opened`);
28481
+ this.name = "VaultPinMismatchError";
28482
+ this.pin = pin;
28483
+ }
28484
+ };
28485
+ var VAULT_PINS_TOGETHER = "expected_content_hash and expected_dek_commit go together";
28411
28486
  async function vaultWriteImpl(args2) {
28412
28487
  const gen = args2.deps.currentGeneration;
28413
28488
  const kek = args2.deps.exporterKekForGeneration(gen);
@@ -28431,10 +28506,20 @@ async function vaultWriteImpl(args2) {
28431
28506
  return { node_id: created.node_id };
28432
28507
  }
28433
28508
  async function vaultReadImpl(args2) {
28509
+ const pinnedHash = args2.expected?.contentHash;
28510
+ const pinnedCommit = args2.expected?.dekCommit;
28511
+ if (args2.expected !== void 0 && (typeof pinnedHash !== "string" || typeof pinnedCommit !== "string")) {
28512
+ throw new VaultIntegrityError(`vault: ${VAULT_PINS_TOGETHER}`);
28513
+ }
28434
28514
  const meta = await args2.deps.getNodeForDownload(args2.nodeId);
28515
+ if (pinnedHash !== void 0 && meta.content_hash !== pinnedHash) {
28516
+ throw new VaultPinMismatchError("content_hash_server");
28517
+ }
28435
28518
  const ct = await args2.deps.getBlob(meta.download_url);
28436
- const expected = (meta.content_hash ?? "").replace(/^blake2b:/, "");
28437
- if (await computeFileDigest(ct) !== expected) {
28519
+ const digestHex = await computeFileDigest(ct);
28520
+ if (pinnedHash !== void 0) {
28521
+ if (`blake2b:${digestHex}` !== pinnedHash) throw new VaultPinMismatchError("ciphertext_digest");
28522
+ } else if (digestHex !== (meta.content_hash ?? "").replace(/^blake2b:/, "")) {
28438
28523
  throw new VaultIntegrityError("vault: ciphertext failed integrity/digest check");
28439
28524
  }
28440
28525
  const wrap = [...meta.wraps].sort((a, b) => b.kek_generation - a.kek_generation)[0];
@@ -28448,6 +28533,9 @@ async function vaultReadImpl(args2) {
28448
28533
  } catch (e) {
28449
28534
  throw new VaultIntegrityError("vault: DEK unwrap failed (bad KEK, AAD mismatch, or tampered wrap)", e);
28450
28535
  }
28536
+ if (pinnedCommit !== void 0 && computeDekCommit(args2.enclaveId, args2.nodeId, fileKey, fileNonce) !== pinnedCommit) {
28537
+ throw new VaultPinMismatchError("dek_commit");
28538
+ }
28451
28539
  try {
28452
28540
  return await decryptFile(ct, fileKey, fileNonce);
28453
28541
  } catch (e) {
@@ -28838,6 +28926,70 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
28838
28926
 
28839
28927
  // src/bridge-agent.ts
28840
28928
  import { homedir as homedir2 } from "node:os";
28929
+
28930
+ // src/status-gate.ts
28931
+ var STATUS_WORKING_DELAY_MS = 3e3;
28932
+ var StatusGate = class {
28933
+ constructor(publish, opts = {}) {
28934
+ this.publish = publish;
28935
+ this.opts = opts;
28936
+ }
28937
+ entries = /* @__PURE__ */ new Map();
28938
+ chains = /* @__PURE__ */ new Map();
28939
+ /** The agent reported a status. Resolves once any publish it starts has settled. */
28940
+ async report(channelId, state, detail) {
28941
+ if (state === "working") {
28942
+ this.holdWorking(channelId, detail);
28943
+ return;
28944
+ }
28945
+ const entry = this.entries.get(channelId);
28946
+ this.entries.delete(channelId);
28947
+ if (entry?.timer) clearTimeout(entry.timer);
28948
+ if (state === "idle" && entry && !entry.published) return;
28949
+ await this.enqueue(channelId, state, detail);
28950
+ }
28951
+ /** A reply body is about to publish on the channel. */
28952
+ replyStarted(channelId) {
28953
+ const entry = this.entries.get(channelId);
28954
+ if (!entry?.timer) return;
28955
+ clearTimeout(entry.timer);
28956
+ entry.timer = null;
28957
+ }
28958
+ /** Drop every pending "working" (bridge shutdown). */
28959
+ close() {
28960
+ for (const entry of this.entries.values()) if (entry.timer) clearTimeout(entry.timer);
28961
+ this.entries.clear();
28962
+ }
28963
+ holdWorking(channelId, detail) {
28964
+ const existing = this.entries.get(channelId);
28965
+ if (existing && (existing.timer || existing.published)) return;
28966
+ const entry = { timer: null, published: false };
28967
+ this.entries.set(channelId, entry);
28968
+ entry.timer = setTimeout(() => {
28969
+ entry.timer = null;
28970
+ if (this.entries.get(channelId) !== entry) return;
28971
+ entry.published = true;
28972
+ this.enqueue(channelId, "working", detail).catch(() => {
28973
+ });
28974
+ }, this.opts.delayMs ?? STATUS_WORKING_DELAY_MS);
28975
+ entry.timer.unref?.();
28976
+ }
28977
+ /** Publishes in order per channel; rejects with the publish error. */
28978
+ enqueue(channelId, state, detail) {
28979
+ const prev = this.chains.get(channelId) ?? Promise.resolve();
28980
+ const run = prev.then(() => this.publish(channelId, state, detail));
28981
+ const settled = run.catch((err) => {
28982
+ this.opts.onError?.(state, err);
28983
+ });
28984
+ this.chains.set(channelId, settled);
28985
+ void settled.then(() => {
28986
+ if (this.chains.get(channelId) === settled) this.chains.delete(channelId);
28987
+ });
28988
+ return run;
28989
+ }
28990
+ };
28991
+
28992
+ // src/bridge-agent.ts
28841
28993
  function logSendSupersededAdoption(kind) {
28842
28994
  switch (kind) {
28843
28995
  case "adopted":
@@ -28881,6 +29033,7 @@ function logPendingCommitResolved(outcome) {
28881
29033
  writeServiceLog("info", "mls.pending_commit.resolved.unknown");
28882
29034
  }
28883
29035
  }
29036
+ var VAULT_REF_BARE_TEXT = "[Files link]";
28884
29037
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
28885
29038
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
28886
29039
  var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
@@ -29269,6 +29422,15 @@ var BridgeAgent = class _BridgeAgent {
29269
29422
  * either source wins; both read the same identity column.
29270
29423
  */
29271
29424
  lastOwnDisplayName;
29425
+ /**
29426
+ * Thinking-indicator gate (owner rule, 2026-10-01): the agent-facing status
29427
+ * routes go through it, so a reply inside 3 s raises no "thinking..." strip.
29428
+ * See status-gate.ts.
29429
+ */
29430
+ statusGate = new StatusGate(
29431
+ (channelId, state, detail) => this.reportStatus(channelId, state, detail),
29432
+ { onError: (state) => writeServiceLog("warn", "status.publish_failed", { working: state === "working", idle: state === "idle" }) }
29433
+ );
29272
29434
  /**
29273
29435
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
29274
29436
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -29366,6 +29528,8 @@ var BridgeAgent = class _BridgeAgent {
29366
29528
  * counter is incremented. Surfaced on health()/metrics().
29367
29529
  */
29368
29530
  failedAttachments = 0;
29531
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
29532
+ refusedVaultRefs = 0;
29369
29533
  /**
29370
29534
  * Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
29371
29535
  * client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
@@ -30800,6 +30964,7 @@ var BridgeAgent = class _BridgeAgent {
30800
30964
  */
30801
30965
  async publishBody(channelId, body, id, onEnvelope) {
30802
30966
  this.enforceBodyRateFloor(channelId, body);
30967
+ this.statusGate.replyStarted(channelId);
30803
30968
  const joined = await this.reconcileJoin(channelId);
30804
30969
  if (!joined) throw new ChannelPendingError(channelId);
30805
30970
  const sendStart = performance.now();
@@ -31426,11 +31591,12 @@ var BridgeAgent = class _BridgeAgent {
31426
31591
  * useVault.downloadFile returns `{name, bytes}` in-memory — here the
31427
31592
  * caller already supplies nodeId so we return bytes, not a re-resolved
31428
31593
  * name the bridge has no cached listing to look up). */
31429
- async vaultRead(enclaveId, vaultChannelId, nodeId) {
31594
+ async vaultRead(enclaveId, vaultChannelId, nodeId, expected) {
31430
31595
  const session = await this.getVaultSession(enclaveId, await this.resolveVaultChannelId(enclaveId, vaultChannelId));
31431
31596
  const bytes = await vaultReadImpl({
31432
31597
  enclaveId,
31433
31598
  nodeId,
31599
+ expected,
31434
31600
  deps: {
31435
31601
  exporterKekForGeneration: session.kekForGeneration,
31436
31602
  getNodeForDownload: (id) => this.vaultGetNodeForDownload(enclaveId, id),
@@ -31913,6 +32079,14 @@ var BridgeAgent = class _BridgeAgent {
31913
32079
  if (TRACE) writeServiceLog("debug", "identity.owner_pin_resolved");
31914
32080
  return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
31915
32081
  }
32082
+ /**
32083
+ * The agent-facing status entry (HTTP /v1/status, WS "status"): "working"
32084
+ * is held 3 s and dropped if the turn ends or a reply starts first. See
32085
+ * status-gate.ts. Resolves at once for "working".
32086
+ */
32087
+ async reportStatusGated(channelId, state, detail) {
32088
+ await this.statusGate.report(channelId, state, detail);
32089
+ }
31916
32090
  async reportStatus(channelId, state, detail) {
31917
32091
  const joined = await this.reconcileJoin(channelId);
31918
32092
  if (!joined) throw new ChannelPendingError(channelId);
@@ -32412,7 +32586,12 @@ var BridgeAgent = class _BridgeAgent {
32412
32586
  const encId = m.envelope.enclave_id;
32413
32587
  if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
32414
32588
  this.channelEnclaveRecorded.add(m.channelId);
32415
- this.store.recordChannelEnclave(m.channelId, encId);
32589
+ const priorEnclave = this.store.getChannelMeta(m.channelId)?.enclaveId;
32590
+ if (priorEnclave === void 0) {
32591
+ this.store.recordChannelEnclave(m.channelId, encId);
32592
+ } else if (priorEnclave !== encId) {
32593
+ writeServiceLog("warn", "channel_enclave.envelope_mismatch");
32594
+ }
32416
32595
  }
32417
32596
  const parsed = JSON.parse(
32418
32597
  new TextDecoder().decode(m.plaintext)
@@ -32426,6 +32605,75 @@ var BridgeAgent = class _BridgeAgent {
32426
32605
  acked.push(m.queueId);
32427
32606
  continue;
32428
32607
  }
32608
+ if (parsed.type === "vault_ref") {
32609
+ const senderType = m.envelope.sender_type;
32610
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
32611
+ const chanMeta = this.freshMeta(m.channelId);
32612
+ const loopSenderIsAgent = this.loopSenderIsAgent(
32613
+ m.channelId,
32614
+ m.envelope.sender_id,
32615
+ senderIsAgent,
32616
+ false
32617
+ );
32618
+ const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent, m.messageId);
32619
+ const caption = typeof parsed.text === "string" ? parsed.text : "";
32620
+ let answerable = true;
32621
+ if (chanMeta && chanMeta.is_dm === false) {
32622
+ answerable = shouldProcessRoomMessage(
32623
+ caption,
32624
+ this.cfg.agentName ?? this.cfg.identityId,
32625
+ this.cfg.identityId,
32626
+ {
32627
+ senderIsAgent: loopSenderIsAgent,
32628
+ roomDisplayName: this.ownRoomDisplayName(),
32629
+ agentHops,
32630
+ maxAgentHops: this.maxAgentHops
32631
+ }
32632
+ );
32633
+ if (!answerable) this.filteredRoomMessages++;
32634
+ } else if (loopSenderIsAgent === true && !agentHopAllowed(agentHops, this.maxAgentHops)) {
32635
+ answerable = false;
32636
+ }
32637
+ if (!answerable) {
32638
+ if (loopSenderIsAgent === true) this.noteAgentTurnSuppressed(agentHops);
32639
+ acked.push(m.queueId);
32640
+ continue;
32641
+ }
32642
+ const ref = parseVaultRefBody(parsed);
32643
+ if (ref === null) {
32644
+ this.refusedVaultRefs++;
32645
+ writeServiceLog("warn", "vault_ref.invalid");
32646
+ acked.push(m.queueId);
32647
+ continue;
32648
+ }
32649
+ const recordedEnclave = this.store.getChannelMeta(m.channelId)?.enclaveId;
32650
+ if (!encId || ref.enclaveId !== encId || recordedEnclave !== ref.enclaveId) {
32651
+ this.refusedVaultRefs++;
32652
+ writeServiceLog("warn", "vault_ref.enclave_mismatch");
32653
+ acked.push(m.queueId);
32654
+ continue;
32655
+ }
32656
+ const vaultRefMessage = {
32657
+ channel_id: m.channelId,
32658
+ message_id: m.messageId,
32659
+ sender_id: m.envelope.sender_id,
32660
+ // A bare link still reads as something to a runtime that
32661
+ // ignores vault_refs.
32662
+ text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
32663
+ ts: m.receivedAt,
32664
+ authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
32665
+ sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
32666
+ sender_is_agent: senderIsAgent,
32667
+ vault_refs: [toVaultRefEntry(ref)],
32668
+ ...m.voiceMode ? {
32669
+ voice: true,
32670
+ guidance: selectVoiceGuidance(chanMeta?.is_dm, m.voiceDefaultResponder)
32671
+ } : {}
32672
+ };
32673
+ if (!await this.stageRuntimeMessage(vaultRefMessage)) continue;
32674
+ acked.push(m.queueId);
32675
+ continue;
32676
+ }
32429
32677
  if (parsed.type === "artifact_share") {
32430
32678
  try {
32431
32679
  const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
@@ -33152,6 +33400,7 @@ var BridgeAgent = class _BridgeAgent {
33152
33400
  custody_read_failing: this.provider.custodyHealth().readFailing,
33153
33401
  custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
33154
33402
  failed_attachments: this.failedAttachments,
33403
+ refused_vault_refs: this.refusedVaultRefs,
33155
33404
  fast_path_sent: this.fastPathSent,
33156
33405
  fast_path_failed: this.fastPathFailed,
33157
33406
  fast_path_skipped: this.fastPathSkipped,
@@ -33212,11 +33461,13 @@ var BridgeAgent = class _BridgeAgent {
33212
33461
  custody: this.provider.custodyHealth(),
33213
33462
  welcomeRequestsSent: this.welcomeRequestsSent,
33214
33463
  failedAttachments: this.failedAttachments,
33464
+ refusedVaultRefs: this.refusedVaultRefs,
33215
33465
  build: BRIDGE_VERSION
33216
33466
  };
33217
33467
  }
33218
33468
  async close() {
33219
33469
  this.closingDown = true;
33470
+ this.statusGate.close();
33220
33471
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
33221
33472
  this.pauseRecheckTimers.clear();
33222
33473
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -33315,6 +33566,10 @@ var BRIDGE_ERRORS = {
33315
33566
  // tell "server/network hiccup, retry" apart from "this ciphertext or key
33316
33567
  // material is not trustworthy, do not retry blindly".
33317
33568
  vault_integrity_failed: { http: 422, message: "vault content or key material failed integrity/signature verification" },
33569
+ // Files link (review 2026-10-01 R4): a pinned /v1/vault-read whose node no
33570
+ // longer holds the shared bytes or key. Terminal for that link: never fall
33571
+ // back to the current version. `detail.pin` names the refused pin.
33572
+ vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
33318
33573
  internal: { http: 500, message: "internal error" }
33319
33574
  };
33320
33575
  function errorBody(code, detail) {
@@ -33361,8 +33616,13 @@ function capabilities(opts) {
33361
33616
  api_version: "1",
33362
33617
  receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
33363
33618
  send_types: ["text", "agent_status", "a2a"],
33364
- webhook_push: false
33619
+ webhook_push: false,
33365
33620
  // B3: A2A-style push-notification config (ciphertext/wake-only)
33621
+ // Files link (review 2026-10-01 section 6): the highest `vault_ref` body
33622
+ // version this daemon parses into BridgeMessage.vault_refs (never fetched
33623
+ // on receipt) and opens with the R4 pins on /v1/vault-read. A daemon
33624
+ // without this field skips and acks a link, so the agent never sees it.
33625
+ vault_ref: 1
33366
33626
  };
33367
33627
  }
33368
33628
  var SEND_CAP = 64 * 1024;
@@ -33404,8 +33664,17 @@ var vaultWriteSchema = z3.object({
33404
33664
  var vaultReadSchema = z3.object({
33405
33665
  enclave_id: z3.string().min(1),
33406
33666
  vault_channel_id: z3.string().min(1).optional(),
33407
- node_id: z3.string().min(1)
33408
- });
33667
+ node_id: z3.string().min(1),
33668
+ // Files link pins (review 2026-10-01 R4), copied by the runtime from a
33669
+ // BridgeMessage.vault_refs entry. Each is enforced when present; omitted,
33670
+ // the read is unchanged.
33671
+ expected_content_hash: z3.string().regex(VAULT_PIN).optional(),
33672
+ expected_dek_commit: z3.string().regex(VAULT_PIN).optional()
33673
+ }).refine(
33674
+ // Audit H1: both pins or neither. A single pin is never a weaker pinned read.
33675
+ (d) => d.expected_content_hash === void 0 === (d.expected_dek_commit === void 0),
33676
+ { message: VAULT_PINS_TOGETHER }
33677
+ );
33409
33678
  var vaultListSchema = z3.object({
33410
33679
  enclave_id: z3.string().min(1),
33411
33680
  vault_channel_id: z3.string().min(1).optional(),
@@ -33545,7 +33814,11 @@ function createHttpServer(agent, opts) {
33545
33814
  if (method === "POST" && path === "/v1/vault-read") {
33546
33815
  const parsed = vaultReadSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33547
33816
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33548
- const r = await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
33817
+ const { expected_content_hash: pinHash, expected_dek_commit: pinCommit } = parsed.data;
33818
+ const r = pinHash !== void 0 && pinCommit !== void 0 ? await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id, {
33819
+ contentHash: pinHash,
33820
+ dekCommit: pinCommit
33821
+ }) : await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
33549
33822
  return json(res, 200, r);
33550
33823
  }
33551
33824
  if (method === "POST" && path === "/v1/vault-list") {
@@ -33563,7 +33836,7 @@ function createHttpServer(agent, opts) {
33563
33836
  if (method === "POST" && path === "/v1/status") {
33564
33837
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33565
33838
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33566
- await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
33839
+ await agent.reportStatusGated(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
33567
33840
  return json(res, 200, { ok: true });
33568
33841
  }
33569
33842
  if (method === "POST" && path === "/v1/memory/record") {
@@ -33638,6 +33911,7 @@ function createHttpServer(agent, opts) {
33638
33911
  if (e instanceof GroupSupersededSendError) return jsonError(res, "group_superseded");
33639
33912
  if (e instanceof IdempotencyConflictError) return jsonError(res, "invalid_request", e.message);
33640
33913
  if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
33914
+ if (e instanceof VaultPinMismatchError) return jsonError(res, "vault_ref_changed", { pin: e.pin });
33641
33915
  if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
33642
33916
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
33643
33917
  writeServiceLog("error", "http.request_failed", {}, e);
@@ -33933,7 +34207,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
33933
34207
  }
33934
34208
  const detail = typeof m.detail === "string" ? m.detail : void 0;
33935
34209
  try {
33936
- await agent.reportStatus(m.channel_id, m.state, detail);
34210
+ await agent.reportStatusGated(m.channel_id, m.state, detail);
33937
34211
  sendJson(sock, { type: "ok" });
33938
34212
  } catch (e) {
33939
34213
  if (e instanceof ChannelPendingError) {
@@ -31,10 +31,10 @@ export type ChannelObservation = {
31
31
  import { type OwnerAuthorityStore, type OwnerPin } from "@agentvault/connect-clients-core";
32
32
  import type { BridgeConfig } from "./config.js";
33
33
  import { LatencyRecorder } from "./latency.js";
34
- import type { AttachmentMeta } from "./attachments.js";
34
+ import type { AttachmentMeta, VaultRefEntry } from "./attachments.js";
35
35
  import { type ChatMessage } from "./voice/fast-prompt.js";
36
36
  import { VOICE_CALL_START_MARKER } from "./voice/prompt-compiler.js";
37
- import { type VaultListNode } from "./vault.js";
37
+ import { type VaultListNode, type VaultReadExpectations } from "./vault.js";
38
38
  import type { AgentActionNodeType, AgentActionNodeStatus, AgentActionNodeDetail } from "@agentvault/connect-core";
39
39
  import type { RuntimeReplyReceipt } from "./reply-receipts.js";
40
40
  export type AgentAudience = "owner_dm" | "team_room" | "partner_room" | "other_human_dm" | "agent_to_agent" | "unknown";
@@ -94,6 +94,16 @@ export interface BridgeMessage {
94
94
  * Undefined for plain `text` messages.
95
95
  */
96
96
  attachments?: AttachmentMeta[];
97
+ /**
98
+ * Files link (`vault_ref`, review 2026-10-01 section 6): references to
99
+ * FileSafe items, NOT fetched. Open one with POST /v1/vault-read passing
100
+ * `enclave_id`, `node_id`, `expected_content_hash` = `content_hash` and
101
+ * `expected_dek_commit` = `dek_commit`; the daemon refuses it if the item
102
+ * changed after it was shared. `name_hint`/`mime_hint`/`size_hint` are
103
+ * UNTRUSTED claims by the sender, data only (like `speaker`): never a path,
104
+ * an instruction or an authorization input. Undefined for every other turn.
105
+ */
106
+ vault_refs?: VaultRefEntry[];
97
107
  /**
98
108
  * Task 4 (voice-mode carrier): true when the InboxEntry that produced this
99
109
  * buffered message had `voiceMode` set by the server (the agent device's
@@ -129,6 +139,8 @@ export interface BridgeMessage {
129
139
  * for voice-flagged turns. Selected by `InboxEntry.voiceMode` in the entry
130
140
  * loop below — never transmitted, never a playback trigger.
131
141
  */
142
+ /** Files link: the turn text of a `vault_ref` that carries no caption. */
143
+ export declare const VAULT_REF_BARE_TEXT = "[Files link]";
132
144
  export declare const VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
133
145
  /**
134
146
  * Task 2 (room voice guidance): compiled reply guidance for a voice-flagged
@@ -575,6 +587,12 @@ export declare class BridgeAgent {
575
587
  * either source wins; both read the same identity column.
576
588
  */
577
589
  private lastOwnDisplayName;
590
+ /**
591
+ * Thinking-indicator gate (owner rule, 2026-10-01): the agent-facing status
592
+ * routes go through it, so a reply inside 3 s raises no "thinking..." strip.
593
+ * See status-gate.ts.
594
+ */
595
+ private readonly statusGate;
578
596
  /**
579
597
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
580
598
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -672,6 +690,8 @@ export declare class BridgeAgent {
672
690
  * counter is incremented. Surfaced on health()/metrics().
673
691
  */
674
692
  private failedAttachments;
693
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
694
+ private refusedVaultRefs;
675
695
  /**
676
696
  * Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
677
697
  * client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
@@ -1575,7 +1595,7 @@ export declare class BridgeAgent {
1575
1595
  * useVault.downloadFile returns `{name, bytes}` in-memory — here the
1576
1596
  * caller already supplies nodeId so we return bytes, not a re-resolved
1577
1597
  * name the bridge has no cached listing to look up). */
1578
- vaultRead(enclaveId: string, vaultChannelId: string | null | undefined, nodeId: string): Promise<{
1598
+ vaultRead(enclaveId: string, vaultChannelId: string | null | undefined, nodeId: string, expected?: VaultReadExpectations): Promise<{
1579
1599
  bytes_b64: string;
1580
1600
  }>;
1581
1601
  /** List + decrypt names for the vault nodes under `parentId` (null = root). */
@@ -1819,6 +1839,12 @@ export declare class BridgeAgent {
1819
1839
  * never trusted on its own, it is always reconfirmed against the store.
1820
1840
  */
1821
1841
  private resolvePinnedOwnerHook;
1842
+ /**
1843
+ * The agent-facing status entry (HTTP /v1/status, WS "status"): "working"
1844
+ * is held 3 s and dropped if the turn ends or a reply starts first. See
1845
+ * status-gate.ts. Resolves at once for "working".
1846
+ */
1847
+ reportStatusGated(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
1822
1848
  reportStatus(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
1823
1849
  /** `runId` must exist and not have settled yet — settle() is terminal. */
1824
1850
  private requireActionRun;
@@ -2130,6 +2156,8 @@ export declare class BridgeAgent {
2130
2156
  custody_store_unavailable: boolean;
2131
2157
  /** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
2132
2158
  failed_attachments: number;
2159
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding), acked. */
2160
+ refused_vault_refs: number;
2133
2161
  /** Task 4 (self-hosted fast voice): cumulative fast-path replies actually sent. */
2134
2162
  fast_path_sent: number;
2135
2163
  /** Task 4 (self-hosted fast voice): cumulative fast-path attempts that errored (LLM call, normalization, or send) — fail-soft, never blocks the buffered turn. */
@@ -2280,6 +2308,8 @@ export declare class BridgeAgent {
2280
2308
  welcomeRequestsSent: number;
2281
2309
  /** Track 5: cumulative artifact_share failures (download/decrypt/digest). */
2282
2310
  failedAttachments: number;
2311
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding), acked. */
2312
+ refusedVaultRefs: number;
2283
2313
  /** SDK-6: bridge build id, so a /v1/metrics reading captured across a daemon
2284
2314
  * restart can be attributed to a version — counters are process-lifetime
2285
2315
  * and zero on restart (spec §5.5). */
package/dist/errors.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * WebSocket returns `{type:"error", error, message, detail?}`. A client gets the
7
7
  * same `error` code regardless of which transport it used. (A2A §9.5/§10.6 pattern.)
8
8
  */
9
- export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "internal";
9
+ export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "group_superseded" | "unknown_action_run" | "vault_integrity_failed" | "vault_ref_changed" | "internal";
10
10
  export interface BridgeErrorSpec {
11
11
  /** HTTP status this code maps to (the WS binding ignores it). */
12
12
  http: number;
@@ -43,6 +43,7 @@ export declare function capabilities(opts: {
43
43
  receive_modes: string[];
44
44
  send_types: string[];
45
45
  webhook_push: boolean;
46
+ vault_ref: number;
46
47
  };
47
48
  export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
48
49
  conversation_id: z.ZodString;
@@ -56,7 +57,7 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
56
57
  text: string;
57
58
  role: "agent" | "peer";
58
59
  conversation_id: string;
59
- modality: "voice" | "text";
60
+ modality: "text" | "voice";
60
61
  message_id?: string | undefined;
61
62
  idempotency_key?: string | undefined;
62
63
  peer_id?: string | undefined;
@@ -64,7 +65,7 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
64
65
  text: string;
65
66
  role: "agent" | "peer";
66
67
  conversation_id: string;
67
- modality: "voice" | "text";
68
+ modality: "text" | "voice";
68
69
  message_id?: string | undefined;
69
70
  idempotency_key?: string | undefined;
70
71
  peer_id?: string | undefined;
@@ -72,7 +73,7 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
72
73
  text: string;
73
74
  role: "agent" | "peer";
74
75
  conversation_id: string;
75
- modality: "voice" | "text";
76
+ modality: "text" | "voice";
76
77
  message_id?: string | undefined;
77
78
  idempotency_key?: string | undefined;
78
79
  peer_id?: string | undefined;
@@ -80,7 +81,7 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
80
81
  text: string;
81
82
  role: "agent" | "peer";
82
83
  conversation_id: string;
83
- modality: "voice" | "text";
84
+ modality: "text" | "voice";
84
85
  message_id?: string | undefined;
85
86
  idempotency_key?: string | undefined;
86
87
  peer_id?: string | undefined;
package/dist/index.js CHANGED
@@ -8593,6 +8593,31 @@ function unpackDekMaterial(buf) {
8593
8593
  return { fileKey: buf.subarray(0, 32), fileNonce: buf.subarray(32) };
8594
8594
  }
8595
8595
 
8596
+ // ../connect-clients-core/dist/filesafe/dek-commit.js
8597
+ import { blake2b as blake2b2 } from "@noble/hashes/blake2b";
8598
+ var DEK_COMMIT_LABEL = "securecoms/vault_ref/dek/v1";
8599
+ function computeDekCommit(enclaveId, nodeId, fileKey, fileNonce) {
8600
+ const enc3 = new TextEncoder();
8601
+ const sep = new Uint8Array([0]);
8602
+ const parts = [
8603
+ enc3.encode(DEK_COMMIT_LABEL),
8604
+ sep,
8605
+ enc3.encode(enclaveId),
8606
+ sep,
8607
+ enc3.encode(nodeId),
8608
+ sep,
8609
+ packDekMaterial(fileKey, fileNonce)
8610
+ ];
8611
+ const preimage = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
8612
+ let at = 0;
8613
+ for (const p of parts) {
8614
+ preimage.set(p, at);
8615
+ at += p.length;
8616
+ }
8617
+ const digest = blake2b2(preimage, { dkLen: 32 });
8618
+ return `blake2b:${Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("")}`;
8619
+ }
8620
+
8596
8621
  // ../../node_modules/@agentvault/crypto/dist/index.js
8597
8622
  init_sha2();
8598
8623
  init_hmac();
@@ -25491,7 +25516,7 @@ var FakeConnectBackend = class _FakeConnectBackend {
25491
25516
  message_id: messageId,
25492
25517
  channel_id: p.channelId,
25493
25518
  enclave_id: p.enclaveId,
25494
- message_type: "text",
25519
+ message_type: p.messageType ?? "text",
25495
25520
  sender_id: p.senderId,
25496
25521
  sender_type: "agent",
25497
25522
  identity_proof: { signature: "", algo: "ed25519", attested: false, canonicalization: "RFC8785" },
@@ -27388,6 +27413,47 @@ var DecryptErrorLogLimiter = class {
27388
27413
 
27389
27414
  // src/attachments.ts
27390
27415
  import { basename } from "node:path";
27416
+ var VAULT_REF_MAX_SIZE_HINT = 100 * 1024 * 1024 - 16;
27417
+ var VAULT_REF_MAX_NAME = 255;
27418
+ var VAULT_REF_MAX_MIME = 127;
27419
+ var CANONICAL_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
27420
+ var VAULT_PIN = /^blake2b:[0-9a-f]{64}$/;
27421
+ var HINT_FORBIDDEN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
27422
+ var MIME_TYPE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/;
27423
+ function parseVaultRefBody(raw) {
27424
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
27425
+ const r = raw;
27426
+ if (r.type !== "vault_ref" || r.v !== 1) return null;
27427
+ const { enclaveId, nodeId, contentHash, dekCommit } = r;
27428
+ if (typeof enclaveId !== "string" || !CANONICAL_UUID.test(enclaveId)) return null;
27429
+ if (typeof nodeId !== "string" || !CANONICAL_UUID.test(nodeId)) return null;
27430
+ if (typeof contentHash !== "string" || !VAULT_PIN.test(contentHash)) return null;
27431
+ if (typeof dekCommit !== "string" || !VAULT_PIN.test(dekCommit)) return null;
27432
+ const body = { v: 1, type: "vault_ref", enclaveId, nodeId, contentHash, dekCommit };
27433
+ if (typeof r.name === "string" && r.name.length <= VAULT_REF_MAX_NAME && !HINT_FORBIDDEN.test(r.name)) {
27434
+ body.name = r.name;
27435
+ }
27436
+ if (typeof r.mime === "string" && r.mime.length <= VAULT_REF_MAX_MIME && !HINT_FORBIDDEN.test(r.mime) && MIME_TYPE.test(r.mime)) {
27437
+ body.mime = r.mime;
27438
+ }
27439
+ if (typeof r.size === "number" && Number.isInteger(r.size) && r.size >= 0 && r.size <= VAULT_REF_MAX_SIZE_HINT) {
27440
+ body.size = r.size;
27441
+ }
27442
+ if (typeof r.text === "string") body.text = r.text;
27443
+ return body;
27444
+ }
27445
+ function toVaultRefEntry(body) {
27446
+ const entry = {
27447
+ enclave_id: body.enclaveId,
27448
+ node_id: body.nodeId,
27449
+ content_hash: body.contentHash,
27450
+ dek_commit: body.dekCommit
27451
+ };
27452
+ if (body.name !== void 0) entry.name_hint = body.name;
27453
+ if (body.mime !== void 0) entry.mime_hint = body.mime;
27454
+ if (body.size !== void 0) entry.size_hint = body.size;
27455
+ return entry;
27456
+ }
27391
27457
  function sanitizeFilename(name, fallback) {
27392
27458
  const base = basename(name ?? "");
27393
27459
  const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
@@ -28257,6 +28323,15 @@ var VaultIntegrityError = class extends Error {
28257
28323
  if (cause !== void 0) this.cause = cause;
28258
28324
  }
28259
28325
  };
28326
+ var VaultPinMismatchError = class extends VaultIntegrityError {
28327
+ pin;
28328
+ constructor(pin) {
28329
+ super(`vault: pinned ${pin} does not match; the file changed after it was shared, not opened`);
28330
+ this.name = "VaultPinMismatchError";
28331
+ this.pin = pin;
28332
+ }
28333
+ };
28334
+ var VAULT_PINS_TOGETHER = "expected_content_hash and expected_dek_commit go together";
28260
28335
  async function vaultWriteImpl(args2) {
28261
28336
  const gen = args2.deps.currentGeneration;
28262
28337
  const kek = args2.deps.exporterKekForGeneration(gen);
@@ -28280,10 +28355,20 @@ async function vaultWriteImpl(args2) {
28280
28355
  return { node_id: created.node_id };
28281
28356
  }
28282
28357
  async function vaultReadImpl(args2) {
28358
+ const pinnedHash = args2.expected?.contentHash;
28359
+ const pinnedCommit = args2.expected?.dekCommit;
28360
+ if (args2.expected !== void 0 && (typeof pinnedHash !== "string" || typeof pinnedCommit !== "string")) {
28361
+ throw new VaultIntegrityError(`vault: ${VAULT_PINS_TOGETHER}`);
28362
+ }
28283
28363
  const meta = await args2.deps.getNodeForDownload(args2.nodeId);
28364
+ if (pinnedHash !== void 0 && meta.content_hash !== pinnedHash) {
28365
+ throw new VaultPinMismatchError("content_hash_server");
28366
+ }
28284
28367
  const ct = await args2.deps.getBlob(meta.download_url);
28285
- const expected = (meta.content_hash ?? "").replace(/^blake2b:/, "");
28286
- if (await computeFileDigest(ct) !== expected) {
28368
+ const digestHex = await computeFileDigest(ct);
28369
+ if (pinnedHash !== void 0) {
28370
+ if (`blake2b:${digestHex}` !== pinnedHash) throw new VaultPinMismatchError("ciphertext_digest");
28371
+ } else if (digestHex !== (meta.content_hash ?? "").replace(/^blake2b:/, "")) {
28287
28372
  throw new VaultIntegrityError("vault: ciphertext failed integrity/digest check");
28288
28373
  }
28289
28374
  const wrap = [...meta.wraps].sort((a, b) => b.kek_generation - a.kek_generation)[0];
@@ -28297,6 +28382,9 @@ async function vaultReadImpl(args2) {
28297
28382
  } catch (e) {
28298
28383
  throw new VaultIntegrityError("vault: DEK unwrap failed (bad KEK, AAD mismatch, or tampered wrap)", e);
28299
28384
  }
28385
+ if (pinnedCommit !== void 0 && computeDekCommit(args2.enclaveId, args2.nodeId, fileKey, fileNonce) !== pinnedCommit) {
28386
+ throw new VaultPinMismatchError("dek_commit");
28387
+ }
28300
28388
  try {
28301
28389
  return await decryptFile(ct, fileKey, fileNonce);
28302
28390
  } catch (e) {
@@ -28687,6 +28775,70 @@ function pruneManagedMemory(home, identityId, hashes, deps = REAL_FS) {
28687
28775
 
28688
28776
  // src/bridge-agent.ts
28689
28777
  import { homedir as homedir2 } from "node:os";
28778
+
28779
+ // src/status-gate.ts
28780
+ var STATUS_WORKING_DELAY_MS = 3e3;
28781
+ var StatusGate = class {
28782
+ constructor(publish, opts = {}) {
28783
+ this.publish = publish;
28784
+ this.opts = opts;
28785
+ }
28786
+ entries = /* @__PURE__ */ new Map();
28787
+ chains = /* @__PURE__ */ new Map();
28788
+ /** The agent reported a status. Resolves once any publish it starts has settled. */
28789
+ async report(channelId, state, detail) {
28790
+ if (state === "working") {
28791
+ this.holdWorking(channelId, detail);
28792
+ return;
28793
+ }
28794
+ const entry = this.entries.get(channelId);
28795
+ this.entries.delete(channelId);
28796
+ if (entry?.timer) clearTimeout(entry.timer);
28797
+ if (state === "idle" && entry && !entry.published) return;
28798
+ await this.enqueue(channelId, state, detail);
28799
+ }
28800
+ /** A reply body is about to publish on the channel. */
28801
+ replyStarted(channelId) {
28802
+ const entry = this.entries.get(channelId);
28803
+ if (!entry?.timer) return;
28804
+ clearTimeout(entry.timer);
28805
+ entry.timer = null;
28806
+ }
28807
+ /** Drop every pending "working" (bridge shutdown). */
28808
+ close() {
28809
+ for (const entry of this.entries.values()) if (entry.timer) clearTimeout(entry.timer);
28810
+ this.entries.clear();
28811
+ }
28812
+ holdWorking(channelId, detail) {
28813
+ const existing = this.entries.get(channelId);
28814
+ if (existing && (existing.timer || existing.published)) return;
28815
+ const entry = { timer: null, published: false };
28816
+ this.entries.set(channelId, entry);
28817
+ entry.timer = setTimeout(() => {
28818
+ entry.timer = null;
28819
+ if (this.entries.get(channelId) !== entry) return;
28820
+ entry.published = true;
28821
+ this.enqueue(channelId, "working", detail).catch(() => {
28822
+ });
28823
+ }, this.opts.delayMs ?? STATUS_WORKING_DELAY_MS);
28824
+ entry.timer.unref?.();
28825
+ }
28826
+ /** Publishes in order per channel; rejects with the publish error. */
28827
+ enqueue(channelId, state, detail) {
28828
+ const prev = this.chains.get(channelId) ?? Promise.resolve();
28829
+ const run = prev.then(() => this.publish(channelId, state, detail));
28830
+ const settled = run.catch((err) => {
28831
+ this.opts.onError?.(state, err);
28832
+ });
28833
+ this.chains.set(channelId, settled);
28834
+ void settled.then(() => {
28835
+ if (this.chains.get(channelId) === settled) this.chains.delete(channelId);
28836
+ });
28837
+ return run;
28838
+ }
28839
+ };
28840
+
28841
+ // src/bridge-agent.ts
28690
28842
  function logSendSupersededAdoption(kind) {
28691
28843
  switch (kind) {
28692
28844
  case "adopted":
@@ -28730,6 +28882,7 @@ function logPendingCommitResolved(outcome) {
28730
28882
  writeServiceLog2("info", "mls.pending_commit.resolved.unknown");
28731
28883
  }
28732
28884
  }
28885
+ var VAULT_REF_BARE_TEXT = "[Files link]";
28733
28886
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
28734
28887
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
28735
28888
  var ROOM_VOICE_GUIDANCE_MENTION_ONLY = "You are in a group voice room. Reply ONLY when a message @mentions your display name. Keep replies to one short sentence. When you should not reply, send nothing.";
@@ -29118,6 +29271,15 @@ var BridgeAgent = class _BridgeAgent {
29118
29271
  * either source wins; both read the same identity column.
29119
29272
  */
29120
29273
  lastOwnDisplayName;
29274
+ /**
29275
+ * Thinking-indicator gate (owner rule, 2026-10-01): the agent-facing status
29276
+ * routes go through it, so a reply inside 3 s raises no "thinking..." strip.
29277
+ * See status-gate.ts.
29278
+ */
29279
+ statusGate = new StatusGate(
29280
+ (channelId, state, detail) => this.reportStatus(channelId, state, detail),
29281
+ { onError: (state) => writeServiceLog2("warn", "status.publish_failed", { working: state === "working", idle: state === "idle" }) }
29282
+ );
29121
29283
  /**
29122
29284
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
29123
29285
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -29215,6 +29377,8 @@ var BridgeAgent = class _BridgeAgent {
29215
29377
  * counter is incremented. Surfaced on health()/metrics().
29216
29378
  */
29217
29379
  failedAttachments = 0;
29380
+ /** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
29381
+ refusedVaultRefs = 0;
29218
29382
  /**
29219
29383
  * Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
29220
29384
  * client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
@@ -30649,6 +30813,7 @@ var BridgeAgent = class _BridgeAgent {
30649
30813
  */
30650
30814
  async publishBody(channelId, body, id, onEnvelope) {
30651
30815
  this.enforceBodyRateFloor(channelId, body);
30816
+ this.statusGate.replyStarted(channelId);
30652
30817
  const joined = await this.reconcileJoin(channelId);
30653
30818
  if (!joined) throw new ChannelPendingError(channelId);
30654
30819
  const sendStart = performance.now();
@@ -31275,11 +31440,12 @@ var BridgeAgent = class _BridgeAgent {
31275
31440
  * useVault.downloadFile returns `{name, bytes}` in-memory — here the
31276
31441
  * caller already supplies nodeId so we return bytes, not a re-resolved
31277
31442
  * name the bridge has no cached listing to look up). */
31278
- async vaultRead(enclaveId, vaultChannelId, nodeId) {
31443
+ async vaultRead(enclaveId, vaultChannelId, nodeId, expected) {
31279
31444
  const session = await this.getVaultSession(enclaveId, await this.resolveVaultChannelId(enclaveId, vaultChannelId));
31280
31445
  const bytes = await vaultReadImpl({
31281
31446
  enclaveId,
31282
31447
  nodeId,
31448
+ expected,
31283
31449
  deps: {
31284
31450
  exporterKekForGeneration: session.kekForGeneration,
31285
31451
  getNodeForDownload: (id) => this.vaultGetNodeForDownload(enclaveId, id),
@@ -31762,6 +31928,14 @@ var BridgeAgent = class _BridgeAgent {
31762
31928
  if (TRACE) writeServiceLog2("debug", "identity.owner_pin_resolved");
31763
31929
  return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
31764
31930
  }
31931
+ /**
31932
+ * The agent-facing status entry (HTTP /v1/status, WS "status"): "working"
31933
+ * is held 3 s and dropped if the turn ends or a reply starts first. See
31934
+ * status-gate.ts. Resolves at once for "working".
31935
+ */
31936
+ async reportStatusGated(channelId, state, detail) {
31937
+ await this.statusGate.report(channelId, state, detail);
31938
+ }
31765
31939
  async reportStatus(channelId, state, detail) {
31766
31940
  const joined = await this.reconcileJoin(channelId);
31767
31941
  if (!joined) throw new ChannelPendingError(channelId);
@@ -32261,7 +32435,12 @@ var BridgeAgent = class _BridgeAgent {
32261
32435
  const encId = m.envelope.enclave_id;
32262
32436
  if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
32263
32437
  this.channelEnclaveRecorded.add(m.channelId);
32264
- this.store.recordChannelEnclave(m.channelId, encId);
32438
+ const priorEnclave = this.store.getChannelMeta(m.channelId)?.enclaveId;
32439
+ if (priorEnclave === void 0) {
32440
+ this.store.recordChannelEnclave(m.channelId, encId);
32441
+ } else if (priorEnclave !== encId) {
32442
+ writeServiceLog2("warn", "channel_enclave.envelope_mismatch");
32443
+ }
32265
32444
  }
32266
32445
  const parsed = JSON.parse(
32267
32446
  new TextDecoder().decode(m.plaintext)
@@ -32275,6 +32454,75 @@ var BridgeAgent = class _BridgeAgent {
32275
32454
  acked.push(m.queueId);
32276
32455
  continue;
32277
32456
  }
32457
+ if (parsed.type === "vault_ref") {
32458
+ const senderType = m.envelope.sender_type;
32459
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
32460
+ const chanMeta = this.freshMeta(m.channelId);
32461
+ const loopSenderIsAgent = this.loopSenderIsAgent(
32462
+ m.channelId,
32463
+ m.envelope.sender_id,
32464
+ senderIsAgent,
32465
+ false
32466
+ );
32467
+ const agentHops = this.agentHops.noteInbound(m.channelId, loopSenderIsAgent, m.messageId);
32468
+ const caption = typeof parsed.text === "string" ? parsed.text : "";
32469
+ let answerable = true;
32470
+ if (chanMeta && chanMeta.is_dm === false) {
32471
+ answerable = shouldProcessRoomMessage(
32472
+ caption,
32473
+ this.cfg.agentName ?? this.cfg.identityId,
32474
+ this.cfg.identityId,
32475
+ {
32476
+ senderIsAgent: loopSenderIsAgent,
32477
+ roomDisplayName: this.ownRoomDisplayName(),
32478
+ agentHops,
32479
+ maxAgentHops: this.maxAgentHops
32480
+ }
32481
+ );
32482
+ if (!answerable) this.filteredRoomMessages++;
32483
+ } else if (loopSenderIsAgent === true && !agentHopAllowed(agentHops, this.maxAgentHops)) {
32484
+ answerable = false;
32485
+ }
32486
+ if (!answerable) {
32487
+ if (loopSenderIsAgent === true) this.noteAgentTurnSuppressed(agentHops);
32488
+ acked.push(m.queueId);
32489
+ continue;
32490
+ }
32491
+ const ref = parseVaultRefBody(parsed);
32492
+ if (ref === null) {
32493
+ this.refusedVaultRefs++;
32494
+ writeServiceLog2("warn", "vault_ref.invalid");
32495
+ acked.push(m.queueId);
32496
+ continue;
32497
+ }
32498
+ const recordedEnclave = this.store.getChannelMeta(m.channelId)?.enclaveId;
32499
+ if (!encId || ref.enclaveId !== encId || recordedEnclave !== ref.enclaveId) {
32500
+ this.refusedVaultRefs++;
32501
+ writeServiceLog2("warn", "vault_ref.enclave_mismatch");
32502
+ acked.push(m.queueId);
32503
+ continue;
32504
+ }
32505
+ const vaultRefMessage = {
32506
+ channel_id: m.channelId,
32507
+ message_id: m.messageId,
32508
+ sender_id: m.envelope.sender_id,
32509
+ // A bare link still reads as something to a runtime that
32510
+ // ignores vault_refs.
32511
+ text: ref.text ? ref.text : VAULT_REF_BARE_TEXT,
32512
+ ts: m.receivedAt,
32513
+ authorization: this.authorizationFor(m.channelId, m.envelope.sender_id),
32514
+ sender_name: this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name,
32515
+ sender_is_agent: senderIsAgent,
32516
+ vault_refs: [toVaultRefEntry(ref)],
32517
+ ...m.voiceMode ? {
32518
+ voice: true,
32519
+ guidance: selectVoiceGuidance(chanMeta?.is_dm, m.voiceDefaultResponder)
32520
+ } : {}
32521
+ };
32522
+ if (!await this.stageRuntimeMessage(vaultRefMessage)) continue;
32523
+ acked.push(m.queueId);
32524
+ continue;
32525
+ }
32278
32526
  if (parsed.type === "artifact_share") {
32279
32527
  try {
32280
32528
  const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
@@ -33001,6 +33249,7 @@ var BridgeAgent = class _BridgeAgent {
33001
33249
  custody_read_failing: this.provider.custodyHealth().readFailing,
33002
33250
  custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
33003
33251
  failed_attachments: this.failedAttachments,
33252
+ refused_vault_refs: this.refusedVaultRefs,
33004
33253
  fast_path_sent: this.fastPathSent,
33005
33254
  fast_path_failed: this.fastPathFailed,
33006
33255
  fast_path_skipped: this.fastPathSkipped,
@@ -33061,11 +33310,13 @@ var BridgeAgent = class _BridgeAgent {
33061
33310
  custody: this.provider.custodyHealth(),
33062
33311
  welcomeRequestsSent: this.welcomeRequestsSent,
33063
33312
  failedAttachments: this.failedAttachments,
33313
+ refusedVaultRefs: this.refusedVaultRefs,
33064
33314
  build: BRIDGE_VERSION
33065
33315
  };
33066
33316
  }
33067
33317
  async close() {
33068
33318
  this.closingDown = true;
33319
+ this.statusGate.close();
33069
33320
  for (const t of this.pauseRecheckTimers.values()) clearTimeout(t);
33070
33321
  this.pauseRecheckTimers.clear();
33071
33322
  for (const t of this.supersedeRecheckTimers.values()) clearTimeout(t);
@@ -33271,6 +33522,10 @@ var BRIDGE_ERRORS = {
33271
33522
  // tell "server/network hiccup, retry" apart from "this ciphertext or key
33272
33523
  // material is not trustworthy, do not retry blindly".
33273
33524
  vault_integrity_failed: { http: 422, message: "vault content or key material failed integrity/signature verification" },
33525
+ // Files link (review 2026-10-01 R4): a pinned /v1/vault-read whose node no
33526
+ // longer holds the shared bytes or key. Terminal for that link: never fall
33527
+ // back to the current version. `detail.pin` names the refused pin.
33528
+ vault_ref_changed: { http: 422, message: "this file changed in Files after it was shared; not opened" },
33274
33529
  internal: { http: 500, message: "internal error" }
33275
33530
  };
33276
33531
  function errorBody(code, detail) {
@@ -33317,8 +33572,13 @@ function capabilities(opts) {
33317
33572
  api_version: "1",
33318
33573
  receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
33319
33574
  send_types: ["text", "agent_status", "a2a"],
33320
- webhook_push: false
33575
+ webhook_push: false,
33321
33576
  // B3: A2A-style push-notification config (ciphertext/wake-only)
33577
+ // Files link (review 2026-10-01 section 6): the highest `vault_ref` body
33578
+ // version this daemon parses into BridgeMessage.vault_refs (never fetched
33579
+ // on receipt) and opens with the R4 pins on /v1/vault-read. A daemon
33580
+ // without this field skips and acks a link, so the agent never sees it.
33581
+ vault_ref: 1
33322
33582
  };
33323
33583
  }
33324
33584
  var SEND_CAP = 64 * 1024;
@@ -33360,8 +33620,17 @@ var vaultWriteSchema = z3.object({
33360
33620
  var vaultReadSchema = z3.object({
33361
33621
  enclave_id: z3.string().min(1),
33362
33622
  vault_channel_id: z3.string().min(1).optional(),
33363
- node_id: z3.string().min(1)
33364
- });
33623
+ node_id: z3.string().min(1),
33624
+ // Files link pins (review 2026-10-01 R4), copied by the runtime from a
33625
+ // BridgeMessage.vault_refs entry. Each is enforced when present; omitted,
33626
+ // the read is unchanged.
33627
+ expected_content_hash: z3.string().regex(VAULT_PIN).optional(),
33628
+ expected_dek_commit: z3.string().regex(VAULT_PIN).optional()
33629
+ }).refine(
33630
+ // Audit H1: both pins or neither. A single pin is never a weaker pinned read.
33631
+ (d) => d.expected_content_hash === void 0 === (d.expected_dek_commit === void 0),
33632
+ { message: VAULT_PINS_TOGETHER }
33633
+ );
33365
33634
  var vaultListSchema = z3.object({
33366
33635
  enclave_id: z3.string().min(1),
33367
33636
  vault_channel_id: z3.string().min(1).optional(),
@@ -33501,7 +33770,11 @@ function createHttpServer(agent, opts) {
33501
33770
  if (method === "POST" && path === "/v1/vault-read") {
33502
33771
  const parsed = vaultReadSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33503
33772
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33504
- const r = await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
33773
+ const { expected_content_hash: pinHash, expected_dek_commit: pinCommit } = parsed.data;
33774
+ const r = pinHash !== void 0 && pinCommit !== void 0 ? await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id, {
33775
+ contentHash: pinHash,
33776
+ dekCommit: pinCommit
33777
+ }) : await agent.vaultRead(parsed.data.enclave_id, parsed.data.vault_channel_id, parsed.data.node_id);
33505
33778
  return json(res, 200, r);
33506
33779
  }
33507
33780
  if (method === "POST" && path === "/v1/vault-list") {
@@ -33519,7 +33792,7 @@ function createHttpServer(agent, opts) {
33519
33792
  if (method === "POST" && path === "/v1/status") {
33520
33793
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
33521
33794
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
33522
- await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
33795
+ await agent.reportStatusGated(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
33523
33796
  return json(res, 200, { ok: true });
33524
33797
  }
33525
33798
  if (method === "POST" && path === "/v1/memory/record") {
@@ -33594,6 +33867,7 @@ function createHttpServer(agent, opts) {
33594
33867
  if (e instanceof GroupSupersededSendError) return jsonError(res, "group_superseded");
33595
33868
  if (e instanceof IdempotencyConflictError) return jsonError(res, "invalid_request", e.message);
33596
33869
  if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
33870
+ if (e instanceof VaultPinMismatchError) return jsonError(res, "vault_ref_changed", { pin: e.pin });
33597
33871
  if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
33598
33872
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
33599
33873
  writeServiceLog2("error", "http.request_failed", {}, e);
@@ -33889,7 +34163,7 @@ async function handleInbound(sock, agent, data, inflight, flush, session) {
33889
34163
  }
33890
34164
  const detail = typeof m.detail === "string" ? m.detail : void 0;
33891
34165
  try {
33892
- await agent.reportStatus(m.channel_id, m.state, detail);
34166
+ await agent.reportStatusGated(m.channel_id, m.state, detail);
33893
34167
  sendJson(sock, { type: "ok" });
33894
34168
  } catch (e) {
33895
34169
  if (e instanceof ChannelPendingError) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Thinking-indicator gate (owner rule, 2026-10-01): a reply that comes back
3
+ * within {@link STATUS_WORKING_DELAY_MS} shows no "thinking..." strip at all;
4
+ * a longer turn shows it.
5
+ *
6
+ * Every status is an MLS publish (about 4.4 s in prod) on the same per-tenant
7
+ * send path as the reply, so an eager "working" made quick replies wait behind
8
+ * it. The gate holds "working" for the delay and publishes it only if the turn
9
+ * is still running then:
10
+ *
11
+ * - "idle" before the delay cancels the pending "working" and publishes
12
+ * nothing (the strip was never shown, so there is nothing to clear);
13
+ * - a reply starting to publish on the channel also cancels it, because
14
+ * some runtimes (OpenClaw) send "idle" only after the reply publish has
15
+ * settled, which would otherwise raise the strip under a visible answer;
16
+ * - once "working" is published, "idle" is published after it, in order;
17
+ * - "stalled" / "error" publish at once (after anything queued) and drop a
18
+ * pending "working".
19
+ *
20
+ * Only the agent-facing status routes (HTTP /v1/status, WS "status") go
21
+ * through the gate. BridgeAgent.reportStatus itself still publishes at once.
22
+ */
23
+ export declare const STATUS_WORKING_DELAY_MS = 3000;
24
+ export type AgentStatusState = "idle" | "working" | "stalled" | "error";
25
+ type Publish = (channelId: string, state: AgentStatusState, detail?: string) => Promise<void>;
26
+ export declare class StatusGate {
27
+ private readonly publish;
28
+ private readonly opts;
29
+ private readonly entries;
30
+ private readonly chains;
31
+ constructor(publish: Publish, opts?: {
32
+ delayMs?: number;
33
+ onError?: (state: AgentStatusState, err: unknown) => void;
34
+ });
35
+ /** The agent reported a status. Resolves once any publish it starts has settled. */
36
+ report(channelId: string, state: AgentStatusState, detail?: string): Promise<void>;
37
+ /** A reply body is about to publish on the channel. */
38
+ replyStarted(channelId: string): void;
39
+ /** Drop every pending "working" (bridge shutdown). */
40
+ close(): void;
41
+ private holdWorking;
42
+ /** Publishes in order per channel; rejects with the publish error. */
43
+ private enqueue;
44
+ }
45
+ export {};
package/dist/vault.d.ts CHANGED
@@ -6,6 +6,18 @@
6
6
  export declare class VaultIntegrityError extends Error {
7
7
  constructor(message: string, cause?: unknown);
8
8
  }
9
+ /** Which Files-link pin a pinned read refused on (review 2026-10-01 R4). */
10
+ export type VaultPin = "content_hash_server" | "ciphertext_digest" | "dek_commit";
11
+ /** A pinned read (`/v1/vault-read` with expected_*) refused because the node no
12
+ * longer holds the bytes or key that were shared: "This file changed in Files
13
+ * after it was shared. Not opened." A VaultIntegrityError, so every existing
14
+ * integrity handler still fails closed on it. */
15
+ export declare class VaultPinMismatchError extends VaultIntegrityError {
16
+ readonly pin: VaultPin;
17
+ constructor(pin: VaultPin);
18
+ }
19
+ /** The pin pair error text, shared with the HTTP boundary's refine (H1). */
20
+ export declare const VAULT_PINS_TOGETHER = "expected_content_hash and expected_dek_commit go together";
9
21
  export interface VaultWriteDeps {
10
22
  exporterKekForGeneration: (gen: number) => Uint8Array;
11
23
  currentGeneration: number;
@@ -45,10 +57,21 @@ export interface VaultReadDeps {
45
57
  }>;
46
58
  getBlob: (url: string) => Promise<Uint8Array>;
47
59
  }
60
+ /** Files-link pins for a read (review 2026-10-01 R4). Both are
61
+ * "blake2b:" + 64 hex, validated at the HTTP boundary. BOTH OR NEITHER
62
+ * (audit H1): a lone dekCommit fell back to the server's content_hash, so a
63
+ * member holding the KEK could re-encrypt new plaintext under the same DEK,
64
+ * recreate the node with the same wrap and a new hash, and be served it; a
65
+ * lone contentHash leaves the two-key swap open. */
66
+ export interface VaultReadExpectations {
67
+ contentHash: string;
68
+ dekCommit: string;
69
+ }
48
70
  export declare function vaultReadImpl(args: {
49
71
  enclaveId: string;
50
72
  nodeId: string;
51
73
  deps: VaultReadDeps;
74
+ expected?: VaultReadExpectations;
52
75
  }): Promise<Uint8Array>;
53
76
  export interface VaultListRawNode {
54
77
  node_id: string;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.25.3";
1
+ export declare const BRIDGE_VERSION = "0.26.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.25.3",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"