web-agent-bridge 1.0.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.ar.md +446 -0
- package/README.md +544 -0
- package/bin/cli.js +80 -0
- package/bin/wab.js +80 -0
- package/examples/bidi-agent.js +119 -0
- package/examples/puppeteer-agent.js +108 -0
- package/examples/vision-agent.js +159 -0
- package/package.json +67 -0
- package/public/css/styles.css +1235 -0
- package/public/dashboard.html +566 -0
- package/public/docs.html +582 -0
- package/public/index.html +306 -0
- package/public/login.html +81 -0
- package/public/register.html +94 -0
- package/script/ai-agent-bridge.js +1282 -0
- package/sdk/README.md +55 -0
- package/sdk/index.js +167 -0
- package/sdk/package.json +14 -0
- package/server/index.js +105 -0
- package/server/middleware/auth.js +44 -0
- package/server/models/adapters/index.js +33 -0
- package/server/models/adapters/mysql.js +183 -0
- package/server/models/adapters/postgresql.js +172 -0
- package/server/models/adapters/sqlite.js +7 -0
- package/server/models/db.js +205 -0
- package/server/routes/api.js +121 -0
- package/server/routes/auth.js +51 -0
- package/server/routes/license.js +51 -0
- package/server/ws.js +81 -0
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Agent Bridge v1.0.0
|
|
3
|
+
* Open-source middleware for AI agent ↔ website interaction
|
|
4
|
+
* https://github.com/web-agent-bridge
|
|
5
|
+
* License: MIT
|
|
6
|
+
*/
|
|
7
|
+
(function (global) {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const VERSION = '1.0.0';
|
|
11
|
+
const LICENSING_SERVER = 'https://api.webagentbridge.com';
|
|
12
|
+
|
|
13
|
+
// ─── Default Configuration ────────────────────────────────────────────
|
|
14
|
+
const DEFAULT_CONFIG = {
|
|
15
|
+
agentPermissions: {
|
|
16
|
+
readContent: true,
|
|
17
|
+
click: true,
|
|
18
|
+
fillForms: false,
|
|
19
|
+
scroll: true,
|
|
20
|
+
navigate: false,
|
|
21
|
+
apiAccess: false,
|
|
22
|
+
automatedLogin: false,
|
|
23
|
+
extractData: false
|
|
24
|
+
},
|
|
25
|
+
features: {
|
|
26
|
+
advancedAnalytics: false,
|
|
27
|
+
realTimeUpdates: false,
|
|
28
|
+
customActions: false,
|
|
29
|
+
webhooks: false
|
|
30
|
+
},
|
|
31
|
+
restrictions: {
|
|
32
|
+
allowedSelectors: [],
|
|
33
|
+
blockedSelectors: ['.private', '[data-private]', '[data-no-agent]'],
|
|
34
|
+
requireLoginForActions: [],
|
|
35
|
+
rateLimit: { maxCallsPerMinute: 60 }
|
|
36
|
+
},
|
|
37
|
+
logging: {
|
|
38
|
+
enabled: false,
|
|
39
|
+
level: 'basic'
|
|
40
|
+
},
|
|
41
|
+
subscriptionTier: 'free',
|
|
42
|
+
licenseKey: null
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ─── Rate Limiter ─────────────────────────────────────────────────────
|
|
46
|
+
class RateLimiter {
|
|
47
|
+
constructor(maxPerMinute) {
|
|
48
|
+
this.maxPerMinute = maxPerMinute;
|
|
49
|
+
this.calls = [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
check() {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
this.calls = this.calls.filter(t => now - t < 60000);
|
|
55
|
+
if (this.calls.length >= this.maxPerMinute) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
this.calls.push(now);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get remaining() {
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
this.calls = this.calls.filter(t => now - t < 60000);
|
|
65
|
+
return Math.max(0, this.maxPerMinute - this.calls.length);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Logger ───────────────────────────────────────────────────────────
|
|
70
|
+
class BridgeLogger {
|
|
71
|
+
constructor(config) {
|
|
72
|
+
this.enabled = config.enabled;
|
|
73
|
+
this.level = config.level;
|
|
74
|
+
this.logs = [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
log(action, details, level = 'basic') {
|
|
78
|
+
if (!this.enabled) return;
|
|
79
|
+
if (level === 'detailed' && this.level !== 'detailed') return;
|
|
80
|
+
|
|
81
|
+
const entry = {
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
action,
|
|
84
|
+
details,
|
|
85
|
+
level
|
|
86
|
+
};
|
|
87
|
+
this.logs.push(entry);
|
|
88
|
+
|
|
89
|
+
if (this.logs.length > 1000) {
|
|
90
|
+
this.logs = this.logs.slice(-500);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getLogs(filter) {
|
|
95
|
+
if (!filter) return [...this.logs];
|
|
96
|
+
return this.logs.filter(l => l.action === filter);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
clear() {
|
|
100
|
+
this.logs = [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Security Sandbox ──────────────────────────────────────────────────
|
|
105
|
+
// Isolates the bridge with origin validation, session tokens, and audit log
|
|
106
|
+
class SecuritySandbox {
|
|
107
|
+
constructor(config) {
|
|
108
|
+
this._sessionToken = this._generateToken();
|
|
109
|
+
this._allowedOrigins = config.security?.allowedOrigins || [location.origin];
|
|
110
|
+
this._auditLog = [];
|
|
111
|
+
this._maxAuditEntries = 500;
|
|
112
|
+
this._commandCounter = 0;
|
|
113
|
+
this._blockedCommands = new Set();
|
|
114
|
+
this._escalationAttempts = 0;
|
|
115
|
+
this._maxEscalationAttempts = 5;
|
|
116
|
+
this._locked = false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_generateToken() {
|
|
120
|
+
const arr = new Uint8Array(32);
|
|
121
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
122
|
+
crypto.getRandomValues(arr);
|
|
123
|
+
} else {
|
|
124
|
+
for (let i = 0; i < 32; i++) arr[i] = Math.floor(Math.random() * 256);
|
|
125
|
+
}
|
|
126
|
+
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get sessionToken() { return this._sessionToken; }
|
|
130
|
+
|
|
131
|
+
validateOrigin(origin) {
|
|
132
|
+
if (!origin) return this._allowedOrigins.includes(location.origin);
|
|
133
|
+
return this._allowedOrigins.includes(origin);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
audit(action, details, status = 'ok') {
|
|
137
|
+
const entry = {
|
|
138
|
+
id: ++this._commandCounter,
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
action,
|
|
141
|
+
status,
|
|
142
|
+
fingerprint: details?.agentId || 'anonymous'
|
|
143
|
+
};
|
|
144
|
+
this._auditLog.push(entry);
|
|
145
|
+
if (this._auditLog.length > this._maxAuditEntries) {
|
|
146
|
+
this._auditLog = this._auditLog.slice(-250);
|
|
147
|
+
}
|
|
148
|
+
return entry;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getAuditLog(limit = 50) {
|
|
152
|
+
return this._auditLog.slice(-limit);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
checkEscalation(requestedTier, currentTier) {
|
|
156
|
+
const tierLevel = { free: 0, starter: 1, pro: 2, enterprise: 3 };
|
|
157
|
+
if ((tierLevel[requestedTier] || 0) > (tierLevel[currentTier] || 0)) {
|
|
158
|
+
this._escalationAttempts++;
|
|
159
|
+
this.audit('escalation_attempt', { requestedTier, currentTier }, 'blocked');
|
|
160
|
+
if (this._escalationAttempts >= this._maxEscalationAttempts) {
|
|
161
|
+
this._locked = true;
|
|
162
|
+
this.audit('bridge_locked', { reason: 'Too many escalation attempts' }, 'critical');
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get isLocked() { return this._locked; }
|
|
170
|
+
|
|
171
|
+
validateCommand(command) {
|
|
172
|
+
if (this._locked) return { valid: false, error: 'Bridge is locked due to security violations' };
|
|
173
|
+
if (typeof command !== 'object' || !command) return { valid: false, error: 'Invalid command format' };
|
|
174
|
+
if (typeof command.method !== 'string') return { valid: false, error: 'Command method must be a string' };
|
|
175
|
+
if (command.method.length > 200) return { valid: false, error: 'Command method too long' };
|
|
176
|
+
if (this._blockedCommands.has(command.method)) return { valid: false, error: 'Command is blocked' };
|
|
177
|
+
return { valid: true };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Self-Healing Selectors ───────────────────────────────────────────
|
|
182
|
+
// Resilient element resolution for SPAs with dynamic DOM
|
|
183
|
+
class SelfHealingSelector {
|
|
184
|
+
constructor() {
|
|
185
|
+
this._fingerprints = new Map(); // name → element fingerprint
|
|
186
|
+
this._healingStats = { healed: 0, failed: 0 };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fingerprint(el) {
|
|
190
|
+
if (!el) return null;
|
|
191
|
+
return {
|
|
192
|
+
tag: el.tagName.toLowerCase(),
|
|
193
|
+
id: el.id || null,
|
|
194
|
+
classes: Array.from(el.classList),
|
|
195
|
+
text: (el.textContent || '').trim().slice(0, 100),
|
|
196
|
+
ariaLabel: el.getAttribute('aria-label') || null,
|
|
197
|
+
name: el.getAttribute('name') || null,
|
|
198
|
+
type: el.getAttribute('type') || null,
|
|
199
|
+
role: el.getAttribute('role') || null,
|
|
200
|
+
dataTestId: el.getAttribute('data-testid') || null,
|
|
201
|
+
dataWabId: el.getAttribute('data-wab-id') || null,
|
|
202
|
+
href: el.tagName === 'A' ? el.getAttribute('href') : null,
|
|
203
|
+
placeholder: el.getAttribute('placeholder') || null,
|
|
204
|
+
parentTag: el.parentElement ? el.parentElement.tagName.toLowerCase() : null,
|
|
205
|
+
index: el.parentElement ? Array.from(el.parentElement.children).indexOf(el) : -1
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
store(actionName, selector) {
|
|
210
|
+
const el = safeQuerySelector(selector);
|
|
211
|
+
if (el) {
|
|
212
|
+
this._fingerprints.set(actionName, {
|
|
213
|
+
selector,
|
|
214
|
+
fp: this.fingerprint(el),
|
|
215
|
+
storedAt: Date.now()
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
resolve(actionName, originalSelector) {
|
|
221
|
+
// Try original selector first
|
|
222
|
+
const el = safeQuerySelector(originalSelector);
|
|
223
|
+
if (el) return { element: el, selector: originalSelector, healed: false };
|
|
224
|
+
|
|
225
|
+
// Try self-healing
|
|
226
|
+
const stored = this._fingerprints.get(actionName);
|
|
227
|
+
if (!stored || !stored.fp) {
|
|
228
|
+
this._healingStats.failed++;
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const healed = this._heal(stored.fp);
|
|
233
|
+
if (healed) {
|
|
234
|
+
this._healingStats.healed++;
|
|
235
|
+
return healed;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this._healingStats.failed++;
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_heal(fp) {
|
|
243
|
+
// Strategy 1: data-wab-id (most stable)
|
|
244
|
+
if (fp.dataWabId) {
|
|
245
|
+
const el = safeQuerySelector(`[data-wab-id="${fp.dataWabId}"]`);
|
|
246
|
+
if (el) return { element: el, selector: `[data-wab-id="${fp.dataWabId}"]`, healed: true, strategy: 'data-wab-id' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Strategy 2: data-testid
|
|
250
|
+
if (fp.dataTestId) {
|
|
251
|
+
const el = safeQuerySelector(`[data-testid="${fp.dataTestId}"]`);
|
|
252
|
+
if (el) return { element: el, selector: `[data-testid="${fp.dataTestId}"]`, healed: true, strategy: 'data-testid' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Strategy 3: id (may have changed)
|
|
256
|
+
if (fp.id) {
|
|
257
|
+
const el = safeQuerySelector(`#${CSS.escape(fp.id)}`);
|
|
258
|
+
if (el) return { element: el, selector: `#${CSS.escape(fp.id)}`, healed: true, strategy: 'id' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Strategy 4: aria-label (semantic, usually stable)
|
|
262
|
+
if (fp.ariaLabel) {
|
|
263
|
+
const sel = `${fp.tag}[aria-label="${CSS.escape(fp.ariaLabel)}"]`;
|
|
264
|
+
const el = safeQuerySelector(sel);
|
|
265
|
+
if (el) return { element: el, selector: sel, healed: true, strategy: 'aria-label' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Strategy 5: name attribute
|
|
269
|
+
if (fp.name) {
|
|
270
|
+
const sel = `${fp.tag}[name="${CSS.escape(fp.name)}"]`;
|
|
271
|
+
const el = safeQuerySelector(sel);
|
|
272
|
+
if (el) return { element: el, selector: sel, healed: true, strategy: 'name' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Strategy 6: text content matching (fuzzy)
|
|
276
|
+
if (fp.text && fp.text.length > 0) {
|
|
277
|
+
const candidates = safeQuerySelectorAll(fp.tag);
|
|
278
|
+
const target = fp.text.toLowerCase();
|
|
279
|
+
let bestMatch = null;
|
|
280
|
+
let bestScore = 0;
|
|
281
|
+
|
|
282
|
+
for (const candidate of candidates) {
|
|
283
|
+
const candidateText = (candidate.textContent || '').trim().toLowerCase();
|
|
284
|
+
if (!candidateText) continue;
|
|
285
|
+
|
|
286
|
+
const score = this._textSimilarity(target, candidateText);
|
|
287
|
+
if (score > 0.7 && score > bestScore) {
|
|
288
|
+
bestScore = score;
|
|
289
|
+
bestMatch = candidate;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (bestMatch) {
|
|
294
|
+
const healedSel = this._buildSelectorFor(bestMatch);
|
|
295
|
+
return { element: bestMatch, selector: healedSel, healed: true, strategy: 'text-fuzzy', confidence: bestScore };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Strategy 7: role + position heuristic
|
|
300
|
+
if (fp.role && fp.parentTag) {
|
|
301
|
+
const candidates = safeQuerySelectorAll(`${fp.parentTag} > ${fp.tag}[role="${fp.role}"]`);
|
|
302
|
+
if (candidates.length > 0 && fp.index >= 0 && fp.index < candidates.length) {
|
|
303
|
+
const el = candidates[fp.index];
|
|
304
|
+
const sel = this._buildSelectorFor(el);
|
|
305
|
+
return { element: el, selector: sel, healed: true, strategy: 'role-position' };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_textSimilarity(a, b) {
|
|
313
|
+
if (a === b) return 1;
|
|
314
|
+
const longer = a.length > b.length ? a : b;
|
|
315
|
+
const shorter = a.length > b.length ? b : a;
|
|
316
|
+
if (longer.length === 0) return 1;
|
|
317
|
+
if (longer.includes(shorter) || shorter.includes(longer)) {
|
|
318
|
+
return shorter.length / longer.length;
|
|
319
|
+
}
|
|
320
|
+
// Simple bigram similarity
|
|
321
|
+
const bigramsA = new Set();
|
|
322
|
+
for (let i = 0; i < a.length - 1; i++) bigramsA.add(a.slice(i, i + 2));
|
|
323
|
+
let matches = 0;
|
|
324
|
+
for (let i = 0; i < b.length - 1; i++) {
|
|
325
|
+
if (bigramsA.has(b.slice(i, i + 2))) matches++;
|
|
326
|
+
}
|
|
327
|
+
const total = Math.max(bigramsA.size, b.length - 1);
|
|
328
|
+
return total > 0 ? matches / total : 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
_buildSelectorFor(el) {
|
|
332
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
333
|
+
if (el.dataset.wabId) return `[data-wab-id="${el.dataset.wabId}"]`;
|
|
334
|
+
const tag = el.tagName.toLowerCase();
|
|
335
|
+
const attrs = ['data-testid', 'aria-label', 'name'];
|
|
336
|
+
for (const attr of attrs) {
|
|
337
|
+
const val = el.getAttribute(attr);
|
|
338
|
+
if (val && document.querySelectorAll(`${tag}[${attr}="${CSS.escape(val)}"]`).length === 1) {
|
|
339
|
+
return `${tag}[${attr}="${CSS.escape(val)}"]`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
getStats() {
|
|
346
|
+
return { ...this._healingStats, tracked: this._fingerprints.size };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ─── Stealth / Human-like Interaction ─────────────────────────────────
|
|
351
|
+
// Makes automation interactions look natural to anti-bot systems
|
|
352
|
+
const Stealth = {
|
|
353
|
+
_enabled: false,
|
|
354
|
+
|
|
355
|
+
enable() { this._enabled = true; },
|
|
356
|
+
disable() { this._enabled = false; },
|
|
357
|
+
get isEnabled() { return this._enabled; },
|
|
358
|
+
|
|
359
|
+
// Random delay between min and max ms, with optional Gaussian distribution
|
|
360
|
+
delay(min = 50, max = 300) {
|
|
361
|
+
if (!this._enabled) return Promise.resolve();
|
|
362
|
+
const duration = min + Math.floor(Math.random() * (max - min));
|
|
363
|
+
return new Promise(resolve => setTimeout(resolve, duration));
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
// Simulate a full mouse event chain: mouseover → mouseenter → mousemove → mousedown → mouseup → click
|
|
367
|
+
async simulateClick(el) {
|
|
368
|
+
if (!el) return;
|
|
369
|
+
const rect = el.getBoundingClientRect();
|
|
370
|
+
const x = rect.left + rect.width * (0.3 + Math.random() * 0.4);
|
|
371
|
+
const y = rect.top + rect.height * (0.3 + Math.random() * 0.4);
|
|
372
|
+
const eventOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y, view: window };
|
|
373
|
+
|
|
374
|
+
el.dispatchEvent(new MouseEvent('mouseover', eventOpts));
|
|
375
|
+
el.dispatchEvent(new MouseEvent('mouseenter', { ...eventOpts, bubbles: false }));
|
|
376
|
+
await this.delay(30, 80);
|
|
377
|
+
el.dispatchEvent(new MouseEvent('mousemove', eventOpts));
|
|
378
|
+
await this.delay(40, 120);
|
|
379
|
+
el.dispatchEvent(new MouseEvent('mousedown', eventOpts));
|
|
380
|
+
await this.delay(50, 150);
|
|
381
|
+
el.dispatchEvent(new MouseEvent('mouseup', eventOpts));
|
|
382
|
+
el.dispatchEvent(new MouseEvent('click', eventOpts));
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
// Simulate human-like typing with variable delays
|
|
386
|
+
async simulateTyping(el, text) {
|
|
387
|
+
if (!el) return;
|
|
388
|
+
el.focus();
|
|
389
|
+
el.dispatchEvent(new Event('focus', { bubbles: true }));
|
|
390
|
+
el.value = '';
|
|
391
|
+
|
|
392
|
+
for (let i = 0; i < text.length; i++) {
|
|
393
|
+
const char = text[i];
|
|
394
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true }));
|
|
395
|
+
el.value += char;
|
|
396
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
397
|
+
el.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true }));
|
|
398
|
+
await this.delay(30, 120); // Human typing speed: 30-120ms per key
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
// Simulate natural scrolling (variable speed, easing)
|
|
405
|
+
async simulateScroll(el, direction = 'down') {
|
|
406
|
+
const target = el || document.documentElement;
|
|
407
|
+
const distance = 300 + Math.floor(Math.random() * 400);
|
|
408
|
+
const steps = 5 + Math.floor(Math.random() * 5);
|
|
409
|
+
const stepSize = distance / steps;
|
|
410
|
+
|
|
411
|
+
for (let i = 0; i < steps; i++) {
|
|
412
|
+
const delta = direction === 'down' ? stepSize : -stepSize;
|
|
413
|
+
if (el && el !== document.documentElement) {
|
|
414
|
+
el.scrollTop += delta;
|
|
415
|
+
} else {
|
|
416
|
+
window.scrollBy(0, delta);
|
|
417
|
+
}
|
|
418
|
+
await this.delay(15, 50);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// ─── Element Utilities ────────────────────────────────────────────────
|
|
424
|
+
function isElementAllowed(selector, config) {
|
|
425
|
+
const { allowedSelectors, blockedSelectors } = config.restrictions;
|
|
426
|
+
|
|
427
|
+
if (blockedSelectors.length > 0) {
|
|
428
|
+
const el = document.querySelector(selector);
|
|
429
|
+
if (el) {
|
|
430
|
+
for (const blocked of blockedSelectors) {
|
|
431
|
+
if (el.matches(blocked) || el.closest(blocked)) return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (allowedSelectors.length > 0) {
|
|
437
|
+
const el = document.querySelector(selector);
|
|
438
|
+
if (el) {
|
|
439
|
+
for (const allowed of allowedSelectors) {
|
|
440
|
+
if (el.matches(allowed) || el.closest(allowed)) return true;
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function safeQuerySelector(selector) {
|
|
450
|
+
try {
|
|
451
|
+
return document.querySelector(selector);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function safeQuerySelectorAll(selector) {
|
|
458
|
+
try {
|
|
459
|
+
return Array.from(document.querySelectorAll(selector));
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return [];
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── Action Registry ──────────────────────────────────────────────────
|
|
466
|
+
class ActionRegistry {
|
|
467
|
+
constructor() {
|
|
468
|
+
this.actions = new Map();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
register(action) {
|
|
472
|
+
if (!action.name || !action.description) {
|
|
473
|
+
throw new Error('Action must have a name and description');
|
|
474
|
+
}
|
|
475
|
+
this.actions.set(action.name, {
|
|
476
|
+
name: action.name,
|
|
477
|
+
description: action.description,
|
|
478
|
+
trigger: action.trigger || 'click',
|
|
479
|
+
selector: action.selector || null,
|
|
480
|
+
fields: action.fields || null,
|
|
481
|
+
submitSelector: action.submitSelector || null,
|
|
482
|
+
endpoint: action.endpoint || null,
|
|
483
|
+
method: action.method || 'GET',
|
|
484
|
+
requiresAuth: action.requiresAuth || false,
|
|
485
|
+
category: action.category || 'general',
|
|
486
|
+
params: action.params || null,
|
|
487
|
+
handler: action.handler || null,
|
|
488
|
+
metadata: action.metadata || {}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
unregister(name) {
|
|
493
|
+
return this.actions.delete(name);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
get(name) {
|
|
497
|
+
return this.actions.get(name) || null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
list() {
|
|
501
|
+
return Array.from(this.actions.values()).map(a => ({
|
|
502
|
+
name: a.name,
|
|
503
|
+
description: a.description,
|
|
504
|
+
trigger: a.trigger,
|
|
505
|
+
category: a.category,
|
|
506
|
+
requiresAuth: a.requiresAuth,
|
|
507
|
+
params: a.params,
|
|
508
|
+
fields: a.fields ? a.fields.map(f => ({ name: f.name, type: f.type, required: f.required !== false })) : null
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
getByCategory(category) {
|
|
513
|
+
return this.list().filter(a => a.category === category);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
toJSON() {
|
|
517
|
+
return this.list();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ─── Event Emitter ────────────────────────────────────────────────────
|
|
522
|
+
class BridgeEventEmitter {
|
|
523
|
+
constructor() {
|
|
524
|
+
this.listeners = {};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
on(event, callback) {
|
|
528
|
+
if (!this.listeners[event]) this.listeners[event] = [];
|
|
529
|
+
this.listeners[event].push(callback);
|
|
530
|
+
return () => this.off(event, callback);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
off(event, callback) {
|
|
534
|
+
if (!this.listeners[event]) return;
|
|
535
|
+
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
emit(event, data) {
|
|
539
|
+
if (!this.listeners[event]) return;
|
|
540
|
+
this.listeners[event].forEach(cb => {
|
|
541
|
+
try { cb(data); } catch (e) { console.error(`[WAB] Event handler error for "${event}":`, e); }
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ─── Main Bridge Class ────────────────────────────────────────────────
|
|
547
|
+
class WebAgentBridge {
|
|
548
|
+
constructor(userConfig) {
|
|
549
|
+
this.config = this._mergeConfig(DEFAULT_CONFIG, userConfig || {});
|
|
550
|
+
this.registry = new ActionRegistry();
|
|
551
|
+
this.rateLimiter = new RateLimiter(this.config.restrictions.rateLimit.maxCallsPerMinute);
|
|
552
|
+
this.logger = new BridgeLogger(this.config.logging);
|
|
553
|
+
this.events = new BridgeEventEmitter();
|
|
554
|
+
this.security = new SecuritySandbox(this.config);
|
|
555
|
+
this.healer = new SelfHealingSelector();
|
|
556
|
+
this.stealth = Stealth;
|
|
557
|
+
this.authenticated = false;
|
|
558
|
+
this.agentInfo = null;
|
|
559
|
+
this._licenseVerified = null;
|
|
560
|
+
this._ready = false;
|
|
561
|
+
this._readyCallbacks = [];
|
|
562
|
+
this._mutationObserver = null;
|
|
563
|
+
|
|
564
|
+
// Enable stealth mode if configured
|
|
565
|
+
if (this.config.stealth?.enabled) {
|
|
566
|
+
this.stealth.enable();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
this._init();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── Initialization ──────────────────────────────────────────────────
|
|
573
|
+
async _init() {
|
|
574
|
+
if (this.config.licenseKey) {
|
|
575
|
+
await this._verifyLicense();
|
|
576
|
+
} else {
|
|
577
|
+
this._licenseVerified = { tier: 'free', valid: true };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
this._autoDiscoverActions();
|
|
581
|
+
this._storeFingerprints();
|
|
582
|
+
this._setupSPAObserver();
|
|
583
|
+
this._ready = true;
|
|
584
|
+
this._readyCallbacks.forEach(cb => cb());
|
|
585
|
+
this._readyCallbacks = [];
|
|
586
|
+
this.events.emit('ready', { version: VERSION, tier: this.getEffectiveTier() });
|
|
587
|
+
this.logger.log('init', { version: VERSION, tier: this.getEffectiveTier(), security: 'sandbox-active' });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Store fingerprints for all discovered actions (self-healing)
|
|
591
|
+
_storeFingerprints() {
|
|
592
|
+
for (const action of this.registry.actions.values()) {
|
|
593
|
+
if (action.selector) {
|
|
594
|
+
this.healer.store(action.name, action.selector);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Watch for SPA DOM changes and re-discover actions
|
|
600
|
+
_setupSPAObserver() {
|
|
601
|
+
if (this._mutationObserver) this._mutationObserver.disconnect();
|
|
602
|
+
|
|
603
|
+
let debounceTimer = null;
|
|
604
|
+
this._mutationObserver = new MutationObserver(() => {
|
|
605
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
606
|
+
debounceTimer = setTimeout(() => {
|
|
607
|
+
this._autoDiscoverActions();
|
|
608
|
+
this._storeFingerprints();
|
|
609
|
+
this.events.emit('dom:changed', { actionsCount: this.registry.actions.size });
|
|
610
|
+
}, 500);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
this._mutationObserver.observe(document.body, {
|
|
614
|
+
childList: true,
|
|
615
|
+
subtree: true,
|
|
616
|
+
attributes: false
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
onReady(callback) {
|
|
621
|
+
if (this._ready) {
|
|
622
|
+
callback();
|
|
623
|
+
} else {
|
|
624
|
+
this._readyCallbacks.push(callback);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
_mergeConfig(defaults, overrides) {
|
|
629
|
+
const result = {};
|
|
630
|
+
for (const key of Object.keys(defaults)) {
|
|
631
|
+
if (typeof defaults[key] === 'object' && defaults[key] !== null && !Array.isArray(defaults[key])) {
|
|
632
|
+
result[key] = this._mergeConfig(defaults[key], overrides[key] || {});
|
|
633
|
+
} else {
|
|
634
|
+
result[key] = overrides[key] !== undefined ? overrides[key] : defaults[key];
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
for (const key of Object.keys(overrides)) {
|
|
638
|
+
if (!(key in defaults)) {
|
|
639
|
+
result[key] = overrides[key];
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return result;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ── License Verification ────────────────────────────────────────────
|
|
646
|
+
async _verifyLicense() {
|
|
647
|
+
try {
|
|
648
|
+
const res = await fetch(`${LICENSING_SERVER}/api/license/verify`, {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
headers: { 'Content-Type': 'application/json' },
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
domain: location.hostname,
|
|
653
|
+
licenseKey: this.config.licenseKey
|
|
654
|
+
})
|
|
655
|
+
});
|
|
656
|
+
if (res.ok) {
|
|
657
|
+
this._licenseVerified = await res.json();
|
|
658
|
+
} else {
|
|
659
|
+
this._licenseVerified = { tier: 'free', valid: false, error: 'License verification failed' };
|
|
660
|
+
}
|
|
661
|
+
} catch (e) {
|
|
662
|
+
this._licenseVerified = { tier: this.config.subscriptionTier || 'free', valid: false, error: 'Offline' };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
getEffectiveTier() {
|
|
667
|
+
if (this._licenseVerified && this._licenseVerified.valid) {
|
|
668
|
+
return this._licenseVerified.tier;
|
|
669
|
+
}
|
|
670
|
+
return 'free';
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── Auto-discover Page Actions ──────────────────────────────────────
|
|
674
|
+
_autoDiscoverActions() {
|
|
675
|
+
const buttons = safeQuerySelectorAll('button, [role="button"], input[type="submit"], a.btn, a.button');
|
|
676
|
+
buttons.forEach((el, i) => {
|
|
677
|
+
const text = (el.textContent || el.value || '').trim();
|
|
678
|
+
if (!text) return;
|
|
679
|
+
|
|
680
|
+
const selector = this._generateSelector(el);
|
|
681
|
+
if (!selector || !isElementAllowed(selector, this.config)) return;
|
|
682
|
+
|
|
683
|
+
const name = this._slugify(text) || `action_${i}`;
|
|
684
|
+
if (!this.registry.get(name)) {
|
|
685
|
+
this.registry.register({
|
|
686
|
+
name,
|
|
687
|
+
description: `Click: ${text}`,
|
|
688
|
+
trigger: 'click',
|
|
689
|
+
selector,
|
|
690
|
+
category: 'auto-discovered'
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const forms = safeQuerySelectorAll('form');
|
|
696
|
+
forms.forEach((form, i) => {
|
|
697
|
+
const formSelector = this._generateSelector(form);
|
|
698
|
+
if (!formSelector || !isElementAllowed(formSelector, this.config)) return;
|
|
699
|
+
|
|
700
|
+
const fields = Array.from(form.querySelectorAll('input, textarea, select'))
|
|
701
|
+
.filter(f => f.type !== 'hidden' && f.type !== 'submit')
|
|
702
|
+
.map(f => ({
|
|
703
|
+
name: f.name || f.id || f.placeholder || `field_${Math.random().toString(36).slice(2, 6)}`,
|
|
704
|
+
selector: this._generateSelector(f),
|
|
705
|
+
type: f.type || 'text',
|
|
706
|
+
required: f.required,
|
|
707
|
+
placeholder: f.placeholder || ''
|
|
708
|
+
}));
|
|
709
|
+
|
|
710
|
+
const submitBtn = form.querySelector('[type="submit"], button:not([type])');
|
|
711
|
+
const formName = form.id || form.name || `form_${i}`;
|
|
712
|
+
|
|
713
|
+
this.registry.register({
|
|
714
|
+
name: `fill_${this._slugify(formName)}`,
|
|
715
|
+
description: `Fill and submit form: ${formName}`,
|
|
716
|
+
trigger: 'fill_and_submit',
|
|
717
|
+
selector: formSelector,
|
|
718
|
+
fields,
|
|
719
|
+
submitSelector: submitBtn ? this._generateSelector(submitBtn) : null,
|
|
720
|
+
category: 'auto-discovered'
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const links = safeQuerySelectorAll('nav a, [role="navigation"] a');
|
|
725
|
+
links.forEach((el, i) => {
|
|
726
|
+
const text = (el.textContent || '').trim();
|
|
727
|
+
if (!text) return;
|
|
728
|
+
|
|
729
|
+
const selector = this._generateSelector(el);
|
|
730
|
+
if (!selector || !isElementAllowed(selector, this.config)) return;
|
|
731
|
+
|
|
732
|
+
const name = `nav_${this._slugify(text)}` || `nav_${i}`;
|
|
733
|
+
if (!this.registry.get(name)) {
|
|
734
|
+
this.registry.register({
|
|
735
|
+
name,
|
|
736
|
+
description: `Navigate: ${text}`,
|
|
737
|
+
trigger: 'click',
|
|
738
|
+
selector,
|
|
739
|
+
category: 'navigation'
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
_generateSelector(el) {
|
|
746
|
+
if (el.id) return `#${CSS.escape(el.id)}`;
|
|
747
|
+
if (el.dataset.wabId) return `[data-wab-id="${el.dataset.wabId}"]`;
|
|
748
|
+
|
|
749
|
+
const classes = Array.from(el.classList).filter(c => !c.match(/^(js-|is-|has-)/));
|
|
750
|
+
if (classes.length > 0) {
|
|
751
|
+
const sel = `${el.tagName.toLowerCase()}.${classes.map(c => CSS.escape(c)).join('.')}`;
|
|
752
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const attrs = ['name', 'data-testid', 'aria-label', 'title', 'role'];
|
|
756
|
+
for (const attr of attrs) {
|
|
757
|
+
const val = el.getAttribute(attr);
|
|
758
|
+
if (val) {
|
|
759
|
+
const sel = `${el.tagName.toLowerCase()}[${attr}="${CSS.escape(val)}"]`;
|
|
760
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const path = [];
|
|
765
|
+
let current = el;
|
|
766
|
+
while (current && current !== document.body) {
|
|
767
|
+
let tag = current.tagName.toLowerCase();
|
|
768
|
+
if (current.id) {
|
|
769
|
+
path.unshift(`#${CSS.escape(current.id)}`);
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
const parent = current.parentElement;
|
|
773
|
+
if (parent) {
|
|
774
|
+
const siblings = Array.from(parent.children).filter(c => c.tagName === current.tagName);
|
|
775
|
+
if (siblings.length > 1) {
|
|
776
|
+
tag += `:nth-of-type(${siblings.indexOf(current) + 1})`;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
path.unshift(tag);
|
|
780
|
+
current = parent;
|
|
781
|
+
}
|
|
782
|
+
return path.join(' > ');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
_slugify(text) {
|
|
786
|
+
return text
|
|
787
|
+
.toLowerCase()
|
|
788
|
+
.replace(/[^\w\s-]/g, '')
|
|
789
|
+
.replace(/[\s_]+/g, '_')
|
|
790
|
+
.replace(/^-+|-+$/g, '')
|
|
791
|
+
.slice(0, 40);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ── Agent Authentication ────────────────────────────────────────────
|
|
795
|
+
authenticate(agentKey, agentMeta = {}) {
|
|
796
|
+
this.logger.log('authenticate', { agentKey: agentKey ? '***' : null });
|
|
797
|
+
this.events.emit('agent:authenticate', { agentMeta });
|
|
798
|
+
this.authenticated = true;
|
|
799
|
+
this.agentInfo = { key: agentKey, ...agentMeta, authenticatedAt: new Date().toISOString() };
|
|
800
|
+
return { success: true, permissions: this._getEffectivePermissions() };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ── Permission Checks ───────────────────────────────────────────────
|
|
804
|
+
_getEffectivePermissions() {
|
|
805
|
+
const perms = { ...this.config.agentPermissions };
|
|
806
|
+
const tier = this.getEffectiveTier();
|
|
807
|
+
|
|
808
|
+
if (tier === 'free') {
|
|
809
|
+
perms.apiAccess = false;
|
|
810
|
+
perms.automatedLogin = false;
|
|
811
|
+
perms.extractData = false;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return perms;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
_checkPermission(action) {
|
|
818
|
+
const perms = this._getEffectivePermissions();
|
|
819
|
+
|
|
820
|
+
switch (action) {
|
|
821
|
+
case 'click': return perms.click;
|
|
822
|
+
case 'fill_and_submit': return perms.fillForms;
|
|
823
|
+
case 'scroll': return perms.scroll;
|
|
824
|
+
case 'navigate': return perms.navigate;
|
|
825
|
+
case 'api': return perms.apiAccess;
|
|
826
|
+
case 'read': return perms.readContent;
|
|
827
|
+
case 'extract': return perms.extractData;
|
|
828
|
+
default: return perms.click;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── Core: Execute Action ────────────────────────────────────────────
|
|
833
|
+
async execute(actionName, params = {}) {
|
|
834
|
+
// Security: check if bridge is locked
|
|
835
|
+
if (this.security.isLocked) {
|
|
836
|
+
return { success: false, error: 'Bridge locked due to security violations' };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (!this.rateLimiter.check()) {
|
|
840
|
+
const error = { success: false, error: 'Rate limit exceeded', retryAfter: 60 };
|
|
841
|
+
this.logger.log('rate_limit', { action: actionName });
|
|
842
|
+
this.security.audit('rate_limit', { action: actionName }, 'blocked');
|
|
843
|
+
this.events.emit('error', error);
|
|
844
|
+
return error;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const action = this.registry.get(actionName);
|
|
848
|
+
if (!action) {
|
|
849
|
+
return { success: false, error: `Action "${actionName}" not found`, available: this.registry.list().map(a => a.name) };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (!this._checkPermission(action.trigger)) {
|
|
853
|
+
this.security.audit('permission_denied', { action: actionName, trigger: action.trigger }, 'blocked');
|
|
854
|
+
return { success: false, error: `Permission denied for trigger type: ${action.trigger}`, tier: this.getEffectiveTier() };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (action.requiresAuth && !this.authenticated) {
|
|
858
|
+
return { success: false, error: 'Authentication required for this action' };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const loginRequired = this.config.restrictions.requireLoginForActions || [];
|
|
862
|
+
if (loginRequired.includes(action.trigger) && !this.authenticated) {
|
|
863
|
+
return { success: false, error: 'Login required for this action type' };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Self-healing: resolve selector if original is broken
|
|
867
|
+
if (action.selector) {
|
|
868
|
+
const resolved = this.healer.resolve(actionName, action.selector);
|
|
869
|
+
if (resolved && resolved.healed) {
|
|
870
|
+
action.selector = resolved.selector;
|
|
871
|
+
this.logger.log('self_heal', { action: actionName, strategy: resolved.strategy, confidence: resolved.confidence }, 'detailed');
|
|
872
|
+
this.events.emit('selector:healed', { action: actionName, strategy: resolved.strategy });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
this.logger.log('execute', { action: actionName, params }, 'basic');
|
|
877
|
+
this.security.audit('execute', { action: actionName, agentId: this.agentInfo?.key });
|
|
878
|
+
this.events.emit('action:before', { action: actionName, params });
|
|
879
|
+
|
|
880
|
+
try {
|
|
881
|
+
let result;
|
|
882
|
+
|
|
883
|
+
if (action.handler) {
|
|
884
|
+
result = await action.handler(params);
|
|
885
|
+
} else {
|
|
886
|
+
switch (action.trigger) {
|
|
887
|
+
case 'click':
|
|
888
|
+
result = await this._executeClick(action);
|
|
889
|
+
break;
|
|
890
|
+
case 'fill_and_submit':
|
|
891
|
+
result = await this._executeFillAndSubmit(action, params);
|
|
892
|
+
break;
|
|
893
|
+
case 'scroll':
|
|
894
|
+
result = await this._executeScroll(action);
|
|
895
|
+
break;
|
|
896
|
+
case 'api':
|
|
897
|
+
result = await this._executeApi(action, params);
|
|
898
|
+
break;
|
|
899
|
+
default:
|
|
900
|
+
result = { success: false, error: `Unknown trigger: ${action.trigger}` };
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
this.events.emit('action:after', { action: actionName, result });
|
|
905
|
+
this.logger.log('execute_result', { action: actionName, success: result.success }, 'detailed');
|
|
906
|
+
return result;
|
|
907
|
+
|
|
908
|
+
} catch (err) {
|
|
909
|
+
const error = { success: false, error: err.message };
|
|
910
|
+
this.events.emit('error', { action: actionName, error: err.message });
|
|
911
|
+
this.logger.log('execute_error', { action: actionName, error: err.message });
|
|
912
|
+
return error;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async _executeClick(action) {
|
|
917
|
+
if (!action.selector) return { success: false, error: 'No selector defined' };
|
|
918
|
+
if (!isElementAllowed(action.selector, this.config)) {
|
|
919
|
+
return { success: false, error: 'Element is blocked by restrictions' };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Self-healing: try to find element even if selector is stale
|
|
923
|
+
let el = safeQuerySelector(action.selector);
|
|
924
|
+
if (!el) {
|
|
925
|
+
const resolved = this.healer.resolve(action.name, action.selector);
|
|
926
|
+
if (resolved) {
|
|
927
|
+
el = resolved.element;
|
|
928
|
+
} else {
|
|
929
|
+
return { success: false, error: `Element not found: ${action.selector}` };
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Stealth: use human-like click simulation
|
|
934
|
+
if (this.stealth.isEnabled) {
|
|
935
|
+
await this.stealth.delay(100, 400);
|
|
936
|
+
await this.stealth.simulateClick(el);
|
|
937
|
+
} else {
|
|
938
|
+
el.click();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return { success: true, action: 'click', selector: action.selector };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async _executeFillAndSubmit(action, params) {
|
|
945
|
+
if (!action.fields) return { success: false, error: 'No fields defined' };
|
|
946
|
+
|
|
947
|
+
const results = [];
|
|
948
|
+
for (const field of action.fields) {
|
|
949
|
+
if (!isElementAllowed(field.selector, this.config)) {
|
|
950
|
+
results.push({ field: field.name, success: false, error: 'Element blocked' });
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
let el = safeQuerySelector(field.selector);
|
|
955
|
+
if (!el) {
|
|
956
|
+
// Self-healing: try to find field by name/placeholder
|
|
957
|
+
const healedField = this.healer.resolve(`field_${field.name}`, field.selector);
|
|
958
|
+
if (healedField) {
|
|
959
|
+
el = healedField.element;
|
|
960
|
+
} else {
|
|
961
|
+
results.push({ field: field.name, success: false, error: 'Element not found' });
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const value = params[field.name];
|
|
967
|
+
if (value !== undefined) {
|
|
968
|
+
if (this.stealth.isEnabled) {
|
|
969
|
+
await this.stealth.delay(200, 600);
|
|
970
|
+
await this.stealth.simulateTyping(el, String(value));
|
|
971
|
+
} else {
|
|
972
|
+
el.focus();
|
|
973
|
+
el.value = value;
|
|
974
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
975
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
976
|
+
}
|
|
977
|
+
results.push({ field: field.name, success: true });
|
|
978
|
+
} else if (field.required !== false) {
|
|
979
|
+
results.push({ field: field.name, success: false, error: 'Value required but not provided' });
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (action.submitSelector) {
|
|
984
|
+
let submitEl = safeQuerySelector(action.submitSelector);
|
|
985
|
+
if (submitEl) {
|
|
986
|
+
if (this.stealth.isEnabled) {
|
|
987
|
+
await this.stealth.delay(300, 800);
|
|
988
|
+
await this.stealth.simulateClick(submitEl);
|
|
989
|
+
} else {
|
|
990
|
+
submitEl.click();
|
|
991
|
+
}
|
|
992
|
+
results.push({ field: '_submit', success: true });
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return { success: results.every(r => r.success), results };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async _executeScroll(action) {
|
|
1000
|
+
if (action.selector) {
|
|
1001
|
+
const el = safeQuerySelector(action.selector);
|
|
1002
|
+
if (el) {
|
|
1003
|
+
if (this.stealth.isEnabled) {
|
|
1004
|
+
await this.stealth.simulateScroll(el);
|
|
1005
|
+
} else {
|
|
1006
|
+
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1007
|
+
}
|
|
1008
|
+
return { success: true, action: 'scroll', selector: action.selector };
|
|
1009
|
+
}
|
|
1010
|
+
return { success: false, error: `Element not found: ${action.selector}` };
|
|
1011
|
+
}
|
|
1012
|
+
if (this.stealth.isEnabled) {
|
|
1013
|
+
await this.stealth.simulateScroll(null, 'down');
|
|
1014
|
+
} else {
|
|
1015
|
+
window.scrollBy({ top: 500, behavior: 'smooth' });
|
|
1016
|
+
}
|
|
1017
|
+
return { success: true, action: 'scroll', direction: 'down' };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async _executeApi(action, params) {
|
|
1021
|
+
if (this.getEffectiveTier() === 'free') {
|
|
1022
|
+
return { success: false, error: 'API access requires a premium subscription' };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const url = new URL(action.endpoint, location.origin);
|
|
1026
|
+
const options = { method: action.method || 'GET', headers: { 'Content-Type': 'application/json' } };
|
|
1027
|
+
|
|
1028
|
+
if (action.method !== 'GET' && params) {
|
|
1029
|
+
options.body = JSON.stringify(params);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const res = await fetch(url.toString(), options);
|
|
1033
|
+
const data = await res.json().catch(() => null);
|
|
1034
|
+
return { success: res.ok, status: res.status, data };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ── Content Reading ─────────────────────────────────────────────────
|
|
1038
|
+
readContent(selector) {
|
|
1039
|
+
if (!this._checkPermission('read')) {
|
|
1040
|
+
return { success: false, error: 'readContent permission denied' };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (!isElementAllowed(selector, this.config)) {
|
|
1044
|
+
return { success: false, error: 'Element is blocked by restrictions' };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const el = safeQuerySelector(selector);
|
|
1048
|
+
if (!el) return { success: false, error: 'Element not found' };
|
|
1049
|
+
|
|
1050
|
+
return {
|
|
1051
|
+
success: true,
|
|
1052
|
+
text: el.textContent.trim(),
|
|
1053
|
+
html: el.innerHTML,
|
|
1054
|
+
attributes: Object.fromEntries(
|
|
1055
|
+
Array.from(el.attributes).map(a => [a.name, a.value])
|
|
1056
|
+
)
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
getPageInfo() {
|
|
1061
|
+
return {
|
|
1062
|
+
title: document.title,
|
|
1063
|
+
url: location.href,
|
|
1064
|
+
domain: location.hostname,
|
|
1065
|
+
lang: document.documentElement.lang || 'unknown',
|
|
1066
|
+
bridgeVersion: VERSION,
|
|
1067
|
+
tier: this.getEffectiveTier(),
|
|
1068
|
+
permissions: this._getEffectivePermissions(),
|
|
1069
|
+
actionsCount: this.registry.actions.size,
|
|
1070
|
+
rateLimitRemaining: this.rateLimiter.remaining,
|
|
1071
|
+
security: {
|
|
1072
|
+
sandboxActive: true,
|
|
1073
|
+
locked: this.security.isLocked,
|
|
1074
|
+
sessionToken: this.security.sessionToken
|
|
1075
|
+
},
|
|
1076
|
+
selfHealing: this.healer.getStats(),
|
|
1077
|
+
stealthMode: this.stealth.isEnabled
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// ── Custom Action Registration ──────────────────────────────────────
|
|
1082
|
+
registerAction(actionDef) {
|
|
1083
|
+
this.registry.register(actionDef);
|
|
1084
|
+
this.events.emit('action:registered', { name: actionDef.name });
|
|
1085
|
+
this.logger.log('register_action', { name: actionDef.name });
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
unregisterAction(name) {
|
|
1089
|
+
this.registry.unregister(name);
|
|
1090
|
+
this.events.emit('action:unregistered', { name });
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ── Discovery / Info ────────────────────────────────────────────────
|
|
1094
|
+
getActions(category) {
|
|
1095
|
+
if (category) return this.registry.getByCategory(category);
|
|
1096
|
+
return this.registry.list();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
getAction(name) {
|
|
1100
|
+
return this.registry.get(name);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// ── Waiting Utilities for Agents ────────────────────────────────────
|
|
1104
|
+
waitForElement(selector, timeout = 10000) {
|
|
1105
|
+
return new Promise((resolve, reject) => {
|
|
1106
|
+
const el = safeQuerySelector(selector);
|
|
1107
|
+
if (el) return resolve(el);
|
|
1108
|
+
|
|
1109
|
+
const observer = new MutationObserver(() => {
|
|
1110
|
+
const found = safeQuerySelector(selector);
|
|
1111
|
+
if (found) {
|
|
1112
|
+
observer.disconnect();
|
|
1113
|
+
clearTimeout(timer);
|
|
1114
|
+
resolve(found);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
1119
|
+
|
|
1120
|
+
const timer = setTimeout(() => {
|
|
1121
|
+
observer.disconnect();
|
|
1122
|
+
reject(new Error(`Timeout: element "${selector}" not found within ${timeout}ms`));
|
|
1123
|
+
}, timeout);
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
waitForNavigation(timeout = 15000) {
|
|
1128
|
+
return new Promise((resolve) => {
|
|
1129
|
+
const start = location.href;
|
|
1130
|
+
const check = setInterval(() => {
|
|
1131
|
+
if (location.href !== start) {
|
|
1132
|
+
clearInterval(check);
|
|
1133
|
+
clearTimeout(timer);
|
|
1134
|
+
resolve({ from: start, to: location.href });
|
|
1135
|
+
}
|
|
1136
|
+
}, 200);
|
|
1137
|
+
const timer = setTimeout(() => {
|
|
1138
|
+
clearInterval(check);
|
|
1139
|
+
resolve({ from: start, to: start, timedOut: true });
|
|
1140
|
+
}, timeout);
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ── Lifecycle ───────────────────────────────────────────────────────
|
|
1145
|
+
refresh() {
|
|
1146
|
+
this.registry = new ActionRegistry();
|
|
1147
|
+
this._autoDiscoverActions();
|
|
1148
|
+
this._storeFingerprints();
|
|
1149
|
+
this.events.emit('refresh');
|
|
1150
|
+
this.logger.log('refresh', {});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
destroy() {
|
|
1154
|
+
this.events.emit('destroy');
|
|
1155
|
+
if (this._mutationObserver) {
|
|
1156
|
+
this._mutationObserver.disconnect();
|
|
1157
|
+
this._mutationObserver = null;
|
|
1158
|
+
}
|
|
1159
|
+
this.registry = new ActionRegistry();
|
|
1160
|
+
this.logger.clear();
|
|
1161
|
+
delete global.AICommands;
|
|
1162
|
+
delete global.WebAgentBridge;
|
|
1163
|
+
delete global.__wab_bidi;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// ── Serialization ───────────────────────────────────────────────────
|
|
1167
|
+
toJSON() {
|
|
1168
|
+
return {
|
|
1169
|
+
version: VERSION,
|
|
1170
|
+
page: this.getPageInfo(),
|
|
1171
|
+
actions: this.getActions()
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// ── WebDriver BiDi Compatibility ────────────────────────────────────
|
|
1176
|
+
// Exposes a standardized protocol for agents using WebDriver BiDi
|
|
1177
|
+
toBiDi() {
|
|
1178
|
+
return {
|
|
1179
|
+
type: 'wab:context',
|
|
1180
|
+
version: VERSION,
|
|
1181
|
+
context: {
|
|
1182
|
+
url: location.href,
|
|
1183
|
+
title: document.title,
|
|
1184
|
+
browsingContext: typeof window !== 'undefined' ? window.name || 'default' : 'default'
|
|
1185
|
+
},
|
|
1186
|
+
capabilities: {
|
|
1187
|
+
actions: this.getActions().map(a => ({
|
|
1188
|
+
id: a.name,
|
|
1189
|
+
type: a.trigger === 'click' ? 'pointerDown' : a.trigger === 'fill_and_submit' ? 'key' : a.trigger,
|
|
1190
|
+
description: a.description,
|
|
1191
|
+
parameters: a.fields ? a.fields.map(f => ({ name: f.name, type: f.type, required: f.required })) : undefined
|
|
1192
|
+
})),
|
|
1193
|
+
permissions: this._getEffectivePermissions(),
|
|
1194
|
+
tier: this.getEffectiveTier()
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Execute via BiDi-style command
|
|
1200
|
+
async executeBiDi(command) {
|
|
1201
|
+
// Security: validate command
|
|
1202
|
+
const validation = this.security.validateCommand(command || {});
|
|
1203
|
+
if (!validation.valid) {
|
|
1204
|
+
return { id: command?.id, error: { code: 'security error', message: validation.error } };
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (!command || !command.method) {
|
|
1208
|
+
return { id: command?.id, error: { code: 'invalid argument', message: 'Missing method' } };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
this.security.audit('bidi_command', { method: command.method });
|
|
1212
|
+
const responseBase = { id: command.id || null, type: 'success' };
|
|
1213
|
+
|
|
1214
|
+
switch (command.method) {
|
|
1215
|
+
case 'wab.getContext':
|
|
1216
|
+
return { ...responseBase, result: this.toBiDi() };
|
|
1217
|
+
|
|
1218
|
+
case 'wab.getActions':
|
|
1219
|
+
return { ...responseBase, result: this.getActions(command.params?.category) };
|
|
1220
|
+
|
|
1221
|
+
case 'wab.executeAction':
|
|
1222
|
+
if (!command.params?.name) {
|
|
1223
|
+
return { id: command.id, error: { code: 'invalid argument', message: 'Action name required' } };
|
|
1224
|
+
}
|
|
1225
|
+
const result = await this.execute(command.params.name, command.params.data || {});
|
|
1226
|
+
return { ...responseBase, result };
|
|
1227
|
+
|
|
1228
|
+
case 'wab.readContent':
|
|
1229
|
+
if (!command.params?.selector) {
|
|
1230
|
+
return { id: command.id, error: { code: 'invalid argument', message: 'Selector required' } };
|
|
1231
|
+
}
|
|
1232
|
+
return { ...responseBase, result: this.readContent(command.params.selector) };
|
|
1233
|
+
|
|
1234
|
+
case 'wab.getPageInfo':
|
|
1235
|
+
return { ...responseBase, result: this.getPageInfo() };
|
|
1236
|
+
|
|
1237
|
+
default:
|
|
1238
|
+
return { id: command.id, error: { code: 'unknown command', message: `Unknown method: ${command.method}` } };
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// ─── Auto-initialize ──────────────────────────────────────────────────
|
|
1244
|
+
function autoInit() {
|
|
1245
|
+
const config = global.AIBridgeConfig || {};
|
|
1246
|
+
|
|
1247
|
+
const scriptTag = document.currentScript || document.querySelector('script[data-wab-config]');
|
|
1248
|
+
if (scriptTag) {
|
|
1249
|
+
const dataConfig = scriptTag.getAttribute('data-config') || scriptTag.getAttribute('data-wab-config');
|
|
1250
|
+
if (dataConfig) {
|
|
1251
|
+
try {
|
|
1252
|
+
Object.assign(config, JSON.parse(dataConfig));
|
|
1253
|
+
} catch (e) {
|
|
1254
|
+
console.error('[WAB] Invalid data-config JSON:', e);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const bridge = new WebAgentBridge(config);
|
|
1260
|
+
|
|
1261
|
+
global.AICommands = bridge;
|
|
1262
|
+
global.WebAgentBridge = WebAgentBridge;
|
|
1263
|
+
|
|
1264
|
+
// WebDriver BiDi compatibility: expose via __wab_bidi channel
|
|
1265
|
+
global.__wab_bidi = {
|
|
1266
|
+
version: VERSION,
|
|
1267
|
+
send: async (command) => bridge.executeBiDi(command),
|
|
1268
|
+
getContext: () => bridge.toBiDi()
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
if (typeof CustomEvent !== 'undefined') {
|
|
1272
|
+
document.dispatchEvent(new CustomEvent('wab:ready', { detail: { version: VERSION } }));
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (document.readyState === 'loading') {
|
|
1277
|
+
document.addEventListener('DOMContentLoaded', autoInit);
|
|
1278
|
+
} else {
|
|
1279
|
+
autoInit();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|