@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.
- package/README.md +378 -0
- package/dist/attachments.d.ts +48 -0
- package/dist/attachments.js +49 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +56 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.js +3963 -0
- package/dist/bridge-agent.d.ts +259 -0
- package/dist/bridge-agent.js +831 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +74 -0
- package/dist/coordination.d.ts +24 -0
- package/dist/coordination.js +63 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +25 -0
- package/dist/http-server.d.ts +30 -0
- package/dist/http-server.js +220 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3646 -0
- package/dist/latency.d.ts +26 -0
- package/dist/latency.js +87 -0
- package/dist/rate-limit.d.ts +17 -0
- package/dist/rate-limit.js +56 -0
- package/dist/service/config.d.ts +23 -0
- package/dist/service/config.js +26 -0
- package/dist/service/install.d.ts +28 -0
- package/dist/service/install.js +60 -0
- package/dist/service/launchd.d.ts +4 -0
- package/dist/service/launchd.js +30 -0
- package/dist/service/resolve-bin.d.ts +35 -0
- package/dist/service/systemd.d.ts +4 -0
- package/dist/service/systemd.js +25 -0
- package/dist/ws-server.d.ts +42 -0
- package/dist/ws-server.js +254 -0
- package/package.json +48 -0
|
@@ -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
|
+
}
|
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,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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
};
|