Skip to content

feat(enrichment): add screenshot pipeline with browser fetch and storage - #2708

Merged
Innei merged 10 commits into
masterfrom
feat/enhance-enrichment
May 13, 2026
Merged

feat(enrichment): add screenshot pipeline with browser fetch and storage#2708
Innei merged 10 commits into
masterfrom
feat/enhance-enrichment

Conversation

@Innei

@Innei Innei commented May 12, 2026

Copy link
Copy Markdown
Member

Summary

Add page-screenshot capture for the OG enrichment provider, with a bounded headless-browser session pool that doubles as a concurrency cap and an SSRF guard on the browser path.

Pipeline

  • Screenshot Pipeline Service — sharp → webp re-encode + blurhash + 3-swatch palette + retry-on-byte-cap
  • Browser Fetch Serviceagent-browser CLI driver; HTML batch + optional viewport screenshot in the same named session
  • Screenshot Storage Service — S3 PUT + LRU evict + Redis NX-EX throttle for last_accessed_at touches
  • Screenshot Repositoryenrichment_screenshots table (migration 0011) with FK CASCADE to enrichment_cache.id
  • OpenGraph Provider — surfaces raw screenshot bytes to EnrichmentService via a WeakMap channel so persistence orders correctly (row id → screenshot row)

Browser Session Pool (this revision)

  • BrowserSessionPool — bounded, lazy og-pool-0..N-1 named sessions, doubles as FIFO semaphore
  • Default maxSize=2, idleMs=60s via env AGENT_BROWSER_MAX_CONCURRENT / AGENT_BROWSER_IDLE_MS
  • release({ discard: true }) tears down sessions whose last command failed
  • OnModuleDestroyawait shutdown() ensures every chromium close is issued before Nest exits
  • Cookies / localStorage persist across slot reuse — OG fetch sends no credentials, so leak surface is bounded; operators wanting stricter isolation set MAX_CONCURRENT=1 + IDLE_MS=0

SSRF Hardening

  • Extracted parseAndValidateUrl + assertHostnameSafe into url-guard.ts (shared)
  • Browser path now runs the same guard as the HTTP path BEFORE acquiring a slot — private IPs / loopback / .internal hosts can no longer reach chromium

Config / Ops

  • openGraph.fetchMode toggles fetch (default, HTTP) vs browser (chromium via agent-browser)
  • openGraph.screenshot.*enabled, maxItems, maxTotalBytes, maxBytesPerImage, webpQuality
  • Dockerfile adds chromium + nss + cjk fonts + agent-browser; pinned via AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium-browser

Migration

0011_enrichment_screenshots.sql — new table; brand-new at deploy time so bare CREATE INDEX is allowed per migration-lint annotation.

Docs

  • docs/superpowers/specs/2026-05-12-enrichment-screenshot-design.md
  • docs/superpowers/specs/2026-05-11-knowledge-base-book-tree-design.md
  • docs/superpowers/plans/2026-05-13-browser-fetch-session-pool.md

Tests

Comprehensive coverage across all new services, including:

  • browser-session-pool.spec.ts — acquire / queue / discard / idle eviction / shutdown / abort (8 tests)
  • browser-fetch.service.spec.ts — pool reuse, SSRF rejection, screenshot capture, timeout
  • open-graph-screenshot.integration.spec.ts — end-to-end with per-harness pool
  • screenshot-storage.service.spec.ts — eviction, retry, S3 errors swallowed
  • 20260512-enrichment-screenshots.spec.ts — migration behaviour

Total: 158 test files / 1014 tests passing locally.

@safedep

safedep Bot commented May 12, 2026

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

No dependency changes detected. Nothing to scan.

View complete scan results →

This report is generated by SafeDep Github App

