wu-framework 1.1.15 → 1.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -20
- package/dist/wu-framework.cjs.js +1 -1
- package/dist/wu-framework.cjs.js.map +1 -1
- package/dist/wu-framework.dev.js +15511 -15146
- package/dist/wu-framework.dev.js.map +1 -1
- package/dist/wu-framework.esm.js +1 -1
- package/dist/wu-framework.esm.js.map +1 -1
- package/dist/wu-framework.umd.js +1 -1
- package/dist/wu-framework.umd.js.map +1 -1
- package/package.json +166 -161
- package/src/adapters/angular/ai.js +30 -30
- package/src/adapters/angular/index.d.ts +154 -154
- package/src/adapters/angular/index.js +932 -932
- package/src/adapters/angular.d.ts +3 -3
- package/src/adapters/angular.js +3 -3
- package/src/adapters/index.js +168 -168
- package/src/adapters/lit/ai.js +20 -20
- package/src/adapters/lit/index.d.ts +120 -120
- package/src/adapters/lit/index.js +721 -721
- package/src/adapters/lit.d.ts +3 -3
- package/src/adapters/lit.js +3 -3
- package/src/adapters/preact/ai.js +33 -33
- package/src/adapters/preact/index.d.ts +108 -108
- package/src/adapters/preact/index.js +661 -661
- package/src/adapters/preact.d.ts +3 -3
- package/src/adapters/preact.js +3 -3
- package/src/adapters/react/index.js +48 -54
- package/src/adapters/react.d.ts +3 -3
- package/src/adapters/react.js +3 -3
- package/src/adapters/shared.js +64 -64
- package/src/adapters/solid/ai.js +32 -32
- package/src/adapters/solid/index.d.ts +101 -101
- package/src/adapters/solid/index.js +586 -586
- package/src/adapters/solid.d.ts +3 -3
- package/src/adapters/solid.js +3 -3
- package/src/adapters/svelte/ai.js +31 -31
- package/src/adapters/svelte/index.d.ts +166 -166
- package/src/adapters/svelte/index.js +798 -798
- package/src/adapters/svelte.d.ts +3 -3
- package/src/adapters/svelte.js +3 -3
- package/src/adapters/vanilla/ai.js +30 -30
- package/src/adapters/vanilla/index.d.ts +179 -179
- package/src/adapters/vanilla/index.js +785 -785
- package/src/adapters/vanilla.d.ts +3 -3
- package/src/adapters/vanilla.js +3 -3
- package/src/adapters/vue/ai.js +52 -52
- package/src/adapters/vue/index.d.ts +299 -299
- package/src/adapters/vue/index.js +610 -610
- package/src/adapters/vue.d.ts +3 -3
- package/src/adapters/vue.js +3 -3
- package/src/ai/wu-ai-actions.js +261 -261
- package/src/ai/wu-ai-agent.js +546 -546
- package/src/ai/wu-ai-browser-primitives.js +354 -354
- package/src/ai/wu-ai-browser.js +380 -380
- package/src/ai/wu-ai-context.js +332 -332
- package/src/ai/wu-ai-conversation.js +613 -613
- package/src/ai/wu-ai-orchestrate.js +1021 -1021
- package/src/ai/wu-ai-permissions.js +381 -381
- package/src/ai/wu-ai-provider.js +700 -700
- package/src/ai/wu-ai-schema.js +225 -225
- package/src/ai/wu-ai-triggers.js +396 -396
- package/src/ai/wu-ai.js +804 -804
- package/src/core/wu-app.js +236 -236
- package/src/core/wu-cache.js +498 -477
- package/src/core/wu-core.js +1412 -1398
- package/src/core/wu-error-boundary.js +396 -382
- package/src/core/wu-event-bus.js +390 -348
- package/src/core/wu-hooks.js +350 -350
- package/src/core/wu-html-parser.js +199 -190
- package/src/core/wu-iframe-sandbox.js +328 -328
- package/src/core/wu-loader.js +385 -273
- package/src/core/wu-logger.js +142 -134
- package/src/core/wu-manifest.js +532 -509
- package/src/core/wu-mcp-bridge.js +432 -432
- package/src/core/wu-overrides.js +510 -510
- package/src/core/wu-performance.js +228 -228
- package/src/core/wu-plugin.js +401 -348
- package/src/core/wu-prefetch.js +414 -414
- package/src/core/wu-proxy-sandbox.js +477 -476
- package/src/core/wu-sandbox.js +779 -779
- package/src/core/wu-script-executor.js +161 -113
- package/src/core/wu-snapshot-sandbox.js +227 -227
- package/src/core/wu-store.js +13 -3
- package/src/core/wu-strategies.js +256 -256
- package/src/core/wu-style-bridge.js +477 -477
- package/src/index.d.ts +317 -0
- package/src/index.js +234 -224
- package/src/utils/dependency-resolver.js +327 -327
|
@@ -1,432 +1,432 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WU-MCP Bridge (Browser Side)
|
|
3
|
-
*
|
|
4
|
-
* Connects to the wu-mcp-server via WebSocket and executes
|
|
5
|
-
* commands using wu.* APIs. This is the "eyes and hands" of
|
|
6
|
-
* the MCP server inside the browser.
|
|
7
|
-
*
|
|
8
|
-
* Security:
|
|
9
|
-
* - Optional auth token sent on first message (handshake)
|
|
10
|
-
* - All state/event/mount operations check wu.ai permissions
|
|
11
|
-
* - Mutating operations emit audit events
|
|
12
|
-
* - Read-only operations (status, list_apps, snapshot, console, network) are unrestricted
|
|
13
|
-
*
|
|
14
|
-
* @example
|
|
15
|
-
* // Connect with auth token
|
|
16
|
-
* wu.mcp.connect('ws://localhost:19100', { token: 'my-secret' });
|
|
17
|
-
*
|
|
18
|
-
* // Connect without auth (development only)
|
|
19
|
-
* wu.mcp.connect();
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import {
|
|
23
|
-
ensureInterceptors,
|
|
24
|
-
networkLog,
|
|
25
|
-
consoleLog,
|
|
26
|
-
captureScreenshot,
|
|
27
|
-
buildA11yTree,
|
|
28
|
-
clickElement,
|
|
29
|
-
typeIntoElement,
|
|
30
|
-
getFilteredNetwork,
|
|
31
|
-
getFilteredConsole,
|
|
32
|
-
} from '../ai/wu-ai-browser-primitives.js';
|
|
33
|
-
import { logger } from './wu-logger.js';
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Create the MCP bridge for a Wu instance.
|
|
37
|
-
*
|
|
38
|
-
* @param {object} wu - The Wu Framework instance (window.wu)
|
|
39
|
-
* @returns {object} Bridge API: { connect, disconnect, isConnected }
|
|
40
|
-
*/
|
|
41
|
-
export function createMcpBridge(wu) {
|
|
42
|
-
let ws = null;
|
|
43
|
-
let reconnectTimer = null;
|
|
44
|
-
let reconnectAttempts = 0;
|
|
45
|
-
let authenticated = false;
|
|
46
|
-
let authToken = null;
|
|
47
|
-
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
48
|
-
const RECONNECT_DELAY = 2000;
|
|
49
|
-
|
|
50
|
-
// Event log for wu_list_events
|
|
51
|
-
const eventLog = [];
|
|
52
|
-
const MAX_EVENT_LOG = 200;
|
|
53
|
-
|
|
54
|
-
// Capture events for history
|
|
55
|
-
if (wu.eventBus) {
|
|
56
|
-
wu.eventBus.on('*', (event) => {
|
|
57
|
-
eventLog.push({
|
|
58
|
-
name: event.name,
|
|
59
|
-
data: event.data,
|
|
60
|
-
timestamp: event.timestamp || Date.now(),
|
|
61
|
-
source: event.source || 'unknown',
|
|
62
|
-
});
|
|
63
|
-
if (eventLog.length > MAX_EVENT_LOG) eventLog.shift();
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Install shared interceptors (idempotent — safe if wu-ai-browser already did it)
|
|
68
|
-
ensureInterceptors();
|
|
69
|
-
|
|
70
|
-
// ── Permission helpers ──
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Check a permission flag via wu.ai.permissions if available.
|
|
74
|
-
* Falls back to deny if wu.ai is not initialized.
|
|
75
|
-
*/
|
|
76
|
-
function _checkPermission(perm) {
|
|
77
|
-
if (wu.ai && wu.ai.permissions) {
|
|
78
|
-
return wu.ai.permissions.check(perm);
|
|
79
|
-
}
|
|
80
|
-
// If AI module not initialized, deny write operations, allow reads
|
|
81
|
-
const readPerms = ['readStore', 'executeActions'];
|
|
82
|
-
return readPerms.includes(perm);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Emit an audit event for bridge operations.
|
|
87
|
-
*/
|
|
88
|
-
function _audit(operation, params, result) {
|
|
89
|
-
if (wu.eventBus) {
|
|
90
|
-
wu.eventBus.emit('mcp:bridge:operation', {
|
|
91
|
-
operation,
|
|
92
|
-
params,
|
|
93
|
-
result: result?.error ? { error: result.error } : { success: true },
|
|
94
|
-
timestamp: Date.now(),
|
|
95
|
-
}, { appName: 'wu-mcp-bridge' });
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ── Command handlers ──
|
|
100
|
-
|
|
101
|
-
const handlers = {
|
|
102
|
-
// ── Read-only operations (no permission gates) ──
|
|
103
|
-
|
|
104
|
-
status() {
|
|
105
|
-
return {
|
|
106
|
-
connected: true,
|
|
107
|
-
framework: 'wu-framework',
|
|
108
|
-
apps: _getAppList(),
|
|
109
|
-
storeKeys: wu.store ? Object.keys(wu.store.get('') || {}) : [],
|
|
110
|
-
actionsCount: wu.ai ? wu.ai.tools().length : 0,
|
|
111
|
-
eventLogSize: eventLog.length,
|
|
112
|
-
};
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
list_apps() {
|
|
116
|
-
return _getAppList();
|
|
117
|
-
},
|
|
118
|
-
|
|
119
|
-
list_events({ limit = 20 }) {
|
|
120
|
-
return eventLog.slice(-limit);
|
|
121
|
-
},
|
|
122
|
-
|
|
123
|
-
list_actions() {
|
|
124
|
-
if (!wu.ai) return { actions: [], note: 'wu.ai not initialized' };
|
|
125
|
-
const tools = wu.ai.tools();
|
|
126
|
-
return { actions: tools, count: tools.length };
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
snapshot({ appName }) {
|
|
130
|
-
try {
|
|
131
|
-
const target = appName
|
|
132
|
-
? document.querySelector(`[data-wu-app="${appName}"]`) || document.querySelector(`#wu-app-${appName}`)
|
|
133
|
-
: document.body;
|
|
134
|
-
|
|
135
|
-
if (!target) return { error: `App "${appName}" not found in DOM` };
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
app: appName || '(page)',
|
|
139
|
-
snapshot: buildA11yTree(target, 0, 5),
|
|
140
|
-
timestamp: Date.now(),
|
|
141
|
-
};
|
|
142
|
-
} catch (err) {
|
|
143
|
-
return { error: err.message };
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
|
|
147
|
-
console({ level = 'all', limit = 50 }) {
|
|
148
|
-
return getFilteredConsole(level, limit);
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
async screenshot({ selector, quality = 0.8 }) {
|
|
152
|
-
const result = await captureScreenshot(selector, quality);
|
|
153
|
-
if (!result.error) result.timestamp = Date.now();
|
|
154
|
-
return result;
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
network({ method, status, limit = 50 }) {
|
|
158
|
-
return getFilteredNetwork(method, status, limit);
|
|
159
|
-
},
|
|
160
|
-
|
|
161
|
-
// ── Permission-gated operations ──
|
|
162
|
-
|
|
163
|
-
get_state({ path }) {
|
|
164
|
-
if (!wu.store) return { error: 'wu.store not available' };
|
|
165
|
-
if (!_checkPermission('readStore')) {
|
|
166
|
-
return { error: 'Permission denied: readStore is disabled' };
|
|
167
|
-
}
|
|
168
|
-
const value = wu.store.get(path || '');
|
|
169
|
-
return { path: path || '(root)', value };
|
|
170
|
-
},
|
|
171
|
-
|
|
172
|
-
set_state({ path, value }) {
|
|
173
|
-
if (!wu.store) return { error: 'wu.store not available' };
|
|
174
|
-
if (!path) return { error: 'path is required' };
|
|
175
|
-
if (!_checkPermission('writeStore')) {
|
|
176
|
-
_audit('set_state', { path }, { error: 'Permission denied' });
|
|
177
|
-
return { error: 'Permission denied: writeStore is disabled' };
|
|
178
|
-
}
|
|
179
|
-
wu.store.set(path, value);
|
|
180
|
-
_audit('set_state', { path, value }, { success: true });
|
|
181
|
-
return { path, value, updated: true };
|
|
182
|
-
},
|
|
183
|
-
|
|
184
|
-
emit_event({ event, data }) {
|
|
185
|
-
if (!wu.eventBus) return { error: 'wu.eventBus not available' };
|
|
186
|
-
if (!event) return { error: 'event name is required' };
|
|
187
|
-
if (!_checkPermission('emitEvents')) {
|
|
188
|
-
_audit('emit_event', { event }, { error: 'Permission denied' });
|
|
189
|
-
return { error: 'Permission denied: emitEvents is disabled' };
|
|
190
|
-
}
|
|
191
|
-
wu.eventBus.emit(event, data, { appName: 'wu-mcp-bridge' });
|
|
192
|
-
_audit('emit_event', { event, data }, { success: true });
|
|
193
|
-
return { emitted: event, data };
|
|
194
|
-
},
|
|
195
|
-
|
|
196
|
-
navigate({ route }) {
|
|
197
|
-
if (!route) return { error: 'Route is required' };
|
|
198
|
-
if (!_checkPermission('emitEvents')) {
|
|
199
|
-
_audit('navigate', { route }, { error: 'Permission denied: emitEvents' });
|
|
200
|
-
return { error: 'Permission denied: emitEvents is disabled' };
|
|
201
|
-
}
|
|
202
|
-
if (wu.eventBus) {
|
|
203
|
-
wu.eventBus.emit('shell:navigate', { route }, { appName: 'wu-mcp-bridge' });
|
|
204
|
-
}
|
|
205
|
-
if (wu.store && _checkPermission('writeStore')) {
|
|
206
|
-
wu.store.set('currentPath', route);
|
|
207
|
-
}
|
|
208
|
-
_audit('navigate', { route }, { success: true });
|
|
209
|
-
return { navigated: route };
|
|
210
|
-
},
|
|
211
|
-
|
|
212
|
-
mount_app({ appName, container }) {
|
|
213
|
-
if (!appName) return { error: 'appName is required' };
|
|
214
|
-
if (!_checkPermission('modifyDOM')) {
|
|
215
|
-
_audit('mount_app', { appName }, { error: 'Permission denied' });
|
|
216
|
-
return { error: 'Permission denied: modifyDOM is disabled' };
|
|
217
|
-
}
|
|
218
|
-
try {
|
|
219
|
-
if (wu.mount) {
|
|
220
|
-
wu.mount(appName, container);
|
|
221
|
-
_audit('mount_app', { appName, container }, { success: true });
|
|
222
|
-
return { mounted: appName, container };
|
|
223
|
-
}
|
|
224
|
-
return { error: 'wu.mount not available' };
|
|
225
|
-
} catch (err) {
|
|
226
|
-
return { error: err.message };
|
|
227
|
-
}
|
|
228
|
-
},
|
|
229
|
-
|
|
230
|
-
unmount_app({ appName }) {
|
|
231
|
-
if (!appName) return { error: 'appName is required' };
|
|
232
|
-
if (!_checkPermission('modifyDOM')) {
|
|
233
|
-
_audit('unmount_app', { appName }, { error: 'Permission denied' });
|
|
234
|
-
return { error: 'Permission denied: modifyDOM is disabled' };
|
|
235
|
-
}
|
|
236
|
-
try {
|
|
237
|
-
if (wu.unmount) {
|
|
238
|
-
wu.unmount(appName);
|
|
239
|
-
_audit('unmount_app', { appName }, { success: true });
|
|
240
|
-
return { unmounted: appName };
|
|
241
|
-
}
|
|
242
|
-
return { error: 'wu.unmount not available' };
|
|
243
|
-
} catch (err) {
|
|
244
|
-
return { error: err.message };
|
|
245
|
-
}
|
|
246
|
-
},
|
|
247
|
-
|
|
248
|
-
click({ selector, text }) {
|
|
249
|
-
if (!_checkPermission('modifyDOM')) {
|
|
250
|
-
return { error: 'Permission denied: modifyDOM is disabled' };
|
|
251
|
-
}
|
|
252
|
-
const result = clickElement(selector, text);
|
|
253
|
-
_audit('click', { selector, text }, result);
|
|
254
|
-
return result;
|
|
255
|
-
},
|
|
256
|
-
|
|
257
|
-
type({ selector, text, clear = false, submit = false }) {
|
|
258
|
-
if (!_checkPermission('modifyDOM')) {
|
|
259
|
-
return { error: 'Permission denied: modifyDOM is disabled' };
|
|
260
|
-
}
|
|
261
|
-
const result = typeIntoElement(selector, text, { clear, submit });
|
|
262
|
-
_audit('type', { selector, textLength: text?.length }, result);
|
|
263
|
-
return result;
|
|
264
|
-
},
|
|
265
|
-
|
|
266
|
-
async execute_action({ action, params }) {
|
|
267
|
-
if (!wu.ai) return { error: 'wu.ai not available' };
|
|
268
|
-
if (!action) return { error: 'action name is required' };
|
|
269
|
-
|
|
270
|
-
try {
|
|
271
|
-
// Execute through public API (respects permissions, validation, audit)
|
|
272
|
-
const result = await wu.ai.execute(action, params || {});
|
|
273
|
-
return { action, ...result };
|
|
274
|
-
} catch (err) {
|
|
275
|
-
return { error: err.message };
|
|
276
|
-
}
|
|
277
|
-
},
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
// ── WebSocket connection ──
|
|
281
|
-
|
|
282
|
-
function connect(url = 'ws://localhost:19100', options = {}) {
|
|
283
|
-
if (ws && ws.readyState <= 1) {
|
|
284
|
-
logger.warn('[wu-mcp-bridge] Already connected or connecting');
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
authToken = options.token || null;
|
|
289
|
-
authenticated = !authToken; // No token = auto-authenticated (dev mode)
|
|
290
|
-
|
|
291
|
-
try {
|
|
292
|
-
ws = new WebSocket(url);
|
|
293
|
-
|
|
294
|
-
ws.onopen = () => {
|
|
295
|
-
logger.debug('[wu-mcp-bridge] Connected to wu-mcp-server');
|
|
296
|
-
reconnectAttempts = 0;
|
|
297
|
-
|
|
298
|
-
// Send auth handshake if token provided
|
|
299
|
-
if (authToken) {
|
|
300
|
-
ws.send(JSON.stringify({
|
|
301
|
-
type: 'auth',
|
|
302
|
-
token: authToken,
|
|
303
|
-
}));
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
ws.onmessage = async (event) => {
|
|
308
|
-
try {
|
|
309
|
-
const msg = JSON.parse(event.data);
|
|
310
|
-
|
|
311
|
-
// Handle auth response
|
|
312
|
-
if (msg.type === 'auth_result') {
|
|
313
|
-
authenticated = msg.success === true;
|
|
314
|
-
if (!authenticated) {
|
|
315
|
-
console.error('[wu-mcp-bridge] Authentication failed:', msg.reason || 'Invalid token');
|
|
316
|
-
disconnect();
|
|
317
|
-
} else {
|
|
318
|
-
logger.debug('[wu-mcp-bridge] Authenticated successfully');
|
|
319
|
-
}
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Reject commands if not authenticated
|
|
324
|
-
if (!authenticated) {
|
|
325
|
-
if (msg.id) {
|
|
326
|
-
_respond(msg.id, null, 'Not authenticated. Send auth token first.');
|
|
327
|
-
}
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const { id, command, params } = msg;
|
|
332
|
-
|
|
333
|
-
if (!id || !command) {
|
|
334
|
-
logger.warn('[wu-mcp-bridge] Invalid message:', msg);
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const handler = handlers[command];
|
|
339
|
-
if (!handler) {
|
|
340
|
-
_respond(id, null, `Unknown command: ${command}`);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
try {
|
|
345
|
-
const result = await handler(params || {});
|
|
346
|
-
_respond(id, result);
|
|
347
|
-
} catch (err) {
|
|
348
|
-
_respond(id, null, err.message);
|
|
349
|
-
}
|
|
350
|
-
} catch (err) {
|
|
351
|
-
console.error('[wu-mcp-bridge] Failed to handle message:', err);
|
|
352
|
-
}
|
|
353
|
-
};
|
|
354
|
-
|
|
355
|
-
ws.onclose = () => {
|
|
356
|
-
logger.debug('[wu-mcp-bridge] Disconnected');
|
|
357
|
-
ws = null;
|
|
358
|
-
authenticated = false;
|
|
359
|
-
_scheduleReconnect(url, options);
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
ws.onerror = () => {
|
|
363
|
-
// onclose will fire after this
|
|
364
|
-
};
|
|
365
|
-
} catch (err) {
|
|
366
|
-
console.error('[wu-mcp-bridge] Connection failed:', err.message);
|
|
367
|
-
_scheduleReconnect(url, options);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function disconnect() {
|
|
372
|
-
if (reconnectTimer) {
|
|
373
|
-
clearTimeout(reconnectTimer);
|
|
374
|
-
reconnectTimer = null;
|
|
375
|
-
}
|
|
376
|
-
reconnectAttempts = MAX_RECONNECT_ATTEMPTS; // prevent reconnect
|
|
377
|
-
if (ws) {
|
|
378
|
-
ws.close();
|
|
379
|
-
ws = null;
|
|
380
|
-
}
|
|
381
|
-
authenticated = false;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function isConnected() {
|
|
385
|
-
return ws !== null && ws.readyState === 1 && authenticated;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// ── Private helpers ──
|
|
389
|
-
|
|
390
|
-
function _respond(id, result, error) {
|
|
391
|
-
if (!ws || ws.readyState !== 1) return;
|
|
392
|
-
const msg = error ? { id, error } : { id, result };
|
|
393
|
-
ws.send(JSON.stringify(msg));
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function _scheduleReconnect(url, options) {
|
|
397
|
-
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
|
|
398
|
-
reconnectAttempts++;
|
|
399
|
-
const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5);
|
|
400
|
-
reconnectTimer = setTimeout(() => connect(url, options), delay);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function _getAppList() {
|
|
404
|
-
const apps = [];
|
|
405
|
-
|
|
406
|
-
if (wu._apps) {
|
|
407
|
-
for (const [name, app] of Object.entries(wu._apps)) {
|
|
408
|
-
apps.push({
|
|
409
|
-
name,
|
|
410
|
-
mounted: app.mounted || app.isMounted || false,
|
|
411
|
-
url: app.url || app.info?.url || '',
|
|
412
|
-
status: app.status || app.info?.status || 'unknown',
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Fallback: scan DOM for wu-app elements
|
|
418
|
-
if (apps.length === 0) {
|
|
419
|
-
document.querySelectorAll('[data-wu-app]').forEach((el) => {
|
|
420
|
-
apps.push({
|
|
421
|
-
name: el.getAttribute('data-wu-app'),
|
|
422
|
-
mounted: true,
|
|
423
|
-
container: `#${el.id || '(no-id)'}`,
|
|
424
|
-
});
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
return apps;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
return { connect, disconnect, isConnected };
|
|
432
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* WU-MCP Bridge (Browser Side)
|
|
3
|
+
*
|
|
4
|
+
* Connects to the wu-mcp-server via WebSocket and executes
|
|
5
|
+
* commands using wu.* APIs. This is the "eyes and hands" of
|
|
6
|
+
* the MCP server inside the browser.
|
|
7
|
+
*
|
|
8
|
+
* Security:
|
|
9
|
+
* - Optional auth token sent on first message (handshake)
|
|
10
|
+
* - All state/event/mount operations check wu.ai permissions
|
|
11
|
+
* - Mutating operations emit audit events
|
|
12
|
+
* - Read-only operations (status, list_apps, snapshot, console, network) are unrestricted
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Connect with auth token
|
|
16
|
+
* wu.mcp.connect('ws://localhost:19100', { token: 'my-secret' });
|
|
17
|
+
*
|
|
18
|
+
* // Connect without auth (development only)
|
|
19
|
+
* wu.mcp.connect();
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
ensureInterceptors,
|
|
24
|
+
networkLog,
|
|
25
|
+
consoleLog,
|
|
26
|
+
captureScreenshot,
|
|
27
|
+
buildA11yTree,
|
|
28
|
+
clickElement,
|
|
29
|
+
typeIntoElement,
|
|
30
|
+
getFilteredNetwork,
|
|
31
|
+
getFilteredConsole,
|
|
32
|
+
} from '../ai/wu-ai-browser-primitives.js';
|
|
33
|
+
import { logger } from './wu-logger.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create the MCP bridge for a Wu instance.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} wu - The Wu Framework instance (window.wu)
|
|
39
|
+
* @returns {object} Bridge API: { connect, disconnect, isConnected }
|
|
40
|
+
*/
|
|
41
|
+
export function createMcpBridge(wu) {
|
|
42
|
+
let ws = null;
|
|
43
|
+
let reconnectTimer = null;
|
|
44
|
+
let reconnectAttempts = 0;
|
|
45
|
+
let authenticated = false;
|
|
46
|
+
let authToken = null;
|
|
47
|
+
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
48
|
+
const RECONNECT_DELAY = 2000;
|
|
49
|
+
|
|
50
|
+
// Event log for wu_list_events
|
|
51
|
+
const eventLog = [];
|
|
52
|
+
const MAX_EVENT_LOG = 200;
|
|
53
|
+
|
|
54
|
+
// Capture events for history
|
|
55
|
+
if (wu.eventBus) {
|
|
56
|
+
wu.eventBus.on('*', (event) => {
|
|
57
|
+
eventLog.push({
|
|
58
|
+
name: event.name,
|
|
59
|
+
data: event.data,
|
|
60
|
+
timestamp: event.timestamp || Date.now(),
|
|
61
|
+
source: event.source || 'unknown',
|
|
62
|
+
});
|
|
63
|
+
if (eventLog.length > MAX_EVENT_LOG) eventLog.shift();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Install shared interceptors (idempotent — safe if wu-ai-browser already did it)
|
|
68
|
+
ensureInterceptors();
|
|
69
|
+
|
|
70
|
+
// ── Permission helpers ──
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check a permission flag via wu.ai.permissions if available.
|
|
74
|
+
* Falls back to deny if wu.ai is not initialized.
|
|
75
|
+
*/
|
|
76
|
+
function _checkPermission(perm) {
|
|
77
|
+
if (wu.ai && wu.ai.permissions) {
|
|
78
|
+
return wu.ai.permissions.check(perm);
|
|
79
|
+
}
|
|
80
|
+
// If AI module not initialized, deny write operations, allow reads
|
|
81
|
+
const readPerms = ['readStore', 'executeActions'];
|
|
82
|
+
return readPerms.includes(perm);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Emit an audit event for bridge operations.
|
|
87
|
+
*/
|
|
88
|
+
function _audit(operation, params, result) {
|
|
89
|
+
if (wu.eventBus) {
|
|
90
|
+
wu.eventBus.emit('mcp:bridge:operation', {
|
|
91
|
+
operation,
|
|
92
|
+
params,
|
|
93
|
+
result: result?.error ? { error: result.error } : { success: true },
|
|
94
|
+
timestamp: Date.now(),
|
|
95
|
+
}, { appName: 'wu-mcp-bridge' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Command handlers ──
|
|
100
|
+
|
|
101
|
+
const handlers = {
|
|
102
|
+
// ── Read-only operations (no permission gates) ──
|
|
103
|
+
|
|
104
|
+
status() {
|
|
105
|
+
return {
|
|
106
|
+
connected: true,
|
|
107
|
+
framework: 'wu-framework',
|
|
108
|
+
apps: _getAppList(),
|
|
109
|
+
storeKeys: wu.store ? Object.keys(wu.store.get('') || {}) : [],
|
|
110
|
+
actionsCount: wu.ai ? wu.ai.tools().length : 0,
|
|
111
|
+
eventLogSize: eventLog.length,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
list_apps() {
|
|
116
|
+
return _getAppList();
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
list_events({ limit = 20 }) {
|
|
120
|
+
return eventLog.slice(-limit);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
list_actions() {
|
|
124
|
+
if (!wu.ai) return { actions: [], note: 'wu.ai not initialized' };
|
|
125
|
+
const tools = wu.ai.tools();
|
|
126
|
+
return { actions: tools, count: tools.length };
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
snapshot({ appName }) {
|
|
130
|
+
try {
|
|
131
|
+
const target = appName
|
|
132
|
+
? document.querySelector(`[data-wu-app="${appName}"]`) || document.querySelector(`#wu-app-${appName}`)
|
|
133
|
+
: document.body;
|
|
134
|
+
|
|
135
|
+
if (!target) return { error: `App "${appName}" not found in DOM` };
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
app: appName || '(page)',
|
|
139
|
+
snapshot: buildA11yTree(target, 0, 5),
|
|
140
|
+
timestamp: Date.now(),
|
|
141
|
+
};
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return { error: err.message };
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
console({ level = 'all', limit = 50 }) {
|
|
148
|
+
return getFilteredConsole(level, limit);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async screenshot({ selector, quality = 0.8 }) {
|
|
152
|
+
const result = await captureScreenshot(selector, quality);
|
|
153
|
+
if (!result.error) result.timestamp = Date.now();
|
|
154
|
+
return result;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
network({ method, status, limit = 50 }) {
|
|
158
|
+
return getFilteredNetwork(method, status, limit);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
// ── Permission-gated operations ──
|
|
162
|
+
|
|
163
|
+
get_state({ path }) {
|
|
164
|
+
if (!wu.store) return { error: 'wu.store not available' };
|
|
165
|
+
if (!_checkPermission('readStore')) {
|
|
166
|
+
return { error: 'Permission denied: readStore is disabled' };
|
|
167
|
+
}
|
|
168
|
+
const value = wu.store.get(path || '');
|
|
169
|
+
return { path: path || '(root)', value };
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
set_state({ path, value }) {
|
|
173
|
+
if (!wu.store) return { error: 'wu.store not available' };
|
|
174
|
+
if (!path) return { error: 'path is required' };
|
|
175
|
+
if (!_checkPermission('writeStore')) {
|
|
176
|
+
_audit('set_state', { path }, { error: 'Permission denied' });
|
|
177
|
+
return { error: 'Permission denied: writeStore is disabled' };
|
|
178
|
+
}
|
|
179
|
+
wu.store.set(path, value);
|
|
180
|
+
_audit('set_state', { path, value }, { success: true });
|
|
181
|
+
return { path, value, updated: true };
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
emit_event({ event, data }) {
|
|
185
|
+
if (!wu.eventBus) return { error: 'wu.eventBus not available' };
|
|
186
|
+
if (!event) return { error: 'event name is required' };
|
|
187
|
+
if (!_checkPermission('emitEvents')) {
|
|
188
|
+
_audit('emit_event', { event }, { error: 'Permission denied' });
|
|
189
|
+
return { error: 'Permission denied: emitEvents is disabled' };
|
|
190
|
+
}
|
|
191
|
+
wu.eventBus.emit(event, data, { appName: 'wu-mcp-bridge' });
|
|
192
|
+
_audit('emit_event', { event, data }, { success: true });
|
|
193
|
+
return { emitted: event, data };
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
navigate({ route }) {
|
|
197
|
+
if (!route) return { error: 'Route is required' };
|
|
198
|
+
if (!_checkPermission('emitEvents')) {
|
|
199
|
+
_audit('navigate', { route }, { error: 'Permission denied: emitEvents' });
|
|
200
|
+
return { error: 'Permission denied: emitEvents is disabled' };
|
|
201
|
+
}
|
|
202
|
+
if (wu.eventBus) {
|
|
203
|
+
wu.eventBus.emit('shell:navigate', { route }, { appName: 'wu-mcp-bridge' });
|
|
204
|
+
}
|
|
205
|
+
if (wu.store && _checkPermission('writeStore')) {
|
|
206
|
+
wu.store.set('currentPath', route);
|
|
207
|
+
}
|
|
208
|
+
_audit('navigate', { route }, { success: true });
|
|
209
|
+
return { navigated: route };
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
mount_app({ appName, container }) {
|
|
213
|
+
if (!appName) return { error: 'appName is required' };
|
|
214
|
+
if (!_checkPermission('modifyDOM')) {
|
|
215
|
+
_audit('mount_app', { appName }, { error: 'Permission denied' });
|
|
216
|
+
return { error: 'Permission denied: modifyDOM is disabled' };
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
if (wu.mount) {
|
|
220
|
+
wu.mount(appName, container);
|
|
221
|
+
_audit('mount_app', { appName, container }, { success: true });
|
|
222
|
+
return { mounted: appName, container };
|
|
223
|
+
}
|
|
224
|
+
return { error: 'wu.mount not available' };
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return { error: err.message };
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
unmount_app({ appName }) {
|
|
231
|
+
if (!appName) return { error: 'appName is required' };
|
|
232
|
+
if (!_checkPermission('modifyDOM')) {
|
|
233
|
+
_audit('unmount_app', { appName }, { error: 'Permission denied' });
|
|
234
|
+
return { error: 'Permission denied: modifyDOM is disabled' };
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
if (wu.unmount) {
|
|
238
|
+
wu.unmount(appName);
|
|
239
|
+
_audit('unmount_app', { appName }, { success: true });
|
|
240
|
+
return { unmounted: appName };
|
|
241
|
+
}
|
|
242
|
+
return { error: 'wu.unmount not available' };
|
|
243
|
+
} catch (err) {
|
|
244
|
+
return { error: err.message };
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
click({ selector, text }) {
|
|
249
|
+
if (!_checkPermission('modifyDOM')) {
|
|
250
|
+
return { error: 'Permission denied: modifyDOM is disabled' };
|
|
251
|
+
}
|
|
252
|
+
const result = clickElement(selector, text);
|
|
253
|
+
_audit('click', { selector, text }, result);
|
|
254
|
+
return result;
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
type({ selector, text, clear = false, submit = false }) {
|
|
258
|
+
if (!_checkPermission('modifyDOM')) {
|
|
259
|
+
return { error: 'Permission denied: modifyDOM is disabled' };
|
|
260
|
+
}
|
|
261
|
+
const result = typeIntoElement(selector, text, { clear, submit });
|
|
262
|
+
_audit('type', { selector, textLength: text?.length }, result);
|
|
263
|
+
return result;
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async execute_action({ action, params }) {
|
|
267
|
+
if (!wu.ai) return { error: 'wu.ai not available' };
|
|
268
|
+
if (!action) return { error: 'action name is required' };
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
// Execute through public API (respects permissions, validation, audit)
|
|
272
|
+
const result = await wu.ai.execute(action, params || {});
|
|
273
|
+
return { action, ...result };
|
|
274
|
+
} catch (err) {
|
|
275
|
+
return { error: err.message };
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// ── WebSocket connection ──
|
|
281
|
+
|
|
282
|
+
function connect(url = 'ws://localhost:19100', options = {}) {
|
|
283
|
+
if (ws && ws.readyState <= 1) {
|
|
284
|
+
logger.warn('[wu-mcp-bridge] Already connected or connecting');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
authToken = options.token || null;
|
|
289
|
+
authenticated = !authToken; // No token = auto-authenticated (dev mode)
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
ws = new WebSocket(url);
|
|
293
|
+
|
|
294
|
+
ws.onopen = () => {
|
|
295
|
+
logger.debug('[wu-mcp-bridge] Connected to wu-mcp-server');
|
|
296
|
+
reconnectAttempts = 0;
|
|
297
|
+
|
|
298
|
+
// Send auth handshake if token provided
|
|
299
|
+
if (authToken) {
|
|
300
|
+
ws.send(JSON.stringify({
|
|
301
|
+
type: 'auth',
|
|
302
|
+
token: authToken,
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
ws.onmessage = async (event) => {
|
|
308
|
+
try {
|
|
309
|
+
const msg = JSON.parse(event.data);
|
|
310
|
+
|
|
311
|
+
// Handle auth response
|
|
312
|
+
if (msg.type === 'auth_result') {
|
|
313
|
+
authenticated = msg.success === true;
|
|
314
|
+
if (!authenticated) {
|
|
315
|
+
console.error('[wu-mcp-bridge] Authentication failed:', msg.reason || 'Invalid token');
|
|
316
|
+
disconnect();
|
|
317
|
+
} else {
|
|
318
|
+
logger.debug('[wu-mcp-bridge] Authenticated successfully');
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Reject commands if not authenticated
|
|
324
|
+
if (!authenticated) {
|
|
325
|
+
if (msg.id) {
|
|
326
|
+
_respond(msg.id, null, 'Not authenticated. Send auth token first.');
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { id, command, params } = msg;
|
|
332
|
+
|
|
333
|
+
if (!id || !command) {
|
|
334
|
+
logger.warn('[wu-mcp-bridge] Invalid message:', msg);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const handler = handlers[command];
|
|
339
|
+
if (!handler) {
|
|
340
|
+
_respond(id, null, `Unknown command: ${command}`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const result = await handler(params || {});
|
|
346
|
+
_respond(id, result);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
_respond(id, null, err.message);
|
|
349
|
+
}
|
|
350
|
+
} catch (err) {
|
|
351
|
+
console.error('[wu-mcp-bridge] Failed to handle message:', err);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
ws.onclose = () => {
|
|
356
|
+
logger.debug('[wu-mcp-bridge] Disconnected');
|
|
357
|
+
ws = null;
|
|
358
|
+
authenticated = false;
|
|
359
|
+
_scheduleReconnect(url, options);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
ws.onerror = () => {
|
|
363
|
+
// onclose will fire after this
|
|
364
|
+
};
|
|
365
|
+
} catch (err) {
|
|
366
|
+
console.error('[wu-mcp-bridge] Connection failed:', err.message);
|
|
367
|
+
_scheduleReconnect(url, options);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function disconnect() {
|
|
372
|
+
if (reconnectTimer) {
|
|
373
|
+
clearTimeout(reconnectTimer);
|
|
374
|
+
reconnectTimer = null;
|
|
375
|
+
}
|
|
376
|
+
reconnectAttempts = MAX_RECONNECT_ATTEMPTS; // prevent reconnect
|
|
377
|
+
if (ws) {
|
|
378
|
+
ws.close();
|
|
379
|
+
ws = null;
|
|
380
|
+
}
|
|
381
|
+
authenticated = false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isConnected() {
|
|
385
|
+
return ws !== null && ws.readyState === 1 && authenticated;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Private helpers ──
|
|
389
|
+
|
|
390
|
+
function _respond(id, result, error) {
|
|
391
|
+
if (!ws || ws.readyState !== 1) return;
|
|
392
|
+
const msg = error ? { id, error } : { id, result };
|
|
393
|
+
ws.send(JSON.stringify(msg));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function _scheduleReconnect(url, options) {
|
|
397
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
|
|
398
|
+
reconnectAttempts++;
|
|
399
|
+
const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5);
|
|
400
|
+
reconnectTimer = setTimeout(() => connect(url, options), delay);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function _getAppList() {
|
|
404
|
+
const apps = [];
|
|
405
|
+
|
|
406
|
+
if (wu._apps) {
|
|
407
|
+
for (const [name, app] of Object.entries(wu._apps)) {
|
|
408
|
+
apps.push({
|
|
409
|
+
name,
|
|
410
|
+
mounted: app.mounted || app.isMounted || false,
|
|
411
|
+
url: app.url || app.info?.url || '',
|
|
412
|
+
status: app.status || app.info?.status || 'unknown',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Fallback: scan DOM for wu-app elements
|
|
418
|
+
if (apps.length === 0) {
|
|
419
|
+
document.querySelectorAll('[data-wu-app]').forEach((el) => {
|
|
420
|
+
apps.push({
|
|
421
|
+
name: el.getAttribute('data-wu-app'),
|
|
422
|
+
mounted: true,
|
|
423
|
+
container: `#${el.id || '(no-id)'}`,
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return apps;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return { connect, disconnect, isConnected };
|
|
432
|
+
}
|