@securecoms/agent-bridge 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -363,6 +363,25 @@ The loopback hop carries plaintext by design — the agent is the endpoint and t
363
363
 
364
364
  ---
365
365
 
366
+ ## Attachments and any-language agents
367
+
368
+ The daemon is the universal substrate: an agent in any language (Python, Go,
369
+ TypeScript, ...) connects over the local loopback WS/HTTP and never speaks MLS
370
+ itself. The daemon performs all MLS encryption/decryption in TypeScript and hands
371
+ the agent **plaintext** — message text and, for attachments, a **local file path**.
372
+
373
+ - **Receiving an attachment:** the delivered message includes
374
+ `attachments: [{ filename, mime, size, path, sha256 }]`. Read the file at `path`
375
+ (saved under `<store-dir>/attachments/<channel>/`). No MLS, no key handling.
376
+ - **Sending an attachment:** `POST /v1/send-attachment` with
377
+ `{ "channel_id": "...", "path": "/local/file.png", "caption": "optional" }`.
378
+ The daemon reads, encrypts, uploads, and dispatches it.
379
+
380
+ This is why Python agents (which have no native MLS library) fully support
381
+ attachments: the cryptography lives in the daemon, not the agent.
382
+
383
+ ---
384
+
366
385
  ## Flags and environment
367
386
 
368
387
  | Flag | Env var | Default | Description |
package/dist/bin.js CHANGED
@@ -2439,6 +2439,11 @@ var SqliteMlsStateStore = class {
2439
2439
  }
2440
2440
  };
2441
2441
 
2442
+ // src/bridge-agent.ts
2443
+ import { decryptFile, encryptFile, computeFileDigest, base64ToBytes as base64ToBytes2, bytesToBase64 as bytesToBase642 } from "@agentvault/crypto";
2444
+ import { promises as fsp } from "node:fs";
2445
+ import { join as join2, dirname as dirname2 } from "node:path";
2446
+
2442
2447
  // src/latency.ts
2443
2448
  var DEFAULT_CAPACITY = 1024;
2444
2449
  function round(n) {
@@ -2586,6 +2591,74 @@ var TokenBucketFloor = class {
2586
2591
  }
2587
2592
  };
2588
2593
 
2594
+ // src/attachments.ts
2595
+ import { basename } from "node:path";
2596
+ function sanitizeFilename(name, fallback) {
2597
+ const base = basename(name ?? "");
2598
+ const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
2599
+ return cleaned.length > 0 ? cleaned : fallback;
2600
+ }
2601
+ var MIME_BY_EXT = {
2602
+ png: "image/png",
2603
+ jpg: "image/jpeg",
2604
+ jpeg: "image/jpeg",
2605
+ gif: "image/gif",
2606
+ webp: "image/webp",
2607
+ svg: "image/svg+xml",
2608
+ pdf: "application/pdf",
2609
+ txt: "text/plain",
2610
+ md: "text/markdown",
2611
+ json: "application/json",
2612
+ csv: "text/csv",
2613
+ html: "text/html",
2614
+ mp4: "video/mp4",
2615
+ mp3: "audio/mpeg",
2616
+ zip: "application/zip",
2617
+ wav: "audio/wav"
2618
+ };
2619
+ function mimeForName(name) {
2620
+ const dot = name.lastIndexOf(".");
2621
+ if (dot < 0 || dot === name.length - 1) return "application/octet-stream";
2622
+ return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
2623
+ }
2624
+ async function prepareAttachment(filePath, caption, channelId, deps) {
2625
+ const bytes = await deps.readFile(filePath);
2626
+ const name = basename(filePath);
2627
+ const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
2628
+ const blobId = await deps.uploadBlob(encryptedData, channelId);
2629
+ const body = {
2630
+ v: 1,
2631
+ type: "artifact_share",
2632
+ name,
2633
+ mime: mimeForName(name),
2634
+ size: bytes.length,
2635
+ blobId,
2636
+ fileKey: deps.bytesToBase64(fileKey),
2637
+ fileNonce: deps.bytesToBase64(fileNonce),
2638
+ digest
2639
+ };
2640
+ if (caption) body.text = caption;
2641
+ return body;
2642
+ }
2643
+ async function materializeAttachment(body, ctx, deps) {
2644
+ const ciphertext = await deps.downloadBlob(body.blobId);
2645
+ const sha2565 = deps.computeFileDigest(ciphertext);
2646
+ if (sha2565 !== body.digest) {
2647
+ throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
2648
+ }
2649
+ const plain = deps.decryptFile(
2650
+ ciphertext,
2651
+ deps.base64ToBytes(body.fileKey),
2652
+ deps.base64ToBytes(body.fileNonce)
2653
+ );
2654
+ const safeName = sanitizeFilename(body.name, ctx.messageId);
2655
+ const dir = `${ctx.attachmentsDir}/${ctx.channelId}`;
2656
+ const path = `${dir}/${ctx.messageId}-${safeName}`;
2657
+ await deps.mkdirp(dir);
2658
+ await deps.writeFile(path, plain);
2659
+ return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2565 };
2660
+ }
2661
+
2589
2662
  // src/bridge-agent.ts
2590
2663
  var DEFAULT_MAX_BUFFER = 1e3;