Innei added 6 commits May 13, 2026 16:55
- Add screenshot pipeline service for capturing page screenshots via headless browser
- Add browser fetch service with Puppeteer-based rendering
- Add screenshot storage service with local filesystem support
- Add screenshot repository with DB schema migration (0011)
- Add enrichment config schema fields for screenshot settings
- Update open-graph provider to support screenshot capture
- Enhance safe-fetch with configurable retries and user agent
- Add comprehensive test suite for all new services
- Update Dockerfile with Chromium and Puppeteer dependencies
- Add design docs for screenshot and knowledge base features
@Innei
Innei force-pushed the feat/enhance-enrichment branch from 8fb2cce to 4061d14 Compare May 13, 2026 09:36
@Innei

Innei commented May 13, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5468e1797d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

internal.live = true
this.scheduleIdleClose(internal)
}
this.flushWaiter()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent reuse of a slot while discard close is still running

When release(..., { discard: true }) is called, closeSlot() is fired asynchronously and then flushWaiter() runs immediately, so a queued acquire can receive the same slot before its agent-browser ... close finishes. Because closeSlot() later removes that slot from this.slots, the waiter may run on a session being torn down and then fail to return capacity on release (no matching internal slot), which can shrink effective pool size and cause stuck acquires under error bursts.

Useful? React with 👍 / 👎.

Comment on lines +186 to +190
const usage = await this.repository.getQuotaUsage()

let projectedCount = usage.count + addedItem
let projectedBytes = usage.totalBytes - existingBytes + newBytes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize screenshot quota checks with writes

Quota enforcement is computed from a point-in-time getQuotaUsage() snapshot, but there is no lock/transaction spanning this check and the subsequent S3 upload + DB upsert. Two concurrent storeOrEvict calls can both observe headroom and proceed, causing maxItems/maxTotalBytes to be exceeded despite each call individually passing checks; this breaks the configured storage cap under normal parallel traffic.

Useful? React with 👍 / 👎.

Innei added 3 commits May 13, 2026 22:58
…SSRF bypass

CLI 0.26.0's batch sub-command parser rejects `eval -b <b64> --json`
when `--json` is also on the sub-command, and misroutes screenshot
`--screenshot-*` flags as a [selector]. Drop the inner `--json` on
eval, and run viewport + screenshot as two standalone invocations
against the same `--session` instead of a batch. webp is not a CLI
output format; fall back to jpeg (sharp still re-encodes to webp
downstream). Add an `isDev` short-circuit in `assertHostnameSafe`
so local SSRF DNS validation does not reject loopback / fakeIp
proxy resolvers like Surge. Add a live spec gated by
`LIVE_BROWSER_FETCH=1` for end-to-end regression coverage.
Two issues surfaced in the codex review of #2708.

BrowserSessionPool: `release({ discard: true })` fired `closeSlot()`
asynchronously then immediately ran `flushWaiter`, so a queued
acquire could pick up the same slot while its `agent-browser ...
close` was still in flight and `slots.splice(...)` was pending —
the waiter would run on a session being torn down and then leak
capacity when its own release found no matching internal slot. Move
the splice to the start of `closeSlot` (synchronously, before the
await), give `flushWaiter` a fallback that mints a fresh slot when
capacity exists but the pool is empty, and track in-flight closes
in a Set drained by `shutdown` so callers can rely on shutdown()
meaning "all chromium is gone". Adds a regression spec where a
discard release with a queued waiter must not hand the discarded
slot to the waiter.

ScreenshotStorageService: `getQuotaUsage` → S3 PUT → DB upsert is
not transactional, so two concurrent `storeOrEvict` calls inside
the same pod could each see headroom and transiently overshoot
`maxItems` / `maxTotalBytes`. Chain all `storeOrEvict` invocations
through a single per-instance promise to serialize the critical
section; absorb the chain link's rejection so one failing write
does not poison subsequent callers. Cross-pod concurrency still
relies on the next LRU pass to converge — quota stays a soft
target, documented in the JSDoc. Adds two specs: concurrent
storeOrEvict never interleaves, and a rejected call does not
poison the chain.
@Innei
Innei merged commit 417a153 into master May 13, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant