web-agent-bridge 2.4.0 → 2.5.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.ar.md +18 -0
- package/README.md +18 -0
- package/package.json +1 -1
- package/sdk/index.d.ts +170 -0
- package/sdk/index.js +246 -1
- package/sdk/package.json +1 -1
- package/server/control-plane/index.js +301 -0
- package/server/data-plane/index.js +354 -0
- package/server/index.js +2 -0
- package/server/llm/index.js +404 -0
- package/server/observability/index.js +394 -0
- package/server/protocol/capabilities.js +223 -0
- package/server/protocol/index.js +243 -0
- package/server/protocol/schema.js +584 -0
- package/server/registry/index.js +326 -0
- package/server/routes/runtime.js +725 -0
- package/server/runtime/event-bus.js +210 -0
- package/server/runtime/index.js +233 -0
- package/server/runtime/sandbox.js +266 -0
- package/server/runtime/scheduler.js +395 -0
- package/server/runtime/state-manager.js +188 -0
- package/server/security/index.js +355 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WAB Runtime - Event Bus
|
|
5
|
+
*
|
|
6
|
+
* Async event system with typed events, middleware, replay buffer,
|
|
7
|
+
* and dead-letter queue. This is the nervous system of the Agent OS.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
class EventBus {
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this._listeners = new Map(); // event → Set<{ id, handler, filter, once }>
|
|
15
|
+
this._middleware = []; // global middleware
|
|
16
|
+
this._history = []; // event replay buffer
|
|
17
|
+
this._deadLetter = []; // failed events
|
|
18
|
+
this._maxHistory = options.maxHistory || 10000;
|
|
19
|
+
this._maxDeadLetter = options.maxDeadLetter || 1000;
|
|
20
|
+
this._stats = { emitted: 0, delivered: 0, failed: 0, dropped: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Subscribe to an event
|
|
25
|
+
* @returns {string} subscription ID for unsubscribe
|
|
26
|
+
*/
|
|
27
|
+
on(event, handler, options = {}) {
|
|
28
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
|
29
|
+
const sub = {
|
|
30
|
+
id: `sub_${crypto.randomBytes(8).toString('hex')}`,
|
|
31
|
+
handler,
|
|
32
|
+
filter: options.filter || null,
|
|
33
|
+
once: options.once || false,
|
|
34
|
+
priority: options.priority || 0,
|
|
35
|
+
};
|
|
36
|
+
this._listeners.get(event).add(sub);
|
|
37
|
+
return sub.id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe once
|
|
42
|
+
*/
|
|
43
|
+
once(event, handler, options = {}) {
|
|
44
|
+
return this.on(event, handler, { ...options, once: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Unsubscribe by subscription ID
|
|
49
|
+
*/
|
|
50
|
+
off(subId) {
|
|
51
|
+
for (const [, subs] of this._listeners) {
|
|
52
|
+
for (const sub of subs) {
|
|
53
|
+
if (sub.id === subId) { subs.delete(sub); return true; }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remove all listeners for an event
|
|
61
|
+
*/
|
|
62
|
+
removeAll(event) {
|
|
63
|
+
if (event) this._listeners.delete(event);
|
|
64
|
+
else this._listeners.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Emit an event
|
|
69
|
+
*/
|
|
70
|
+
async emit(event, data, metadata = {}) {
|
|
71
|
+
const envelope = {
|
|
72
|
+
id: `evt_${crypto.randomBytes(12).toString('hex')}`,
|
|
73
|
+
event,
|
|
74
|
+
data,
|
|
75
|
+
metadata: {
|
|
76
|
+
...metadata,
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
source: metadata.source || 'system',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Run global middleware
|
|
83
|
+
for (const mw of this._middleware) {
|
|
84
|
+
try {
|
|
85
|
+
const result = await mw(envelope);
|
|
86
|
+
if (result === false) {
|
|
87
|
+
this._stats.dropped++;
|
|
88
|
+
return envelope;
|
|
89
|
+
}
|
|
90
|
+
} catch (_) { /* middleware errors don't block */ }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Store in history
|
|
94
|
+
this._history.push(envelope);
|
|
95
|
+
if (this._history.length > this._maxHistory) {
|
|
96
|
+
this._history = this._history.slice(-Math.floor(this._maxHistory * 0.8));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this._stats.emitted++;
|
|
100
|
+
|
|
101
|
+
// Get listeners (event + wildcard)
|
|
102
|
+
const listeners = [];
|
|
103
|
+
const exact = this._listeners.get(event);
|
|
104
|
+
if (exact) for (const sub of exact) listeners.push(sub);
|
|
105
|
+
const wild = this._listeners.get('*');
|
|
106
|
+
if (wild) for (const sub of wild) listeners.push(sub);
|
|
107
|
+
|
|
108
|
+
// Also match namespace wildcards: 'task.*' matches 'task.completed'
|
|
109
|
+
for (const [pattern, subs] of this._listeners) {
|
|
110
|
+
if (pattern === event || pattern === '*') continue;
|
|
111
|
+
if (pattern.endsWith('.*') && event.startsWith(pattern.slice(0, -1))) {
|
|
112
|
+
for (const sub of subs) listeners.push(sub);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Sort by priority (higher first)
|
|
117
|
+
listeners.sort((a, b) => b.priority - a.priority);
|
|
118
|
+
|
|
119
|
+
// Dispatch
|
|
120
|
+
const toRemove = [];
|
|
121
|
+
for (const sub of listeners) {
|
|
122
|
+
try {
|
|
123
|
+
if (sub.filter && !sub.filter(envelope.data, envelope.metadata)) continue;
|
|
124
|
+
await sub.handler(envelope.data, envelope.metadata, envelope);
|
|
125
|
+
this._stats.delivered++;
|
|
126
|
+
if (sub.once) toRemove.push(sub);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
this._stats.failed++;
|
|
129
|
+
this._deadLetter.push({ envelope, error: err.message, subscriberId: sub.id, timestamp: Date.now() });
|
|
130
|
+
if (this._deadLetter.length > this._maxDeadLetter) {
|
|
131
|
+
this._deadLetter = this._deadLetter.slice(-Math.floor(this._maxDeadLetter * 0.8));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Cleanup one-time subs
|
|
137
|
+
for (const sub of toRemove) {
|
|
138
|
+
for (const [, subs] of this._listeners) subs.delete(sub);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return envelope;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Add global middleware
|
|
146
|
+
*/
|
|
147
|
+
use(middleware) {
|
|
148
|
+
this._middleware.push(middleware);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Replay events matching filter since a timestamp
|
|
153
|
+
*/
|
|
154
|
+
async replay(since, filter, handler) {
|
|
155
|
+
const events = this._history.filter(
|
|
156
|
+
e => e.metadata.timestamp >= since && (!filter || filter(e))
|
|
157
|
+
);
|
|
158
|
+
for (const e of events) {
|
|
159
|
+
await handler(e.data, e.metadata, e);
|
|
160
|
+
}
|
|
161
|
+
return events.length;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Wait for a specific event (returns a promise)
|
|
166
|
+
*/
|
|
167
|
+
waitFor(event, timeout = 30000, filter = null) {
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
this.off(subId);
|
|
171
|
+
reject(new Error(`Timeout waiting for event: ${event}`));
|
|
172
|
+
}, timeout);
|
|
173
|
+
|
|
174
|
+
const subId = this.once(event, (data, meta) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
resolve({ data, meta });
|
|
177
|
+
}, { filter });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get dead letter queue
|
|
183
|
+
*/
|
|
184
|
+
getDeadLetters(limit = 50) {
|
|
185
|
+
return this._deadLetter.slice(-limit);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get stats
|
|
190
|
+
*/
|
|
191
|
+
getStats() {
|
|
192
|
+
return {
|
|
193
|
+
...this._stats,
|
|
194
|
+
listeners: this._countListeners(),
|
|
195
|
+
historySize: this._history.length,
|
|
196
|
+
deadLetterSize: this._deadLetter.length,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_countListeners() {
|
|
201
|
+
let count = 0;
|
|
202
|
+
for (const [, subs] of this._listeners) count += subs.size;
|
|
203
|
+
return count;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Singleton event bus for the runtime
|
|
208
|
+
const bus = new EventBus();
|
|
209
|
+
|
|
210
|
+
module.exports = { EventBus, bus };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WAB Runtime - Main Entry Point
|
|
5
|
+
*
|
|
6
|
+
* The Runtime is the core of the Agent OS. It provides:
|
|
7
|
+
* - Task scheduling with retries and timeouts
|
|
8
|
+
* - Agent state management with checkpoints
|
|
9
|
+
* - Execution sandboxing
|
|
10
|
+
* - Event-driven architecture
|
|
11
|
+
*
|
|
12
|
+
* This is NOT a browser wrapper - it's a real execution runtime
|
|
13
|
+
* comparable to Ray or Temporal.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { EventBus, bus } = require('./event-bus');
|
|
17
|
+
const { Scheduler, TaskState } = require('./scheduler');
|
|
18
|
+
const { StateManager } = require('./state-manager');
|
|
19
|
+
const { ExecutionSandbox } = require('./sandbox');
|
|
20
|
+
|
|
21
|
+
class WABRuntime {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.scheduler = new Scheduler({
|
|
24
|
+
maxConcurrent: options.maxConcurrentTasks || 20,
|
|
25
|
+
maxQueueSize: options.maxQueueSize || 1000,
|
|
26
|
+
defaultRetries: options.defaultRetries || 3,
|
|
27
|
+
defaultTimeout: options.defaultTimeout || 30000,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
this.state = new StateManager({
|
|
31
|
+
maxCheckpoints: options.maxCheckpoints || 50,
|
|
32
|
+
ttl: options.stateTTL || 24 * 3600_000,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this.sandbox = new ExecutionSandbox({
|
|
36
|
+
maxConcurrent: options.maxSandboxes || 100,
|
|
37
|
+
defaultTimeout: options.sandboxTimeout || 30000,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.events = bus;
|
|
41
|
+
this._started = false;
|
|
42
|
+
this._cleanupTimer = null;
|
|
43
|
+
|
|
44
|
+
// Register built-in task handlers
|
|
45
|
+
this._registerBuiltinHandlers();
|
|
46
|
+
|
|
47
|
+
// Wire events
|
|
48
|
+
this._wireEventHandlers();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Start the runtime
|
|
53
|
+
*/
|
|
54
|
+
start() {
|
|
55
|
+
if (this._started) return;
|
|
56
|
+
this._started = true;
|
|
57
|
+
|
|
58
|
+
// Periodic cleanup
|
|
59
|
+
this._cleanupTimer = setInterval(() => {
|
|
60
|
+
this.state.cleanup();
|
|
61
|
+
this.sandbox.cleanup();
|
|
62
|
+
}, 600_000); // 10 min
|
|
63
|
+
if (this._cleanupTimer.unref) this._cleanupTimer.unref();
|
|
64
|
+
|
|
65
|
+
this.events.emit('runtime.started', {
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
capabilities: this.getCapabilities(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stop the runtime
|
|
73
|
+
*/
|
|
74
|
+
stop() {
|
|
75
|
+
if (!this._started) return;
|
|
76
|
+
this._started = false;
|
|
77
|
+
if (this._cleanupTimer) clearInterval(this._cleanupTimer);
|
|
78
|
+
this.events.emit('runtime.stopped', { timestamp: Date.now() });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Submit a task
|
|
83
|
+
*/
|
|
84
|
+
submitTask(task) {
|
|
85
|
+
if (!this._started) throw new Error('Runtime not started');
|
|
86
|
+
return this.scheduler.submit(task);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Execute a task in a sandbox with full lifecycle
|
|
91
|
+
*/
|
|
92
|
+
async executeInSandbox(taskId, handler, options = {}) {
|
|
93
|
+
// Create sandbox
|
|
94
|
+
const sbx = this.sandbox.create(taskId, options);
|
|
95
|
+
|
|
96
|
+
// Save initial state
|
|
97
|
+
this.state.save(taskId, { status: 'sandbox_created', sandboxId: sbx.id });
|
|
98
|
+
this.state.checkpoint(taskId, 'pre-execution');
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const result = await this.sandbox.execute(sbx.id, handler);
|
|
102
|
+
|
|
103
|
+
if (result.success) {
|
|
104
|
+
this.state.save(taskId, { status: 'completed', result: result.result });
|
|
105
|
+
} else {
|
|
106
|
+
this.state.save(taskId, { status: 'failed', error: result.error });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return result;
|
|
110
|
+
} finally {
|
|
111
|
+
// Cleanup sandbox after a delay
|
|
112
|
+
setTimeout(() => this.sandbox.destroy(sbx.id), 60000);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Register a task type handler
|
|
118
|
+
*/
|
|
119
|
+
registerTaskHandler(taskType, handler) {
|
|
120
|
+
this.scheduler.registerHandler(taskType, handler);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get runtime capabilities for protocol exposure
|
|
125
|
+
*/
|
|
126
|
+
getCapabilities() {
|
|
127
|
+
return {
|
|
128
|
+
scheduler: { maxConcurrent: 20, retries: true, deadlines: true, dependencies: true },
|
|
129
|
+
state: { checkpoints: true, rollback: true, ttl: true },
|
|
130
|
+
sandbox: { isolation: true, resourceLimits: true, auditTrail: true },
|
|
131
|
+
events: { async: true, replay: true, wildcards: true, deadLetter: true },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get runtime health and stats
|
|
137
|
+
*/
|
|
138
|
+
getHealth() {
|
|
139
|
+
return {
|
|
140
|
+
status: this._started ? 'running' : 'stopped',
|
|
141
|
+
uptime: this._started ? Date.now() : 0,
|
|
142
|
+
scheduler: this.scheduler.getStats(),
|
|
143
|
+
state: this.state.getStats(),
|
|
144
|
+
sandbox: this.sandbox.getStats(),
|
|
145
|
+
events: this.events.getStats(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Built-in Handlers ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
_registerBuiltinHandlers() {
|
|
152
|
+
// Browser task handler (delegated to data plane)
|
|
153
|
+
this.scheduler.registerHandler('browser', async (params, ctx) => {
|
|
154
|
+
ctx.reportProgress(10, 0);
|
|
155
|
+
// Browser tasks are handled by data-plane executor
|
|
156
|
+
return { delegated: 'data-plane', params };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// API task handler
|
|
160
|
+
this.scheduler.registerHandler('api', async (params, ctx) => {
|
|
161
|
+
ctx.reportProgress(10, 0);
|
|
162
|
+
return { delegated: 'data-plane', params };
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Extraction task handler
|
|
166
|
+
this.scheduler.registerHandler('extraction', async (params, ctx) => {
|
|
167
|
+
ctx.reportProgress(10, 0);
|
|
168
|
+
return { delegated: 'data-plane', params };
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Workflow (composite) handler
|
|
172
|
+
this.scheduler.registerHandler('workflow', async (params, ctx) => {
|
|
173
|
+
const results = [];
|
|
174
|
+
for (let i = 0; i < ctx.steps.length; i++) {
|
|
175
|
+
ctx.reportProgress(Math.floor((i / ctx.steps.length) * 100), i);
|
|
176
|
+
ctx.checkpoint({ step: i, results });
|
|
177
|
+
|
|
178
|
+
const step = ctx.steps[i];
|
|
179
|
+
const handler = this.scheduler._handlers.get(step.type || 'general');
|
|
180
|
+
if (handler) {
|
|
181
|
+
const result = await handler(step.params || {}, ctx);
|
|
182
|
+
results.push({ step: i, result });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
ctx.reportProgress(100, ctx.steps.length);
|
|
186
|
+
return { steps: results };
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Composite task (parallel execution)
|
|
190
|
+
this.scheduler.registerHandler('composite', async (params, ctx) => {
|
|
191
|
+
if (!params.tasks || !Array.isArray(params.tasks)) {
|
|
192
|
+
throw new Error('Composite task requires tasks array');
|
|
193
|
+
}
|
|
194
|
+
const promises = params.tasks.map((t, i) => {
|
|
195
|
+
const handler = this.scheduler._handlers.get(t.type || 'general');
|
|
196
|
+
if (!handler) return { step: i, error: `No handler for: ${t.type}` };
|
|
197
|
+
return handler(t.params || {}, ctx).then(r => ({ step: i, result: r }));
|
|
198
|
+
});
|
|
199
|
+
return { results: await Promise.allSettled(promises) };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// General purpose handler (pass-through)
|
|
203
|
+
this.scheduler.registerHandler('general', async (params) => {
|
|
204
|
+
return { received: params };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Event Wiring ────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
_wireEventHandlers() {
|
|
211
|
+
// Track task states in state manager
|
|
212
|
+
this.events.on('task.started', (data) => {
|
|
213
|
+
this.state.save(data.taskId, { status: 'running', startedAt: Date.now(), attempt: data.attempt });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
this.events.on('task.completed', (data) => {
|
|
217
|
+
this.state.merge(data.taskId, { status: 'completed', duration: data.duration });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
this.events.on('task.failed', (data) => {
|
|
221
|
+
this.state.merge(data.taskId, { status: 'failed', error: data.error, attempts: data.attempts });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.events.on('task.progress', (data) => {
|
|
225
|
+
this.state.merge(data.taskId, { progress: data.progress, currentStep: data.step });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Singleton runtime
|
|
231
|
+
const runtime = new WABRuntime();
|
|
232
|
+
|
|
233
|
+
module.exports = { WABRuntime, runtime, EventBus, bus, Scheduler, TaskState, StateManager, ExecutionSandbox };
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WAB Runtime - Execution Sandbox
|
|
5
|
+
*
|
|
6
|
+
* Isolates task execution. Each task runs in its own sandbox with:
|
|
7
|
+
* - Resource limits (memory, CPU time, network)
|
|
8
|
+
* - Permission boundaries
|
|
9
|
+
* - Isolated state
|
|
10
|
+
* - Audit trail
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
class ExecutionSandbox {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
this._sandboxes = new Map();
|
|
18
|
+
this._maxConcurrent = options.maxConcurrent || 100;
|
|
19
|
+
this._defaultTimeout = options.defaultTimeout || 30000;
|
|
20
|
+
this._stats = { created: 0, completed: 0, failed: 0, timedOut: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a new sandbox for a task
|
|
25
|
+
*/
|
|
26
|
+
create(taskId, options = {}) {
|
|
27
|
+
if (this._sandboxes.size >= this._maxConcurrent) {
|
|
28
|
+
throw new Error('Maximum concurrent sandbox limit reached');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sandbox = {
|
|
32
|
+
id: `sbx_${crypto.randomBytes(12).toString('hex')}`,
|
|
33
|
+
taskId,
|
|
34
|
+
agentId: options.agentId || null,
|
|
35
|
+
siteId: options.siteId || null,
|
|
36
|
+
|
|
37
|
+
// Resource limits
|
|
38
|
+
limits: {
|
|
39
|
+
timeout: options.timeout || this._defaultTimeout,
|
|
40
|
+
maxMemory: options.maxMemory || 128 * 1024 * 1024, // 128MB
|
|
41
|
+
maxNetworkCalls: options.maxNetworkCalls || 100,
|
|
42
|
+
maxDomOperations: options.maxDomOperations || 1000,
|
|
43
|
+
allowedDomains: options.allowedDomains || ['*'],
|
|
44
|
+
blockedSelectors: options.blockedSelectors || [],
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// Runtime state
|
|
48
|
+
state: 'created',
|
|
49
|
+
usage: {
|
|
50
|
+
networkCalls: 0,
|
|
51
|
+
domOperations: 0,
|
|
52
|
+
startedAt: null,
|
|
53
|
+
completedAt: null,
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Permission boundaries
|
|
57
|
+
capabilities: new Set(options.capabilities || []),
|
|
58
|
+
|
|
59
|
+
// Audit trail
|
|
60
|
+
audit: [],
|
|
61
|
+
|
|
62
|
+
// Isolated key-value store
|
|
63
|
+
store: new Map(),
|
|
64
|
+
|
|
65
|
+
createdAt: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
this._sandboxes.set(sandbox.id, sandbox);
|
|
69
|
+
this._stats.created++;
|
|
70
|
+
return sandbox;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Execute a function within a sandbox
|
|
75
|
+
*/
|
|
76
|
+
async execute(sandboxId, fn) {
|
|
77
|
+
const sandbox = this._sandboxes.get(sandboxId);
|
|
78
|
+
if (!sandbox) throw new Error(`Sandbox not found: ${sandboxId}`);
|
|
79
|
+
if (sandbox.state !== 'created' && sandbox.state !== 'running') {
|
|
80
|
+
throw new Error(`Sandbox ${sandboxId} is in state ${sandbox.state}, cannot execute`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
sandbox.state = 'running';
|
|
84
|
+
sandbox.usage.startedAt = Date.now();
|
|
85
|
+
|
|
86
|
+
// Create scoped context for the function
|
|
87
|
+
const context = this._createContext(sandbox);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const result = await _withTimeout(fn(context), sandbox.limits.timeout);
|
|
91
|
+
sandbox.state = 'completed';
|
|
92
|
+
sandbox.usage.completedAt = Date.now();
|
|
93
|
+
this._stats.completed++;
|
|
94
|
+
|
|
95
|
+
sandbox.audit.push({
|
|
96
|
+
action: 'complete',
|
|
97
|
+
timestamp: Date.now(),
|
|
98
|
+
duration: sandbox.usage.completedAt - sandbox.usage.startedAt,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return { success: true, result, sandbox: this._getSandboxSummary(sandbox) };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
sandbox.state = err.message.includes('timed out') ? 'timeout' : 'failed';
|
|
104
|
+
sandbox.usage.completedAt = Date.now();
|
|
105
|
+
|
|
106
|
+
if (sandbox.state === 'timeout') this._stats.timedOut++;
|
|
107
|
+
else this._stats.failed++;
|
|
108
|
+
|
|
109
|
+
sandbox.audit.push({
|
|
110
|
+
action: sandbox.state,
|
|
111
|
+
timestamp: Date.now(),
|
|
112
|
+
error: err.message,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return { success: false, error: err.message, sandbox: this._getSandboxSummary(sandbox) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a scoped execution context
|
|
121
|
+
*/
|
|
122
|
+
_createContext(sandbox) {
|
|
123
|
+
const self = this;
|
|
124
|
+
return {
|
|
125
|
+
taskId: sandbox.taskId,
|
|
126
|
+
agentId: sandbox.agentId,
|
|
127
|
+
siteId: sandbox.siteId,
|
|
128
|
+
|
|
129
|
+
// Capability check
|
|
130
|
+
hasCapability(cap) {
|
|
131
|
+
return sandbox.capabilities.has(cap);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
requireCapability(cap) {
|
|
135
|
+
if (!sandbox.capabilities.has(cap)) {
|
|
136
|
+
throw new Error(`Sandbox lacks capability: ${cap}`);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// Domain check
|
|
141
|
+
checkDomain(domain) {
|
|
142
|
+
if (sandbox.limits.allowedDomains[0] === '*') return true;
|
|
143
|
+
return sandbox.limits.allowedDomains.some(d => domain.endsWith(d));
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// Resource tracking
|
|
147
|
+
trackNetworkCall() {
|
|
148
|
+
sandbox.usage.networkCalls++;
|
|
149
|
+
if (sandbox.usage.networkCalls > sandbox.limits.maxNetworkCalls) {
|
|
150
|
+
throw new Error('Network call limit exceeded');
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
trackDomOperation() {
|
|
155
|
+
sandbox.usage.domOperations++;
|
|
156
|
+
if (sandbox.usage.domOperations > sandbox.limits.maxDomOperations) {
|
|
157
|
+
throw new Error('DOM operation limit exceeded');
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// Isolated store
|
|
162
|
+
set(key, value) { sandbox.store.set(key, value); },
|
|
163
|
+
get(key) { return sandbox.store.get(key); },
|
|
164
|
+
|
|
165
|
+
// Audit
|
|
166
|
+
log(action, details) {
|
|
167
|
+
sandbox.audit.push({ action, details, timestamp: Date.now() });
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
// Selector validation
|
|
171
|
+
checkSelector(selector) {
|
|
172
|
+
for (const blocked of sandbox.limits.blockedSelectors) {
|
|
173
|
+
if (selector.includes(blocked)) {
|
|
174
|
+
throw new Error(`Selector blocked by sandbox policy: ${blocked}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
// Read sandbox time remaining
|
|
181
|
+
get timeRemaining() {
|
|
182
|
+
if (!sandbox.usage.startedAt) return sandbox.limits.timeout;
|
|
183
|
+
return Math.max(0, sandbox.limits.timeout - (Date.now() - sandbox.usage.startedAt));
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get sandbox summary (safe to expose)
|
|
190
|
+
*/
|
|
191
|
+
_getSandboxSummary(sandbox) {
|
|
192
|
+
return {
|
|
193
|
+
id: sandbox.id,
|
|
194
|
+
taskId: sandbox.taskId,
|
|
195
|
+
state: sandbox.state,
|
|
196
|
+
usage: { ...sandbox.usage },
|
|
197
|
+
auditCount: sandbox.audit.length,
|
|
198
|
+
duration: sandbox.usage.completedAt
|
|
199
|
+
? sandbox.usage.completedAt - sandbox.usage.startedAt
|
|
200
|
+
: null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Destroy a sandbox and free resources
|
|
206
|
+
*/
|
|
207
|
+
destroy(sandboxId) {
|
|
208
|
+
const sandbox = this._sandboxes.get(sandboxId);
|
|
209
|
+
if (sandbox) {
|
|
210
|
+
sandbox.store.clear();
|
|
211
|
+
this._sandboxes.delete(sandboxId);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get audit trail for a sandbox
|
|
217
|
+
*/
|
|
218
|
+
getAudit(sandboxId) {
|
|
219
|
+
const sandbox = this._sandboxes.get(sandboxId);
|
|
220
|
+
return sandbox ? [...sandbox.audit] : [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* List active sandboxes
|
|
225
|
+
*/
|
|
226
|
+
listActive() {
|
|
227
|
+
const active = [];
|
|
228
|
+
for (const [, sb] of this._sandboxes) {
|
|
229
|
+
if (sb.state === 'created' || sb.state === 'running') {
|
|
230
|
+
active.push(this._getSandboxSummary(sb));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return active;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
getStats() {
|
|
237
|
+
return { ...this._stats, active: this._sandboxes.size };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Cleanup completed/failed sandboxes older than maxAge
|
|
242
|
+
*/
|
|
243
|
+
cleanup(maxAge = 3600_000) {
|
|
244
|
+
const cutoff = Date.now() - maxAge;
|
|
245
|
+
let cleaned = 0;
|
|
246
|
+
for (const [id, sb] of this._sandboxes) {
|
|
247
|
+
if (sb.state !== 'created' && sb.state !== 'running' && sb.createdAt < cutoff) {
|
|
248
|
+
sb.store.clear();
|
|
249
|
+
this._sandboxes.delete(id);
|
|
250
|
+
cleaned++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return cleaned;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _withTimeout(promise, ms) {
|
|
258
|
+
if (!ms || ms <= 0) return promise;
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
const timer = setTimeout(() => reject(new Error(`Sandbox execution timed out after ${ms}ms`)), ms);
|
|
261
|
+
promise.then(r => { clearTimeout(timer); resolve(r); })
|
|
262
|
+
.catch(e => { clearTimeout(timer); reject(e); });
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { ExecutionSandbox };
|