@securecoms/agent-bridge 0.25.4 → 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 +15 -0
- package/dist/attachments.d.ts +55 -0
- package/dist/bin.js +200 -9
- package/dist/bridge-agent.d.ts +21 -3
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +5 -4
- package/dist/index.js +200 -9
- package/dist/vault.d.ts +23 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
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
|
package/dist/attachments.d.ts
CHANGED
|
@@ -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
|
|
28437
|
-
if (
|
|
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) {
|
|
@@ -28945,6 +29033,7 @@ function logPendingCommitResolved(outcome) {
|
|
|
28945
29033
|
writeServiceLog("info", "mls.pending_commit.resolved.unknown");
|
|
28946
29034
|
}
|
|
28947
29035
|
}
|
|
29036
|
+
var VAULT_REF_BARE_TEXT = "[Files link]";
|
|
28948
29037
|
var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
|
|
28949
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.";
|
|
28950
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.";
|
|
@@ -29439,6 +29528,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
29439
29528
|
* counter is incremented. Surfaced on health()/metrics().
|
|
29440
29529
|
*/
|
|
29441
29530
|
failedAttachments = 0;
|
|
29531
|
+
/** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
|
|
29532
|
+
refusedVaultRefs = 0;
|
|
29442
29533
|
/**
|
|
29443
29534
|
* Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
|
|
29444
29535
|
* client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
|
|
@@ -31500,11 +31591,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31500
31591
|
* useVault.downloadFile returns `{name, bytes}` in-memory — here the
|
|
31501
31592
|
* caller already supplies nodeId so we return bytes, not a re-resolved
|
|
31502
31593
|
* name the bridge has no cached listing to look up). */
|
|
31503
|
-
async vaultRead(enclaveId, vaultChannelId, nodeId) {
|
|
31594
|
+
async vaultRead(enclaveId, vaultChannelId, nodeId, expected) {
|
|
31504
31595
|
const session = await this.getVaultSession(enclaveId, await this.resolveVaultChannelId(enclaveId, vaultChannelId));
|
|
31505
31596
|
const bytes = await vaultReadImpl({
|
|
31506
31597
|
enclaveId,
|
|
31507
31598
|
nodeId,
|
|
31599
|
+
expected,
|
|
31508
31600
|
deps: {
|
|
31509
31601
|
exporterKekForGeneration: session.kekForGeneration,
|
|
31510
31602
|
getNodeForDownload: (id) => this.vaultGetNodeForDownload(enclaveId, id),
|
|
@@ -32494,7 +32586,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32494
32586
|
const encId = m.envelope.enclave_id;
|
|
32495
32587
|
if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
|
|
32496
32588
|
this.channelEnclaveRecorded.add(m.channelId);
|
|
32497
|
-
this.store.
|
|
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
|
+
}
|
|
32498
32595
|
}
|
|
32499
32596
|
const parsed = JSON.parse(
|
|
32500
32597
|
new TextDecoder().decode(m.plaintext)
|
|
@@ -32508,6 +32605,75 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32508
32605
|
acked.push(m.queueId);
|
|
32509
32606
|
continue;
|
|
32510
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
|
+
}
|
|
32511
32677
|
if (parsed.type === "artifact_share") {
|
|
32512
32678
|
try {
|
|
32513
32679
|
const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
|
|
@@ -33234,6 +33400,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33234
33400
|
custody_read_failing: this.provider.custodyHealth().readFailing,
|
|
33235
33401
|
custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
|
|
33236
33402
|
failed_attachments: this.failedAttachments,
|
|
33403
|
+
refused_vault_refs: this.refusedVaultRefs,
|
|
33237
33404
|
fast_path_sent: this.fastPathSent,
|
|
33238
33405
|
fast_path_failed: this.fastPathFailed,
|
|
33239
33406
|
fast_path_skipped: this.fastPathSkipped,
|
|
@@ -33294,6 +33461,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33294
33461
|
custody: this.provider.custodyHealth(),
|
|
33295
33462
|
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
33296
33463
|
failedAttachments: this.failedAttachments,
|
|
33464
|
+
refusedVaultRefs: this.refusedVaultRefs,
|
|
33297
33465
|
build: BRIDGE_VERSION
|
|
33298
33466
|
};
|
|
33299
33467
|
}
|
|
@@ -33398,6 +33566,10 @@ var BRIDGE_ERRORS = {
|
|
|
33398
33566
|
// tell "server/network hiccup, retry" apart from "this ciphertext or key
|
|
33399
33567
|
// material is not trustworthy, do not retry blindly".
|
|
33400
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" },
|
|
33401
33573
|
internal: { http: 500, message: "internal error" }
|
|
33402
33574
|
};
|
|
33403
33575
|
function errorBody(code, detail) {
|
|
@@ -33444,8 +33616,13 @@ function capabilities(opts) {
|
|
|
33444
33616
|
api_version: "1",
|
|
33445
33617
|
receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
|
|
33446
33618
|
send_types: ["text", "agent_status", "a2a"],
|
|
33447
|
-
webhook_push: false
|
|
33619
|
+
webhook_push: false,
|
|
33448
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
|
|
33449
33626
|
};
|
|
33450
33627
|
}
|
|
33451
33628
|
var SEND_CAP = 64 * 1024;
|
|
@@ -33487,8 +33664,17 @@ var vaultWriteSchema = z3.object({
|
|
|
33487
33664
|
var vaultReadSchema = z3.object({
|
|
33488
33665
|
enclave_id: z3.string().min(1),
|
|
33489
33666
|
vault_channel_id: z3.string().min(1).optional(),
|
|
33490
|
-
node_id: z3.string().min(1)
|
|
33491
|
-
|
|
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
|
+
);
|
|
33492
33678
|
var vaultListSchema = z3.object({
|
|
33493
33679
|
enclave_id: z3.string().min(1),
|
|
33494
33680
|
vault_channel_id: z3.string().min(1).optional(),
|
|
@@ -33628,7 +33814,11 @@ function createHttpServer(agent, opts) {
|
|
|
33628
33814
|
if (method === "POST" && path === "/v1/vault-read") {
|
|
33629
33815
|
const parsed = vaultReadSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
33630
33816
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
33631
|
-
const
|
|
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);
|
|
33632
33822
|
return json(res, 200, r);
|
|
33633
33823
|
}
|
|
33634
33824
|
if (method === "POST" && path === "/v1/vault-list") {
|
|
@@ -33721,6 +33911,7 @@ function createHttpServer(agent, opts) {
|
|
|
33721
33911
|
if (e instanceof GroupSupersededSendError) return jsonError(res, "group_superseded");
|
|
33722
33912
|
if (e instanceof IdempotencyConflictError) return jsonError(res, "invalid_request", e.message);
|
|
33723
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 });
|
|
33724
33915
|
if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
|
|
33725
33916
|
if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
|
|
33726
33917
|
writeServiceLog("error", "http.request_failed", {}, e);
|
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -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
|
|
@@ -678,6 +690,8 @@ export declare class BridgeAgent {
|
|
|
678
690
|
* counter is incremented. Surfaced on health()/metrics().
|
|
679
691
|
*/
|
|
680
692
|
private failedAttachments;
|
|
693
|
+
/** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
|
|
694
|
+
private refusedVaultRefs;
|
|
681
695
|
/**
|
|
682
696
|
* Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
|
|
683
697
|
* client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
|
|
@@ -1581,7 +1595,7 @@ export declare class BridgeAgent {
|
|
|
1581
1595
|
* useVault.downloadFile returns `{name, bytes}` in-memory — here the
|
|
1582
1596
|
* caller already supplies nodeId so we return bytes, not a re-resolved
|
|
1583
1597
|
* name the bridge has no cached listing to look up). */
|
|
1584
|
-
vaultRead(enclaveId: string, vaultChannelId: string | null | undefined, nodeId: string): Promise<{
|
|
1598
|
+
vaultRead(enclaveId: string, vaultChannelId: string | null | undefined, nodeId: string, expected?: VaultReadExpectations): Promise<{
|
|
1585
1599
|
bytes_b64: string;
|
|
1586
1600
|
}>;
|
|
1587
1601
|
/** List + decrypt names for the vault nodes under `parentId` (null = root). */
|
|
@@ -2142,6 +2156,8 @@ export declare class BridgeAgent {
|
|
|
2142
2156
|
custody_store_unavailable: boolean;
|
|
2143
2157
|
/** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
|
|
2144
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;
|
|
2145
2161
|
/** Task 4 (self-hosted fast voice): cumulative fast-path replies actually sent. */
|
|
2146
2162
|
fast_path_sent: number;
|
|
2147
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. */
|
|
@@ -2292,6 +2308,8 @@ export declare class BridgeAgent {
|
|
|
2292
2308
|
welcomeRequestsSent: number;
|
|
2293
2309
|
/** Track 5: cumulative artifact_share failures (download/decrypt/digest). */
|
|
2294
2310
|
failedAttachments: number;
|
|
2311
|
+
/** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding), acked. */
|
|
2312
|
+
refusedVaultRefs: number;
|
|
2295
2313
|
/** SDK-6: bridge build id, so a /v1/metrics reading captured across a daemon
|
|
2296
2314
|
* restart can be attributed to a version — counters are process-lifetime
|
|
2297
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;
|
package/dist/http-server.d.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
28286
|
-
if (
|
|
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) {
|
|
@@ -28794,6 +28882,7 @@ function logPendingCommitResolved(outcome) {
|
|
|
28794
28882
|
writeServiceLog2("info", "mls.pending_commit.resolved.unknown");
|
|
28795
28883
|
}
|
|
28796
28884
|
}
|
|
28885
|
+
var VAULT_REF_BARE_TEXT = "[Files link]";
|
|
28797
28886
|
var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
|
|
28798
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.";
|
|
28799
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.";
|
|
@@ -29288,6 +29377,8 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
29288
29377
|
* counter is incremented. Surfaced on health()/metrics().
|
|
29289
29378
|
*/
|
|
29290
29379
|
failedAttachments = 0;
|
|
29380
|
+
/** Files link: vault_ref bodies refused by R1 (strict parse) or R2 (enclave binding). */
|
|
29381
|
+
refusedVaultRefs = 0;
|
|
29291
29382
|
/**
|
|
29292
29383
|
* Task 4 (self-hosted fast voice): test/deploy seam for the fast-path LLM
|
|
29293
29384
|
* client. Undefined whenever `cfg.voice?.fastPath.enabled` is not true —
|
|
@@ -31349,11 +31440,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
31349
31440
|
* useVault.downloadFile returns `{name, bytes}` in-memory — here the
|
|
31350
31441
|
* caller already supplies nodeId so we return bytes, not a re-resolved
|
|
31351
31442
|
* name the bridge has no cached listing to look up). */
|
|
31352
|
-
async vaultRead(enclaveId, vaultChannelId, nodeId) {
|
|
31443
|
+
async vaultRead(enclaveId, vaultChannelId, nodeId, expected) {
|
|
31353
31444
|
const session = await this.getVaultSession(enclaveId, await this.resolveVaultChannelId(enclaveId, vaultChannelId));
|
|
31354
31445
|
const bytes = await vaultReadImpl({
|
|
31355
31446
|
enclaveId,
|
|
31356
31447
|
nodeId,
|
|
31448
|
+
expected,
|
|
31357
31449
|
deps: {
|
|
31358
31450
|
exporterKekForGeneration: session.kekForGeneration,
|
|
31359
31451
|
getNodeForDownload: (id) => this.vaultGetNodeForDownload(enclaveId, id),
|
|
@@ -32343,7 +32435,12 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32343
32435
|
const encId = m.envelope.enclave_id;
|
|
32344
32436
|
if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
|
|
32345
32437
|
this.channelEnclaveRecorded.add(m.channelId);
|
|
32346
|
-
this.store.
|
|
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
|
+
}
|
|
32347
32444
|
}
|
|
32348
32445
|
const parsed = JSON.parse(
|
|
32349
32446
|
new TextDecoder().decode(m.plaintext)
|
|
@@ -32357,6 +32454,75 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
32357
32454
|
acked.push(m.queueId);
|
|
32358
32455
|
continue;
|
|
32359
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
|
+
}
|
|
32360
32526
|
if (parsed.type === "artifact_share") {
|
|
32361
32527
|
try {
|
|
32362
32528
|
const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
|
|
@@ -33083,6 +33249,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33083
33249
|
custody_read_failing: this.provider.custodyHealth().readFailing,
|
|
33084
33250
|
custody_store_unavailable: this.provider.custodyHealth().storeUnavailable,
|
|
33085
33251
|
failed_attachments: this.failedAttachments,
|
|
33252
|
+
refused_vault_refs: this.refusedVaultRefs,
|
|
33086
33253
|
fast_path_sent: this.fastPathSent,
|
|
33087
33254
|
fast_path_failed: this.fastPathFailed,
|
|
33088
33255
|
fast_path_skipped: this.fastPathSkipped,
|
|
@@ -33143,6 +33310,7 @@ var BridgeAgent = class _BridgeAgent {
|
|
|
33143
33310
|
custody: this.provider.custodyHealth(),
|
|
33144
33311
|
welcomeRequestsSent: this.welcomeRequestsSent,
|
|
33145
33312
|
failedAttachments: this.failedAttachments,
|
|
33313
|
+
refusedVaultRefs: this.refusedVaultRefs,
|
|
33146
33314
|
build: BRIDGE_VERSION
|
|
33147
33315
|
};
|
|
33148
33316
|
}
|
|
@@ -33354,6 +33522,10 @@ var BRIDGE_ERRORS = {
|
|
|
33354
33522
|
// tell "server/network hiccup, retry" apart from "this ciphertext or key
|
|
33355
33523
|
// material is not trustworthy, do not retry blindly".
|
|
33356
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" },
|
|
33357
33529
|
internal: { http: 500, message: "internal error" }
|
|
33358
33530
|
};
|
|
33359
33531
|
function errorBody(code, detail) {
|
|
@@ -33400,8 +33572,13 @@ function capabilities(opts) {
|
|
|
33400
33572
|
api_version: "1",
|
|
33401
33573
|
receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
|
|
33402
33574
|
send_types: ["text", "agent_status", "a2a"],
|
|
33403
|
-
webhook_push: false
|
|
33575
|
+
webhook_push: false,
|
|
33404
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
|
|
33405
33582
|
};
|
|
33406
33583
|
}
|
|
33407
33584
|
var SEND_CAP = 64 * 1024;
|
|
@@ -33443,8 +33620,17 @@ var vaultWriteSchema = z3.object({
|
|
|
33443
33620
|
var vaultReadSchema = z3.object({
|
|
33444
33621
|
enclave_id: z3.string().min(1),
|
|
33445
33622
|
vault_channel_id: z3.string().min(1).optional(),
|
|
33446
|
-
node_id: z3.string().min(1)
|
|
33447
|
-
|
|
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
|
+
);
|
|
33448
33634
|
var vaultListSchema = z3.object({
|
|
33449
33635
|
enclave_id: z3.string().min(1),
|
|
33450
33636
|
vault_channel_id: z3.string().min(1).optional(),
|
|
@@ -33584,7 +33770,11 @@ function createHttpServer(agent, opts) {
|
|
|
33584
33770
|
if (method === "POST" && path === "/v1/vault-read") {
|
|
33585
33771
|
const parsed = vaultReadSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
|
|
33586
33772
|
if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
|
|
33587
|
-
const
|
|
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);
|
|
33588
33778
|
return json(res, 200, r);
|
|
33589
33779
|
}
|
|
33590
33780
|
if (method === "POST" && path === "/v1/vault-list") {
|
|
@@ -33677,6 +33867,7 @@ function createHttpServer(agent, opts) {
|
|
|
33677
33867
|
if (e instanceof GroupSupersededSendError) return jsonError(res, "group_superseded");
|
|
33678
33868
|
if (e instanceof IdempotencyConflictError) return jsonError(res, "invalid_request", e.message);
|
|
33679
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 });
|
|
33680
33871
|
if (e instanceof VaultIntegrityError) return jsonError(res, "vault_integrity_failed", e.message);
|
|
33681
33872
|
if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
|
|
33682
33873
|
writeServiceLog2("error", "http.request_failed", {}, e);
|
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.
|
|
1
|
+
export declare const BRIDGE_VERSION = "0.26.0";
|