rterm-backend 3.1.3 → 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 CHANGED
@@ -374859,6 +374859,8 @@ function pickBackendSnapshot(raw) {
374859
374859
  agentspan: raw.agentspan,
374860
374860
  webIntel: raw.webIntel,
374861
374861
  nats: raw.nats,
374862
+ synapse: raw.synapse,
374863
+ numbat: raw.numbat,
374862
374864
  gateway: raw.gateway,
374863
374865
  layout: raw.layout,
374864
374866
  recursionLimit: raw.recursionLimit,
@@ -374940,6 +374942,8 @@ function normalizeBackendSettings(settings) {
374940
374942
  next.agentspan = normalizeAgentspanSettings(next.agentspan);
374941
374943
  next.webIntel = normalizeWebIntelSettings(next.webIntel);
374942
374944
  next.nats = normalizeNatsSettings(next.nats);
374945
+ next.synapse = normalizeSynapseSettings(next.synapse);
374946
+ next.numbat = normalizeNumbatSettings(next.numbat);
374943
374947
  next.schemaVersion = BACKEND_SETTINGS_SCHEMA_VERSION;
374944
374948
  return next;
374945
374949
  }
@@ -375158,6 +375162,41 @@ function normalizeNatsSettings(raw) {
375158
375162
  ...hasAuth ? { auth: auth2 } : {}
375159
375163
  };
375160
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
+ }
375161
375200
  function migrateBackendToV3(settings) {
375162
375201
  const next = { ...settings };
375163
375202
  delete next.language;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rterm-backend",
3
- "version": "3.1.3",
4
- "description": "Headless AI-native backend for RTerm / neuralOS — v3.1.3: settings.nats preserved through migration (NATS mesh now configurable). v3.1.2 added the comprehensive NATS event mesh (token/user-pass/NKey/JWT/creds/TLS auth, JetStream, Key-Value, request/reply). Transports (SSH/serial/local) and SQLite install automatically.",
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()