@wong2kim/wmux 1.1.2 → 2.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.
@@ -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 page = await engine.getPage(surfaceId);
30
- if (!page) {
31
- throw new Error('No browser page available. Call browser_open first.');
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
- await page.goto(url, { waitUntil: 'domcontentloaded' });
34
- const finalUrl = page.url();
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 ${finalUrl}` }],
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
- const page = await engine.getPage(surfaceId);
55
- if (!page) {
56
- throw new Error('No browser page available. Call browser_open first.');
57
- }
58
- await page.goBack();
59
- const currentUrl = page.url();
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 ${currentUrl}` }],
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: `Wait completed: custom predicate satisfied` }],
81
+ content: [{ type: 'text', text: warningPrefix + 'Wait completed: custom predicate satisfied' }],
74
82
  };
75
83
  }
76
84
  // Default: wait for network idle
@@ -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',
@@ -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 (\r, \n, \x00-\x1f except \t) from text
17
- * that will be written to a PTY, preventing embedded command injection.
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 all control chars except tab (\x09)
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-\x08\x0a-\x1f\x7f\u0080-\u009f]/g, '');
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": "1.1.2",
4
+ "version": "2.0.0",
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": {