@agentlip/hub 0.1.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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Plugin runtime harness (Worker-based) with RPC, timeouts, and circuit breaker.
3
+ *
4
+ * Implements bd-16d.4.3: isolates plugin execution in Bun Workers, enforces wall-clock timeouts,
5
+ * and implements circuit breaker to skip broken plugins after repeated failures.
6
+ *
7
+ * Plan references:
8
+ * - Plugin contract (v1): Worker-based isolation, 5s default timeout
9
+ * - ADR-0005: Worker isolation by default
10
+ * - Gate E: plugin hangs bounded by timeout; hub continues ingesting messages
11
+ */
12
+
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Types
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+
17
+ export type PluginType = "extractor" | "linkifier";
18
+
19
+ export interface MessageInput {
20
+ id: string;
21
+ content_raw: string;
22
+ sender: string;
23
+ topic_id: string;
24
+ channel_id: string;
25
+ created_at: string;
26
+ }
27
+
28
+ export interface EnrichInput {
29
+ message: MessageInput;
30
+ config: Record<string, unknown>;
31
+ }
32
+
33
+ export interface ExtractInput {
34
+ message: MessageInput;
35
+ config: Record<string, unknown>;
36
+ }
37
+
38
+ export interface Enrichment {
39
+ kind: string;
40
+ span: { start: number; end: number };
41
+ data: Record<string, unknown>;
42
+ }
43
+
44
+ export interface Attachment {
45
+ kind: string;
46
+ key?: string;
47
+ value_json: Record<string, unknown>;
48
+ dedupe_key?: string;
49
+ }
50
+
51
+ export interface RunPluginOptions {
52
+ type: PluginType;
53
+ modulePath: string;
54
+ input: EnrichInput | ExtractInput;
55
+ timeoutMs?: number;
56
+ pluginName?: string; // For circuit breaker tracking
57
+ }
58
+
59
+ export type PluginResult<T> =
60
+ | { ok: true; data: T }
61
+ | { ok: false; error: string; code: PluginErrorCode };
62
+
63
+ export type PluginErrorCode =
64
+ | "TIMEOUT"
65
+ | "WORKER_CRASH"
66
+ | "INVALID_OUTPUT"
67
+ | "CIRCUIT_OPEN"
68
+ | "LOAD_ERROR"
69
+ | "EXECUTION_ERROR";
70
+
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Circuit Breaker
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ interface CircuitBreakerState {
76
+ failureCount: number;
77
+ lastFailureAt: number;
78
+ state: "closed" | "open";
79
+ openedAt: number;
80
+ }
81
+
82
+ const DEFAULT_FAILURE_THRESHOLD = 3;
83
+ const DEFAULT_COOLDOWN_MS = 60_000; // 1 minute
84
+
85
+ export class CircuitBreaker {
86
+ private readonly states = new Map<string, CircuitBreakerState>();
87
+ private readonly failureThreshold: number;
88
+ private readonly cooldownMs: number;
89
+
90
+ constructor(options?: {
91
+ failureThreshold?: number;
92
+ cooldownMs?: number;
93
+ }) {
94
+ this.failureThreshold = options?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
95
+ this.cooldownMs = options?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
96
+ }
97
+
98
+ /**
99
+ * Check if plugin is allowed to execute.
100
+ * If circuit is open and cooldown expired, transitions to closed.
101
+ */
102
+ isAllowed(pluginName: string): boolean {
103
+ const state = this.states.get(pluginName);
104
+ if (!state || state.state === "closed") {
105
+ return true;
106
+ }
107
+
108
+ // Circuit is open - check if cooldown expired
109
+ const now = Date.now();
110
+ const elapsedSinceOpen = now - state.openedAt;
111
+
112
+ if (elapsedSinceOpen >= this.cooldownMs) {
113
+ // Cooldown expired - reset to closed
114
+ this.states.set(pluginName, {
115
+ failureCount: 0,
116
+ lastFailureAt: 0,
117
+ state: "closed",
118
+ openedAt: 0,
119
+ });
120
+ return true;
121
+ }
122
+
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Record a failure. Opens circuit if threshold exceeded.
128
+ */
129
+ recordFailure(pluginName: string): void {
130
+ const now = Date.now();
131
+ const state = this.states.get(pluginName) ?? {
132
+ failureCount: 0,
133
+ lastFailureAt: 0,
134
+ state: "closed" as const,
135
+ openedAt: 0,
136
+ };
137
+
138
+ const newFailureCount = state.failureCount + 1;
139
+ const newState: CircuitBreakerState = {
140
+ failureCount: newFailureCount,
141
+ lastFailureAt: now,
142
+ state: newFailureCount >= this.failureThreshold ? "open" : "closed",
143
+ openedAt: newFailureCount >= this.failureThreshold ? now : state.openedAt,
144
+ };
145
+
146
+ this.states.set(pluginName, newState);
147
+ }
148
+
149
+ /**
150
+ * Record a success. Resets failure count.
151
+ */
152
+ recordSuccess(pluginName: string): void {
153
+ this.states.set(pluginName, {
154
+ failureCount: 0,
155
+ lastFailureAt: 0,
156
+ state: "closed",
157
+ openedAt: 0,
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Get current state for a plugin (for observability).
163
+ */
164
+ getState(pluginName: string): CircuitBreakerState | null {
165
+ return this.states.get(pluginName) ?? null;
166
+ }
167
+
168
+ /**
169
+ * Reset all circuit breakers (for testing).
170
+ */
171
+ reset(): void {
172
+ this.states.clear();
173
+ }
174
+ }
175
+
176
+ // ─────────────────────────────────────────────────────────────────────────────
177
+ // Worker RPC Protocol
178
+ // ─────────────────────────────────────────────────────────────────────────────
179
+
180
+ interface WorkerRequest {
181
+ type: PluginType;
182
+ input: EnrichInput | ExtractInput;
183
+ }
184
+
185
+ interface WorkerResponse {
186
+ ok: true;
187
+ data: Enrichment[] | Attachment[];
188
+ }
189
+
190
+ interface WorkerError {
191
+ ok: false;
192
+ error: string;
193
+ }
194
+
195
+ // ─────────────────────────────────────────────────────────────────────────────
196
+ // Plugin Runner
197
+ // ─────────────────────────────────────────────────────────────────────────────
198
+
199
+ const DEFAULT_TIMEOUT_MS = 5000;
200
+
201
+ /**
202
+ * Global circuit breaker instance (shared across all plugin executions).
203
+ */
204
+ const globalCircuitBreaker = new CircuitBreaker();
205
+
206
+ /**
207
+ * Execute a plugin in a Bun Worker with timeout and circuit breaker protection.
208
+ *
209
+ * Features:
210
+ * - Spawns Worker with plugin module
211
+ * - Enforces wall-clock timeout (terminates Worker on timeout)
212
+ * - Circuit breaker: skips execution if plugin has failed repeatedly
213
+ * - RPC request/response protocol
214
+ * - Output validation
215
+ *
216
+ * @param options Plugin execution options
217
+ * @returns Plugin result (success with data or error with code)
218
+ */
219
+ export async function runPlugin<T extends Enrichment[] | Attachment[]>(
220
+ options: RunPluginOptions
221
+ ): Promise<PluginResult<T>> {
222
+ const {
223
+ type,
224
+ modulePath,
225
+ input,
226
+ timeoutMs = DEFAULT_TIMEOUT_MS,
227
+ pluginName = modulePath,
228
+ } = options;
229
+
230
+ // Circuit breaker check
231
+ if (!globalCircuitBreaker.isAllowed(pluginName)) {
232
+ const state = globalCircuitBreaker.getState(pluginName);
233
+ const cooldownRemaining = state
234
+ ? Math.ceil((DEFAULT_COOLDOWN_MS - (Date.now() - state.openedAt)) / 1000)
235
+ : 0;
236
+
237
+ return {
238
+ ok: false,
239
+ error: `Circuit breaker open for ${pluginName} (${state?.failureCount} failures, ${cooldownRemaining}s cooldown remaining)`,
240
+ code: "CIRCUIT_OPEN",
241
+ };
242
+ }
243
+
244
+ let worker: Worker | null = null;
245
+
246
+ try {
247
+ // Spawn Worker with plugin module
248
+ worker = new Worker(new URL("./pluginWorker.ts", import.meta.url).href, {
249
+ type: "module",
250
+ });
251
+
252
+ // Create timeout promise
253
+ const timeoutPromise = new Promise<never>((_, reject) => {
254
+ setTimeout(() => {
255
+ reject(new Error("Plugin execution timeout"));
256
+ }, timeoutMs);
257
+ });
258
+
259
+ // Create result promise
260
+ const resultPromise = new Promise<T>((resolve, reject) => {
261
+ worker!.onmessage = (event: MessageEvent) => {
262
+ const response = event.data as WorkerResponse | WorkerError;
263
+
264
+ if (response.ok) {
265
+ resolve(response.data as T);
266
+ } else {
267
+ reject(new Error(response.error));
268
+ }
269
+ };
270
+
271
+ worker!.onerror = (error: ErrorEvent) => {
272
+ reject(new Error(`Worker error: ${error.message}`));
273
+ };
274
+ });
275
+
276
+ // Send RPC request to worker
277
+ const request: WorkerRequest = { type, input };
278
+ worker.postMessage({ modulePath, request });
279
+
280
+ // Race between result and timeout
281
+ const data = await Promise.race([resultPromise, timeoutPromise]);
282
+
283
+ // Validate output
284
+ const validationError = validatePluginOutput(type, data);
285
+ if (validationError) {
286
+ globalCircuitBreaker.recordFailure(pluginName);
287
+ return {
288
+ ok: false,
289
+ error: validationError,
290
+ code: "INVALID_OUTPUT",
291
+ };
292
+ }
293
+
294
+ // Success - reset circuit breaker
295
+ globalCircuitBreaker.recordSuccess(pluginName);
296
+
297
+ return { ok: true, data };
298
+ } catch (error: any) {
299
+ // Record failure
300
+ globalCircuitBreaker.recordFailure(pluginName);
301
+
302
+ // Classify error
303
+ if (error.message === "Plugin execution timeout") {
304
+ return {
305
+ ok: false,
306
+ error: `Plugin timed out after ${timeoutMs}ms`,
307
+ code: "TIMEOUT",
308
+ };
309
+ }
310
+
311
+ if (error.message?.includes("Worker error")) {
312
+ return {
313
+ ok: false,
314
+ error: error.message,
315
+ code: "WORKER_CRASH",
316
+ };
317
+ }
318
+
319
+ return {
320
+ ok: false,
321
+ error: error.message ?? "Unknown error",
322
+ code: "EXECUTION_ERROR",
323
+ };
324
+ } finally {
325
+ // Always terminate worker
326
+ if (worker) {
327
+ worker.terminate();
328
+ }
329
+ }
330
+ }
331
+
332
+ // ─────────────────────────────────────────────────────────────────────────────
333
+ // Output Validation
334
+ // ─────────────────────────────────────────────────────────────────────────────
335
+
336
+ function validatePluginOutput(
337
+ type: PluginType,
338
+ output: unknown
339
+ ): string | null {
340
+ if (!Array.isArray(output)) {
341
+ return "Output must be an array";
342
+ }
343
+
344
+ if (type === "linkifier") {
345
+ for (const item of output) {
346
+ if (!isValidEnrichment(item)) {
347
+ return `Invalid enrichment: ${JSON.stringify(item)}`;
348
+ }
349
+ }
350
+ } else if (type === "extractor") {
351
+ for (const item of output) {
352
+ if (!isValidAttachment(item)) {
353
+ return `Invalid attachment: ${JSON.stringify(item)}`;
354
+ }
355
+ }
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ function isValidEnrichment(obj: unknown): obj is Enrichment {
362
+ if (typeof obj !== "object" || obj === null) return false;
363
+ const e = obj as any;
364
+
365
+ return (
366
+ typeof e.kind === "string" &&
367
+ e.kind.trim().length > 0 &&
368
+ typeof e.span === "object" &&
369
+ e.span !== null &&
370
+ typeof e.span.start === "number" &&
371
+ Number.isInteger(e.span.start) &&
372
+ typeof e.span.end === "number" &&
373
+ Number.isInteger(e.span.end) &&
374
+ e.span.start >= 0 &&
375
+ e.span.end > e.span.start &&
376
+ typeof e.data === "object" &&
377
+ e.data !== null &&
378
+ !Array.isArray(e.data)
379
+ );
380
+ }
381
+
382
+ function isValidAttachment(obj: unknown): obj is Attachment {
383
+ if (typeof obj !== "object" || obj === null) return false;
384
+ const a = obj as any;
385
+
386
+ return (
387
+ typeof a.kind === "string" &&
388
+ a.kind.trim().length > 0 &&
389
+ (a.key === undefined || typeof a.key === "string") &&
390
+ typeof a.value_json === "object" &&
391
+ a.value_json !== null &&
392
+ !Array.isArray(a.value_json) &&
393
+ (a.dedupe_key === undefined ||
394
+ (typeof a.dedupe_key === "string" && a.dedupe_key.trim().length > 0))
395
+ );
396
+ }
397
+
398
+ // ─────────────────────────────────────────────────────────────────────────────
399
+ // Exports (for testing and hub integration)
400
+ // ─────────────────────────────────────────────────────────────────────────────
401
+
402
+ export { globalCircuitBreaker };
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Plugin Worker script - executes plugin code in isolated Worker context.
3
+ *
4
+ * Receives RPC requests via postMessage:
5
+ * - { modulePath, request: { type, input } }
6
+ *
7
+ * Responds with:
8
+ * - { ok: true, data: Enrichment[] | Attachment[] }
9
+ * - { ok: false, error: string }
10
+ *
11
+ * This script runs in a Bun Worker thread (isolated from hub process).
12
+ *
13
+ * ISOLATION GUARANTEES (bd-16d.4.4):
14
+ * - Filesystem guards block writes to .agentlip/ directory (practical isolation)
15
+ * - Plugins receive no workspace path context (path-blind execution)
16
+ *
17
+ * LIMITATIONS (v1):
18
+ * - Not cryptographic sandboxing (Bun Workers share process memory)
19
+ * - Plugins can access network and non-.agentlip filesystem
20
+ * - Guards are best-effort (sophisticated plugins might bypass)
21
+ * - Future: consider true sandboxing (subprocess, Deno, wasm)
22
+ */
23
+
24
+ import { promises as fs } from "node:fs";
25
+ import { resolve, normalize, sep } from "node:path";
26
+
27
+ interface WorkerRequest {
28
+ type: "extractor" | "linkifier";
29
+ input: {
30
+ message: {
31
+ id: string;
32
+ content_raw: string;
33
+ sender: string;
34
+ topic_id: string;
35
+ channel_id: string;
36
+ created_at: string;
37
+ };
38
+ config: Record<string, unknown>;
39
+ };
40
+ }
41
+
42
+ interface LinkifierPlugin {
43
+ name: string;
44
+ version: string;
45
+ enrich(input: any): Promise<any[]>;
46
+ }
47
+
48
+ interface ExtractorPlugin {
49
+ name: string;
50
+ version: string;
51
+ extract(input: any): Promise<any[]>;
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Filesystem Isolation Guards (bd-16d.4.4)
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Check if a path targets .agentlip/ directory (or any .agentlip ancestor).
60
+ * Returns true if path should be blocked.
61
+ */
62
+ function isAgentlipPath(targetPath: string): boolean {
63
+ try {
64
+ const normalized = normalize(resolve(targetPath));
65
+ const parts = normalized.split(sep);
66
+
67
+ // Check if any path component is exactly '.agentlip'
68
+ return parts.includes(".agentlip");
69
+ } catch {
70
+ // If path resolution fails, be conservative and block
71
+ return true;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Wrap filesystem write operations to block .agentlip/ access.
77
+ * This is practical isolation, not cryptographic sandboxing.
78
+ */
79
+ function installFilesystemGuards(): void {
80
+ // Promises API (async)
81
+ const originalWriteFile = fs.writeFile;
82
+ const originalAppendFile = fs.appendFile;
83
+ const originalMkdir = fs.mkdir;
84
+ const originalRm = fs.rm;
85
+ const originalRmdir = fs.rmdir;
86
+ const originalUnlink = fs.unlink;
87
+ const originalOpen = fs.open;
88
+
89
+ // Sync API
90
+ const fsSync = require("node:fs");
91
+ const originalWriteFileSync = fsSync.writeFileSync;
92
+ const originalAppendFileSync = fsSync.appendFileSync;
93
+ const originalMkdirSync = fsSync.mkdirSync;
94
+ const originalRmSync = fsSync.rmSync;
95
+ const originalRmdirSync = fsSync.rmdirSync;
96
+ const originalUnlinkSync = fsSync.unlinkSync;
97
+ const originalOpenSync = fsSync.openSync;
98
+
99
+ const blockMessage = "Plugin isolation violation: write access to .agentlip/ directory is forbidden";
100
+
101
+ // @ts-ignore - intentionally overriding
102
+ fs.writeFile = async function (path: any, data: any, options?: any) {
103
+ if (isAgentlipPath(String(path))) {
104
+ throw new Error(blockMessage);
105
+ }
106
+ return originalWriteFile.call(this, path, data, options);
107
+ };
108
+
109
+ // @ts-ignore
110
+ fs.appendFile = async function (path: any, data: any, options?: any) {
111
+ if (isAgentlipPath(String(path))) {
112
+ throw new Error(blockMessage);
113
+ }
114
+ return originalAppendFile.call(this, path, data, options);
115
+ };
116
+
117
+ // @ts-ignore
118
+ fs.mkdir = async function (path: any, options?: any) {
119
+ if (isAgentlipPath(String(path))) {
120
+ throw new Error(blockMessage);
121
+ }
122
+ return originalMkdir.call(this, path, options);
123
+ };
124
+
125
+ // @ts-ignore
126
+ fs.rm = async function (path: any, options?: any) {
127
+ if (isAgentlipPath(String(path))) {
128
+ throw new Error(blockMessage);
129
+ }
130
+ return originalRm.call(this, path, options);
131
+ };
132
+
133
+ // @ts-ignore - runtime filesystem guard
134
+ fs.rmdir = async function (path: any, options?: any) {
135
+ if (isAgentlipPath(String(path))) {
136
+ throw new Error(blockMessage);
137
+ }
138
+ // @ts-ignore - dynamic override
139
+ return originalRmdir.call(this, path, options);
140
+ };
141
+
142
+ // @ts-ignore
143
+ fs.unlink = async function (path: any) {
144
+ if (isAgentlipPath(String(path))) {
145
+ throw new Error(blockMessage);
146
+ }
147
+ return originalUnlink.call(this, path);
148
+ };
149
+
150
+ // @ts-ignore
151
+ fs.open = async function (path: any, flags: any, ...args: any[]) {
152
+ // Block write/append modes
153
+ const flagsStr = String(flags || "r");
154
+ const isWrite = /[wa+]/.test(flagsStr) || flags === 1 || flags === 2 || flags === 3;
155
+
156
+ if (isWrite && isAgentlipPath(String(path))) {
157
+ throw new Error(blockMessage);
158
+ }
159
+ return originalOpen.call(this, path, flags, ...args);
160
+ };
161
+
162
+ // Sync API guards
163
+ fsSync.writeFileSync = function (path: any, data: any, options?: any) {
164
+ if (isAgentlipPath(String(path))) {
165
+ throw new Error(blockMessage);
166
+ }
167
+ return originalWriteFileSync.call(this, path, data, options);
168
+ };
169
+
170
+ fsSync.appendFileSync = function (path: any, data: any, options?: any) {
171
+ if (isAgentlipPath(String(path))) {
172
+ throw new Error(blockMessage);
173
+ }
174
+ return originalAppendFileSync.call(this, path, data, options);
175
+ };
176
+
177
+ fsSync.mkdirSync = function (path: any, options?: any) {
178
+ if (isAgentlipPath(String(path))) {
179
+ throw new Error(blockMessage);
180
+ }
181
+ return originalMkdirSync.call(this, path, options);
182
+ };
183
+
184
+ fsSync.rmSync = function (path: any, options?: any) {
185
+ if (isAgentlipPath(String(path))) {
186
+ throw new Error(blockMessage);
187
+ }
188
+ return originalRmSync.call(this, path, options);
189
+ };
190
+
191
+ fsSync.rmdirSync = function (path: any, options?: any) {
192
+ if (isAgentlipPath(String(path))) {
193
+ throw new Error(blockMessage);
194
+ }
195
+ return originalRmdirSync.call(this, path, options);
196
+ };
197
+
198
+ fsSync.unlinkSync = function (path: any) {
199
+ if (isAgentlipPath(String(path))) {
200
+ throw new Error(blockMessage);
201
+ }
202
+ return originalUnlinkSync.call(this, path);
203
+ };
204
+
205
+ fsSync.openSync = function (path: any, flags: any, ...args: any[]) {
206
+ const flagsStr = String(flags || "r");
207
+ const isWrite = /[wa+]/.test(flagsStr) || flags === 1 || flags === 2 || flags === 3;
208
+
209
+ if (isWrite && isAgentlipPath(String(path))) {
210
+ throw new Error(blockMessage);
211
+ }
212
+ return originalOpenSync.call(this, path, flags, ...args);
213
+ };
214
+ }
215
+
216
+ // Install guards before plugin code runs
217
+ installFilesystemGuards();
218
+
219
+ // Type assertion for Worker context
220
+ const workerSelf = self as unknown as Worker;
221
+
222
+ workerSelf.onmessage = async (event: MessageEvent) => {
223
+ try {
224
+ const { modulePath, request } = event.data as {
225
+ modulePath: string;
226
+ request: WorkerRequest;
227
+ };
228
+
229
+ // Dynamically import plugin module
230
+ let plugin: LinkifierPlugin | ExtractorPlugin;
231
+
232
+ try {
233
+ const module = await import(modulePath);
234
+ plugin = module.default ?? module;
235
+ } catch (importError: any) {
236
+ workerSelf.postMessage({
237
+ ok: false,
238
+ error: `Failed to load plugin: ${importError.message}`,
239
+ });
240
+ return;
241
+ }
242
+
243
+ // Validate plugin interface
244
+ if (!plugin || typeof plugin !== "object") {
245
+ workerSelf.postMessage({
246
+ ok: false,
247
+ error: "Plugin module must export a default object",
248
+ });
249
+ return;
250
+ }
251
+
252
+ // Call appropriate plugin method
253
+ let result: any[];
254
+
255
+ try {
256
+ if (request.type === "linkifier") {
257
+ const linkifier = plugin as LinkifierPlugin;
258
+ if (typeof linkifier.enrich !== "function") {
259
+ workerSelf.postMessage({
260
+ ok: false,
261
+ error: "Linkifier plugin must implement enrich() method",
262
+ });
263
+ return;
264
+ }
265
+ result = await linkifier.enrich(request.input);
266
+ } else {
267
+ const extractor = plugin as ExtractorPlugin;
268
+ if (typeof extractor.extract !== "function") {
269
+ workerSelf.postMessage({
270
+ ok: false,
271
+ error: "Extractor plugin must implement extract() method",
272
+ });
273
+ return;
274
+ }
275
+ result = await extractor.extract(request.input);
276
+ }
277
+ } catch (execError: any) {
278
+ workerSelf.postMessage({
279
+ ok: false,
280
+ error: `Plugin execution failed: ${execError.message}`,
281
+ });
282
+ return;
283
+ }
284
+
285
+ // Send success response (validation happens in main thread)
286
+ workerSelf.postMessage({
287
+ ok: true,
288
+ data: result,
289
+ });
290
+ } catch (error: any) {
291
+ workerSelf.postMessage({
292
+ ok: false,
293
+ error: `Worker error: ${error.message}`,
294
+ });
295
+ }
296
+ };