From 7effd7c09559f94c03eb34ebe16e4766686d96f8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 05:47:18 +0000 Subject: [PATCH 1/2] feat(automate): Obscura-first fetch router, tron automate, and the hosted OpenMCP relay at /mcp/tron `tron automate` is one MCP server with an engine switch: Obscura (its own renderer + V8, tens of ms and ~30 MB on a light page) renders first, and the Chromium session takes over for JS-heavy pages, bot walls and logins, where a warm Chromium is 3-10x faster. Measured on the same pages before deciding: Obscura wins cold start and tiny pages, loses badly on GitHub/Wikipedia. SDK (packages/sdk/src/mcp): - obscura.ts: MCP client driving `obscura mcp` over stdio. Stdio on purpose: Obscura's HTTP transport stalls every request after the first on a keep-alive connection by ~4 s. - automate.ts: fetch_page (markdown|text|links|html, engine=auto|obscura| chromium, budget, wall/empty detection with fallback) + engine_status; an in-page markdown walker for the Chromium path. - chromium.ts: DirectChromium spawns a Chromium binary itself (headless, throwaway profile, kernel-picked DevTools port, isolated tab per call, idle shutdown, private-host resolver rules) for servers and boxes without the launcher. - http.ts: Streamable-HTTP transport (plain JSON, batches, 202 for notifications, optional bearer) + the OpenMCP descriptor at /.well-known/openmcp.json. - automate-bin.ts: `tron automate [mcp|serve|fetch|status]`; `tron mcp` gains fetch_page too. CLI/installer (apps/web/public/install.sh): `tron automate` case; the installer downloads the pinned Obscura release (two ~100 MB binaries, so not in the tarball) next to the launcher on install and upgrade, `ensure-obscura` subcommand, TB_NO_OBSCURA_INSTALL / TRONBROWSER_OBSCURA_VERSION. Hosted relay (services/api/src/mcp): tronbrowser.dev/mcp/tron, keyless and stateless (fetch_page + screenshot_page only), fresh tab per call, public targets only (name and resolved address), 3 concurrent, 30/min per caller, 1 MiB body, 400k chars, 45 s Obscura budget. Static descriptor at /.well-known/openmcp.json lists it on openmcp.logicsrc.com once registered. Dockerfile moves to bookworm with Caddy copied in, builds the API as a pnpm workspace member, and bakes in ungoogled-chromium 152.0.7977.82-1 (portable Linux build) and Obscura 0.2.2. Caddy proxies /mcp/* to the API. Tests: router (fallback on wall/error/timeout/missing, pinned engines, formats, truncation), Obscura client, HTTP transport + descriptor, DirectChromium (live half skips without a browser), CLI heredoc, installer hooks, relay guard (private ranges, DNS rebinding, rate limiter) and routes. Smoke script: services/api/scripts/mcp-smoke.mjs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018WGZpo8on6XLGYnccXV3dP --- Caddyfile | 8 + Dockerfile | 76 ++++-- README.md | 6 + apps/web/public/.well-known/openmcp.json | 12 + apps/web/public/install.sh | 84 +++++++ apps/web/test/install-automate.test.ts | 120 +++++++++ docs/mcp.md | 144 +++++++++-- packages/sdk/src/automate-bin.ts | 193 +++++++++++++++ packages/sdk/src/index.ts | 4 + packages/sdk/src/mcp-bin.ts | 9 +- packages/sdk/src/mcp/automate.test.ts | 260 ++++++++++++++++++++ packages/sdk/src/mcp/automate.ts | 296 +++++++++++++++++++++++ packages/sdk/src/mcp/chromium.test.ts | 76 ++++++ packages/sdk/src/mcp/chromium.ts | 260 ++++++++++++++++++++ packages/sdk/src/mcp/http.test.ts | 110 +++++++++ packages/sdk/src/mcp/http.ts | 194 +++++++++++++++ packages/sdk/src/mcp/index.ts | 9 +- packages/sdk/src/mcp/obscura.test.ts | 103 ++++++++ packages/sdk/src/mcp/obscura.ts | 231 ++++++++++++++++++ packages/sdk/src/mcp/server.ts | 5 +- packages/sdk/src/mcp/session.ts | 5 + pnpm-lock.yaml | 4 +- railway.json | 4 + services/api/package.json | 1 + services/api/scripts/mcp-smoke.mjs | 59 +++++ services/api/src/index.ts | 7 + services/api/src/mcp/guard.test.ts | 69 ++++++ services/api/src/mcp/guard.ts | 123 ++++++++++ services/api/src/mcp/tron.test.ts | 129 ++++++++++ services/api/src/mcp/tron.ts | 220 +++++++++++++++++ 30 files changed, 2781 insertions(+), 40 deletions(-) create mode 100644 apps/web/public/.well-known/openmcp.json create mode 100644 apps/web/test/install-automate.test.ts create mode 100644 packages/sdk/src/automate-bin.ts create mode 100644 packages/sdk/src/mcp/automate.test.ts create mode 100644 packages/sdk/src/mcp/automate.ts create mode 100644 packages/sdk/src/mcp/chromium.test.ts create mode 100644 packages/sdk/src/mcp/chromium.ts create mode 100644 packages/sdk/src/mcp/http.test.ts create mode 100644 packages/sdk/src/mcp/http.ts create mode 100644 packages/sdk/src/mcp/obscura.test.ts create mode 100644 packages/sdk/src/mcp/obscura.ts create mode 100644 services/api/scripts/mcp-smoke.mjs create mode 100644 services/api/src/mcp/guard.test.ts create mode 100644 services/api/src/mcp/guard.ts create mode 100644 services/api/src/mcp/tron.test.ts create mode 100644 services/api/src/mcp/tron.ts diff --git a/Caddyfile b/Caddyfile index 2a5fd914..d7889f7f 100644 --- a/Caddyfile +++ b/Caddyfile @@ -17,6 +17,12 @@ @api path /api/* /api reverse_proxy @api localhost:8090 + # OpenMCP relay (tronbrowser.dev/mcp/tron): same API process. Its descriptor + # is the static /.well-known/openmcp.json below, which is what makes the + # listing "verified" on a catalog (served from this origin, not registered). + @mcp path /mcp/* /mcp + reverse_proxy @mcp localhost:8090 + # Security headers on every response. header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -35,6 +41,8 @@ header /install.sh Content-Type "text/x-shellscript; charset=utf-8" header /sitemap.xml Content-Type "application/xml; charset=utf-8" header /manifest.json Content-Type "application/manifest+json; charset=utf-8" + header /.well-known/openmcp.json Content-Type "application/json; charset=utf-8" + header /.well-known/openmcp.json Access-Control-Allow-Origin "*" # Caching: long for images, SHORT for code/HTML so updates propagate fast # (no content hashing yet — don't let stale JS strand logged-in users). diff --git a/Dockerfile b/Dockerfile index 53aa94de..f0ae2fc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,67 @@ # Single container for tronbrowser.dev: Caddy serves the static site and -# reverse-proxies /api to the bundled Hono API (one service, one domain). -# Built from the monorepo root context. +# reverse-proxies /api and /mcp to the bundled Hono API (one service, one +# domain). Built from the monorepo root context. +# +# The API now carries the OpenMCP relay at /mcp/tron, so the image also holds +# its two engines: ungoogled-chromium (the portable Linux build, headless) and +# Obscura. Both are pinned below; bump the ARGs to move them. -# --- build the API --- -FROM node:24-slim AS api -WORKDIR /api -COPY services/api/package.json ./ -RUN npm install --no-audit --no-fund -COPY services/api/src ./src -RUN printf '%s' '{"compilerOptions":{"target":"ES2023","module":"NodeNext","moduleResolution":"NodeNext","outDir":"dist","rootDir":"src","strict":true,"skipLibCheck":true,"esModuleInterop":true},"include":["src"]}' > tsconfig.build.json \ - && npx tsc -p tsconfig.build.json \ - && npm prune --omit=dev +ARG UNGOOGLED_CHROMIUM_VERSION=152.0.7977.82-1 +ARG OBSCURA_VERSION=0.2.2 -# --- final: caddy + node --- -FROM caddy:2-alpine +# --- build the API (a pnpm workspace member: it imports @tronbrowser/sdk) --- +FROM node:24-bookworm-slim AS api +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate +WORKDIR /repo +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ +COPY packages/browser-core/package.json packages/browser-core/ +COPY packages/agent-runtime/package.json packages/agent-runtime/ +COPY packages/sdk/package.json packages/sdk/ +COPY services/api/package.json services/api/ +RUN pnpm install --frozen-lockfile --filter @tronbrowser/api... +COPY packages/browser-core packages/browser-core +COPY packages/agent-runtime packages/agent-runtime +COPY packages/sdk packages/sdk +COPY services/api services/api +RUN pnpm --filter @tronbrowser/browser-core --filter @tronbrowser/agent-runtime --filter @tronbrowser/sdk --filter @tronbrowser/api build \ + && pnpm --filter @tronbrowser/api deploy --prod /out + +# --- engines: fetched once at build time, not at boot --- +FROM debian:bookworm-slim AS engines +ARG UNGOOGLED_CHROMIUM_VERSION +ARG OBSCURA_VERSION +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils && rm -rf /var/lib/apt/lists/* +RUN set -eu; arch="$(uname -m)"; case "$arch" in x86_64) uc=x86_64; ob=x86_64 ;; aarch64) uc=arm64; ob=aarch64 ;; *) echo "unsupported arch $arch" >&2; exit 1 ;; esac; \ + mkdir -p /opt/ungoogled-chromium /opt/obscura; \ + curl -fsSL "https://github.com/ungoogled-software/ungoogled-chromium-portablelinux/releases/download/${UNGOOGLED_CHROMIUM_VERSION}/ungoogled-chromium-${UNGOOGLED_CHROMIUM_VERSION}-${uc}_linux.tar.xz" \ + | tar -xJ --strip-components=1 -C /opt/ungoogled-chromium; \ + test -x /opt/ungoogled-chromium/chrome; \ + curl -fsSL "https://github.com/h4ckf0r0day/obscura/releases/download/v${OBSCURA_VERSION}/obscura-${ob}-linux-stealth.tar.gz" \ + | tar -xz -C /opt/obscura; \ + test -x /opt/obscura/obscura + +# --- final: caddy + node + tor + the engines --- +# Debian rather than Alpine: the portable ungoogled-chromium and Obscura are +# glibc binaries. Caddy is a static binary, copied from its own image. +FROM node:24-bookworm-slim +COPY --from=caddy:2 /usr/bin/caddy /usr/bin/caddy # openssh-client: the store provisions BBS publisher accounts and generates # ed25519 keypairs via `ssh`/`ssh-keygen` (services/api/src/store/fileshost.ts). # tor: runs a Tor v3 hidden service in this same container so tronbrowser.dev is # reachable over a stable .onion (start.sh writes torrc and boots it). The onion # key persists on a Railway volume mounted at /var/lib/tor/hidden_service. -RUN apk add --no-cache nodejs openssh-client tor +# The lib* rows are what headless Chromium links against; fonts so text renders. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates openssh-client tor \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 \ + libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 \ + libcairo2 libatspi2.0-0 libxshmfence1 libx11-6 libx11-xcb1 libxcb1 libxext6 libglib2.0-0 \ + libdbus-1-3 libexpat1 fonts-liberation fonts-noto-color-emoji \ + && rm -rf /var/lib/apt/lists/* +COPY --from=engines /opt/ungoogled-chromium /opt/ungoogled-chromium +COPY --from=engines /opt/obscura /opt/obscura +ENV TRON_MCP_CHROMIUM_BIN=/opt/ungoogled-chromium/chrome \ + OBSCURA_BIN=/opt/obscura/obscura COPY Caddyfile /etc/caddy/Caddyfile COPY apps/web/public/ /srv/ # Extension store (tronbrowser.dev/store) — static frontend; dynamic bits hit @@ -29,9 +71,9 @@ COPY apps/extensions/public/ /srv/store/ # symlinks to them for local dev, but Docker COPY won't follow symlinks pointing # outside the copied dir — so copy the real files in (these override the links). COPY logo.svg favicon.svg hero.svg banner.png /srv/ -COPY --from=api /api/dist /api/dist -COPY --from=api /api/node_modules /api/node_modules -COPY --from=api /api/package.json /api/package.json +COPY --from=api /out/dist /api/dist +COPY --from=api /out/node_modules /api/node_modules +COPY --from=api /out/package.json /api/package.json # DB migrations run on boot (start.sh) so schema never drifts from the deploy. COPY scripts/db-migrate.mjs /api/db-migrate.mjs COPY packages/storage/migrations /api/migrations diff --git a/README.md b/README.md index ee8259d4..d5e6c65d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ Linux phones (Librem 5 / PinePhone / Ubuntu Touch). See to the cloud SQLite (Turso) or your own self-hosted backend. - Keeps all Chromium features: extensions, profiles, bookmarks, history, PWAs, DevTools. +- **`tron automate`** — an MCP server for agents: Obscura renders pages first + (fast, light), the Chromium session takes over for full-JS pages and bot + walls. Stdio for a local host, HTTP with an OpenMCP descriptor for a catalog. + The same relay runs hosted at **tronbrowser.dev/mcp/tron** (keyless + `fetch_page` + `screenshot_page`, listed on openmcp.logicsrc.com). See + [docs/mcp.md](docs/mcp.md). ## Monorepo diff --git a/apps/web/public/.well-known/openmcp.json b/apps/web/public/.well-known/openmcp.json new file mode 100644 index 00000000..1224878c --- /dev/null +++ b/apps/web/public/.well-known/openmcp.json @@ -0,0 +1,12 @@ +{ + "openmcp": "0.1", + "mcp": "https://tronbrowser.dev/mcp/tron", + "name": "TronBrowser", + "description": "Fetch any public web page as markdown, text, links or html, or take a screenshot, through TronBrowser: Obscura renders first (fast, light) and the ungoogled-chromium engine takes over for JS-heavy pages and bot walls. Keyless; a few fetches at a time.", + "url": "https://tronbrowser.dev", + "auth": { "kind": "none", "open": ["fetch_page", "screenshot_page"] }, + "tags": ["browser", "fetch", "scrape", "screenshot", "chromium", "ungoogled-chromium", "obscura", "web", "hosted"], + "operator": "https://logicsrc.com/.well-known/openprofile.md", + "tools": ["fetch_page", "screenshot_page"], + "catalogs": ["https://openmcp.logicsrc.com"] +} diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index 63e8b322..29290ec2 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -93,6 +93,12 @@ Usage: tron run