@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.
- package/LICENSE +21 -0
- package/dist/browser-session.d.ts +43 -0
- package/dist/browser-session.d.ts.map +1 -0
- package/dist/browser-session.js +500 -0
- package/dist/browser-session.js.map +1 -0
- package/dist/browser-surface.d.ts +3 -0
- package/dist/browser-surface.d.ts.map +1 -0
- package/dist/browser-surface.js +255 -0
- package/dist/browser-surface.js.map +1 -0
- package/dist/html-extract.d.ts +20 -0
- package/dist/html-extract.d.ts.map +1 -0
- package/dist/html-extract.js +122 -0
- package/dist/html-extract.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/sidecar/dispatch.d.ts +18 -0
- package/dist/sidecar/dispatch.d.ts.map +1 -0
- package/dist/sidecar/dispatch.js +294 -0
- package/dist/sidecar/dispatch.js.map +1 -0
- package/dist/sidecar/install.d.ts +37 -0
- package/dist/sidecar/install.d.ts.map +1 -0
- package/dist/sidecar/install.js +242 -0
- package/dist/sidecar/install.js.map +1 -0
- package/dist/sidecar/types.d.ts +97 -0
- package/dist/sidecar/types.d.ts.map +1 -0
- package/dist/sidecar/types.js +20 -0
- package/dist/sidecar/types.js.map +1 -0
- package/dist/sidecar.d.ts +31 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +165 -0
- package/dist/sidecar.js.map +1 -0
- package/dist/ssrf-guard.d.ts +43 -0
- package/dist/ssrf-guard.d.ts.map +1 -0
- package/dist/ssrf-guard.js +164 -0
- package/dist/ssrf-guard.js.map +1 -0
- package/dist/web-fetch.d.ts +23 -0
- package/dist/web-fetch.d.ts.map +1 -0
- package/dist/web-fetch.js +253 -0
- package/dist/web-fetch.js.map +1 -0
- package/package.json +74 -0
- package/src/browser-session.test.ts +333 -0
- package/src/browser-session.ts +567 -0
- package/src/browser-surface.test.ts +367 -0
- package/src/browser-surface.ts +275 -0
- package/src/html-extract.ts +152 -0
- package/src/index.ts +35 -0
- package/src/sidecar/dispatch.test.ts +313 -0
- package/src/sidecar/dispatch.ts +314 -0
- package/src/sidecar/install.ts +283 -0
- package/src/sidecar/types.test.ts +26 -0
- package/src/sidecar/types.ts +114 -0
- package/src/sidecar.test.ts +57 -0
- package/src/sidecar.ts +167 -0
- package/src/ssrf-guard.test.ts +109 -0
- package/src/ssrf-guard.ts +165 -0
- package/src/web-fetch.test.ts +305 -0
- 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
|
+
});
|