The runtime image was `gcr.io/distroless/cc-debian12` with no `USER`, so the
process ran as root. Obscura executes untrusted page JavaScript in-process
through V8, so a V8 exploit lands with the process's privileges, and there is
no reason for those to be root's -- the image binds an unprivileged port, reads
the CA bundle, and writes only to the storage dir and a temp dir.
Switch to the `:nonroot` tag.
Verified against a locally built image rather than assumed:
Config.User -> 65532
--version -> obscura 0.1.0
fetch https://example.com -> succeeds, so the CA bundle is readable
fetch --storage-dir /tmp/obs -> succeeds
The tag is deliberately not pinned to a digest. distroless is rebuilt often
with base-layer security patches and tracking the tag picks those up; a digest
pin freezes them until someone bumps it, which for a *base* image trades a real
ongoing risk for a theoretical one. Reasoning recorded in the Dockerfile so a
reviewer can disagree knowingly.
Docs: the three `docker run` examples already published to host loopback
(`-p 127.0.0.1:9222:9222`), so that part needed no change. What did need
documenting is the consequence of dropping root -- **a mounted `--storage-dir`
must be writable by uid 65532** -- because that is the one thing this change
breaks, and it breaks quietly. Verified: with an unwritable storage dir the run
completes, exits 0, and prints no warning; the cookie jar simply never
persists. `save_to_file` errors are swallowed. Noted in the docs with a
`install -d -o 65532` line, and worth a separate silent-failure fix.
The in-container `--host 0.0.0.0` is left alone and now explains itself: a
container-loopback bind is unreachable through `-p`, so it is required for the
published port to work at all. It is not a licence to expose the port.
The profile section promises that each profile keeps "the WebGL/GPU renderer
internally consistent" and that Windows profiles report ANGLE Direct3D11 and
macOS profiles ANGLE Metal. That was true when the sentence was written in
#288, against the WebGL shim that existed then.
29d0bce ("render: expose WebGL as unavailable without backend") made
`getContext('webgl')`, `'experimental-webgl'` and `'webgl2'` return `null`,
deliberately, with an in-source comment explaining why. So there is no
renderer string for a page to read, and nothing for the profile to be
consistent with.
Checked on main (f449e6f) and on the v0.2.0 stealth binary:
obscura fetch "data:text/html,<p>x" \
--eval "String(document.createElement('canvas').getContext('webgl'))"
null
Doc-only. The `gpu` and `gpuVendor` fields are still assigned into the
fingerprint object in bootstrap.js and are not read anywhere; whether to
remove them or to wire them back up when WebGL lands is a separate call, so
this change does not touch them.
The loop in navigate_with_wait_post_inner limits how many documents
a navigation chain may load. It counts navigations the page itself
triggers after each load: assignments to location and form
submissions, which CDP's Page.ClientNavigationReason calls
scriptInitiated and formSubmission*. HTTP 3xx redirects never reach
this loop. They are followed one layer down, in obscura-net, with
their own limit and their own error.
The message still read "Too many redirects (limit 10)", so a chain
of client-initiated navigations looked like a redirect storm. This
name sends an investigation to the wrong layer. It points at the
redirect chain while the cause is a JavaScript loop.
The limit was also a function-local constant with no way to raise
it. The low default is right and stays 10, but an endpoint that
chains longer than nine navigations for good reasons was simply
unreachable. It is now a per-page value with a fallback to the
environment, in the same form navigation_timeout already has:
page.set_navigation_chain_limit(20) programmatic, takes precedence
OBSCURA_NAV_CHAIN_LIMIT=20 in operation
neither set unchanged default 10
The number counts documents, the first navigation included. So 10
allows the requested document and nine client-initiated navigations
on top. The error message names this unit. The number is now
something an operator acts on, and "limit 10" next to the word
"navigations" would read as ten navigations and send them exactly
one too low.
Values below 1 are raised to 1 on both paths. A zero would let the
loop run without a single iteration, the page would report success
and have loaded nothing. Only an unreadable value from the
environment falls back to the default. A 0 therefore means the same
on both paths: load the first document and do not chain further.
Provide a standalone viewer at tools/live-view.mjs that polls
Page.captureScreenshot with connection-aware target discovery.
Unlike the inline doc example, it follows the agent across pages:
it discovers targets through the global HTTP list and re-attaches
whenever the agent opens a new page. Capture pacing is adaptive:
fast while frames change, idle back-off otherwise, so watching
feels continuous without busy looping.
Part of #691.
Agents driving obscura through MCP, Puppeteer, or Playwright are
invisible because the browser is headless. Add a guide showing how
to stream what the browser sees to a local tab with a small
dependency-free Node script (Node 21+ native WebSocket).
The script attaches through Target.attachToTarget and polls
Page.captureScreenshot twice a second over Server-Sent Events.
Polling is deliberate: screencast frames stream only to the session
that drives the page, so a passive viewer goes quiet once an agent
session takes over. The guide documents that plus two more
behaviors found while building it: every screencast frame needs
screencastFrameAck or delivery stalls, and a bare page socket
refuses screencast commands with 503.
Fixes#691.
Add a short screenshot flag, reject batch conflicts during argument parsing, and remove unsupported performance and parity language from the public documentation.
Document screenshots, scrolling, screencasting, PDF, MCP media output, release builds, and evidence-based render verification across the public guides and agent skill.
The on_request/on_response Vec lived on the ObscuraHttpClient shared by
every page in the context, so a callback registered on one page fired for
all pages (#408 cross-page bleed) and outlived its page.
Callbacks now live in a per-page CallbackRegistry (Arc shared with the
page's JS runtime state). Page-driven fetch paths (navigation, POST,
scripts, stylesheets, robots.txt, module loads, JS fetch/XHR in both
plain and stealth modes) pass the registry through; the HTTP client
itself is callback-free.
Adds callbacks_do_not_bleed_across_pages: page A's callback stays silent
for page B's requests and stops firing after page A is dropped.
The request interception API (#306) and the global --stealth flag are
merged, so:
- Use-as-a-Rust-library.md: add an "Intercept requests" section covering
on_request/on_response, enable_interception (Continue/Fulfill/Fail),
and add_preload_script, with examples.
- Intercept-and-modify-requests.md: point embedders at the native API.
- AGENTS.md: --stealth is now global (all subcommands incl. scrape, via
OBSCURA_STEALTH for workers); note the interception API on the obscura
crate and that op_fetch_url rewrites must stay behind validate_fetch_url.
- skills/obscura/SKILL.md: fix the build note (stealth is not a default
feature), correct "randomized" to a consistent fingerprint, add the
global --stealth flag, --dump assets/cookies, and an interception note.
- --stealth is now a global flag: document it on every subcommand
including scrape, and that it works before or after the subcommand.
- Correct the stealth description: it presents a consistent Chrome
fingerprint (TLS, headers, JS surfaces), not a randomized one, and
scripted fetch()/XHR share that fingerprint.
- Add --dump cookies to the CLI reference and the extract-data guide.
- Note --dump assets now also lists fetch()/XHR request URLs.