@agent-native/creative-context 0.6.0 → 0.6.2

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.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync } from "node:fs";
2
3
 
3
4
  import {
@@ -31,8 +32,34 @@ const MAX_RADII = 64;
31
32
  const MAX_CSS_VARIABLES = 128;
32
33
  const MAX_COMPONENT_STYLES = 24;
33
34
  const FONT_READY_TIMEOUT_MS = 4_000;
34
- const BROWSER_NAVIGATION_DISABLED_WARNING =
35
- "Browser rendering is disabled until Chromium navigation has a connect-time SSRF guard.";
35
+ const MAX_BROWSER_RESOURCE_BYTES = 12 * 1024 * 1024;
36
+ const MAX_BROWSER_RESOURCE_COUNT = 400;
37
+ const MAX_BROWSER_RESOURCE_BYTES_TOTAL = 64 * 1024 * 1024;
38
+ const BROWSER_RESOURCE_TIMEOUT_MS = 15_000;
39
+ const BROWSER_REQUEST_HEADERS = new Set([
40
+ "accept",
41
+ "accept-language",
42
+ "if-modified-since",
43
+ "if-none-match",
44
+ "origin",
45
+ "range",
46
+ "referer",
47
+ "user-agent",
48
+ ]);
49
+ const BROWSER_RESPONSE_HEADERS = new Set([
50
+ "connection",
51
+ "content-encoding",
52
+ "content-length",
53
+ "keep-alive",
54
+ "proxy-authenticate",
55
+ "proxy-authorization",
56
+ "set-cookie",
57
+ "set-cookie2",
58
+ "te",
59
+ "trailer",
60
+ "transfer-encoding",
61
+ "upgrade",
62
+ ]);
36
63
 
37
64
  export interface RenderedPageRequest {
38
65
  url: string;
@@ -75,12 +102,19 @@ interface PlaywrightRequestLike {
75
102
  url(): string;
76
103
  isNavigationRequest(): boolean;
77
104
  resourceType(): string;
105
+ method?(): string;
106
+ headers?(): Record<string, string>;
78
107
  }
79
108
 
80
109
  interface PlaywrightRouteLike {
81
110
  request(): PlaywrightRequestLike;
82
111
  continue(): Promise<void>;
83
112
  abort(errorCode?: string): Promise<void>;
113
+ fulfill(options: {
114
+ status: number;
115
+ headers: Record<string, string>;
116
+ body: Uint8Array;
117
+ }): Promise<void>;
84
118
  }
85
119
 
86
120
  interface PlaywrightPageLike {
@@ -128,16 +162,10 @@ interface PlaywrightLike {
128
162
  }
129
163
 
130
164
  export interface LayeredRenderedPageProviderOptions {
131
- /**
132
- * Retained for compatibility with the previous browser renderer. Arbitrary
133
- * Chromium navigation is disabled until a connect-time SSRF guard exists.
134
- */
135
165
  requestBuilderBrowserConnection?: (input: {
136
166
  sessionId: string;
137
167
  }) => Promise<Record<string, unknown>>;
138
- /** @deprecated Browser navigation is disabled until it is connect-time guarded. */
139
168
  loadPlaywright?: () => Promise<PlaywrightLike | null>;
140
- /** @deprecated Browser navigation is disabled until it is connect-time guarded. */
141
169
  requestAttachedBrowserConnection?: (input: {
142
170
  sessionId: string;
143
171
  url: string;
@@ -146,27 +174,94 @@ export interface LayeredRenderedPageProviderOptions {
146
174
  }
147
175
 
148
176
  export class LayeredRenderedPageProvider implements RenderedPageProvider {
177
+ readonly #requestBuilderBrowserConnection: (input: {
178
+ sessionId: string;
179
+ }) => Promise<Record<string, unknown>>;
180
+ readonly #loadPlaywright: () => Promise<PlaywrightLike | null>;
181
+ readonly #requestAttachedBrowserConnection?: NonNullable<
182
+ LayeredRenderedPageProviderOptions["requestAttachedBrowserConnection"]
183
+ >;
149
184
  readonly #staticFetch: typeof ssrfSafeFetch;
150
185
 
151
186
  constructor(options: LayeredRenderedPageProviderOptions = {}) {
187
+ this.#requestBuilderBrowserConnection =
188
+ options.requestBuilderBrowserConnection ?? defaultBuilderBrowserRequest;
189
+ this.#loadPlaywright = options.loadPlaywright ?? loadOptionalPlaywright;
190
+ this.#requestAttachedBrowserConnection =
191
+ options.requestAttachedBrowserConnection;
152
192
  this.#staticFetch = options.staticFetch ?? ssrfSafeFetch;
153
193
  }
154
194
 
155
195
  async render(request: RenderedPageRequest): Promise<RenderedPageResult> {
156
196
  await assertPublicBrowserUrl(request.url);
157
- return renderStatic(
158
- request,
159
- [BROWSER_NAVIGATION_DISABLED_WARNING],
160
- this.#staticFetch,
161
- );
197
+ const warnings: string[] = [];
198
+ const playwright = await this.#loadPlaywright().catch((error) => {
199
+ warnings.push(`Playwright unavailable: ${errorMessage(error)}`);
200
+ return null;
201
+ });
202
+
203
+ if (request.preferHosted !== false && playwright) {
204
+ try {
205
+ const connection = await this.#requestBuilderBrowserConnection({
206
+ sessionId: `creative-context-${randomUUID()}`,
207
+ });
208
+ const wsUrl =
209
+ typeof connection.wsUrl === "string" ? connection.wsUrl.trim() : "";
210
+ if (!wsUrl) throw new Error("Builder Browser did not return wsUrl.");
211
+ return await renderWithPlaywright(
212
+ playwright,
213
+ request,
214
+ warnings,
215
+ "builder-browser",
216
+ wsUrl,
217
+ );
218
+ } catch (error) {
219
+ warnings.push(`Builder Browser unavailable: ${errorMessage(error)}`);
220
+ }
221
+ }
222
+
223
+ if (playwright) {
224
+ try {
225
+ return await renderWithPlaywright(
226
+ playwright,
227
+ request,
228
+ warnings,
229
+ "local-playwright",
230
+ );
231
+ } catch (error) {
232
+ warnings.push(`Local Playwright unavailable: ${errorMessage(error)}`);
233
+ }
234
+ }
235
+
236
+ if (playwright && this.#requestAttachedBrowserConnection) {
237
+ try {
238
+ const connection = await this.#requestAttachedBrowserConnection({
239
+ sessionId: `creative-context-attached-${randomUUID()}`,
240
+ url: request.url,
241
+ });
242
+ if (!connection.wsUrl?.trim()) {
243
+ throw new Error("Approved attached browser did not return wsUrl.");
244
+ }
245
+ return await renderWithPlaywright(
246
+ playwright,
247
+ request,
248
+ warnings,
249
+ "attached-chrome",
250
+ connection.wsUrl,
251
+ );
252
+ } catch (error) {
253
+ warnings.push(`Attached Chrome unavailable: ${errorMessage(error)}`);
254
+ }
255
+ } else {
256
+ warnings.push(
257
+ "Attached Chrome unavailable: no approved browser connection adapter is configured.",
258
+ );
259
+ }
260
+
261
+ return renderStatic(request, warnings, this.#staticFetch);
162
262
  }
