feat(craft): give the agent headless browser use via a built-in browser skill (#12564)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
roshanandClaude Opus 4.8 authored and GitHub committed 2026-07-01 18:46:01 +00:00
1 parent 74cfa63dd8
commit 5895337a05
12 files changed
+2880 -2

No files matched your search

@@ -148,7 +148,9 @@ jobs:
context: ./backend/onyx/server/features/build/sandbox/image
file: ./backend/onyx/server/features/build/sandbox/image/Dockerfile
# CI tests don't exercise skill runtime deps (soffice/pdftoppm/pptxgenjs),
# so skip LibreOffice et al. to keep the CI image lean.
# so skip LibreOffice et al. to keep the CI image lean. Browser stays
# on (ENABLE_BROWSER defaults true) — chromium is lighter than the
# skill bundle and keeps the CI image consistent with prod.
extra_build_args: "ENABLE_SKILLS=false"
- image: backend
context: ./backend
+3 -1
View File
@@ -162,7 +162,9 @@ jobs:
context: ./backend/onyx/server/features/build/sandbox/image
file: ./backend/onyx/server/features/build/sandbox/image/Dockerfile
# CI tests don't exercise skill runtime deps (soffice/pdftoppm/pptxgenjs),
# so skip LibreOffice et al. to keep the CI image lean.
# so skip LibreOffice et al. to keep the CI image lean. Browser stays
# on (ENABLE_BROWSER defaults true) — chromium is lighter and the
# browser integration test needs it.
extra_build_args: "ENABLE_SKILLS=false"
- image: backend
context: ./backend
@@ -0,0 +1,100 @@
"""seed_browser_built_in_skill
Seeds the built-in ``browser`` skill row (drives the in-pod agent-browser via
the session-pinned ``browser`` wrapper). Mirrors the seeding in
7f5b159041be_skill_built_in_id_discriminator, but — per that migration's own
guidance — guards against a tenant's custom skill already owning the ``browser``
slug: a built-in must never silently clobber or shadow a custom row.
Availability is gated per-deployment by the skill's registry ``is_available``
(keyed on ENABLE_BROWSER, in onyx.skills.built_in); this migration only creates
the row.
Revision ID: 989bc57562e4
Revises: a4c9d2e7f1b8
Create Date: 2026-07-01 11:08:10.633955
"""
import uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "989bc57562e4"
down_revision = "a4c9d2e7f1b8"
branch_labels = None
depends_on = None
_BROWSER_SLUG = "browser"
_BROWSER_DESCRIPTION = (
"Drive a real (headless) browser for anything webfetch can't do: navigating "
"JS-rendered pages, clicking, filling forms, multi-step flows, taking "
"screenshots, extracting data, logging into sites, testing web apps, or "
"visually checking your own app. Use whenever the task needs a rendered, "
"interactive browser rather than static page content."
)
_skill_table = sa.table(
"skill",
sa.column("id", postgresql.UUID(as_uuid=True)),
sa.column("slug", sa.String),
sa.column("name", sa.String),
sa.column("description", sa.Text),
sa.column("built_in_skill_id", sa.String),
sa.column("bundle_file_id", sa.String),
sa.column("bundle_sha256", sa.String),
sa.column("author_user_id", postgresql.UUID(as_uuid=True)),
sa.column("public_permission", sa.String),
sa.column("enabled", sa.Boolean),
)
def upgrade() -> None:
bind = op.get_bind()
# Fail loud if a custom skill already owns the slug (built_in_skill_id IS
# NULL): the built-in must not clobber it via the upsert below.
existing = bind.execute(
sa.text("SELECT built_in_skill_id FROM skill WHERE slug = :slug"),
{"slug": _BROWSER_SLUG},
).first()
if existing is not None and existing[0] is None:
raise RuntimeError(
"Cannot seed built-in 'browser' skill: a custom skill already owns "
"the 'browser' slug. Rename that custom skill before upgrading."
)
insert_stmt = postgresql.insert(_skill_table).values(
[
{
"id": uuid.uuid4(),
"slug": _BROWSER_SLUG,
"name": _BROWSER_SLUG,
"description": _BROWSER_DESCRIPTION,
"built_in_skill_id": _BROWSER_SLUG,
"bundle_file_id": None,
"bundle_sha256": None,
"author_user_id": None,
"public_permission": "VIEWER",
"enabled": True,
}
]
)
stmt = insert_stmt.on_conflict_do_update(
index_elements=["slug"],
set_={
"name": insert_stmt.excluded.name,
"description": insert_stmt.excluded.description,
},
)
bind.execute(stmt)
def downgrade() -> None:
op.get_bind().execute(
sa.text("DELETE FROM skill WHERE slug = :slug AND built_in_skill_id = :slug"),
{"slug": _BROWSER_SLUG},
)
@@ -69,6 +69,10 @@ SANDBOX_SERVICE_ACCOUNT_NAME = os.environ.get("SANDBOX_SERVICE_ACCOUNT_NAME", "s
ENABLE_CRAFT = os.environ.get("ENABLE_CRAFT", "false").lower() == "true"
# Gates the built-in `browser` skill. Distinct from the sandbox image's
# build-time ENABLE_BROWSER ARG — set this runtime env to match it.
ENABLE_BROWSER = os.environ.get("ENABLE_BROWSER", "false").lower() == "true"
SANDBOX_PUSH_PRIVATE_KEY = os.environ.get("ONYX_SANDBOX_PUSH_PRIVATE_KEY", "")
@@ -47,7 +47,9 @@ FROM ${BASE_IMAGE_REGISTRY}/library/python:3.13-slim@sha256:f82c96458eedc847b233
# the API server at session-setup, but their runtime tools must already be in
# the image.
ARG ENABLE_SKILLS=true
ARG ENABLE_BROWSER=true
ARG OPENCODE_VERSION=1.15.7
ARG AGENT_BROWSER_VERSION=0.31.1
# firewall-init.sh needs: iptables (egress lockdown), ca-certificates (trust
# store), gosu (legacy, not currently invoked). setpriv ships with util-linux in
@@ -90,6 +92,27 @@ RUN if [ "$ENABLE_SKILLS" = "true" ]; then \
&& rm -rf /var/lib/apt/lists/*; \
fi
# Chromium engine + certutil (libnss3-tools imports the egress-proxy CA into
# Chromium's NSS db). Its own layer: big and rarely-changing, so an
# AGENT_BROWSER_VERSION bump below re-pulls only the small CLI layer, not this.
RUN if [ "$ENABLE_BROWSER" = "true" ]; then \
apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
chromium \
libnss3-tools \
&& rm -rf /var/lib/apt/lists/*; \
fi
# agent-browser CLI/daemon. Do NOT run `agent-browser install` — it fetches
# Chrome-for-Testing (no arm64 build); we use system chromium via
# AGENT_BROWSER_EXECUTABLE_PATH. --allow-scripts lets the npm postinstall run on
# npm 11+.
RUN if [ "$ENABLE_BROWSER" = "true" ]; then \
npm install -g --allow-scripts=agent-browser "agent-browser@${AGENT_BROWSER_VERSION}" && \
agent-browser --version && \
npm cache clean --force && \
rm -rf /root/.npm; \
fi
# Reconcile UID/GID 1000 with whatever the base image already ships with.
RUN EXISTING_USER=$(id -nu 1000 2>/dev/null || echo ""); \
EXISTING_GROUP=$(getent group 1000 | cut -d: -f1 2>/dev/null || echo ""); \
@@ -164,6 +187,9 @@ RUN chmod +x /workspace/firewall-init.sh
# kubernetes_sandbox_manager._OPENCODE_SESSION_TAG_PLUGIN_PATH.
COPY opencode-plugins/ /workspace/opencode-plugins/
COPY browser-cli.sh /usr/local/bin/browser
RUN chmod 0755 /usr/local/bin/browser
RUN chown -R sandbox:sandbox /workspace
USER sandbox
@@ -0,0 +1,25 @@
#!/bin/bash
# `browser` — wraps `agent-browser` so the Craft agent can drive a browser in the
# locked-down pod: pins this session and supplies the env the bare binary needs.
# A real PATH executable, not an alias — opencode's bash tool is non-interactive
# (no ~/.bashrc / alias expansion).
set -euo pipefail
# Chromium needs --no-sandbox (pod drops caps/seccomp) and the proxy as a FLAG
# (it ignores *_PROXY env; userinfo stripped — proxy authorizes by source IP);
# agent-browser otherwise launches a non-installed Chrome-for-Testing.
CLEAN_PROXY="$(printf '%s' "${HTTPS_PROXY:-${HTTP_PROXY:-}}" | sed -E 's#^([a-z]+://)?[^@/]*@#\1#')"
# No proxy → an empty --proxy-server= sends Chromium direct, which egress blocks; fail fast.
if [ -z "${AGENT_BROWSER_ARGS:-}" ] && [ -z "$CLEAN_PROXY" ]; then
echo "browser: no egress proxy configured (HTTPS_PROXY/HTTP_PROXY unset)" >&2
exit 1
fi
export AGENT_BROWSER_EXECUTABLE_PATH="${AGENT_BROWSER_EXECUTABLE_PATH:-/usr/bin/chromium}"
export AGENT_BROWSER_ARGS="${AGENT_BROWSER_ARGS:---no-sandbox,--proxy-server=${CLEAN_PROXY},--proxy-bypass-list=127.0.0.1;localhost,--disable-dev-shm-usage,--disable-gpu}"
# Pin this session's browser (one pod hosts many; cwd is /workspace/sessions/<id>/).
SESSION_ID="$(pwd | sed -n 's#.*/sessions/\([0-9a-fA-F-]\{36\}\).*#\1#p')"
if [ -n "$SESSION_ID" ]; then
exec agent-browser --session "$SESSION_ID" "$@"
fi
exec agent-browser "$@"
@@ -19,6 +19,34 @@ if [ -z "${OPENCODE_SERVER_PASSWORD:-}" ]; then
echo "[entrypoint] WARNING: OPENCODE_SERVER_PASSWORD is empty — opencode serve will run without auth"
fi
# Trust the egress-proxy MITM CA in Chromium's NSS db (Chromium reads trust only
# from there) so browser HTTPS works. Best-effort — a failure, or no browser
# runtime, must not block boot.
import_proxy_ca() {
# Non-zero on no-import so the caller logs the "not imported" branch rather
# than falsely claiming success (a browser image with a broken CA mount).
command -v certutil >/dev/null 2>&1 || return 1
local bundle="${SANDBOX_PROXY_CA_BUNDLE_DST:-/etc/ssl/sandbox/ca-bundle.crt}"
[ -f "$bundle" ] || return 1
local nssdb="${HOME:-/home/sandbox}/.pki/nssdb"
mkdir -p "$nssdb"
[ -f "$nssdb/cert9.db" ] || certutil -d "sql:$nssdb" -N --empty-password
# The bundle is many roots; `certutil -A` imports one cert at a time, so split.
local splitdir imported=0 f
splitdir="$(mktemp -d)"
csplit -z -f "$splitdir/ca-" -b "%03d.pem" "$bundle" "/BEGIN CERTIFICATE/" "{*}" >/dev/null 2>&1 || true
for f in "$splitdir"/ca-*.pem; do
[ -f "$f" ] || continue
certutil -d "sql:$nssdb" -A -t "C,," -n "proxy-$(basename "$f" .pem)" -i "$f" 2>/dev/null \
&& imported=$((imported + 1))
done
rm -rf "$splitdir"
[ "$imported" -gt 0 ]
}
import_proxy_ca \
&& echo "[entrypoint] imported proxy CA into Chromium NSS db" \
|| echo "[entrypoint] proxy CA not imported into NSS (browser runtime absent or CA missing)"
backoff=1
max_backoff=30
+6
View File
@@ -36,6 +36,7 @@ from sqlalchemy.orm import Session
from onyx.db.enums import ExternalAppType
from onyx.image_gen.generation import is_image_generation_configured
from onyx.server.features.build.configs import ENABLE_BROWSER
# On-disk root for built-in skill content (one ``<skill_id>/`` dir each). Pushed
# to sandboxes at session setup; not baked into the sandbox image.
@@ -177,6 +178,11 @@ _REGISTRY: Final = BuiltInSkillRegistry(
),
),
SeededBuiltInProvider(skill_id="company-search"),
SeededBuiltInProvider(
skill_id="browser",
is_available=lambda _: ENABLE_BROWSER,
unavailable_reason="Browser runtime is not enabled for this deployment.",
),
ExternalAppBuiltInProvider(skill_id="slack", app_type=ExternalAppType.SLACK),
ExternalAppBuiltInProvider(skill_id="linear", app_type=ExternalAppType.LINEAR),
ExternalAppBuiltInProvider(
File diff suppressed because it is too large. Load diff
@@ -23,6 +23,7 @@ from sqlalchemy.orm import Session
from onyx.db.models import Skill
from onyx.db.models import User
from onyx.db.skill import fetch_skill_for_user
from onyx.db.skill import list_skills_for_sandbox_injection
from onyx.db.skill import list_skills_for_user
from onyx.error_handling.exceptions import OnyxError
from onyx.server.features.skill.mutation_helpers import ensure_custom_skill
@@ -108,6 +109,28 @@ class TestAvailabilityGate:
}
assert available_built_ins <= visible_built_ins
def test_browser_built_in_gated_on_enable_browser(
self,
db_session: Session,
test_user: User,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_seed_canonical(db_session)
monkeypatch.setattr(built_in_module, "ENABLE_BROWSER", False)
off = {
s.built_in_skill_id
for s in list_skills_for_sandbox_injection(test_user, db_session)
}
assert "browser" not in off
monkeypatch.setattr(built_in_module, "ENABLE_BROWSER", True)
on = {
s.built_in_skill_id
for s in list_skills_for_sandbox_injection(test_user, db_session)
}
assert "browser" in on
def test_unavailable_built_in_cannot_be_fetched_by_id(
self,
db_session: Session,
@@ -0,0 +1,48 @@
"""Browser-use test in the Craft k8s integration lane.
The agent's `browser` command has to work inside the locked-down sandbox pod,
which is the intersection of four things that have each broken before: Chromium
launching with --no-sandbox under the pod's dropped caps/seccomp, reaching the
public internet through the MITM egress proxy, and trusting the proxy CA for
HTTPS. A clean snapshot of a real HTTPS page exercises all of them at once
against a real pod + the chart's sandbox proxy.
"""
from __future__ import annotations
import pytest
from kubernetes import client
from onyx.server.features.build.configs import SANDBOX_BACKEND
from onyx.server.features.build.configs import SANDBOX_NAMESPACE
from onyx.server.features.build.configs import SandboxBackend
from tests.integration.tests.craft.k8s.k8s_fixtures import pod_exec
from tests.integration.tests.craft.k8s.k8s_fixtures import PoolSession
pytestmark = pytest.mark.skipif(
SANDBOX_BACKEND != SandboxBackend.KUBERNETES,
reason="K8s tests require SANDBOX_BACKEND=kubernetes; run in the dedicated K8s CI job.",
)
class TestBrowserUse:
def test_browser_fetches_https_through_egress_proxy(
self,
k8s_client: client.CoreV1Api,
pool_session: PoolSession,
) -> None:
_sandbox_id, session_id, pod_name = pool_session
# Run `browser` from the session workspace (its cwd is how the wrapper
# resolves --session), exactly as the agent would.
out = pod_exec(
k8s_client,
pod_name,
SANDBOX_NAMESPACE,
f"cd /workspace/sessions/{session_id} "
"&& browser open https://example.com && browser snapshot -i",
)
assert "Example Domain" in out, out
# Guard the specific MITM-cert failure mode that has regressed before.
assert "ERR_CERT_AUTHORITY_INVALID" not in out, out
+108
View File
@@ -0,0 +1,108 @@
# Browser use
Give the Craft **agent** a real browser it can drive for tasks `webfetch` can't
do — JS-rendered pages, clicking, forms, multi-step flows, screenshots, scraping,
logging into sites, and visually checking the app it's building.
This is **agent-only**: the browser is headless and the user does not see or
drive it. The webapp preview is unchanged (it remains the normal webapp preview).
There is no streamed browser, no human takeover, and no open-internet streaming.
## How it works
The agent drives a real Chromium (via [`vercel-labs/agent-browser`](https://github.com/vercel-labs/agent-browser),
a Rust daemon + CLI over CDP) from its `bash` tool. It works the accessibility
tree: `browser open <url>`, `browser snapshot -i` (compact `@eN` refs), then
`browser click/type` those refs — ~200–400 tokens per step instead of raw HTML.
### The `browser` command (`image/browser-cli.sh` → `/usr/local/bin/browser`)
A thin wrapper over `agent-browser`, baked into the sandbox image, that makes the
agent's life simple — it just uses the plain commands. The wrapper, per call:
- **Pins the session** — injects `--session <uuid>` resolved from the cwd
(`/workspace/sessions/<uuid>/`), so each session drives its own browser. One
pod hosts many sessions, so this can't be a global env; the cwd is the only
reliable per-call signal. Skipped if the caller already passed
`--session`/`--auto-connect`/`--cdp`.
- **Carries the Chromium env** — the invoking command launches Chromium when a
session has none, so it must carry `--no-sandbox` (the pod drops caps +
seccomp), the egress proxy as a `--proxy-server` flag (Chromium ignores
`*_PROXY` env; userinfo is stripped since the proxy authorizes by source IP),
and headless-stability flags. Defaults only — anything already exported wins.
Proxy-CA trust is **not** done here — it's a once-at-startup step in
`entrypoint.sh` (the main container, as uid 1000), which imports the egress-proxy
CA bundle into Chromium's per-user NSS db (`~/.pki/nssdb`) so HTTPS through the
MITM proxy is trusted. It runs in the main container (not the K8s
`firewall-init` initContainer, whose `/home/sandbox` isn't shared), splits the
bundle and imports every cert (`certutil -A -i` imports only the first), and is
a no-op without the browser runtime.
Why a real PATH executable and not a bashrc alias: opencode runs the agent's
`bash` tool in non-interactive shells, which don't source `~/.bashrc` or expand
aliases.
### The `browser` built-in skill
`backend/onyx/skills/builtin/browser/SKILL.md` is `agent-browser`'s own core
usage guide (pulled from `agent-browser skills get core --full`, version-matched
to the pinned CLI) with every `agent-browser` rewritten to `browser`, the install
lines dropped (pre-installed), and a short Onyx note (headless; pinned session;
no `--session`). It's a normal seeded built-in skill:
- Registered in `onyx/skills/built_in.py` with `is_available` keyed on
`ENABLE_BROWSER`.
- Row seeded by migration `c4e7b1a9f2d3_seed_browser_built_in_skill`.
- **Gated per-deployment**, not per-user: the registry's `is_available` returns
`ENABLE_BROWSER`, so `_exclude_unavailable_built_ins` hides the skill from
sandbox injection wherever the image was built without the browser runtime. No
PostHog flag — like `pptx`/`company-search`, it's a plain built-in skill; the
only question is whether the image has the `browser` command, which is a
deployment property, not a per-user one.
The agent discovers and uses it like any other built-in skill — it appears in the
AGENTS.md skills list with its description, and the agent reads its `SKILL.md`.
No dedicated AGENTS.md section is needed.
## Gating
`ENABLE_BROWSER` (`onyx/server/features/build/configs.py`, api-server runtime
env, default OFF): set it to match the sandbox image's build-time
`ENABLE_BROWSER` ARG. It's the single signal of "does this deployment's image
include the browser runtime," and gates the built-in `browser` skill via the
registry. There is no per-user feature flag.
## Image
`image/Dockerfile`, gated on `ENABLE_BROWSER=true` (default; set false in dev/CI
to skip ~400 MB): installs `chromium` (system, arm64-native — we point the daemon
at it via `AGENT_BROWSER_EXECUTABLE_PATH` rather than `agent-browser install`,
which fetches a no-arm64 Chrome-for-Testing) + `libnss3-tools` (certutil) +
`agent-browser` (npm global). The `browser` wrapper is always copied; it's inert
when `ENABLE_BROWSER=false` (agent-browser absent) and only advertised via the
per-user-gated skill.
## Browser state is ephemeral
agent-browser keeps per-session state (cookies, localStorage, the Chromium
profile) under a fixed `~/.agent-browser/` (= `/home/sandbox/.agent-browser/`,
outside the snapshotted `/workspace/sessions/<id>` volume). The wrapper passes
neither `--profile` nor `--restore`, so browser state is intentionally ephemeral
— it lives only for the daemon's lifetime and is **not** captured by session
snapshots; logins/cookies do not survive a pod reap or restore.
Making it persist would mean relocating agent-browser's data dir under
`/workspace` (no data-dir env override exists beyond `AGENT_BROWSER_SOCKET_DIR`;
the lever is `HOME`) and using `--restore` (cookies+localStorage only, not a full
`--profile`) — which writes auth cookies into the S3 snapshot. That's a
deliberate security/size tradeoff to revisit only if a "resume a logged-in
browser across reaps" use case appears.
## Not in scope
- No screencast / streamed browser to the frontend.
- No human-driven browser or pixel-input takeover.
- No socat forwarder, stream ports (8930/8931), WS proxy, or `/browser-input`
endpoint — the agent uses snapshot/click (CDP), which don't need the screencast.
- The webapp preview is untouched.