@moxxy/plugin-browser 0.27.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/dist/browser-session.d.ts +43 -0
  3. package/dist/browser-session.d.ts.map +1 -0
  4. package/dist/browser-session.js +500 -0
  5. package/dist/browser-session.js.map +1 -0
  6. package/dist/browser-surface.d.ts +3 -0
  7. package/dist/browser-surface.d.ts.map +1 -0
  8. package/dist/browser-surface.js +255 -0
  9. package/dist/browser-surface.js.map +1 -0
  10. package/dist/html-extract.d.ts +20 -0
  11. package/dist/html-extract.d.ts.map +1 -0
  12. package/dist/html-extract.js +122 -0
  13. package/dist/html-extract.js.map +1 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +24 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/sidecar/dispatch.d.ts +18 -0
  19. package/dist/sidecar/dispatch.d.ts.map +1 -0
  20. package/dist/sidecar/dispatch.js +294 -0
  21. package/dist/sidecar/dispatch.js.map +1 -0
  22. package/dist/sidecar/install.d.ts +37 -0
  23. package/dist/sidecar/install.d.ts.map +1 -0
  24. package/dist/sidecar/install.js +242 -0
  25. package/dist/sidecar/install.js.map +1 -0
  26. package/dist/sidecar/types.d.ts +97 -0
  27. package/dist/sidecar/types.d.ts.map +1 -0
  28. package/dist/sidecar/types.js +20 -0
  29. package/dist/sidecar/types.js.map +1 -0
  30. package/dist/sidecar.d.ts +31 -0
  31. package/dist/sidecar.d.ts.map +1 -0
  32. package/dist/sidecar.js +165 -0
  33. package/dist/sidecar.js.map +1 -0
  34. package/dist/ssrf-guard.d.ts +43 -0
  35. package/dist/ssrf-guard.d.ts.map +1 -0
  36. package/dist/ssrf-guard.js +164 -0
  37. package/dist/ssrf-guard.js.map +1 -0
  38. package/dist/web-fetch.d.ts +23 -0
  39. package/dist/web-fetch.d.ts.map +1 -0
  40. package/dist/web-fetch.js +253 -0
  41. package/dist/web-fetch.js.map +1 -0
  42. package/package.json +74 -0
  43. package/src/browser-session.test.ts +333 -0
  44. package/src/browser-session.ts +567 -0
  45. package/src/browser-surface.test.ts +367 -0
  46. package/src/browser-surface.ts +275 -0
  47. package/src/html-extract.ts +152 -0
  48. package/src/index.ts +35 -0
  49. package/src/sidecar/dispatch.test.ts +313 -0
  50. package/src/sidecar/dispatch.ts +314 -0
  51. package/src/sidecar/install.ts +283 -0
  52. package/src/sidecar/types.test.ts +26 -0
  53. package/src/sidecar/types.ts +114 -0
  54. package/src/sidecar.test.ts +57 -0
  55. package/src/sidecar.ts +167 -0
  56. package/src/ssrf-guard.test.ts +109 -0
  57. package/src/ssrf-guard.ts +165 -0
  58. package/src/web-fetch.test.ts +305 -0
  59. package/src/web-fetch.ts +311 -0
