@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 +19 -0
- package/dist/bin.js +228 -13
- package/dist/bridge-agent.d.ts +50 -0
- package/dist/index.js +218 -3
- package/package.json +1 -1
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
|
|
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
|
|
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 ??
|
|
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 `${
|
|
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(
|
|
3730
|
-
deps.mkdirp(
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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 ??
|
|
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();
|
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -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 ??
|
|
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
|
`);
|