@wong2kim/wmux 1.1.1 → 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.
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerWaitTools = registerWaitTools;
4
+ const zod_1 = require("zod");
5
+ const PlaywrightEngine_1 = require("../PlaywrightEngine");
6
+ const security_1 = require("../security");
7
+ // Optional surfaceId schema reused across tools
8
+ const optionalSurfaceId = zod_1.z
9
+ .string()
10
+ .optional()
11
+ .describe('Target a specific surface by ID. Omit to use the active surface.');
12
+ /**
13
+ * Register wait-related MCP tools on the given server.
14
+ *
15
+ * Tools:
16
+ * - browser_wait — wait for a URL, selector, text, JS predicate, or network idle
17
+ */
18
+ function registerWaitTools(server) {
19
+ const engine = PlaywrightEngine_1.PlaywrightEngine.getInstance();
20
+ // -----------------------------------------------------------------------
21
+ // browser_wait
22
+ // -----------------------------------------------------------------------
23
+ server.tool('browser_wait', 'Wait for a condition: URL pattern, CSS selector, text content, custom JS predicate, or network idle. Priority: url > selector > text > fn > networkidle.', {
24
+ url: zod_1.z
25
+ .string()
26
+ .optional()
27
+ .describe('URL or glob pattern to wait for (e.g. "**/dashboard**").'),
28
+ selector: zod_1.z
29
+ .string()
30
+ .optional()
31
+ .describe('CSS selector to wait for.'),
32
+ text: zod_1.z
33
+ .string()
34
+ .optional()
35
+ .describe('Text to wait for in document.body.innerText.'),
36
+ fn: zod_1.z
37
+ .string()
38
+ .optional()
39
+ .describe('Custom JavaScript predicate function body to wait for (must return truthy).'),
40
+ timeout: zod_1.z
41
+ .number()
42
+ .optional()
43
+ .describe('Maximum wait time in milliseconds. Defaults to 30000.'),
44
+ surfaceId: optionalSurfaceId,
45
+ }, async ({ url, selector, text, fn, timeout, surfaceId }) => {
46
+ const resolvedTimeout = timeout ?? 30000;
47
+ try {
48
+ const page = await engine.getPage(surfaceId);
49
+ if (!page) {
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).');
51
+ }
52
+ // Priority: url > selector > text > fn > networkidle
53
+ if (url) {
54
+ await page.waitForURL(url, { timeout: resolvedTimeout });
55
+ return {
56
+ content: [{ type: 'text', text: `Wait completed: URL matched "${url}"` }],
57
+ };
58
+ }
59
+ if (selector) {
60
+ await page.waitForSelector(selector, { timeout: resolvedTimeout });
61
+ return {
62
+ content: [{ type: 'text', text: `Wait completed: selector "${selector}" found` }],
63
+ };
64
+ }
65
+ if (text) {
66
+ await page.waitForFunction((t) => document.body.innerText.includes(t), text, { timeout: resolvedTimeout });
67
+ return {
68
+ content: [{ type: 'text', text: `Wait completed: text "${text}" found` }],
69
+ };
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
+ }
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
+ : '';
80
+ return {
81
+ content: [{ type: 'text', text: warningPrefix + 'Wait completed: custom predicate satisfied' }],
82
+ };
83
+ }
84
+ // Default: wait for network idle
85
+ await page.waitForLoadState('networkidle', { timeout: resolvedTimeout });
86
+ return {
87
+ content: [{ type: 'text', text: `Wait completed: network idle` }],
88
+ };
89
+ }
90
+ catch (error) {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ // Provide clear timeout messaging
93
+ if (message.includes('Timeout') || message.includes('timeout')) {
94
+ const condition = url
95
+ ? `URL "${url}"`
96
+ : selector
97
+ ? `selector "${selector}"`
98
+ : text
99
+ ? `text "${text}"`
100
+ : fn
101
+ ? 'custom predicate'
102
+ : 'network idle';
103
+ return {
104
+ content: [
105
+ {
106
+ type: 'text',
107
+ text: `Timed out after ${resolvedTimeout}ms waiting for ${condition}`,
108
+ },
109
+ ],
110
+ isError: true,
111
+ };
112
+ }
113
+ return {
114
+ content: [{ type: 'text', text: message }],
115
+ isError: true,
116
+ };
117
+ }
118
+ });
119
+ }
@@ -45,6 +45,9 @@ exports.IPC = {
45
45
  FS_WATCH: 'fs:watch',
46
46
  FS_UNWATCH: 'fs:unwatch',
47
47
  FS_CHANGED: 'fs:changed',
48
+ // Scrollback persistence
49
+ SCROLLBACK_DUMP: 'scrollback:dump',
50
+ SCROLLBACK_LOAD: 'scrollback:load',
48
51
  };
49
52
  // Named Pipe / Unix socket path for wmux API
50
53
  // Fixed name so MCP clients (e.g. Claude Code) can reconnect across wmux restarts
@@ -25,9 +25,26 @@ exports.ALL_RPC_METHODS = [
25
25
  'system.identify',
26
26
  'system.capabilities',
27
27
  'browser.open',
28
- 'browser.snapshot',
29
- 'browser.click',
30
- 'browser.fill',
31
- 'browser.eval',
32
28
  'browser.navigate',
29
+ 'browser.close',
30
+ 'browser.session.start',
31
+ 'browser.session.stop',
32
+ 'browser.session.status',
33
+ 'browser.session.list',
34
+ 'browser.type.humanlike',
35
+ 'browser.cdp.target',
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',
43
+ 'daemon.createSession',
44
+ 'daemon.destroySession',
45
+ 'daemon.attachSession',
46
+ 'daemon.detachSession',
47
+ 'daemon.resizeSession',
48
+ 'daemon.listSessions',
49
+ 'daemon.ping',
33
50
  ];
@@ -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,23 @@
1
1
  {
2
2
  "name": "@wong2kim/wmux",
3
3
  "productName": "wmux",
4
- "version": "1.1.1",
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:mcp": "tsc -p tsconfig.mcp.json && esbuild dist/mcp/mcp/index.js --bundle --platform=node --outfile=dist/mcp-bundle/index.js --external:electron",
15
+ "build:daemon": "tsc -p tsconfig.daemon.json",
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",
20
+ "test": "vitest run"
19
21
  },
20
22
  "bin": {
21
23
  "wmux": "dist/cli/cli/index.js",
@@ -76,7 +78,8 @@
76
78
  "postcss": "^8.5.8",
77
79
  "tailwindcss": "^3.4.19",
78
80
  "typescript": "^5.9.3",
79
- "vite": "^5.4.21"
81
+ "vite": "^5.4.21",
82
+ "vitest": "^4.1.0"
80
83
  },
81
84
  "dependencies": {
82
85
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -87,6 +90,7 @@
87
90
  "electron-squirrel-startup": "^1.0.1",
88
91
  "immer": "^11.1.4",
89
92
  "node-pty": "^1.1.0",
93
+ "playwright-core": "^1.58.2",
90
94
  "react": "^19.2.4",
91
95
  "react-dom": "^19.2.4",
92
96
  "react-resizable-panels": "^4.7.3",