@securecoms/agent-bridge 0.11.0 → 0.11.2

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.
@@ -0,0 +1,87 @@
1
+ // latency.ts — lightweight per-stage latency recorder for the agent-bridge.
2
+ //
3
+ // Captures the SecureComs MESSAGE-ENVELOPE cost (MLS encrypt, relay publish,
4
+ // decrypt, receive cycle) so we can report real p50/p95/p99 SEPARATE from the
5
+ // agent's LLM time. Deliberately dependency-free and bounded: each stage keeps
6
+ // a fixed-size ring buffer of recent samples and percentiles are computed on
7
+ // demand from a sorted copy. Off the hot path beyond a single array write.
8
+ //
9
+ // This is NOT a metrics-pipeline replacement; it is a self-contained, loopback
10
+ // -exposed snapshot (`/v1/metrics`) for profiling + load tests. Per-stage
11
+ // timings are monotonic (performance.now) and local — true cross-process
12
+ // end-to-end (sender-publish → recipient-decrypt) needs clock-synced
13
+ // correlation by message id and is a follow-up.
14
+ const DEFAULT_CAPACITY = 1024;
15
+ function round(n) {
16
+ // Two-decimal ms is plenty; avoids float noise in the JSON snapshot.
17
+ return Math.round(n * 100) / 100;
18
+ }
19
+ /** Nearest-rank percentile (1-indexed ceil) over an ascending-sorted array. */
20
+ function percentile(sorted, p) {
21
+ if (sorted.length === 0)
22
+ return 0;
23
+ const rank = Math.ceil((p / 100) * sorted.length);
24
+ const idx = Math.min(Math.max(rank, 1), sorted.length) - 1;
25
+ return sorted[idx];
26
+ }
27
+ export class LatencyRecorder {
28
+ capacity;
29
+ // Per-stage ring buffer of recent samples (ms).
30
+ buffers = new Map();
31
+ // Write cursor per stage (for the ring buffer wrap).
32
+ cursors = new Map();
33
+ constructor(opts = {}) {
34
+ this.capacity = opts.capacity ?? DEFAULT_CAPACITY;
35
+ }
36
+ /** Record a single sample (ms) for a stage. O(1). */
37
+ record(stage, ms) {
38
+ let buf = this.buffers.get(stage);
39
+ if (!buf) {
40
+ buf = [];
41
+ this.buffers.set(stage, buf);
42
+ this.cursors.set(stage, 0);
43
+ }
44
+ if (buf.length < this.capacity) {
45
+ buf.push(ms);
46
+ }
47
+ else {
48
+ // Ring-buffer overwrite of the oldest slot.
49
+ const cur = this.cursors.get(stage) ?? 0;
50
+ buf[cur] = ms;
51
+ this.cursors.set(stage, (cur + 1) % this.capacity);
52
+ }
53
+ }
54
+ /**
55
+ * Time an async fn, record its elapsed wall time under `stage`, return its
56
+ * value. Records even when the fn throws (the failure still consumed time),
57
+ * then re-throws — so a thrown error never loses the timing sample.
58
+ */
59
+ async time(stage, fn) {
60
+ const start = performance.now();
61
+ try {
62
+ return await fn();
63
+ }
64
+ finally {
65
+ this.record(stage, performance.now() - start);
66
+ }
67
+ }
68
+ /** Percentile snapshot per stage. Rounded to 2 decimals (ms). */
69
+ snapshot() {
70
+ const out = {};
71
+ for (const [stage, buf] of this.buffers) {
72
+ if (buf.length === 0)
73
+ continue;
74
+ const sorted = [...buf].sort((a, b) => a - b);
75
+ const sum = sorted.reduce((acc, n) => acc + n, 0);
76
+ out[stage] = {
77
+ count: sorted.length,
78
+ p50: round(percentile(sorted, 50)),
79
+ p95: round(percentile(sorted, 95)),
80
+ p99: round(percentile(sorted, 99)),
81
+ max: round(sorted[sorted.length - 1]),
82
+ mean: round(sum / sorted.length),
83
+ };
84
+ }
85
+ return out;
86
+ }
87
+ }
@@ -0,0 +1,56 @@
1
+ // Per-channel token-bucket safety floor for the SecureComs agent bridge.
2
+ //
3
+ // A lazy-refill token bucket: tokens regenerate based on elapsed wall-clock time
4
+ // at each read, so there is no timer/interval. The clock is injectable for
5
+ // deterministic tests. State is per-key; an unseen key starts full.
6
+ //
7
+ // `capacity <= 0` disables the floor entirely (tryConsume always allows),
8
+ // mirroring how `maxBuffer <= 0` disables the bridge's buffer cap.
9
+ export class TokenBucketFloor {
10
+ capacity;
11
+ refillPerMs;
12
+ now;
13
+ buckets = new Map();
14
+ constructor(opts) {
15
+ this.capacity = opts.capacity;
16
+ this.refillPerMs = opts.refillPerMinute / 60_000;
17
+ this.now = opts.now ?? Date.now;
18
+ }
19
+ refill(key) {
20
+ const t = this.now();
21
+ let b = this.buckets.get(key);
22
+ if (!b) {
23
+ b = { tokens: this.capacity, lastRefill: t };
24
+ this.buckets.set(key, b);
25
+ return b;
26
+ }
27
+ const elapsed = t - b.lastRefill;
28
+ if (elapsed > 0) {
29
+ b.tokens = Math.min(this.capacity, b.tokens + elapsed * this.refillPerMs);
30
+ b.lastRefill = t;
31
+ }
32
+ return b;
33
+ }
34
+ /** Consume one token. Returns true if allowed, false if the bucket is empty. */
35
+ tryConsume(key) {
36
+ if (this.capacity <= 0)
37
+ return true; // disabled
38
+ const b = this.refill(key);
39
+ if (b.tokens >= 1) {
40
+ b.tokens -= 1;
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ /** Milliseconds until one token is available (0 if already available). */
46
+ msUntilNextToken(key) {
47
+ if (this.capacity <= 0)
48
+ return 0;
49
+ const b = this.refill(key);
50
+ if (b.tokens >= 1)
51
+ return 0;
52
+ if (this.refillPerMs <= 0)
53
+ return Infinity;
54
+ return Math.ceil((1 - b.tokens) / this.refillPerMs);
55
+ }
56
+ }
@@ -0,0 +1,26 @@
1
+ import { basename, extname, isAbsolute, resolve } from "node:path";
2
+ function slugify(s) {
3
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
4
+ }
5
+ export function deriveServiceConfig(input) {
6
+ const stem = input.label ?? basename(input.credentialPath, extname(input.credentialPath));
7
+ const slug = slugify(stem);
8
+ const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
9
+ const args = ["--credential", cred];
10
+ if (input.storePath)
11
+ args.push("--store", isAbsolute(input.storePath) ? input.storePath : resolve(input.storePath));
12
+ if (input.port != null)
13
+ args.push("--port", String(input.port));
14
+ return {
15
+ slug,
16
+ label: `com.securecoms.bridge.${slug}`,
17
+ unitName: `securecoms-bridge-${slug}`,
18
+ args,
19
+ execPath: input.execPath,
20
+ binScript: input.binScript,
21
+ uid: input.uid,
22
+ plistPath: `${input.home}/Library/LaunchAgents/com.securecoms.bridge.${slug}.plist`,
23
+ unitPath: `${input.home}/.config/systemd/user/securecoms-bridge-${slug}.service`,
24
+ logPath: `${input.home}/Library/Logs/securecoms-bridge-${slug}.log`,
25
+ };
26
+ }
@@ -0,0 +1,60 @@
1
+ import { dirname } from "node:path";
2
+ import { deriveServiceConfig } from "./config.js";
3
+ import { renderLaunchdPlist } from "./launchd.js";
4
+ import { renderSystemdUnit } from "./systemd.js";
5
+ export class ServiceUnsupportedError extends Error {
6
+ }
7
+ function tokenHint(input) {
8
+ return `${dirname(input.credentialPath)}/bridge-token`;
9
+ }
10
+ function requireOk(r, what) {
11
+ if (r.status !== 0)
12
+ throw new Error(`${what} failed (exit ${r.status})${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
13
+ }
14
+ export function installService(input, deps) {
15
+ const cfg = deriveServiceConfig(input);
16
+ if (deps.platform === "darwin") {
17
+ deps.mkdirp(dirname(cfg.plistPath));
18
+ deps.mkdirp(dirname(cfg.logPath));
19
+ deps.writeFile(cfg.plistPath, renderLaunchdPlist(cfg));
20
+ // Reload-safe: boot out any prior instance (ignore failure), then bootstrap + enable.
21
+ deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
22
+ // bootstrap actually loads the service — its failure is the real install failure,
23
+ // so surface it (don't let a broken install look silently successful).
24
+ requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
25
+ deps.run("launchctl", ["enable", `gui/${cfg.uid}/${cfg.label}`]);
26
+ // Start (or restart) the service now so install doesn't wait for the next login.
27
+ // Mirrors the linux `enable --now`; kickstart -k re-launches if an old instance lingers.
28
+ requireOk(deps.run("launchctl", ["kickstart", "-k", `gui/${cfg.uid}/${cfg.label}`]), "launchctl kickstart");
29
+ return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, tokenHint: tokenHint(input) };
30
+ }
31
+ if (deps.platform === "linux") {
32
+ deps.mkdirp(dirname(cfg.unitPath));
33
+ deps.writeFile(cfg.unitPath, renderSystemdUnit(cfg));
34
+ deps.run("systemctl", ["--user", "daemon-reload"]);
35
+ deps.run("loginctl", ["enable-linger", deps.user]);
36
+ // enable --now is the load-bearing command (enable at login + start now); surface failure.
37
+ requireOk(deps.run("systemctl", ["--user", "enable", "--now", cfg.unitName]), "systemctl --user enable --now");
38
+ return { label: cfg.unitName, filePath: cfg.unitPath, logHint: `journalctl --user -u ${cfg.unitName} -f`, tokenHint: tokenHint(input) };
39
+ }
40
+ throw new ServiceUnsupportedError(`Service install isn't supported on ${deps.platform} yet. Run the agent in a terminal, or use a process manager.`);
41
+ }
42
+ export function uninstallService(input, deps) {
43
+ const cfg = deriveServiceConfig(input);
44
+ if (deps.platform === "darwin") {
45
+ const existed = deps.fileExists(cfg.plistPath);
46
+ deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
47
+ if (existed)
48
+ deps.removeFile(cfg.plistPath);
49
+ return { removed: existed, filePath: cfg.plistPath };
50
+ }
51
+ if (deps.platform === "linux") {
52
+ const existed = deps.fileExists(cfg.unitPath);
53
+ deps.run("systemctl", ["--user", "disable", "--now", cfg.unitName]);
54
+ if (existed)
55
+ deps.removeFile(cfg.unitPath);
56
+ deps.run("systemctl", ["--user", "daemon-reload"]);
57
+ return { removed: existed, filePath: cfg.unitPath };
58
+ }
59
+ throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
60
+ }
@@ -0,0 +1,30 @@
1
+ /** A deterministic launchd LaunchAgent plist. ProgramArguments re-invoke the
2
+ * running bin in run mode; KeepAlive restarts on crash, RunAtLoad starts at login. */
3
+ export function renderLaunchdPlist(cfg) {
4
+ const argv = [cfg.execPath, cfg.binScript, ...cfg.args];
5
+ const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
6
+ return `<?xml version="1.0" encoding="UTF-8"?>
7
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
8
+ <plist version="1.0">
9
+ <dict>
10
+ <key>Label</key>
11
+ <string>${cfg.label}</string>
12
+ <key>ProgramArguments</key>
13
+ <array>
14
+ ${programArgs}
15
+ </array>
16
+ <key>RunAtLoad</key>
17
+ <true/>
18
+ <key>KeepAlive</key>
19
+ <true/>
20
+ <key>StandardOutPath</key>
21
+ <string>${escapeXml(cfg.logPath)}</string>
22
+ <key>StandardErrorPath</key>
23
+ <string>${escapeXml(cfg.logPath)}</string>
24
+ </dict>
25
+ </plist>
26
+ `;
27
+ }
28
+ function escapeXml(s) {
29
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
30
+ }
@@ -0,0 +1,25 @@
1
+ /** Quotes a single systemd ExecStart token if it contains whitespace or a
2
+ * double-quote character. Tokens that are already safe pass through unchanged
3
+ * so the common no-space case stays readable in the unit file. */
4
+ function sdQuote(s) {
5
+ return s.includes(" ") || s.includes('"') ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : s;
6
+ }
7
+ /** A systemd --user unit. ExecStart re-invokes the running bin in run mode;
8
+ * Restart=always supervises crashes. WantedBy=default.target enables at login. */
9
+ export function renderSystemdUnit(cfg) {
10
+ const execStart = [cfg.execPath, cfg.binScript, ...cfg.args].map(sdQuote).join(" ");
11
+ return `[Unit]
12
+ Description=SecureComs agent bridge (${cfg.slug})
13
+ After=network-online.target
14
+ Wants=network-online.target
15
+
16
+ [Service]
17
+ Type=simple
18
+ ExecStart=${execStart}
19
+ Restart=always
20
+ RestartSec=5
21
+
22
+ [Install]
23
+ WantedBy=default.target
24
+ `;
25
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Warm libsodium once per process, before any file-crypto call.
3
+ *
4
+ * ⛔⛔ THIS IS A DELEGATE, AND THE DELEGATION IS THE POINT. #331 moved the
5
+ * warm-up body and its memo to `packages/connect-sdk-ts/src/sodium-ready.ts`.
6
+ * A process that holds TWO independent readiness memos warms libsodium twice,
7
+ * and this bridge process holds one instance of the SDK module already. Keeping
8
+ * a second memo here would have made the two barriers count separately.
9
+ * ⛔ DO NOT restore a local memo, a local `generateEphemeralKeypair()` call, or
10
+ * a local `warming` variable. `src/sodium-ready.delegation.gate.test.ts` (G-E)
11
+ * counts the underlying warm-ups across BOTH barriers and reads 2 the moment
12
+ * one comes back.
13
+ *
14
+ * ⛔ THE NAME AND THE EXPORT STAY. `bridge-agent.ts:64` imports this symbol and
15
+ * `index.ts:7` re-exports it. Callers keep calling `ensureSodiumReady()` from
16
+ * this package; only where the body lives changed.
17
+ *
18
+ * The four findings this file carried before #331, and where each one now
19
+ * lives. None is deleted, because each still binds on something.
20
+ *
21
+ * ⛔ 1. THE DISCARDED KEYPAIR IS THE READINESS GATE, NOT DEAD CODE. ⇒ ITS
22
+ * SUBJECT MOVED. The `generateEphemeralKeypair()` await now sits in the SDK
23
+ * module named above, and the finding travels with it. It still says what it
24
+ * always said: `@agentvault/crypto` has no readiness contract on its entire
25
+ * synchronous surface, sixteen exported synchronous functions throw when
26
+ * libsodium's WASM heap is not yet initialised (`<member> is not a function`
27
+ * or `Cannot read properties of undefined (reading '_malloc')`), and
28
+ * `generateEphemeralKeypair` is the ASYNC export that awaits `sodium.ready`
29
+ * internally on the SAME module instance those synchronous exports use. ⛔ Do
30
+ * not "simplify" the delegate by inlining a keypair mint back here: that is a
31
+ * second warm-up, not a shortcut.
32
+ *
33
+ * ⛔ 2. DO NOT IMPORT `libsodium-wrappers-sumo` DIRECTLY HERE. ⇒ THIS ONE STILL
34
+ * BINDS ON THIS FILE, UNCHANGED. `esbuild.config.mjs:15` inlines every
35
+ * `@agentvault/connect-*` workspace package into the bridge bundle and that
36
+ * specifier is absent from the EXTERNAL list at `:3-8`, so esbuild would bundle
37
+ * a SECOND libsodium instance out of this file. The await would then warm a
38
+ * heap that nothing reads, and this fix would look correct and do nothing.
39
+ * ⚠️ The same inlining is why the delegate below is not a no-op in the shipped
40
+ * bundle: the SDK barrier is inlined into `dist/bin.js` exactly once, and this
41
+ * delegate points at that one copy.
42
+ *
43
+ * ⛔ 3. MEMOIZE THE PROMISE, NOT A BOOLEAN. ⇒ ITS SUBJECT MOVED. The memo is the
44
+ * SDK module's `warming`. Measured: under `if (warmed) return; await ...;
45
+ * warmed = true;` two CONCURRENT callers both run the body. The bridge has two
46
+ * independent entry points (send and receive), so that concurrency is real
47
+ * here, which is why the finding was written here first. It is pinned now by
48
+ * `packages/connect-sdk-ts/src/sodium-ready.memo.gate.test.ts` (G-C1) and by
49
+ * `src/sodium-ready.retry.test.ts:52-69` in this package, which reaches the SDK
50
+ * memo THROUGH this delegate.
51
+ *
52
+ * ⛔⛔ 4. THE `catch` THAT CLEARS THE MEMO IS NOT REDUNDANT. DO NOT DELETE IT.
53
+ * ⇒ ITS SUBJECT MOVED, AND THE CONSEQUENCE STILL LANDS IN THIS PACKAGE. A bare
54
+ * `warming ??= ...` in the SDK module would hold a REJECTED promise for the
55
+ * life of the process if the FIRST warm-up rejects (WASM instantiation failure,
56
+ * OOM, a corrupt binary), and every later prepareAttachment AND
57
+ * materializeAttachment IN THIS PACKAGE would then reject at its first
58
+ * statement, forever. ⛔ ON THE RECEIVE SIDE THAT IS SILENT: the ingest pump's
59
+ * fail-soft catch acks the message, increments the failed-attachment counter
60
+ * and logs, so one transient failure becomes a PERMANENT, PROCESS-WIDE
61
+ * attachment outage that never surfaces as a crash. The SDK module clears the
62
+ * memo on rejection and RETHROWS, so the first caller still sees the real
63
+ * failure. Pinned by `src/sodium-ready.retry.test.ts`, both halves.
64
+ */
65
+ export declare function ensureSodiumReady(): Promise<void>;
@@ -0,0 +1,96 @@
1
+ /** Vault-crypto integrity failures: digest mismatch, DEK-wrap AEAD failure
2
+ * (bad/dropped nonce, wrong KEK, spliced ciphertext), or "no wrap for any
3
+ * generation we hold". Distinguished from generic REST/plumbing errors so
4
+ * the HTTP boundary can map it to a dedicated error code (errors.ts
5
+ * `vault_integrity_failed`) instead of a bare 500. */
6
+ export declare class VaultIntegrityError extends Error {
7
+ constructor(message: string, cause?: unknown);
8
+ }
9
+ export interface VaultWriteDeps {
10
+ exporterKekForGeneration: (gen: number) => Uint8Array;
11
+ currentGeneration: number;
12
+ createNode: (body: any) => Promise<{
13
+ node_id: string;
14
+ presign_post: {
15
+ url: string;
16
+ fields: Record<string, string>;
17
+ } | null;
18
+ }>;
19
+ putBlob: (presign: {
20
+ url: string;
21
+ fields: Record<string, string>;
22
+ }, ciphertext: Uint8Array) => Promise<void>;
23
+ commitNode: (nodeId: string) => Promise<{
24
+ status: string;
25
+ }>;
26
+ }
27
+ export declare function vaultWriteImpl(args: {
28
+ enclaveId: string;
29
+ nodeId: string;
30
+ name: string;
31
+ bytes: Uint8Array;
32
+ deps: VaultWriteDeps;
33
+ }): Promise<{
34
+ node_id: string;
35
+ }>;
36
+ export interface VaultReadDeps {
37
+ exporterKekForGeneration: (gen: number) => Uint8Array;
38
+ getNodeForDownload: (nodeId: string) => Promise<{
39
+ download_url: string;
40
+ wraps: {
41
+ kek_generation: number;
42
+ wrapped_dek_b64: string;
43
+ }[];
44
+ content_hash: string | null;
45
+ }>;
46
+ getBlob: (url: string) => Promise<Uint8Array>;
47
+ }
48
+ export declare function vaultReadImpl(args: {
49
+ enclaveId: string;
50
+ nodeId: string;
51
+ deps: VaultReadDeps;
52
+ }): Promise<Uint8Array>;
53
+ export interface VaultListRawNode {
54
+ node_id: string;
55
+ kind: "folder" | "file";
56
+ parent_id: string | null;
57
+ enc_name_b64: string;
58
+ name_kek_generation: number;
59
+ ciphertext_len: number | null;
60
+ content_hash: string | null;
61
+ }
62
+ export interface VaultListNode {
63
+ node_id: string;
64
+ kind: "folder" | "file";
65
+ parent_id: string | null;
66
+ name: string;
67
+ size_bytes: number | null;
68
+ content_hash: string | null;
69
+ }
70
+ export interface VaultListDeps {
71
+ exporterKekForGeneration: (gen: number) => Uint8Array;
72
+ listNodes: (parentId: string | null) => Promise<{
73
+ nodes: VaultListRawNode[];
74
+ }>;
75
+ }
76
+ export declare function vaultListImpl(args: {
77
+ enclaveId: string;
78
+ parentId: string | null;
79
+ deps: VaultListDeps;
80
+ }): Promise<VaultListNode[]>;
81
+ export interface VaultDeleteDeps {
82
+ deleteNode: (nodeId: string) => Promise<{
83
+ deleted: string;
84
+ bytes_freed: number;
85
+ }>;
86
+ }
87
+ /** No crypto on delete (the server drops an opaque row) — kept as a thin
88
+ * pass-through so BridgeAgent.vaultDelete has the same deps-injection shape
89
+ * as write/read/list for testability and symmetry. */
90
+ export declare function vaultDeleteImpl(args: {
91
+ nodeId: string;
92
+ deps: VaultDeleteDeps;
93
+ }): Promise<{
94
+ deleted: string;
95
+ bytes_freed: number;
96
+ }>;