agent-tool-forge 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/lib/agent-registry.js +170 -0
- package/lib/api-client.js +792 -0
- package/lib/api-loader.js +260 -0
- package/lib/auth.d.ts +25 -0
- package/lib/auth.js +158 -0
- package/lib/checks/check-adapter.js +172 -0
- package/lib/checks/compose.js +42 -0
- package/lib/checks/content-match.js +14 -0
- package/lib/checks/cost-budget.js +11 -0
- package/lib/checks/index.js +18 -0
- package/lib/checks/json-valid.js +15 -0
- package/lib/checks/latency.js +11 -0
- package/lib/checks/length-bounds.js +17 -0
- package/lib/checks/negative-match.js +14 -0
- package/lib/checks/no-hallucinated-numbers.js +63 -0
- package/lib/checks/non-empty.js +34 -0
- package/lib/checks/regex-match.js +12 -0
- package/lib/checks/run-checks.js +84 -0
- package/lib/checks/schema-match.js +26 -0
- package/lib/checks/tool-call-count.js +16 -0
- package/lib/checks/tool-selection.js +34 -0
- package/lib/checks/types.js +45 -0
- package/lib/comparison/compare.js +86 -0
- package/lib/comparison/format.js +104 -0
- package/lib/comparison/index.js +6 -0
- package/lib/comparison/statistics.js +59 -0
- package/lib/comparison/types.js +41 -0
- package/lib/config-schema.js +200 -0
- package/lib/config.d.ts +66 -0
- package/lib/conversation-store.d.ts +77 -0
- package/lib/conversation-store.js +443 -0
- package/lib/db.d.ts +6 -0
- package/lib/db.js +1112 -0
- package/lib/dep-check.js +99 -0
- package/lib/drift-background.js +61 -0
- package/lib/drift-monitor.js +187 -0
- package/lib/eval-runner.js +566 -0
- package/lib/fixtures/fixture-store.js +161 -0
- package/lib/fixtures/index.js +11 -0
- package/lib/forge-engine.js +982 -0
- package/lib/forge-eval-generator.js +417 -0
- package/lib/forge-file-writer.js +386 -0
- package/lib/forge-service-client.js +190 -0
- package/lib/forge-service.d.ts +4 -0
- package/lib/forge-service.js +655 -0
- package/lib/forge-verifier-generator.js +271 -0
- package/lib/handlers/admin.js +151 -0
- package/lib/handlers/agents.js +229 -0
- package/lib/handlers/chat-resume.js +334 -0
- package/lib/handlers/chat-sync.js +320 -0
- package/lib/handlers/chat.js +320 -0
- package/lib/handlers/conversations.js +92 -0
- package/lib/handlers/preferences.js +88 -0
- package/lib/handlers/tools-list.js +58 -0
- package/lib/hitl-engine.d.ts +60 -0
- package/lib/hitl-engine.js +261 -0
- package/lib/http-utils.js +92 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +141 -0
- package/lib/init.js +636 -0
- package/lib/manual-entry.js +59 -0
- package/lib/mcp-server.js +252 -0
- package/lib/output-groups.js +54 -0
- package/lib/postgres-store.d.ts +31 -0
- package/lib/postgres-store.js +465 -0
- package/lib/preference-store.d.ts +47 -0
- package/lib/preference-store.js +79 -0
- package/lib/prompt-store.d.ts +42 -0
- package/lib/prompt-store.js +60 -0
- package/lib/rate-limiter.d.ts +30 -0
- package/lib/rate-limiter.js +104 -0
- package/lib/react-engine.d.ts +110 -0
- package/lib/react-engine.js +337 -0
- package/lib/runner/cli.js +156 -0
- package/lib/runner/cost-estimator.js +71 -0
- package/lib/runner/gate.js +46 -0
- package/lib/runner/index.js +165 -0
- package/lib/sidecar.d.ts +83 -0
- package/lib/sidecar.js +161 -0
- package/lib/sse.d.ts +15 -0
- package/lib/sse.js +30 -0
- package/lib/tools-scanner.js +91 -0
- package/lib/tui.js +253 -0
- package/lib/verifier-report.js +78 -0
- package/lib/verifier-runner.js +338 -0
- package/lib/verifier-scanner.js +70 -0
- package/lib/verifier-worker-pool.js +196 -0
- package/lib/views/chat.js +340 -0
- package/lib/views/endpoints.js +203 -0
- package/lib/views/eval-run.js +206 -0
- package/lib/views/forge-agent.js +538 -0
- package/lib/views/forge.js +410 -0
- package/lib/views/main-menu.js +275 -0
- package/lib/views/mediation.js +381 -0
- package/lib/views/model-compare.js +430 -0
- package/lib/views/model-comparison.js +333 -0
- package/lib/views/onboarding.js +470 -0
- package/lib/views/performance.js +237 -0
- package/lib/views/run-evals.js +205 -0
- package/lib/views/settings.js +829 -0
- package/lib/views/tools-evals.js +514 -0
- package/lib/views/verifier-coverage.js +617 -0
- package/lib/workers/verifier-worker.js +52 -0
- package/package.json +123 -0
- package/widget/forge-chat.js +789 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VerifierRunner — post-tool-call checks in the live ReAct loop.
|
|
3
|
+
*
|
|
4
|
+
* Verifier types:
|
|
5
|
+
* schema — JSON Schema validation on result (basic type + required checks)
|
|
6
|
+
* pattern — regex match/reject on result text
|
|
7
|
+
* custom — user-provided function loaded from verifiersDir
|
|
8
|
+
*
|
|
9
|
+
* Returns worst outcome: block > warn > pass.
|
|
10
|
+
* Block short-circuits (stops pipeline immediately).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { insertVerifierResult } from './db.js';
|
|
14
|
+
import { resolve, isAbsolute } from 'path';
|
|
15
|
+
|
|
16
|
+
const OUTCOME_SEVERITY = { pass: 0, warn: 1, block: 2 };
|
|
17
|
+
|
|
18
|
+
export class VerifierRunner {
|
|
19
|
+
/**
|
|
20
|
+
* @param {import('better-sqlite3').Database} db
|
|
21
|
+
* @param {object} config — forge config (used for verifiersDir path + sandbox settings)
|
|
22
|
+
* @param {import('./verifier-worker-pool.js').VerifierWorkerPool} [workerPool] — optional pre-created pool
|
|
23
|
+
*/
|
|
24
|
+
constructor(db, config = {}, workerPool = null) {
|
|
25
|
+
this._db = db;
|
|
26
|
+
this._config = config;
|
|
27
|
+
this._verifiers = new Map(); // toolName → verifier[]
|
|
28
|
+
this._workerPool = workerPool;
|
|
29
|
+
this._ownPool = false; // true if we created the pool internally
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Tear down the worker pool (if we own it).
|
|
34
|
+
* Call this when the sidecar shuts down.
|
|
35
|
+
*/
|
|
36
|
+
destroy() {
|
|
37
|
+
if (this._ownPool && this._workerPool) {
|
|
38
|
+
this._workerPool.destroy();
|
|
39
|
+
this._workerPool = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register verifiers for a tool (manual/programmatic registration).
|
|
45
|
+
* @param {string} toolName
|
|
46
|
+
* @param {Array<{ name: string, type: 'schema'|'pattern'|'custom', spec: object, order?: string }>} verifiers
|
|
47
|
+
*/
|
|
48
|
+
registerVerifiers(toolName, verifiers) {
|
|
49
|
+
this._verifiers.set(toolName, verifiers);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load all enabled verifiers and their bindings from the DB.
|
|
54
|
+
* Builds the internal _verifiers Map keyed by tool_name.
|
|
55
|
+
* Wildcard bindings stored under '*'.
|
|
56
|
+
*
|
|
57
|
+
* For custom type: spec_json = { filePath, exportName }. Runs through worker pool if sandbox enabled.
|
|
58
|
+
* If file missing → register a warn-only stub.
|
|
59
|
+
*
|
|
60
|
+
* @param {import('better-sqlite3').Database} db
|
|
61
|
+
*/
|
|
62
|
+
async loadFromDb(db) {
|
|
63
|
+
const targetDb = db || this._db;
|
|
64
|
+
if (!targetDb) return;
|
|
65
|
+
|
|
66
|
+
// Lazily create worker pool for custom verifiers if sandbox is enabled
|
|
67
|
+
// and no pool was provided to the constructor.
|
|
68
|
+
const sandboxEnabled = this._config?.verification?.sandbox !== false;
|
|
69
|
+
if (sandboxEnabled && !this._workerPool) {
|
|
70
|
+
const { VerifierWorkerPool } = await import('./verifier-worker-pool.js');
|
|
71
|
+
this._workerPool = new VerifierWorkerPool({
|
|
72
|
+
size: this._config?.verification?.workerPoolSize ?? undefined,
|
|
73
|
+
timeoutMs: this._config?.verification?.customTimeout ?? 2000,
|
|
74
|
+
maxQueueDepth: this._config?.verification?.maxQueueDepth ?? 200
|
|
75
|
+
});
|
|
76
|
+
this._ownPool = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Get all enabled verifiers with their bindings
|
|
80
|
+
const allVerifiers = targetDb.prepare(
|
|
81
|
+
'SELECT * FROM verifier_registry WHERE enabled = 1 ORDER BY aciru_order ASC'
|
|
82
|
+
).all();
|
|
83
|
+
|
|
84
|
+
const allBindings = targetDb.prepare(
|
|
85
|
+
'SELECT * FROM verifier_tool_bindings WHERE enabled = 1'
|
|
86
|
+
).all();
|
|
87
|
+
|
|
88
|
+
// Build a map: tool_name → sorted verifier specs
|
|
89
|
+
const toolMap = new Map();
|
|
90
|
+
|
|
91
|
+
for (const binding of allBindings) {
|
|
92
|
+
const verifier = allVerifiers.find(v => v.verifier_name === binding.verifier_name);
|
|
93
|
+
if (!verifier) continue;
|
|
94
|
+
|
|
95
|
+
let spec;
|
|
96
|
+
try {
|
|
97
|
+
spec = JSON.parse(verifier.spec_json);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
process.stderr.write(`[verifier-runner] Skipping verifier "${verifier.verifier_name}": malformed spec_json: ${err.message}\n`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const entry = {
|
|
104
|
+
name: verifier.verifier_name,
|
|
105
|
+
type: verifier.type,
|
|
106
|
+
order: verifier.aciru_order,
|
|
107
|
+
spec
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// For custom verifiers, resolve the path (sandboxed to verifiersDir)
|
|
111
|
+
if (verifier.type === 'custom' && spec.filePath) {
|
|
112
|
+
const verifiersDir = this._config?.verification?.verifiersDir;
|
|
113
|
+
const resolvedPath = isAbsolute(spec.filePath) ? spec.filePath : resolve(spec.filePath);
|
|
114
|
+
if (!verifiersDir || !resolvedPath.startsWith(resolve(verifiersDir))) {
|
|
115
|
+
entry.spec = { fn: () => ({ outcome: 'warn', message: `Custom verifier "${verifier.verifier_name}": path outside verifiersDir` }) };
|
|
116
|
+
const toolName = binding.tool_name;
|
|
117
|
+
if (!toolMap.has(toolName)) toolMap.set(toolName, []);
|
|
118
|
+
toolMap.get(toolName).push(entry);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// If sandbox mode and worker pool available, store path for pool dispatch
|
|
122
|
+
if (this._workerPool) {
|
|
123
|
+
entry.spec = {
|
|
124
|
+
...spec,
|
|
125
|
+
resolvedPath,
|
|
126
|
+
exportName: spec.exportName || 'verify',
|
|
127
|
+
usePool: true
|
|
128
|
+
};
|
|
129
|
+
} else {
|
|
130
|
+
// Sandbox disabled — import directly (dev mode)
|
|
131
|
+
try {
|
|
132
|
+
const mod = await import(resolvedPath);
|
|
133
|
+
const fn = mod[spec.exportName || 'verify'] || mod.default;
|
|
134
|
+
entry.spec = typeof fn === 'function'
|
|
135
|
+
? { ...spec, fn }
|
|
136
|
+
: { fn: () => ({ outcome: 'warn', message: `Custom verifier "${verifier.verifier_name}": no verify function found` }) };
|
|
137
|
+
} catch {
|
|
138
|
+
entry.spec = { fn: () => ({ outcome: 'warn', message: `Custom verifier "${verifier.verifier_name}": file not found or import failed` }) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Store verifier role for pool timeout/crash outcome
|
|
142
|
+
entry.role = verifier.role ?? 'any';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const toolName = binding.tool_name;
|
|
146
|
+
if (!toolMap.has(toolName)) toolMap.set(toolName, []);
|
|
147
|
+
toolMap.get(toolName).push(entry);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Sort each tool's verifiers by order
|
|
151
|
+
for (const [key, verifiers] of toolMap) {
|
|
152
|
+
verifiers.sort((a, b) => a.order.localeCompare(b.order));
|
|
153
|
+
this._verifiers.set(key, verifiers);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run all registered verifiers for a tool against the call result.
|
|
159
|
+
* Merges tool-specific verifiers + wildcard ('*') verifiers.
|
|
160
|
+
* Deduplicates by verifier name. Sorts by ACIRU order.
|
|
161
|
+
* Block short-circuits (returns immediately).
|
|
162
|
+
*
|
|
163
|
+
* @param {string} toolName
|
|
164
|
+
* @param {object} args — tool call input
|
|
165
|
+
* @param {object} result — tool call result ({ status, body, error })
|
|
166
|
+
* @returns {{ outcome: 'pass'|'warn'|'block', message: string|null, verifierName: string|null }}
|
|
167
|
+
*/
|
|
168
|
+
async verify(toolName, args, result) {
|
|
169
|
+
const toolSpecific = this._verifiers.get(toolName) || [];
|
|
170
|
+
const wildcards = this._verifiers.get('*') || [];
|
|
171
|
+
|
|
172
|
+
// Merge and deduplicate by name
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
const merged = [];
|
|
175
|
+
for (const v of [...toolSpecific, ...wildcards]) {
|
|
176
|
+
if (!seen.has(v.name)) {
|
|
177
|
+
seen.add(v.name);
|
|
178
|
+
merged.push(v);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (merged.length === 0) {
|
|
183
|
+
return { outcome: 'pass', message: null, verifierName: null };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Sort by order field if present
|
|
187
|
+
merged.sort((a, b) => (a.order ?? 'Z-9999').localeCompare(b.order ?? 'Z-9999'));
|
|
188
|
+
|
|
189
|
+
let worst = { outcome: 'pass', message: null, verifierName: null };
|
|
190
|
+
|
|
191
|
+
for (const v of merged) {
|
|
192
|
+
let vResult;
|
|
193
|
+
try {
|
|
194
|
+
switch (v.type) {
|
|
195
|
+
case 'schema':
|
|
196
|
+
vResult = runSchemaVerifier(v.spec, result.body);
|
|
197
|
+
break;
|
|
198
|
+
case 'pattern':
|
|
199
|
+
vResult = runPatternVerifier(v.spec, result.body);
|
|
200
|
+
break;
|
|
201
|
+
case 'custom':
|
|
202
|
+
vResult = await runCustomVerifier(v.spec, toolName, args, result, this._workerPool, v.role ?? 'any');
|
|
203
|
+
break;
|
|
204
|
+
default:
|
|
205
|
+
vResult = { outcome: 'pass', message: `Unknown verifier type: ${v.type}` };
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
vResult = { outcome: 'warn', message: `Verifier "${v.name}" threw: ${err.message}` };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Block → short-circuit immediately
|
|
212
|
+
if (vResult.outcome === 'block') {
|
|
213
|
+
return { ...vResult, verifierName: v.name };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!(vResult.outcome in OUTCOME_SEVERITY)) {
|
|
217
|
+
vResult = { outcome: 'warn', message: `Verifier "${v.name}" returned invalid outcome: "${vResult.outcome}"` };
|
|
218
|
+
}
|
|
219
|
+
if (OUTCOME_SEVERITY[vResult.outcome] > OUTCOME_SEVERITY[worst.outcome]) {
|
|
220
|
+
worst = { ...vResult, verifierName: v.name };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return worst;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Log a verifier result to the verifier_results table.
|
|
229
|
+
* @param {string} sessionId
|
|
230
|
+
* @param {string} toolName
|
|
231
|
+
* @param {{ outcome: string, message: string|null, verifierName: string|null }} result
|
|
232
|
+
*/
|
|
233
|
+
logResult(sessionId, toolName, result) {
|
|
234
|
+
if (!this._db) return;
|
|
235
|
+
try {
|
|
236
|
+
insertVerifierResult(this._db, {
|
|
237
|
+
session_id: sessionId,
|
|
238
|
+
tool_name: toolName,
|
|
239
|
+
verifier_name: result.verifierName ?? 'unknown',
|
|
240
|
+
outcome: result.outcome,
|
|
241
|
+
message: result.message ?? null
|
|
242
|
+
});
|
|
243
|
+
} catch { /* log failure is non-fatal */ }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Verifier implementations ─────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Schema verifier — basic type + required field checks.
|
|
251
|
+
* @param {object} schema — { required: string[], properties: { [key]: { type } } }
|
|
252
|
+
* @param {object} body — tool result body
|
|
253
|
+
* @returns {{ outcome: string, message: string|null }}
|
|
254
|
+
*/
|
|
255
|
+
function runSchemaVerifier(schema, body) {
|
|
256
|
+
if (!body || typeof body !== 'object') {
|
|
257
|
+
return { outcome: 'block', message: 'Result is not an object' };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Check required fields
|
|
261
|
+
if (schema.required) {
|
|
262
|
+
for (const field of schema.required) {
|
|
263
|
+
if (!(field in body)) {
|
|
264
|
+
return { outcome: 'block', message: `Missing required field: ${field}` };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Check types
|
|
270
|
+
if (schema.properties) {
|
|
271
|
+
for (const [key, def] of Object.entries(schema.properties)) {
|
|
272
|
+
if (key in body && def.type) {
|
|
273
|
+
const actualType = Array.isArray(body[key]) ? 'array' : typeof body[key];
|
|
274
|
+
if (actualType !== def.type) {
|
|
275
|
+
return { outcome: 'block', message: `Field "${key}" expected type "${def.type}", got "${actualType}"` };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { outcome: 'pass', message: null };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Pattern verifier — regex match/reject on stringified result.
|
|
286
|
+
* @param {{ match?: string, reject?: string, outcome?: string }} spec
|
|
287
|
+
* @param {object} body
|
|
288
|
+
* @returns {{ outcome: string, message: string|null }}
|
|
289
|
+
*/
|
|
290
|
+
function runPatternVerifier(spec, body) {
|
|
291
|
+
const text = typeof body === 'string' ? body : JSON.stringify(body);
|
|
292
|
+
const outcome = spec.outcome ?? 'warn';
|
|
293
|
+
|
|
294
|
+
if (spec.reject) {
|
|
295
|
+
let regex;
|
|
296
|
+
try { regex = new RegExp(spec.reject); } catch (err) {
|
|
297
|
+
return { outcome: 'warn', message: `Invalid reject regex "${spec.reject}": ${err.message}` };
|
|
298
|
+
}
|
|
299
|
+
if (regex.test(text)) {
|
|
300
|
+
return { outcome, message: `Result matches reject pattern: ${spec.reject}` };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (spec.match) {
|
|
305
|
+
let regex;
|
|
306
|
+
try { regex = new RegExp(spec.match); } catch (err) {
|
|
307
|
+
return { outcome: 'warn', message: `Invalid match regex "${spec.match}": ${err.message}` };
|
|
308
|
+
}
|
|
309
|
+
if (!regex.test(text)) {
|
|
310
|
+
return { outcome, message: `Result does not match required pattern: ${spec.match}` };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { outcome: 'pass', message: null };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Custom verifier — routes through worker pool (sandboxed) or calls directly (dev mode).
|
|
319
|
+
* @param {{ fn?: Function, resolvedPath?: string, exportName?: string, usePool?: boolean }} spec
|
|
320
|
+
* @param {string} toolName
|
|
321
|
+
* @param {object} args
|
|
322
|
+
* @param {object} result
|
|
323
|
+
* @param {import('./verifier-worker-pool.js').VerifierWorkerPool|null} workerPool
|
|
324
|
+
* @param {string} role — 'read' | 'write' | 'any'
|
|
325
|
+
* @returns {Promise<{ outcome: string, message: string|null }>}
|
|
326
|
+
*/
|
|
327
|
+
async function runCustomVerifier(spec, toolName, args, result, workerPool, role) {
|
|
328
|
+
// Sandboxed path: dispatch to worker pool
|
|
329
|
+
if (spec.usePool && workerPool) {
|
|
330
|
+
return workerPool.run(spec.resolvedPath, spec.exportName, toolName, args, result, role);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Direct (non-sandboxed / dev mode)
|
|
334
|
+
if (typeof spec.fn !== 'function') {
|
|
335
|
+
return { outcome: 'warn', message: 'Custom verifier has no fn function' };
|
|
336
|
+
}
|
|
337
|
+
return await spec.fn(toolName, args, result);
|
|
338
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifier Scanner — Discovers existing verifiers from barrel.
|
|
3
|
+
* Used for gap detection: tools without verifier coverage.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
7
|
+
import { resolve, join } from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract verifier names from barrel file.
|
|
11
|
+
* @param {string} barrelsPath
|
|
12
|
+
* @returns {string[]}
|
|
13
|
+
*/
|
|
14
|
+
function scanBarrel(barrelsPath) {
|
|
15
|
+
const abs = resolve(process.cwd(), barrelsPath);
|
|
16
|
+
if (!existsSync(abs)) return [];
|
|
17
|
+
const content = readFileSync(abs, 'utf-8');
|
|
18
|
+
const names = [];
|
|
19
|
+
const re = /export\s+\{\s*(\w+Verifier)\s*\}\s+from\s+['"]([^'"]+)['"]/g;
|
|
20
|
+
let m;
|
|
21
|
+
const lines = content.split('\n').filter((l) => !l.trim().startsWith('//'));
|
|
22
|
+
const text = lines.join('\n');
|
|
23
|
+
while ((m = re.exec(text))) {
|
|
24
|
+
const exportName = m[1];
|
|
25
|
+
const snake = exportName
|
|
26
|
+
.replace(/Verifier$/, '')
|
|
27
|
+
.replace(/([A-Z])/g, (c) => '_' + c.toLowerCase())
|
|
28
|
+
.replace(/^_/, '');
|
|
29
|
+
names.push(snake);
|
|
30
|
+
}
|
|
31
|
+
return names;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Scan verifier files for `name = 'snake_case'` pattern.
|
|
36
|
+
* @param {string} verifiersDir
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
function scanVerifierFiles(verifiersDir) {
|
|
40
|
+
const abs = resolve(process.cwd(), verifiersDir);
|
|
41
|
+
if (!existsSync(abs)) return [];
|
|
42
|
+
const names = [];
|
|
43
|
+
const files = readdirSync(abs).filter(
|
|
44
|
+
(f) => f.endsWith('.verifier.ts') || f.endsWith('.verifier.js')
|
|
45
|
+
);
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const content = readFileSync(join(abs, file), 'utf-8');
|
|
48
|
+
const m = /name\s*=\s*['"]([^'"]+)['"]/.exec(content);
|
|
49
|
+
if (m) names.push(m[1]);
|
|
50
|
+
}
|
|
51
|
+
return names;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get existing verifier names.
|
|
56
|
+
* @param {object} config - forge.config verification section
|
|
57
|
+
* @returns {string[]}
|
|
58
|
+
*/
|
|
59
|
+
export function getExistingVerifiers(config) {
|
|
60
|
+
const verifiers = [];
|
|
61
|
+
if (config?.verifiersDir) {
|
|
62
|
+
const fromFiles = scanVerifierFiles(config.verifiersDir);
|
|
63
|
+
verifiers.push(...fromFiles);
|
|
64
|
+
}
|
|
65
|
+
if (config?.barrelsFile && verifiers.length === 0) {
|
|
66
|
+
const fromBarrel = scanBarrel(config.barrelsFile);
|
|
67
|
+
verifiers.push(...fromBarrel);
|
|
68
|
+
}
|
|
69
|
+
return [...new Set(verifiers)];
|
|
70
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VerifierWorkerPool — fixed-size Worker thread pool for sandboxed custom verifier execution.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (1000 verifiers × 1M sessions):
|
|
5
|
+
* - N workers (default: min(4, cpus), configurable via poolSize)
|
|
6
|
+
* - Module cache per worker (imported once, reused)
|
|
7
|
+
* - Dispatch to first available idle worker
|
|
8
|
+
* - Per-call timeout: 2000ms (configurable) — on expiry: terminate + replace worker
|
|
9
|
+
* - Queue: max 200 pending calls (configurable); rejects with warn/block based on role
|
|
10
|
+
* - Dead worker (crash/OOM): replaced immediately
|
|
11
|
+
* - Timeout/crash outcome: 'write' role → 'block', 'read'|'any' → 'warn'
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Worker } from 'worker_threads';
|
|
15
|
+
import { cpus } from 'os';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import { dirname, resolve } from 'path';
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const WORKER_PATH = resolve(__dirname, 'workers', 'verifier-worker.js');
|
|
21
|
+
|
|
22
|
+
export class VerifierWorkerPool {
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {number} [opts.size] — pool size (default: min(4, cpus().length))
|
|
26
|
+
* @param {number} [opts.timeoutMs=2000] — per-call timeout in ms
|
|
27
|
+
* @param {number} [opts.maxQueueDepth=200] — max pending calls before rejecting
|
|
28
|
+
*/
|
|
29
|
+
constructor(opts = {}) {
|
|
30
|
+
this._size = opts.size ?? Math.min(4, cpus().length);
|
|
31
|
+
this._timeoutMs = opts.timeoutMs ?? 2000;
|
|
32
|
+
this._maxQueueDepth = opts.maxQueueDepth ?? 200;
|
|
33
|
+
|
|
34
|
+
this._workers = []; // { worker, busy, lastUsed }
|
|
35
|
+
this._callMap = new Map(); // callId → { resolve, timeoutHandle, role, entry }
|
|
36
|
+
this._queue = []; // { callArgs, role, resolve }
|
|
37
|
+
this._nextId = 1;
|
|
38
|
+
this._destroyed = false;
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < this._size; i++) {
|
|
41
|
+
this._workers.push(this._createWorkerEntry());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @private */
|
|
46
|
+
_createWorkerEntry() {
|
|
47
|
+
const worker = new Worker(WORKER_PATH, { type: 'module' });
|
|
48
|
+
const entry = { worker, busy: false, lastUsed: 0 };
|
|
49
|
+
|
|
50
|
+
worker.on('message', (msg) => {
|
|
51
|
+
this._handleMessage(msg, entry);
|
|
52
|
+
});
|
|
53
|
+
worker.on('error', (err) => {
|
|
54
|
+
this._handleWorkerError(entry, err);
|
|
55
|
+
});
|
|
56
|
+
worker.on('exit', (code) => {
|
|
57
|
+
if (code !== 0 && !this._destroyed) {
|
|
58
|
+
this._replaceWorker(entry);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @private */
|
|
66
|
+
_handleMessage({ id, outcome, message }, entry) {
|
|
67
|
+
const pending = this._callMap.get(id);
|
|
68
|
+
if (!pending) return;
|
|
69
|
+
clearTimeout(pending.timeoutHandle);
|
|
70
|
+
this._callMap.delete(id);
|
|
71
|
+
entry.busy = false;
|
|
72
|
+
entry.lastUsed = Date.now();
|
|
73
|
+
pending.resolve({ outcome, message });
|
|
74
|
+
// Drain queue
|
|
75
|
+
this._drainQueue();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @private */
|
|
79
|
+
_handleWorkerError(crashedEntry, err) {
|
|
80
|
+
// Fail only the pending calls that were dispatched to the crashed worker
|
|
81
|
+
for (const [id, pending] of this._callMap) {
|
|
82
|
+
if (pending.entry !== crashedEntry) continue;
|
|
83
|
+
clearTimeout(pending.timeoutHandle);
|
|
84
|
+
this._callMap.delete(id);
|
|
85
|
+
const outcome = pending.role === 'write' ? 'block' : 'warn';
|
|
86
|
+
pending.resolve({ outcome, message: `Verifier worker crashed: ${err.message}` });
|
|
87
|
+
}
|
|
88
|
+
if (!this._destroyed) {
|
|
89
|
+
this._replaceWorker(crashedEntry);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @private */
|
|
94
|
+
_replaceWorker(entry) {
|
|
95
|
+
const idx = this._workers.indexOf(entry);
|
|
96
|
+
if (idx === -1) return;
|
|
97
|
+
try { entry.worker.terminate(); } catch { /* ignore */ }
|
|
98
|
+
this._workers[idx] = this._createWorkerEntry();
|
|
99
|
+
this._drainQueue();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @private */
|
|
103
|
+
_drainQueue() {
|
|
104
|
+
while (this._queue.length > 0) {
|
|
105
|
+
const freeEntry = this._workers.find(e => !e.busy);
|
|
106
|
+
if (!freeEntry) break;
|
|
107
|
+
const { callArgs, role, resolve } = this._queue.shift();
|
|
108
|
+
this._dispatch(freeEntry, callArgs, role, resolve);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @private */
|
|
113
|
+
_dispatch(entry, { id, verifierPath, exportName, toolName, args, result }, role, resolve) {
|
|
114
|
+
entry.busy = true;
|
|
115
|
+
entry.lastUsed = Date.now();
|
|
116
|
+
|
|
117
|
+
const timeoutHandle = setTimeout(() => {
|
|
118
|
+
this._callMap.delete(id);
|
|
119
|
+
const outcome = role === 'write' ? 'block' : 'warn';
|
|
120
|
+
resolve({ outcome, message: `Verifier timed out after ${this._timeoutMs}ms` });
|
|
121
|
+
// Replace the worker (stuck in user code)
|
|
122
|
+
this._replaceWorker(entry);
|
|
123
|
+
}, this._timeoutMs).unref();
|
|
124
|
+
|
|
125
|
+
this._callMap.set(id, { resolve, timeoutHandle, role, entry });
|
|
126
|
+
entry.worker.postMessage({ id, verifierPath, exportName, toolName, args, result });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run a custom verifier in a worker thread.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} verifierPath — absolute path to verifier module
|
|
133
|
+
* @param {string} exportName — exported function name (e.g. 'verify')
|
|
134
|
+
* @param {string} toolName
|
|
135
|
+
* @param {object} args — tool call input
|
|
136
|
+
* @param {object} result — tool call result
|
|
137
|
+
* @param {string} [role='any'] — 'read' | 'write' | 'any' (determines timeout outcome)
|
|
138
|
+
* @returns {Promise<{ outcome: 'pass'|'warn'|'block', message: string|null }>}
|
|
139
|
+
*/
|
|
140
|
+
run(verifierPath, exportName, toolName, args, result, role = 'any') {
|
|
141
|
+
if (this._destroyed) {
|
|
142
|
+
const outcome = role === 'write' ? 'block' : 'warn';
|
|
143
|
+
return Promise.resolve({ outcome, message: 'Verifier pool is destroyed' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const id = this._nextId++;
|
|
147
|
+
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const callArgs = { id, verifierPath, exportName, toolName, args, result };
|
|
150
|
+
|
|
151
|
+
// Try to dispatch immediately to a free worker
|
|
152
|
+
const freeEntry = this._workers.find(e => !e.busy);
|
|
153
|
+
if (freeEntry) {
|
|
154
|
+
this._dispatch(freeEntry, callArgs, role, resolve);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Queue if under limit
|
|
159
|
+
if (this._queue.length >= this._maxQueueDepth) {
|
|
160
|
+
const outcome = role === 'write' ? 'block' : 'warn';
|
|
161
|
+
resolve({ outcome, message: 'Verifier queue full — request dropped' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this._queue.push({ callArgs, role, resolve });
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Tear down all workers. Outstanding calls resolve with warn/block.
|
|
171
|
+
*/
|
|
172
|
+
destroy() {
|
|
173
|
+
this._destroyed = true;
|
|
174
|
+
|
|
175
|
+
// Fail all pending calls
|
|
176
|
+
for (const [id, pending] of this._callMap) {
|
|
177
|
+
clearTimeout(pending.timeoutHandle);
|
|
178
|
+
this._callMap.delete(id);
|
|
179
|
+
const outcome = pending.role === 'write' ? 'block' : 'warn';
|
|
180
|
+
pending.resolve({ outcome, message: 'Verifier pool shutting down' });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Drain queue
|
|
184
|
+
for (const { callArgs, role, resolve } of this._queue) {
|
|
185
|
+
const outcome = role === 'write' ? 'block' : 'warn';
|
|
186
|
+
resolve({ outcome, message: 'Verifier pool shutting down' });
|
|
187
|
+
}
|
|
188
|
+
this._queue.length = 0;
|
|
189
|
+
|
|
190
|
+
// Terminate workers
|
|
191
|
+
for (const entry of this._workers) {
|
|
192
|
+
try { entry.worker.terminate(); } catch { /* ignore */ }
|
|
193
|
+
}
|
|
194
|
+
this._workers.length = 0;
|
|
195
|
+
}
|
|
196
|
+
}
|