@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 +15 -0
- package/dist/attachments.d.ts +55 -0
- package/dist/bin.js +285 -11
- package/dist/bridge-agent.d.ts +33 -3
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +5 -4
- package/dist/index.js +285 -11
- package/dist/status-gate.d.ts +45 -0
- 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) {
|
|
@@ -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.
|
|
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
|
|
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.
|
|
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.
|
|
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) {
|
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
|
|
@@ -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;
|
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) {
|
|
@@ -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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
1
|
+
export declare const BRIDGE_VERSION = "0.26.0";
|