@securecoms/agent-bridge 0.10.0 → 0.11.1
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/dist/attachments.d.ts +8 -4
- package/dist/attachments.js +49 -0
- package/dist/auth.js +56 -0
- package/dist/bin.js +1005 -121
- package/dist/bin.js.pre-c1-f803007 +5795 -0
- package/dist/bin.js.pre-c3-096a9a3 +5832 -0
- package/dist/bin.js.pre-ffrebuild-b162d4a +5639 -0
- package/dist/bin.js.pre-kpheal-20260708-150924 +5531 -0
- package/dist/bin.js.pre-kppersist-20260708-152341 +5543 -0
- package/dist/bin.js.pre-p0-20260708-102415 +5196 -0
- package/dist/bin.js.pre-p1b-02f47e1 +5639 -0
- package/dist/bin.js.pre-warmfix-20260708-153705 +5613 -0
- package/dist/bin.js.pretrace-113205 +5395 -0
- package/dist/bridge-agent.d.ts +110 -19
- package/dist/bridge-agent.js +831 -0
- package/dist/config.js +74 -0
- package/dist/coordination.js +63 -0
- package/dist/errors.js +25 -0
- package/dist/http-server.js +220 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1006 -121
- package/dist/index.js.pre-p0-20260708-102415 +4879 -0
- package/dist/latency.js +87 -0
- package/dist/rate-limit.js +56 -0
- package/dist/service/config.js +26 -0
- package/dist/service/install.js +60 -0
- package/dist/service/launchd.js +30 -0
- package/dist/service/systemd.js +25 -0
- package/dist/sodium-ready.d.ts +43 -0
- package/dist/vault.d.ts +96 -0
- package/dist/ws-server.js +254 -0
- package/package.json +2 -2
package/dist/latency.js
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warm libsodium once per process, before any file-crypto call.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THE DISCARDED KEYPAIR IS THE READINESS GATE, NOT DEAD CODE. Do not
|
|
5
|
+
* delete it. `@agentvault/crypto` has no readiness contract on its entire
|
|
6
|
+
* synchronous surface: sixteen exported synchronous functions throw when
|
|
7
|
+
* libsodium's WASM heap is not yet initialised, with either
|
|
8
|
+
* `<member> is not a function` or
|
|
9
|
+
* `Cannot read properties of undefined (reading '_malloc')`.
|
|
10
|
+
* `generateEphemeralKeypair` is an ASYNC export that awaits `sodium.ready`
|
|
11
|
+
* internally, on the SAME module instance the synchronous exports use, so
|
|
12
|
+
* awaiting it is what makes those exports safe to call afterwards. The X25519
|
|
13
|
+
* keypair it returns is minted and thrown away: it is never stored, never
|
|
14
|
+
* published, and never used for anything.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ DO NOT IMPORT `libsodium-wrappers-sumo` DIRECTLY HERE. That specifier is
|
|
17
|
+
* absent from esbuild.config.mjs's EXTERNAL list, so esbuild would bundle a
|
|
18
|
+
* SECOND libsodium instance. The await would then warm a heap that nothing
|
|
19
|
+
* reads, and this fix would look correct and do nothing.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ MEMOIZE THE PROMISE, NOT A BOOLEAN. Measured: under
|
|
22
|
+
* `if (warmed) return; await ...; warmed = true;` two CONCURRENT callers both
|
|
23
|
+
* run the body. The bridge has two independent entry points (send and
|
|
24
|
+
* receive), so that concurrency is real. The promise form runs the body once.
|
|
25
|
+
*
|
|
26
|
+
* ⛔⛔ THE `catch` THAT CLEARS THE MEMO IS NOT REDUNDANT. DO NOT DELETE IT.
|
|
27
|
+
* Caching the promise fixes the double-warm race, but a bare
|
|
28
|
+
* `warming ??= ...` introduces a failure mode the boolean form did NOT have:
|
|
29
|
+
* if the FIRST warm-up REJECTS (WASM instantiation failure, OOM, a corrupt
|
|
30
|
+
* binary), `warming` holds a REJECTED promise for the life of the process, and
|
|
31
|
+
* every later prepareAttachment AND materializeAttachment then rejects at its
|
|
32
|
+
* first statement, forever. ⛔ ON THE RECEIVE SIDE THAT IS SILENT: the ingest
|
|
33
|
+
* pump's fail-soft catch acks the message, increments the failed-attachment
|
|
34
|
+
* counter and logs, so one transient failure becomes a PERMANENT,
|
|
35
|
+
* PROCESS-WIDE attachment outage that never surfaces as a crash. The boolean
|
|
36
|
+
* form would have retried. Clearing the memo on rejection restores that retry
|
|
37
|
+
* while keeping the single-warm guarantee on the success path.
|
|
38
|
+
* ⛔ It RETHROWS: the first caller must still see the real failure. This is
|
|
39
|
+
* not a swallow-and-proceed, which is the exact shape that caused the
|
|
40
|
+
* production incident this whole finding catalogues.
|
|
41
|
+
* Pinned by src/sodium-ready.retry.test.ts, both halves.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ensureSodiumReady(): Promise<void>;
|
package/dist/vault.d.ts
ADDED
|
@@ -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
|
+
}>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import { ChannelPendingError, RateLimitedError } from "./bridge-agent.js";
|
|
3
|
+
import { tokenMatches, bearerFrom, originHostAllowed } from "./auth.js";
|
|
4
|
+
import { wsErrorFrame } from "./errors.js";
|
|
5
|
+
const PING_INTERVAL_MS = 30_000;
|
|
6
|
+
/** Pull the local token from `?token=` first, then `Authorization: Bearer`. */
|
|
7
|
+
function tokenFromUpgrade(req) {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
10
|
+
const q = url.searchParams.get("token");
|
|
11
|
+
if (q)
|
|
12
|
+
return q;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
/* malformed URL — fall through to header */
|
|
16
|
+
}
|
|
17
|
+
return bearerFrom(req.headers.authorization);
|
|
18
|
+
}
|
|
19
|
+
function authedUpgrade(req, opts) {
|
|
20
|
+
if (!tokenMatches(tokenFromUpgrade(req), opts.token))
|
|
21
|
+
return false;
|
|
22
|
+
if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
export function attachAgentWebSocket(server, agent, opts) {
|
|
28
|
+
const wss = new WebSocketServer({ server, path: "/v1/ws" });
|
|
29
|
+
// Server-side keepalive: ping every interval, drop sockets that miss a pong.
|
|
30
|
+
const keepalive = setInterval(() => {
|
|
31
|
+
for (const client of wss.clients) {
|
|
32
|
+
const sock = client;
|
|
33
|
+
if (sock.isAlive === false) {
|
|
34
|
+
sock.terminate();
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
sock.isAlive = false;
|
|
38
|
+
try {
|
|
39
|
+
sock.ping();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* socket on its way out */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}, PING_INTERVAL_MS);
|
|
46
|
+
// Don't let the interval pin the process open.
|
|
47
|
+
if (typeof keepalive.unref === "function")
|
|
48
|
+
keepalive.unref();
|
|
49
|
+
// One agent per process: the live connection currently owning the inbound
|
|
50
|
+
// stream, carrying its cleanup so a replacement can drain it synchronously.
|
|
51
|
+
let current = null;
|
|
52
|
+
wss.on("connection", (sock, req) => {
|
|
53
|
+
// Auth is enforced here (not verifyClient) so we can close with a clean
|
|
54
|
+
// 1008 policy-violation code that the client observes, rather than a bare
|
|
55
|
+
// HTTP handshake rejection.
|
|
56
|
+
if (!authedUpgrade(req, opts)) {
|
|
57
|
+
sock.close(1008, "unauthorized");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Single consumer, newest wins (mirrors the SSE path). Close the prior
|
|
61
|
+
// socket AND run its cleanup synchronously, so its un-acked in-flight is
|
|
62
|
+
// re-buffered (in FIFO order) BEFORE this socket's initial flush — otherwise
|
|
63
|
+
// the replaced socket's async close could re-buffer those messages after
|
|
64
|
+
// this socket has already consumed newer ones, reordering delivery.
|
|
65
|
+
if (current && current.sock !== sock) {
|
|
66
|
+
const prior = current;
|
|
67
|
+
if (prior.sock.readyState === WebSocket.OPEN) {
|
|
68
|
+
prior.sock.close(1000, "replaced by newer connection");
|
|
69
|
+
}
|
|
70
|
+
prior.cleanup();
|
|
71
|
+
}
|
|
72
|
+
sock.isAlive = true;
|
|
73
|
+
sock.on("pong", () => {
|
|
74
|
+
sock.isAlive = true;
|
|
75
|
+
});
|
|
76
|
+
// Delivered-but-unacked messages, keyed by message_id in delivery order.
|
|
77
|
+
// Held until the agent acks; re-buffered on disconnect (at-least-once).
|
|
78
|
+
const inflight = new Map();
|
|
79
|
+
const flush = () => {
|
|
80
|
+
if (sock.readyState !== WebSocket.OPEN)
|
|
81
|
+
return;
|
|
82
|
+
const batch = agent.takeBuffered();
|
|
83
|
+
for (let i = 0; i < batch.length; i++) {
|
|
84
|
+
if (sock.readyState !== WebSocket.OPEN) {
|
|
85
|
+
// Socket closing mid-flush — give the rest back to the buffer.
|
|
86
|
+
agent.requeueFront(batch.slice(i));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const frame = { type: "message", ...batch[i] };
|
|
90
|
+
try {
|
|
91
|
+
sock.send(JSON.stringify(frame));
|
|
92
|
+
inflight.set(batch[i].message_id, batch[i]);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
agent.requeueFront(batch.slice(i));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const unsubscribe = agent.subscribe(flush);
|
|
101
|
+
let cleaned = false;
|
|
102
|
+
const cleanup = () => {
|
|
103
|
+
if (cleaned)
|
|
104
|
+
return;
|
|
105
|
+
cleaned = true;
|
|
106
|
+
unsubscribe();
|
|
107
|
+
// Re-buffer anything delivered but never acked, in original order, so the
|
|
108
|
+
// next consumer redelivers it rather than the daemon dropping it. Poke
|
|
109
|
+
// listeners so a still-connected replacement consumer drains it now.
|
|
110
|
+
if (inflight.size) {
|
|
111
|
+
agent.requeueFront([...inflight.values()]);
|
|
112
|
+
inflight.clear();
|
|
113
|
+
agent.wakeListeners();
|
|
114
|
+
}
|
|
115
|
+
if (current?.sock === sock)
|
|
116
|
+
current = null;
|
|
117
|
+
};
|
|
118
|
+
current = { sock, cleanup };
|
|
119
|
+
sock.on("close", cleanup);
|
|
120
|
+
sock.on("error", cleanup);
|
|
121
|
+
sock.on("message", (data) => {
|
|
122
|
+
void handleInbound(sock, agent, data, inflight);
|
|
123
|
+
});
|
|
124
|
+
// Flush anything already buffered for an agent that connects after delivery.
|
|
125
|
+
flush();
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
close() {
|
|
129
|
+
clearInterval(keepalive);
|
|
130
|
+
for (const client of wss.clients) {
|
|
131
|
+
try {
|
|
132
|
+
client.terminate();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* already gone */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
wss.close();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function sendJson(sock, body) {
|
|
143
|
+
if (sock.readyState !== WebSocket.OPEN)
|
|
144
|
+
return;
|
|
145
|
+
try {
|
|
146
|
+
sock.send(JSON.stringify(body));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* socket closing */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function handleInbound(sock, agent, data, inflight) {
|
|
153
|
+
let msg;
|
|
154
|
+
try {
|
|
155
|
+
msg = JSON.parse(data.toString("utf8"));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (typeof msg !== "object" || msg === null) {
|
|
162
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const m = msg;
|
|
166
|
+
if (m.type === "ack") {
|
|
167
|
+
// Consume confirmation: drop the message from in-flight so it is not
|
|
168
|
+
// re-buffered on disconnect. Fire-and-forget (no reply frame).
|
|
169
|
+
if (typeof m.message_id !== "string" || !m.message_id) {
|
|
170
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
inflight.delete(m.message_id);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (m.type === "send") {
|
|
177
|
+
if (typeof m.channel_id !== "string" || !m.channel_id) {
|
|
178
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (m.a2a && typeof m.a2a === "object") {
|
|
182
|
+
const a = m.a2a;
|
|
183
|
+
if (typeof a.a2a_version !== "string" || !a.a2a_version || (a.kind !== "message" && a.kind !== "task" && a.kind !== "artifact") || typeof a.object !== "object" || a.object === null) {
|
|
184
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
|
|
189
|
+
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
if (e instanceof ChannelPendingError) {
|
|
193
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
194
|
+
}
|
|
195
|
+
else if (e instanceof RateLimitedError) {
|
|
196
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (typeof m.text !== "string" || !m.text) {
|
|
205
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const idempotencyKey = typeof m.idempotency_key === "string" ? m.idempotency_key : undefined;
|
|
209
|
+
try {
|
|
210
|
+
const r = await agent.send(m.channel_id, m.text, idempotencyKey);
|
|
211
|
+
sendJson(sock, { type: "sent", message_id: r.message_id });
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
if (e instanceof ChannelPendingError) {
|
|
215
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
216
|
+
}
|
|
217
|
+
else if (e instanceof RateLimitedError) {
|
|
218
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (m.type === "status") {
|
|
227
|
+
const states = ["idle", "working", "stalled", "error"];
|
|
228
|
+
if (typeof m.channel_id !== "string" ||
|
|
229
|
+
!m.channel_id ||
|
|
230
|
+
typeof m.state !== "string" ||
|
|
231
|
+
!states.includes(m.state)) {
|
|
232
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const detail = typeof m.detail === "string" ? m.detail : undefined;
|
|
236
|
+
try {
|
|
237
|
+
await agent.reportStatus(m.channel_id, m.state, detail);
|
|
238
|
+
sendJson(sock, { type: "ok" });
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
if (e instanceof ChannelPendingError) {
|
|
242
|
+
sendJson(sock, wsErrorFrame("channel_pending"));
|
|
243
|
+
}
|
|
244
|
+
else if (e instanceof RateLimitedError) {
|
|
245
|
+
sendJson(sock, wsErrorFrame("rate_limited"));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
sendJson(sock, wsErrorFrame("invalid_request"));
|
|
254
|
+
}
|