@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.
- package/README.md +126 -0
- package/package.json +36 -0
- package/src/agentlipd.ts +309 -0
- package/src/apiV1.ts +1468 -0
- package/src/authMiddleware.ts +134 -0
- package/src/authToken.ts +32 -0
- package/src/bodyParser.ts +272 -0
- package/src/config.ts +273 -0
- package/src/derivedStaleness.ts +255 -0
- package/src/extractorDerived.ts +374 -0
- package/src/index.ts +878 -0
- package/src/linkifierDerived.ts +407 -0
- package/src/lock.ts +172 -0
- package/src/pluginRuntime.ts +402 -0
- package/src/pluginWorker.ts +296 -0
- package/src/rateLimiter.ts +286 -0
- package/src/serverJson.ts +138 -0
- package/src/ui.ts +843 -0
- package/src/wsEndpoint.ts +481 -0
|
@@ -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
|
+
};
|