neuralos 3.9.4 → 3.9.6
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/LICENSE +202 -0
- package/README.md +48 -347
- package/cli.js +78 -0
- package/engine.js +124 -0
- package/index.js +160 -0
- package/package.json +37 -43
- package/test.js +20 -0
- package/tools.js +47 -0
- package/bin/gybackend.cjs +0 -398530
- package/plugins/agentspan-bridge/agentspan-bridge.extreme.spec.ts +0 -328
- package/plugins/agentspan-bridge/agentspan-bridge.phase2.extreme.spec.ts +0 -282
- package/plugins/agentspan-bridge/conductorClient.mjs +0 -182
- package/plugins/agentspan-bridge/index.mjs +0 -389
- package/plugins/agentspan-bridge/playbookToWorkflowDef.mjs +0 -209
- package/plugins/agentspan-bridge/plugin.json +0 -27
- package/plugins/fraudops/index.d.mts +0 -9
- package/plugins/fraudops/index.mjs +0 -185
- package/plugins/fraudops/plugin.json +0 -10
- package/plugins/iam-connector/index.d.mts +0 -10
- package/plugins/iam-connector/index.mjs +0 -229
- package/plugins/iam-connector/plugin.json +0 -10
- package/plugins/mitmproxy-bridge/index.d.mts +0 -20
- package/plugins/mitmproxy-bridge/index.mjs +0 -256
- package/plugins/mitmproxy-bridge/plugin.json +0 -17
- package/plugins/monid-bridge/index.mjs +0 -228
- package/plugins/monid-bridge/monid-bridge.extreme.spec.mjs +0 -75
- package/plugins/monid-bridge/plugin.json +0 -15
- package/plugins/netdata-rterm/index.mjs +0 -229
- package/plugins/netdata-rterm/netdata-rterm.extreme.spec.ts +0 -252
- package/plugins/netdata-rterm/plugin.json +0 -22
- package/plugins/netexec-bridge/index.d.mts +0 -28
- package/plugins/netexec-bridge/index.mjs +0 -325
- package/plugins/netexec-bridge/plugin.json +0 -20
- package/plugins/neuralos/index.mjs +0 -137
- package/plugins/neuralos/neuralos.extreme.spec.mts +0 -386
- package/plugins/neuralos/neuralosEngine.mjs +0 -369
- package/plugins/neuralos/plugin.json +0 -15
- package/plugins/numbat-bridge/index.mjs +0 -229
- package/plugins/numbat-bridge/numbat-bridge.extreme.spec.mjs +0 -148
- package/plugins/numbat-bridge/plugin.json +0 -21
- package/plugins/patch-manager/index.d.mts +0 -10
- package/plugins/patch-manager/index.mjs +0 -288
- package/plugins/patch-manager/plugin.json +0 -10
- package/plugins/promptfoo-redteam/index.d.mts +0 -15
- package/plugins/promptfoo-redteam/index.mjs +0 -277
- package/plugins/promptfoo-redteam/plugin.json +0 -16
- package/plugins/reactorpro-bridge/index.mjs +0 -499
- package/plugins/reactorpro-bridge/plugin.json +0 -26
- package/plugins/reactorpro-bridge/reactorpro-bridge.extreme.spec.mts +0 -409
- package/plugins/reactorpro-bridge/reactorproAgent.mjs +0 -399
- package/plugins/request-router/index.d.mts +0 -9
- package/plugins/request-router/index.mjs +0 -239
- package/plugins/request-router/plugin.json +0 -10
- package/plugins/sample-k8s-slo/index.ts +0 -58
- package/plugins/sample-k8s-slo/plugin.json +0 -11
- package/plugins/sop-assistant/index.d.mts +0 -9
- package/plugins/sop-assistant/index.mjs +0 -325
- package/plugins/sop-assistant/plugin.json +0 -10
- package/plugins/synapse-bridge/index.mjs +0 -484
- package/plugins/synapse-bridge/plugin.json +0 -29
- package/plugins/synapse-bridge/synapse-bridge.extreme.spec.mjs +0 -204
- package/plugins/synapse-bridge/synapseAgent.extreme.spec.mjs +0 -236
- package/plugins/synapse-bridge/synapseAgent.mjs +0 -254
- package/plugins/web-intel/index.mjs +0 -381
- package/plugins/web-intel/plugin.json +0 -28
- package/plugins/web-intel/sidecar.mjs +0 -134
- package/plugins/web-intel/web-intel.extreme.spec.ts +0 -263
- package/plugins/web-intel/wigoloClient.mjs +0 -145
|
@@ -1,399 +0,0 @@
|
|
|
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
|
-
// `invoke` routes a prompt into a REAL RTerm agent turn (tools, policy,
|
|
228
|
-
// audit all apply) and returns the final assistant text. Requires the
|
|
229
|
-
// runAgentTask PluginContext hook — present on the daemon and desktop,
|
|
230
|
-
// absent in bare test harnesses. When absent, return a clear error
|
|
231
|
-
// instead of pretending: a peer must know invoke is unavailable here.
|
|
232
|
-
invoke: async (input = {}) => {
|
|
233
|
-
const runner = deps.runAgentTask
|
|
234
|
-
if (typeof runner !== 'function') {
|
|
235
|
-
return { error: { code: 3002, message: 'INVOKE_UNAVAILABLE: this runtime does not expose agent turns (runAgentTask hook absent)', retryable: false } }
|
|
236
|
-
}
|
|
237
|
-
const prompt = input?.arguments?.prompt
|
|
238
|
-
?? input?.prompt
|
|
239
|
-
?? input?.text
|
|
240
|
-
?? input?.message
|
|
241
|
-
?? (typeof input === 'string' ? input : '')
|
|
242
|
-
if (!prompt || !String(prompt).trim()) {
|
|
243
|
-
return { error: { code: 3003, message: 'INVOKE_NEEDS_PROMPT: pass arguments.prompt (or text)', retryable: false } }
|
|
244
|
-
}
|
|
245
|
-
const conversationId = typeof input?.conversation_id === 'string' ? input.conversation_id : undefined
|
|
246
|
-
const result = await runner(String(prompt), {
|
|
247
|
-
sessionId: conversationId,
|
|
248
|
-
// Stay under the mesh dispatch budget (180s default) so the reply
|
|
249
|
-
// reaches the caller before its own timeout fires.
|
|
250
|
-
timeoutMs: Math.max(5_000, (cfg.dispatchTimeout ?? 180_000) - 10_000),
|
|
251
|
-
})
|
|
252
|
-
if (!result?.ok) {
|
|
253
|
-
return { error: { code: 5001, message: result?.error || 'agent turn produced no answer', retryable: true } }
|
|
254
|
-
}
|
|
255
|
-
return {
|
|
256
|
-
output: result.answer,
|
|
257
|
-
conversation_id: result.sessionId,
|
|
258
|
-
}
|
|
259
|
-
},
|
|
260
|
-
...(deps.skills ?? {}),
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** Start the responder loop: subscribe to mesh.agent.<id>.inbox (core NATS),
|
|
265
|
-
* execute each request's skill, and respond on the reply subject. Returns a
|
|
266
|
-
* stop function. Never JetStream over the inbox — see §inbox-streaming. */
|
|
267
|
-
export async function startResponder(nc, cfg, ctx, log = () => {}) {
|
|
268
|
-
const inbox = `${cfg.prefix}.agent.${cfg.agentId}.inbox`
|
|
269
|
-
const sub = nc.subscribe(inbox)
|
|
270
|
-
let stopped = false
|
|
271
|
-
const loop = (async () => {
|
|
272
|
-
for await (const msg of sub) {
|
|
273
|
-
if (stopped) break
|
|
274
|
-
;(async () => {
|
|
275
|
-
try {
|
|
276
|
-
if (isPublishAck(msg.data)) return // skip ack-shaped messages
|
|
277
|
-
const req = uj(msg.data)
|
|
278
|
-
const skillId = req?.payload?.skill
|
|
279
|
-
const input = req?.payload?.input
|
|
280
|
-
log(`[reactorpro] inbound request from ${req?.from} skill=${skillId} task=${req?.task_id}`)
|
|
281
|
-
const result = await executeSkill(skillId, input, ctx)
|
|
282
|
-
const respond = buildRespond(req, cfg, result)
|
|
283
|
-
const signed = ctx.identity ? signEnvelope(respond, ctx.identity) : respond
|
|
284
|
-
const replySubject = req?.payload?.reply_to || msg.reply
|
|
285
|
-
if (replySubject && typeof nc.publish === 'function') nc.publish(replySubject, j(signed))
|
|
286
|
-
else if (typeof msg.respond === 'function') msg.respond(j(signed))
|
|
287
|
-
} catch (e) {
|
|
288
|
-
try {
|
|
289
|
-
const errEnv = envelope('respond', { error: { code: 5000, message: String(e?.message ?? e), retryable: true } }, cfg)
|
|
290
|
-
const signed = ctx.identity ? signEnvelope(errEnv, ctx.identity) : errEnv
|
|
291
|
-
if (typeof msg.respond === 'function') msg.respond(j(signed))
|
|
292
|
-
} catch { /* best-effort */ }
|
|
293
|
-
}
|
|
294
|
-
})()
|
|
295
|
-
}
|
|
296
|
-
})()
|
|
297
|
-
loop.catch(() => {})
|
|
298
|
-
return () => { stopped = true; try { sub.unsubscribe() } catch { /* best-effort */ } }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Map a ReactorPro skill id to an RTerm handler. Returns {output} or {error}. */
|
|
302
|
-
export async function executeSkill(skillId, input, ctx) {
|
|
303
|
-
const skills = (typeof ctx.getSkills === 'function' ? ctx.getSkills() : ctx.skills) || {}
|
|
304
|
-
const handler = skills[skillId]
|
|
305
|
-
if (!handler) {
|
|
306
|
-
return { error: { code: 3001, message: `SKILL_NOT_FOUND: ${skillId}`, retryable: false } }
|
|
307
|
-
}
|
|
308
|
-
try {
|
|
309
|
-
const output = await handler(input ?? {}, ctx)
|
|
310
|
-
return { output: output ?? {} }
|
|
311
|
-
} catch (e) {
|
|
312
|
-
return { error: { code: 5000, message: String(e?.message ?? e), retryable: true } }
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ─── 4. HEARTBEAT ────────────────────────────────────────────────────────────
|
|
317
|
-
|
|
318
|
-
/** Publish a signed heartbeat (liveness + id-collision detection). */
|
|
319
|
-
export function heartbeat(nc, cfg, identity) {
|
|
320
|
-
const env = envelope('heartbeat', { ts: new Date().toISOString() }, cfg)
|
|
321
|
-
nc.publish(`${cfg.prefix}.heartbeat.${cfg.agentId}`, j(signEnvelope(env, identity)))
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** Start a periodic signed heartbeat. Returns a stop function. */
|
|
325
|
-
export function startHeartbeat(nc, cfg, identity, intervalMs = 30000, log = () => {}) {
|
|
326
|
-
try { heartbeat(nc, cfg, identity) } catch (e) { log(`[reactorpro] heartbeat failed: ${e?.message ?? e}`) }
|
|
327
|
-
const t = setInterval(() => {
|
|
328
|
-
try { heartbeat(nc, cfg, identity) } catch (e) { log(`[reactorpro] heartbeat failed: ${e?.message ?? e}`) }
|
|
329
|
-
}, intervalMs)
|
|
330
|
-
return () => clearInterval(t)
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// ─── 5. REGISTRY ─────────────────────────────────────────────────────────────
|
|
334
|
-
|
|
335
|
-
/** The manifest this RTerm advertises.
|
|
336
|
-
*
|
|
337
|
-
* SHAPE CONTRACT (mirrors the gateway's Manifest struct, envelope.go:146):
|
|
338
|
-
* id — json:"id" (NOT "identity"; manifestMatches drops a
|
|
339
|
-
* manifest whose ID is empty, so the old `identity` key made
|
|
340
|
-
* this peer invisible to gateway discovery)
|
|
341
|
-
* skills — []{id, name?, description?} objects, NOT bare strings
|
|
342
|
-
* (the gateway's Skill struct unmarshals objects)
|
|
343
|
-
* fingerprint — json:"fingerprint,omitempty" — pins trust-on-first-use
|
|
344
|
-
* The register/discover payloads are the BARE manifest (the gateway's
|
|
345
|
-
* Register/handleDiscoverRequest both attachPayload(manifest) directly), so
|
|
346
|
-
* callers must not nest it under a "manifest" key. */
|
|
347
|
-
export function buildManifest(cfg, skillIds) {
|
|
348
|
-
const skills = (skillIds ?? []).map((s) =>
|
|
349
|
-
typeof s === 'string' ? { id: s, name: s } : s)
|
|
350
|
-
return {
|
|
351
|
-
id: cfg.agentId,
|
|
352
|
-
name: cfg.name ?? cfg.agentId,
|
|
353
|
-
capabilities: ['rterm', 'agent', ...(cfg.capabilities ?? [])],
|
|
354
|
-
skills,
|
|
355
|
-
fingerprint: cfg.fingerprint ?? null,
|
|
356
|
-
local_agents: [{ id: cfg.agentId, name: cfg.name ?? cfg.agentId, capabilities: ['rterm', 'agent'] }],
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** Register with the mesh registry (mesh.registry.register).
|
|
361
|
-
* Payload is the BARE manifest — the gateway's Register does
|
|
362
|
-
* attachPayload(envelope, a.Manifest()) with no wrapper object. */
|
|
363
|
-
export function registerManifest(nc, cfg, identity, manifest) {
|
|
364
|
-
const env = envelope('register', manifest, cfg, { to: 'REGISTRY' })
|
|
365
|
-
nc.publish(`${cfg.prefix}.registry.register`, j(signEnvelope(env, identity)))
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** Graceful exit. */
|
|
369
|
-
export function deregisterManifest(nc, cfg, identity) {
|
|
370
|
-
const env = envelope('register', { id: cfg.agentId, gone: true }, cfg, { to: 'REGISTRY' })
|
|
371
|
-
nc.publish(`${cfg.prefix}.registry.deregister`, j(signEnvelope(env, identity)))
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/** Answer discovery broadcasts with our manifest.
|
|
375
|
-
* The reply payload is the BARE manifest — the gateway's
|
|
376
|
-
* handleDiscoverRequest does attachPayload(reply, manifest), and its
|
|
377
|
-
* manifestsFrom accepts either {agents:[...]} or a bare manifest with
|
|
378
|
-
* id != "". Nesting under "manifest" would make it invisible. */
|
|
379
|
-
export async function answerDiscovery(nc, cfg, identity, manifest, log = () => {}) {
|
|
380
|
-
const subject = `${cfg.prefix}.registry.discover`
|
|
381
|
-
const sub = nc.subscribe(subject)
|
|
382
|
-
let stopped = false
|
|
383
|
-
const loop = (async () => {
|
|
384
|
-
for await (const msg of sub) {
|
|
385
|
-
if (stopped) break
|
|
386
|
-
try {
|
|
387
|
-
if (isPublishAck(msg.data)) continue
|
|
388
|
-
const req = uj(msg.data)
|
|
389
|
-
if (req?.from === cfg.agentId) continue // don't answer ourselves
|
|
390
|
-
if (msg.reply) {
|
|
391
|
-
const env = envelope('respond', manifest, cfg, { to: req?.from, in_reply_to: req?.id })
|
|
392
|
-
nc.publish(msg.reply, j(signEnvelope(env, identity)))
|
|
393
|
-
}
|
|
394
|
-
} catch (e) { log(`[reactorpro] discovery answer failed: ${e?.message ?? e}`) }
|
|
395
|
-
}
|
|
396
|
-
})()
|
|
397
|
-
loop.catch(() => {})
|
|
398
|
-
return () => { stopped = true; try { sub.unsubscribe() } catch { /* best-effort */ } }
|
|
399
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// request-router plugin type declarations
|
|
2
|
-
export function register(ctx: any): void
|
|
3
|
-
export function classifyRequest(request: any): string
|
|
4
|
-
export function routeRequest(request: any): { route: string; risk: string; reason: string }
|
|
5
|
-
export function buildRequestId(): string
|
|
6
|
-
export function buildApprovalRecord(requestId: string, approvedBy: string, rationale: string, decision: string): any
|
|
7
|
-
export function buildQueueEntry(request: any, requestId: string): any
|
|
8
|
-
export function filterQueue(queue: any[] | null | undefined, filter?: any): any[]
|
|
9
|
-
export default any
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* request-router plugin — automated request handling and approval workflow.
|
|
3
|
-
*
|
|
4
|
-
* Receives operational requests (access, restart, deploy, patch, custom),
|
|
5
|
-
* classifies risk, routes for approval (auto-approve/queue/MOP), executes
|
|
6
|
-
* end-to-end, and audits every step. Formalizes RTerm's existing command
|
|
7
|
-
* policy + MOP approval into a structured request pipeline.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
// --- Pure: classify a request by risk ---
|
|
11
|
-
export function classifyRequest(request) {
|
|
12
|
-
const req = request ?? {}
|
|
13
|
-
const type = String(req.type ?? '').toLowerCase()
|
|
14
|
-
const urgency = String(req.urgency ?? 'low').toLowerCase()
|
|
15
|
-
const target = String(req.target ?? '').toLowerCase()
|
|
16
|
-
|
|
17
|
-
// Destructive operations = high risk
|
|
18
|
-
if (['delete', 'drop', 'purge', 'format', 'destroy'].some((w) => type.includes(w))) return 'high'
|
|
19
|
-
// Production targets = elevated risk
|
|
20
|
-
if (target.includes('prod') || target.includes('production')) {
|
|
21
|
-
if (['restart', 'stop', 'kill', 'deploy', 'patch', 'update'].some((w) => type.includes(w))) return 'high'
|
|
22
|
-
return 'medium'
|
|
23
|
-
}
|
|
24
|
-
// Restart/stop operations = medium risk
|
|
25
|
-
if (['restart', 'stop', 'kill', 'deploy', 'patch', 'update', 'reboot', 'shutdown'].some((w) => type.includes(w))) return 'medium'
|
|
26
|
-
// Access requests = medium risk
|
|
27
|
-
if (['access', 'grant', 'permission', 'role', 'sudo', 'admin'].some((w) => type.includes(w))) return 'medium'
|
|
28
|
-
// Read-only = low risk
|
|
29
|
-
if (['status', 'check', 'list', 'show', 'get', 'describe', 'read'].some((w) => type.includes(w))) return 'low'
|
|
30
|
-
// Default: medium
|
|
31
|
-
return 'medium'
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// --- Pure: route a request based on risk ---
|
|
35
|
-
export function routeRequest(request) {
|
|
36
|
-
const req = request ?? {}
|
|
37
|
-
const risk = classifyRequest(req)
|
|
38
|
-
const urgency = String(req.urgency ?? 'low').toLowerCase()
|
|
39
|
-
|
|
40
|
-
if (risk === 'low') return { route: 'auto_approve', risk, reason: 'low-risk read-only operation' }
|
|
41
|
-
if (risk === 'medium') {
|
|
42
|
-
if (urgency === 'critical' || urgency === 'high') {
|
|
43
|
-
return { route: 'queue', risk, reason: 'medium-risk with high urgency — expedited approval' }
|
|
44
|
-
}
|
|
45
|
-
return { route: 'queue', risk, reason: 'medium-risk operation — requires operator approval' }
|
|
46
|
-
}
|
|
47
|
-
return { route: 'mop', risk, reason: 'high-risk operation — requires MOP change (plan → approve → run)' }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// --- Pure: build a request ID ---
|
|
51
|
-
export function buildRequestId() {
|
|
52
|
-
return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// --- Pure: build an approval record ---
|
|
56
|
-
export function buildApprovalRecord(requestId, approvedBy, rationale, decision) {
|
|
57
|
-
return {
|
|
58
|
-
requestId,
|
|
59
|
-
approvedBy,
|
|
60
|
-
rationale,
|
|
61
|
-
decision, // approved | denied
|
|
62
|
-
at: Date.now(),
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// --- Pure: build a request queue entry ---
|
|
67
|
-
export function buildQueueEntry(request, requestId) {
|
|
68
|
-
const req = request ?? {}
|
|
69
|
-
const { route, risk, reason } = routeRequest(req)
|
|
70
|
-
return {
|
|
71
|
-
id: requestId,
|
|
72
|
-
type: req.type,
|
|
73
|
-
target: req.target,
|
|
74
|
-
justification: req.justification ?? '',
|
|
75
|
-
urgency: req.urgency ?? 'low',
|
|
76
|
-
risk,
|
|
77
|
-
route,
|
|
78
|
-
routeReason: reason,
|
|
79
|
-
status: route === 'auto_approve' ? 'auto_approved' : 'pending',
|
|
80
|
-
submittedBy: req.submittedBy ?? 'unknown',
|
|
81
|
-
submittedAt: Date.now(),
|
|
82
|
-
...(route === 'auto_approve' ? { approvedAt: Date.now(), approvedBy: 'system' } : {}),
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// --- Pure: filter the request queue ---
|
|
87
|
-
export function filterQueue(queue, filter = {}) {
|
|
88
|
-
let out = Array.isArray(queue) ? [...queue] : []
|
|
89
|
-
const f = filter ?? {}
|
|
90
|
-
if (f.status) out = out.filter((r) => r.status === f.status)
|
|
91
|
-
if (f.risk) out = out.filter((r) => r.risk === f.risk)
|
|
92
|
-
if (f.urgency) out = out.filter((r) => r.urgency === f.urgency)
|
|
93
|
-
if (f.target) out = out.filter((r) => String(r.target ?? '').includes(f.target))
|
|
94
|
-
return out
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// --- Plugin entry ---
|
|
98
|
-
export function register(ctx) {
|
|
99
|
-
const { registerTool, registerTrigger, registerPanel, exec, readLedger, log } = ctx
|
|
100
|
-
const requestQueue = []
|
|
101
|
-
|
|
102
|
-
// Tool: submit_request — submit an operational request
|
|
103
|
-
registerTool({
|
|
104
|
-
name: 'submit_request',
|
|
105
|
-
description: 'Submit an operational request for approval and execution. The request is classified by risk and routed: low-risk → auto-approve, medium-risk → queue for operator approval, high-risk → MOP change.',
|
|
106
|
-
params: {
|
|
107
|
-
type: { type: 'string', description: 'Request type: access, restart, deploy, patch, status, custom' },
|
|
108
|
-
target: { type: 'string', description: 'Target host or service' },
|
|
109
|
-
justification: { type: 'string', description: 'Business justification' },
|
|
110
|
-
urgency: { type: 'string', description: 'low, medium, high, critical' },
|
|
111
|
-
submittedBy: { type: 'string', description: 'Who is submitting the request' },
|
|
112
|
-
},
|
|
113
|
-
handler: async (params) => {
|
|
114
|
-
const requestId = buildRequestId()
|
|
115
|
-
const entry = buildQueueEntry(params, requestId)
|
|
116
|
-
requestQueue.push(entry)
|
|
117
|
-
log(`[request-router] ${entry.id}: ${entry.type} on ${entry.target} → ${entry.route} (${entry.risk} risk)`)
|
|
118
|
-
return { requestId, ...entry }
|
|
119
|
-
},
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
// Tool: approve_request — approve or deny a pending request
|
|
123
|
-
registerTool({
|
|
124
|
-
name: 'approve_request',
|
|
125
|
-
description: 'Approve or deny a pending request. Executes the request if approved. Records the approval in the audit trail.',
|
|
126
|
-
params: {
|
|
127
|
-
requestId: { type: 'string', description: 'Request ID to approve/deny' },
|
|
128
|
-
approvedBy: { type: 'string', description: 'Who is approving' },
|
|
129
|
-
rationale: { type: 'string', description: 'Reason for the decision' },
|
|
130
|
-
decision: { type: 'string', description: 'approved or denied' },
|
|
131
|
-
},
|
|
132
|
-
handler: async (params) => {
|
|
133
|
-
const { requestId, approvedBy, rationale, decision } = params ?? {}
|
|
134
|
-
if (!requestId || !approvedBy) return { error: 'requestId and approvedBy are required' }
|
|
135
|
-
const entry = requestQueue.find((r) => r.id === requestId)
|
|
136
|
-
if (!entry) return { error: `request ${requestId} not found` }
|
|
137
|
-
if (entry.status !== 'pending') return { error: `request ${requestId} is already ${entry.status}` }
|
|
138
|
-
|
|
139
|
-
const approval = buildApprovalRecord(requestId, approvedBy, rationale ?? '', decision ?? 'approved')
|
|
140
|
-
entry.status = approval.decision === 'approved' ? 'approved' : 'denied'
|
|
141
|
-
entry.approvedBy = approvedBy
|
|
142
|
-
entry.approvedAt = approval.at
|
|
143
|
-
entry.rationale = rationale
|
|
144
|
-
|
|
145
|
-
log(`[request-router] ${requestId} ${approval.decision} by ${approvedBy}`)
|
|
146
|
-
|
|
147
|
-
// If approved, execute the request
|
|
148
|
-
if (approval.decision === 'approved') {
|
|
149
|
-
const cmd = buildRequestCommand(entry)
|
|
150
|
-
if (cmd) {
|
|
151
|
-
const output = await exec(cmd, { host: entry.target })
|
|
152
|
-
entry.executionOutput = output.slice(0, 1000)
|
|
153
|
-
entry.executedAt = Date.now()
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return { requestId, ...entry, approval }
|
|
158
|
-
},
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
// Tool: list_requests — list requests in the queue
|
|
162
|
-
registerTool({
|
|
163
|
-
name: 'list_requests',
|
|
164
|
-
description: 'List requests in the queue with optional filters (status, risk, urgency, target).',
|
|
165
|
-
params: {
|
|
166
|
-
status: { type: 'string', description: 'Filter by status: pending, approved, denied, auto_approved' },
|
|
167
|
-
risk: { type: 'string', description: 'Filter by risk: low, medium, high' },
|
|
168
|
-
urgency: { type: 'string', description: 'Filter by urgency' },
|
|
169
|
-
target: { type: 'string', description: 'Filter by target (substring match)' },
|
|
170
|
-
},
|
|
171
|
-
handler: async (params) => {
|
|
172
|
-
const filtered = filterQueue(requestQueue, params ?? {})
|
|
173
|
-
return { total: filtered.length, requests: filtered }
|
|
174
|
-
},
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
// Tool: request_status — get the status of a specific request
|
|
178
|
-
registerTool({
|
|
179
|
-
name: 'request_status',
|
|
180
|
-
description: 'Get the status of a specific request by ID.',
|
|
181
|
-
params: { requestId: { type: 'string', description: 'Request ID' } },
|
|
182
|
-
handler: async (params) => {
|
|
183
|
-
const entry = requestQueue.find((r) => r.id === params?.requestId)
|
|
184
|
-
if (!entry) return { error: `request ${params?.requestId} not found` }
|
|
185
|
-
return entry
|
|
186
|
-
},
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
// Trigger: request_urgent — fires when a high-urgency request is submitted
|
|
190
|
-
registerTrigger({
|
|
191
|
-
name: 'request_urgent',
|
|
192
|
-
description: 'Fires when a request with critical/high urgency is submitted. Use for immediate notification.',
|
|
193
|
-
match: (event) => {
|
|
194
|
-
if (event?.source !== 'request-router') return false
|
|
195
|
-
return event.labels?.urgency === 'critical' || event.labels?.urgency === 'high'
|
|
196
|
-
},
|
|
197
|
-
action: 'run-playbook',
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
// Trigger: request_approved — fires when a request is approved
|
|
201
|
-
registerTrigger({
|
|
202
|
-
name: 'request_approved',
|
|
203
|
-
description: 'Fires when a request is approved. Use for post-approval automation.',
|
|
204
|
-
match: (event) => {
|
|
205
|
-
if (event?.source !== 'request-router') return false
|
|
206
|
-
return event.labels?.status === 'approved'
|
|
207
|
-
},
|
|
208
|
-
action: 'run-playbook',
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
// Panel: request-queue — request queue dashboard
|
|
212
|
-
registerPanel({
|
|
213
|
-
name: 'request-queue',
|
|
214
|
-
title: 'Request Queue',
|
|
215
|
-
render: (data) => {
|
|
216
|
-
const queue = Array.isArray(data) ? data : requestQueue
|
|
217
|
-
const rows = queue.map((r) =>
|
|
218
|
-
`<tr><td>${r.id}</td><td>${r.type}</td><td>${r.target}</td><td>${r.risk}</td><td>${r.urgency}</td><td>${r.status}</td><td>${r.submittedBy ?? ''}</td></tr>`
|
|
219
|
-
).join('')
|
|
220
|
-
return `<div class="request-queue"><h3>Request Queue</h3><p>Total: ${queue.length} | Pending: ${queue.filter((r) => r.status === 'pending').length} | Approved: ${queue.filter((r) => r.status === 'approved').length}</p><table><thead><tr><th>ID</th><th>Type</th><th>Target</th><th>Risk</th><th>Urgency</th><th>Status</th><th>By</th></tr></thead><tbody>${rows}</tbody></table></div>`
|
|
221
|
-
},
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
log('[request-router] registered: 4 tools, 2 triggers, 1 panel')
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// --- Pure: build the command to execute for an approved request ---
|
|
228
|
-
function buildRequestCommand(entry) {
|
|
229
|
-
const type = String(entry.type ?? '').toLowerCase()
|
|
230
|
-
const target = entry.target
|
|
231
|
-
if (type.includes('restart')) return `systemctl restart ${target} 2>/dev/null || Restart-Service ${target} -Force 2>/dev/null`
|
|
232
|
-
if (type.includes('stop')) return `systemctl stop ${target} 2>/dev/null || Stop-Service ${target} -Force 2>/dev/null`
|
|
233
|
-
if (type.includes('status')) return `systemctl status ${target} 2>/dev/null || Get-Service ${target} 2>/dev/null`
|
|
234
|
-
if (type.includes('deploy')) return `echo "deploying to ${target}"`
|
|
235
|
-
if (type.includes('patch')) return `echo "patching ${target}"`
|
|
236
|
-
return null // custom requests don't have a built-in command
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export default { register, classifyRequest, routeRequest, buildRequestId, buildApprovalRecord, buildQueueEntry, filterQueue }
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "request-router",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"description": "Automated request handling and approval workflow for RTerm — receive operational requests, classify risk, route for approval (auto-approve/queue/MOP), execute end-to-end, and audit every step.",
|
|
5
|
-
"entry": "index.mjs",
|
|
6
|
-
"tools": ["submit_request", "approve_request", "list_requests", "request_status"],
|
|
7
|
-
"triggers": ["request_urgent", "request_approved"],
|
|
8
|
-
"panels": ["request-queue"],
|
|
9
|
-
"permissions": ["exec", "readLedger:incidents"]
|
|
10
|
-
}
|