diff --git a/docs/superpowers/specs/2026-09-23-fortress-mcp-design.md b/docs/superpowers/specs/2026-09-23-fortress-mcp-design.md new file mode 100644 index 0000000..75fd864 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-fortress-mcp-design.md @@ -0,0 +1,191 @@ +# Fortress stealth browser via MCP + +## Goal + +Give the agent the [Fortress](https://github.com/tiliondev/fortress) stealth-browser tools (`fetch_protected_page`, `read_page`, `extract_page`, `search_web`, `screenshot_page`, `save_profile`, `run_browser_task`, and the rest of its 29 tools) by running its MCP server, `tilion-mcp`, as a stdio subprocess and exposing each tool as a Pi custom tool. The existing Playwright bridge stays. The two are toggled independently. + +Fortress is a Chromium fork that fixes the browser fingerprint inside the engine, so pages behind Cloudflare, DataDome, PerimeterX, or Akamai load instead of returning 403 or a challenge page. Its MCP tools also return bounded, clean markdown instead of raw HTML, which keeps token use down when the agent reads the web. + +## Non-goals + +- Fortress hosted mode (`TILION_BASE_URL` / `TILION_API_KEY`). Local engine only. +- Per-tool allow/block lists. Every tool the server reports is exposed. +- A Telegram command to toggle Fortress at runtime. It is configured with env vars and set at startup. +- Pointing Playwright MCP at the Fortress engine over CDP (option B from brainstorming). This can be added later without touching this design. +- macOS. The Fortress engine has no native macOS binary. The bot targets Docker (Linux x64) and Windows x64, and both are supported natively. + +## Architecture + +### 1. Generic MCP bridge: `src/agent/mcp-bridge.ts` + +The tool-wrapping and client code in `playwright-mcp.ts` moves into a generic module: + +```ts +export interface McpBridgeOptions { + label: string; // "playwright" | "fortress", used in log lines + command: string; // executable to spawn + args: string[]; + env?: Record; // merged over process.env for the child + toolPrefix?: string; // e.g. "fortress_"; omitted = names unchanged + logger: Logger; + clientFactory?: (spawn: SpawnSpec) => Promise; // test seam +} + +export interface SpawnSpec { command: string; args: string[]; env?: Record } + +export interface McpBridge { + tools: CustomTools; + dispose: () => Promise; +} + +export async function createMcpBridge(opts: McpBridgeOptions): Promise; +``` + +- `McpClientLike`, `McpToolDescriptor`, and `McpCallResult` move here unchanged. +- `wrapTool` takes the prefix. The Pi tool is named `prefix + t.name`, and `callTool` still receives the original `t.name`. +- The default client factory builds `StdioClientTransport({ command, args, env: { ...process.env, ...env }, stderr: "pipe" })`. The child's stderr is logged at `warn` as `${label} mcp: `. +- `dispose` closes the client and logs a warning on failure (same behavior as today). + +### 2. Playwright wrapper: `src/agent/playwright-mcp.ts` + +Keeps its public API (`createPlaywrightMcpBridge`, `PlaywrightMcpOptions`, `_internals.buildArgs`). It resolves the `@playwright/mcp` bin as it does today and calls `createMcpBridge({ label: "playwright", command: process.execPath, args: [binAbs, ...buildArgs(opts)] })` with no prefix, so tool names (`browser_*`) do not change. + +### 3. Fortress wrapper: `src/agent/fortress-mcp.ts` + +```ts +export interface FortressMcpOptions { + command: string[]; // argv, e.g. ["tilion-mcp"] or ["python", "-m", "tilion.mcp"] + headless: boolean; + writeDir: string; // absolute, inside the workspace + passthroughEnv: Record; // TILION_PROXY / TILION_REGION / CAPTCHA_API_KEY when set + logger: Logger; + clientFactory?: McpBridgeOptions["clientFactory"]; +} + +export async function createFortressMcpBridge(opts: FortressMcpOptions): Promise; +export const _internals = { buildEnv }; +``` + +- `buildEnv` returns `{ ...passthroughEnv, TILION_MCP_HEADLESS: headless ? "1" : "0", TILION_MCP_WRITE_DIR: writeDir }`. +- Before spawning, the bridge creates `writeDir` (`mkdir -p`). +- It calls `createMcpBridge({ label: "fortress", command: command[0], args: command.slice(1), env, toolPrefix: "fortress_" })`. + +The prefix avoids clashes with Playwright tools and tells the model which engine a tool drives. + +`TILION_MCP_WRITE_DIR` is set because `tilion-mcp` otherwise writes screenshots, saved pages, and downloads to `/tilion-mcp`. The `send` tool only accepts workspace-relative paths, so files there could not be sent to the owner. + +### 4. Config: `src/config.ts` + +```ts +export interface FortressConfig { + enabled: true; + command: string[]; + headless: boolean; + env: Record; +} +// Config gains: fortress?: FortressConfig; +``` + +| Variable | Default | Purpose | +| --- | --- | --- | +| `FORTRESS_ENABLED` | `false` | Enable the Fortress MCP bridge | +| `FORTRESS_COMMAND` | `tilion-mcp` | Command that starts the server, shell-style parsed with `parseArgsString`. On Windows without the console script on PATH: `python -m tilion.mcp` | +| `FORTRESS_HEADLESS` | `true` | `false` opens a visible window (native hosts only) | +| `TILION_PROXY` | | Passed through: egress proxy `http://user:pass@host:port` | +| `TILION_REGION` | | Passed through: region hint, e.g. `us` | +| `CAPTCHA_API_KEY` | | Passed through: enables `fortress_solve_captcha` and auto-solve in fetch | + +Only non-blank pass-through values go into `env`. They are passed explicitly instead of relying on `process.env` inheritance so the config object documents what Fortress receives. An empty `FORTRESS_COMMAND` after parsing falls back to the default. + +### 5. Wiring: `src/main.ts` + +After the Playwright block, and with the same shape: + +```ts +let fortressDispose: (() => Promise) | undefined; +if (config.fortress?.enabled) { + try { + const bridge = await createFortressMcpBridge({ + command: config.fortress.command, + headless: config.fortress.headless, + writeDir: path.join(config.workspaceDir, "fortress"), + passthroughEnv: config.fortress.env, + logger, + }); + customTools.push(...bridge.tools); + fortressDispose = bridge.dispose; + logger.info(`fortress tools enabled (${bridge.tools.length} tools)`); + } catch (err) { + logger.error("fortress bridge failed to start, continuing without stealth browser tools", err); + } +} else { + logger.info("fortress tools disabled (set FORTRESS_ENABLED=true to enable)"); +} +``` + +Shutdown calls `fortressDispose` next to `playwrightDispose`, and logs failures as warnings. + +`createPiBackend` receives `fortressEnabled: boolean`, which is true only if the bridge actually started, and passes it to `secretarySystemPrompt`. + +### 6. System prompt: `src/agent/system-prompt.ts` + +`secretarySystemPrompt(timezone, { fortress })`. When `fortress` is true it appends: + +``` +## Web access (Fortress) +- For reading or extracting web content, prefer fortress_read_page, fortress_extract_page, and fortress_search_web. They return clean, bounded markdown. +- When a site blocks you (403, Cloudflare/captcha page, empty JS shell), retry with fortress_fetch_protected_page. +- Use the browser_* tools for step-by-step interactive browsing when you need to watch the page. +- Fortress writes screenshots, saved pages, and downloads under fortress/ in the workspace. Send them with `send`. +``` + +When `fortress` is false the prompt stays byte-for-byte the same as today. + +## Failure mode + +A missing `tilion-mcp` or Python, a failed engine download, or an MCP handshake error rejects `createFortressMcpBridge`. `main.ts` logs the error and continues without Fortress tools, and the system prompt omits the Fortress section. The bot never fails to start because of Fortress. Tool-level errors come back from the server as `isError: true` results and are forwarded to the model as they are today. + +## Docker + +In the `Dockerfile`, after the Playwright install: + +```dockerfile +# Fortress stealth-browser MCP (FORTRESS_ENABLED=true). Isolated venv because +# Debian's system Python is externally managed (PEP 668). +RUN apt-get update && apt-get install -y --no-install-recommends python3-venv \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -m venv /opt/tilion \ + && /opt/tilion/bin/pip install --no-cache-dir "tilion[mcp]" +ENV PATH="/opt/tilion/bin:${PATH}" +``` + +The engine binary (about 300 MB) downloads on first run and is cached by `tilion-fortress`. During implementation, find the cache location: +- If a build-time fetch is available, pre-fetch in the image. +- Otherwise, set the cache to a path under a volume so the download survives container restarts. + +Record which one was used in the Dockerfile comment. Chromium runtime libraries are already present from `playwright install --with-deps chromium`. If the engine needs more, add them to the same `apt-get` line. + +## Docs + +- README env table: the three `FORTRESS_*` rows plus the pass-through rows. Add a short "Stealth browsing (Fortress)" section. +- `.env.example`: a commented Fortress block. +- `SETUP-WINDOWS.md`: `pip install "tilion[mcp]"` as an optional step, and the `FORTRESS_COMMAND=python -m tilion.mcp` fallback. + +## Testing + +- `mcp-bridge.test.ts`: the moved Playwright tests (wrapping, execute forwarding, dispose, dispose error). New cases: + - With `toolPrefix`, the Pi name is prefixed and `callTool` still gets the unprefixed name. + - `clientFactory` receives the `command`, `args`, and `env` it was given. +- `playwright-mcp.test.ts`: `buildArgs` tests stay. One test that the bridge is created without a prefix. +- `fortress-mcp.test.ts`: + - `buildEnv` covers headless 0/1, the write dir, and pass-through merge. + - `writeDir` is created. + - `command[0]` / `command.slice(1)` split correctly. + - Tools come out prefixed. +- `config.test.ts`: + - Disabled by default. + - `FORTRESS_COMMAND` parsing and default. + - `FORTRESS_HEADLESS`. + - Pass-through includes only non-blank vars. +- `system-prompt.test.ts`: the Fortress section is present when enabled and absent otherwise. +- Manual smoke test: in the Docker image with `FORTRESS_ENABLED=true`, the startup log shows `fortress tools enabled (29 tools)` and a Telegram request "read https://quotes.toscrape.com/js" returns the quotes.