@open-mercato/web-research-browser 0.9.1-develop.7548.1.ee60458c45 → 0.9.1-develop.7553.1.8dbab05b5a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import { connect as netConnect } from 'node:net'
2
+ import { createState, dispatch, teardown, type SidecarState } from '../dispatch'
3
+ import * as egressProxyModule from '../egressProxy'
4
+ import {
5
+ LAUNCH_ARGS,
6
+ RESOLVER_LOCKDOWN_ARGS,
7
+ type BrowserHandle,
8
+ type BrowserType,
9
+ type ContextHandle,
10
+ type LaunchOptions,
11
+ type RouteHandle,
12
+ } from '../playwright'
13
+
14
+ const FILTER_ENV = 'OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES'
15
+ const PUBLIC_PAGE = 'https://93.184.216.34/'
16
+
17
+ type FakeBrowserType = BrowserType & { launches: LaunchOptions[] }
18
+
19
+ function fakeBrowser(): BrowserHandle {
20
+ const page = {
21
+ goto: async () => undefined,
22
+ content: async () => '<html></html>',
23
+ url: () => PUBLIC_PAGE,
24
+ close: async () => undefined,
25
+ }
26
+ const context: ContextHandle = {
27
+ newPage: async () => page,
28
+ close: async () => undefined,
29
+ route: async (_pattern: string, _handler: (route: RouteHandle) => Promise<void> | void) => undefined,
30
+ }
31
+ return { newContext: async () => context, close: async () => undefined }
32
+ }
33
+
34
+ function fakeBrowserType(launch?: (options: LaunchOptions) => Promise<BrowserHandle>): FakeBrowserType {
35
+ const type: FakeBrowserType = {
36
+ launches: [],
37
+ async launch(options = {}) {
38
+ type.launches.push(options)
39
+ if (launch) return launch(options)
40
+ await new Promise((resolve) => setTimeout(resolve, 5))
41
+ return fakeBrowser()
42
+ },
43
+ }
44
+ return type
45
+ }
46
+
47
+ function render(state: SidecarState, leaseId: string) {
48
+ return dispatch(state, { id: `render-${leaseId}`, method: 'render', params: { url: PUBLIC_PAGE, leaseId } })
49
+ }
50
+
51
+ function proxyAccepts(url: string): Promise<boolean> {
52
+ const { port } = new URL(url)
53
+ return new Promise((resolve) => {
54
+ const probe = netConnect({ host: '127.0.0.1', port: Number(port) })
55
+ probe.once('connect', () => {
56
+ probe.destroy()
57
+ resolve(true)
58
+ })
59
+ probe.once('error', () => resolve(false))
60
+ })
61
+ }
62
+
63
+ describe('browser launch wiring (#7218)', () => {
64
+ const savedFilter = process.env[FILTER_ENV]
65
+ const states: SidecarState[] = []
66
+
67
+ const stateWith = (overrides: Partial<SidecarState>): SidecarState => {
68
+ const state = createState({ warn: () => undefined, ...overrides })
69
+ states.push(state)
70
+ return state
71
+ }
72
+
73
+ beforeEach(() => {
74
+ delete process.env[FILTER_ENV]
75
+ })
76
+
77
+ afterEach(async () => {
78
+ jest.restoreAllMocks()
79
+ for (const state of states.splice(0)) await teardown(state)
80
+ })
81
+
82
+ afterAll(() => {
83
+ if (savedFilter === undefined) delete process.env[FILTER_ENV]
84
+ else process.env[FILTER_ENV] = savedFilter
85
+ })
86
+
87
+ it('launches Chromium through a live egress proxy with loopback not bypassed and UDP restricted', async () => {
88
+ const browserType = fakeBrowserType()
89
+ const state = stateWith({ loadBrowserType: async () => browserType })
90
+
91
+ expect((await render(state, 'lease-1')).ok).toBe(true)
92
+
93
+ expect(browserType.launches).toHaveLength(1)
94
+ const options = browserType.launches[0]
95
+ expect(options?.proxy).toEqual({ server: state.egressProxy?.url, bypass: '<-loopback>' })
96
+ expect(options?.proxy?.server).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
97
+ expect(await proxyAccepts(String(options?.proxy?.server))).toBe(true)
98
+ expect(options?.args).toEqual(
99
+ expect.arrayContaining([
100
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
101
+ '--disable-quic',
102
+ ...RESOLVER_LOCKDOWN_ARGS,
103
+ ]),
104
+ )
105
+ expect(LAUNCH_ARGS).not.toEqual(expect.arrayContaining(RESOLVER_LOCKDOWN_ARGS))
106
+ })
107
+
108
+ it('launches again on the next render after a failed launch', async () => {
109
+ let attempts = 0
110
+ const browserType = fakeBrowserType(async () => {
111
+ attempts += 1
112
+ if (attempts === 1) throw new Error('Target page crashed')
113
+ return fakeBrowser()
114
+ })
115
+ const state = stateWith({ loadBrowserType: async () => browserType })
116
+
117
+ expect((await render(state, 'lease-1')).ok).toBe(false)
118
+ expect((await render(state, 'lease-2')).ok).toBe(true)
119
+
120
+ expect(browserType.launches).toHaveLength(2)
121
+ expect(await proxyAccepts(String(state.egressProxy?.url))).toBe(true)
122
+ })
123
+
124
+ it('closes the proxy it tore down, not one a concurrent render started meanwhile', async () => {
125
+ let releaseClose: () => void = () => undefined
126
+ const closing = new Promise<void>((resolve) => {
127
+ releaseClose = resolve
128
+ })
129
+ let launches = 0
130
+ const browserType = fakeBrowserType(async () => {
131
+ launches += 1
132
+ const browser = fakeBrowser()
133
+ if (launches === 1) return { ...browser, close: () => closing }
134
+ return browser
135
+ })
136
+ const state = stateWith({ loadBrowserType: async () => browserType })
137
+ await render(state, 'lease-1')
138
+ const firstProxy = String(state.egressProxy?.url)
139
+
140
+ const tearingDown = teardown(state)
141
+ await new Promise((resolve) => setTimeout(resolve, 5))
142
+ expect((await render(state, 'lease-2')).ok).toBe(true)
143
+ const secondProxy = String(state.egressProxy?.url)
144
+ releaseClose()
145
+ await tearingDown
146
+
147
+ expect(secondProxy).not.toBe(firstProxy)
148
+ expect(await proxyAccepts(firstProxy)).toBe(false)
149
+ expect(await proxyAccepts(secondProxy)).toBe(true)
150
+ })
151
+
152
+ it('does not launch when the egress proxy cannot start', async () => {
153
+ jest.spyOn(egressProxyModule, 'startEgressProxy').mockRejectedValueOnce(new Error('EADDRNOTAVAIL'))
154
+ const browserType = fakeBrowserType()
155
+ const state = stateWith({ loadBrowserType: async () => browserType })
156
+
157
+ const reply = await render(state, 'lease-1')
158
+
159
+ expect(reply.ok).toBe(false)
160
+ expect(browserType.launches).toHaveLength(0)
161
+ expect(state.browser).toBeNull()
162
+ })
163
+
164
+ it('closes the egress proxy when the launch fails', async () => {
165
+ const browserType = fakeBrowserType(async () => {
166
+ throw new Error('Target page crashed')
167
+ })
168
+ const state = stateWith({ loadBrowserType: async () => browserType })
169
+
170
+ const reply = await render(state, 'lease-1')
171
+
172
+ expect(reply.ok).toBe(false)
173
+ const server = browserType.launches[0]?.proxy?.server
174
+ expect(server).toBeDefined()
175
+ expect(await proxyAccepts(String(server))).toBe(false)
176
+ expect(state.egressProxy ?? null).toBeNull()
177
+ })
178
+
179
+ it('closes the egress proxy on teardown', async () => {
180
+ const state = stateWith({ loadBrowserType: async () => fakeBrowserType() })
181
+ await render(state, 'lease-1')
182
+ const server = String(state.egressProxy?.url)
183
+ expect(await proxyAccepts(server)).toBe(true)
184
+
185
+ await teardown(state)
186
+
187
+ expect(await proxyAccepts(server)).toBe(false)
188
+ expect(state.egressProxy).toBeNull()
189
+ })
190
+
191
+ it('launches once when two leases open concurrently', async () => {
192
+ const browserType = fakeBrowserType()
193
+ const state = stateWith({ loadBrowserType: async () => browserType })
194
+
195
+ const replies = await Promise.all([render(state, 'lease-a'), render(state, 'lease-b')])
196
+
197
+ expect(replies.map((reply) => reply.ok)).toEqual([true, true])
198
+ expect(browserType.launches).toHaveLength(1)
199
+ })
200
+
201
+ it.each(['0', '1'])('warns that the filter env var set to %s is ignored', async (value) => {
202
+ process.env[FILTER_ENV] = value
203
+ const warnings: string[] = []
204
+ const state = stateWith({ loadBrowserType: async () => fakeBrowserType(), warn: (line) => warnings.push(line) })
205
+
206
+ await render(state, 'lease-1')
207
+ await render(state, 'lease-2')
208
+
209
+ const notices = warnings.filter((line) => line.includes(FILTER_ENV))
210
+ expect(notices).toHaveLength(1)
211
+ expect(notices[0]).toContain('always on')
212
+ })
213
+
214
+ it('does not warn about the filter env var when it is unset', async () => {
215
+ const warnings: string[] = []
216
+ const state = stateWith({ loadBrowserType: async () => fakeBrowserType(), warn: (line) => warnings.push(line) })
217
+
218
+ await render(state, 'lease-1')
219
+
220
+ expect(warnings.filter((line) => line.includes(FILTER_ENV))).toEqual([])
221
+ })
222
+ })
@@ -0,0 +1,200 @@
1
+ import type { LookupFn } from '@open-mercato/web-research'
2
+ import { createState, dispatch } from '../dispatch'
3
+ import { installNavigationGuard, type BrowserHandle, type ContextHandle, type RouteHandle } from '../playwright'
4
+ import { SidecarError } from '../protocol'
5
+
6
+ type RouteHandler = (route: RouteHandle) => Promise<void> | void
7
+
8
+ type GuardedContext = ContextHandle & {
9
+ handler(): RouteHandler | undefined
10
+ }
11
+
12
+ const FILTER_ENV = 'OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES'
13
+ const PUBLIC_PAGE = 'https://93.184.216.34/'
14
+ const METADATA = 'http://169.254.169.254/latest/meta-data/'
15
+ const LOOPBACK = 'http://127.0.0.1:5432/'
16
+
17
+ function fakeContext(): GuardedContext {
18
+ let routeHandler: RouteHandler | undefined
19
+ return {
20
+ newPage: async () => ({}),
21
+ close: async () => undefined,
22
+ route: async (_pattern: string, handler: RouteHandler) => {
23
+ routeHandler = handler
24
+ },
25
+ handler: () => routeHandler,
26
+ }
27
+ }
28
+
29
+ async function settle(handler: RouteHandler | undefined, url: string, isNavigation: boolean): Promise<string> {
30
+ if (!handler) throw new Error('[internal] no route handler was installed')
31
+ let outcome = 'pending'
32
+ await handler({
33
+ request: () => ({ url: () => url, isNavigationRequest: () => isNavigation }),
34
+ abort: async () => {
35
+ outcome = 'abort'
36
+ },
37
+ continue: async () => {
38
+ outcome = 'continue'
39
+ },
40
+ })
41
+ return outcome
42
+ }
43
+
44
+ type RenderFixture = {
45
+ browser: BrowserHandle
46
+ context: GuardedContext
47
+ contextOptions: Array<Record<string, unknown> | undefined>
48
+ subresourceOutcomes: string[]
49
+ contentReads: () => number
50
+ }
51
+
52
+ function renderFixture(finalUrl: string = PUBLIC_PAGE): RenderFixture {
53
+ const contextOptions: Array<Record<string, unknown> | undefined> = []
54
+ const context = fakeContext()
55
+ const subresourceOutcomes: string[] = []
56
+ let reads = 0
57
+ const page = {
58
+ goto: async () => {
59
+ subresourceOutcomes.push(await settle(context.handler(), METADATA, false))
60
+ },
61
+ content: async () => {
62
+ reads += 1
63
+ return '<html><body>page</body></html>'
64
+ },
65
+ url: () => finalUrl,
66
+ close: async () => undefined,
67
+ }
68
+ context.newPage = async () => page
69
+ const browser: BrowserHandle = {
70
+ newContext: async (options) => {
71
+ contextOptions.push(options)
72
+ return context
73
+ },
74
+ close: async () => undefined,
75
+ }
76
+ return { browser, context, contextOptions, subresourceOutcomes, contentReads: () => reads }
77
+ }
78
+
79
+ describe('browser request guard (#7218)', () => {
80
+ const savedFilter = process.env[FILTER_ENV]
81
+
82
+ beforeEach(() => {
83
+ delete process.env[FILTER_ENV]
84
+ })
85
+
86
+ afterAll(() => {
87
+ if (savedFilter === undefined) delete process.env[FILTER_ENV]
88
+ else process.env[FILTER_ENV] = savedFilter
89
+ })
90
+
91
+ it('still blocks a private navigation and allows a public one', async () => {
92
+ const context = fakeContext()
93
+ await installNavigationGuard(context)
94
+
95
+ expect(await settle(context.handler(), METADATA, true)).toBe('abort')
96
+ expect(await settle(context.handler(), PUBLIC_PAGE, true)).toBe('continue')
97
+ })
98
+
99
+ it('blocks private subresources with default options', async () => {
100
+ const context = fakeContext()
101
+ await installNavigationGuard(context)
102
+
103
+ expect(await settle(context.handler(), METADATA, false)).toBe('abort')
104
+ expect(await settle(context.handler(), LOOPBACK, false)).toBe('abort')
105
+ expect(await settle(context.handler(), 'https://93.184.216.34/app.js', false)).toBe('continue')
106
+ })
107
+
108
+ it('ignores the deprecated filterSubresources: false option', async () => {
109
+ const context = fakeContext()
110
+ await installNavigationGuard(context, { filterSubresources: false })
111
+
112
+ expect(await settle(context.handler(), METADATA, false)).toBe('abort')
113
+ })
114
+
115
+ it('blocks a private subresource of a rendered page in the default sidecar configuration', async () => {
116
+ const fixture = renderFixture()
117
+ const state = createState({ browser: fixture.browser, warn: () => undefined })
118
+
119
+ const reply = await dispatch(state, {
120
+ id: 'render-1',
121
+ method: 'render',
122
+ params: { url: PUBLIC_PAGE, leaseId: 'lease-1' },
123
+ })
124
+
125
+ expect(reply.ok).toBe(true)
126
+ expect(fixture.subresourceOutcomes).toEqual(['abort'])
127
+ expect(fixture.contextOptions[0]).toMatchObject({ serviceWorkers: 'block' })
128
+ })
129
+
130
+ it.each(['0', '1'])('ignores the filter env var set to %s and still filters subresources', async (value) => {
131
+ process.env[FILTER_ENV] = value
132
+ const fixture = renderFixture()
133
+ const state = createState({ browser: fixture.browser, warn: () => undefined })
134
+
135
+ const reply = await dispatch(state, {
136
+ id: 'render-env',
137
+ method: 'render',
138
+ params: { url: PUBLIC_PAGE, leaseId: 'lease-env' },
139
+ })
140
+
141
+ expect(reply.ok).toBe(true)
142
+ expect(fixture.subresourceOutcomes).toEqual(['abort'])
143
+ })
144
+
145
+ it('refuses a render whose page ended on a non-public URL and returns no html', async () => {
146
+ const fixture = renderFixture('http://127.0.0.1:8080/admin')
147
+ const state = createState({ browser: fixture.browser, warn: () => undefined })
148
+
149
+ const reply = await dispatch(state, {
150
+ id: 'render-final',
151
+ method: 'render',
152
+ params: { url: PUBLIC_PAGE, leaseId: 'lease-final' },
153
+ })
154
+
155
+ expect(reply.ok).toBe(false)
156
+ if (reply.ok) return
157
+ expect(reply.error.kind).toBe('navigation')
158
+ expect(JSON.stringify(reply)).not.toContain('<html')
159
+ expect(fixture.contentReads()).toBe(0)
160
+ })
161
+
162
+ it('aborts a request whose URL cannot be parsed instead of letting it through', async () => {
163
+ const context = fakeContext()
164
+ await installNavigationGuard(context)
165
+
166
+ expect(await settle(context.handler(), 'http://[::1', false)).toBe('abort')
167
+ expect(await settle(context.handler(), 'http://[::1', true)).toBe('abort')
168
+ })
169
+
170
+ it('fails closed when request interception cannot be installed', async () => {
171
+ const context: ContextHandle = { newPage: async () => ({}), close: async () => undefined }
172
+
173
+ const error = await installNavigationGuard(context, { warn: () => undefined }).catch((caught: unknown) => caught)
174
+
175
+ expect(error).toBeInstanceOf(SidecarError)
176
+ expect((error as SidecarError).kind).toBe('init')
177
+ })
178
+
179
+ it('caches a blocked subresource verdict even if the name later resolves publicly', async () => {
180
+ const answers = ['10.0.0.7', '93.184.216.34']
181
+ const lookup: LookupFn = jest.fn(async () => [{ address: answers.shift() ?? '93.184.216.34', family: 4 }])
182
+ const context = fakeContext()
183
+ await installNavigationGuard(context, { lookup })
184
+
185
+ expect(await settle(context.handler(), 'https://flip.test/a.js', false)).toBe('abort')
186
+ expect(await settle(context.handler(), 'https://flip.test/b.js', false)).toBe('abort')
187
+ expect(lookup).toHaveBeenCalledTimes(1)
188
+ })
189
+
190
+ it('re-vets navigations instead of caching them', async () => {
191
+ const answers = ['93.184.216.34', '10.0.0.7']
192
+ const lookup: LookupFn = jest.fn(async () => [{ address: answers.shift() ?? '10.0.0.7', family: 4 }])
193
+ const context = fakeContext()
194
+ await installNavigationGuard(context, { lookup })
195
+
196
+ expect(await settle(context.handler(), 'https://flip.test/', true)).toBe('continue')
197
+ expect(await settle(context.handler(), 'https://flip.test/', true)).toBe('abort')
198
+ expect(lookup).toHaveBeenCalledTimes(2)
199
+ })
200
+ })
package/src/dispatch.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { assertPublicUrl } from '@open-mercato/web-research'
1
+ import { assertPublicUrl, type LookupFn } from '@open-mercato/web-research'
2
+ import { startEgressProxy, type EgressConnect, type EgressProxy } from './egressProxy'
2
3
  import {
3
4
  DEFAULT_USER_AGENT,
5
+ RESOLVER_LOCKDOWN_ARGS,
4
6
  isSandboxDisabled,
5
7
  resolveLaunchArgs,
6
8
  importChromium,
@@ -8,6 +10,7 @@ import {
8
10
  launchChromium,
9
11
  type InstallRunner,
10
12
  type BrowserHandle,
13
+ type BrowserType,
11
14
  type ContextHandle,
12
15
  type PageHandle,
13
16
  } from './playwright'
@@ -27,14 +30,25 @@ export const LEASE_IDLE_TIMEOUT_MS = 5 * 60_000
27
30
  export type SidecarState = {
28
31
  browser: BrowserHandle | null
29
32
  leases: Map<string, Lease>
33
+ /** @deprecated Subresource filtering is always on; this field is ignored. */
30
34
  filterSubresources: boolean
31
35
  warn: (message: string) => void
32
36
  /** Injectable so a lease-expiry test does not have to wait five minutes. */
33
37
  now: () => number
34
38
  /** Injectable so tests never shell out to `npx playwright install`. */
35
39
  installRunner?: InstallRunner
40
+ /** Injectable DNS for the URL checks and the egress proxy, so tests never resolve real names. */
41
+ lookup?: LookupFn
42
+ /** Injectable dial for the egress proxy, so tests can stand in for public hosts. */
43
+ connect?: EgressConnect
44
+ /** Injectable so tests never import Playwright. */
45
+ loadBrowserType?: () => Promise<BrowserType>
46
+ egressProxy?: EgressProxy | null
47
+ launching?: Promise<BrowserHandle> | null
36
48
  }
37
49
 
50
+ const FILTER_SUBRESOURCES_ENV = 'OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES'
51
+
38
52
  export const DEFAULT_GOTO_TIMEOUT_MS = 30_000
39
53
  const MAX_GOTO_TIMEOUT_MS = 120_000
40
54
 
@@ -42,16 +56,19 @@ export function createState(overrides: Partial<SidecarState> = {}): SidecarState
42
56
  return {
43
57
  browser: null,
44
58
  leases: new Map(),
45
- filterSubresources: process.env.OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES === '1',
59
+ filterSubresources: true,
46
60
  warn: (message) => process.stderr.write(`om-web-research-sidecar: ${message}\n`),
47
61
  now: () => Date.now(),
48
62
  ...overrides,
49
63
  }
50
64
  }
