@open-mercato/web-research-browser 0.7.1-develop.7194.1.ab4fc81f82
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/.turbo/turbo-build.log +2 -0
- package/build.mjs +10 -0
- package/dist/adapter.js +162 -0
- package/dist/adapter.js.map +7 -0
- package/dist/client.js +189 -0
- package/dist/client.js.map +7 -0
- package/dist/dispatch.js +161 -0
- package/dist/dispatch.js.map +7 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +7 -0
- package/dist/playwright.js +143 -0
- package/dist/playwright.js.map +7 -0
- package/dist/protocol.js +28 -0
- package/dist/protocol.js.map +7 -0
- package/dist/sidecar.js +79 -0
- package/dist/sidecar.js.map +7 -0
- package/jest.config.cjs +31 -0
- package/package.json +64 -0
- package/src/__tests__/browser.test.ts +242 -0
- package/src/__tests__/install.test.ts +72 -0
- package/src/__tests__/leases.test.ts +50 -0
- package/src/__tests__/sandbox.test.ts +67 -0
- package/src/adapter.ts +196 -0
- package/src/client.ts +245 -0
- package/src/dispatch.ts +205 -0
- package/src/index.ts +10 -0
- package/src/playwright.ts +265 -0
- package/src/protocol.ts +51 -0
- package/src/sidecar.ts +108 -0
- package/tsconfig.json +10 -0
- package/watch.mjs +7 -0
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { assertPublicUrl } from '@open-mercato/web-research'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_USER_AGENT,
|
|
4
|
+
isSandboxDisabled,
|
|
5
|
+
resolveLaunchArgs,
|
|
6
|
+
importChromium,
|
|
7
|
+
installNavigationGuard,
|
|
8
|
+
launchChromium,
|
|
9
|
+
type InstallRunner,
|
|
10
|
+
type BrowserHandle,
|
|
11
|
+
type ContextHandle,
|
|
12
|
+
type PageHandle,
|
|
13
|
+
} from './playwright'
|
|
14
|
+
import { SidecarError, type SidecarReply, type SidecarRequest } from './protocol'
|
|
15
|
+
|
|
16
|
+
export type Lease = { context: ContextHandle; page: PageHandle; touchedAt: number }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long an untouched lease may live.
|
|
20
|
+
*
|
|
21
|
+
* `release` normally closes a context, but the caller can die between `render`
|
|
22
|
+
* and `release` — a killed parent, a dropped socket — and every orphan is a live
|
|
23
|
+
* BrowserContext holding memory for as long as the sidecar runs.
|
|
24
|
+
*/
|
|
25
|
+
export const LEASE_IDLE_TIMEOUT_MS = 5 * 60_000
|
|
26
|
+
|
|
27
|
+
export type SidecarState = {
|
|
28
|
+
browser: BrowserHandle | null
|
|
29
|
+
leases: Map<string, Lease>
|
|
30
|
+
filterSubresources: boolean
|
|
31
|
+
warn: (message: string) => void
|
|
32
|
+
/** Injectable so a lease-expiry test does not have to wait five minutes. */
|
|
33
|
+
now: () => number
|
|
34
|
+
/** Injectable so tests never shell out to `npx playwright install`. */
|
|
35
|
+
installRunner?: InstallRunner
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_GOTO_TIMEOUT_MS = 30_000
|
|
39
|
+
const MAX_GOTO_TIMEOUT_MS = 120_000
|
|
40
|
+
|
|
41
|
+
export function createState(overrides: Partial<SidecarState> = {}): SidecarState {
|
|
42
|
+
return {
|
|
43
|
+
browser: null,
|
|
44
|
+
leases: new Map(),
|
|
45
|
+
filterSubresources: process.env.OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES === '1',
|
|
46
|
+
warn: (message) => process.stderr.write(`om-web-research-sidecar: ${message}\n`),
|
|
47
|
+
now: () => Date.now(),
|
|
48
|
+
...overrides,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {
|
|
53
|
+
if (state.browser) return state.browser
|
|
54
|
+
const chromium = await importChromium()
|
|
55
|
+
if (isSandboxDisabled()) {
|
|
56
|
+
// Said once per browser, not once per render, but said: this process renders
|
|
57
|
+
// pages chosen by a search engine or a model with the one mitigation
|
|
58
|
+
// Chromium offers against a hostile renderer turned off.
|
|
59
|
+
state.warn(
|
|
60
|
+
'SECURITY: Chromium sandbox is disabled by OM_WEB_RESEARCH_BROWSER_NO_SANDBOX=1 — a renderer compromise reaches this host',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
state.browser = await launchChromium(
|
|
64
|
+
chromium,
|
|
65
|
+
{ headless: true, args: resolveLaunchArgs() },
|
|
66
|
+
{ onProgress: state.warn, ...(state.installRunner ? { install: state.installRunner } : {}) },
|
|
67
|
+
)
|
|
68
|
+
return state.browser
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One BrowserContext per lease. Contexts are Playwright's real isolation boundary
|
|
73
|
+
* (cookies, storage, cache) and cost ~10ms, so two concurrent searches never share
|
|
74
|
+
* a cookie jar the way a single shared page would.
|
|
75
|
+
*/
|
|
76
|
+
async function openLease(state: SidecarState, leaseId: string, userAgent: string): Promise<Lease> {
|
|
77
|
+
await reapIdleLeases(state)
|
|
78
|
+
const existing = state.leases.get(leaseId)
|
|
79
|
+
if (existing) {
|
|
80
|
+
existing.touchedAt = state.now()
|
|
81
|
+
return existing
|
|
82
|
+
}
|
|
83
|
+
const browser = await ensureBrowser(state)
|
|
84
|
+
const context = await browser.newContext({ userAgent, locale: 'en-US' })
|
|
85
|
+
await installNavigationGuard(context, { filterSubresources: state.filterSubresources, warn: state.warn })
|
|
86
|
+
const page = (await context.newPage()) as unknown as PageHandle
|
|
87
|
+
const lease: Lease = { context, page, touchedAt: state.now() }
|
|
88
|
+
state.leases.set(leaseId, lease)
|
|
89
|
+
return lease
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Closes leases nobody released. Swept on open, so an idle sidecar stays idle. */
|
|
93
|
+
export async function reapIdleLeases(state: SidecarState): Promise<void> {
|
|
94
|
+
const cutoff = state.now() - LEASE_IDLE_TIMEOUT_MS
|
|
95
|
+
for (const [leaseId, lease] of [...state.leases]) {
|
|
96
|
+
if (lease.touchedAt > cutoff) continue
|
|
97
|
+
state.warn(`reaping lease ${leaseId} idle for over ${LEASE_IDLE_TIMEOUT_MS}ms`)
|
|
98
|
+
await closeLease(state, leaseId)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function closeLease(state: SidecarState, leaseId: string): Promise<void> {
|
|
103
|
+
const lease = state.leases.get(leaseId)
|
|
104
|
+
if (!lease) return
|
|
105
|
+
state.leases.delete(leaseId)
|
|
106
|
+
try {
|
|
107
|
+
await lease.context.close()
|
|
108
|
+
} catch {
|
|
109
|
+
// A context that already died takes nothing with it.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function teardown(state: SidecarState): Promise<void> {
|
|
114
|
+
for (const leaseId of [...state.leases.keys()]) await closeLease(state, leaseId)
|
|
115
|
+
const browser = state.browser
|
|
116
|
+
state.browser = null
|
|
117
|
+
if (browser) {
|
|
118
|
+
try {
|
|
119
|
+
await browser.close()
|
|
120
|
+
} catch {
|
|
121
|
+
// Best effort; the parent escalates to SIGKILL if we hang.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stringParam(request: SidecarRequest, key: string): string {
|
|
127
|
+
const value = request.params?.[key]
|
|
128
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
129
|
+
throw new SidecarError(`missing "${key}" parameter`, 'runtime')
|
|
130
|
+
}
|
|
131
|
+
return value
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function dispatchInner(state: SidecarState, request: SidecarRequest): Promise<SidecarReply> {
|
|
135
|
+
switch (request.method) {
|
|
136
|
+
case 'ping':
|
|
137
|
+
return { id: request.id, ok: true, result: { ready: true } }
|
|
138
|
+
|
|
139
|
+
case 'render': {
|
|
140
|
+
const url = stringParam(request, 'url')
|
|
141
|
+
const leaseId = stringParam(request, 'leaseId')
|
|
142
|
+
// Re-checked here, in a distinct process from the caller, and BEFORE
|
|
143
|
+
// Playwright is imported — a blocked URL never launches a browser.
|
|
144
|
+
try {
|
|
145
|
+
await assertPublicUrl(url, 'browser render', { failClosed: true })
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return {
|
|
148
|
+
id: request.id,
|
|
149
|
+
ok: false,
|
|
150
|
+
error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const userAgent = typeof request.params?.userAgent === 'string' ? request.params.userAgent : DEFAULT_USER_AGENT
|
|
155
|
+
const timeout = Math.min(
|
|
156
|
+
typeof request.params?.timeoutMs === 'number' ? request.params.timeoutMs : DEFAULT_GOTO_TIMEOUT_MS,
|
|
157
|
+
MAX_GOTO_TIMEOUT_MS,
|
|
158
|
+
)
|
|
159
|
+
const waitUntil = typeof request.params?.waitUntil === 'string' ? request.params.waitUntil : 'domcontentloaded'
|
|
160
|
+
|
|
161
|
+
const lease = await openLease(state, leaseId, userAgent)
|
|
162
|
+
try {
|
|
163
|
+
await lease.page.goto(url, { waitUntil, timeout })
|
|
164
|
+
} catch (error) {
|
|
165
|
+
return {
|
|
166
|
+
id: request.id,
|
|
167
|
+
ok: false,
|
|
168
|
+
error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const html = await lease.page.content()
|
|
172
|
+
return { id: request.id, ok: true, result: { url: lease.page.url(), html } }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case 'release':
|
|
176
|
+
await closeLease(state, stringParam(request, 'leaseId'))
|
|
177
|
+
return { id: request.id, ok: true }
|
|
178
|
+
|
|
179
|
+
case 'close':
|
|
180
|
+
await teardown(state)
|
|
181
|
+
return { id: request.id, ok: true }
|
|
182
|
+
|
|
183
|
+
default:
|
|
184
|
+
return {
|
|
185
|
+
id: request.id,
|
|
186
|
+
ok: false,
|
|
187
|
+
error: { message: `unknown method: ${request.method}`, kind: 'runtime' },
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function dispatch(state: SidecarState, request: SidecarRequest): Promise<SidecarReply> {
|
|
193
|
+
try {
|
|
194
|
+
return await dispatchInner(state, request)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return {
|
|
197
|
+
id: request.id,
|
|
198
|
+
ok: false,
|
|
199
|
+
error: {
|
|
200
|
+
message: error instanceof Error ? error.message : String(error),
|
|
201
|
+
kind: error instanceof SidecarError ? error.kind : 'unknown',
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
browserAdapterModule,
|
|
3
|
+
browserOptionsSchema,
|
|
4
|
+
createBrowserAdapter,
|
|
5
|
+
type BrowserOptions,
|
|
6
|
+
} from './adapter'
|
|
7
|
+
export { Sidecar, createSidecar, type SidecarOptions, type SidecarStream, type SpawnFn } from './client'
|
|
8
|
+
export { SidecarError, type SidecarErrorKind, type SidecarReply, type SidecarRequest } from './protocol'
|
|
9
|
+
|
|
10
|
+
export { browserAdapterModule as default } from './adapter'
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { assertPublicUrl } from '@open-mercato/web-research'
|
|
3
|
+
import { SidecarError } from './protocol'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Structural projections of the Playwright surface we touch.
|
|
7
|
+
*
|
|
8
|
+
* Playwright is a real dependency of this package — installing the adapter gets
|
|
9
|
+
* you the tooling it needs — but it is still reached through a dynamic import
|
|
10
|
+
* inside the sidecar, so the app and MCP processes never load it. The types stay
|
|
11
|
+
* structural for the same reason: nothing here should pull the SDK into a bundle
|
|
12
|
+
* that only ever spawns the child.
|
|
13
|
+
*/
|
|
14
|
+
export type PageHandle = {
|
|
15
|
+
goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>
|
|
16
|
+
content(): Promise<string>
|
|
17
|
+
url(): string
|
|
18
|
+
close(): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ContextHandle = {
|
|
22
|
+
newPage(): Promise<unknown>
|
|
23
|
+
close(): Promise<void>
|
|
24
|
+
route?(pattern: string, handler: (route: RouteHandle) => Promise<void> | void): Promise<void>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type RouteHandle = {
|
|
28
|
+
request(): { url(): string; isNavigationRequest(): boolean }
|
|
29
|
+
abort(reason?: string): Promise<void>
|
|
30
|
+
continue(): Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type BrowserHandle = {
|
|
34
|
+
newContext(options?: Record<string, unknown>): Promise<ContextHandle>
|
|
35
|
+
close(): Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BrowserType = {
|
|
39
|
+
launch(options?: { headless?: boolean; args?: string[] }): Promise<BrowserHandle>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isModuleNotFound(message: string): boolean {
|
|
43
|
+
return (
|
|
44
|
+
/cannot find (package|module) ['"]?playwright/i.test(message) ||
|
|
45
|
+
/ERR_MODULE_NOT_FOUND/i.test(message) ||
|
|
46
|
+
/failed to resolve ['"]?playwright/i.test(message)
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function importChromium(): Promise<BrowserType> {
|
|
51
|
+
try {
|
|
52
|
+
const playwright = (await import('playwright')) as unknown as { chromium: BrowserType }
|
|
53
|
+
return playwright.chromium
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
56
|
+
// Playwright is a dependency of this package, so a module-not-found here
|
|
57
|
+
// means a broken install (hoisting, a pruned image layer), not a missing opt-in.
|
|
58
|
+
throw new SidecarError(
|
|
59
|
+
`Playwright could not be loaded even though it is a dependency of this package — reinstall dependencies. Underlying: ${message}`,
|
|
60
|
+
isModuleNotFound(message) ? 'needs-install' : 'init',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Playwright's launch error when the npm package is installed but the browser
|
|
67
|
+
* binary was never downloaded. The wording has been stable across 1.x, and it is
|
|
68
|
+
* the only signal Playwright gives for this case.
|
|
69
|
+
*/
|
|
70
|
+
function isMissingBrowserError(error: unknown): boolean {
|
|
71
|
+
return error instanceof Error && /Executable doesn'?t exist at/i.test(error.message)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Generous, but bounded — an unbounded install hangs the sidecar forever. */
|
|
75
|
+
const INSTALL_TIMEOUT_MS = 10 * 60_000
|
|
76
|
+
|
|
77
|
+
export type InstallRunner = (onProgress: (line: string) => void) => Promise<void>
|
|
78
|
+
|
|
79
|
+
const defaultInstallRunner: InstallRunner = async (onProgress) =>
|
|
80
|
+
new Promise((resolve, reject) => {
|
|
81
|
+
const child = spawn('npx', ['playwright', 'install', 'chromium'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
82
|
+
let tail = ''
|
|
83
|
+
const collect = (chunk: Buffer): void => {
|
|
84
|
+
tail = `${tail}${chunk.toString('utf8')}`.slice(-4000)
|
|
85
|
+
}
|
|
86
|
+
child.stdout?.on('data', collect)
|
|
87
|
+
child.stderr?.on('data', collect)
|
|
88
|
+
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
child.kill('SIGKILL')
|
|
91
|
+
reject(new Error(`\`npx playwright install chromium\` exceeded ${INSTALL_TIMEOUT_MS}ms`))
|
|
92
|
+
}, INSTALL_TIMEOUT_MS)
|
|
93
|
+
|
|
94
|
+
child.once('error', (error) => {
|
|
95
|
+
clearTimeout(timer)
|
|
96
|
+
reject(error)
|
|
97
|
+
})
|
|
98
|
+
child.once('close', (code) => {
|
|
99
|
+
clearTimeout(timer)
|
|
100
|
+
if (code === 0) {
|
|
101
|
+
onProgress('chromium install complete')
|
|
102
|
+
resolve()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
reject(new Error(`\`npx playwright install chromium\` failed (exit ${code}): ${tail.trim()}`))
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Launches Chromium, downloading the binary once if it is missing.
|
|
111
|
+
*
|
|
112
|
+
* Installing the npm package does not fetch the ~150MB browser, so an operator
|
|
113
|
+
* who enables this tier would otherwise hit a launch failure with no obvious
|
|
114
|
+
* remedy. One retry only — a second failure is a real fault, not a cold cache.
|
|
115
|
+
*/
|
|
116
|
+
export async function launchChromium(
|
|
117
|
+
browserType: BrowserType,
|
|
118
|
+
options: { headless?: boolean; args?: string[] },
|
|
119
|
+
deps: { install?: InstallRunner; onProgress?: (line: string) => void } = {},
|
|
120
|
+
): Promise<BrowserHandle> {
|
|
121
|
+
try {
|
|
122
|
+
return await browserType.launch(options)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// Deliberately not retried without the sandbox. Falling back silently would
|
|
125
|
+
// mean the security posture depends on whether a container happened to be
|
|
126
|
+
// configured, with nobody told which one they got.
|
|
127
|
+
if (isSandboxFailure(error)) {
|
|
128
|
+
throw new SidecarError(
|
|
129
|
+
`Chromium could not start its sandbox, and this adapter renders untrusted pages so it will not run without one. ` +
|
|
130
|
+
`Fix the container (run as a non-root user, or grant SYS_ADMIN, or enable user namespaces), ` +
|
|
131
|
+
`or accept the risk explicitly with ${SANDBOX_OPT_OUT_ENV}=1. Underlying: ${error instanceof Error ? error.message : String(error)}`,
|
|
132
|
+
'init',
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
if (!isMissingBrowserError(error)) throw error
|
|
136
|
+
const onProgress = deps.onProgress ?? (() => {})
|
|
137
|
+
onProgress('chromium binary missing, running `npx playwright install chromium` (one-time, ~150MB)')
|
|
138
|
+
try {
|
|
139
|
+
await (deps.install ?? defaultInstallRunner)(onProgress)
|
|
140
|
+
} catch (installError) {
|
|
141
|
+
const detail = installError instanceof Error ? installError.message : String(installError)
|
|
142
|
+
throw new SidecarError(
|
|
143
|
+
`Playwright browser auto-install failed: ${detail}. Run \`npx playwright install chromium\` manually.`,
|
|
144
|
+
'init',
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
return await browserType.launch(options)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const MAX_VERDICT_CACHE = 2048
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Blocks private-origin navigations for the life of the context.
|
|
155
|
+
*
|
|
156
|
+
* The parent and the sidecar both vet the URL before a `goto`, but a page reached
|
|
157
|
+
* through a legitimate public navigation can redirect or script-navigate itself
|
|
158
|
+
* to a metadata endpoint, and those hops never pass through the RPC layer.
|
|
159
|
+
*
|
|
160
|
+
* Fails closed: Chromium resolves names with its own resolver, so a hostname
|
|
161
|
+
* `node:dns` cannot vet must not be allowed through on the assumption it is dead.
|
|
162
|
+
*/
|
|
163
|
+
export async function installNavigationGuard(
|
|
164
|
+
context: ContextHandle,
|
|
165
|
+
options: { filterSubresources?: boolean; warn?: (message: string) => void } = {},
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
if (typeof context.route !== 'function') {
|
|
168
|
+
options.warn?.('context.route() unavailable — in-page navigation SSRF guard NOT installed')
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
const filterSubresources = options.filterSubresources ?? false
|
|
172
|
+
const verdicts = new Map<string, boolean>()
|
|
173
|
+
const remember = (host: string, blocked: boolean): void => {
|
|
174
|
+
if (verdicts.size >= MAX_VERDICT_CACHE) {
|
|
175
|
+
const oldest = verdicts.keys().next().value
|
|
176
|
+
if (oldest !== undefined) verdicts.delete(oldest)
|
|
177
|
+
}
|
|
178
|
+
verdicts.set(host, blocked)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await context.route('**/*', async (route) => {
|
|
182
|
+
const request = route.request()
|
|
183
|
+
const isNavigation = request.isNavigationRequest()
|
|
184
|
+
if (!isNavigation && !filterSubresources) return route.continue()
|
|
185
|
+
|
|
186
|
+
const url = request.url()
|
|
187
|
+
let host: string
|
|
188
|
+
try {
|
|
189
|
+
host = new URL(url).host
|
|
190
|
+
} catch {
|
|
191
|
+
return isNavigation ? route.abort('blockedbyclient') : route.continue()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const cached = isNavigation ? undefined : verdicts.get(host)
|
|
195
|
+
if (cached === true) return route.abort('blockedbyclient')
|
|
196
|
+
if (cached === false) return route.continue()
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await assertPublicUrl(url, isNavigation ? 'browser navigation' : 'browser subresource', {
|
|
200
|
+
failClosed: true,
|
|
201
|
+
})
|
|
202
|
+
remember(host, false)
|
|
203
|
+
} catch {
|
|
204
|
+
remember(host, true)
|
|
205
|
+
return route.abort('blockedbyclient')
|
|
206
|
+
}
|
|
207
|
+
return route.continue()
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Launch flags aimed at not announcing automation. Playwright's defaults set
|
|
213
|
+
* `navigator.webdriver` and a headless UA, which the SERP endpoints this adapter
|
|
214
|
+
* exists to reach specifically look for.
|
|
215
|
+
*
|
|
216
|
+
* `--disable-dev-shm-usage` is a sizing workaround, not a security control: it
|
|
217
|
+
* makes Chromium write shared memory to /tmp instead of a container's small
|
|
218
|
+
* /dev/shm. The sandbox is deliberately NOT disabled here — see
|
|
219
|
+
* {@link resolveLaunchArgs}.
|
|
220
|
+
*/
|
|
221
|
+
export const LAUNCH_ARGS = [
|
|
222
|
+
'--disable-blink-features=AutomationControlled',
|
|
223
|
+
'--disable-dev-shm-usage',
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
export const SANDBOX_OPT_OUT_ENV = 'OM_WEB_RESEARCH_BROWSER_NO_SANDBOX'
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Launch flags for this process, sandbox included unless an operator opted out.
|
|
230
|
+
*
|
|
231
|
+
* This adapter exists to render pages chosen by a search engine or a model, so
|
|
232
|
+
* the renderer is by definition parsing hostile input. `--no-sandbox` turns a
|
|
233
|
+
* renderer compromise into code running as whatever user the app runs as, which
|
|
234
|
+
* is the one mitigation Chromium provides for exactly this threat.
|
|
235
|
+
*
|
|
236
|
+
* It used to be on unconditionally because the sandbox needs privileges a
|
|
237
|
+
* root-in-Docker container does not grant by default. That is a container
|
|
238
|
+
* problem with container fixes (run as a non-root user, or grant `SYS_ADMIN`, or
|
|
239
|
+
* enable user namespaces); silently shipping every deployment the insecure
|
|
240
|
+
* variant to spare those is the wrong trade. The opt-out stays available for
|
|
241
|
+
* environments that genuinely cannot, and it is loud.
|
|
242
|
+
*/
|
|
243
|
+
export function resolveLaunchArgs(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
244
|
+
return env[SANDBOX_OPT_OUT_ENV] === '1' ? [...LAUNCH_ARGS, '--no-sandbox'] : [...LAUNCH_ARGS]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function isSandboxDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
248
|
+
return env[SANDBOX_OPT_OUT_ENV] === '1'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Chromium's refusal when it cannot set up its sandbox. The wording differs
|
|
253
|
+
* between the root check and the namespace failure, so both shapes are matched.
|
|
254
|
+
*/
|
|
255
|
+
export function isSandboxFailure(error: unknown): boolean {
|
|
256
|
+
if (!(error instanceof Error)) return false
|
|
257
|
+
return (
|
|
258
|
+
/running as root without --no-sandbox/i.test(error.message) ||
|
|
259
|
+
/No usable sandbox/i.test(error.message) ||
|
|
260
|
+
/clone helper|namespace sandbox|SUID sandbox/i.test(error.message)
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export const DEFAULT_USER_AGENT =
|
|
265
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type SidecarErrorKind = 'init' | 'needs-install' | 'navigation' | 'runtime' | 'timeout' | 'unknown'
|
|
2
|
+
|
|
3
|
+
export type SidecarRequest = {
|
|
4
|
+
readonly id: string
|
|
5
|
+
readonly method: string
|
|
6
|
+
readonly params?: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type SidecarReply =
|
|
10
|
+
| { readonly id: string; readonly ok: true; readonly result?: unknown }
|
|
11
|
+
| {
|
|
12
|
+
readonly id: string
|
|
13
|
+
readonly ok: false
|
|
14
|
+
readonly error: { readonly message: string; readonly kind: SidecarErrorKind }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SidecarError extends Error {
|
|
18
|
+
readonly kind: SidecarErrorKind
|
|
19
|
+
|
|
20
|
+
constructor(message: string, kind: SidecarErrorKind = 'runtime') {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = 'SidecarError'
|
|
23
|
+
this.kind = kind
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Line-delimited JSON, one object per line, correlated by `id`. Deliberately not
|
|
29
|
+
* JSON-RPC 2.0: the surface is four methods over a pipe we own both ends of, and
|
|
30
|
+
* an `ok` discriminant is easier to narrow than presence-of-`error`.
|
|
31
|
+
*/
|
|
32
|
+
export function encodeLine(value: SidecarRequest | SidecarReply): string {
|
|
33
|
+
return `${JSON.stringify(value)}\n`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function decodeLine(line: string): unknown {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(line)
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isReply(value: unknown): value is SidecarReply {
|
|
45
|
+
return (
|
|
46
|
+
typeof value === 'object' &&
|
|
47
|
+
value !== null &&
|
|
48
|
+
typeof (value as { id?: unknown }).id === 'string' &&
|
|
49
|
+
typeof (value as { ok?: unknown }).ok === 'boolean'
|
|
50
|
+
)
|
|
51
|
+
}
|
package/src/sidecar.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright sidecar entry point.
|
|
3
|
+
*
|
|
4
|
+
* Runs as a child process so Playwright never enters the app or MCP bundle and a
|
|
5
|
+
* crashed browser cannot take the server down. Speaks line-delimited JSON on
|
|
6
|
+
* stdio; every request is serialized per lease so a `goto` cannot race a
|
|
7
|
+
* `release` on the same context.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createState, dispatch, teardown, type SidecarState } from './dispatch'
|
|
11
|
+
import { decodeLine, encodeLine, type SidecarReply, type SidecarRequest } from './protocol'
|
|
12
|
+
|
|
13
|
+
const state: SidecarState = createState()
|
|
14
|
+
|
|
15
|
+
function write(reply: SidecarReply): void {
|
|
16
|
+
try {
|
|
17
|
+
process.stdout.write(encodeLine(reply))
|
|
18
|
+
} catch {
|
|
19
|
+
// stdout is gone; the parent will time the call out and reap us.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Per-lease chains: work on different contexts proceeds concurrently. */
|
|
24
|
+
const chains = new Map<string, Promise<void>>()
|
|
25
|
+
|
|
26
|
+
function chainKey(request: SidecarRequest): string {
|
|
27
|
+
const leaseId = request.params?.leaseId
|
|
28
|
+
return typeof leaseId === 'string' ? leaseId : '__global__'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function enqueue(line: string): void {
|
|
32
|
+
const parsed = decodeLine(line)
|
|
33
|
+
if (typeof parsed !== 'object' || parsed === null) return
|
|
34
|
+
const request = parsed as SidecarRequest
|
|
35
|
+
if (typeof request.id !== 'string' || typeof request.method !== 'string') return
|
|
36
|
+
|
|
37
|
+
const key = chainKey(request)
|
|
38
|
+
const previous = chains.get(key) ?? Promise.resolve()
|
|
39
|
+
const next: Promise<void> = previous
|
|
40
|
+
.then(async () => {
|
|
41
|
+
write(await dispatch(state, request))
|
|
42
|
+
})
|
|
43
|
+
.catch((error: unknown) => {
|
|
44
|
+
write({
|
|
45
|
+
id: request.id,
|
|
46
|
+
ok: false,
|
|
47
|
+
error: { message: error instanceof Error ? error.message : String(error), kind: 'unknown' },
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
.finally(() => {
|
|
51
|
+
// Every render takes a fresh lease id, so without this the map grows one
|
|
52
|
+
// settled promise per request for the life of the process. Only the tail is
|
|
53
|
+
// dropped; a later request on the same lease owns the entry by then.
|
|
54
|
+
if (chains.get(key) === next) chains.delete(key)
|
|
55
|
+
})
|
|
56
|
+
chains.set(key, next)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function shutdownAndExit(code: number): Promise<void> {
|
|
60
|
+
try {
|
|
61
|
+
await teardown(state)
|
|
62
|
+
} catch {
|
|
63
|
+
// Exiting regardless.
|
|
64
|
+
}
|
|
65
|
+
process.exit(code)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A SIGKILL'd parent produces no stdin EOF, so without this poll a Chromium can
|
|
70
|
+
* outlive the server that spawned it and leak memory until the box is restarted.
|
|
71
|
+
*/
|
|
72
|
+
function startParentWatchdog(): void {
|
|
73
|
+
const parentPid = process.ppid
|
|
74
|
+
if (!parentPid || parentPid <= 1) return
|
|
75
|
+
const interval = setInterval(() => {
|
|
76
|
+
try {
|
|
77
|
+
process.kill(parentPid, 0)
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
|
|
80
|
+
clearInterval(interval)
|
|
81
|
+
void shutdownAndExit(0)
|
|
82
|
+
}
|
|
83
|
+
// EPERM means it exists but we may not signal it — still alive.
|
|
84
|
+
}
|
|
85
|
+
}, 2000)
|
|
86
|
+
interval.unref?.()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function main(): void {
|
|
90
|
+
let buffer = ''
|
|
91
|
+
process.stdin.setEncoding('utf8')
|
|
92
|
+
process.stdin.on('data', (chunk: string) => {
|
|
93
|
+
buffer += chunk
|
|
94
|
+
let newline = buffer.indexOf('\n')
|
|
95
|
+
while (newline !== -1) {
|
|
96
|
+
const line = buffer.slice(0, newline)
|
|
97
|
+
buffer = buffer.slice(newline + 1)
|
|
98
|
+
if (line.trim()) enqueue(line)
|
|
99
|
+
newline = buffer.indexOf('\n')
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
process.stdin.on('end', () => void shutdownAndExit(0))
|
|
103
|
+
process.stdout.on('error', () => void shutdownAndExit(0))
|
|
104
|
+
startParentWatchdog()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Guarded so tests can import the module without starting the stdio loop.
|
|
108
|
+
if (process.env.OM_WEB_RESEARCH_SIDECAR_TEST !== '1') main()
|
package/tsconfig.json
ADDED