163
263
  }
164
264
 
165
- /**
166
- * Internal browser renderer retained for a future connect-time-guarded
167
- * adapter. `LayeredRenderedPageProvider` must not call this for arbitrary
168
- * caller URLs until Chromium's socket connection is guarded as well.
169
- */
170
265
  export async function renderWithPlaywright(
171
266
  playwright: PlaywrightLike,
172
267
  request: RenderedPageRequest,
@@ -179,20 +274,19 @@ export async function renderWithPlaywright(
179
274
  : await launchChromium(playwright.chromium);
180
275
  let isolatedContext: PlaywrightContextLike | undefined;
181
276
  try {
182
- // A hosted CDP browser can already contain the user's other tabs. Prefer a
183
- // fresh context so extraction never reads ambient browser state, cookies,
184
- // or a page left behind by another workflow. Older browser adapters may not
185
- // expose newContext, so retain the existing-context fallback.
186
- try {
187
- isolatedContext = await browser.newContext?.();
188
- } catch {
189
- isolatedContext = undefined;
277
+ // Never reuse a connected browser's ambient context: it can carry cookies,
278
+ // extensions, or tabs from another workflow. The safe proxy below is only
279
+ // useful when the page itself is isolated from that state.
280
+ if (!browser.newContext) {
281
+ throw new Error("Browser did not provide isolated context support.");
190
282
  }
191
- const context = isolatedContext ?? browser.contexts()[0];
192
- if (!context)
193
- throw new Error("Browser did not provide an isolated context.");
194
- const page = context.pages()[0] ?? (await context.newPage());
195
- await installNavigationGuard(page);
283
+ isolatedContext = await browser.newContext();
284
+ const page = await isolatedContext.newPage();
285
+ const getFinalNavigationUrl = await installNavigationGuard(
286
+ page,
287
+ request,
288
+ warnings,
289
+ );
196
290
  await page.setViewportSize({ width: 1440, height: 900 });
197
291
  await page.goto(request.url, {
198
292
  timeout: boundedTimeout(request.timeoutMs),
@@ -227,7 +321,7 @@ export async function renderWithPlaywright(
227
321
  });
228
322
  await new Promise((resolve) => setTimeout(resolve, 150));
229
323
  await page.evaluate(dismissConsentOverlays).catch(() => undefined);
230
- const finalUrl = page.url();
324
+ const finalUrl = getFinalNavigationUrl() ?? page.url();
231
325
  await assertPublicBrowserUrl(finalUrl);
232
326
  const [title, text, desktopScreenshot, extraction] = await Promise.all([
233
327
  page.title().catch((error) => {
@@ -362,17 +456,58 @@ async function waitForFontReadiness(
362
456
  }
363
457
  }
364
458
 
365
- async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
459
+ async function installNavigationGuard(
460
+ page: PlaywrightPageLike,
461
+ renderRequest: RenderedPageRequest,
462
+ warnings: string[],
463
+ ): Promise<() => string | undefined> {
464
+ let finalNavigationUrl: string | undefined;
465
+ let resourceCount = 0;
466
+ let resourceBytes = 0;
467
+ let reservedResourceBytes = 0;
468
+ const bodyBudgetWaiters: Array<() => void> = [];
469
+ let resourceLimitWarningAdded = false;
470
+ let blockedResourceWarningAdded = false;
471
+ let failedResourceWarningAdded = false;
472
+
473
+ const reserveBodyBudget = async (): Promise<() => void> => {
474
+ while (
475
+ reservedResourceBytes + MAX_BROWSER_RESOURCE_BYTES >
476
+ MAX_BROWSER_RESOURCE_BYTES_TOTAL
477
+ ) {
478
+ await new Promise<void>((resolve) => {
479
+ bodyBudgetWaiters.push(resolve);
480
+ });
481
+ }
482
+ reservedResourceBytes += MAX_BROWSER_RESOURCE_BYTES;
483
+ let released = false;
484
+ return () => {
485
+ if (released) return;
486
+ released = true;
487
+ reservedResourceBytes -= MAX_BROWSER_RESOURCE_BYTES;
488
+ bodyBudgetWaiters.shift()?.();
489
+ };
490
+ };
491
+
492
+ const addWarning = (value: string): void => {
493
+ if (!warnings.includes(value)) warnings.push(value);
494
+ };
495
+
366
496
  await page.route("**/*", async (route) => {
367
- const request = route.request();
497
+ const browserRequest = route.request();
368
498
  let parsed: URL;
369
499
  try {
370
- parsed = new URL(request.url());
500
+ parsed = new URL(browserRequest.url());
371
501
  } catch {
502
+ // coercion-ok: an absent optional package means the next compatible probe should run.
372
503
  await route.abort("blockedbyclient");
373
504
  return;
374
505
  }
375
- if (parsed.protocol === "data:" || parsed.protocol === "blob:") {
506
+ if (
507
+ parsed.protocol === "about:" ||
508
+ parsed.protocol === "data:" ||
509
+ parsed.protocol === "blob:"
510
+ ) {
376
511
  await route.continue();
377
512
  return;
378
513
  }
@@ -380,14 +515,133 @@ async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
380
515
  await route.abort("blockedbyclient");
381
516
  return;
382
517
  }
518
+
519
+ const method = (browserRequest.method?.() ?? "GET").toUpperCase();
520
+ if (method !== "GET" && method !== "HEAD") {
521
+ await route.abort("blockedbyclient");
522
+ if (!resourceLimitWarningAdded) {
523
+ resourceLimitWarningAdded = true;
524
+ addWarning(
525
+ "Browser blocked a non-read-only resource request during extraction.",
526
+ );
527
+ }
528
+ return;
529
+ }
530
+ if (resourceCount >= MAX_BROWSER_RESOURCE_COUNT) {
531
+ await route.abort("blockedbyclient");
532
+ if (!resourceLimitWarningAdded) {
533
+ resourceLimitWarningAdded = true;
534
+ addWarning(
535
+ `Browser resource budget reached (${MAX_BROWSER_RESOURCE_COUNT} requests).`,
536
+ );
537
+ }
538
+ return;
539
+ }
540
+
541
+ // Reserve the request slot before the first await. Browser route handlers
542
+ // overlap, so incrementing only after the proxy response arrives lets a
543
+ // burst of requests all pass the limit check.
544
+ resourceCount += 1;
545
+ let bodyBudgetRelease: (() => void) | undefined;
546
+ let committedBytes = 0;
547
+ let fulfilled = false;
548
+
383
549
  try {
384
550
  await assertPublicBrowserUrl(parsed.href);
385
- } catch {
551
+ const response = await ssrfSafeFetch(
552
+ parsed.href,
553
+ {
554
+ method,
555
+ headers: browserRequestHeaders(browserRequest),
556
+ signal: AbortSignal.timeout(
557
+ Math.min(
558
+ BROWSER_RESOURCE_TIMEOUT_MS,
559
+ boundedTimeout(renderRequest.timeoutMs),
560
+ ),
561
+ ),
562
+ },
563
+ { maxRedirects: 5 },
564
+ );
565
+ bodyBudgetRelease = await reserveBodyBudget();
566
+ const body = await readBoundedResponseBytes(
567
+ response,
568
+ MAX_BROWSER_RESOURCE_BYTES,
569
+ );
570
+ bodyBudgetRelease();
571
+ bodyBudgetRelease = undefined;
572
+ if (resourceBytes + body.byteLength > MAX_BROWSER_RESOURCE_BYTES_TOTAL) {
573
+ await route.abort("blockedbyclient");
574
+ if (!resourceLimitWarningAdded) {
575
+ resourceLimitWarningAdded = true;
576
+ addWarning(
577
+ `Browser resource budget reached (${MAX_BROWSER_RESOURCE_BYTES_TOTAL} bytes).`,
578
+ );
579
+ }
580
+ return;
581
+ }
582
+ resourceBytes += body.byteLength;
583
+ committedBytes = body.byteLength;
584
+ if (browserRequest.isNavigationRequest()) {
585
+ finalNavigationUrl = response.url || parsed.href;
586
+ }
587
+ await route.fulfill({
588
+ status: response.status,
589
+ headers: browserResponseHeaders(response),
590
+ body: Buffer.from(body),
591
+ });
592
+ fulfilled = true;
593
+ } catch (error) {
594
+ if (committedBytes > 0) {
595
+ resourceBytes -= committedBytes;
596
+ committedBytes = 0;
597
+ }
386
598
  await route.abort("blockedbyclient");
387
- return;
599
+ if (isSsrfError(error)) {
600
+ if (!blockedResourceWarningAdded) {
601
+ blockedResourceWarningAdded = true;
602
+ addWarning(
603
+ "Some browser resources were blocked by the SSRF safety policy.",
604
+ );
605
+ }
606
+ } else if (!failedResourceWarningAdded) {
607
+ failedResourceWarningAdded = true;
608
+ addWarning(
609
+ "Some browser resources could not be fetched through the safe network proxy.",
610
+ );
611
+ }
612
+ } finally {
613
+ bodyBudgetRelease?.();
614
+ if (!fulfilled) resourceCount -= 1;
388
615
  }
389
- await route.continue();
390
616
  });
617
+ return () => finalNavigationUrl;
618
+ }
619
+
620
+ function browserRequestHeaders(
621
+ request: PlaywrightRequestLike,
622
+ ): Record<string, string> {
623
+ const source = request.headers?.() ?? {};
624
+ return Object.fromEntries(
625
+ Object.entries(source).filter(([name, value]) => {
626
+ return BROWSER_REQUEST_HEADERS.has(name.toLowerCase()) && Boolean(value);
627
+ }),
628
+ );
629
+ }
630
+
631
+ function browserResponseHeaders(response: Response): Record<string, string> {
632
+ const headers: Record<string, string> = {};
633
+ response.headers.forEach((value, name) => {
634
+ if (!BROWSER_RESPONSE_HEADERS.has(name.toLowerCase())) {
635
+ headers[name] = value;
636
+ }
637
+ });
638
+ return headers;
639
+ }
640
+
641
+ function isSsrfError(error: unknown): boolean {
642
+ return /ssrf blocked|private\/internal|connect blocked/i.test(
643
+ errorMessage(error),
644
+ );
391
645
  }
392
646
 
393
647
  const SYSTEM_CHROME_EXECUTABLES = [
@@ -404,20 +658,113 @@ async function launchChromium(
404
658
  headless: true,
405
659
  args: ["--no-sandbox", "--disable-dev-shm-usage"],
406
660
  };
661
+ let missingBrowserError: unknown;
407
662
  try {
408
663
  return await chromium.launch(launchOptions);
409
664
  } catch (error) {
410
665
  if (!isMissingBrowserError(error)) throw error;
411
- for (const executablePath of SYSTEM_CHROME_EXECUTABLES) {
412
- if (!existsSync(executablePath)) continue;
413
- try {
414
- return await chromium.launch({ ...launchOptions, executablePath });
415
- } catch {
416
- continue;
666
+ missingBrowserError = error;
667
+ }
668
+
669
+ const serverlessChromium = await loadOptionalServerlessChromium();
670
+ if (serverlessChromium) {
671
+ try {
672
+ const executablePath = await serverlessChromium.executablePath();
673
+ if (executablePath) {
674
+ return await chromium.launch({
675
+ ...launchOptions,
676
+ args: [...launchOptions.args, ...(serverlessChromium.args ?? [])],
677
+ executablePath,
678
+ });
679
+ }
680
+ } catch (error) {
681
+ if (!isMissingBrowserError(error)) throw error;
682
+ missingBrowserError = error;
683
+ }
684
+ }
685
+
686
+ for (const executablePath of SYSTEM_CHROME_EXECUTABLES) {
687
+ if (!existsSync(executablePath)) continue;
688
+ try {
689
+ return await chromium.launch({ ...launchOptions, executablePath });
690
+ } catch {
691
+ continue;
692
+ }
693
+ }
694
+ if (missingBrowserError) {
695
+ throw missingBrowserError;
696
+ }
697
+ throw new Error(
698
+ "No Chromium executable is available for browser extraction.",
699
+ );
700
+ }
701
+
702
+ interface ServerlessChromiumLike {
703
+ args?: string[];
704
+ executablePath(): Promise<string>;
705
+ }
706
+
707
+ async function loadOptionalServerlessChromium(): Promise<ServerlessChromiumLike | null> {
708
+ const specifier = "@sparticuz/chromium";
709
+ try {
710
+ const module = (await import(/* @vite-ignore */ specifier)) as unknown as {
711
+ default?: Partial<ServerlessChromiumLike>;
712
+ } & Partial<ServerlessChromiumLike>;
713
+ const chromium = module.default ?? module;
714
+ return typeof chromium.executablePath === "function"
715
+ ? (chromium as ServerlessChromiumLike)
716
+ : null;
717
+ } catch {
718
+ // coercion-ok: this optional capability is absent in non-serverless installs.
719
+ return null;
720
+ }
721
+ }
722
+
723
+ /*
724
+ * Kept separate from Playwright loading so a deployment can omit the large
725
+ * serverless Chromium package and still use Builder Browser or system Chrome.
726
+ */
727
+ async function loadOptionalPlaywright(): Promise<PlaywrightLike | null> {
728
+ for (const specifier of [
729
+ "playwright",
730
+ "@playwright/test",
731
+ "playwright-core",
732
+ ]) {
733
+ try {
734
+ const module = (await import(
735
+ /* @vite-ignore */ specifier
736
+ )) as unknown as {
737
+ default?: Partial<PlaywrightLike>;
738
+ } & Partial<PlaywrightLike>;
739
+ for (const candidate of [module.default, module]) {
740
+ if (typeof candidate?.chromium?.launch === "function") {
741
+ return candidate as PlaywrightLike;
742
+ }
417
743
  }
744
+ // coercion-ok: an absent optional package means the next compatible probe should run.
745
+ } catch {
746
+ // Try the next compatible browser package before falling back to HTML.
418
747
  }
419
- throw error;
420
748
  }
749
+
750
+ // coercion-ok: browser packages are optional in non-Node runtimes.
751
+ return null;
752
+ }
753
+
754
+ async function defaultBuilderBrowserRequest(input: {
755
+ sessionId: string;
756
+ }): Promise<Record<string, unknown>> {
757
+ const server = (await import("@agent-native/core/server")) as unknown as {
758
+ requestBuilderBrowserConnection?: (value: {
759
+ sessionId: string;
760
+ }) => Promise<Record<string, unknown>>;
761
+ };
762
+ if (!server.requestBuilderBrowserConnection) {
763
+ throw new Error(
764
+ "@agent-native/core/server does not export requestBuilderBrowserConnection.",
765
+ );
766
+ }
767
+ return server.requestBuilderBrowserConnection(input);
421
768
  }
422
769
 
423
770
  function isMissingBrowserError(error: unknown): boolean {
@@ -155,7 +155,7 @@ describe("website context connector", () => {
155
155
  ).rejects.toThrow(/SSRF blocked/i);
156
156
  });
157
157
 
158
- it("disables arbitrary Chromium navigation and keeps the static fallback", async () => {
158
+ it("falls back explicitly when the browser capability is unavailable", async () => {
159
159
  const staticFetch = vi.fn(async (url: string) =>
160
160
  response(
161
161
  url,
@@ -176,7 +176,7 @@ describe("website context connector", () => {
176
176
  preferHosted: false,
177
177
  });
178
178
 
179
- expect(loadPlaywright).not.toHaveBeenCalled();
179
+ expect(loadPlaywright).toHaveBeenCalledTimes(1);
180
180
  expect(staticFetch).toHaveBeenCalledTimes(1);
181
181
  expect(result).toMatchObject({
182
182
  method: "static-html",
@@ -185,7 +185,7 @@ describe("website context connector", () => {
185
185
  });
186
186
  expect(result.warnings).toEqual(
187
187
  expect.arrayContaining([
188
- expect.stringContaining("connect-time SSRF guard"),
188
+ expect.stringContaining("Playwright unavailable"),
189
189
  expect.stringContaining("SSRF-safe static HTML fallback"),
190
190
  ]),
191
191
  );