51
65
 
52
- async function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {
53
- if (state.browser) return state.browser
54
- const chromium = await importChromium()
66
+ function lookupOption(state: SidecarState): { lookup?: LookupFn } {
67
+ return state.lookup ? { lookup: state.lookup } : {}
68
+ }
69
+
70
+ async function launchBrowser(state: SidecarState): Promise<BrowserHandle> {
71
+ const browserType = await (state.loadBrowserType ?? importChromium)()
55
72
  if (isSandboxDisabled()) {
56
73
  // Said once per browser, not once per render, but said: this process renders
57
74
  // pages chosen by a search engine or a model with the one mitigation
@@ -60,12 +77,45 @@ async function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {
60
77
  'SECURITY: Chromium sandbox is disabled by OM_WEB_RESEARCH_BROWSER_NO_SANDBOX=1 — a renderer compromise reaches this host',
61
78
  )
62
79
  }
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
80
+ if (process.env[FILTER_SUBRESOURCES_ENV] !== undefined) {
81
+ state.warn(`subresource filtering is now always on; ${FILTER_SUBRESOURCES_ENV} is ignored`)
82
+ }
83
+ const proxy = await startEgressProxy({
84
+ ...lookupOption(state),
85
+ ...(state.connect ? { connect: state.connect } : {}),
86
+ })
87
+ let browser: BrowserHandle
88
+ try {
89
+ browser = await launchChromium(
90
+ browserType,
91
+ {
92
+ headless: true,
93
+ args: [...resolveLaunchArgs(), ...RESOLVER_LOCKDOWN_ARGS],
94
+ proxy: { server: proxy.url, bypass: '<-loopback>' },
95
+ },
96
+ { onProgress: state.warn, ...(state.installRunner ? { install: state.installRunner } : {}) },
97
+ )
98
+ } catch (error) {
99
+ await proxy.close()
100
+ throw error
101
+ }
102
+ state.egressProxy = proxy
103
+ state.browser = browser
104
+ return browser
105
+ }
106
+
107
+ /**
108
+ * Single-flight: two leases opening at once share one launch, so neither a
109
+ * browser nor its egress proxy is orphaned outside `teardown`.
110
+ */
111
+ async function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {
112
+ if (state.browser) return state.browser
113
+ if (!state.launching) {
114
+ state.launching = launchBrowser(state).finally(() => {
115
+ state.launching = null
116
+ })
117
+ }
118
+ return state.launching
69
119
  }
70
120
 
71
121
  /**
@@ -81,8 +131,8 @@ async function openLease(state: SidecarState, leaseId: string, userAgent: string
81
131
  return existing
82
132
  }
83
133
  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 })
134
+ const context = await browser.newContext({ userAgent, locale: 'en-US', serviceWorkers: 'block' })
135
+ await installNavigationGuard(context, { warn: state.warn, ...lookupOption(state) })
86
136
  const page = (await context.newPage()) as unknown as PageHandle
87
137
  const lease: Lease = { context, page, touchedAt: state.now() }
88
138
  state.leases.set(leaseId, lease)
@@ -111,9 +161,12 @@ async function closeLease(state: SidecarState, leaseId: string): Promise<void> {
111
161
  }
112
162
 
113
163
  export async function teardown(state: SidecarState): Promise<void> {
164
+ if (state.launching) await state.launching.catch(() => null)
114
165
  for (const leaseId of [...state.leases.keys()]) await closeLease(state, leaseId)
115
166
  const browser = state.browser
167
+ const proxy = state.egressProxy
116
168
  state.browser = null
169
+ state.egressProxy = null
117
170
  if (browser) {
118
171
  try {
119
172
  await browser.close()
@@ -121,6 +174,7 @@ export async function teardown(state: SidecarState): Promise<void> {
121
174
  // Best effort; the parent escalates to SIGKILL if we hang.
122
175
  }
123
176
  }
177
+ if (proxy) await proxy.close()
124
178
  }
125
179
 
126
180
  function stringParam(request: SidecarRequest, key: string): string {
@@ -142,7 +196,7 @@ async function dispatchInner(state: SidecarState, request: SidecarRequest): Prom
142
196
  // Re-checked here, in a distinct process from the caller, and BEFORE
143
197
  // Playwright is imported — a blocked URL never launches a browser.
144
198
  try {
145
- await assertPublicUrl(url, 'browser render', { failClosed: true })
199
+ await assertPublicUrl(url, 'browser render', { failClosed: true, ...lookupOption(state) })
146
200
  } catch (error) {
147
201
  return {
148
202
  id: request.id,
@@ -168,8 +222,18 @@ async function dispatchInner(state: SidecarState, request: SidecarRequest): Prom
168
222
  error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },
169
223
  }
170
224
  }
225
+ const finalUrl = lease.page.url()
226
+ try {
227
+ await assertPublicUrl(finalUrl, 'browser final URL', { failClosed: true, ...lookupOption(state) })
228
+ } catch (error) {
229
+ return {
230
+ id: request.id,
231
+ ok: false,
232
+ error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },
233
+ }
234
+ }
171
235
  const html = await lease.page.content()
172
- return { id: request.id, ok: true, result: { url: lease.page.url(), html } }
236
+ return { id: request.id, ok: true, result: { url: finalUrl, html } }
173
237
  }
174
238
 
175
239
  case 'release':