web-agent-bridge 2.5.0 → 2.7.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,264 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Deterministic Replay Engine
5
+ *
6
+ * Records all task inputs/outputs/side-effects for deterministic replay.
7
+ * Enables debugging, testing, and verification of agent workflows.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+ const { bus } = require('../runtime/event-bus');
12
+
13
+ class ReplayEngine {
14
+ constructor() {
15
+ this._recordings = new Map(); // taskId → Recording
16
+ this._maxRecordings = 5000;
17
+ this._recordingEnabled = true;
18
+ }
19
+
20
+ /**
21
+ * Start recording a task execution
22
+ */
23
+ startRecording(taskId, input) {
24
+ if (!this._recordingEnabled) return null;
25
+
26
+ const recording = {
27
+ id: `rec_${crypto.randomBytes(8).toString('hex')}`,
28
+ taskId,
29
+ input: this._deepClone(input),
30
+ steps: [],
31
+ sideEffects: [],
32
+ startedAt: Date.now(),
33
+ completedAt: null,
34
+ output: null,
35
+ error: null,
36
+ checksum: null,
37
+ replayable: true,
38
+ };
39
+
40
+ this._recordings.set(taskId, recording);
41
+ this._evict();
42
+ return recording.id;
43
+ }
44
+
45
+ /**
46
+ * Record a step in the execution
47
+ */
48
+ recordStep(taskId, step) {
49
+ const rec = this._recordings.get(taskId);
50
+ if (!rec) return;
51
+
52
+ rec.steps.push({
53
+ index: rec.steps.length,
54
+ type: step.type,
55
+ action: step.action,
56
+ input: this._deepClone(step.input),
57
+ output: this._deepClone(step.output),
58
+ duration: step.duration || 0,
59
+ timestamp: Date.now(),
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Record a side effect (network call, DOM mutation, storage write, etc.)
65
+ */
66
+ recordSideEffect(taskId, effect) {
67
+ const rec = this._recordings.get(taskId);
68
+ if (!rec) return;
69
+
70
+ rec.sideEffects.push({
71
+ index: rec.sideEffects.length,
72
+ type: effect.type, // 'network', 'dom', 'storage', 'event'
73
+ target: effect.target,
74
+ data: this._deepClone(effect.data),
75
+ timestamp: Date.now(),
76
+ reversible: effect.reversible !== false,
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Complete a recording
82
+ */
83
+ completeRecording(taskId, output, error = null) {
84
+ const rec = this._recordings.get(taskId);
85
+ if (!rec) return null;
86
+
87
+ rec.completedAt = Date.now();
88
+ rec.output = this._deepClone(output);
89
+ rec.error = error ? { message: error.message, code: error.code } : null;
90
+ rec.checksum = this._computeChecksum(rec);
91
+
92
+ bus.emit('replay.recording.complete', { taskId, recordingId: rec.id, steps: rec.steps.length });
93
+ return rec;
94
+ }
95
+
96
+ /**
97
+ * Replay a recorded task
98
+ * Returns the replay plan (steps to execute) with recorded outputs for verification
99
+ */
100
+ async replay(taskId, options = {}) {
101
+ const rec = this._recordings.get(taskId);
102
+ if (!rec) throw new Error(`No recording found for task ${taskId}`);
103
+ if (!rec.completedAt) throw new Error('Recording not yet complete');
104
+
105
+ const replayResult = {
106
+ recordingId: rec.id,
107
+ taskId,
108
+ originalInput: rec.input,
109
+ originalOutput: rec.output,
110
+ steps: [],
111
+ match: true,
112
+ verificationMode: options.verify !== false,
113
+ replayedAt: Date.now(),
114
+ };
115
+
116
+ // In verification mode, run each step and compare outputs
117
+ if (options.executor && options.verify !== false) {
118
+ for (const step of rec.steps) {
119
+ try {
120
+ const replayOutput = await options.executor(step);
121
+ const outputMatch = this._deepEqual(step.output, replayOutput);
122
+
123
+ replayResult.steps.push({
124
+ index: step.index,
125
+ action: step.action,
126
+ originalOutput: step.output,
127
+ replayOutput,
128
+ match: outputMatch,
129
+ });
130
+
131
+ if (!outputMatch) {
132
+ replayResult.match = false;
133
+ if (!options.continueOnMismatch) break;
134
+ }
135
+ } catch (err) {
136
+ replayResult.steps.push({
137
+ index: step.index,
138
+ action: step.action,
139
+ error: err.message,
140
+ match: false,
141
+ });
142
+ replayResult.match = false;
143
+ if (!options.continueOnMismatch) break;
144
+ }
145
+ }
146
+ } else {
147
+ // Dry-run mode: just return the recorded steps
148
+ replayResult.steps = rec.steps.map(s => ({
149
+ index: s.index,
150
+ action: s.action,
151
+ input: s.input,
152
+ output: s.output,
153
+ duration: s.duration,
154
+ }));
155
+ }
156
+
157
+ bus.emit('replay.completed', { taskId, match: replayResult.match });
158
+ return replayResult;
159
+ }
160
+
161
+ /**
162
+ * Get recording
163
+ */
164
+ getRecording(taskId) {
165
+ return this._recordings.get(taskId) || null;
166
+ }
167
+
168
+ /**
169
+ * List recordings
170
+ */
171
+ listRecordings(limit = 50) {
172
+ const all = Array.from(this._recordings.values());
173
+ return all.slice(-limit).reverse().map(r => ({
174
+ id: r.id,
175
+ taskId: r.taskId,
176
+ steps: r.steps.length,
177
+ sideEffects: r.sideEffects.length,
178
+ startedAt: r.startedAt,
179
+ completedAt: r.completedAt,
180
+ hasError: !!r.error,
181
+ checksum: r.checksum,
182
+ }));
183
+ }
184
+
185
+ /**
186
+ * Compare two recordings
187
+ */
188
+ diff(taskId1, taskId2) {
189
+ const r1 = this._recordings.get(taskId1);
190
+ const r2 = this._recordings.get(taskId2);
191
+ if (!r1 || !r2) return null;
192
+
193
+ const diffs = [];
194
+ const maxSteps = Math.max(r1.steps.length, r2.steps.length);
195
+
196
+ for (let i = 0; i < maxSteps; i++) {
197
+ const s1 = r1.steps[i];
198
+ const s2 = r2.steps[i];
199
+
200
+ if (!s1 || !s2) {
201
+ diffs.push({ index: i, type: 'missing', in: s1 ? 'recording2' : 'recording1' });
202
+ } else if (!this._deepEqual(s1.output, s2.output)) {
203
+ diffs.push({ index: i, type: 'output_mismatch', action: s1.action, output1: s1.output, output2: s2.output });
204
+ }
205
+ }
206
+
207
+ return {
208
+ match: diffs.length === 0,
209
+ inputMatch: this._deepEqual(r1.input, r2.input),
210
+ outputMatch: this._deepEqual(r1.output, r2.output),
211
+ diffs,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Enable/disable recording
217
+ */
218
+ setEnabled(enabled) {
219
+ this._recordingEnabled = enabled;
220
+ }
221
+
222
+ getStats() {
223
+ return {
224
+ totalRecordings: this._recordings.size,
225
+ enabled: this._recordingEnabled,
226
+ maxRecordings: this._maxRecordings,
227
+ };
228
+ }
229
+
230
+ // ── Internal ──
231
+
232
+ _computeChecksum(rec) {
233
+ const data = JSON.stringify({
234
+ input: rec.input,
235
+ steps: rec.steps.map(s => ({ action: s.action, output: s.output })),
236
+ output: rec.output,
237
+ });
238
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
239
+ }
240
+
241
+ _deepClone(obj) {
242
+ if (obj === undefined || obj === null) return obj;
243
+ try {
244
+ return JSON.parse(JSON.stringify(obj));
245
+ } catch {
246
+ return obj;
247
+ }
248
+ }
249
+
250
+ _deepEqual(a, b) {
251
+ return JSON.stringify(a) === JSON.stringify(b);
252
+ }
253
+
254
+ _evict() {
255
+ if (this._recordings.size <= this._maxRecordings) return;
256
+ const keys = Array.from(this._recordings.keys());
257
+ const toRemove = keys.slice(0, keys.length - this._maxRecordings);
258
+ for (const k of toRemove) this._recordings.delete(k);
259
+ }
260
+ }
261
+
262
+ const replayEngine = new ReplayEngine();
263
+
264
+ module.exports = { ReplayEngine, replayEngine };
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Session Engine
5
+ *
6
+ * Manages browser execution sessions with cookies, tokens,
7
+ * and state persistence across navigation.
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+ const { bus } = require('../runtime/event-bus');
12
+
13
+ class SessionEngine {
14
+ constructor() {
15
+ this._sessions = new Map(); // sessionId → BrowserSession
16
+ this._cookieJars = new Map(); // sessionId → CookieJar
17
+ this._maxSessions = 500;
18
+ this._defaultTTL = 3600_000; // 1 hour
19
+ }
20
+
21
+ /**
22
+ * Create a new browser execution session
23
+ */
24
+ create(config = {}) {
25
+ const sessionId = `sess_${crypto.randomBytes(16).toString('hex')}`;
26
+
27
+ const session = {
28
+ id: sessionId,
29
+ agentId: config.agentId || null,
30
+ siteId: config.siteId || null,
31
+ state: 'active',
32
+ viewport: config.viewport || { width: 1920, height: 1080 },
33
+ userAgent: config.userAgent || null,
34
+ proxy: config.proxy || null,
35
+ localStorage: {},
36
+ sessionStorage: {},
37
+ variables: {}, // Arbitrary key-value store for the agent
38
+ history: [],
39
+ createdAt: Date.now(),
40
+ expiresAt: Date.now() + (config.ttl || this._defaultTTL),
41
+ lastActivity: Date.now(),
42
+ };
43
+
44
+ this._sessions.set(sessionId, session);
45
+ this._cookieJars.set(sessionId, new CookieJar());
46
+ this._evict();
47
+
48
+ bus.emit('session.created', { sessionId, agentId: session.agentId, siteId: session.siteId });
49
+ return session;
50
+ }
51
+
52
+ /**
53
+ * Get session
54
+ */
55
+ get(sessionId) {
56
+ const session = this._sessions.get(sessionId);
57
+ if (!session) return null;
58
+ if (session.expiresAt < Date.now()) {
59
+ this.destroy(sessionId);
60
+ return null;
61
+ }
62
+ return session;
63
+ }
64
+
65
+ /**
66
+ * Update session last activity
67
+ */
68
+ touch(sessionId) {
69
+ const session = this._sessions.get(sessionId);
70
+ if (session) session.lastActivity = Date.now();
71
+ }
72
+
73
+ /**
74
+ * Set cookies for a session
75
+ */
76
+ setCookies(sessionId, cookies) {
77
+ const jar = this._cookieJars.get(sessionId);
78
+ if (!jar) return;
79
+ for (const cookie of cookies) {
80
+ jar.set(cookie);
81
+ }
82
+ this.touch(sessionId);
83
+ }
84
+
85
+ /**
86
+ * Get cookies for a session/domain
87
+ */
88
+ getCookies(sessionId, domain = null) {
89
+ const jar = this._cookieJars.get(sessionId);
90
+ if (!jar) return [];
91
+ return jar.getAll(domain);
92
+ }
93
+
94
+ /**
95
+ * Set localStorage value
96
+ */
97
+ setStorage(sessionId, key, value, type = 'local') {
98
+ const session = this._sessions.get(sessionId);
99
+ if (!session) return;
100
+ const store = type === 'session' ? session.sessionStorage : session.localStorage;
101
+ store[key] = value;
102
+ this.touch(sessionId);
103
+ }
104
+
105
+ /**
106
+ * Get localStorage value
107
+ */
108
+ getStorage(sessionId, key, type = 'local') {
109
+ const session = this._sessions.get(sessionId);
110
+ if (!session) return null;
111
+ const store = type === 'session' ? session.sessionStorage : session.localStorage;
112
+ return store[key] || null;
113
+ }
114
+
115
+ /**
116
+ * Set variable in session
117
+ */
118
+ setVariable(sessionId, key, value) {
119
+ const session = this._sessions.get(sessionId);
120
+ if (session) {
121
+ session.variables[key] = value;
122
+ this.touch(sessionId);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Get variable from session
128
+ */
129
+ getVariable(sessionId, key) {
130
+ const session = this._sessions.get(sessionId);
131
+ return session?.variables[key] || null;
132
+ }
133
+
134
+ /**
135
+ * Record navigation in session history
136
+ */
137
+ recordNavigation(sessionId, url, title = '') {
138
+ const session = this._sessions.get(sessionId);
139
+ if (!session) return;
140
+ session.history.push({
141
+ url,
142
+ title,
143
+ timestamp: Date.now(),
144
+ index: session.history.length,
145
+ });
146
+ this.touch(sessionId);
147
+ }
148
+
149
+ /**
150
+ * Export session state (for transfer/persistence)
151
+ */
152
+ export(sessionId) {
153
+ const session = this._sessions.get(sessionId);
154
+ if (!session) return null;
155
+ return {
156
+ id: session.id,
157
+ agentId: session.agentId,
158
+ siteId: session.siteId,
159
+ viewport: session.viewport,
160
+ userAgent: session.userAgent,
161
+ localStorage: { ...session.localStorage },
162
+ sessionStorage: { ...session.sessionStorage },
163
+ variables: { ...session.variables },
164
+ cookies: this.getCookies(sessionId),
165
+ history: [...session.history],
166
+ createdAt: session.createdAt,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Import session state (restore from exported data)
172
+ */
173
+ import(data) {
174
+ const session = this.create({
175
+ agentId: data.agentId,
176
+ siteId: data.siteId,
177
+ viewport: data.viewport,
178
+ userAgent: data.userAgent,
179
+ });
180
+
181
+ session.localStorage = data.localStorage || {};
182
+ session.sessionStorage = data.sessionStorage || {};
183
+ session.variables = data.variables || {};
184
+ session.history = data.history || [];
185
+
186
+ if (data.cookies) {
187
+ this.setCookies(session.id, data.cookies);
188
+ }
189
+
190
+ return session;
191
+ }
192
+
193
+ /**
194
+ * Destroy session
195
+ */
196
+ destroy(sessionId) {
197
+ this._sessions.delete(sessionId);
198
+ this._cookieJars.delete(sessionId);
199
+ bus.emit('session.destroyed', { sessionId });
200
+ }
201
+
202
+ /**
203
+ * List sessions
204
+ */
205
+ list(filters = {}, limit = 50) {
206
+ const now = Date.now();
207
+ let sessions = Array.from(this._sessions.values()).filter(s => s.expiresAt >= now);
208
+
209
+ if (filters.agentId) sessions = sessions.filter(s => s.agentId === filters.agentId);
210
+ if (filters.siteId) sessions = sessions.filter(s => s.siteId === filters.siteId);
211
+ if (filters.state) sessions = sessions.filter(s => s.state === filters.state);
212
+
213
+ return sessions.slice(0, limit).map(s => ({
214
+ id: s.id,
215
+ agentId: s.agentId,
216
+ siteId: s.siteId,
217
+ state: s.state,
218
+ historyLength: s.history.length,
219
+ createdAt: s.createdAt,
220
+ lastActivity: s.lastActivity,
221
+ }));
222
+ }
223
+
224
+ getStats() {
225
+ return {
226
+ activeSessions: this._sessions.size,
227
+ maxSessions: this._maxSessions,
228
+ };
229
+ }
230
+
231
+ _evict() {
232
+ const now = Date.now();
233
+ for (const [id, session] of this._sessions) {
234
+ if (session.expiresAt < now) this.destroy(id);
235
+ }
236
+ if (this._sessions.size > this._maxSessions) {
237
+ const sorted = Array.from(this._sessions.entries())
238
+ .sort((a, b) => a[1].lastActivity - b[1].lastActivity);
239
+ const toRemove = sorted.slice(0, sorted.length - this._maxSessions);
240
+ for (const [id] of toRemove) this.destroy(id);
241
+ }
242
+ }
243
+ }
244
+
245
+ // ─── Cookie Jar ─────────────────────────────────────────────────────────────
246
+
247
+ class CookieJar {
248
+ constructor() {
249
+ this._cookies = new Map(); // domain:name → cookie
250
+ }
251
+
252
+ set(cookie) {
253
+ const key = `${cookie.domain || '*'}:${cookie.name}`;
254
+ this._cookies.set(key, {
255
+ name: cookie.name,
256
+ value: cookie.value,
257
+ domain: cookie.domain || '*',
258
+ path: cookie.path || '/',
259
+ expires: cookie.expires || null,
260
+ httpOnly: cookie.httpOnly || false,
261
+ secure: cookie.secure || false,
262
+ sameSite: cookie.sameSite || 'Lax',
263
+ setAt: Date.now(),
264
+ });
265
+ }
266
+
267
+ get(name, domain = '*') {
268
+ return this._cookies.get(`${domain}:${name}`) || null;
269
+ }
270
+
271
+ getAll(domain = null) {
272
+ const now = Date.now();
273
+ const result = [];
274
+ for (const cookie of this._cookies.values()) {
275
+ if (cookie.expires && cookie.expires < now) continue;
276
+ if (domain && cookie.domain !== '*' && cookie.domain !== domain) continue;
277
+ result.push({ ...cookie });
278
+ }
279
+ return result;
280
+ }
281
+
282
+ delete(name, domain = '*') {
283
+ this._cookies.delete(`${domain}:${name}`);
284
+ }
285
+
286
+ clear() {
287
+ this._cookies.clear();
288
+ }
289
+ }
290
+
291
+ const sessionEngine = new SessionEngine();
292
+
293
+ module.exports = { SessionEngine, CookieJar, sessionEngine };
@@ -184,6 +184,19 @@ class AgentIdentity {
184
184
  }
185
185
  }
186
186
 
187
+ /**
188
+ * Validate a session token and return session data
189
+ */
190
+ validateSession(token) {
191
+ const session = this._sessions.get(token);
192
+ if (!session) return null;
193
+ if (Date.now() > session.expiresAt) {
194
+ this._sessions.delete(token);
195
+ return null;
196
+ }
197
+ return session;
198
+ }
199
+
187
200
  /**
188
201
  * Cleanup expired sessions
189
202
  */