rterm-backend 3.8.3 → 3.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * reactorpro-bridge — wire compatibility with the ReactorPro gateway.
3
+ *
4
+ * This is the contract test for v3.8.5: the plugin must be interoperable
5
+ * with the REAL gateway implementation (crates/agent-gateway/internal/mesh),
6
+ * the way the ReactorPro desktop implements it. Two halves:
7
+ *
8
+ * A. Pure-JS invariants (always run): fingerprint derivation, signing
9
+ * payload construction, envelope sign/verify round-trip, respond/error
10
+ * shapes, identity minting + tamper detection, settings persistence
11
+ * (the v3.1.3 lesson: a settings block missing from pickBackendSnapshot
12
+ * is silently wiped on save).
13
+ *
14
+ * B. Go cross-check (runs when the ReactorPro checkout + Go are present):
15
+ * the plugin signs an envelope, and the GATEWAY's own VerifyEnvelope
16
+ * must accept it; the gateway signs one, and the plugin must accept it.
17
+ * Skipped cleanly when the checkout is absent — CI without the ReactorPro
18
+ * repo still runs part A.
19
+ *
20
+ * Run: npx tsx plugins/reactorpro-bridge/reactorpro-bridge.extreme.spec.mts
21
+ */
22
+ import { execFileSync, spawnSync } from 'node:child_process'
23
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import { join } from 'node:path'
26
+
27
+ import {
28
+ AUTH_KEYS,
29
+ buildRespond,
30
+ checkIdentity,
31
+ envelope,
32
+ fingerprintFor,
33
+ mintIdentity,
34
+ rawPublicKeyFromPem,
35
+ signEnvelope,
36
+ signingPayload,
37
+ verifyEnvelope,
38
+ } from './reactorproAgent.mjs'
39
+ import { loadOrCreateIdentity, resolveConfig } from './index.mjs'
40
+
41
+ let pass = 0
42
+ let fail = 0
43
+ const failures: string[] = []
44
+
45
+ function ok(cond: unknown, label: string, note = ''): void {
46
+ if (cond) {
47
+ pass++
48
+ console.log(`PASS ${label}`)
49
+ } else {
50
+ fail++
51
+ failures.push(label)
52
+ console.log(`FAIL ${label}${note ? ` — ${note}` : ''}`)
53
+ }
54
+ }
55
+
56
+ // ─── A. pure-JS invariants ───────────────────────────────────────────────────
57
+
58
+ // A1. Fingerprint: sha256:<16hex> over "<agent id>\n" + raw SPKI public key.
59
+ {
60
+ const id = mintIdentity('rterm/xcheck/fixture')
61
+ const fp = fingerprintFor('rterm/xcheck/fixture', id.publicKeyPem)
62
+ ok(/^sha256:[0-9a-f]{16}$/.test(fp), 'fingerprint is sha256:<16hex>')
63
+ // The id is inside the digest: same key, different id => different fp.
64
+ const fp2 = fingerprintFor('rterm/xcheck/other', id.publicKeyPem)
65
+ ok(fp !== fp2, 'fingerprint binds the agent id (different id => different fp)')
66
+ // And the raw public key must round-trip from the PEM.
67
+ ok(rawPublicKeyFromPem(id.publicKeyPem).length === 32, 'raw public key is 32 bytes (Ed25519)')
68
+ }
69
+
70
+ // A2. checkIdentity detects tampering.
71
+ {
72
+ const id = mintIdentity('rterm/xcheck/tamper')
73
+ let threw = false
74
+ try {
75
+ // The identity's id field is `identity` — mutating it (e.g. renaming the
76
+ // agent) breaks the fingerprint, which binds id + key.
77
+ checkIdentity({ ...id, identity: 'someone-else' } as never)
78
+ } catch {
79
+ threw = true
80
+ }
81
+ ok(threw, 'checkIdentity rejects an identity whose fingerprint does not match')
82
+
83
+ let threwKey = false
84
+ const other = mintIdentity('rterm/xcheck/tamper')
85
+ try {
86
+ checkIdentity({ ...id, publicKeyPem: other.publicKeyPem } as never)
87
+ } catch {
88
+ threwKey = true
89
+ }
90
+ ok(threwKey, 'checkIdentity rejects a swapped public key')
91
+ }
92
+
93
+ // A3. signEnvelope/verifyEnvelope round-trip, ReactorPro convention.
94
+ {
95
+ const id = mintIdentity('rterm/xcheck/signer')
96
+ const cfg = { agentId: 'rterm/xcheck/signer', prefix: 'mesh', fingerprint: id.fingerprint }
97
+ const env = envelope('request', { skill: 'ping', input: {}, reply_to: '_REPLY.abc.123' }, cfg, { to: 'peer-1' })
98
+ const signed = signEnvelope(env, id)
99
+
100
+ ok(AUTH_KEYS.every((k) => typeof (signed as Record<string, unknown>)[k] === 'string'),
101
+ 'signed envelope carries sig/pub/fp')
102
+ const v = verifyEnvelope(signed)
103
+ ok(v.ok && v.note !== 'unsigned', `plugin verifies its own signature (${v.note})`)
104
+
105
+ // Tamper with the payload -> signature must fail.
106
+ const tampered = { ...(signed as Record<string, unknown>), payload: { skill: 'invoke', input: { evil: true } } }
107
+ const tv = verifyEnvelope(tampered)
108
+ ok(!tv.ok, 'tampered payload fails verification')
109
+
110
+ // Swap the fingerprint -> identity mismatch.
111
+ const fpSwap = { ...(signed as Record<string, unknown>), fp: 'sha256:deadbeefdeadbeef' }
112
+ const fv = verifyEnvelope(fpSwap)
113
+ ok(!fv.ok, 'swapped fingerprint fails verification')
114
+ }
115
+
116
+ // A4. The signing payload covers every meaning-bearing field (gateway order).
117
+ {
118
+ const env = {
119
+ v: '0.3.0', id: 'id-1', type: 'respond', ts: '2026-09-17T00:00:00Z',
120
+ from: 'a', to: 'b', task_id: 't-1', in_reply_to: 'r-1', fp: 'sha256:0123456789abcdef',
121
+ trace: { trace_id: 'tr-1', span_id: 'sp-1' },
122
+ error: { code: 3001, message: 'SKILL_NOT_FOUND', retryable: false },
123
+ payload: { output: { x: 1 } },
124
+ }
125
+ const sp = signingPayload(env as never)
126
+ const text = sp.toString('latin1')
127
+ // Length-prefixed fields in gateway order: v, id, type, ts, from, to, task_id, in_reply_to, fp
128
+ ok(text.startsWith('5:0.3.0\n'), 'signing payload starts with the version field, length-prefixed')
129
+ ok(text.includes('4:id-1\n'), 'signing payload includes id')
130
+ ok(text.includes('7:respond\n'), 'signing payload includes type')
131
+ ok(text.includes('1:a\n'), 'signing payload includes from')
132
+ ok(text.includes('5:tr-1\n') || text.includes('4:tr-1\n'), 'signing payload includes trace_id')
133
+ ok(text.includes('4:3001\n'), 'signing payload includes error code')
134
+ // Absent trace/error contribute their empty positions.
135
+ const bare = signingPayload({ v: '0.3.0', id: 'x', type: 'emit', ts: 't', from: 'f' } as never)
136
+ const bareText = bare.toString('latin1')
137
+ ok(bareText.includes('0:\n0:\n'), 'absent trace/error contribute empty positions')
138
+ }
139
+
140
+ // A5. buildRespond: output XOR error, in_reply_to + task_id correlation.
141
+ {
142
+ const cfg = { agentId: 'a', prefix: 'mesh' }
143
+ const req = { id: 'req-1', from: 'caller', task_id: 'task-9' }
144
+ const r = buildRespond(req as never, cfg, { output: { pong: true } })
145
+ ok(r.in_reply_to === 'req-1' && r.task_id === 'task-9' && r.to === 'caller',
146
+ 'respond correlates in_reply_to/task_id/to')
147
+ ok((r.payload as Record<string, unknown>).output !== undefined, 'respond payload carries output')
148
+
149
+ let threw = false
150
+ try {
151
+ buildRespond(req as never, cfg, { output: { a: 1 }, error: { code: 1, message: 'x', retryable: false } } as never)
152
+ } catch {
153
+ threw = true
154
+ }
155
+ ok(threw, 'respond rejects output AND error together')
156
+ }
157
+
158
+ // A6. Settings persistence — the v3.1.3 lesson.
159
+ // resolveConfig must read the reactorpro block, and the block must survive
160
+ // the migration snapshot (pickBackendSnapshot) — verified by importing the
161
+ // real migrations module.
162
+ {
163
+ const cfg = resolveConfig({
164
+ settings: {
165
+ reactorpro: {
166
+ enabled: true,
167
+ url: 'nats://mesh.example:4222',
168
+ agentId: 'rterm/xcheck/persist',
169
+ prefix: 'mesh',
170
+ identityPath: '/tmp/xcheck-identity.json',
171
+ dispatchTimeout: 240000,
172
+ },
173
+ },
174
+ })
175
+ ok(cfg.agentId === 'rterm/xcheck/persist', 'resolveConfig reads reactorpro.agentId')
176
+ ok(cfg.dispatchTimeout === 240000, 'resolveConfig reads reactorpro.dispatchTimeout')
177
+ ok(cfg.enabled === true, 'resolveConfig reads reactorpro.enabled')
178
+
179
+ // The migration whitelist must carry the block. Import the real module.
180
+ const migrationsPath = join(import.meta.dirname, '..', '..', 'packages', 'backend', 'src', 'services', 'settings', 'migrations.ts')
181
+ if (existsSync(migrationsPath)) {
182
+ const src = readFileSync(migrationsPath, 'utf-8')
183
+ ok(src.includes('reactorpro: raw.reactorpro'),
184
+ 'pickBackendSnapshot carries reactorpro (v3.1.3 lesson: missing keys are silently wiped)')
185
+ ok(src.includes('normalizeReactorProSettings'),
186
+ 'normalizeBackendSettings sanitizes the reactorpro block')
187
+ } else {
188
+ ok(false, 'migrations.ts not found for the persistence check')
189
+ }
190
+ }
191
+
192
+ // A7. Identity persistence: mint once, load forever; 0600; tamper detection.
193
+ {
194
+ const dir = mkdtempSync(join(tmpdir(), 'rp-xcheck-'))
195
+ const identityPath = join(dir, 'identity.json')
196
+ const cfg = { agentId: 'rterm/xcheck/persist-id', identityPath }
197
+ const first = loadOrCreateIdentity(cfg as never)
198
+ const second = loadOrCreateIdentity(cfg as never)
199
+ ok(first.fingerprint === second.fingerprint, 'identity is minted once and reloaded unchanged')
200
+
201
+ // Tamper with the stored identity -> loadOrCreateIdentity must throw.
202
+ const stored = JSON.parse(readFileSync(identityPath, 'utf-8'))
203
+ stored.identity = 'attacker'
204
+ writeFileSync(identityPath, JSON.stringify(stored))
205
+ let threw = false
206
+ try {
207
+ loadOrCreateIdentity(cfg as never)
208
+ } catch {
209
+ threw = true
210
+ }
211
+ ok(threw, 'a tampered identity file is rejected on load')
212
+ }
213
+
214
+ // ─── B. Go cross-check against the REAL gateway ──────────────────────────────
215
+
216
+ const RP_CHECKOUT = process.env.RP_CHECKOUT ?? join(process.env.HOME ?? '', 'work', 'ReactorPro')
217
+ const RP_GW = join(RP_CHECKOUT, 'crates', 'agent-gateway')
218
+
219
+ if (existsSync(join(RP_GW, 'go.mod')) && existsSync('/opt/homebrew/bin/go')) {
220
+ console.log('\n-- Go cross-check against the real gateway --')
221
+ const fixtureDir = mkdtempSync(join(tmpdir(), 'rp-fixtures-'))
222
+
223
+ // B1. Plugin signs -> gateway verifies.
224
+ const id = mintIdentity('rterm/xcheck/go')
225
+ writeFileSync(join(fixtureDir, 'gateway_identity.json'), JSON.stringify({
226
+ identity: 'rterm/xcheck/go',
227
+ privateKeyPem: id.privateKeyPem,
228
+ publicKeyPem: id.publicKeyPem,
229
+ fingerprint: id.fingerprint,
230
+ }, null, 2))
231
+
232
+ const cfg = { agentId: 'rterm/xcheck/go', prefix: 'mesh', fingerprint: id.fingerprint }
233
+ const env = envelope('respond', { output: { cross: true } }, cfg, { to: 'reactorpro/edge-1' })
234
+ const signed = signEnvelope(env, id)
235
+ // COMPACT on purpose. The gateway's Envelope.Payload is a json.RawMessage,
236
+ // which captures the payload bytes EXACTLY as they appear in the file —
237
+ // including any pretty-print indentation. The plugin signs
238
+ // JSON.stringify(payload) (compact) and publishes j(signed) (compact), so a
239
+ // pretty-printed fixture would make the gateway re-hash DIFFERENT bytes than
240
+ // were signed and fail verification even though the real wire exchange is
241
+ // correct. The fixture must be byte-faithful to the wire.
242
+ writeFileSync(join(fixtureDir, 'plugin_signed_envelope.json'), JSON.stringify(signed))
243
+ writeFileSync(join(fixtureDir, 'plugin_signing_payload.hex'),
244
+ signingPayload(signed as never).toString('hex') + '\n')
245
+
246
+ const goTest = spawnSync('/opt/homebrew/bin/go', ['test', './internal/mesh', '-run', 'TestRtermBridgeCrossVerify', '-v', '-count=1'],
247
+ { cwd: RP_GW, env: { ...process.env, RTERM_BRIDGE_FIXTURES: fixtureDir }, encoding: 'utf-8', timeout: 180000 })
248
+
249
+ ok(goTest.status === 0,
250
+ `the REAL gateway verifies the plugin's envelope (go test exit ${goTest.status})`,
251
+ goTest.status !== 0 ? (goTest.stderr || goTest.stdout || '').split('\n').filter((l) => l.includes('FAIL') || l.includes('mismatch')).slice(0, 3).join('; ') : '')
252
+
253
+ // B2. Gateway signs -> plugin verifies.
254
+ const gwEnvPath = join(fixtureDir, 'gateway_signed_envelope.json')
255
+ if (existsSync(gwEnvPath)) {
256
+ const gwEnv = JSON.parse(readFileSync(gwEnvPath, 'utf-8'))
257
+ const v = verifyEnvelope(gwEnv)
258
+ ok(v.ok && v.note !== 'unsigned',
259
+ `the plugin verifies the gateway's envelope (${v.note})`,
260
+ JSON.stringify(v))
261
+ } else {
262
+ ok(false, 'gateway_signed_envelope.json was not produced by the Go test')
263
+ }
264
+ } else {
265
+ console.log('\n-- Go cross-check skipped (ReactorPro checkout or Go not present) --')
266
+ }
267
+
268
+ console.log(`\n${pass} passed, ${fail} failed`)
269
+ if (fail > 0) {
270
+ console.log(`FAILURES:\n${failures.map((f) => ` - ${f}`).join('\n')}`)
271
+ process.exit(1)
272
+ }
273
+ console.log('reactorpro-bridge: ALL TESTS PASSED')
@@ -0,0 +1,347 @@
1
+ /**
2
+ * reactorproAgent — full-duplex ReactorPro mesh citizen for RTerm/neuralOS.
3
+ *
4
+ * Implements the ReactorPro gateway wire protocol exactly as the gateway
5
+ * enforces it (crates/agent-gateway/internal/mesh/):
6
+ *
7
+ * 1. IDENTITY — Ed25519 keypair bound to an agent id; fingerprint
8
+ * sha256:<16hex> over `<agent id>\n` + raw public key bytes. The id is
9
+ * inside the digest, so a key under another id is a different identity.
10
+ * 2. ENVELOPE — v0.3.0 signed JSON with `sig`/`pub`/`fp` (the ReactorPro
11
+ * convention; the legacy Synapse `signature`/`from_identity` fields are
12
+ * ignored by ReactorPro edges and cannot invoke).
13
+ * 3. RESPONDER — serve `mesh.agent.<id>.inbox` (core NATS, NEVER JetStream
14
+ * — a stream over the inbox breaks request/reply), answering skills.
15
+ * 4. HEARTBEAT — signed liveness on `mesh.heartbeat.<id>`; registry entries
16
+ * expire (TTL ≈ 3× heartbeat), so a dead edge stops being advertised.
17
+ * 5. REGISTRY — announce the manifest on `mesh.registry.register` (KV bucket
18
+ * `mesh_registry`) and answer `mesh.registry.discover` broadcasts.
19
+ *
20
+ * Pure functions (signing, fingerprint, envelope) are dependency-free and
21
+ * unit-testable; transport is injected.
22
+ */
23
+
24
+ import { randomUUID, createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as edSign, verify as edVerify } from 'node:crypto'
25
+
26
+ const enc = new TextEncoder()
27
+ const dec = new TextDecoder()
28
+ const j = (v) => enc.encode(JSON.stringify(v))
29
+ const uj = (b) => JSON.parse(dec.decode(b))
30
+
31
+ // ─── 1. IDENTITY ────────────────────────────────────────────────────────────
32
+
33
+ /** Raw 32-byte Ed25519 public key from a PEM SPKI certificate.
34
+ *
35
+ * The gateway's FingerprintFor hashes the RAW key bytes
36
+ * (identity.go: `sha256(agentID + "\n" + publicKey)` where publicKey is
37
+ * ed25519.PublicKey), and VerifyEnvelope calls ed25519.Verify with that raw
38
+ * key. The PEM body is the 44-byte SPKI DER — a 12-byte ASN.1 header
39
+ * (302a300506032b6570032100) followed by the 32-byte key — so the header must
40
+ * be stripped. Base64-decoding the PEM body alone returns all 44 bytes. */
41
+ export function rawPublicKeyFromPem(pem) {
42
+ const keyObject = createPublicKey(pem)
43
+ const spki = keyObject.export({ type: 'spki', format: 'der' })
44
+ if (spki.length < 32) {
45
+ throw new Error(`SPKI DER too short for Ed25519: ${spki.length} bytes`)
46
+ }
47
+ return Buffer.from(spki.subarray(spki.length - 32))
48
+ }
49
+
50
+ /**
51
+ * fingerprint = "sha256:" + hex(sha256(agent_id + "\n" + raw_pubkey))[:16]
52
+ * The id is inside the digest — same key under another id = different fp.
53
+ */
54
+ export function fingerprintFor(agentId, publicKeyPem) {
55
+ const raw = rawPublicKeyFromPem(publicKeyPem)
56
+ const digest = Buffer.concat([enc.encode(agentId + '\n'), raw])
57
+ const hex = createHash('sha256').update(digest).digest('hex')
58
+ return 'sha256:' + hex.slice(0, 16)
59
+ }
60
+
61
+ /** Mint a new gateway-compatible identity: {identity, privateKeyPem,
62
+ * publicKeyPem, fingerprint}. The id is permanent — it is bound into the
63
+ * fingerprint; minting again under the same id is a DIFFERENT identity. */
64
+ export function mintIdentity(agentId) {
65
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
66
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString()
67
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
68
+ return {
69
+ identity: agentId,
70
+ privateKeyPem,
71
+ publicKeyPem,
72
+ fingerprint: fingerprintFor(agentId, publicKeyPem),
73
+ }
74
+ }
75
+
76
+ /** Verify an identity file's internal consistency (id ↔ key ↔ fingerprint). */
77
+ export function checkIdentity(id) {
78
+ const fp = fingerprintFor(id.identity, id.publicKeyPem)
79
+ if (id.fingerprint !== fp) {
80
+ throw new Error('identity fails its own fingerprint check — tampered or wrong format')
81
+ }
82
+ return id
83
+ }
84
+
85
+ // ─── 2. ENVELOPE + SIGNING (ReactorPro convention) ──────────────────────────
86
+
87
+ export const AUTH_KEYS = ['sig', 'pub', 'fp']
88
+
89
+ /** The exact byte string an Ed25519 signature covers — a field-joined,
90
+ * length-prefixed digest, mirrored from the gateway's SigningPayload
91
+ * (internal/mesh/identity.go). Lengths are UTF-8 byte lengths.
92
+ *
93
+ * Fields: v, id, type, ts, from, to, task_id, in_reply_to, fp,
94
+ * [trace_id, span_id], [err_code, err_message, err_retryable],
95
+ * then the raw sha256(payload) digest. Optional groups contribute their
96
+ * empty positions when absent, so "absent" and "empty" cannot collide. */
97
+ export function signingPayload(env) {
98
+ const parts = [
99
+ env.v ?? '', env.id ?? '', env.type ?? '',
100
+ env.ts ?? '', env.from ?? '', env.to ?? '',
101
+ env.task_id ?? '', env.in_reply_to ?? '', env.fp ?? '',
102
+ ]
103
+ const trace = env.trace
104
+ if (trace && typeof trace === 'object') {
105
+ parts.push(trace.trace_id ?? '', trace.span_id ?? '')
106
+ } else {
107
+ parts.push('', '')
108
+ }
109
+ const error = env.error
110
+ if (error && typeof error === 'object') {
111
+ parts.push(String(error.code ?? 0), String(error.message ?? ''), error.retryable ? 'true' : 'false')
112
+ } else {
113
+ parts.push('', '', '')
114
+ }
115
+
116
+ const chunks = []
117
+ for (const part of parts) {
118
+ const raw = enc.encode(String(part))
119
+ chunks.push(enc.encode(`${Buffer.byteLength(raw)}:`), raw, enc.encode('\n'))
120
+ }
121
+ const payload = env.payload
122
+ const payloadBytes = payload === undefined || payload === null
123
+ ? Buffer.alloc(0)
124
+ : Buffer.from(JSON.stringify(payload), 'utf-8')
125
+ chunks.push(createHash('sha256').update(payloadBytes).digest())
126
+ return Buffer.concat(chunks)
127
+ }
128
+
129
+ /** Build an unsigned envelope (register|discover|request|respond|emit|heartbeat). */
130
+ export function envelope(type, payload, cfg, extra = {}) {
131
+ return {
132
+ v: '0.3.0',
133
+ id: randomUUID(),
134
+ type,
135
+ ts: new Date().toISOString(),
136
+ from: cfg.agentId,
137
+ ...(cfg.fingerprint ? { fp: cfg.fingerprint } : {}),
138
+ trace: { trace_id: randomUUID(), span_id: randomUUID() },
139
+ payload,
140
+ ...extra,
141
+ }
142
+ }
143
+
144
+ /** Attach pub/fp/sig the ReactorPro way. The signature covers the fingerprint
145
+ * but not the public key or itself, matching the gateway. */
146
+ export function signEnvelope(env, identity) {
147
+ const signed = {}
148
+ for (const [k, v] of Object.entries(env)) {
149
+ if (!AUTH_KEYS.includes(k)) signed[k] = v
150
+ }
151
+ signed.pub = identity.publicKeyPem
152
+ signed.fp = identity.fingerprint
153
+ const priv = createPrivateKey(identity.privateKeyPem)
154
+ signed.sig = edSign(null, signingPayload(signed), priv).toString('hex')
155
+ return signed
156
+ }
157
+
158
+ /** Verify a ReactorPro-convention signature. { ok: true, note: 'unsigned' }
159
+ * when the envelope carries none — that is a policy decision, not a
160
+ * verification failure.
161
+ *
162
+ * Only sig and pub are stripped before recomputing the payload — the
163
+ * fingerprint is deliberately RETAINED because the gateway's SigningPayload
164
+ * covers it (identity.go: "Fingerprint is deliberately retained: it is part
165
+ * of the signed payload"). Stripping fp here made the verifier's payload
166
+ * differ from the signer's, so every signature failed. */
167
+ export function verifyEnvelope(env) {
168
+ const pubPem = env?.pub ?? ''
169
+ const sigHex = env?.sig ?? ''
170
+ if (!pubPem || !sigHex) return { ok: true, note: 'unsigned' }
171
+ try {
172
+ const unsigned = {}
173
+ for (const [k, v] of Object.entries(env)) {
174
+ if (k === 'sig' || k === 'pub') continue
175
+ unsigned[k] = v
176
+ }
177
+ // Node's edVerify needs a KeyObject — a raw 32-byte buffer throws
178
+ // ERR_OSSL_UNSUPPORTED. The PEM round-trips through createPublicKey.
179
+ const pub = createPublicKey(pubPem)
180
+ const ok = edVerify(null, signingPayload(unsigned), pub, Buffer.from(sigHex, 'hex'))
181
+ if (!ok) return { ok: false, note: 'signature check failed' }
182
+ } catch (e) {
183
+ return { ok: false, note: `signature check failed: ${e?.message ?? e}` }
184
+ }
185
+ const claimed = env?.fp ?? ''
186
+ const proved = fingerprintFor(String(env?.from ?? ''), pubPem)
187
+ if (claimed && claimed !== proved) {
188
+ return { ok: false, note: `fingerprint mismatch: claims ${claimed}, key proves ${proved}` }
189
+ }
190
+ return { ok: true, note: proved }
191
+ }
192
+
193
+ /** A JetStream PubAck ({"stream":…,"seq":…}) rather than a mesh envelope. */
194
+ export function isPublishAck(data) {
195
+ try {
196
+ const o = uj(data)
197
+ return typeof o === 'object' && o !== null && 'stream' in o && 'seq' in o && !('type' in o) && !('id' in o)
198
+ } catch { return false }
199
+ }
200
+
201
+ // ─── 3. RESPONDER (inbox serving, core NATS only) ───────────────────────────
202
+
203
+ /** Build a ReactorPro respond envelope (payload has output XOR error). */
204
+ export function buildRespond(requestEnv, cfg, { output, error } = {}) {
205
+ if (output && error) throw new Error('respond must contain output OR error, not both')
206
+ const payload = error ? { error } : { output: output ?? {} }
207
+ return envelope('respond', payload, cfg, {
208
+ to: requestEnv?.from,
209
+ task_id: requestEnv?.task_id,
210
+ in_reply_to: requestEnv?.id,
211
+ })
212
+ }
213
+
214
+ /** Skills RTerm serves on the mesh by default. The `invoke` skill routes a
215
+ * prompt into a real RTerm agent turn (the same path the desktop uses). */
216
+ export function defaultServeSkills(cfg, deps = {}) {
217
+ return {
218
+ ping: async () => ({ pong: true, ts: new Date().toISOString() }),
219
+ describe: async () => buildManifest(cfg, deps.skills ? Object.keys(deps.skills) : ['ping', 'describe', 'status', 'invoke']),
220
+ status: async () => ({
221
+ agent_id: cfg.agentId,
222
+ fingerprint: cfg.fingerprint ?? null,
223
+ connected: true,
224
+ skills: ['ping', 'describe', 'status', 'invoke'],
225
+ uptime_seconds: Math.floor((Date.now() - (deps.startedAt ?? Date.now())) / 1000),
226
+ }),
227
+ ...(deps.skills ?? {}),
228
+ }
229
+ }
230
+
231
+ /** Start the responder loop: subscribe to mesh.agent.<id>.inbox (core NATS),
232
+ * execute each request's skill, and respond on the reply subject. Returns a
233
+ * stop function. Never JetStream over the inbox — see §inbox-streaming. */
234
+ export async function startResponder(nc, cfg, ctx, log = () => {}) {
235
+ const inbox = `${cfg.prefix}.agent.${cfg.agentId}.inbox`
236
+ const sub = nc.subscribe(inbox)
237
+ let stopped = false
238
+ const loop = (async () => {
239
+ for await (const msg of sub) {
240
+ if (stopped) break
241
+ ;(async () => {
242
+ try {
243
+ if (isPublishAck(msg.data)) return // skip ack-shaped messages
244
+ const req = uj(msg.data)
245
+ const skillId = req?.payload?.skill
246
+ const input = req?.payload?.input
247
+ log(`[reactorpro] inbound request from ${req?.from} skill=${skillId} task=${req?.task_id}`)
248
+ const result = await executeSkill(skillId, input, ctx)
249
+ const respond = buildRespond(req, cfg, result)
250
+ const signed = ctx.identity ? signEnvelope(respond, ctx.identity) : respond
251
+ const replySubject = req?.payload?.reply_to || msg.reply
252
+ if (replySubject && typeof nc.publish === 'function') nc.publish(replySubject, j(signed))
253
+ else if (typeof msg.respond === 'function') msg.respond(j(signed))
254
+ } catch (e) {
255
+ try {
256
+ const errEnv = envelope('respond', { error: { code: 5000, message: String(e?.message ?? e), retryable: true } }, cfg)
257
+ const signed = ctx.identity ? signEnvelope(errEnv, ctx.identity) : errEnv
258
+ if (typeof msg.respond === 'function') msg.respond(j(signed))
259
+ } catch { /* best-effort */ }
260
+ }
261
+ })()
262
+ }
263
+ })()
264
+ loop.catch(() => {})
265
+ return () => { stopped = true; try { sub.unsubscribe() } catch { /* best-effort */ } }
266
+ }
267
+
268
+ /** Map a ReactorPro skill id to an RTerm handler. Returns {output} or {error}. */
269
+ export async function executeSkill(skillId, input, ctx) {
270
+ const skills = (typeof ctx.getSkills === 'function' ? ctx.getSkills() : ctx.skills) || {}
271
+ const handler = skills[skillId]
272
+ if (!handler) {
273
+ return { error: { code: 3001, message: `SKILL_NOT_FOUND: ${skillId}`, retryable: false } }
274
+ }
275
+ try {
276
+ const output = await handler(input ?? {}, ctx)
277
+ return { output: output ?? {} }
278
+ } catch (e) {
279
+ return { error: { code: 5000, message: String(e?.message ?? e), retryable: true } }
280
+ }
281
+ }
282
+
283
+ // ─── 4. HEARTBEAT ────────────────────────────────────────────────────────────
284
+
285
+ /** Publish a signed heartbeat (liveness + id-collision detection). */
286
+ export function heartbeat(nc, cfg, identity) {
287
+ const env = envelope('heartbeat', { ts: new Date().toISOString() }, cfg)
288
+ nc.publish(`${cfg.prefix}.heartbeat.${cfg.agentId}`, j(signEnvelope(env, identity)))
289
+ }
290
+
291
+ /** Start a periodic signed heartbeat. Returns a stop function. */
292
+ export function startHeartbeat(nc, cfg, identity, intervalMs = 30000, log = () => {}) {
293
+ try { heartbeat(nc, cfg, identity) } catch (e) { log(`[reactorpro] heartbeat failed: ${e?.message ?? e}`) }
294
+ const t = setInterval(() => {
295
+ try { heartbeat(nc, cfg, identity) } catch (e) { log(`[reactorpro] heartbeat failed: ${e?.message ?? e}`) }
296
+ }, intervalMs)
297
+ return () => clearInterval(t)
298
+ }
299
+
300
+ // ─── 5. REGISTRY ─────────────────────────────────────────────────────────────
301
+
302
+ /** The manifest this RTerm advertises. */
303
+ export function buildManifest(cfg, skillIds) {
304
+ return {
305
+ identity: cfg.agentId,
306
+ name: cfg.name ?? cfg.agentId,
307
+ capabilities: ['rterm', 'agent', ...(cfg.capabilities ?? [])],
308
+ skills: skillIds,
309
+ fingerprint: cfg.fingerprint ?? null,
310
+ local_agents: [{ id: cfg.agentId, name: cfg.name ?? cfg.agentId, capabilities: ['rterm', 'agent'] }],
311
+ }
312
+ }
313
+
314
+ /** Register with the mesh registry (mesh.registry.register). */
315
+ export function registerManifest(nc, cfg, identity, manifest) {
316
+ const env = envelope('register', { manifest }, cfg, { to: 'REGISTRY' })
317
+ nc.publish(`${cfg.prefix}.registry.register`, j(signEnvelope(env, identity)))
318
+ }
319
+
320
+ /** Graceful exit. */
321
+ export function deregisterManifest(nc, cfg, identity) {
322
+ const env = envelope('register', { manifest: { identity: cfg.agentId, gone: true } }, cfg, { to: 'REGISTRY' })
323
+ nc.publish(`${cfg.prefix}.registry.deregister`, j(signEnvelope(env, identity)))
324
+ }
325
+
326
+ /** Answer discovery broadcasts with our manifest. */
327
+ export async function answerDiscovery(nc, cfg, identity, manifest, log = () => {}) {
328
+ const subject = `${cfg.prefix}.registry.discover`
329
+ const sub = nc.subscribe(subject)
330
+ let stopped = false
331
+ const loop = (async () => {
332
+ for await (const msg of sub) {
333
+ if (stopped) break
334
+ try {
335
+ if (isPublishAck(msg.data)) continue
336
+ const req = uj(msg.data)
337
+ if (req?.from === cfg.agentId) continue // don't answer ourselves
338
+ if (msg.reply) {
339
+ const env = envelope('respond', { manifest }, cfg, { to: req?.from, in_reply_to: req?.id })
340
+ nc.publish(msg.reply, j(signEnvelope(env, identity)))
341
+ }
342
+ } catch (e) { log(`[reactorpro] discovery answer failed: ${e?.message ?? e}`) }
343
+ }
344
+ })()
345
+ loop.catch(() => {})
346
+ return () => { stopped = true; try { sub.unsubscribe() } catch { /* best-effort */ } }
347
+ }