@securecoms/agent-bridge 0.1.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.
@@ -0,0 +1,26 @@
1
+ export interface StageStats {
2
+ count: number;
3
+ p50: number;
4
+ p95: number;
5
+ p99: number;
6
+ max: number;
7
+ mean: number;
8
+ }
9
+ export declare class LatencyRecorder {
10
+ private readonly capacity;
11
+ private readonly buffers;
12
+ private readonly cursors;
13
+ constructor(opts?: {
14
+ capacity?: number;
15
+ });
16
+ /** Record a single sample (ms) for a stage. O(1). */
17
+ record(stage: string, ms: number): void;
18
+ /**
19
+ * Time an async fn, record its elapsed wall time under `stage`, return its
20
+ * value. Records even when the fn throws (the failure still consumed time),
21
+ * then re-throws — so a thrown error never loses the timing sample.
22
+ */
23
+ time<T>(stage: string, fn: () => Promise<T>): Promise<T>;
24
+ /** Percentile snapshot per stage. Rounded to 2 decimals (ms). */
25
+ snapshot(): Record<string, StageStats>;
26
+ }
@@ -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,17 @@
1
+ export interface TokenBucketFloorOpts {
2
+ capacity: number;
3
+ refillPerMinute: number;
4
+ now?: () => number;
5
+ }
6
+ export declare class TokenBucketFloor {
7
+ private readonly capacity;
8
+ private readonly refillPerMs;
9
+ private readonly now;
10
+ private readonly buckets;
11
+ constructor(opts: TokenBucketFloorOpts);
12
+ private refill;
13
+ /** Consume one token. Returns true if allowed, false if the bucket is empty. */
14
+ tryConsume(key: string): boolean;
15
+ /** Milliseconds until one token is available (0 if already available). */
16
+ msUntilNextToken(key: string): number;
17
+ }
@@ -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,23 @@
1
+ export interface ServiceInput {
2
+ credentialPath: string;
3
+ storePath?: string;
4
+ port?: number;
5
+ label?: string;
6
+ execPath: string;
7
+ binScript: string;
8
+ home: string;
9
+ uid: number;
10
+ }
11
+ export interface ServiceConfig {
12
+ slug: string;
13
+ label: string;
14
+ unitName: string;
15
+ args: string[];
16
+ execPath: string;
17
+ binScript: string;
18
+ uid: number;
19
+ plistPath: string;
20
+ unitPath: string;
21
+ logPath: string;
22
+ }
23
+ export declare function deriveServiceConfig(input: ServiceInput): ServiceConfig;
@@ -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,28 @@
1
+ import { type ServiceInput } from "./config.js";
2
+ export declare class ServiceUnsupportedError extends Error {
3
+ }
4
+ export interface ServiceDeps {
5
+ platform: NodeJS.Platform;
6
+ run: (cmd: string, args: string[]) => {
7
+ status: number;
8
+ stdout: string;
9
+ stderr: string;
10
+ };
11
+ writeFile: (path: string, data: string) => void;
12
+ removeFile: (path: string) => void;
13
+ mkdirp: (path: string) => void;
14
+ fileExists: (path: string) => boolean;
15
+ user: string;
16
+ }
17
+ export interface InstallResult {
18
+ label: string;
19
+ filePath: string;
20
+ logHint: string;
21
+ tokenHint: string;
22
+ }
23
+ export interface UninstallResult {
24
+ removed: boolean;
25
+ filePath: string;
26
+ }
27
+ export declare function installService(input: ServiceInput, deps: ServiceDeps): InstallResult;
28
+ export declare function uninstallService(input: ServiceInput, deps: ServiceDeps): UninstallResult;
@@ -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,4 @@
1
+ import type { ServiceConfig } from "./config.js";
2
+ /** A deterministic launchd LaunchAgent plist. ProgramArguments re-invoke the
3
+ * running bin in run mode; KeepAlive restarts on crash, RunAtLoad starts at login. */
4
+ export declare function renderLaunchdPlist(cfg: ServiceConfig): string;
@@ -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,35 @@
1
+ export declare class GlobalInstallPermissionError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+ export interface ResolveBinDeps {
5
+ run: (cmd: string, args: string[]) => {
6
+ status: number;
7
+ stdout: string;
8
+ stderr: string;
9
+ };
10
+ fileExists: (path: string) => boolean;
11
+ }
12
+ export interface ResolveBinInput {
13
+ binScript: string;
14
+ packageName: string;
15
+ }
16
+ /** npx unpacks packages into a `_npx` cache dir that is garbage-collected; a
17
+ * service definition must never bake a path under it. */
18
+ export declare function isEphemeralPath(p: string): boolean;
19
+ /**
20
+ * Returns an absolute, reboot-stable path to `dist/bin.js`. If we're already
21
+ * running from a durable location (dev `node dist/bin.js`, or a global install),
22
+ * use it as-is. If we're under an npx cache, install the *currently-running*
23
+ * package copy globally (content-pinned, offline, works pre-publish) and return
24
+ * the global path.
25
+ */
26
+ /** True when two paths refer to the same file, resolving symlinks. Falls back
27
+ * to string equality when a path can't be resolved (e.g. doesn't exist). */
28
+ export declare function refersToSameFile(a: string, b: string, realpath: (p: string) => string): boolean;
29
+ export declare function resolveStableBinScript(input: ResolveBinInput, deps: ResolveBinDeps): string;
30
+ /** Reads this package's own name/version from the sibling package.json
31
+ * (dist/bin.js -> ../package.json). */
32
+ export declare function loadPackageMeta(importMetaUrl: string): {
33
+ name: string;
34
+ version: string;
35
+ };
@@ -0,0 +1,4 @@
1
+ import type { ServiceConfig } from "./config.js";
2
+ /** A systemd --user unit. ExecStart re-invokes the running bin in run mode;
3
+ * Restart=always supervises crashes. WantedBy=default.target enables at login. */
4
+ export declare function renderSystemdUnit(cfg: ServiceConfig): string;
@@ -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,42 @@
1
+ /**
2
+ * Agent-facing keepalive WebSocket (/v1/ws).
3
+ *
4
+ * Adds a single persistent bidirectional socket for the AGENT⇄DAEMON hop,
5
+ * modeled on AgentVault's keepalive WS (token in `?token=` query OR
6
+ * `Authorization: Bearer`). This replaces the curl/long-poll loop: the agent
7
+ * receives live inbound messages as JSON frames and sends/reports status over
8
+ * the same socket.
9
+ *
10
+ * Protocol (all frames are JSON text):
11
+ * Daemon → agent:
12
+ * { "type":"message", channel_id, message_id, sender_id, text, ts } // inbound, decrypted
13
+ * { "type":"sent", message_id } // ack of a send
14
+ * { "type":"ok" } // ack of a status report
15
+ * { "type":"error", error, message, detail? } // canonical codes (see errors.ts):
16
+ * // channel_pending | invalid_request
17
+ * Agent → daemon:
18
+ * { "type":"send", channel_id, text?, a2a?, idempotency_key? } // exactly one of text | a2a
19
+ * { "type":"status", channel_id, state, detail? }
20
+ * { "type":"ack", message_id } // confirms a delivered `message` was consumed
21
+ *
22
+ * Inbound delivery is at-least-once. A delivered `message` is held in-flight
23
+ * until the agent sends a matching `ack`; if the socket drops first, the frame
24
+ * is re-buffered and redelivered to the next consumer. The agent dedups by
25
+ * message_id (sends are already idempotent via idempotency_key).
26
+ *
27
+ * Single consumer: the daemon serves one agent per process. A new `/v1/ws`
28
+ * connection replaces the prior one (newest wins, mirroring the SSE path); the
29
+ * replaced socket's un-acked in-flight messages are re-buffered.
30
+ *
31
+ * Keepalive uses the ws library's server-side ping (the protocol-level ping
32
+ * frame, not an application message); dead sockets are reaped via pong tracking.
33
+ */
34
+ import type { Server as HttpServer } from "node:http";
35
+ import type { BridgeAgent } from "./bridge-agent.js";
36
+ export interface AgentWebSocketOptions {
37
+ token: string;
38
+ allowOrigins: string[];
39
+ }
40
+ export declare function attachAgentWebSocket(server: HttpServer, agent: BridgeAgent, opts: AgentWebSocketOptions): {
41
+ close(): void;
42
+ };