@@ -0,0 +1,253 @@
1
+ import { isIP } from 'node:net';
2
+ import { Agent } from 'undici';
3
+ import { MoxxyError, defineTool, z } from '@moxxy/sdk';
4
+ import { htmlToMarkdown, htmlToPlainText } from './html-extract.js';
5
+ import { assertPublicUrl, setSsrfDnsResolver, SsrfBlockedError, } from './ssrf-guard.js';
6
+ /**
7
+ * Light-tier web fetch: a single HTTP GET (or HEAD) with HTML→text/markdown
8
+ * post-processing for the common case of "read this page". Zero new
9
+ * dependencies — uses Node's built-in `fetch`.
10
+ *
11
+ * For JS-heavy / interactive pages, use `browser_session` (Playwright
12
+ * sidecar) instead. The web-research skill picks the tier.
13
+ */
14
+ export { htmlToMarkdown, htmlToPlainText } from './html-extract.js';
15
+ const MAX_BYTES_DEFAULT = 2 * 1024 * 1024; // 2 MB
16
+ const MAX_REDIRECTS_DEFAULT = 5;
17
+ const FETCH_TIMEOUT_MS_DEFAULT = 20_000;
18
+ /** Test seam: override DNS resolution so SSRF tests stay hermetic. Pass null to reset. */
19
+ export function setWebFetchDnsResolver(fn) {
20
+ setSsrfDnsResolver(fn);
21
+ }
22
+ /**
23
+ * Shared guard, with rejections re-thrown as MoxxyError for the tool surface.
24
+ * Returns the vetted resolved addresses (null when the host is an IP literal
25
+ * or resolution failed open) so the fetch can be pinned to exactly what was
26
+ * checked.
27
+ */
28
+ async function assertPublicUrlForWebFetch(raw) {
29
+ try {
30
+ return await assertPublicUrl(raw, 'web_fetch');
31
+ }
32
+ catch (err) {
33
+ if (err instanceof SsrfBlockedError) {
34
+ throw new MoxxyError({ code: 'INTERNAL', message: err.message });
35
+ }
36
+ throw err;
37
+ }
38
+ }
39
+ /**
40
+ * DNS-rebinding (TOCTOU) defense: a lookup function that always answers with
41
+ * the addresses the SSRF guard just vetted, so the connection provably goes
42
+ * to the IP that was checked — a second, attacker-controlled DNS answer
43
+ * between check and connect can never be consulted. Exported for tests.
44
+ */
45
+ export function createPinnedLookup(addresses) {
46
+ // Implemented against the runtime contract (net.connect may ask for one
47
+ // address or, with autoSelectFamily/happy-eyeballs, `all: true`), which is
48
+ // wider than the single-answer `LookupFunction` type — hence the cast.
49
+ const lookup = (hostname, options, callback) => {
50
+ if (addresses.length === 0) {
51
+ const err = new Error(`web_fetch: no pinned address for "${hostname}"`);
52
+ err.code = 'ENOTFOUND';
53
+ callback(err);
54
+ return;
55
+ }
56
+ const all = addresses.map((address) => ({ address, family: isIP(address) }));
57
+ if (options?.all)
58
+ callback(null, all);
59
+ else
60
+ callback(null, all[0].address, all[0].family);
61
+ };
62
+ return lookup;
63
+ }
64
+ /** Per-hop undici dispatcher whose connections resolve via the pinned lookup. */
65
+ function createPinnedDispatcher(addresses) {
66
+ return new Agent({ connect: { lookup: createPinnedLookup(addresses) } });
67
+ }
68
+ export const webFetchTool = defineTool({
69
+ name: 'web_fetch',
70
+ description: 'Fetch a URL over HTTP(S) and return the page content. HTML is post-processed to readable text (or markdown). Use for simple GETs; if the page needs JS execution, clicks, or form fills, use browser_session instead.',
71
+ inputSchema: z.object({
72
+ url: z.string().url().describe('Absolute http:// or https:// URL.'),
73
+ format: z
74
+ .enum(['text', 'markdown', 'raw'])
75
+ .optional()
76
+ .default('text')
77
+ .describe('How to render the response. `text` strips HTML to readable plain text. `markdown` keeps headings + lists + links. `raw` returns the body as-is (HTML, JSON, etc).'),
78
+ method: z.enum(['GET', 'HEAD']).optional().default('GET'),
79
+ headers: z.record(z.string(), z.string()).optional(),
80
+ maxBytes: z.number().int().positive().max(20_000_000).optional(),
81
+ timeoutMs: z.number().int().positive().max(120_000).optional(),
82
+ selector: z
83
+ .string()
84
+ .optional()
85
+ .describe('Optional CSS-like selector for the readability extractor (e.g. "main", "article"). Falls back to whole-body extraction.'),
86
+ }),
87
+ permission: { action: 'prompt' },
88
+ isolation: {
89
+ capabilities: {
90
+ net: { mode: 'any' },
91
+ timeMs: 120_000,
92
+ },
93
+ },
94
+ async handler({ url, format, method, headers, maxBytes, timeoutMs, selector }, ctx) {
95
+ const cap = maxBytes ?? MAX_BYTES_DEFAULT;
96
+ const timeout = timeoutMs ?? FETCH_TIMEOUT_MS_DEFAULT;
97
+ const aborter = new AbortController();
98
+ const onParentAbort = () => aborter.abort('parent signal');
99
+ ctx.signal.addEventListener('abort', onParentAbort, { once: true });
100
+ const timer = setTimeout(() => aborter.abort('fetch timeout'), timeout);
101
+ let fetched = null;
102
+ try {
103
+ const res = await fetchFollowRedirects(url, {
104
+ method,
105
+ headers: { 'user-agent': 'moxxy/0.0', ...(headers ?? {}) },
106
+ signal: aborter.signal,
107
+ maxRedirects: MAX_REDIRECTS_DEFAULT,
108
+ });
109
+ fetched = res;
110
+ if (method === 'HEAD') {
111
+ return formatHeadResult(res);
112
+ }
113
+ const body = await readCapped(res, cap);
114
+ const contentType = res.headers.get('content-type') ?? '';
115
+ const isHtml = contentType.includes('text/html') || contentType.includes('application/xhtml');
116
+ if (format === 'raw' || !isHtml) {
117
+ return formatBodyResult({
118
+ status: res.status,
119
+ url: res.url,
120
+ contentType,
121
+ body,
122
+ truncated: body.truncated,
123
+ });
124
+ }
125
+ const extracted = format === 'markdown'
126
+ ? htmlToMarkdown(body.text, { selector })
127
+ : htmlToPlainText(body.text, { selector });
128
+ return formatBodyResult({
129
+ status: res.status,
130
+ url: res.url,
131
+ contentType,
132
+ body: { text: extracted, truncated: body.truncated },
133
+ truncated: body.truncated,
134
+ });
135
+ }
136
+ finally {
137
+ clearTimeout(timer);
138
+ ctx.signal.removeEventListener('abort', onParentAbort);
139
+ // Close the final hop's pinned dispatcher (body is consumed by now).
140
+ if (fetched)
141
+ await fetched.dispose();
142
+ }
143
+ },
144
+ });
145
+ async function fetchFollowRedirects(initialUrl, opts) {
146
+ let current = initialUrl;
147
+ for (let hop = 0; hop <= opts.maxRedirects; hop++) {
148
+ // Re-validate on every hop — the initial URL AND each redirect target —
149
+ // so a public URL can't 302 us into the internal network.
150
+ const vettedAddrs = await assertPublicUrlForWebFetch(current);
151
+ // DNS-rebinding TOCTOU defense: pin THIS hop's connection to the
152
+ // addresses the guard just vetted. The hostname (and so TLS SNI/cert
153
+ // validation) is untouched — only name resolution is overridden. When
154
+ // there is nothing to pin (IP-literal host already vetted, or fail-open
155
+ // resolution error where the real fetch fails identically), plain fetch.
156
+ const dispatcher = vettedAddrs && vettedAddrs.length > 0 ? createPinnedDispatcher(vettedAddrs) : null;
157
+ const closeDispatcher = async () => {
158
+ if (dispatcher) {
159
+ try {
160
+ await dispatcher.close();
161
+ }
162
+ catch { /* ignore */ }
163
+ }
164
+ };
165
+ let res;
166
+ try {
167
+ res = await fetch(current, {
168
+ method: opts.method,
169
+ headers: opts.headers,
170
+ signal: opts.signal,
171
+ redirect: 'manual',
172
+ ...(dispatcher ? { dispatcher } : {}),
173
+ });
174
+ }
175
+ catch (err) {
176
+ await closeDispatcher();
177
+ throw err;
178
+ }
179
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
180
+ const next = new URL(res.headers.get('location'), current).toString();
181
+ current = next;
182
+ // drain to avoid the connection leaking
183
+ try {
184
+ await res.body?.cancel();
185
+ }
186
+ catch { /* ignore */ }
187
+ await closeDispatcher();
188
+ continue;
189
+ }
190
+ return {
191
+ status: res.status,
192
+ url: current,
193
+ headers: res.headers,
194
+ body: res.body,
195
+ dispose: closeDispatcher,
196
+ };
197
+ }
198
+ throw new MoxxyError({
199
+ code: 'INTERNAL',
200
+ message: `Too many redirects (>${opts.maxRedirects}) starting at ${initialUrl}`,
201
+ });
202
+ }
203
+ async function readCapped(res, cap) {
204
+ if (!res.body)
205
+ return { text: '', truncated: false };
206
+ const reader = res.body.getReader();
207
+ // Streaming UTF-8 decode: a multi-byte sequence straddling a chunk boundary —
208
+ // or the hard byte cut at `cap` — is buffered across `decode({stream:true})`
209
+ // calls instead of being sliced mid-codepoint into a replacement char. Avoids
210
+ // the Buffer.concat re-copy too.
211
+ const decoder = new TextDecoder('utf-8');
212
+ let text = '';
213
+ let total = 0;
214
+ let truncated = false;
215
+ try {
216
+ while (true) {
217
+ const { done, value } = await reader.read();
218
+ if (done)
219
+ break;
220
+ if (total + value.byteLength > cap) {
221
+ text += decoder.decode(value.subarray(0, cap - total), { stream: true });
222
+ total = cap;
223
+ truncated = true;
224
+ break;
225
+ }
226
+ text += decoder.decode(value, { stream: true });
227
+ total += value.byteLength;
228
+ }
229
+ text += decoder.decode(); // flush any buffered partial sequence
230
+ }
231
+ finally {
232
+ try {
233
+ reader.releaseLock();
234
+ }
235
+ catch { /* ignore */ }
236
+ try {
237
+ await res.body.cancel();
238
+ }
239
+ catch { /* ignore */ }
240
+ }
241
+ return { text, truncated };
242
+ }
243
+ function formatHeadResult(res) {
244
+ const lines = [`HTTP ${res.status} ${res.url}`];
245
+ for (const [k, v] of res.headers.entries())
246
+ lines.push(`${k}: ${v}`);
247
+ return lines.join('\n');
248
+ }
249
+ function formatBodyResult(r) {
250
+ const header = `HTTP ${r.status} ${r.url}\ncontent-type: ${r.contentType}${r.truncated ? '\n[response truncated]' : ''}`;
251
+ return `${header}\n\n${r.body.text}`;
252
+ }
253
+ //# sourceMappingURL=web-fetch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-fetch.js","sourceRoot":"","sources":["../src/web-fetch.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@moxxy/plugin-browser",
3
+ "version": "0.27.0",
4
+ "description": "Browser capabilities for moxxy: web_fetch (zero-deps light tier) + browser_session (Playwright sidecar for JS-heavy / interactive pages).",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "browser",
9
+ "playwright",
10
+ "tools"
11
+ ],
12
+ "homepage": "https://moxxy.ai",
13
+ "bugs": {
14
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
19
+ "directory": "packages/plugin-browser"
20
+ },
21
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "bin": {
36
+ "moxxy-browser-sidecar": "./dist/sidecar.js"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "skills"
42
+ ],
43
+ "moxxy": {
44
+ "plugin": {
45
+ "entry": "./dist/index.js",
46
+ "skills": "./skills"
47
+ }
48
+ },
49
+ "dependencies": {
50
+ "undici": "^6.26.0",
51
+ "@moxxy/sdk": "0.27.0"
52
+ },
53
+ "peerDependencies": {
54
+ "playwright": "^1.40.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "playwright": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "^22.10.0",
63
+ "typescript": "^5.7.3",
64
+ "vitest": "^2.1.8",
65
+ "@moxxy/vitest-preset": "0.0.0",
66
+ "@moxxy/tsconfig": "0.0.0"
67
+ },
68
+ "scripts": {
69
+ "build": "tsc -p tsconfig.json && chmod +x dist/sidecar.js",
70
+ "typecheck": "tsc -p tsconfig.json --noEmit",
71
+ "test": "vitest run",
72
+ "clean": "rm -rf dist .turbo"
73
+ }
74
+ }
@@ -0,0 +1,333 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { PassThrough } from 'node:stream';
3
+ import { asSessionId, asToolCallId, asTurnId } from '@moxxy/sdk';
4
+ import type { ToolContext } from '@moxxy/sdk';
5
+ import {
6
+ browserSidecarCall,
7
+ buildBrowserSessionTool,
8
+ closeBrowserSidecar,
9
+ resolveBrowserInstallRoot,
10
+ type SidecarStream,
11
+ } from './browser-session.js';
12
+ import { setSsrfDnsResolver } from './ssrf-guard.js';
13
+
14
+ // Hermetic DNS for the parent-side SSRF guard on `goto`: every hostname
15
+ // "resolves" public so tests never hit real DNS.
16
+ beforeEach(() => setSsrfDnsResolver(async () => ['93.184.216.34']));
17
+ afterEach(() => setSsrfDnsResolver(null));
18
+
19
+ /**
20
+ * The sidecar is exercised via a fake `spawnFn` that drives a scripted
21
+ * protocol — keeps Playwright out of the test loop entirely.
22
+ */
23
+
24
+ const baseCtx = (): ToolContext => ({
25
+ sessionId: asSessionId('s'),
26
+ turnId: asTurnId('t'),
27
+ callId: asToolCallId('c'),
28
+ cwd: '/tmp',
29
+ signal: new AbortController().signal,
30
+ log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
31
+ logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
32
+ });
33
+
34
+ describe('resolveBrowserInstallRoot', () => {
35
+ it('returns the prefix root (the node_modules parent) for a packaged sidecar path', () => {
36
+ // The real installed layout: <root>/node_modules/@moxxy/cli/dist/sidecar.js
37
+ const sidecarPath = '/Users/me/Library/App/cli/node_modules/@moxxy/cli/dist/sidecar.js';
38
+ expect(resolveBrowserInstallRoot({ sidecarPath })).toBe('/Users/me/Library/App/cli');
39
+ });
40
+
41
+ it('falls back to the sidecar dir when no node_modules is on the path', () => {
42
+ expect(resolveBrowserInstallRoot({ sidecarPath: '/opt/app/dist/sidecar.js' })).toBe('/opt/app/dist');
43
+ });
44
+ });
45
+
46
+ function makeFakeSpawn(handler: (req: { id: string; method: string; params?: unknown }) => unknown): {
47
+ spawn: (path: string) => SidecarStream;
48
+ receivedRequests: Array<{ id: string; method: string; params?: unknown }>;
49
+ } {
50
+ const receivedRequests: Array<{ id: string; method: string; params?: unknown }> = [];
51
+
52
+ const spawn = (_scriptPath: string): SidecarStream => {
53
+ const stdin = new PassThrough();
54
+ const stdout = new PassThrough();
55
+ let buf = '';
56
+ stdin.on('data', (chunk) => {
57
+ buf += chunk.toString('utf8');
58
+ let nl: number;
59
+ while ((nl = buf.indexOf('\n')) !== -1) {
60
+ const line = buf.slice(0, nl);
61
+ buf = buf.slice(nl + 1);
62
+ if (!line.trim()) continue;
63
+ const req = JSON.parse(line);
64
+ receivedRequests.push(req);
65
+ const result = handler(req);
66
+ const reply = { id: req.id, ok: true, result };
67
+ stdout.write(JSON.stringify(reply) + '\n');
68
+ }
69
+ });
70
+ const exitListeners: Array<(code: number | null) => void> = [];
71
+ const stream: SidecarStream = {
72
+ stdin,
73
+ stdout,
74
+ kill: () => {
75
+ for (const l of exitListeners) l(0);
76
+ return true;
77
+ },
78
+ once: (_event, listener) => {
79
+ exitListeners.push(listener as (code: number | null) => void);
80
+ },
81
+ };
82
+ return stream;
83
+ };
84
+ return { spawn, receivedRequests };
85
+ }
86
+
87
+ describe('browser_session tool (sidecar protocol)', () => {
88
+ it('drives `goto` and returns the result', async () => {
89
+ const { spawn, receivedRequests } = makeFakeSpawn((req) => {
90
+ if (req.method === 'goto') return { url: (req.params as { url: string }).url };
91
+ return null;
92
+ });
93
+
94
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn });
95
+ const out = await tool.handler(
96
+ { action: { kind: 'goto', url: 'https://example.com' } },
97
+ baseCtx(),
98
+ );
99
+ expect(out).toEqual({ url: 'https://example.com' });
100
+ expect(receivedRequests).toHaveLength(1);
101
+ expect(receivedRequests[0]!.method).toBe('goto');
102
+
103
+ await closeBrowserSidecar();
104
+ });
105
+
106
+ it('drives `text` after `goto` on the same sidecar (shared page)', async () => {
107
+ const { spawn, receivedRequests } = makeFakeSpawn((req) => {
108
+ if (req.method === 'goto') return { url: 'https://x' };
109
+ if (req.method === 'text') return 'hello world';
110
+ return null;
111
+ });
112
+
113
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn });
114
+ await tool.handler({ action: { kind: 'goto', url: 'https://x' } }, baseCtx());
115
+ const text = await tool.handler({ action: { kind: 'text', selector: 'main' } }, baseCtx());
116
+ expect(text).toBe('hello world');
117
+ expect(receivedRequests.map((r) => r.method)).toEqual(['goto', 'text']);
118
+
119
+ await closeBrowserSidecar();
120
+ });
121
+
122
+ it('forwards eval expression to the sidecar', async () => {
123
+ const { spawn, receivedRequests } = makeFakeSpawn((req) => {
124
+ if (req.method === 'eval') return 42;
125
+ return null;
126
+ });
127
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn });
128
+ const out = await tool.handler(
129
+ { action: { kind: 'eval', expression: '1 + 41' } },
130
+ baseCtx(),
131
+ );
132
+ expect(out).toBe(42);
133
+ expect((receivedRequests[0]!.params as { expression: string }).expression).toBe('1 + 41');
134
+ await closeBrowserSidecar();
135
+ });
136
+ });
137
+
138
+ /** A sidecar that consumes requests but NEVER replies — drives the
139
+ * parent-side per-call timeout path. */
140
+ function makeSilentSpawn(): {
141
+ spawn: (path: string) => SidecarStream;
142
+ receivedRequests: Array<{ id: string; method: string }>;
143
+ } {
144
+ const receivedRequests: Array<{ id: string; method: string }> = [];
145
+ const spawn = (_p: string): SidecarStream => {
146
+ const stdin = new PassThrough();
147
+ const stdout = new PassThrough();
148
+ let buf = '';
149
+ stdin.on('data', (chunk) => {
150
+ buf += chunk.toString('utf8');
151
+ let nl: number;
152
+ while ((nl = buf.indexOf('\n')) !== -1) {
153
+ const line = buf.slice(0, nl);
154
+ buf = buf.slice(nl + 1);
155
+ if (line.trim()) receivedRequests.push(JSON.parse(line));
156
+ }
157
+ // deliberately never writes a reply
158
+ });
159
+ const exitListeners: Array<(code: number | null) => void> = [];
160
+ return {
161
+ stdin,
162
+ stdout,
163
+ kill: () => {
164
+ for (const l of exitListeners) l(0);
165
+ return true;
166
+ },
167
+ once: (_e, listener) => {
168
+ exitListeners.push(listener as (code: number | null) => void);
169
+ },
170
+ };
171
+ };
172
+ return { spawn, receivedRequests };
173
+ }
174
+
175
+ describe('browser_session parent-side per-call timeout', () => {
176
+ it('rejects a hung sidecar op after the configured timeout instead of hanging forever', async () => {
177
+ const { spawn, receivedRequests } = makeSilentSpawn();
178
+ // 50ms timeout via the deps seam so the test is fast.
179
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn, callTimeoutMs: 50 });
180
+ await expect(
181
+ tool.handler({ action: { kind: 'url' } }, baseCtx()),
182
+ ).rejects.toThrow(/timed out/);
183
+ expect(receivedRequests).toHaveLength(1); // the request WAS sent; just never answered
184
+ await closeBrowserSidecar();
185
+ });
186
+
187
+ it('a later reply for a timed-out call is ignored (no unhandled settle)', async () => {
188
+ // The pending entry is deleted on timeout, so a straggling reply is a no-op.
189
+ const { spawn } = makeSilentSpawn();
190
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn, callTimeoutMs: 30 });
191
+ await expect(tool.handler({ action: { kind: 'url' } }, baseCtx())).rejects.toThrow(/timed out/);
192
+ // No throw / no hang on cleanup proves the entry was dropped.
193
+ await closeBrowserSidecar();
194
+ });
195
+ });
196
+
197
+ describe('browser_session pending-map bound (MAX_PENDING)', () => {
198
+ it('rejects with "busy" once the queued-request ceiling is hit, instead of growing pending unbounded', async () => {
199
+ // A wedged sidecar head (never replies) + a chatty caller (surface polls at
200
+ // 3/s) must NOT let the parent accumulate pending entries without limit. The
201
+ // bound is 256; the 257th call degrades to a clean "busy" rejection rather
202
+ // than an OOM. Earlier calls stay pending (a long timeout keeps them alive
203
+ // for the duration of the test) and the shared sidecar is untouched.
204
+ const { spawn } = makeSilentSpawn();
205
+ // Long per-call timeout so the first 256 stay pending while we probe the 257th.
206
+ const deps = { sidecarPath: '/fake.js', spawnFn: spawn, callTimeoutMs: 60_000 };
207
+ const pending: Array<Promise<unknown>> = [];
208
+ for (let i = 0; i < 256; i++) {
209
+ // Swallow each eventual rejection (closeBrowserSidecar rejects them all on
210
+ // teardown) so they don't surface as unhandled rejections.
211
+ pending.push(browserSidecarCall('url', {}, deps).catch(() => undefined));
212
+ }
213
+ // The 257th must be rejected synchronously-ish with a bounded "busy" error.
214
+ await expect(browserSidecarCall('url', {}, deps)).rejects.toThrow(/busy/i);
215
+ // Tear down: closing rejects every still-pending call (sidecar "exited").
216
+ await closeBrowserSidecar();
217
+ await Promise.all(pending);
218
+ });
219
+ });
220
+
221
+ /**
222
+ * A sidecar whose child IGNORES SIGTERM — only a SIGKILL emits `exit`. Drives
223
+ * the close() escalation path: SIGTERM first, then SIGKILL after the grace
224
+ * timeout, so a wedged child can't survive as an orphan after session shutdown.
225
+ */
226
+ function makeStubbornSpawn(): {
227
+ spawn: (path: string) => SidecarStream;
228
+ signalsSent: string[];
229
+ } {
230
+ const signalsSent: string[] = [];
231
+ const spawn = (_p: string): SidecarStream => {
232
+ const stdin = new PassThrough();
233
+ const stdout = new PassThrough();
234
+ // Consume input but never reply — close()'s `close` RPC will time out fast
235
+ // (callTimeoutMs), then the SIGTERM→SIGKILL escalation runs.
236
+ stdin.on('data', () => {});
237
+ const exitListeners: Array<(code: number | null) => void> = [];
238
+ return {
239
+ stdin,
240
+ stdout,
241
+ kill: (signal?: NodeJS.Signals) => {
242
+ signalsSent.push(signal ?? 'SIGTERM');
243
+ // Only SIGKILL actually terminates this stubborn child.
244
+ if (signal === 'SIGKILL') for (const l of exitListeners) l(null);
245
+ return true;
246
+ },
247
+ once: (_e, listener) => {
248
+ exitListeners.push(listener as (code: number | null) => void);
249
+ },
250
+ };
251
+ };
252
+ return { spawn, signalsSent };
253
+ }
254
+
255
+ describe('browser_session close() SIGKILL escalation', () => {
256
+ it('escalates SIGTERM → SIGKILL when the child ignores SIGTERM, and still resolves', async () => {
257
+ vi.useFakeTimers();
258
+ try {
259
+ const { spawn, signalsSent } = makeStubbornSpawn();
260
+ // Short call timeout so the best-effort `close` RPC inside close() gives up fast.
261
+ const deps = { sidecarPath: '/fake.js', spawnFn: spawn, callTimeoutMs: 20 };
262
+ // Spawn the singleton via a normal call (which will itself time out — swallow it).
263
+ void browserSidecarCall('url', {}, deps).catch(() => undefined);
264
+ // Drive the close: advance through the close-RPC timeout, then the 2s SIGKILL grace.
265
+ const closing = closeBrowserSidecar();
266
+ await vi.advanceTimersByTimeAsync(20); // close RPC times out
267
+ await vi.advanceTimersByTimeAsync(2000); // SIGTERM grace elapses → SIGKILL
268
+ await closing; // must resolve, not hang
269
+ expect(signalsSent).toContain('SIGTERM');
270
+ expect(signalsSent).toContain('SIGKILL');
271
+ } finally {
272
+ vi.useRealTimers();
273
+ }
274
+ });
275
+ });
276
+
277
+ describe('browser_session eval opt-out', () => {
278
+ afterEach(() => {
279
+ delete process.env.MOXXY_BROWSER_DISABLE_EVAL;
280
+ });
281
+ it('refuses eval when MOXXY_BROWSER_DISABLE_EVAL=1 without reaching the sidecar', async () => {
282
+ process.env.MOXXY_BROWSER_DISABLE_EVAL = '1';
283
+ const { spawn, receivedRequests } = makeFakeSpawn(() => 42);
284
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn });
285
+ await expect(
286
+ tool.handler({ action: { kind: 'eval', expression: '1+1' } }, baseCtx()),
287
+ ).rejects.toThrow(/disabled/);
288
+ expect(receivedRequests.some((r) => r.method === 'eval')).toBe(false);
289
+ await closeBrowserSidecar();
290
+ });
291
+ });
292
+
293
+ describe('browser_session SSRF guard (parent layer)', () => {
294
+ it.each([
295
+ 'http://169.254.169.254/latest/meta-data/',
296
+ 'http://localhost:3000/',
297
+ 'http://10.0.0.5/internal',
298
+ ])('rejects goto %s before any RPC reaches the sidecar', async (url) => {
299
+ const { spawn, receivedRequests } = makeFakeSpawn(() => null);
300
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn });
301
+ await expect(
302
+ tool.handler({ action: { kind: 'goto', url } }, baseCtx()),
303
+ ).rejects.toThrow(/private|loopback/);
304
+ expect(receivedRequests).toHaveLength(0); // never sent to the sidecar
305
+ await closeBrowserSidecar();
306
+ });
307
+
308
+ it('rejects a hostname resolving to a private address', async () => {
309
+ setSsrfDnsResolver(async () => ['10.1.2.3']);
310
+ const { spawn, receivedRequests } = makeFakeSpawn(() => null);
311
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn });
312
+ await expect(
313
+ tool.handler({ action: { kind: 'goto', url: 'https://intranet.example.com/' } }, baseCtx()),
314
+ ).rejects.toThrow(/private|loopback/);
315
+ expect(receivedRequests).toHaveLength(0);
316
+ await closeBrowserSidecar();
317
+ });
318
+
319
+ it('allows a public URL through to the sidecar', async () => {
320
+ const { spawn, receivedRequests } = makeFakeSpawn((req) => {
321
+ if (req.method === 'goto') return { url: (req.params as { url: string }).url };
322
+ return null;
323
+ });
324
+ const tool = buildBrowserSessionTool({ sidecarPath: '/fake.js', spawnFn: spawn });
325
+ const out = await tool.handler(
326
+ { action: { kind: 'goto', url: 'https://example.com/' } },
327
+ baseCtx(),
328
+ );
329
+ expect(out).toEqual({ url: 'https://example.com/' });
330
+ expect(receivedRequests).toHaveLength(1);
331
+ await closeBrowserSidecar();
332
+ });
333
+ });