@wong2kim/wmux 1.1.2 → 2.0.1
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 +137 -86
- package/dist/cli/cli/commands/system.js +12 -1
- package/dist/cli/shared/rpc.js +6 -0
- package/dist/cli/shared/types.js +108 -4
- package/dist/mcp/mcp/index.js +12 -1
- package/dist/mcp/mcp/playwright/PlaywrightEngine.js +193 -86
- package/dist/mcp/mcp/playwright/anti-detection.js +12 -7
- package/dist/mcp/mcp/playwright/security.js +29 -0
- package/dist/mcp/mcp/playwright/tools/extraction.js +3 -3
- package/dist/mcp/mcp/playwright/tools/file.js +4 -4
- package/dist/mcp/mcp/playwright/tools/inspection.js +93 -29
- package/dist/mcp/mcp/playwright/tools/interaction.js +207 -137
- package/dist/mcp/mcp/playwright/tools/navigation.js +37 -14
- package/dist/mcp/mcp/playwright/tools/state.js +4 -4
- package/dist/mcp/mcp/playwright/tools/utility.js +2 -2
- package/dist/mcp/mcp/playwright/tools/wait.js +10 -2
- package/dist/mcp/shared/rpc.js +6 -0
- package/dist/mcp/shared/types.js +108 -4
- package/package.json +6 -5
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.registerNavigationTools = registerNavigationTools;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const PlaywrightEngine_1 = require("../PlaywrightEngine");
|
|
6
|
+
const types_1 = require("../../../shared/types");
|
|
7
|
+
const wmux_client_1 = require("../../wmux-client");
|
|
6
8
|
// Optional surfaceId schema reused across tools
|
|
7
9
|
const optionalSurfaceId = zod_1.z
|
|
8
10
|
.string()
|
|
@@ -26,14 +28,17 @@ function registerNavigationTools(server) {
|
|
|
26
28
|
surfaceId: optionalSurfaceId,
|
|
27
29
|
}, async ({ url, surfaceId }) => {
|
|
28
30
|
try {
|
|
29
|
-
const
|
|
30
|
-
if (!
|
|
31
|
-
|
|
31
|
+
const urlCheck = (0, types_1.validateNavigationUrl)(url);
|
|
32
|
+
if (!urlCheck.valid) {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: 'text', text: `URL blocked: ${urlCheck.reason}` }],
|
|
35
|
+
isError: true,
|
|
36
|
+
};
|
|
32
37
|
}
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
// Use RPC for fast, reliable navigation (bypasses Playwright CDP discovery)
|
|
39
|
+
await (0, wmux_client_1.sendRpc)('browser.navigate', { url, ...(surfaceId && { surfaceId }) });
|
|
35
40
|
return {
|
|
36
|
-
content: [{ type: 'text', text: `Navigated to ${
|
|
41
|
+
content: [{ type: 'text', text: `Navigated to ${url}` }],
|
|
37
42
|
};
|
|
38
43
|
}
|
|
39
44
|
catch (error) {
|
|
@@ -51,14 +56,25 @@ function registerNavigationTools(server) {
|
|
|
51
56
|
surfaceId: optionalSurfaceId,
|
|
52
57
|
}, async ({ surfaceId }) => {
|
|
53
58
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
// Use CDP via RPC for reliability
|
|
60
|
+
await (0, wmux_client_1.sendRpc)('browser.cdp.send', {
|
|
61
|
+
method: 'Page.navigateToHistoryEntry',
|
|
62
|
+
params: {},
|
|
63
|
+
...(surfaceId && { surfaceId }),
|
|
64
|
+
}).catch(() => {
|
|
65
|
+
// Fallback: use history navigation via JS evaluation
|
|
66
|
+
return (0, wmux_client_1.sendRpc)('browser.evaluate', {
|
|
67
|
+
expression: 'history.back()',
|
|
68
|
+
...(surfaceId && { surfaceId }),
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// Get current URL
|
|
72
|
+
const urlResult = await (0, wmux_client_1.sendRpc)('browser.evaluate', {
|
|
73
|
+
expression: 'location.href',
|
|
74
|
+
...(surfaceId && { surfaceId }),
|
|
75
|
+
});
|
|
60
76
|
return {
|
|
61
|
-
content: [{ type: 'text', text: `Navigated back to ${
|
|
77
|
+
content: [{ type: 'text', text: `Navigated back to ${urlResult.value}` }],
|
|
62
78
|
};
|
|
63
79
|
}
|
|
64
80
|
catch (error) {
|
|
@@ -89,7 +105,7 @@ function registerNavigationTools(server) {
|
|
|
89
105
|
try {
|
|
90
106
|
const browser = await engine.getBrowser();
|
|
91
107
|
if (!browser) {
|
|
92
|
-
throw new Error('No browser connected. Call browser_open first.');
|
|
108
|
+
throw new Error('No browser connected. Call browser_open with a URL first to establish a CDP connection.');
|
|
93
109
|
}
|
|
94
110
|
const resolvedAction = action ?? 'list';
|
|
95
111
|
// Collect all pages across all contexts
|
|
@@ -128,6 +144,13 @@ function registerNavigationTools(server) {
|
|
|
128
144
|
}
|
|
129
145
|
const newPage = await context.newPage();
|
|
130
146
|
if (url) {
|
|
147
|
+
const urlCheck = (0, types_1.validateNavigationUrl)(url);
|
|
148
|
+
if (!urlCheck.valid) {
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: 'text', text: `URL blocked: ${urlCheck.reason}` }],
|
|
151
|
+
isError: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
131
154
|
await newPage.goto(url, { waitUntil: 'domcontentloaded' });
|
|
132
155
|
}
|
|
133
156
|
return {
|
|
@@ -48,7 +48,7 @@ function registerStateTools(server) {
|
|
|
48
48
|
try {
|
|
49
49
|
const page = await engine.getPage(surfaceId);
|
|
50
50
|
if (!page) {
|
|
51
|
-
throw new Error('No browser page available. Call browser_open first.');
|
|
51
|
+
throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
|
|
52
52
|
}
|
|
53
53
|
const context = page.context();
|
|
54
54
|
switch (action) {
|
|
@@ -126,7 +126,7 @@ function registerStateTools(server) {
|
|
|
126
126
|
try {
|
|
127
127
|
const page = await engine.getPage(surfaceId);
|
|
128
128
|
if (!page) {
|
|
129
|
-
throw new Error('No browser page available. Call browser_open first.');
|
|
129
|
+
throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
|
|
130
130
|
}
|
|
131
131
|
const storageName = type === 'local' ? 'localStorage' : 'sessionStorage';
|
|
132
132
|
switch (action) {
|
|
@@ -245,7 +245,7 @@ function registerStateTools(server) {
|
|
|
245
245
|
try {
|
|
246
246
|
const page = await engine.getPage(surfaceId);
|
|
247
247
|
if (!page) {
|
|
248
|
-
throw new Error('No browser page available. Call browser_open first.');
|
|
248
|
+
throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
|
|
249
249
|
}
|
|
250
250
|
const context = page.context();
|
|
251
251
|
const applied = [];
|
|
@@ -387,7 +387,7 @@ function registerStateTools(server) {
|
|
|
387
387
|
try {
|
|
388
388
|
const page = await engine.getPage(surfaceId);
|
|
389
389
|
if (!page) {
|
|
390
|
-
throw new Error('No browser page available. Call browser_open first.');
|
|
390
|
+
throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
|
|
391
391
|
}
|
|
392
392
|
await page.setViewportSize({ width, height });
|
|
393
393
|
return {
|
|
@@ -65,7 +65,7 @@ function registerUtilityTools(server) {
|
|
|
65
65
|
try {
|
|
66
66
|
const page = await engine.getPage(surfaceId);
|
|
67
67
|
if (!page) {
|
|
68
|
-
throw new Error('No browser page available. Call browser_open first.');
|
|
68
|
+
throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
|
|
69
69
|
}
|
|
70
70
|
try {
|
|
71
71
|
// Try Playwright's built-in pdf() first
|
|
@@ -130,7 +130,7 @@ function registerUtilityTools(server) {
|
|
|
130
130
|
try {
|
|
131
131
|
const page = await engine.getPage(surfaceId);
|
|
132
132
|
if (!page) {
|
|
133
|
-
throw new Error('No browser page available. Call browser_open first.');
|
|
133
|
+
throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
|
|
134
134
|
}
|
|
135
135
|
const context = page.context();
|
|
136
136
|
if (action === 'start') {
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.registerWaitTools = registerWaitTools;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const PlaywrightEngine_1 = require("../PlaywrightEngine");
|
|
6
|
+
const security_1 = require("../security");
|
|
6
7
|
// Optional surfaceId schema reused across tools
|
|
7
8
|
const optionalSurfaceId = zod_1.z
|
|
8
9
|
.string()
|
|
@@ -46,7 +47,7 @@ function registerWaitTools(server) {
|
|
|
46
47
|
try {
|
|
47
48
|
const page = await engine.getPage(surfaceId);
|
|
48
49
|
if (!page) {
|
|
49
|
-
throw new Error('No browser page available. Call browser_open first.');
|
|
50
|
+
throw new Error('No browser page available. Call browser_open with a URL first to establish a CDP connection (required even if a browser panel is already visible).');
|
|
50
51
|
}
|
|
51
52
|
// Priority: url > selector > text > fn > networkidle
|
|
52
53
|
if (url) {
|
|
@@ -68,9 +69,16 @@ function registerWaitTools(server) {
|
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
if (fn) {
|
|
72
|
+
const warnings = (0, security_1.detectDangerousPatterns)(fn);
|
|
73
|
+
if (warnings.length > 0) {
|
|
74
|
+
console.warn(`[browser_wait] Dangerous patterns in fn: ${warnings.join(', ')}`);
|
|
75
|
+
}
|
|
71
76
|
await page.waitForFunction(fn, undefined, { timeout: resolvedTimeout });
|
|
77
|
+
const warningPrefix = warnings.length > 0
|
|
78
|
+
? `\u26A0 Security warning: fn contains potentially dangerous patterns: ${warnings.join(', ')}.\n`
|
|
79
|
+
: '';
|
|
72
80
|
return {
|
|
73
|
-
content: [{ type: 'text', text:
|
|
81
|
+
content: [{ type: 'text', text: warningPrefix + 'Wait completed: custom predicate satisfied' }],
|
|
74
82
|
};
|
|
75
83
|
}
|
|
76
84
|
// Default: wait for network idle
|
package/dist/mcp/shared/rpc.js
CHANGED
|
@@ -34,6 +34,12 @@ exports.ALL_RPC_METHODS = [
|
|
|
34
34
|
'browser.type.humanlike',
|
|
35
35
|
'browser.cdp.target',
|
|
36
36
|
'browser.cdp.info',
|
|
37
|
+
'browser.cdp.send',
|
|
38
|
+
'browser.screenshot',
|
|
39
|
+
'browser.evaluate',
|
|
40
|
+
'browser.type.cdp',
|
|
41
|
+
'browser.click.cdp',
|
|
42
|
+
'browser.press.cdp',
|
|
37
43
|
'daemon.createSession',
|
|
38
44
|
'daemon.destroySession',
|
|
39
45
|
'daemon.attachSession',
|
package/dist/mcp/shared/types.js
CHANGED
|
@@ -7,19 +7,22 @@ exports.validateMessage = validateMessage;
|
|
|
7
7
|
exports.createSurface = createSurface;
|
|
8
8
|
exports.createLeafPane = createLeafPane;
|
|
9
9
|
exports.createWorkspace = createWorkspace;
|
|
10
|
+
exports.validateNavigationUrl = validateNavigationUrl;
|
|
10
11
|
// === Utility: generate unique IDs ===
|
|
11
12
|
function generateId(prefix) {
|
|
12
13
|
return `${prefix}-${crypto.randomUUID()}`;
|
|
13
14
|
}
|
|
14
15
|
// === Security: sanitize text before PTY write ===
|
|
15
16
|
/**
|
|
16
|
-
* Strips control characters
|
|
17
|
-
*
|
|
17
|
+
* Strips dangerous control characters from text before writing to a PTY.
|
|
18
|
+
* Removes: NULL byte (\x00) and C1 control characters (\x80-\x9f).
|
|
19
|
+
* Preserves: CR (\r), LF (\n), Tab (\t), ESC sequences (\x1b[...),
|
|
20
|
+
* and other standard terminal control characters needed for normal operation.
|
|
18
21
|
*/
|
|
19
22
|
function sanitizePtyText(text) {
|
|
20
|
-
// Remove
|
|
23
|
+
// Remove NULL byte and C1 control characters (U+0080–U+009F)
|
|
21
24
|
// eslint-disable-next-line no-control-regex
|
|
22
|
-
return text.replace(/[\x00
|
|
25
|
+
return text.replace(/[\x00\u0080-\u009f]/g, '');
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
25
28
|
* Validates and clamps a user-supplied name string.
|
|
@@ -77,3 +80,104 @@ function createWorkspace(name) {
|
|
|
77
80
|
activePaneId: rootPane.id,
|
|
78
81
|
};
|
|
79
82
|
}
|
|
83
|
+
// === Security: URL validation for SSRF prevention ===
|
|
84
|
+
/**
|
|
85
|
+
* Validates a URL for safe navigation. Blocks dangerous schemes and private
|
|
86
|
+
* network addresses to prevent SSRF attacks from AI agent-driven browsing.
|
|
87
|
+
*
|
|
88
|
+
* Allows localhost/127.0.0.1/[::1] for local development servers.
|
|
89
|
+
*
|
|
90
|
+
* NOTE (v1 limitation): This is string-based validation only. DNS-resolved IPs
|
|
91
|
+
* are not checked, so DNS rebinding attacks are not mitigated. A future version
|
|
92
|
+
* should resolve hostnames and re-validate the resolved IP.
|
|
93
|
+
*/
|
|
94
|
+
function validateNavigationUrl(url) {
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(url);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { valid: false, reason: 'Invalid URL' };
|
|
101
|
+
}
|
|
102
|
+
// Only allow http and https schemes
|
|
103
|
+
const scheme = parsed.protocol.toLowerCase();
|
|
104
|
+
if (scheme !== 'http:' && scheme !== 'https:') {
|
|
105
|
+
return { valid: false, reason: `Blocked URL scheme: ${scheme}` };
|
|
106
|
+
}
|
|
107
|
+
// Extract hostname (strip brackets from IPv6)
|
|
108
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
109
|
+
// Allow localhost and IPv4/IPv6 loopback
|
|
110
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
|
111
|
+
return { valid: true };
|
|
112
|
+
}
|
|
113
|
+
// Block IPv6 private/link-local ranges
|
|
114
|
+
if (hostname.startsWith('[') || hostname.includes(':')) {
|
|
115
|
+
// Hostname is an IPv6 address (URL parser strips brackets in .hostname)
|
|
116
|
+
const addr = hostname;
|
|
117
|
+
// Block fc00::/7 (unique local) — starts with fc or fd
|
|
118
|
+
if (addr.startsWith('fc') || addr.startsWith('fd')) {
|
|
119
|
+
return { valid: false, reason: 'Blocked private IPv6 address (fc00::/7)' };
|
|
120
|
+
}
|
|
121
|
+
// Block fe80::/10 (link-local) — starts with fe8, fe9, fea, feb
|
|
122
|
+
if (/^fe[89ab]/.test(addr)) {
|
|
123
|
+
return { valid: false, reason: 'Blocked link-local IPv6 address (fe80::/10)' };
|
|
124
|
+
}
|
|
125
|
+
// ::1 already allowed above; block any other loopback representation
|
|
126
|
+
// Normalize: collapse :: and check
|
|
127
|
+
if (addr === '0:0:0:0:0:0:0:1' || addr === '0000:0000:0000:0000:0000:0000:0000:0001') {
|
|
128
|
+
return { valid: true };
|
|
129
|
+
}
|
|
130
|
+
// Block null IPv6 address (:: or 0:0:0:0:0:0:0:0) — equivalent to 0.0.0.0
|
|
131
|
+
if (addr === '::' || addr === '0:0:0:0:0:0:0:0' || addr === '0000:0000:0000:0000:0000:0000:0000:0000') {
|
|
132
|
+
return { valid: false, reason: 'Blocked null IPv6 address (equivalent to 0.0.0.0)' };
|
|
133
|
+
}
|
|
134
|
+
// Block IPv4-mapped IPv6 (::ffff:x.x.x.x) and IPv4-compatible IPv6 (::x.x.x.x)
|
|
135
|
+
// These resolve to their embedded IPv4 address, bypassing IPv4 private IP checks.
|
|
136
|
+
const v4MappedMatch = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(addr);
|
|
137
|
+
const v4CompatMatch = !v4MappedMatch ? /^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(addr) : null;
|
|
138
|
+
const embeddedV4 = v4MappedMatch?.[1] ?? v4CompatMatch?.[1];
|
|
139
|
+
if (embeddedV4) {
|
|
140
|
+
// Recursively validate the embedded IPv4 through the same checks
|
|
141
|
+
const embeddedResult = validateNavigationUrl(`http://${embeddedV4}/`);
|
|
142
|
+
if (!embeddedResult.valid) {
|
|
143
|
+
return { valid: false, reason: `Blocked IPv4-mapped/compatible IPv6: embedded ${embeddedV4} — ${embeddedResult.reason}` };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { valid: true };
|
|
147
|
+
}
|
|
148
|
+
// Check for IPv4 addresses
|
|
149
|
+
const ipv4Match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
|
|
150
|
+
if (ipv4Match) {
|
|
151
|
+
const octets = [
|
|
152
|
+
parseInt(ipv4Match[1], 10),
|
|
153
|
+
parseInt(ipv4Match[2], 10),
|
|
154
|
+
parseInt(ipv4Match[3], 10),
|
|
155
|
+
parseInt(ipv4Match[4], 10),
|
|
156
|
+
];
|
|
157
|
+
// 127.0.0.1 already allowed above; block other 127.x.x.x
|
|
158
|
+
if (octets[0] === 127) {
|
|
159
|
+
return { valid: false, reason: 'Blocked loopback address' };
|
|
160
|
+
}
|
|
161
|
+
// Block 10.0.0.0/8
|
|
162
|
+
if (octets[0] === 10) {
|
|
163
|
+
return { valid: false, reason: 'Blocked private IP address (10.0.0.0/8)' };
|
|
164
|
+
}
|
|
165
|
+
// Block 172.16.0.0/12 (172.16.x.x – 172.31.x.x)
|
|
166
|
+
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
|
|
167
|
+
return { valid: false, reason: 'Blocked private IP address (172.16.0.0/12)' };
|
|
168
|
+
}
|
|
169
|
+
// Block 192.168.0.0/16
|
|
170
|
+
if (octets[0] === 192 && octets[1] === 168) {
|
|
171
|
+
return { valid: false, reason: 'Blocked private IP address (192.168.0.0/16)' };
|
|
172
|
+
}
|
|
173
|
+
// Block 169.254.0.0/16 (link-local, includes cloud metadata 169.254.169.254)
|
|
174
|
+
if (octets[0] === 169 && octets[1] === 254) {
|
|
175
|
+
return { valid: false, reason: 'Blocked link-local/cloud metadata address (169.254.0.0/16)' };
|
|
176
|
+
}
|
|
177
|
+
// Block 0.0.0.0
|
|
178
|
+
if (octets.every((o) => o === 0)) {
|
|
179
|
+
return { valid: false, reason: 'Blocked null address (0.0.0.0)' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { valid: true };
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wong2kim/wmux",
|
|
3
3
|
"productName": "wmux",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.1",
|
|
5
5
|
"description": "Windows terminal multiplexer with MCP server for AI agents - run multiple CLI sessions in parallel, control via Claude Code and other AI tools",
|
|
6
6
|
"main": ".vite/build/index.js",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"postinstall": "node scripts/fix-node-pty.js",
|
|
9
|
-
"start": "npm run build:mcp && electron-forge start",
|
|
10
|
-
"package": "npm run build:mcp && electron-forge package",
|
|
11
|
-
"make": "npm run build:mcp && electron-forge make",
|
|
9
|
+
"start": "npm run build:daemon && npm run build:mcp && electron-forge start",
|
|
10
|
+
"package": "npm run build:daemon && npm run build:mcp && electron-forge package",
|
|
11
|
+
"make": "npm run build:daemon && npm run build:mcp && electron-forge make",
|
|
12
12
|
"forge-publish": "electron-forge publish",
|
|
13
13
|
"lint": "eslint --ext .ts,.tsx .",
|
|
14
14
|
"build:cli": "tsc -p tsconfig.cli.json",
|
|
15
|
+
"build:daemon": "tsc -p tsconfig.daemon.json",
|
|
15
16
|
"build:mcp": "tsc -p tsconfig.mcp.json && esbuild dist/mcp/mcp/index.js --bundle --platform=node --outfile=dist/mcp-bundle/index.js --external:electron --external:playwright-core",
|
|
16
17
|
"cli": "node dist/cli/cli/index.js",
|
|
17
18
|
"mcp": "node dist/mcp/mcp/index.js",
|
|
18
|
-
"prepublishOnly": "npm run build:cli && npm run build:mcp",
|
|
19
|
+
"prepublishOnly": "npm run build:daemon && npm run build:cli && npm run build:mcp",
|
|
19
20
|
"test": "vitest run"
|
|
20
21
|
},
|
|
21
22
|
"bin": {
|