rterm-backend 3.1.2 → 3.1.4
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/bin/gybackend.cjs +75 -0
- package/package.json +2 -2
- package/plugins/numbat-bridge/index.mjs +229 -0
- package/plugins/numbat-bridge/numbat-bridge.extreme.spec.mjs +148 -0
- package/plugins/numbat-bridge/plugin.json +21 -0
- package/plugins/synapse-bridge/index.mjs +252 -0
- package/plugins/synapse-bridge/plugin.json +22 -0
- package/plugins/synapse-bridge/synapse-bridge.extreme.spec.mjs +174 -0
package/bin/gybackend.cjs
CHANGED
|
@@ -374858,6 +374858,9 @@ function pickBackendSnapshot(raw) {
|
|
|
374858
374858
|
cloud: raw.cloud,
|
|
374859
374859
|
agentspan: raw.agentspan,
|
|
374860
374860
|
webIntel: raw.webIntel,
|
|
374861
|
+
nats: raw.nats,
|
|
374862
|
+
synapse: raw.synapse,
|
|
374863
|
+
numbat: raw.numbat,
|
|
374861
374864
|
gateway: raw.gateway,
|
|
374862
374865
|
layout: raw.layout,
|
|
374863
374866
|
recursionLimit: raw.recursionLimit,
|
|
@@ -374938,6 +374941,9 @@ function normalizeBackendSettings(settings) {
|
|
|
374938
374941
|
next.cloud = normalizeCloudSettings(next.cloud);
|
|
374939
374942
|
next.agentspan = normalizeAgentspanSettings(next.agentspan);
|
|
374940
374943
|
next.webIntel = normalizeWebIntelSettings(next.webIntel);
|
|
374944
|
+
next.nats = normalizeNatsSettings(next.nats);
|
|
374945
|
+
next.synapse = normalizeSynapseSettings(next.synapse);
|
|
374946
|
+
next.numbat = normalizeNumbatSettings(next.numbat);
|
|
374941
374947
|
next.schemaVersion = BACKEND_SETTINGS_SCHEMA_VERSION;
|
|
374942
374948
|
return next;
|
|
374943
374949
|
}
|
|
@@ -375122,6 +375128,75 @@ function normalizeWebIntelSettings(raw) {
|
|
|
375122
375128
|
warmupOnInit: src.warmupOnInit === true
|
|
375123
375129
|
};
|
|
375124
375130
|
}
|
|
375131
|
+
function normalizeNatsSettings(raw) {
|
|
375132
|
+
const src = isObject5(raw) ? raw : {};
|
|
375133
|
+
const url2 = typeof src.url === "string" && src.url.trim() ? src.url.trim() : void 0;
|
|
375134
|
+
const servers = Array.isArray(src.servers) ? src.servers.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => s.trim()) : void 0;
|
|
375135
|
+
const prefix = typeof src.prefix === "string" && src.prefix.trim() ? src.prefix.trim() : void 0;
|
|
375136
|
+
const queue2 = typeof src.queue === "string" && src.queue.trim() ? src.queue.trim() : void 0;
|
|
375137
|
+
const num3 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
375138
|
+
const rawAuth = isObject5(src.auth) ? src.auth : void 0;
|
|
375139
|
+
const str3 = (v) => typeof v === "string" && v.trim() ? v : void 0;
|
|
375140
|
+
const auth2 = rawAuth ? {
|
|
375141
|
+
...str3(rawAuth.token) ? { token: str3(rawAuth.token) } : {},
|
|
375142
|
+
...str3(rawAuth.username) ? { username: str3(rawAuth.username) } : {},
|
|
375143
|
+
...str3(rawAuth.password) ? { password: str3(rawAuth.password) } : {},
|
|
375144
|
+
...str3(rawAuth.nkeySeed) ? { nkeySeed: str3(rawAuth.nkeySeed) } : {},
|
|
375145
|
+
...str3(rawAuth.jwt) ? { jwt: str3(rawAuth.jwt) } : {},
|
|
375146
|
+
...str3(rawAuth.jwtSeed) ? { jwtSeed: str3(rawAuth.jwtSeed) } : {},
|
|
375147
|
+
...str3(rawAuth.creds) ? { creds: str3(rawAuth.creds) } : {},
|
|
375148
|
+
...str3(rawAuth.tlsCert) ? { tlsCert: str3(rawAuth.tlsCert) } : {},
|
|
375149
|
+
...str3(rawAuth.tlsKey) ? { tlsKey: str3(rawAuth.tlsKey) } : {},
|
|
375150
|
+
...str3(rawAuth.tlsCa) ? { tlsCa: str3(rawAuth.tlsCa) } : {}
|
|
375151
|
+
} : void 0;
|
|
375152
|
+
const hasAuth = auth2 && Object.keys(auth2).length > 0;
|
|
375153
|
+
return {
|
|
375154
|
+
enabled: src.enabled !== false,
|
|
375155
|
+
...url2 ? { url: url2 } : {},
|
|
375156
|
+
...servers && servers.length > 0 ? { servers } : {},
|
|
375157
|
+
...prefix ? { prefix } : {},
|
|
375158
|
+
...queue2 ? { queue: queue2 } : {},
|
|
375159
|
+
...num3(src.maxReconnectAttempts) !== void 0 ? { maxReconnectAttempts: num3(src.maxReconnectAttempts) } : {},
|
|
375160
|
+
...num3(src.reconnectTimeWait) !== void 0 ? { reconnectTimeWait: num3(src.reconnectTimeWait) } : {},
|
|
375161
|
+
...num3(src.timeout) !== void 0 ? { timeout: num3(src.timeout) } : {},
|
|
375162
|
+
...hasAuth ? { auth: auth2 } : {}
|
|
375163
|
+
};
|
|
375164
|
+
}
|
|
375165
|
+
function normalizeSynapseSettings(raw) {
|
|
375166
|
+
const src = isObject5(raw) ? raw : {};
|
|
375167
|
+
const url2 = typeof src.url === "string" && src.url.trim() ? src.url.trim() : void 0;
|
|
375168
|
+
const servers = Array.isArray(src.servers) ? src.servers.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => s.trim()) : void 0;
|
|
375169
|
+
const prefix = typeof src.prefix === "string" && src.prefix.trim() ? src.prefix.trim() : void 0;
|
|
375170
|
+
const agentId = typeof src.agentId === "string" && src.agentId.trim() ? src.agentId.trim() : void 0;
|
|
375171
|
+
const rawAuth = isObject5(src.auth) ? src.auth : void 0;
|
|
375172
|
+
const str3 = (v) => typeof v === "string" && v.trim() ? v : void 0;
|
|
375173
|
+
const authKeys = ["token", "tokenSecretRef", "username", "password", "passwordSecretRef", "nkeySeed", "jwt", "jwtSeed", "creds", "tlsCert", "tlsKey", "tlsCa"];
|
|
375174
|
+
const auth2 = rawAuth ? Object.fromEntries(authKeys.filter((k) => str3(rawAuth[k])).map((k) => [k, str3(rawAuth[k])])) : void 0;
|
|
375175
|
+
const hasAuth = auth2 && Object.keys(auth2).length > 0;
|
|
375176
|
+
return {
|
|
375177
|
+
enabled: src.enabled !== false,
|
|
375178
|
+
...url2 ? { url: url2 } : {},
|
|
375179
|
+
...servers && servers.length > 0 ? { servers } : {},
|
|
375180
|
+
...prefix ? { prefix } : {},
|
|
375181
|
+
...agentId ? { agentId } : {},
|
|
375182
|
+
...hasAuth ? { auth: auth2 } : {}
|
|
375183
|
+
};
|
|
375184
|
+
}
|
|
375185
|
+
function normalizeNumbatSettings(raw) {
|
|
375186
|
+
const src = isObject5(raw) ? raw : {};
|
|
375187
|
+
const binaryPath = typeof src.binaryPath === "string" && src.binaryPath.trim() ? src.binaryPath.trim() : void 0;
|
|
375188
|
+
const recordsPath = typeof src.recordsPath === "string" && src.recordsPath.trim() ? src.recordsPath.trim() : void 0;
|
|
375189
|
+
const ingestToken = typeof src.ingestToken === "string" && src.ingestToken.trim() ? src.ingestToken.trim() : void 0;
|
|
375190
|
+
const sev = typeof src.minSeverity === "string" ? src.minSeverity.toLowerCase() : "";
|
|
375191
|
+
const minSeverity = ["info", "low", "medium", "high", "critical"].includes(sev) ? sev : void 0;
|
|
375192
|
+
return {
|
|
375193
|
+
enabled: src.enabled !== false,
|
|
375194
|
+
...binaryPath ? { binaryPath } : {},
|
|
375195
|
+
...recordsPath ? { recordsPath } : {},
|
|
375196
|
+
...ingestToken ? { ingestToken } : {},
|
|
375197
|
+
...minSeverity ? { minSeverity } : {}
|
|
375198
|
+
};
|
|
375199
|
+
}
|
|
375125
375200
|
function migrateBackendToV3(settings) {
|
|
375126
375201
|
const next = { ...settings };
|
|
375127
375202
|
delete next.language;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rterm-backend",
|
|
3
|
-
"version": "3.1.
|
|
4
|
-
"description": "Headless AI-native backend for RTerm / neuralOS — v3.1.
|
|
3
|
+
"version": "3.1.4",
|
|
4
|
+
"description": "Headless AI-native backend for RTerm / neuralOS — v3.1.4: synapse-bridge (Synapse mesh interop: discover/dispatch/register mesh agents) + numbat-bridge (Numbat endpoint AI-agent detection → triggers) plugins. 11 plugins total. Transports (SSH/serial/local), SQLite, and NATS libs install automatically.",
|
|
5
5
|
"main": "bin/gybackend.cjs",
|
|
6
6
|
"bin": { "gybackend": "bin/gybackend.cjs" },
|
|
7
7
|
"license": "MIT",
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* numbat-bridge — RTerm ↔ Numbat (endpoint AI-agent detection) integration.
|
|
3
|
+
*
|
|
4
|
+
* Numbat = EDR for AI agents: endpoint visibility, CEL detection, forensic
|
|
5
|
+
* reconstruction. RTerm = control plane. This bridge wires them:
|
|
6
|
+
* - DEPLOY: install/manage the numbat binary + hooks on hosts (via playbooks/exec).
|
|
7
|
+
* - INGEST: accept Numbat findings (NDJSON records) delivered over HTTP or read
|
|
8
|
+
* from a local records file, normalize them, and feed RTerm triggers.
|
|
9
|
+
* - ACT: turn detections into governed actions (playbooks, MOP changes, incidents).
|
|
10
|
+
*
|
|
11
|
+
* Numbat detects; RTerm responds.
|
|
12
|
+
*
|
|
13
|
+
* Config (settings.numbat, or env):
|
|
14
|
+
* enabled — master switch (default true)
|
|
15
|
+
* binaryPath — path to the numbat binary (default "numbat" on PATH)
|
|
16
|
+
* recordsPath — local NDJSON records file to tail (default ~/.numbat/records.ndjson)
|
|
17
|
+
* ingestToken — bearer token the HTTP ingest endpoint requires (vault secretRef ok)
|
|
18
|
+
* minSeverity — only ingest findings at/above this severity (info|low|medium|high|critical)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createRequire } from 'node:module'
|
|
22
|
+
import { randomUUID } from 'node:crypto'
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url)
|
|
25
|
+
|
|
26
|
+
// ─── config ─────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export function resolveConfig(ctx = {}, env = process.env) {
|
|
29
|
+
const s = (typeof ctx.getSettings === 'function' ? ctx.getSettings() : ctx.settings) || {}
|
|
30
|
+
const b = s.numbat || {}
|
|
31
|
+
return {
|
|
32
|
+
enabled: b.enabled !== false,
|
|
33
|
+
binaryPath: b.binaryPath || env.NUMBAT_BIN || 'numbat',
|
|
34
|
+
recordsPath: b.recordsPath || env.NUMBAT_RECORDS || `${process.env.HOME}/.numbat/records.ndjson`,
|
|
35
|
+
ingestToken: b.ingestToken || undefined,
|
|
36
|
+
minSeverity: b.minSeverity || 'low',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SEVERITY_ORDER = ['info', 'low', 'medium', 'high', 'critical']
|
|
41
|
+
function severityAtLeast(sev, min) {
|
|
42
|
+
const i = SEVERITY_ORDER.indexOf(String(sev ?? 'info').toLowerCase())
|
|
43
|
+
const m = SEVERITY_ORDER.indexOf(String(min ?? 'low').toLowerCase())
|
|
44
|
+
return (i < 0 ? 0 : i) >= (m < 0 ? 0 : m)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── record normalization ───────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** Normalize a Numbat NDJSON record (event/finding/enforcement/indicator/scan) into
|
|
50
|
+
* a compact RTerm finding. Returns null for records that shouldn't be ingested. */
|
|
51
|
+
export function normalizeRecord(rec, cfg) {
|
|
52
|
+
if (!rec || typeof rec !== 'object') return null
|
|
53
|
+
const type = rec.record_type ?? rec.type ?? 'event'
|
|
54
|
+
const severity = rec.severity ?? rec.rule_severity ?? rec.level ?? 'info'
|
|
55
|
+
// Only findings + high-signal events become trigger inputs; raw events are noise.
|
|
56
|
+
const isFinding = type === 'finding' || type === 'enforcement' || type === 'indicator'
|
|
57
|
+
if (!isFinding && type === 'event' && !severityAtLeast(severity, 'high')) return null
|
|
58
|
+
if (!severityAtLeast(severity, cfg.minSeverity)) return null
|
|
59
|
+
return {
|
|
60
|
+
id: rec.id ?? rec.record_id ?? randomUUID(),
|
|
61
|
+
source: 'numbat',
|
|
62
|
+
recordType: type,
|
|
63
|
+
severity,
|
|
64
|
+
ruleId: rec.rule_id ?? rec.rule?.id ?? undefined,
|
|
65
|
+
title: rec.title ?? rec.rule_name ?? rec.rule?.name ?? type,
|
|
66
|
+
agent: rec.agent ?? rec.agent_id ?? rec.source?.agent ?? undefined,
|
|
67
|
+
host: rec.host ?? rec.hostname ?? rec.source?.host ?? undefined,
|
|
68
|
+
summary: rec.summary ?? rec.description ?? rec.content_preview ?? undefined,
|
|
69
|
+
ts: rec.ts ?? rec.timestamp ?? new Date().toISOString(),
|
|
70
|
+
raw: rec,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parse an NDJSON blob (one JSON record per line) into normalized findings. */
|
|
75
|
+
export function parseNdjson(text, cfg) {
|
|
76
|
+
const out = []
|
|
77
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
78
|
+
const t = line.trim()
|
|
79
|
+
if (!t) continue
|
|
80
|
+
try { const n = normalizeRecord(JSON.parse(t), cfg); if (n) out.push(n) } catch { /* skip malformed */ }
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── deploy (run numbat commands on a host) ─────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/** Build the numbat CLI argv for a deploy action. Pure — testable. */
|
|
88
|
+
export function buildDeployCommand(action, opts = {}) {
|
|
89
|
+
const agent = opts.agent || 'codex'
|
|
90
|
+
switch (action) {
|
|
91
|
+
case 'inventory': return ['agents']
|
|
92
|
+
case 'scan': return opts.agent ? ['scan', '--agent', agent] : ['scan']
|
|
93
|
+
case 'install-monitor': return ['hook', 'install', '--agent', agent, '--emit', opts.emit ?? 'all']
|
|
94
|
+
case 'install-enforce': return ['hook', 'install', '--agent', agent, '--emit', opts.emit ?? 'all', ...(opts.rulesDir ? ['--rules-dir', opts.rulesDir] : []), '--enforce']
|
|
95
|
+
case 'status': return ['hook', 'status', '--agent', agent]
|
|
96
|
+
case 'uninstall': return ['hook', 'uninstall', '--agent', agent]
|
|
97
|
+
default: throw new Error(`unknown numbat deploy action: ${action}`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Run a numbat command via the plugin's exec capability (local or remote host). */
|
|
102
|
+
async function runNumbat(ctx, cfg, argv, target) {
|
|
103
|
+
const cmdline = [cfg.binaryPath, ...argv].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(' ')
|
|
104
|
+
// Prefer the plugin exec/runCommand capability when available (policy-gated).
|
|
105
|
+
if (typeof ctx.runCommand === 'function') {
|
|
106
|
+
return ctx.runCommand({ command: cmdline, target })
|
|
107
|
+
}
|
|
108
|
+
if (typeof ctx.exec === 'function') {
|
|
109
|
+
return ctx.exec(cmdline, { target })
|
|
110
|
+
}
|
|
111
|
+
// Local fallback via child_process.
|
|
112
|
+
const { execFile } = require('node:child_process')
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
execFile(cfg.binaryPath, argv, { timeout: 60000 }, (err, stdout, stderr) => {
|
|
115
|
+
resolve({ ok: !err, exitCode: err?.code ?? 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? ''), error: err ? String(err.message) : undefined })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── plugin registration ────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
async function guarded(fn, log) {
|
|
123
|
+
try { return await fn() } catch (e) {
|
|
124
|
+
const msg = e?.message ?? String(e)
|
|
125
|
+
log?.(`[numbat] ${msg}`)
|
|
126
|
+
return { error: msg, hint: 'Is numbat installed? (go install github.com/perplexityai/numbat/cmd/numbat@latest, or download a release). Configure settings.numbat.' }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function register(ctx) {
|
|
131
|
+
const { registerTool, registerTrigger, registerPanel, log } = ctx
|
|
132
|
+
const cfg = resolveConfig(ctx)
|
|
133
|
+
|
|
134
|
+
registerTool({
|
|
135
|
+
name: 'numbat_health',
|
|
136
|
+
description: 'Check the numbat binary is present and report its version + the configured records path.',
|
|
137
|
+
params: {},
|
|
138
|
+
handler: async () => guarded(async () => {
|
|
139
|
+
const r = await runNumbat(ctx, cfg, ['version'])
|
|
140
|
+
return { binaryPath: cfg.binaryPath, recordsPath: cfg.recordsPath, ...r }
|
|
141
|
+
}, log),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
registerTool({
|
|
145
|
+
name: 'numbat_deploy',
|
|
146
|
+
description: 'Deploy/manage numbat on a host: inventory agents, scan, install monitor-only or enforce hooks, check status, or uninstall. Runs the numbat CLI via the policy-gated exec path (local or a target host).',
|
|
147
|
+
params: {
|
|
148
|
+
action: { type: 'string', description: 'inventory | scan | install-monitor | install-enforce | status | uninstall' },
|
|
149
|
+
agent: { type: 'string', description: 'Target agent (e.g. codex)', optional: true },
|
|
150
|
+
target: { type: 'string', description: 'Host/terminal to run on (default local)', optional: true },
|
|
151
|
+
rulesDir: { type: 'string', description: 'Custom rules dir (for enforce)', optional: true },
|
|
152
|
+
emit: { type: 'string', description: 'Emit mode (default all)', optional: true },
|
|
153
|
+
},
|
|
154
|
+
handler: async (p) => guarded(async () => {
|
|
155
|
+
if (!p?.action) return { error: 'numbat_deploy needs an action' }
|
|
156
|
+
const argv = buildDeployCommand(p.action, p)
|
|
157
|
+
const r = await runNumbat(ctx, cfg, argv, p?.target)
|
|
158
|
+
return { action: p.action, argv, ...r }
|
|
159
|
+
}, log),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
registerTool({
|
|
163
|
+
name: 'numbat_ingest',
|
|
164
|
+
description: 'Ingest Numbat NDJSON records (events/findings/enforcement/indicators) — normalize them into RTerm findings and fire the numbat_finding trigger for each. Pass ndjson text directly or read from the configured records file.',
|
|
165
|
+
params: {
|
|
166
|
+
ndjson: { type: 'string', description: 'NDJSON records (one JSON per line)', optional: true },
|
|
167
|
+
fromFile: { type: 'boolean', description: 'Read from recordsPath instead of inline ndjson', optional: true },
|
|
168
|
+
},
|
|
169
|
+
handler: async (p) => guarded(async () => {
|
|
170
|
+
let text = p?.ndjson
|
|
171
|
+
if (!text && (p?.fromFile || !p?.ndjson)) {
|
|
172
|
+
const fs = require('node:fs')
|
|
173
|
+
try { text = fs.readFileSync(cfg.recordsPath, 'utf8') } catch { text = '' }
|
|
174
|
+
}
|
|
175
|
+
const findings = parseNdjson(text ?? '', cfg)
|
|
176
|
+
// Fire a trigger event per finding (the trigger engine routes these).
|
|
177
|
+
if (typeof ctx.emitEvent === 'function') {
|
|
178
|
+
for (const f of findings) ctx.emitEvent({ source: 'numbat', ...f })
|
|
179
|
+
}
|
|
180
|
+
return { ingested: findings.length, findings }
|
|
181
|
+
}, log),
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
registerTool({
|
|
185
|
+
name: 'numbat_findings_summary',
|
|
186
|
+
description: 'Summarize Numbat findings from the records file by severity + rule + agent (quick threat picture).',
|
|
187
|
+
params: {},
|
|
188
|
+
handler: async () => guarded(async () => {
|
|
189
|
+
const fs = require('node:fs')
|
|
190
|
+
let text = ''
|
|
191
|
+
try { text = fs.readFileSync(cfg.recordsPath, 'utf8') } catch { /* none */ }
|
|
192
|
+
const findings = parseNdjson(text, { ...cfg, minSeverity: 'info' })
|
|
193
|
+
const bySeverity = {}
|
|
194
|
+
const byRule = {}
|
|
195
|
+
const byAgent = {}
|
|
196
|
+
for (const f of findings) {
|
|
197
|
+
bySeverity[f.severity] = (bySeverity[f.severity] ?? 0) + 1
|
|
198
|
+
if (f.ruleId) byRule[f.ruleId] = (byRule[f.ruleId] ?? 0) + 1
|
|
199
|
+
if (f.agent) byAgent[f.agent] = (byAgent[f.agent] ?? 0) + 1
|
|
200
|
+
}
|
|
201
|
+
return { total: findings.length, bySeverity, byRule, byAgent, recordsPath: cfg.recordsPath }
|
|
202
|
+
}, log),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
registerTrigger({
|
|
206
|
+
name: 'numbat_finding',
|
|
207
|
+
description: 'Fires when a Numbat detection (finding/enforcement/indicator, or high-severity event) is ingested. Use to auto-remediate: isolate the host, kill the agent, open an incident, or run a playbook.',
|
|
208
|
+
match: (event) => {
|
|
209
|
+
if (event?.source !== 'numbat') return false
|
|
210
|
+
return severityAtLeast(event.severity, 'medium')
|
|
211
|
+
},
|
|
212
|
+
action: 'propose-change',
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
registerPanel({
|
|
216
|
+
name: 'numbat-findings',
|
|
217
|
+
title: 'Numbat Findings',
|
|
218
|
+
render: (data) => {
|
|
219
|
+
const rows = (Array.isArray(data) ? data : []).map((f) =>
|
|
220
|
+
`<tr><td>${f.severity ?? ''}</td><td>${f.title ?? ''}</td><td>${f.agent ?? ''}</td><td>${f.host ?? ''}</td></tr>`
|
|
221
|
+
).join('')
|
|
222
|
+
return `<div class="numbat-findings"><h3>Numbat Findings</h3><p>Records: ${cfg.recordsPath}</p><table><thead><tr><th>Severity</th><th>Title</th><th>Agent</th><th>Host</th></tr></thead><tbody>${rows}</tbody></table></div>`
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
log(`[numbat] numbat-bridge registered: 4 tools, 1 trigger, 1 panel (bin=${cfg.binaryPath})`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default { register, resolveConfig, normalizeRecord, parseNdjson, buildDeployCommand }
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { register, resolveConfig, normalizeRecord, parseNdjson, buildDeployCommand } from './index.mjs'
|
|
2
|
+
|
|
3
|
+
const cases = []
|
|
4
|
+
function test(n, r) { cases.push({ name: n, run: r }) }
|
|
5
|
+
function assert(c, m) { if (!c) throw new Error(m ?? 'assertion failed') }
|
|
6
|
+
function eq(a, b, m) { if (a !== b) throw new Error(`${m ?? 'eq'}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`) }
|
|
7
|
+
|
|
8
|
+
function mkCtx(settings = {}, extra = {}) {
|
|
9
|
+
const tools = new Map(); const triggers = []; const panels = []; const logs = []; const emitted = []
|
|
10
|
+
const ctx = {
|
|
11
|
+
settings: { numbat: settings },
|
|
12
|
+
registerTool: (t) => tools.set(t.name, t),
|
|
13
|
+
registerTrigger: (t) => triggers.push(t),
|
|
14
|
+
registerPanel: (p) => panels.push(p),
|
|
15
|
+
log: (l) => logs.push(l),
|
|
16
|
+
emitEvent: (e) => emitted.push(e),
|
|
17
|
+
...extra,
|
|
18
|
+
}
|
|
19
|
+
return { tools, triggers, panels, logs, emitted, ctx }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CFG = { minSeverity: 'low' }
|
|
23
|
+
|
|
24
|
+
// ─── config ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
test('resolveConfig defaults', () => {
|
|
27
|
+
const c = resolveConfig({ settings: {} }, {})
|
|
28
|
+
eq(c.binaryPath, 'numbat', 'default binary')
|
|
29
|
+
eq(c.minSeverity, 'low', 'default minSeverity')
|
|
30
|
+
eq(c.enabled, true, 'enabled default')
|
|
31
|
+
assert(c.recordsPath.includes('.numbat'), 'records path under ~/.numbat')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// ─── normalizeRecord ────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
test('normalizeRecord maps a finding record', () => {
|
|
37
|
+
const rec = { record_type: 'finding', severity: 'high', rule_id: 'cloud-metadata', agent: 'codex', host: 'web-01', summary: 'metadata access' }
|
|
38
|
+
const n = normalizeRecord(rec, CFG)
|
|
39
|
+
eq(n.source, 'numbat', 'source')
|
|
40
|
+
eq(n.recordType, 'finding', 'type')
|
|
41
|
+
eq(n.severity, 'high', 'severity')
|
|
42
|
+
eq(n.ruleId, 'cloud-metadata', 'ruleId')
|
|
43
|
+
eq(n.agent, 'codex', 'agent')
|
|
44
|
+
eq(n.host, 'web-01', 'host')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('normalizeRecord drops low-signal raw events (noise)', () => {
|
|
48
|
+
const rec = { record_type: 'event', severity: 'info', summary: 'routine' }
|
|
49
|
+
eq(normalizeRecord(rec, CFG), null, 'info event dropped')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('normalizeRecord keeps high-severity events', () => {
|
|
53
|
+
const rec = { record_type: 'event', severity: 'critical', summary: 'metadata 169.254.169.254' }
|
|
54
|
+
const n = normalizeRecord(rec, CFG)
|
|
55
|
+
assert(n, 'critical event kept')
|
|
56
|
+
eq(n.severity, 'critical', 'severity')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('normalizeRecord enforces minSeverity', () => {
|
|
60
|
+
const rec = { record_type: 'finding', severity: 'low', rule_id: 'x' }
|
|
61
|
+
eq(normalizeRecord(rec, { minSeverity: 'high' }), null, 'low finding below high threshold dropped')
|
|
62
|
+
assert(normalizeRecord(rec, { minSeverity: 'low' }), 'low finding at low threshold kept')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ─── parseNdjson ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
test('parseNdjson parses multiple records, skips malformed', () => {
|
|
68
|
+
const nd = [
|
|
69
|
+
JSON.stringify({ record_type: 'finding', severity: 'high', rule_id: 'r1', agent: 'codex' }),
|
|
70
|
+
'not json',
|
|
71
|
+
JSON.stringify({ record_type: 'event', severity: 'info' }),
|
|
72
|
+
JSON.stringify({ record_type: 'indicator', severity: 'medium', rule_id: 'r2' }),
|
|
73
|
+
].join('\n')
|
|
74
|
+
const out = parseNdjson(nd, CFG)
|
|
75
|
+
eq(out.length, 2, 'two findings (finding + indicator; info event + malformed dropped)')
|
|
76
|
+
eq(out[0].ruleId, 'r1', 'first rule')
|
|
77
|
+
eq(out[1].recordType, 'indicator', 'second type')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ─── buildDeployCommand ─────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
test('buildDeployCommand builds correct argv per action', () => {
|
|
83
|
+
eq(JSON.stringify(buildDeployCommand('inventory')), JSON.stringify(['agents']), 'inventory')
|
|
84
|
+
eq(JSON.stringify(buildDeployCommand('scan', { agent: 'codex' })), JSON.stringify(['scan', '--agent', 'codex']), 'scan')
|
|
85
|
+
eq(JSON.stringify(buildDeployCommand('install-monitor', { agent: 'codex' })), JSON.stringify(['hook', 'install', '--agent', 'codex', '--emit', 'all']), 'install-monitor')
|
|
86
|
+
const en = buildDeployCommand('install-enforce', { agent: 'codex', rulesDir: './policy' })
|
|
87
|
+
assert(en.includes('--enforce'), 'enforce flag')
|
|
88
|
+
assert(en.includes('--rules-dir'), 'rules-dir flag')
|
|
89
|
+
eq(JSON.stringify(buildDeployCommand('status', { agent: 'codex' })), JSON.stringify(['hook', 'status', '--agent', 'codex']), 'status')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('buildDeployCommand throws on unknown action', () => {
|
|
93
|
+
let threw = false
|
|
94
|
+
try { buildDeployCommand('bogus') } catch { threw = true }
|
|
95
|
+
assert(threw, 'expected throw')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// ─── register wiring ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
test('register wires 4 tools, 1 trigger, 1 panel', () => {
|
|
101
|
+
const { tools, triggers, panels, ctx } = mkCtx()
|
|
102
|
+
register(ctx)
|
|
103
|
+
eq(tools.size, 4, 'tool count')
|
|
104
|
+
for (const n of ['numbat_health', 'numbat_deploy', 'numbat_ingest', 'numbat_findings_summary']) assert(tools.has(n), `missing ${n}`)
|
|
105
|
+
eq(triggers.length, 1, 'trigger count')
|
|
106
|
+
eq(triggers[0].name, 'numbat_finding', 'trigger name')
|
|
107
|
+
eq(panels.length, 1, 'panel count')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ─── ingest → trigger ───────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
test('numbat_ingest normalizes + emits a trigger event per finding', async () => {
|
|
113
|
+
const { tools, emitted, ctx } = mkCtx()
|
|
114
|
+
register(ctx)
|
|
115
|
+
const nd = [
|
|
116
|
+
JSON.stringify({ record_type: 'finding', severity: 'high', rule_id: 'cloud-metadata', agent: 'codex', host: 'web-01' }),
|
|
117
|
+
JSON.stringify({ record_type: 'finding', severity: 'medium', rule_id: 'r2', agent: 'cursor' }),
|
|
118
|
+
].join('\n')
|
|
119
|
+
const r = await tools.get('numbat_ingest').handler({ ndjson: nd })
|
|
120
|
+
eq(r.ingested, 2, 'ingested count')
|
|
121
|
+
eq(emitted.length, 2, 'emitted events')
|
|
122
|
+
eq(emitted[0].source, 'numbat', 'event source')
|
|
123
|
+
eq(emitted[0].ruleId, 'cloud-metadata', 'event ruleId')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ─── trigger match ──────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
test('numbat_finding trigger matches numbat-source medium+ severity', () => {
|
|
129
|
+
const { triggers, ctx } = mkCtx()
|
|
130
|
+
register(ctx)
|
|
131
|
+
const t = triggers[0]
|
|
132
|
+
assert(t.match({ source: 'numbat', severity: 'high' }), 'matches high')
|
|
133
|
+
assert(t.match({ source: 'numbat', severity: 'medium' }), 'matches medium')
|
|
134
|
+
assert(!t.match({ source: 'numbat', severity: 'low' }), 'rejects low')
|
|
135
|
+
assert(!t.match({ source: 'other', severity: 'high' }), 'rejects other source')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// ─── runner ─────────────────────────────────────────────────────────────────
|
|
139
|
+
async function main() {
|
|
140
|
+
let pass = 0, fail = 0
|
|
141
|
+
for (const c of cases) {
|
|
142
|
+
try { await c.run(); pass++; console.log(`PASS ${c.name}`) }
|
|
143
|
+
catch (e) { fail++; console.log(`FAIL ${c.name}: ${e?.message ?? e}`) }
|
|
144
|
+
}
|
|
145
|
+
console.log(`\n${pass}/${pass + fail} passed, ${fail} failed`)
|
|
146
|
+
if (fail > 0) process.exit(1)
|
|
147
|
+
}
|
|
148
|
+
main()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "numbat-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Numbat bridge for RTerm — deploy Numbat (endpoint AI-agent detection/EDR) to hosts, ingest its findings (NDJSON events/findings/enforcement/indicators), and fire RTerm triggers to auto-remediate (isolate host, kill agent, open incident, run playbook). Numbat detects; RTerm responds. Config in Settings (numbat block).",
|
|
5
|
+
"entry": "index.mjs",
|
|
6
|
+
"tools": [
|
|
7
|
+
"numbat_health",
|
|
8
|
+
"numbat_deploy",
|
|
9
|
+
"numbat_ingest",
|
|
10
|
+
"numbat_findings_summary"
|
|
11
|
+
],
|
|
12
|
+
"triggers": [
|
|
13
|
+
"numbat_finding"
|
|
14
|
+
],
|
|
15
|
+
"panels": [
|
|
16
|
+
"numbat-findings"
|
|
17
|
+
],
|
|
18
|
+
"permissions": [
|
|
19
|
+
"exec"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* synapse-bridge — RTerm ↔ Synapse mesh interop.
|
|
3
|
+
*
|
|
4
|
+
* Lets RTerm speak the Synapse protocol (v0.3.0) over a shared NATS server:
|
|
5
|
+
* discover live mesh agents, dispatch tasks to them, and register RTerm itself
|
|
6
|
+
* as a mesh agent (bidirectional federation). Built on the same NatsEventBus
|
|
7
|
+
* conventions (auth, request/reply, JetStream) added in v3.1.2.
|
|
8
|
+
*
|
|
9
|
+
* Config (settings.synapse, or env):
|
|
10
|
+
* url — NATS server (default nats://localhost:4222)
|
|
11
|
+
* servers — array of urls (takes precedence)
|
|
12
|
+
* prefix — mesh subject prefix (default "mesh")
|
|
13
|
+
* agentId — this instance's mesh agent id (default "rterm-001")
|
|
14
|
+
* auth — { token | username/password | nkeySeed | jwt/jwtSeed | creds | tls* }
|
|
15
|
+
* enabled — master switch (default true when a server is configured)
|
|
16
|
+
*
|
|
17
|
+
* Secrets may be inline or `secretRef` pointers resolved via the vault.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createRequire } from 'node:module'
|
|
21
|
+
import { randomUUID } from 'node:crypto'
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url)
|
|
24
|
+
const enc = new TextEncoder()
|
|
25
|
+
const dec = new TextDecoder()
|
|
26
|
+
const j = (v) => enc.encode(JSON.stringify(v))
|
|
27
|
+
const uj = (b) => JSON.parse(dec.decode(b))
|
|
28
|
+
|
|
29
|
+
// ─── config resolution ──────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export function resolveConfig(ctx = {}, env = process.env) {
|
|
32
|
+
const s = (typeof ctx.getSettings === 'function' ? ctx.getSettings() : ctx.settings) || {}
|
|
33
|
+
const block = s.synapse || {}
|
|
34
|
+
const servers = Array.isArray(block.servers) && block.servers.length > 0
|
|
35
|
+
? block.servers
|
|
36
|
+
: (block.url || env.SYNAPSE_NATS_URL || env.NATS_URL || 'nats://localhost:4222')
|
|
37
|
+
return {
|
|
38
|
+
servers,
|
|
39
|
+
prefix: block.prefix || 'mesh',
|
|
40
|
+
agentId: block.agentId || env.SYNAPSE_AGENT_ID || 'rterm-001',
|
|
41
|
+
auth: block.auth || undefined,
|
|
42
|
+
enabled: block.enabled !== false,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve auth secrets through the vault when secretRef-style values are used. */
|
|
47
|
+
function resolveAuth(ctx, auth) {
|
|
48
|
+
if (!auth) return undefined
|
|
49
|
+
const out = { ...auth }
|
|
50
|
+
if (out.passwordSecretRef && typeof ctx.getSecret === 'function') {
|
|
51
|
+
try { out.password = ctx.getSecret(out.passwordSecretRef); delete out.passwordSecretRef } catch { /* leave unset */ }
|
|
52
|
+
}
|
|
53
|
+
if (out.tokenSecretRef && typeof ctx.getSecret === 'function') {
|
|
54
|
+
try { out.token = ctx.getSecret(out.tokenSecretRef); delete out.tokenSecretRef } catch { /* leave unset */ }
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── transport (lazy NATS connection) ───────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function loadTransport() {
|
|
62
|
+
try { return require('@nats-io/transport-node') } catch {
|
|
63
|
+
throw new Error('NATS transport (@nats-io/transport-node) is not available in this build')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildAuthenticator(t, auth) {
|
|
68
|
+
if (!auth) return undefined
|
|
69
|
+
const e = new TextEncoder()
|
|
70
|
+
if (auth.creds) return t.credsAuthenticator(typeof auth.creds === 'string' ? e.encode(auth.creds) : auth.creds)
|
|
71
|
+
if (auth.jwt) return t.jwtAuthenticator(auth.jwt, typeof auth.jwtSeed === 'string' ? e.encode(auth.jwtSeed) : auth.jwtSeed)
|
|
72
|
+
if (auth.nkeySeed) return t.nkeyAuthenticator(typeof auth.nkeySeed === 'string' ? e.encode(auth.nkeySeed) : auth.nkeySeed)
|
|
73
|
+
if (auth.token) return t.tokenAuthenticator(auth.token)
|
|
74
|
+
if (auth.username !== undefined) return t.usernamePasswordAuthenticator(auth.username, auth.password ?? '')
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let _conn = null
|
|
79
|
+
async function connectMesh(ctx) {
|
|
80
|
+
if (_conn && !_conn.isClosed()) return _conn
|
|
81
|
+
const cfg = resolveConfig(ctx)
|
|
82
|
+
const t = loadTransport()
|
|
83
|
+
const auth = buildAuthenticator(t, resolveAuth(ctx, cfg.auth))
|
|
84
|
+
const copts = { servers: cfg.servers, name: cfg.agentId, ...(auth ? { authenticator: auth } : {}) }
|
|
85
|
+
const connectFn = (typeof ctx.natsConnect === 'function') ? ctx.natsConnect : (o) => t.connect(o)
|
|
86
|
+
_conn = await connectFn(copts)
|
|
87
|
+
return _conn
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Test hook: inject a fake connection. */
|
|
91
|
+
export function __setConnForTest(c) { _conn = c }
|
|
92
|
+
|
|
93
|
+
// ─── Synapse envelope ───────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export function envelope(type, payload, cfg, extra = {}) {
|
|
96
|
+
return {
|
|
97
|
+
v: '0.3.0',
|
|
98
|
+
id: randomUUID(),
|
|
99
|
+
type,
|
|
100
|
+
ts: new Date().toISOString(),
|
|
101
|
+
from: cfg.agentId,
|
|
102
|
+
trace: { trace_id: randomUUID(), span_id: randomUUID() },
|
|
103
|
+
payload,
|
|
104
|
+
...extra,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── core ops ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export async function discoverAgents(ctx, filter = {}) {
|
|
111
|
+
const cfg = resolveConfig(ctx)
|
|
112
|
+
const nc = await connectMesh(ctx)
|
|
113
|
+
const msg = await nc.request(`${cfg.prefix}.registry.discover`, j(envelope('discover', filter, cfg)), { timeout: 4000 })
|
|
114
|
+
const reply = uj(msg.data)
|
|
115
|
+
const agents = Array.isArray(reply) ? reply : (reply.payload?.agents ?? reply.payload ?? reply)
|
|
116
|
+
return Array.isArray(agents) ? agents : []
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function dispatchTask(ctx, target, skill, input = {}, opts = {}) {
|
|
120
|
+
const cfg = resolveConfig(ctx)
|
|
121
|
+
const nc = await connectMesh(ctx)
|
|
122
|
+
const env = envelope('request', { skill, input }, cfg, { to: target, task_id: randomUUID() })
|
|
123
|
+
const msg = await nc.request(`${cfg.prefix}.agent.${target}.inbox`, j(env), { timeout: opts.timeout ?? 30000 })
|
|
124
|
+
return uj(msg.data)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function registerSelf(ctx, manifest = {}) {
|
|
128
|
+
const cfg = resolveConfig(ctx)
|
|
129
|
+
const nc = await connectMesh(ctx)
|
|
130
|
+
const payload = {
|
|
131
|
+
agent_id: cfg.agentId,
|
|
132
|
+
name: manifest.name || 'RTerm / neuralOS',
|
|
133
|
+
type: 'agent',
|
|
134
|
+
capabilities: manifest.capabilities || ['ops-automation', 'playbooks', 'fleet-orchestration', 'mop-changes'],
|
|
135
|
+
skills: manifest.skills || [],
|
|
136
|
+
endpoint: `${cfg.prefix}.agent.${cfg.agentId}.inbox`,
|
|
137
|
+
availability: 'online',
|
|
138
|
+
...manifest,
|
|
139
|
+
}
|
|
140
|
+
nc.publish(`${cfg.prefix}.registry.register`, j(envelope('register', payload, cfg)))
|
|
141
|
+
return { registered: cfg.agentId, endpoint: payload.endpoint }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── plugin registration ────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async function guarded(fn, log) {
|
|
147
|
+
try { return await fn() } catch (e) {
|
|
148
|
+
const msg = e?.message ?? String(e)
|
|
149
|
+
log?.(`[synapse] ${msg}`)
|
|
150
|
+
return { error: msg, hint: 'Is the NATS server running and the synapse block configured? (settings.synapse.url, default nats://localhost:4222).' }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function register(ctx) {
|
|
155
|
+
const { registerTool, registerTrigger, registerPanel, log } = ctx
|
|
156
|
+
const cfg = resolveConfig(ctx)
|
|
157
|
+
|
|
158
|
+
registerTool({
|
|
159
|
+
name: 'synapse_health',
|
|
160
|
+
description: 'Check connectivity to the Synapse mesh (NATS server) and report the configured agent id + subject prefix.',
|
|
161
|
+
params: {},
|
|
162
|
+
handler: async () => guarded(async () => {
|
|
163
|
+
const nc = await connectMesh(ctx)
|
|
164
|
+
return { connected: !nc.isClosed(), agentId: cfg.agentId, prefix: cfg.prefix, servers: cfg.servers }
|
|
165
|
+
}, log),
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
registerTool({
|
|
169
|
+
name: 'synapse_discover',
|
|
170
|
+
description: 'Discover live Synapse mesh agents and their skills via the registry (mesh.registry.discover). Optional filter by capabilities/skill_ids/availability.',
|
|
171
|
+
params: {
|
|
172
|
+
capabilities: { type: 'array', description: 'Capabilities to match (all-of)', optional: true },
|
|
173
|
+
skill_ids: { type: 'array', description: 'Skill ids to match (all-of)', optional: true },
|
|
174
|
+
availability: { type: 'string', description: 'e.g. online', optional: true },
|
|
175
|
+
},
|
|
176
|
+
handler: async (p) => guarded(async () => {
|
|
177
|
+
const filter = {}
|
|
178
|
+
if (p?.capabilities) filter.capabilities = p.capabilities
|
|
179
|
+
if (p?.skill_ids) filter.skill_ids = p.skill_ids
|
|
180
|
+
if (p?.availability) filter.availability = p.availability
|
|
181
|
+
const agents = await discoverAgents(ctx, filter)
|
|
182
|
+
return { count: agents.length, agents }
|
|
183
|
+
}, log),
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
registerTool({
|
|
187
|
+
name: 'synapse_dispatch',
|
|
188
|
+
description: 'Dispatch a task to a Synapse mesh agent (mesh.agent.{id}.inbox) and await its response. The task is durably tracked in the mesh.',
|
|
189
|
+
params: {
|
|
190
|
+
target: { type: 'string', description: 'Target agent id (e.g. grip-cli-001)' },
|
|
191
|
+
skill: { type: 'string', description: 'Skill id from the target manifest' },
|
|
192
|
+
input: { type: 'object', description: 'Input payload for the skill', optional: true },
|
|
193
|
+
timeout: { type: 'number', description: 'Reply timeout ms (default 30000)', optional: true },
|
|
194
|
+
},
|
|
195
|
+
handler: async (p) => guarded(async () => {
|
|
196
|
+
if (!p?.target || !p?.skill) return { error: 'synapse_dispatch needs target and skill' }
|
|
197
|
+
const response = await dispatchTask(ctx, p.target, p.skill, p.input ?? {}, { timeout: p.timeout })
|
|
198
|
+
return { target: p.target, skill: p.skill, response }
|
|
199
|
+
}, log),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
registerTool({
|
|
203
|
+
name: 'synapse_register',
|
|
204
|
+
description: 'Register this RTerm/neuralOS instance as a Synapse mesh agent (mesh.registry.register) so other mesh agents can discover and dispatch to it.',
|
|
205
|
+
params: {
|
|
206
|
+
name: { type: 'string', optional: true },
|
|
207
|
+
capabilities: { type: 'array', optional: true },
|
|
208
|
+
skills: { type: 'array', optional: true },
|
|
209
|
+
},
|
|
210
|
+
handler: async (p) => guarded(async () => registerSelf(ctx, p ?? {}), log),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
registerTool({
|
|
214
|
+
name: 'synapse_agents_summary',
|
|
215
|
+
description: 'Compact summary of live Synapse mesh agents (id, name, skill count, first skills) for quick situational awareness.',
|
|
216
|
+
params: {},
|
|
217
|
+
handler: async () => guarded(async () => {
|
|
218
|
+
const agents = await discoverAgents(ctx, {})
|
|
219
|
+
return {
|
|
220
|
+
count: agents.length,
|
|
221
|
+
agents: agents.map((a) => ({
|
|
222
|
+
id: a.id ?? a.agent_id ?? a.name,
|
|
223
|
+
name: a.name,
|
|
224
|
+
skillCount: (a.skills ?? a.capabilities ?? []).length,
|
|
225
|
+
skills: (a.skills ?? a.capabilities ?? []).slice(0, 5).map((s) => (typeof s === 'string' ? s : s.id ?? s.name)),
|
|
226
|
+
})),
|
|
227
|
+
}
|
|
228
|
+
}, log),
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
registerTrigger({
|
|
232
|
+
name: 'synapse_mesh_event',
|
|
233
|
+
description: 'Fires when a Synapse mesh event (task failure, reputation penalty, approval request) is observed. Use for cross-mesh remediation.',
|
|
234
|
+
match: (event) => event?.source === 'synapse',
|
|
235
|
+
action: 'propose-change',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
registerPanel({
|
|
239
|
+
name: 'synapse-mesh-agents',
|
|
240
|
+
title: 'Synapse Mesh Agents',
|
|
241
|
+
render: (data) => {
|
|
242
|
+
const rows = (Array.isArray(data) ? data : []).map((a) =>
|
|
243
|
+
`<tr><td>${a.id ?? ''}</td><td>${a.name ?? ''}</td><td>${a.skillCount ?? ''}</td></tr>`
|
|
244
|
+
).join('')
|
|
245
|
+
return `<div class="synapse-mesh"><h3>Synapse Mesh Agents</h3><p>Agent: ${cfg.agentId} · Prefix: ${cfg.prefix}</p><table><thead><tr><th>Id</th><th>Name</th><th>Skills</th></tr></thead><tbody>${rows}</tbody></table></div>`
|
|
246
|
+
},
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
log(`[synapse] synapse-bridge registered: 5 tools, 1 trigger, 1 panel (agent=${cfg.agentId}, prefix=${cfg.prefix})`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default { register, resolveConfig, envelope, discoverAgents, dispatchTask, registerSelf }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "synapse-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Synapse mesh bridge for RTerm — discover live Synapse agents, dispatch tasks to them, and register RTerm itself as a mesh agent (bidirectional federation). Speaks the Synapse protocol (v0.3.0) over a shared NATS server using the v3.1.2 auth/request-reply/JetStream transport. Config in Settings (synapse block); secrets via the vault.",
|
|
5
|
+
"entry": "index.mjs",
|
|
6
|
+
"tools": [
|
|
7
|
+
"synapse_health",
|
|
8
|
+
"synapse_discover",
|
|
9
|
+
"synapse_dispatch",
|
|
10
|
+
"synapse_register",
|
|
11
|
+
"synapse_agents_summary"
|
|
12
|
+
],
|
|
13
|
+
"triggers": [
|
|
14
|
+
"synapse_mesh_event"
|
|
15
|
+
],
|
|
16
|
+
"panels": [
|
|
17
|
+
"synapse-mesh-agents"
|
|
18
|
+
],
|
|
19
|
+
"permissions": [
|
|
20
|
+
"exec"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { register, resolveConfig, envelope, discoverAgents, dispatchTask, registerSelf, __setConnForTest } from './index.mjs'
|
|
2
|
+
|
|
3
|
+
const cases = []
|
|
4
|
+
function test(n, r) { cases.push({ name: n, run: r }) }
|
|
5
|
+
function assert(c, m) { if (!c) throw new Error(m ?? 'assertion failed') }
|
|
6
|
+
function eq(a, b, m) { if (a !== b) throw new Error(`${m ?? 'eq'}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`) }
|
|
7
|
+
|
|
8
|
+
// ─── fake NATS connection ───────────────────────────────────────────────────
|
|
9
|
+
function fakeConn() {
|
|
10
|
+
const published = []
|
|
11
|
+
const requests = new Map()
|
|
12
|
+
return {
|
|
13
|
+
isClosed: () => false,
|
|
14
|
+
publish(subject, data) { published.push({ subject, data: JSON.parse(new TextDecoder().decode(data)) }) },
|
|
15
|
+
async request(subject, data, _opts) {
|
|
16
|
+
const h = requests.get(subject)
|
|
17
|
+
const reply = h ? h(JSON.parse(new TextDecoder().decode(data))) : { ok: true }
|
|
18
|
+
return { data: new TextEncoder().encode(JSON.stringify(reply)) }
|
|
19
|
+
},
|
|
20
|
+
_on(subject, h) { requests.set(subject, h) },
|
|
21
|
+
published,
|
|
22
|
+
drain: async () => {},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mkCtx(settings = {}, conn) {
|
|
27
|
+
const tools = new Map(); const triggers = []; const panels = []; const logs = []
|
|
28
|
+
const ctx = {
|
|
29
|
+
settings: { synapse: settings },
|
|
30
|
+
natsConnect: async () => conn,
|
|
31
|
+
registerTool: (t) => tools.set(t.name, t),
|
|
32
|
+
registerTrigger: (t) => triggers.push(t),
|
|
33
|
+
registerPanel: (p) => panels.push(p),
|
|
34
|
+
log: (l) => logs.push(l),
|
|
35
|
+
}
|
|
36
|
+
return { tools, triggers, panels, logs, ctx }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── config ─────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
test('resolveConfig defaults (url, prefix=mesh, agentId=rterm-001)', () => {
|
|
42
|
+
const c = resolveConfig({ settings: {} }, {})
|
|
43
|
+
eq(c.servers, 'nats://localhost:4222', 'default url')
|
|
44
|
+
eq(c.prefix, 'mesh', 'default prefix')
|
|
45
|
+
eq(c.agentId, 'rterm-001', 'default agentId')
|
|
46
|
+
eq(c.enabled, true, 'enabled default')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('resolveConfig reads settings.synapse block', () => {
|
|
50
|
+
const c = resolveConfig({ settings: { synapse: { url: 'nats://h:4222', prefix: 'mesh', agentId: 'rterm-x', auth: { token: 't' } } } }, {})
|
|
51
|
+
eq(c.servers, 'nats://h:4222', 'url from settings')
|
|
52
|
+
eq(c.agentId, 'rterm-x', 'agentId from settings')
|
|
53
|
+
eq(c.auth.token, 't', 'auth token')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// ─── envelope ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
test('envelope has Synapse v0.3.0 shape', () => {
|
|
59
|
+
const cfg = { agentId: 'rterm-001' }
|
|
60
|
+
const e = envelope('discover', { capabilities: [] }, cfg)
|
|
61
|
+
eq(e.v, '0.3.0', 'protocol version')
|
|
62
|
+
eq(e.type, 'discover', 'type')
|
|
63
|
+
eq(e.from, 'rterm-001', 'from = agentId')
|
|
64
|
+
assert(e.id, 'has id')
|
|
65
|
+
assert(e.ts, 'has ts')
|
|
66
|
+
assert(e.trace?.trace_id && e.trace?.span_id, 'has trace context')
|
|
67
|
+
assert(e.payload, 'has payload')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ─── register wiring ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
test('register wires 5 tools, 1 trigger, 1 panel', () => {
|
|
73
|
+
const conn = fakeConn()
|
|
74
|
+
const { tools, triggers, panels, ctx } = mkCtx({}, conn)
|
|
75
|
+
register(ctx)
|
|
76
|
+
eq(tools.size, 5, 'tool count')
|
|
77
|
+
for (const n of ['synapse_health', 'synapse_discover', 'synapse_dispatch', 'synapse_register', 'synapse_agents_summary']) assert(tools.has(n), `missing ${n}`)
|
|
78
|
+
eq(triggers.length, 1, 'trigger count')
|
|
79
|
+
eq(triggers[0].name, 'synapse_mesh_event', 'trigger name')
|
|
80
|
+
eq(panels.length, 1, 'panel count')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// ─── discover ───────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
test('synapse_discover returns agents from registry', async () => {
|
|
86
|
+
const conn = fakeConn()
|
|
87
|
+
conn._on('mesh.registry.discover', () => [
|
|
88
|
+
{ id: 'grip-cli-001', name: 'Grip CLI', skills: [{ id: 'himalaya' }] },
|
|
89
|
+
{ id: 'agentspan-001', name: 'Agentspan', skills: [{ id: 'status' }] },
|
|
90
|
+
])
|
|
91
|
+
const { tools, ctx } = mkCtx({}, conn)
|
|
92
|
+
register(ctx)
|
|
93
|
+
const r = await tools.get('synapse_discover').handler({})
|
|
94
|
+
eq(r.count, 2, 'agent count')
|
|
95
|
+
eq(r.agents[0].id, 'grip-cli-001', 'first agent')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('synapse_discover passes filter through to the envelope', async () => {
|
|
99
|
+
__setConnForTest(null)
|
|
100
|
+
const conn = fakeConn()
|
|
101
|
+
let captured
|
|
102
|
+
conn._on('mesh.registry.discover', (env) => { captured = env; return [] })
|
|
103
|
+
const { tools, ctx } = mkCtx({}, conn)
|
|
104
|
+
register(ctx)
|
|
105
|
+
await tools.get('synapse_discover').handler({ capabilities: ['chat'], availability: 'online' })
|
|
106
|
+
eq(captured.type, 'discover', 'envelope type')
|
|
107
|
+
eq(captured.payload.availability, 'online', 'filter availability')
|
|
108
|
+
assert(Array.isArray(captured.payload.capabilities), 'filter capabilities')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ─── dispatch ───────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
test('synapse_dispatch sends request to agent inbox + returns response', async () => {
|
|
114
|
+
__setConnForTest(null)
|
|
115
|
+
const conn = fakeConn()
|
|
116
|
+
let captured
|
|
117
|
+
conn._on('mesh.agent.grip-001.inbox', (env) => { captured = env; return { stream: 'AGENT_INBOXES', seq: 42 } })
|
|
118
|
+
const { tools, ctx } = mkCtx({}, conn)
|
|
119
|
+
register(ctx)
|
|
120
|
+
const r = await tools.get('synapse_dispatch').handler({ target: 'grip-001', skill: 'respond', input: { text: 'hi' } })
|
|
121
|
+
eq(r.response.seq, 42, 'response seq')
|
|
122
|
+
eq(captured.type, 'request', 'envelope type')
|
|
123
|
+
eq(captured.to, 'grip-001', 'envelope to')
|
|
124
|
+
eq(captured.payload.skill, 'respond', 'skill')
|
|
125
|
+
eq(captured.payload.input.text, 'hi', 'input')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('synapse_dispatch requires target + skill', async () => {
|
|
129
|
+
const conn = fakeConn()
|
|
130
|
+
const { tools, ctx } = mkCtx({}, conn)
|
|
131
|
+
register(ctx)
|
|
132
|
+
const r = await tools.get('synapse_dispatch').handler({})
|
|
133
|
+
assert(r.error, 'expected error for missing target/skill')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// ─── register self ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
test('synapse_register publishes a register envelope to the registry', async () => {
|
|
139
|
+
__setConnForTest(null)
|
|
140
|
+
const conn = fakeConn()
|
|
141
|
+
const { tools, ctx } = mkCtx({ agentId: 'rterm-001' }, conn)
|
|
142
|
+
register(ctx)
|
|
143
|
+
const r = await tools.get('synapse_register').handler({ name: 'RTerm', capabilities: ['ops'] })
|
|
144
|
+
eq(r.registered, 'rterm-001', 'registered id')
|
|
145
|
+
const pub = conn.published.find((p) => p.subject === 'mesh.registry.register')
|
|
146
|
+
assert(pub, 'expected a register publish')
|
|
147
|
+
eq(pub.data.type, 'register', 'envelope type')
|
|
148
|
+
eq(pub.data.payload.agent_id, 'rterm-001', 'payload agent_id')
|
|
149
|
+
assert(pub.data.payload.endpoint.includes('rterm-001.inbox'), 'endpoint inbox')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// ─── trigger match ──────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
test('synapse_mesh_event trigger matches only synapse-source events', () => {
|
|
155
|
+
const conn = fakeConn()
|
|
156
|
+
const { triggers, ctx } = mkCtx({}, conn)
|
|
157
|
+
register(ctx)
|
|
158
|
+
const t = triggers[0]
|
|
159
|
+
assert(t.match({ source: 'synapse' }), 'matches synapse source')
|
|
160
|
+
assert(!t.match({ source: 'other' }), 'rejects other source')
|
|
161
|
+
assert(!t.match({}), 'rejects empty')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// ─── runner ─────────────────────────────────────────────────────────────────
|
|
165
|
+
async function main() {
|
|
166
|
+
let pass = 0, fail = 0
|
|
167
|
+
for (const c of cases) {
|
|
168
|
+
try { await c.run(); pass++; console.log(`PASS ${c.name}`) }
|
|
169
|
+
catch (e) { fail++; console.log(`FAIL ${c.name}: ${e?.message ?? e}`) }
|
|
170
|
+
}
|
|
171
|
+
console.log(`\n${pass}/${pass + fail} passed, ${fail} failed`)
|
|
172
|
+
if (fail > 0) process.exit(1)
|
|
173
|
+
}
|
|
174
|
+
main()
|