2591
2664
  var CHANNEL_META_TTL_MS = 5 * 6e4;
@@ -2606,6 +2679,14 @@ var RateLimitedError = class extends Error {
2606
2679
  this.name = "RateLimitedError";
2607
2680
  }
2608
2681
  };
2682
+ var InvalidPathError = class extends Error {
2683
+ constructor(filePath, cause) {
2684
+ super(`cannot read attachment file: ${filePath}`);
2685
+ this.filePath = filePath;
2686
+ this.name = "InvalidPathError";
2687
+ if (cause !== void 0) this.cause = cause;
2688
+ }
2689
+ };
2609
2690
  var BRIDGE_VERSION = "0.0.0";
2610
2691
  function hexToBytes2(h) {
2611
2692
  return new Uint8Array(Buffer.from(h, "hex"));
@@ -2644,6 +2725,25 @@ var BridgeAgent = class _BridgeAgent {
2644
2725
  capacity: cfg.rateLimitCapacity ?? 20,
2645
2726
  refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
2646
2727
  });
2728
+ const self = this;
2729
+ this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
2730
+ const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
2731
+ const res = await self.fetchImpl(url, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
2732
+ if (!res.ok) throw new Error(`download blob failed: ${res.status}`);
2733
+ return new Uint8Array(await res.arrayBuffer());
2734
+ };
2735
+ this.uploadBlobImpl = deps.uploadBlob ?? async function(ciphertext, channelId) {
2736
+ const url = `${cfg.apiBase}/api/v1/attachments?channel_id=${encodeURIComponent(channelId)}&caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
2737
+ const res = await self.fetchImpl(url, {
2738
+ method: "POST",
2739
+ headers: { "content-type": "application/octet-stream", authorization: `Bearer ${cfg.apiKey}` },
2740
+ body: ciphertext
2741
+ });
2742
+ if (!res.ok) throw new Error(`upload blob failed: ${res.status}`);
2743
+ const j = await res.json();
2744
+ return String(j.attachment_id);
2745
+ };
2746
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join2(dirname2(cfg.storePath), "attachments");
2647
2747
  }
2648
2748
  transport;
2649
2749
  provider;
@@ -2687,6 +2787,12 @@ var BridgeAgent = class _BridgeAgent {
2687
2787
  rateLimitedSends = 0;
2688
2788
  /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
2689
2789
  welcomeRequestsSent = 0;
2790
+ /**
2791
+ * Track 5: cumulative count of artifact_share messages where download/decrypt/digest
2792
+ * failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
2793
+ * counter is incremented. Surfaced on health()/metrics().
2794
+ */
2795
+ failedAttachments = 0;
2690
2796
  /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
2691
2797
  lastWelcomeRequest = /* @__PURE__ */ new Map();
2692
2798
  /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
@@ -2699,6 +2805,12 @@ var BridgeAgent = class _BridgeAgent {
2699
2805
  startedAt = 0;
2700
2806
  store;
2701
2807
  latency;
2808
+ /** Track 5: injectable blob download fn (default: fetch-based). */
2809
+ downloadBlobImpl;
2810
+ /** Track 5: injectable blob upload fn (default: fetch-based). */
2811
+ uploadBlobImpl;
2812
+ /** Track 5: directory where received attachments are saved. */
2813
+ attachmentsDirImpl;
2702
2814
  /**
2703
2815
  * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
2704
2816
  *
@@ -2819,6 +2931,59 @@ var BridgeAgent = class _BridgeAgent {
2819
2931
  this.latency.record("send.total", performance.now() - sendStart);
2820
2932
  return { message_id: id, status: "sent" };
2821
2933
  }
2934
+ sendDeps() {
2935
+ return {
2936
+ uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
2937
+ encryptFile,
2938
+ bytesToBase64: bytesToBase642,
2939
+ readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
2940
+ };
2941
+ }
2942
+ async sendAttachment(channelId, filePath, caption) {
2943
+ this.enforceRateFloor("attachment", channelId);
2944
+ const joined = await this.reconcileJoin(channelId);
2945
+ if (!joined) throw new ChannelPendingError(channelId);
2946
+ let body;
2947
+ try {
2948
+ body = await prepareAttachment(filePath, caption, channelId, this.sendDeps());
2949
+ } catch (e) {
2950
+ if (e.code === "ENOENT" || e.code === "EACCES" || e.code === "ENOTDIR" || e.code === "EISDIR") {
2951
+ throw new InvalidPathError(filePath, e);
2952
+ }
2953
+ throw e;
2954
+ }
2955
+ const id = crypto.randomUUID();
2956
+ const sendStart = performance.now();
2957
+ await sendWithSelfHeal(
2958
+ this.provider,
2959
+ channelId,
2960
+ (epoch) => this.latency.time(
2961
+ "send.encrypt",
2962
+ () => buildOutboundEnvelope({
2963
+ custody: this.custody,
2964
+ groupKey: this.provider,
2965
+ messageId: id,
2966
+ channelId,
2967
+ enclaveId: this.cfg.enclaveId,
2968
+ senderId: this.cfg.identityId,
2969
+ senderType: "agent",
2970
+ type: "artifact_share",
2971
+ bodyPlaintext: JSON.stringify(body),
2972
+ bindingId: "",
2973
+ bindingVersion: "0.0.0",
2974
+ agentSkillId: null,
2975
+ epoch,
2976
+ traceparent: ZERO_TRACEPARENT,
2977
+ references: [],
2978
+ stage5Outcome: "allow",
2979
+ stage5Evaluators: []
2980
+ })
2981
+ ),
2982
+ (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
2983
+ );
2984
+ this.latency.record("send.total", performance.now() - sendStart);
2985
+ return { message_id: id, status: "sent" };
2986
+ }
2822
2987
  async listChannels() {
2823
2988
  const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
2824
2989
  const res = await this.fetchImpl(url, {
@@ -2995,6 +3160,42 @@ var BridgeAgent = class _BridgeAgent {
2995
3160
  new TextDecoder().decode(m.plaintext)
2996
3161
  );
2997
3162
  if (parsed.type && parsed.type !== "text") {
3163
+ if (parsed.type === "artifact_share") {
3164
+ try {
3165
+ const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
3166
+ const senderType = m.envelope.sender_type;
3167
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
3168
+ const meta = await materializeAttachment(
3169
+ parsed,
3170
+ { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
3171
+ {
3172
+ downloadBlob: this.downloadBlobImpl,
3173
+ decryptFile,
3174
+ computeFileDigest,
3175
+ base64ToBytes: base64ToBytes2,
3176
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
3177
+ writeFile: (p, data) => fsp.writeFile(p, data)
3178
+ }
3179
+ );
3180
+ this.buffer.push({
3181
+ channel_id: m.channelId,
3182
+ message_id: m.messageId,
3183
+ sender_id: m.envelope.sender_id,
3184
+ text: typeof parsed.text === "string" ? parsed.text : "",
3185
+ ts: m.receivedAt,
3186
+ sender_name: senderName,
3187
+ sender_is_agent: senderIsAgent,
3188
+ attachments: [meta]
3189
+ });
3190
+ this.capBuffer();
3191
+ } catch (e) {
3192
+ this.failedAttachments++;
3193
+ process.stderr.write(
3194
+ `[bridge] attachment failed on ${m.messageId}: ${e.message}
3195
+ `
3196
+ );
3197
+ }
3198
+ }
2998
3199
  acked.push(m.queueId);
2999
3200
  continue;
3000
3201
  }
@@ -3119,6 +3320,7 @@ var BridgeAgent = class _BridgeAgent {
3119
3320
  filtered_room: this.filteredRoomMessages,
3120
3321
  rate_limited: this.rateLimitedSends,
3121
3322
  permanently_skipped: this.provider.permanentlySkippedCount(),
3323
+ failed_attachments: this.failedAttachments,
3122
3324
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
3123
3325
  version: BRIDGE_VERSION
3124
3326
  };
@@ -3159,7 +3361,8 @@ var BridgeAgent = class _BridgeAgent {
3159
3361
  filteredRoomMessages: this.filteredRoomMessages,
3160
3362
  rateLimitedSends: this.rateLimitedSends,
3161
3363
  permanentlySkipped: this.provider.permanentlySkippedCount(),
3162
- welcomeRequestsSent: this.welcomeRequestsSent
3364
+ welcomeRequestsSent: this.welcomeRequestsSent,
3365
+ failedAttachments: this.failedAttachments
3163
3366
  };
3164
3367
  }
3165
3368
  async close() {
@@ -3271,6 +3474,11 @@ var a2aSchema = z.object({
3271
3474
  kind: z.enum(["message", "task", "artifact"]),
3272
3475
  object: z.record(z.unknown())
3273
3476
  });
3477
+ var sendAttachmentSchema = z.object({
3478
+ channel_id: z.string().min(1),
3479
+ path: z.string().min(1),
3480
+ caption: z.string().optional()
3481
+ });
3274
3482
  var sendSchema = z.object({
3275
3483
  channel_id: z.string().min(1),
3276
3484
  text: z.string().optional(),
@@ -3365,6 +3573,12 @@ function createHttpServer(agent, opts) {
3365
3573
  const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, parsed.data.idempotency_key) : await agent.send(parsed.data.channel_id, parsed.data.text, parsed.data.idempotency_key);
3366
3574
  return json(res, 200, r);
3367
3575
  }
3576
+ if (method === "POST" && path === "/v1/send-attachment") {
3577
+ const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
3578
+ if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
3579
+ const r = await agent.sendAttachment(parsed.data.channel_id, parsed.data.path, parsed.data.caption);
3580
+ return json(res, 200, r);
3581
+ }
3368
3582
  if (method === "POST" && path === "/v1/status") {
3369
3583
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
3370
3584
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -3414,6 +3628,7 @@ function createHttpServer(agent, opts) {
3414
3628
  }
3415
3629
  if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
3416
3630
  if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
3631
+ if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
3417
3632
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
3418
3633
  process.stderr.write(`[bridge] request error: ${e?.message ?? e}
3419
3634
  `);
@@ -3628,21 +3843,21 @@ async function handleInbound(sock, agent, data, inflight) {
3628
3843
 
3629
3844
  // src/bin.ts
3630
3845
  import { readFileSync as readFileSync3, mkdirSync, writeFileSync as writeFileSync2, rmSync, existsSync as existsSync2, realpathSync } from "node:fs";
3631
- import { join as join3, dirname as dirname4 } from "node:path";
3846
+ import { join as join4, dirname as dirname5 } from "node:path";
3632
3847
  import { fileURLToPath } from "node:url";
3633
3848
  import { homedir, userInfo } from "node:os";
3634
3849
  import { spawnSync } from "node:child_process";
3635
3850
 
3636
3851
  // src/service/install.ts
3637
- import { dirname as dirname2 } from "node:path";
3852
+ import { dirname as dirname3 } from "node:path";
3638
3853
 
3639
3854
  // src/service/config.ts
3640
- import { basename, extname, isAbsolute, resolve } from "node:path";
3855
+ import { basename as basename2, extname, isAbsolute, resolve } from "node:path";
3641
3856
  function slugify(s) {
3642
3857
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
3643
3858
  }
3644
3859
  function deriveServiceConfig(input) {
3645
- const stem = input.label ?? basename(input.credentialPath, extname(input.credentialPath));
3860
+ const stem = input.label ?? basename2(input.credentialPath, extname(input.credentialPath));
3646
3861
  const slug = slugify(stem);
3647
3862
  const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
3648
3863
  const args2 = ["--credential", cred];
@@ -3718,7 +3933,7 @@ WantedBy=default.target
3718
3933
  var ServiceUnsupportedError = class extends Error {
3719
3934
  };
3720
3935
  function tokenHint(input) {
3721
- return `${dirname2(input.credentialPath)}/bridge-token`;
3936
+ return `${dirname3(input.credentialPath)}/bridge-token`;
3722
3937
  }
3723
3938
  function requireOk(r, what) {
3724
3939
  if (r.status !== 0) throw new Error(`${what} failed (exit ${r.status})${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
@@ -3726,8 +3941,8 @@ function requireOk(r, what) {
3726
3941
  function installService(input, deps) {
3727
3942
  const cfg = deriveServiceConfig(input);
3728
3943
  if (deps.platform === "darwin") {
3729
- deps.mkdirp(dirname2(cfg.plistPath));
3730
- deps.mkdirp(dirname2(cfg.logPath));
3944
+ deps.mkdirp(dirname3(cfg.plistPath));
3945
+ deps.mkdirp(dirname3(cfg.logPath));
3731
3946
  deps.writeFile(cfg.plistPath, renderLaunchdPlist(cfg));
3732
3947
  deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
3733
3948
  requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
@@ -3736,7 +3951,7 @@ function installService(input, deps) {
3736
3951
  return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, tokenHint: tokenHint(input) };
3737
3952
  }
3738
3953
  if (deps.platform === "linux") {
3739
- deps.mkdirp(dirname2(cfg.unitPath));
3954
+ deps.mkdirp(dirname3(cfg.unitPath));
3740
3955
  deps.writeFile(cfg.unitPath, renderSystemdUnit(cfg));
3741
3956
  deps.run("systemctl", ["--user", "daemon-reload"]);
3742
3957
  deps.run("loginctl", ["enable-linger", deps.user]);
@@ -3764,7 +3979,7 @@ function uninstallService(input, deps) {
3764
3979
  }
3765
3980
 
3766
3981
  // src/service/resolve-bin.ts
3767
- import { join as join2, dirname as dirname3 } from "node:path";
3982
+ import { join as join3, dirname as dirname4 } from "node:path";
3768
3983
  import { createRequire } from "node:module";
3769
3984
  var GlobalInstallPermissionError = class extends Error {
3770
3985
  constructor(message) {
@@ -3789,7 +4004,7 @@ function resolveStableBinScript(input, deps) {
3789
4004
  if (deps.fileExists(input.binScript)) return input.binScript;
3790
4005
  throw new Error(`bin path does not exist and is not an npx cache path: ${input.binScript}`);
3791
4006
  }
3792
- const pkgRoot = dirname3(dirname3(input.binScript));
4007
+ const pkgRoot = dirname4(dirname4(input.binScript));
3793
4008
  const inst = deps.run("npm", ["i", "-g", pkgRoot, "--install-links"]);
3794
4009
  if (inst.status !== 0) {
3795
4010
  if (/EACCES|permission denied/i.test(inst.stderr)) {
@@ -3802,7 +4017,7 @@ function resolveStableBinScript(input, deps) {
3802
4017
  }
3803
4018
  const root = deps.run("npm", ["root", "-g"]);
3804
4019
  if (root.status !== 0) throw new Error(`npm root -g failed (exit ${root.status})`);
3805
- const stable = join2(root.stdout.trim(), input.packageName, "dist", "bin.js");
4020
+ const stable = join3(root.stdout.trim(), input.packageName, "dist", "bin.js");
3806
4021
  if (!deps.fileExists(stable)) throw new Error(`global install did not produce ${stable}`);
3807
4022
  return stable;
3808
4023
  }
@@ -3921,7 +4136,7 @@ async function main() {
3921
4136
  `);
3922
4137
  process.exit(1);
3923
4138
  }
3924
- const tokenFile = daemon.tokenFile ?? join3(dirname4(credentialPath), "bridge-token");
4139
+ const tokenFile = daemon.tokenFile ?? join4(dirname5(credentialPath), "bridge-token");
3925
4140
  const token = resolveToken({ tokenFile, token: daemon.token });
3926
4141
  const agent = new BridgeAgent(cfg);
3927
4142
  await agent.start();
@@ -1,6 +1,7 @@
1
1
  import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
2
2
  import type { BridgeConfig } from "./config.js";
3
3
  import { LatencyRecorder } from "./latency.js";
4
+ import type { AttachmentMeta } from "./attachments.js";
4
5
  export interface BridgeMessage {
5
6
  channel_id: string;
6
7
  message_id: string;
@@ -20,6 +21,12 @@ export interface BridgeMessage {
20
21
  * @mention filter reads (not recomputed differently).
21
22
  */
22
23
  sender_is_agent?: boolean;
24
+ /**
25
+ * Track 5: materialized attachment metadata for `artifact_share` messages.
26
+ * Each entry corresponds to one downloaded+decrypted file saved to disk.
27
+ * Undefined for plain `text` messages.
28
+ */
29
+ attachments?: AttachmentMeta[];
23
30
  }
24
31
  export interface BridgeAgentDeps {
25
32
  transport?: any;
@@ -27,6 +34,24 @@ export interface BridgeAgentDeps {
27
34
  fetchImpl?: typeof fetch;
28
35
  /** Injectable so tests can assert recorded stages; defaults to a fresh one. */
29
36
  latency?: LatencyRecorder;
37
+ /**
38
+ * Track 5: injectable blob downloader. Defaults to the fetch-based impl that
39
+ * GETs `${apiBase}/api/v1/attachments/<blobId>?caller_identity_id=<identityId>`
40
+ * with Bearer auth. Override in tests to avoid real HTTP.
41
+ */
42
+ downloadBlob?: (blobId: string) => Promise<Uint8Array>;
43
+ /**
44
+ * Track 5: injectable blob uploader. Defaults to the fetch-based impl that
45
+ * POSTs ciphertext to `${apiBase}/api/v1/attachments?channel_id=<id>&caller_identity_id=<id>`
46
+ * with Bearer auth and returns the server-assigned blobId. Override in tests to avoid real HTTP.
47
+ */
48
+ uploadBlob?: (ciphertext: Uint8Array, channelId: string) => Promise<string>;
49
+ /**
50
+ * Track 5: override the directory where received attachments are saved.
51
+ * Defaults to `join(dirname(cfg.storePath), "attachments")`. Override in
52
+ * tests that use `:memory:` as the store path.
53
+ */
54
+ attachmentsDir?: string;
30
55
  /**
31
56
  * Phase 1 item 4 (ADR-0002): hard cap on the in-memory inbound buffer. A
32
57
  * consumer that never drains (agent offline, no /v1/events poller) must not
@@ -62,6 +87,10 @@ export declare class RateLimitedError extends Error {
62
87
  readonly retryAfterMs: number;
63
88
  constructor(channelId: string, retryAfterMs: number);
64
89
  }
90
+ export declare class InvalidPathError extends Error {
91
+ readonly filePath: string;
92
+ constructor(filePath: string, cause?: unknown);
93
+ }
65
94
  export declare const BRIDGE_VERSION = "0.0.0";
66
95
  export declare class BridgeAgent {
67
96
  private readonly cfg;
@@ -106,6 +135,12 @@ export declare class BridgeAgent {
106
135
  private rateLimitedSends;
107
136
  /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
108
137
  private welcomeRequestsSent;
138
+ /**
139
+ * Track 5: cumulative count of artifact_share messages where download/decrypt/digest
140
+ * failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
141
+ * counter is incremented. Surfaced on health()/metrics().
142
+ */
143
+ private failedAttachments;
109
144
  /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
110
145
  private readonly lastWelcomeRequest;
111
146
  /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
@@ -118,6 +153,12 @@ export declare class BridgeAgent {
118
153
  private startedAt;
119
154
  private readonly store;
120
155
  private readonly latency;
156
+ /** Track 5: injectable blob download fn (default: fetch-based). */
157
+ private readonly downloadBlobImpl;
158
+ /** Track 5: injectable blob upload fn (default: fetch-based). */
159
+ private readonly uploadBlobImpl;
160
+ /** Track 5: directory where received attachments are saved. */
161
+ private readonly attachmentsDirImpl;
121
162
  constructor(cfg: BridgeConfig, deps?: BridgeAgentDeps);
122
163
  /**
123
164
  * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
@@ -151,6 +192,11 @@ export declare class BridgeAgent {
151
192
  message_id: string;
152
193
  status: "sent";
153
194
  }>;
195
+ private sendDeps;
196
+ sendAttachment(channelId: string, filePath: string, caption?: string): Promise<{
197
+ message_id: string;
198
+ status: "sent";
199
+ }>;
154
200
  listChannels(): Promise<Array<{
155
201
  channel_id: string;
156
202
  name: string | null;
@@ -215,6 +261,8 @@ export declare class BridgeAgent {
215
261
  rate_limited: number;
216
262
  /** Cumulative inbound rows acked-and-dropped because they were provably past-epoch undecryptable (forward-secrecy secret gone). */
217
263
  permanently_skipped: number;
264
+ /** Track 5: cumulative artifact_share messages where download/decrypt/digest failed (fail-soft — acked, no redeliver). */
265
+ failed_attachments: number;
218
266
  uptime_s: number;
219
267
  version: string;
220
268
  };
@@ -254,6 +302,8 @@ export declare class BridgeAgent {
254
302
  rateLimitedSends: number;
255
303
  permanentlySkipped: number;
256
304
  welcomeRequestsSent: number;
305
+ /** Track 5: cumulative artifact_share failures (download/decrypt/digest). */
306
+ failedAttachments: number;
257
307
  };
258
308
  close(): Promise<void>;
259
309
  }
package/dist/index.js CHANGED
@@ -2367,6 +2367,11 @@ var SqliteMlsStateStore = class {
2367
2367
  }
2368
2368
  };
2369
2369
 
2370
+ // src/bridge-agent.ts
2371
+ import { decryptFile, encryptFile, computeFileDigest, base64ToBytes as base64ToBytes2, bytesToBase64 as bytesToBase642 } from "@agentvault/crypto";
2372
+ import { promises as fsp } from "node:fs";
2373
+ import { join, dirname } from "node:path";
2374
+
2370
2375
  // src/latency.ts
2371
2376
  var DEFAULT_CAPACITY = 1024;
2372
2377
  function round(n) {
@@ -2514,6 +2519,74 @@ var TokenBucketFloor = class {
2514
2519
  }
2515
2520
  };
2516
2521
 
2522
+ // src/attachments.ts
2523
+ import { basename } from "node:path";
2524
+ function sanitizeFilename(name, fallback) {
2525
+ const base = basename(name ?? "");
2526
+ const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
2527
+ return cleaned.length > 0 ? cleaned : fallback;
2528
+ }
2529
+ var MIME_BY_EXT = {
2530
+ png: "image/png",
2531
+ jpg: "image/jpeg",
2532
+ jpeg: "image/jpeg",
2533
+ gif: "image/gif",
2534
+ webp: "image/webp",
2535
+ svg: "image/svg+xml",
2536
+ pdf: "application/pdf",
2537
+ txt: "text/plain",
2538
+ md: "text/markdown",
2539
+ json: "application/json",
2540
+ csv: "text/csv",
2541
+ html: "text/html",
2542
+ mp4: "video/mp4",
2543
+ mp3: "audio/mpeg",
2544
+ zip: "application/zip",
2545
+ wav: "audio/wav"
2546
+ };
2547
+ function mimeForName(name) {
2548
+ const dot = name.lastIndexOf(".");
2549
+ if (dot < 0 || dot === name.length - 1) return "application/octet-stream";
2550
+ return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
2551
+ }
2552
+ async function prepareAttachment(filePath, caption, channelId, deps) {
2553
+ const bytes = await deps.readFile(filePath);
2554
+ const name = basename(filePath);
2555
+ const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
2556
+ const blobId = await deps.uploadBlob(encryptedData, channelId);
2557
+ const body = {
2558
+ v: 1,
2559
+ type: "artifact_share",
2560
+ name,
2561
+ mime: mimeForName(name),
2562
+ size: bytes.length,
2563
+ blobId,
2564
+ fileKey: deps.bytesToBase64(fileKey),
2565
+ fileNonce: deps.bytesToBase64(fileNonce),
2566
+ digest
2567
+ };
2568
+ if (caption) body.text = caption;
2569
+ return body;
2570
+ }
2571
+ async function materializeAttachment(body, ctx, deps) {
2572
+ const ciphertext = await deps.downloadBlob(body.blobId);
2573
+ const sha2565 = deps.computeFileDigest(ciphertext);
2574
+ if (sha2565 !== body.digest) {
2575
+ throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
2576
+ }
2577
+ const plain = deps.decryptFile(
2578
+ ciphertext,
2579
+ deps.base64ToBytes(body.fileKey),
2580
+ deps.base64ToBytes(body.fileNonce)
2581
+ );
2582
+ const safeName = sanitizeFilename(body.name, ctx.messageId);
2583
+ const dir = `${ctx.attachmentsDir}/${ctx.channelId}`;
2584
+ const path = `${dir}/${ctx.messageId}-${safeName}`;
2585
+ await deps.mkdirp(dir);
2586
+ await deps.writeFile(path, plain);
2587
+ return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2565 };
2588
+ }
2589
+
2517
2590
  // src/bridge-agent.ts
2518
2591
  var DEFAULT_MAX_BUFFER = 1e3;
2519
2592
  var CHANNEL_META_TTL_MS = 5 * 6e4;
@@ -2534,6 +2607,14 @@ var RateLimitedError = class extends Error {
2534
2607
  this.name = "RateLimitedError";
2535
2608
  }
2536
2609
  };
2610
+ var InvalidPathError = class extends Error {
2611
+ constructor(filePath, cause) {
2612
+ super(`cannot read attachment file: ${filePath}`);
2613
+ this.filePath = filePath;
2614
+ this.name = "InvalidPathError";
2615
+ if (cause !== void 0) this.cause = cause;
2616
+ }
2617
+ };
2537
2618
  var BRIDGE_VERSION = "0.0.0";
2538
2619
  function hexToBytes2(h) {
2539
2620
  return new Uint8Array(Buffer.from(h, "hex"));
@@ -2572,6 +2653,25 @@ var BridgeAgent = class _BridgeAgent {
2572
2653
  capacity: cfg.rateLimitCapacity ?? 20,
2573
2654
  refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
2574
2655
  });
2656
+ const self = this;
2657
+ this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
2658
+ const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
2659
+ const res = await self.fetchImpl(url, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
2660
+ if (!res.ok) throw new Error(`download blob failed: ${res.status}`);
2661
+ return new Uint8Array(await res.arrayBuffer());
2662
+ };
2663
+ this.uploadBlobImpl = deps.uploadBlob ?? async function(ciphertext, channelId) {
2664
+ const url = `${cfg.apiBase}/api/v1/attachments?channel_id=${encodeURIComponent(channelId)}&caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
2665
+ const res = await self.fetchImpl(url, {
2666
+ method: "POST",
2667
+ headers: { "content-type": "application/octet-stream", authorization: `Bearer ${cfg.apiKey}` },
2668
+ body: ciphertext
2669
+ });
2670
+ if (!res.ok) throw new Error(`upload blob failed: ${res.status}`);
2671
+ const j = await res.json();
2672
+ return String(j.attachment_id);
2673
+ };
2674
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join(dirname(cfg.storePath), "attachments");
2575
2675
  }
2576
2676
  transport;
2577
2677
  provider;
@@ -2615,6 +2715,12 @@ var BridgeAgent = class _BridgeAgent {
2615
2715
  rateLimitedSends = 0;
2616
2716
  /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
2617
2717
  welcomeRequestsSent = 0;
2718
+ /**
2719
+ * Track 5: cumulative count of artifact_share messages where download/decrypt/digest
2720
+ * failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
2721
+ * counter is incremented. Surfaced on health()/metrics().
2722
+ */
2723
+ failedAttachments = 0;
2618
2724
  /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
2619
2725
  lastWelcomeRequest = /* @__PURE__ */ new Map();
2620
2726
  /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
@@ -2627,6 +2733,12 @@ var BridgeAgent = class _BridgeAgent {
2627
2733
  startedAt = 0;
2628
2734
  store;
2629
2735
  latency;
2736
+ /** Track 5: injectable blob download fn (default: fetch-based). */
2737
+ downloadBlobImpl;
2738
+ /** Track 5: injectable blob upload fn (default: fetch-based). */
2739
+ uploadBlobImpl;
2740
+ /** Track 5: directory where received attachments are saved. */
2741
+ attachmentsDirImpl;
2630
2742
  /**
2631
2743
  * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
2632
2744
  *
@@ -2747,6 +2859,59 @@ var BridgeAgent = class _BridgeAgent {
2747
2859
  this.latency.record("send.total", performance.now() - sendStart);
2748
2860
  return { message_id: id, status: "sent" };
2749
2861
  }
2862
+ sendDeps() {
2863
+ return {
2864
+ uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
2865
+ encryptFile,
2866
+ bytesToBase64: bytesToBase642,
2867
+ readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
2868
+ };
2869
+ }
2870
+ async sendAttachment(channelId, filePath, caption) {
2871
+ this.enforceRateFloor("attachment", channelId);
2872
+ const joined = await this.reconcileJoin(channelId);
2873
+ if (!joined) throw new ChannelPendingError(channelId);
2874
+ let body;
2875
+ try {
2876
+ body = await prepareAttachment(filePath, caption, channelId, this.sendDeps());
2877
+ } catch (e) {
2878
+ if (e.code === "ENOENT" || e.code === "EACCES" || e.code === "ENOTDIR" || e.code === "EISDIR") {
2879
+ throw new InvalidPathError(filePath, e);
2880
+ }
2881
+ throw e;
2882
+ }
2883
+ const id = crypto.randomUUID();
2884
+ const sendStart = performance.now();
2885
+ await sendWithSelfHeal(
2886
+ this.provider,
2887
+ channelId,
2888
+ (epoch) => this.latency.time(
2889
+ "send.encrypt",
2890
+ () => buildOutboundEnvelope({
2891
+ custody: this.custody,
2892
+ groupKey: this.provider,
2893
+ messageId: id,
2894
+ channelId,
2895
+ enclaveId: this.cfg.enclaveId,
2896
+ senderId: this.cfg.identityId,
2897
+ senderType: "agent",
2898
+ type: "artifact_share",
2899
+ bodyPlaintext: JSON.stringify(body),
2900
+ bindingId: "",
2901
+ bindingVersion: "0.0.0",
2902
+ agentSkillId: null,
2903
+ epoch,
2904
+ traceparent: ZERO_TRACEPARENT,
2905
+ references: [],
2906
+ stage5Outcome: "allow",
2907
+ stage5Evaluators: []
2908
+ })
2909
+ ),
2910
+ (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
2911
+ );
2912
+ this.latency.record("send.total", performance.now() - sendStart);
2913
+ return { message_id: id, status: "sent" };
2914
+ }
2750
2915
  async listChannels() {
2751
2916
  const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
2752
2917
  const res = await this.fetchImpl(url, {
@@ -2923,6 +3088,42 @@ var BridgeAgent = class _BridgeAgent {
2923
3088
  new TextDecoder().decode(m.plaintext)
2924
3089
  );
2925
3090
  if (parsed.type && parsed.type !== "text") {
3091
+ if (parsed.type === "artifact_share") {
3092
+ try {
3093
+ const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
3094
+ const senderType = m.envelope.sender_type;
3095
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
3096
+ const meta = await materializeAttachment(
3097
+ parsed,
3098
+ { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
3099
+ {
3100
+ downloadBlob: this.downloadBlobImpl,
3101
+ decryptFile,
3102
+ computeFileDigest,
3103
+ base64ToBytes: base64ToBytes2,
3104
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
3105
+ writeFile: (p, data) => fsp.writeFile(p, data)
3106
+ }
3107
+ );
3108
+ this.buffer.push({
3109
+ channel_id: m.channelId,
3110
+ message_id: m.messageId,
3111
+ sender_id: m.envelope.sender_id,
3112
+ text: typeof parsed.text === "string" ? parsed.text : "",
3113
+ ts: m.receivedAt,
3114
+ sender_name: senderName,
3115
+ sender_is_agent: senderIsAgent,
3116
+ attachments: [meta]
3117
+ });
3118
+ this.capBuffer();
3119
+ } catch (e) {
3120
+ this.failedAttachments++;
3121
+ process.stderr.write(
3122
+ `[bridge] attachment failed on ${m.messageId}: ${e.message}
3123
+ `
3124
+ );
3125
+ }
3126
+ }
2926
3127
  acked.push(m.queueId);
2927
3128
  continue;
2928
3129
  }
@@ -3047,6 +3248,7 @@ var BridgeAgent = class _BridgeAgent {
3047
3248
  filtered_room: this.filteredRoomMessages,
3048
3249
  rate_limited: this.rateLimitedSends,
3049
3250
  permanently_skipped: this.provider.permanentlySkippedCount(),
3251
+ failed_attachments: this.failedAttachments,
3050
3252
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
3051
3253
  version: BRIDGE_VERSION
3052
3254
  };
@@ -3087,7 +3289,8 @@ var BridgeAgent = class _BridgeAgent {
3087
3289
  filteredRoomMessages: this.filteredRoomMessages,
3088
3290
  rateLimitedSends: this.rateLimitedSends,
3089
3291
  permanentlySkipped: this.provider.permanentlySkippedCount(),
3090
- welcomeRequestsSent: this.welcomeRequestsSent
3292
+ welcomeRequestsSent: this.welcomeRequestsSent,
3293
+ failedAttachments: this.failedAttachments
3091
3294
  };
3092
3295
  }
3093
3296
  async close() {
@@ -3099,7 +3302,7 @@ var BridgeAgent = class _BridgeAgent {
3099
3302
 
3100
3303
  // src/config.ts
3101
3304
  import { readFileSync, statSync } from "node:fs";
3102
- import { dirname, join } from "node:path";
3305
+ import { dirname as dirname2, join as join2 } from "node:path";
3103
3306
  var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
3104
3307
  function loadConfig(opts) {
3105
3308
  const raw = readFileSync(opts.credentialPath, "utf8");
@@ -3116,7 +3319,7 @@ function loadConfig(opts) {
3116
3319
  throw new Error("credential file is not valid JSON");
3117
3320
  }
3118
3321
  for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
3119
- const storePath = opts.storePath ?? join(dirname(opts.credentialPath), "bridge-state.db");
3322
+ const storePath = opts.storePath ?? join2(dirname2(opts.credentialPath), "bridge-state.db");
3120
3323
  const agentName = typeof json2.agent_name === "string" && json2.agent_name ? json2.agent_name : typeof json2.display_name === "string" && json2.display_name ? json2.display_name : void 0;
3121
3324
  const rateLimitCapacity = typeof json2.rate_limit_capacity === "number" ? json2.rate_limit_capacity : 20;
3122
3325
  const rateLimitRefillPerMinute = typeof json2.rate_limit_refill_per_minute === "number" ? json2.rate_limit_refill_per_minute : 20;
@@ -3269,6 +3472,11 @@ var a2aSchema = z.object({
3269
3472
  kind: z.enum(["message", "task", "artifact"]),
3270
3473
  object: z.record(z.unknown())
3271
3474
  });
3475
+ var sendAttachmentSchema = z.object({
3476
+ channel_id: z.string().min(1),
3477
+ path: z.string().min(1),
3478
+ caption: z.string().optional()
3479
+ });
3272
3480
  var sendSchema = z.object({
3273
3481
  channel_id: z.string().min(1),
3274
3482
  text: z.string().optional(),
@@ -3363,6 +3571,12 @@ function createHttpServer(agent, opts) {
3363
3571
  const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, parsed.data.idempotency_key) : await agent.send(parsed.data.channel_id, parsed.data.text, parsed.data.idempotency_key);
3364
3572
  return json(res, 200, r);
3365
3573
  }
3574
+ if (method === "POST" && path === "/v1/send-attachment") {
3575
+ const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
3576
+ if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
3577
+ const r = await agent.sendAttachment(parsed.data.channel_id, parsed.data.path, parsed.data.caption);
3578
+ return json(res, 200, r);
3579
+ }
3366
3580
  if (method === "POST" && path === "/v1/status") {
3367
3581
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
3368
3582
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -3412,6 +3626,7 @@ function createHttpServer(agent, opts) {
3412
3626
  }
3413
3627
  if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
3414
3628
  if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
3629
+ if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
3415
3630
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
3416
3631
  process.stderr.write(`[bridge] request error: ${e?.message ?? e}
3417
3632
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",