diff --git a/packages/agent-connector/src/adapters/claude.js b/packages/agent-connector/src/adapters/claude.js index 9eec83dcc..95d108a93 100644 --- a/packages/agent-connector/src/adapters/claude.js +++ b/packages/agent-connector/src/adapters/claude.js @@ -419,7 +419,14 @@ class ClaudeAdapter extends BaseAdapter { const cmd = [claudeBin, '-p', prompt, '--output-format', 'stream-json', '--verbose']; cmd.push('--append-system-prompt', systemPrompt); - cmd.push('--disallowedTools', 'AskUserQuestion', 'CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup'); + const disallowedTools = ['AskUserQuestion', 'CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup']; + if (browserEnabled) { + // Hard-ban the native WebFetch: it can't render JS and bypasses the + // workspace fetch chain. Prompt-level bans proved insufficient. + // WebSearch stays allowed (pure search, no page fetching). + disallowedTools.push('WebFetch'); + } + cmd.push('--disallowedTools', ...disallowedTools); // Resume existing conversation (skipped on retry after stale session) const sessionId = this._channelSessions[channelName]; @@ -493,8 +500,13 @@ class ClaudeAdapter extends BaseAdapter { mcpTools.push(`${pfx}workspace_list_files`, `${pfx}workspace_read_file`); mcpWriteTools.push(`${pfx}workspace_write_file`, `${pfx}workspace_delete_file`); } + if (!this.disabledModules.has('search')) { + mcpTools.push(`${pfx}workspace_image_search`); + mcpWriteTools.push(`${pfx}workspace_image_save`); + } if (!this.disabledModules.has('browser')) { mcpTools.push( + `${pfx}workspace_fetch_url`, `${pfx}workspace_browser_list_tabs`, `${pfx}workspace_browser_snapshot`, `${pfx}workspace_browser_screenshot` diff --git a/packages/agent-connector/src/adapters/workspace-prompt.js b/packages/agent-connector/src/adapters/workspace-prompt.js index 90385e7a8..a816a548a 100644 --- a/packages/agent-connector/src/adapters/workspace-prompt.js +++ b/packages/agent-connector/src/adapters/workspace-prompt.js @@ -30,10 +30,17 @@ function buildBrowserDirective(browserEnabled) { return ( '\n## Browser Use (MANDATORY)\n' + 'This workspace has the **shared Browser Fabric session** enabled. ' + - 'All web browsing MUST go through it so the user can watch the ' + - 'session live in their right-side panel and so cookies / state ' + - 'persist across agents.\n\n' + - '**Use ONLY these tools for any web browsing:**\n' + + 'All web browsing MUST go through the workspace tools so the user can ' + + 'watch the session live in their right-side panel and so cookies / ' + + 'state persist across agents.\n\n' + + '**To READ a web page, ALWAYS use `mcp__openagents-workspace__workspace_fetch_url` first.** ' + + 'It handles JavaScript-heavy pages (Notion, SPAs) automatically and does ' + + 'not consume a shared browser tab. Only open a shared browser tab when ' + + 'you need to interact with the page (click, type, log in) or when ' + + 'workspace_fetch_url reports AUTH_REQUIRED / BOT_CHALLENGE — in that ' + + 'case open the URL in a tab and ask a human to complete the login in ' + + 'the live view.\n\n' + + '**Tools for interactive browsing:**\n' + '- `mcp__openagents-workspace__workspace_browser_open`\n' + '- `mcp__openagents-workspace__workspace_browser_navigate`\n' + '- `mcp__openagents-workspace__workspace_browser_click`\n' + @@ -43,13 +50,19 @@ function buildBrowserDirective(browserEnabled) { '- `mcp__openagents-workspace__workspace_browser_list_tabs`\n' + '- `mcp__openagents-workspace__workspace_browser_close`\n' + '\n' + + 'Shared browser tabs are a limited per-workspace resource: close your ' + + 'tab (`workspace_browser_close`) as soon as you are done with it. Idle ' + + 'tabs are auto-closed after a few minutes.\n\n' + 'If you don\'t have these MCP tools, use `Bash` + `curl` against ' + - '`/v1/browser/tabs` (documented below in Shared Browser).\n\n' + + '`/v1/fetch` and `/v1/browser/tabs` (documented below in Shared Browser).\n\n' + '**FORBIDDEN — do NOT call any of these:**\n' + '- `mcp__browsermcp__*` (any local Browser MCP extension tool)\n' + '- `mcp__playwright__*`, `mcp__puppeteer__*`, `mcp__chrome-devtools__*`, or any other local-browser MCP\n' + - '- `WebFetch`, `WebSearch`, `web_fetch`, `web_search`, or any built-in network/browser tool\n' + + '- `WebFetch` / `web_fetch` — it cannot render JavaScript and fails on ' + + 'many pages; use `workspace_fetch_url` instead\n' + '\n' + + '`WebSearch` / `web_search` (pure search, no page fetching) IS allowed — ' + + 'but read the result URLs with `workspace_fetch_url`, not WebFetch.\n\n' + 'If a local browser tool errors with "extension isn\'t connected" or ' + '"connect your browser", do NOT ask the user to connect anything — ' + 'the local extension is irrelevant here. Immediately switch to the ' + @@ -207,6 +220,7 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel // Capabilities preamble const caps = []; if (!disabled.has('files')) caps.push('share and read files with other agents and users'); + if (!disabled.has('search')) caps.push('search the web for images and post them into the chat'); if (!disabled.has('browser')) caps.push('browse websites in a shared browser'); if (!disabled.has('knowledge')) caps.push('create and access a shared knowledge base'); caps.push('discover other agents in the workspace'); @@ -287,6 +301,34 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel sections.push(s); } + // Image search + if (!disabled.has('search')) { + let s = '\n### Image Search\n\n'; + s += ( + 'You CAN find images on the web and show them in the chat.\n\n' + + '**Search images:**\n' + + `${curl} -s -X POST ${baseUrl}/v1/search/images ` + + `-H "${h}" -H "Content-Type: application/json" ` + + `-d '{"query":"golden gate bridge","network":"${workspaceId}","count":10}'\n\n` + + '**To show an image in chat**, embed the result\'s `image_url` in your reply ' + + 'as markdown: `![title](image_url)` — it renders inline.\n\n' + ); + if (!isPlan) { + s += ( + '**To keep a copy in the workspace AND post it as an attachment** ' + + '(survives external links going dead):\n' + + `${curl} -s -X POST ${baseUrl}/v1/files/from_url ` + + `-H "${h}" -H "Content-Type: application/json" ` + + `-d '{"url":"IMAGE_URL","network":"${workspaceId}",` + + `"channel_name":"${channelName}","source":"openagents:${agentName}",` + + `"post_to_channel":true,"caption":"optional message text"}'\n\n` + + 'Mention the source page when you share images, and never present a ' + + 'search result as license-free.\n' + ); + } + sections.push(s); + } + // Browser if (!disabled.has('browser')) { let s = '\n### Shared Browser\n\n'; @@ -304,7 +346,14 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel if (!isPlan) { s += ( - '**To browse a website**, exec these steps (use exec for each):\n' + + '**To just READ a page (preferred — no tab needed, handles JS pages):**\n' + + `${curl} -s -X POST ${baseUrl}/v1/fetch ` + + `-H "${h}" -H "Content-Type: application/json" ` + + `-d '{"url":"https://example.com","network":"${workspaceId}",` + + `"source":"openagents:${agentName}"}'\n` + + 'If it returns error_code AUTH_REQUIRED or BOT_CHALLENGE, open the URL ' + + 'in a shared tab (below) and share its `live_url` so a human can log in.\n\n' + + '**To browse interactively** (click/type/login), exec these steps (use exec for each):\n' + `Step 1 — open tab: ` + `${curl} -s -X POST ${baseUrl}/v1/browser/tabs ` + `-H "${h}" -H "Content-Type: application/json" ` + @@ -314,7 +363,9 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel `${curl} -s -H "${h}" ${baseUrl}/v1/browser/tabs/TAB_ID/snapshot\n` + `Step 3 — close tab: ` + `${curl} -s -X DELETE -H "${h}" ${baseUrl}/v1/browser/tabs/TAB_ID\n` + - '(Replace TAB_ID with the `id` from the step 1 response)\n\n' + '(Replace TAB_ID with the `id` from the step 1 response)\n' + + 'Tabs are a limited per-workspace resource — always close yours when done; ' + + 'idle tabs are auto-closed after a few minutes.\n\n' ); } diff --git a/packages/agent-connector/src/mcp-server.js b/packages/agent-connector/src/mcp-server.js index 77c8a1f9a..d50b44e19 100644 --- a/packages/agent-connector/src/mcp-server.js +++ b/packages/agent-connector/src/mcp-server.js @@ -100,9 +100,66 @@ function buildToolDefs(disabledModules) { ); } + // -- Search module -- + if (!disabledModules.has('search')) { + tools.push( + { + name: 'workspace_image_search', + description: + 'Search the web for images. Returns image URLs you can show in chat directly by embedding ' + + 'markdown — ![title](image_url) — in your reply, or persist with workspace_image_save.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Image search query' }, + count: { type: 'number', description: 'Number of results (default 10, max 20)' }, + }, + required: ['query'], + }, + }, + { + name: 'workspace_image_save', + description: + 'Download an image (or any file) URL into workspace storage. By default it is also posted ' + + 'into the current channel as an inline image attachment (set post_to_channel=false to only save).', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'Direct image/file URL (e.g. image_url from workspace_image_search)' }, + filename: { type: 'string', description: 'Filename to store as (derived from URL if omitted)' }, + caption: { type: 'string', description: 'Message text to accompany the posted image' }, + post_to_channel: { type: 'boolean', description: 'Post into the chat as an attachment (default true)' }, + }, + required: ['url'], + }, + }, + ); + } + // -- Browser module -- if (!disabledModules.has('browser')) { tools.push( + { + name: 'workspace_fetch_url', + description: + 'Read a web page as text WITHOUT opening a shared browser tab. Preferred way to read any URL: ' + + 'tries a fast static fetch first, and automatically renders JavaScript-heavy pages (Notion, SPAs) ' + + 'in a temporary browser session. Only open a shared browser tab (workspace_browser_open) when you ' + + 'need to interact with the page (click/type/login) or the result says AUTH_REQUIRED.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'URL to fetch (http/https)' }, + mode: { + type: 'string', + enum: ['auto', 'static', 'render'], + description: 'auto (default): static first, browser render if needed. static: never use a browser. render: force browser rendering.', + }, + max_chars: { type: 'number', description: 'Max characters of content to return (default 20000)' }, + }, + required: ['url'], + }, + }, { name: 'workspace_browser_open', description: @@ -629,6 +686,61 @@ class McpServer { // ── Browser ── + case 'workspace_fetch_url': { + try { + const result = await this.ws.fetchUrl(this.workspaceId, this.token, args.url, { + mode: args.mode || 'auto', + maxChars: args.max_chars, + source: `openagents:${this.agentName}`, + }); + const header = [ + `URL: ${result.url}`, + result.title ? `Title: ${result.title}` : null, + `Source: ${result.content_source}${result.truncated ? ' (truncated)' : ''}`, + ].filter(Boolean).join('\n'); + return text(`${header}\n\n${result.content || '(empty page)'}`); + } catch (e) { + const detail = e.data || {}; + const parts = [`Fetch failed: ${e.message}`]; + if (detail.error_code) parts.push(`Code: ${detail.error_code}`); + if (detail.hint) parts.push(`Hint: ${detail.hint}`); + return text(parts.join('\n')); + } + } + + case 'workspace_image_search': { + const data = await this.ws.searchImages(this.workspaceId, this.token, args.query, { + count: args.count || 10, + }); + const results = (data && data.results) || []; + if (!results.length) return text(`No image results for "${args.query}".`); + const lines = results.map((r, i) => { + const dims = r.width && r.height ? ` (${r.width}x${r.height})` : ''; + return `${i + 1}. ${r.title || 'untitled'}${dims}\n` + + ` image_url: ${r.image_url}\n` + + ` source: ${r.page_url || r.source || 'unknown'}`; + }); + return text( + `Found ${results.length} images for "${args.query}":\n\n${lines.join('\n')}\n\n` + + 'To show one in chat, embed it in your reply as markdown: ![title](image_url). ' + + 'To keep a copy in the workspace (and post it as an attachment), call workspace_image_save with the image_url.' + ); + } + + case 'workspace_image_save': { + const result = await this.ws.uploadFileFromUrl(this.workspaceId, this.token, args.url, { + filename: args.filename, + channelName: this.channelName, + source: `openagents:${this.agentName}`, + postToChannel: args.post_to_channel !== false, + caption: args.caption, + }); + const posted = result.posted_to_channel + ? ' and posted to the channel' + : ''; + return text(`Saved ${result.filename} (${result.content_type}, ${result.size} bytes, id: ${result.id})${posted}.`); + } + case 'workspace_browser_open': { const opts = { url: args.url || 'about:blank', diff --git a/packages/agent-connector/src/workspace-client.js b/packages/agent-connector/src/workspace-client.js index 81dd1edb6..7df4109af 100644 --- a/packages/agent-connector/src/workspace-client.js +++ b/packages/agent-connector/src/workspace-client.js @@ -471,6 +471,31 @@ class WorkspaceClient { return data.data || data; } + /** + * Search the web for images via POST /v1/search/images. + */ + async searchImages(workspaceId, token, query, { count = 10 } = {}) { + const body = { query, network: workspaceId, count }; + const data = await this._post('/v1/search/images', body, this._wsHeaders(token), 30000); + return data.data || data; + } + + /** + * Download a URL into workspace storage via POST /v1/files/from_url. + * With postToChannel, the file is also posted into the chat as an + * inline attachment. + */ + async uploadFileFromUrl(workspaceId, token, url, { + filename, channelName, source = 'human:user', postToChannel = false, caption, + } = {}) { + const body = { url, network: workspaceId, source, post_to_channel: postToChannel }; + if (filename) body.filename = filename; + if (channelName) body.channel_name = channelName; + if (caption) body.caption = caption; + const data = await this._post('/v1/files/from_url', body, this._wsHeaders(token), 90000); + return data.data || data; + } + /** * List files via GET /v1/files. */ @@ -583,6 +608,19 @@ class WorkspaceClient { return data.data || data; } + /** + * Server-side fetch chain via POST /v1/fetch — reads a page WITHOUT + * holding a shared browser tab. Static HTTP first; JS-heavy pages are + * rendered in an ephemeral browser session that is closed immediately. + */ + async fetchUrl(workspaceId, token, url, { mode = 'auto', maxChars, source } = {}) { + const body = { url, network: workspaceId, mode }; + if (maxChars) body.max_chars = maxChars; + if (source) body.source = source; + const data = await this._post('/v1/fetch', body, this._wsHeaders(token), 90000); + return data.data || data; + } + /** * List persistent browser contexts via GET /v1/browser/contexts. */ @@ -878,7 +916,11 @@ class WorkspaceClient { if (typeof msg === 'string' && msg.toLowerCase().includes('session_revoked')) { reject(new SessionRevokedError(msg)); } else { - reject(new Error(msg)); + const err = new Error(msg); + // Preserve structured error details (error_code, hint, + // quota occupancy, ...) so tool handlers can surface them. + if (parsed.data && typeof parsed.data === 'object') err.data = parsed.data; + reject(err); } } else { resolve(parsed); diff --git a/sdk/src/openagents/adapters/workspace_prompt.py b/sdk/src/openagents/adapters/workspace_prompt.py index eee392141..55def7562 100644 --- a/sdk/src/openagents/adapters/workspace_prompt.py +++ b/sdk/src/openagents/adapters/workspace_prompt.py @@ -30,10 +30,17 @@ def build_browser_directive(browser_enabled: bool) -> str: return ( "\n## Browser Use (MANDATORY)\n" "This workspace has the **shared Browser Fabric session** enabled. " - "All web browsing MUST go through it so the user can watch the " - "session live in their right-side panel and so cookies / state " - "persist across agents.\n\n" - "**Use ONLY these tools for any web browsing:**\n" + "All web browsing MUST go through the workspace tools so the user can " + "watch the session live in their right-side panel and so cookies / " + "state persist across agents.\n\n" + "**To READ a web page, ALWAYS use `mcp__openagents-workspace__workspace_fetch_url` first.** " + "It handles JavaScript-heavy pages (Notion, SPAs) automatically and does " + "not consume a shared browser tab. Only open a shared browser tab when " + "you need to interact with the page (click, type, log in) or when " + "workspace_fetch_url reports AUTH_REQUIRED / BOT_CHALLENGE — in that " + "case open the URL in a tab and ask a human to complete the login in " + "the live view.\n\n" + "**Tools for interactive browsing:**\n" "- `mcp__openagents-workspace__workspace_browser_open`\n" "- `mcp__openagents-workspace__workspace_browser_navigate`\n" "- `mcp__openagents-workspace__workspace_browser_click`\n" @@ -43,13 +50,19 @@ def build_browser_directive(browser_enabled: bool) -> str: "- `mcp__openagents-workspace__workspace_browser_list_tabs`\n" "- `mcp__openagents-workspace__workspace_browser_close`\n" "\n" + "Shared browser tabs are a limited per-workspace resource: close your " + "tab (`workspace_browser_close`) as soon as you are done with it. Idle " + "tabs are auto-closed after a few minutes.\n\n" "If you don't have these MCP tools, use `Bash` + `curl` against " - "`/v1/browser/tabs` (documented below in Shared Browser).\n\n" + "`/v1/fetch` and `/v1/browser/tabs` (documented below in Shared Browser).\n\n" "**FORBIDDEN — do NOT call any of these:**\n" "- `mcp__browsermcp__*` (any local Browser MCP extension tool)\n" "- `mcp__playwright__*`, `mcp__puppeteer__*`, `mcp__chrome-devtools__*`, or any other local-browser MCP\n" - "- `WebFetch`, `WebSearch`, `web_fetch`, `web_search`, or any built-in network/browser tool\n" + "- `WebFetch` / `web_fetch` — it cannot render JavaScript and fails on " + "many pages; use `workspace_fetch_url` instead\n" "\n" + "`WebSearch` / `web_search` (pure search, no page fetching) IS allowed — " + "but read the result URLs with `workspace_fetch_url`, not WebFetch.\n\n" "If a local browser tool errors with \"extension isn't connected\" or " "\"connect your browser\", do NOT ask the user to connect anything — " "the local extension is irrelevant here. Immediately switch to the " @@ -138,6 +151,8 @@ def build_api_skills_prompt( caps = [] if "files" not in _disabled: caps.append("share and read files with other agents and users") + if "search" not in _disabled: + caps.append("search the web for images and post them into the chat") if "browser" not in _disabled: caps.append("browse websites in a shared browser") caps.append("discover other agents in the workspace") @@ -196,6 +211,32 @@ def build_api_skills_prompt( sections.append(s) + # ── Image search ── + if "search" not in _disabled: + s = "\n### Image Search\n\n" + s += ( + "You CAN find images on the web and show them in the chat.\n\n" + "**Search images:**\n" + f"curl -s -X POST {base_url}/v1/search/images " + f'-H "{h}" -H "Content-Type: application/json" ' + f'-d \'{{"query":"golden gate bridge","network":"{workspace_id}","count":10}}\'\n\n' + "**To show an image in chat**, embed the result's `image_url` in your reply " + "as markdown: `![title](image_url)` — it renders inline.\n\n" + ) + if not is_plan: + s += ( + "**To keep a copy in the workspace AND post it as an attachment** " + "(survives external links going dead):\n" + f"curl -s -X POST {base_url}/v1/files/from_url " + f'-H "{h}" -H "Content-Type: application/json" ' + f'-d \'{{"url":"IMAGE_URL","network":"{workspace_id}",' + f'"channel_name":"{channel_name}","source":"openagents:{agent_name}",' + f'"post_to_channel":true,"caption":"optional message text"}}\'\n\n' + "Mention the source page when you share images, and never present a " + "search result as license-free.\n" + ) + sections.append(s) + # ── Browser ── if "browser" not in _disabled: s = "\n### Shared Browser\n\n" @@ -402,6 +443,8 @@ def _build_opencode_api_skills_prompt( caps = [] if "files" not in _disabled: caps.append("share and read files with other agents and users") + if "search" not in _disabled: + caps.append("search the web for images and post them into the chat") if "browser" not in _disabled: caps.append("browse websites in a shared browser") caps.append("discover other agents in the workspace") @@ -458,13 +501,45 @@ def _build_opencode_api_skills_prompt( sections.append(s) + # ── Image search ── + if "search" not in _disabled: + s = "\n### Image Search\n\n" + s += ( + "You CAN find images on the web and show them in the chat.\n\n" + "**Search images:**\n" + f"curl -s -X POST {base_url}/v1/search/images " + f'-H "{h}" -H "Content-Type: application/json" ' + f'-d \'{{"query":"golden gate bridge","network":"{workspace_id}","count":10}}\'\n\n' + "**To show an image in chat**, embed the result's `image_url` in your reply " + "as markdown: `![title](image_url)` — it renders inline.\n\n" + ) + if not is_plan: + s += ( + "**To keep a copy in the workspace AND post it as an attachment:**\n" + f"curl -s -X POST {base_url}/v1/files/from_url " + f'-H "{h}" -H "Content-Type: application/json" ' + f'-d \'{{"url":"IMAGE_URL","network":"{workspace_id}",' + f'"channel_name":"{channel_name}","source":"openagents:{agent_name}",' + f'"post_to_channel":true,"caption":"optional message text"}}\'\n\n' + "Mention the source page when you share images, and never present a " + "search result as license-free.\n" + ) + sections.append(s) + # ── Browser ── if "browser" not in _disabled: s = "\n### Shared Browser\n\n" if not is_plan: s += ( - "**To browse a website**, run these steps in bash:\n" + "**To just READ a page (preferred — no tab needed, handles JS pages):**\n" + f"curl -s -X POST {base_url}/v1/fetch " + f'-H "{h}" -H "Content-Type: application/json" ' + f'-d \'{{"url":"https://example.com","network":"{workspace_id}",' + f'"source":"openagents:{agent_name}"}}\'\n' + "If it returns error_code AUTH_REQUIRED or BOT_CHALLENGE, open the URL " + "in a shared tab (below) and share its `live_url` so a human can log in.\n\n" + "**To browse interactively** (click/type/login), run these steps in bash:\n" f"Step 1 — open tab: " f"curl -s -X POST {base_url}/v1/browser/tabs " f'-H "{h}" -H "Content-Type: application/json" ' @@ -474,7 +549,9 @@ def _build_opencode_api_skills_prompt( f'curl -s -H "{h}" {base_url}/v1/browser/tabs/TAB_ID/snapshot\n' f"Step 3 — close tab: " f'curl -s -X DELETE -H "{h}" {base_url}/v1/browser/tabs/TAB_ID\n' - f"(Replace TAB_ID with the id from step 1 response)\n\n" + f"(Replace TAB_ID with the id from step 1 response)\n" + "Tabs are a limited per-workspace resource — always close yours when done; " + "idle tabs are auto-closed after a few minutes.\n\n" ) s += ( diff --git a/workspace/backend/app/browser.py b/workspace/backend/app/browser.py index ae4ff2c60..7662760db 100644 --- a/workspace/backend/app/browser.py +++ b/workspace/backend/app/browser.py @@ -16,6 +16,8 @@ import httpx from app.browser_creds import redact +from app.browser_egress import DENY_MARKER_HEADER, EgressPolicyProxy +from app.net_security import UnsafeURLError, validate_public_url logger = logging.getLogger(__name__) @@ -25,6 +27,73 @@ BROWSERFABRIC_URL = os.environ.get("BROWSERFABRIC_URL", "https://api.browserfabric.com") BROWSERFABRIC_PROVISION_SECRET = os.environ.get("BROWSERFABRIC_PROVISION_SECRET", "") +# render_page_text: let a JS page settle before snapshotting, and retry while it +# still looks like an empty shell. +RENDER_SETTLE_SECONDS = float(os.environ.get("RENDER_SETTLE_SECONDS", "1.5")) +RENDER_SETTLE_ATTEMPTS = int(os.environ.get("RENDER_SETTLE_ATTEMPTS", "3")) +RENDER_MIN_TEXT_CHARS = int(os.environ.get("RENDER_MIN_TEXT_CHARS", "50")) + +# Enumerate the images the DOM actually resolved, so a rendered read can hand +# back the same assets a static read extracts from markup. currentSrc is what +# the browser picked out of any srcset; src is the fallback before layout. +# Bounded here rather than in the caller so a pathological page can't return a +# multi-megabyte list across the process boundary. +RENDER_IMAGE_JS = ( + "Array.from(document.images).slice(0, 60)" + ".map(function (i) { return {url: i.currentSrc || i.src || '', alt: i.alt || ''}; })" + ".filter(function (x) { return x.url; })" +) + +# Chromium's own sandbox is a second containment layer under the egress proxy: +# it is what keeps a renderer compromise (from a page an agent chose) inside +# the renderer process. It needs a non-root user plus unprivileged user +# namespaces in the container, which the current backend image does not +# provide, so it stays opt-in — flipping the default here without changing the +# image would make every browser launch fail. +BROWSER_SANDBOX = os.environ.get("BROWSER_SANDBOX", "").lower() in ("1", "true", "yes") + +# The only non-http(s) URL a tab may hold: the blank page a tab is opened on +# before anywhere real is navigated to. +BLANK_PAGE = "about:blank" + + +async def guard_browser_url(url: Optional[str]) -> str: + """Policy gate for every URL a browser is asked to load. + + Raises UnsafeURLError for anything that is not a public http(s) target. + `about:blank` is allowed through as an exact match only — it is the + default a tab opens on, and it reaches no network — while any other + non-http(s) URL (file://, chrome://, data:, view-source:, ...) is refused. + + This is the entry check. It is not the whole defence: once a page is + loaded, where it navigates next is constrained by the egress proxy, not + by this function. + """ + if not url or url.strip() == "" or url.strip() == BLANK_PAGE: + return BLANK_PAGE + await validate_public_url(url) + return url + + +class BrowserNavigationError(RuntimeError): + """Navigation failure with a machine-readable code the agent can act on.""" + + def __init__(self, code: str, message: str): + self.code = code + super().__init__(message) + + +def classify_navigation_error(exc: Exception) -> str: + """Map a raw navigation exception to a stable error code.""" + low = str(exc).lower() + if "err_name_not_resolved" in low or "err_cert" in low or "ssl" in low or "tls" in low or "dns" in low: + return "DNS_OR_TLS_ERROR" + if "timeout" in low or "timed out" in low: + return "NAV_TIMEOUT" + if "err_blocked" in low or "err_connection_refused" in low or "403" in low: + return "CONTENT_BLOCKED" + return "NAVIGATION_FAILED" + class BrowserManager: """Singleton managing shared browser tabs via Browser Fabric or local Playwright.""" @@ -40,6 +109,7 @@ def __init__(self): self._sessions: dict = {} # tab_id -> Browser Fabric session id self._live_urls: dict = {} # tab_id -> Browser Fabric share URL self._tab_keys: dict = {} # tab_id -> per-workspace BF API key + self._egress_proxy = None # EgressPolicyProxy (local mode only) @classmethod def get(cls) -> "BrowserManager": @@ -127,13 +197,26 @@ async def _ensure_playwright(self): from playwright.async_api import async_playwright self._playwright = await async_playwright().start() + async def _ensure_egress_proxy(self): + """Start (once) the policy proxy every local browser request goes through.""" + if self._egress_proxy is None: + self._egress_proxy = EgressPolicyProxy() + await self._egress_proxy.start() + return self._egress_proxy + async def _ensure_local_browser(self): if self._browser and self._browser.is_connected(): return await self._ensure_playwright() + proxy = await self._ensure_egress_proxy() + # chromium_sandbox is the switch that actually decides this: Playwright + # defaults it to False and injects --no-sandbox itself, so merely + # leaving that flag out of `args` would keep the sandbox off while + # looking like it had been enabled. self._browser = await self._playwright.chromium.launch( headless=True, - args=["--no-sandbox", "--disable-setuid-sandbox"], + args=list(proxy.chromium_args()), + chromium_sandbox=BROWSER_SANDBOX, ) # ------------------------------------------------------------------ @@ -158,7 +241,11 @@ async def _prune_dead_sessions(self) -> int: return len(dead) async def open_tab(self, tab_id: str, url: str = "about:blank", bb_context_id: str = None, api_key: str = None) -> dict: - """Create a new browser tab. Returns {url, title}.""" + """Create a new browser tab. Returns {url, title}. + + Raises UnsafeURLError if `url` is not a public http(s) target. + """ + url = await guard_browser_url(url) if api_key: self._tab_keys[tab_id] = api_key async with self._global_lock: @@ -226,7 +313,11 @@ async def open_tab(self, tab_id: str, url: str = "about:blank", bb_context_id: s return {"url": page.url, "title": title, "warnings": warnings} async def navigate(self, tab_id: str, url: str) -> dict: - """Navigate a tab to a URL. Returns {url, title}.""" + """Navigate a tab to a URL. Returns {url, title}. + + Raises UnsafeURLError if `url` is not a public http(s) target. + """ + url = await guard_browser_url(url) if self._is_cloud_tab(tab_id): session_id = self._get_session(tab_id) try: @@ -396,6 +487,124 @@ async def close_tab(self, tab_id: str, session_id_hint: str = None, api_key: str pass return True, None + async def render_page_text(self, url: str, api_key: str = None) -> dict: + """One-shot render for the fetch chain: create a session/page, navigate, + snapshot, close. Never registers a tab, so it doesn't consume the + workspace tab quota. Returns {url, title, text}. + + Raises BrowserNavigationError on navigation failure, UnsafeURLError if + the entry URL is not a public http(s) target. + + Validating the entry URL constrains only the first request. Everything + the page does afterwards — redirects, meta-refresh, JS navigation, + XHR, iframes, subresources, WebSockets — is constrained in local mode + by the egress proxy (see app.browser_egress), which is the actual + boundary. In Browser Fabric mode the navigation happens on BF's + infrastructure and only this entry check applies. + """ + url = await guard_browser_url(url) + if url == BLANK_PAGE: + raise BrowserNavigationError("NAVIGATION_FAILED", "No URL to render") + if self.is_cloud_for(api_key): + key = api_key or BROWSERFABRIC_API_KEY + result = await self._bf_call("create_session", {"headless": True}, api_key=key) + session_id = result["result"]["session_id"] + try: + try: + await self._bf_call( + "navigate", {"url": url, "wait_until": "domcontentloaded"}, session_id, api_key=key + ) + except Exception as e: + raise BrowserNavigationError(classify_navigation_error(e), str(e)[:500]) from e + # domcontentloaded fires before client-side frameworks (Notion, + # SPAs) paint. Snapshot after a short settle and retry while the + # page is still an empty shell. + text = "" + for _ in range(RENDER_SETTLE_ATTEMPTS): + await asyncio.sleep(RENDER_SETTLE_SECONDS) + snap = await self._bf_call("snapshot", {}, session_id, api_key=key) + text = snap.get("result", {}).get("snapshot", "") or "" + if len(text.strip()) >= RENDER_MIN_TEXT_CHARS: + break + info = await self._bf_call("get_page_info", {}, session_id, api_key=key) + page_info = info.get("result", {}) + images = [] + try: + shot = await self._bf_call( + "evaluate_js", {"expression": RENDER_IMAGE_JS}, session_id, api_key=key + ) + images = shot.get("result", {}).get("result") or [] + except Exception as e: + # Images are a bonus on top of the text read; never fail + # the render because the page wouldn't enumerate them. + logger.debug("Image enumeration failed for %s: %s", url, e) + return { + "url": page_info.get("url", url), + "title": page_info.get("title", ""), + "text": text, + "images": images, + } + finally: + try: + await self._bf_call("close_session", {}, session_id, api_key=key) + except Exception as e: + logger.warning("Failed to close ephemeral BF session %s: %s", session_id, e) + else: + async with self._global_lock: + await self._ensure_local_browser() + page = await self._browser.new_page() + + # A policy denial arrives as an ordinary 403 and page.goto() does + # not raise on HTTP error status, so without this the refusal page + # would be scraped and returned as if it were the article. Only + # main-frame navigations are treated as fatal: a page whose image + # or analytics call was refused still has real content worth + # returning. + blocked_navigations = [] + deny_token = self._egress_proxy.deny_token if self._egress_proxy else None + + def _note_blocked(response): + try: + # Compare the token, not just the header name. Any site the + # policy allows could set this header itself, and would + # otherwise be able to make its own page look like a + # blocked internal address. + if not deny_token or response.headers.get(DENY_MARKER_HEADER) != deny_token: + return + # response.frame is the frame that issued the request, so an + # image in the top document also reports the main frame. + # Only a navigation of the main frame replaces the content + # this function is about to return. + if response.frame is page.main_frame and response.request.is_navigation_request(): + blocked_navigations.append(response.url) + except Exception: + pass + + page.on("response", _note_blocked) + try: + try: + await page.goto(url, wait_until="domcontentloaded", timeout=30000) + except Exception as e: + raise BrowserNavigationError(classify_navigation_error(e), str(e)[:500]) from e + await page.wait_for_timeout(1500) # let client-side rendering settle + if blocked_navigations: + raise UnsafeURLError( + "BLOCKED_PRIVATE_ADDRESS", + "The page navigated to a non-public address, which was blocked", + ) + text = await page.inner_text("body") + try: + images = await page.evaluate(RENDER_IMAGE_JS) + except Exception as e: + logger.debug("Image enumeration failed for %s: %s", url, e) + images = [] + return {"url": page.url, "title": await page.title(), "text": text, "images": images} + finally: + try: + await page.close() + except Exception: + pass + async def shutdown(self) -> None: """Close all tabs and the browser.""" for tab_id in list(self._sessions.keys()) + list(self._pages.keys()): @@ -412,6 +621,12 @@ async def shutdown(self) -> None: except Exception: pass self._playwright = None + if self._egress_proxy: + try: + await self._egress_proxy.stop() + except Exception: + pass + self._egress_proxy = None # ------------------------------------------------------------------ # Reconnection (serverless / cold-start recovery) diff --git a/workspace/backend/app/browser_egress.py b/workspace/backend/app/browser_egress.py new file mode 100644 index 000000000..f026d94cf --- /dev/null +++ b/workspace/backend/app/browser_egress.py @@ -0,0 +1,508 @@ +# -*- coding: utf-8 -*- +""" +Egress policy proxy for the local (Playwright) browser. + +Validating the URL a browser is *told* to open constrains nothing about where +it goes next: a 302, a meta-refresh, a JS navigation, an XHR, an iframe, an +image, or a WebSocket can all reach an internal address without any top-level +navigation happening. Intercepting inside the page (Playwright routing) means +enumerating every one of those mechanisms, still leaves service workers and +WebSockets needing their own handling, and cannot close the gap between the +interceptor's DNS lookup and Chromium's own. + +So the boundary is put below the browser instead. Chromium is launched with +`--proxy-server` pointing here, which turns every request it makes — of every +kind — into one chokepoint, and stops it from resolving DNS at all: the name +arrives here in the request line or CONNECT target, this proxy resolves it +exactly once via `resolve_and_validate`, and connects to that pinned address. + +Deny is the default: anything this proxy cannot parse, resolve, or prove +public gets a 403 and no connection is made. + +One gotcha this depends on: Chromium bypasses the proxy for loopback targets +unless it is launched with `--proxy-bypass-list=<-loopback>`. Without that +flag `http://127.0.0.1:...` goes direct and never reaches this code, which +would leave the backend's own API reachable. `chromium_proxy_args()` below is +the only supported way to build the flags, so the two can't drift apart. +""" + +import asyncio +import logging +import secrets +from typing import Optional + +from app.net_security import UnsafeURLError, resolve_and_validate + +logger = logging.getLogger(__name__) + +_MAX_HEADER_BYTES = 64 * 1024 +_HEADER_TIMEOUT_SECONDS = 15.0 +_CONNECT_TIMEOUT_SECONDS = 15.0 +# An upstream that never closes must not be able to hold a relay task open +# indefinitely; the plain-HTTP path is bounded even when framing is absent. +_EXCHANGE_TIMEOUT_SECONDS = 120.0 + +# A denial is an ordinary 403, and page.goto() does not raise on HTTP error +# status — so without a marker the browser would simply render the refusal +# text and the caller would report it as a successful page load. This header +# is what lets BrowserManager tell "we refused this" apart from "the site +# returned 403". +# +# Its VALUE is a per-proxy random token, not a fixed constant. Any site the +# policy allows could otherwise return this header and make an ordinary page +# look like a blocked internal address, turning a public page into a forged +# SSRF rejection. Over HTTPS the proxy cannot see, let alone strip, an +# upstream's headers, so the marker has to be unforgeable rather than merely +# stripped. +DENY_MARKER_HEADER = "x-workspace-egress-blocked" + +_DENY_TEXT = b"Blocked by workspace egress policy: destination is not public" + + +def _deny_response(token: str) -> bytes: + return ( + b"HTTP/1.1 403 Forbidden\r\n" + b"Content-Type: text/plain; charset=utf-8\r\n" + b"X-Workspace-Egress-Blocked: " + token.encode("ascii") + b"\r\n" + b"Content-Length: " + str(len(_DENY_TEXT)).encode() + b"\r\n" + b"Connection: close\r\n" + b"\r\n" + _DENY_TEXT + ) + + +def _bad_gateway(reason: str) -> bytes: + body = f"Egress proxy could not reach the destination: {reason}".encode("utf-8") + return ( + b"HTTP/1.1 502 Bad Gateway\r\n" + b"Content-Type: text/plain; charset=utf-8\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"Connection: close\r\n" + b"\r\n" + body + ) + + +def _split_host_port(authority: str, default_port: int) -> tuple: + """Split 'host:port' / '[v6]:port' / 'host' into (host, port).""" + authority = authority.strip() + if authority.startswith("["): + close = authority.find("]") + if close == -1: + raise ValueError("malformed IPv6 authority") + host = authority[1:close] + rest = authority[close + 1:] + port = int(rest[1:]) if rest.startswith(":") and rest[1:].isdigit() else default_port + return host, port + if ":" in authority: + host, _, port_str = authority.rpartition(":") + if port_str.isdigit(): + return host, int(port_str) + raise ValueError("malformed port") + return authority, default_port + + +def _target_from_absolute_url(target: str) -> tuple: + """Parse the absolute-form request target proxies receive for plain HTTP + ('GET http://host/path HTTP/1.1') into (host, port, path).""" + if "://" not in target: + raise ValueError("expected absolute-form request target") + scheme, _, rest = target.partition("://") + if scheme.lower() not in ("http", "https"): + raise ValueError(f"unsupported scheme '{scheme}'") + authority, slash, path = rest.partition("/") + default_port = 443 if scheme.lower() == "https" else 80 + host, port = _split_host_port(authority, default_port) + return host, port, (("/" + path) if slash else "/") + + +# Connection-management headers. These are meaningful only between two +# adjacent HTTP peers and must not be relayed. Transfer-Encoding is +# deliberately NOT in this set: the body framing it describes is relayed +# verbatim, so dropping the header would leave the receiver unable to parse +# what it is being sent. +# +# Upgrade is stripped on purpose. A 101 response would turn this path back +# into an unframed tunnel, which is exactly what must not happen here. +# Chromium tunnels ws:// through CONNECT anyway, so nothing legitimate needs +# an in-band upgrade. +_CONNECTION_HEADERS = { + b"connection", b"proxy-connection", b"proxy-authorization", b"proxy-authenticate", + b"keep-alive", b"te", b"trailer", b"upgrade", +} + + +def _parse_headers(header_block: bytes) -> dict: + """Parse a raw header block into {lowercased name: value}, both bytes.""" + parsed = {} + for line in header_block.split(b"\r\n"): + if not line or b":" not in line: + continue + name, _, value = line.partition(b":") + parsed[name.strip().lower()] = value.strip() + return parsed + + +def _rewrite_headers(header_block: bytes, *, close: bool = True) -> bytes: + """Strip connection-management headers and pin the connection closed. + + Every request must be individually validated, which means this proxy can + never let a second request ride a connection it has already spliced to an + upstream. Closing after one exchange, in both directions, is what makes + that structurally true rather than a property of the upstream's manners. + + close=False is for interim 1xx responses, where announcing a close would + contradict the final response still to come on the same connection. + """ + kept = [ + line for line in header_block.split(b"\r\n") + if line and line.split(b":", 1)[0].strip().lower() not in _CONNECTION_HEADERS + ] + if close: + kept.append(b"Connection: close") + return b"\r\n".join(kept) + b"\r\n" if kept else b"" + + +async def _relay_exact(src: asyncio.StreamReader, dst: asyncio.StreamWriter, count: int) -> None: + remaining = count + while remaining > 0: + chunk = await src.read(min(65536, remaining)) + if not chunk: + return + dst.write(chunk) + await dst.drain() + remaining -= len(chunk) + + +async def _relay_chunked(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None: + """Relay a chunked body, stopping at the terminal chunk rather than at EOF.""" + while True: + size_line = await src.readline() + if not size_line: + return + dst.write(size_line) + await dst.drain() + try: + size = int(size_line.split(b";")[0].strip(), 16) + except ValueError: + return + if size == 0: + while True: # trailers, terminated by a blank line + trailer = await src.readline() + if not trailer: + return + dst.write(trailer) + await dst.drain() + if trailer in (b"\r\n", b"\n"): + return + await _relay_exact(src, dst, size) + # readexactly, not read -- read(2) is free to return a single byte when + # the CR and LF land in different TCP segments, and the stray LF would + # then be consumed as the next chunk-size line and truncate the body. + try: + terminator = await src.readexactly(2) + except asyncio.IncompleteReadError: + return + if terminator != b"\r\n": + return + dst.write(terminator) + await dst.drain() + + +async def _relay_until_eof(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None: + while True: + data = await src.read(65536) + if not data: + return + dst.write(data) + await dst.drain() + + +async def _relay_body(src, dst, headers: dict, *, read_to_eof_if_unframed: bool) -> None: + """Relay exactly one message body, using the framing its headers declare.""" + if b"chunked" in headers.get(b"transfer-encoding", b"").lower(): + await _relay_chunked(src, dst) + elif b"content-length" in headers: + try: + length = int(headers[b"content-length"]) + except ValueError: + return + await _relay_exact(src, dst, length) + elif read_to_eof_if_unframed: + await _relay_until_eof(src, dst) + + +async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None: + try: + while True: + data = await src.read(65536) + if not data: + break + dst.write(data) + await dst.drain() + except (ConnectionError, asyncio.CancelledError, OSError): + pass + finally: + try: + dst.close() + except Exception: + pass + + +class EgressPolicyProxy: + """A loopback HTTP/HTTPS proxy that only forwards to public addresses.""" + + def __init__(self): + self._server: Optional[asyncio.AbstractServer] = None + self._port: Optional[int] = None + # Proves a denial came from this proxy. Regenerated per instance so a + # value seen by one workspace's page is useless anywhere else. + self.deny_token = secrets.token_urlsafe(24) + self.blocked_count = 0 + self.allowed_count = 0 + + @property + def port(self) -> Optional[int]: + return self._port + + async def start(self) -> int: + if self._server is not None: + return self._port + # Bind to an ephemeral port on loopback only: each uvicorn worker runs + # its own proxy for its own browser, and nothing off-box can use it. + self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0) + self._port = self._server.sockets[0].getsockname()[1] + logger.info("Browser egress policy proxy listening on 127.0.0.1:%d", self._port) + return self._port + + async def stop(self) -> None: + if self._server is None: + return + self._server.close() + try: + await self._server.wait_closed() + except Exception: + pass + self._server = None + self._port = None + + def chromium_args(self) -> list: + """Launch flags that route this browser's traffic through the proxy. + + `<-loopback>` removes loopback from Chromium's implicit bypass list. + Without it, 127.0.0.1 and localhost are fetched directly and never + reach the policy check. + """ + if self._port is None: + return [] + return [ + f"--proxy-server=http://127.0.0.1:{self._port}", + "--proxy-bypass-list=<-loopback>", + ] + + # ------------------------------------------------------------------ + + async def _deny(self, writer: asyncio.StreamWriter, host: str, port: int, reason: str) -> None: + self.blocked_count += 1 + logger.warning("Egress policy blocked browser request to %s:%s (%s)", host, port, reason) + try: + writer.write(_deny_response(self.deny_token)) + await writer.drain() + except (ConnectionError, OSError): + pass + writer.close() + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + await self._handle_inner(reader, writer) + except Exception: + logger.exception("Egress proxy connection failed") + try: + writer.close() + except Exception: + pass + + async def _handle_inner(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + request_line = await asyncio.wait_for( + reader.readline(), timeout=_HEADER_TIMEOUT_SECONDS + ) + except (asyncio.TimeoutError, ConnectionError): + writer.close() + return + if not request_line: + writer.close() + return + + parts = request_line.decode("latin-1").strip().split(" ") + if len(parts) < 3: + await self._deny(writer, "?", 0, "malformed request line") + return + method, target = parts[0].upper(), parts[1] + + header_block = bytearray() + while True: + try: + line = await asyncio.wait_for(reader.readline(), timeout=_HEADER_TIMEOUT_SECONDS) + except (asyncio.TimeoutError, ConnectionError): + writer.close() + return + if not line or line in (b"\r\n", b"\n"): + break + header_block += line + if len(header_block) > _MAX_HEADER_BYTES: + await self._deny(writer, "?", 0, "header block too large") + return + + if method == "CONNECT": + # HTTPS and WebSocket-over-TLS. Policy is enforced on the tunnel + # target and the bytes are then relayed untouched, so the browser's + # own certificate verification is unaffected — no MITM here. + try: + host, port = _split_host_port(target, 443) + except ValueError as e: + await self._deny(writer, target, 0, str(e)) + return + await self._tunnel(reader, writer, host, port) + return + + try: + host, port, path = _target_from_absolute_url(target) + except ValueError as e: + await self._deny(writer, target, 0, str(e)) + return + await self._forward(reader, writer, method, host, port, path, bytes(header_block)) + + async def _pin(self, writer: asyncio.StreamWriter, host: str, port: int) -> Optional[str]: + """Return the pinned public IP, or None after writing a denial.""" + try: + return await resolve_and_validate(host, port) + except UnsafeURLError as e: + await self._deny(writer, host, port, e.code) + return None + except Exception as e: # fail closed on anything unexpected + await self._deny(writer, host, port, f"policy error: {type(e).__name__}") + return None + + async def _tunnel(self, reader, writer, host: str, port: int) -> None: + pinned = await self._pin(writer, host, port) + if pinned is None: + return + try: + up_reader, up_writer = await asyncio.wait_for( + asyncio.open_connection(pinned, port), timeout=_CONNECT_TIMEOUT_SECONDS + ) + except (OSError, asyncio.TimeoutError) as e: + writer.write(_bad_gateway(type(e).__name__)) + await writer.drain() + writer.close() + return + + self.allowed_count += 1 + writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await writer.drain() + await asyncio.gather( + _pipe(reader, up_writer), _pipe(up_reader, writer), return_exceptions=True + ) + + async def _forward(self, reader, writer, method: str, host: str, port: int, + path: str, header_block: bytes) -> None: + pinned = await self._pin(writer, host, port) + if pinned is None: + return + try: + up_reader, up_writer = await asyncio.wait_for( + asyncio.open_connection(pinned, port), timeout=_CONNECT_TIMEOUT_SECONDS + ) + except (OSError, asyncio.TimeoutError) as e: + writer.write(_bad_gateway(type(e).__name__)) + await writer.drain() + writer.close() + return + + self.allowed_count += 1 + try: + await asyncio.wait_for( + self._exchange(reader, writer, up_reader, up_writer, method, path, header_block), + timeout=_EXCHANGE_TIMEOUT_SECONDS, + ) + except (asyncio.TimeoutError, ConnectionError, OSError): + pass + finally: + for w in (up_writer, writer): + try: + w.close() + except Exception: + pass + + async def _exchange(self, reader, writer, up_reader, up_writer, + method: str, path: str, header_block: bytes) -> None: + """Relay exactly one request and one response, then let the caller close. + + Nothing is read from the browser after this request's body. A second + request arriving on this connection is therefore never forwarded + anywhere — it cannot reach the upstream this socket is already spliced + to, which is what would let it skip the destination check entirely. + """ + request_headers = _parse_headers(header_block) + # Re-emit in origin form. The client's Host header is preserved so + # virtual-hosted origins still resolve correctly; only the request + # target is rewritten and connection headers are replaced. + up_writer.write(f"{method} {path} HTTP/1.1\r\n".encode("latin-1")) + up_writer.write(_rewrite_headers(header_block)) + up_writer.write(b"\r\n") + await up_writer.drain() + + # Expect/100-continue is answered here rather than forwarded. Waiting + # for a body the browser will not send until it is told to continue, + # while the upstream waits for that body, deadlocks the exchange. + if b"100-continue" in request_headers.get(b"expect", b"").lower(): + writer.write(b"HTTP/1.1 100 Continue\r\n\r\n") + await writer.drain() + await _relay_body(reader, up_writer, request_headers, read_to_eof_if_unframed=False) + + while True: + status_line, raw_headers, response_headers = await self._read_message_head(up_reader) + if status_line is None: + return + try: + status = int(status_line.split(b" ")[1]) + except (IndexError, ValueError): + status = 0 + + # 101 would hand the rest of the connection to the upstream as an + # opaque tunnel, which is the framing this path exists to keep. + # Upgrade is already stripped from the request, so an upstream + # offering one is answering something nobody asked for. + if status == 101: + logger.warning("Egress proxy refused an unrequested protocol upgrade") + return + + # 1xx is informational -- 103 Early Hints in particular precedes the + # real response. Relay it and keep reading rather than treating it + # as the end of the exchange. + interim = 100 <= status < 200 + writer.write(status_line) + writer.write(_rewrite_headers(raw_headers, close=not interim)) + writer.write(b"\r\n") + await writer.drain() + if interim: + continue + + if method == "HEAD" or status in (204, 304): + return + await _relay_body(up_reader, writer, response_headers, read_to_eof_if_unframed=True) + return + + async def _read_message_head(self, reader) -> tuple: + """Read a status line plus header block. Returns (None, ...) at EOF.""" + status_line = await reader.readline() + if not status_line: + return None, b"", {} + raw_headers = bytearray() + while True: + line = await reader.readline() + if not line or line in (b"\r\n", b"\n"): + break + raw_headers += line + if len(raw_headers) > _MAX_HEADER_BYTES: + return None, b"", {} + return status_line, bytes(raw_headers), _parse_headers(bytes(raw_headers)) diff --git a/workspace/backend/app/main.py b/workspace/backend/app/main.py index 042436322..5c083ab1c 100644 --- a/workspace/backend/app/main.py +++ b/workspace/backend/app/main.py @@ -17,7 +17,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.config import config -from app.routers import account, browser, cloud_agents, devices, events, files, knowledge, network, notifications, routines, shares, timers, todos, workspaces +from app.routers import account, browser, cloud_agents, devices, events, fetch, files, knowledge, network, notifications, routines, search, shares, timers, todos, workspaces logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -501,11 +501,13 @@ async def _log_validation_errors(request: Request, exc: RequestValidationError): app.include_router(cloud_agents.router) app.include_router(devices.router) app.include_router(events.router) +app.include_router(fetch.router) app.include_router(files.router) app.include_router(knowledge.router) app.include_router(network.router) app.include_router(notifications.router) app.include_router(routines.router) +app.include_router(search.router) app.include_router(shares.router) app.include_router(todos.router) app.include_router(timers.router) diff --git a/workspace/backend/app/net_security.py b/workspace/backend/app/net_security.py new file mode 100644 index 000000000..faacbde46 --- /dev/null +++ b/workspace/backend/app/net_security.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +""" +SSRF-safe outbound networking for agent-triggered fetches. + +Anything that fetches a URL chosen by a workspace agent (POST /v1/fetch, +POST /v1/files/from_url) MUST go through here. A raw httpx.get would let an +agent reach the backend's own network: cloud metadata (169.254.169.254), +localhost, and private/internal services. + +Protections: + - scheme restricted to http/https, destination port restricted to an allowlist + - URLs with embedded credentials rejected + - every hostname is resolved and ALL resolved IPs must be public + (private / loopback / link-local / reserved / multicast are blocked, + including IPv4-mapped IPv6) + - the validated IP is PINNED and connected to directly, so the name is + resolved exactly once — a DNS rebind between validation and connect can't + swing the destination to an internal address + - redirects are followed manually and re-validated (and re-pinned) per hop + - each hop gets its own connection pool, so two hostnames sharing an IP can + never reuse one another's connection (which would carry the wrong TLS SNI) + - trust_env=False so backend proxy env vars can't redirect the request + - streamed with an enforced byte cap so a huge body can't OOM the worker + +The browser render path cannot use this module (Chromium does its own +networking); it is constrained by the egress proxy in app.browser_egress, +which enforces the same policy via `resolve_and_validate`. +""" + +import asyncio +import ipaddress +import logging +import os +from http.cookiejar import CookieJar +from typing import Optional +from urllib.parse import urljoin, urlparse + +import httpx + +logger = logging.getLogger(__name__) + +ALLOWED_SCHEMES = {"http", "https"} +DEFAULT_MAX_REDIRECTS = 4 + +# The User-Agent for every agent-triggered outbound fetch. Defined here, next +# to safe_fetch, because both callers (/v1/fetch and /v1/files/from_url) must +# send the same one: a page read and the image download that follows it come +# from the same logical client, and two drifting strings mean a site can serve +# the article but refuse its images. +# +# The platform token is load-bearing, not cosmetic. mp.weixin.qq.com serves a +# "当前环境异常" interstitial instead of the article to non-browser agents, and +# it treats "X11; Linux x86_64" as one of them: measured over four requests +# each from the same IP, the Linux token was blocked 4/4 while the Windows and +# macOS tokens succeeded 4/4. The trailing product token is fine to keep (3/3 +# with it), so we stay honest about who is calling. +OUTBOUND_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Safari/537.36 OpenAgentsFetch/1.0" +) + + +def _parse_port_allowlist() -> frozenset: + """Destination ports agents may reach. Restricting this stops the fetch + tools from being used to probe non-HTTP services (SSH, SMTP, Redis, ...) + on hosts that are technically public.""" + raw = os.environ.get("OUTBOUND_ALLOWED_PORTS", "80,443,8080,8443") + ports = set() + for part in raw.split(","): + part = part.strip() + if part.isdigit(): + ports.add(int(part)) + return frozenset(ports or {80, 443}) + + +ALLOWED_PORTS = _parse_port_allowlist() + + +class UnsafeURLError(Exception): + """A URL was rejected before or during fetching. Carries a stable code.""" + + def __init__(self, code: str, message: str): + self.code = code + super().__init__(message) + + +def _ip_is_public(ip: ipaddress._BaseAddress) -> bool: + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: + ip = ip.ipv4_mapped + return not ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + or ip.is_unspecified + ) + + +async def _resolve_ips(host: str, port: int) -> list: + """Resolve `host` to a de-duplicated, order-preserving list of IP literals.""" + loop = asyncio.get_event_loop() + try: + infos = await loop.getaddrinfo(host, port, proto=0, type=0) + except OSError as e: + raise UnsafeURLError("DNS_RESOLUTION_FAILED", f"Could not resolve host '{host}': {e}") from e + ips = [] + for info in infos: + ip_str = info[4][0] + if ip_str not in ips: + ips.append(ip_str) + return ips + + +async def resolve_and_validate(host: str, port: int) -> str: + """Resolve `host` and return the single IP literal to connect to. + + Every address the name resolves to must be public — a name that returns + both a public and a private address is rejected outright rather than + having a "good" address picked out of it, since which one a later + resolution would pick is not ours to control. + + The returned IP is what callers must actually connect to. Resolving here + and connecting elsewhere by name would reopen the rebinding window this + function exists to close. + """ + try: + literal = ipaddress.ip_address(host) + except ValueError: + literal = None + + if literal is not None: + if not _ip_is_public(literal): + logger.warning("Blocked SSRF attempt to non-public literal %s", host) + raise UnsafeURLError("BLOCKED_PRIVATE_ADDRESS", "Host resolves to a non-public address") + ips = [str(literal)] + else: + # Address policy is checked before port policy so a request aimed at an + # internal host reports BLOCKED_PRIVATE_ADDRESS — the code worth + # alerting on — rather than being masked by a port rejection. + ips = list(await _resolve_ips(host, port)) + if not ips: + raise UnsafeURLError("DNS_RESOLUTION_FAILED", f"Host '{host}' did not resolve") + for ip_str in ips: + if not _ip_is_public(ipaddress.ip_address(ip_str)): + # The resolved address is deliberately kept out of the + # caller-facing message: echoing it back turns this endpoint + # into an internal-DNS mapping oracle for the agent. + logger.warning("Blocked SSRF attempt: %s resolved to non-public %s", host, ip_str) + raise UnsafeURLError( + "BLOCKED_PRIVATE_ADDRESS", + f"Host '{host}' resolves to a non-public address", + ) + + if port not in ALLOWED_PORTS: + raise UnsafeURLError( + "BLOCKED_PORT", + f"Port {port} is not allowed; permitted ports: {sorted(ALLOWED_PORTS)}", + ) + return ips[0] + + +def _split_url(url: str) -> tuple: + """Return (scheme, host, port) after scheme/credential/host checks.""" + parsed = urlparse(url) + if parsed.scheme not in ALLOWED_SCHEMES: + raise UnsafeURLError("UNSUPPORTED_SCHEME", "Only http(s) URLs are supported") + if parsed.username or parsed.password: + raise UnsafeURLError("URL_CREDENTIALS_NOT_ALLOWED", "URLs with embedded credentials are not allowed") + host = parsed.hostname + if not host: + raise UnsafeURLError("INVALID_URL", "URL has no host") + try: + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except ValueError: + raise UnsafeURLError("INVALID_URL", "URL has an invalid port") + return parsed.scheme, host, port + + +async def validate_public_url(url: str) -> str: + """Raise UnsafeURLError unless `url` is an http(s) URL on an allowed port + that resolves only to public addresses and carries no embedded credentials. + + Returns the pinned IP, so a caller that is about to connect can use it + instead of resolving the name a second time. + """ + _scheme, host, port = _split_url(url) + return await resolve_and_validate(host, port) + + +class _PinnedTransport(httpx.AsyncHTTPTransport): + """Connects to a pre-validated IP while keeping the request's logical + identity: the Host header and the TLS SNI / certificate hostname stay the + original name, so HTTPS still verifies against the domain the caller asked + for — only the address the socket goes to is fixed. + """ + + def __init__(self, pinned_ip: str, **kwargs): + super().__init__(**kwargs) + self._pinned_ip = pinned_ip + + async def handle_async_request(self, request: httpx.Request): + logical_url = request.url + original_host = logical_url.host + original_authority = request.headers.get("host") or logical_url.netloc.decode("ascii") + # Host header must survive the rewrite or virtual-hosted origins 404. + request.headers["Host"] = original_authority + # server_hostname for the TLS handshake: drives both SNI and the + # certificate hostname check. Without it the cert would be validated + # against the bare IP and every HTTPS fetch would fail. + request.extensions = dict(request.extensions or {}) + request.extensions["sni_hostname"] = original_host + request.url = logical_url.copy_with(host=self._pinned_ip, port=logical_url.port) + try: + return await super().handle_async_request(request) + finally: + # The rewrite has to be undone before httpx sees the response. + # Everything above this layer reads identity off the request URL -- + # cookie ownership most of all, which would otherwise be filed + # under the pinned address and never sent back to the real host. + request.url = logical_url + + +class SafeFetchResult: + __slots__ = ("content", "status_code", "headers", "final_url", "truncated") + + def __init__(self, content, status_code, headers, final_url, truncated): + self.content = content + self.status_code = status_code + self.headers = headers + self.final_url = final_url + self.truncated = truncated + + @property + def content_type(self) -> str: + return (self.headers.get("content-type", "") or "").split(";")[0].strip().lower() + + +async def safe_fetch( + url: str, + *, + max_bytes: int, + timeout: float, + headers: Optional[dict] = None, + truncate: bool = False, + max_redirects: int = DEFAULT_MAX_REDIRECTS, +) -> SafeFetchResult: + """SSRF-safe GET with manual, re-validated, IP-pinned redirects and a + streamed byte cap. + + truncate=True -> stop reading at max_bytes and flag `truncated` (text reads). + truncate=False -> raise UnsafeURLError('RESPONSE_TOO_LARGE') past max_bytes. + """ + request_headers = dict(headers or {}) + current = url + # Cookies have to outlive the per-hop clients below: plenty of sites set a + # session cookie on the redirect and expect it back on the landing page, + # and httpx keeps its jar on the client. This must be a raw CookieJar -- + # handing httpx a Cookies instance makes it copy the cookies into a fresh + # jar per client, which would silently drop everything a later hop sets. + # A CookieJar is adopted by reference, and its domain and path rules still + # decide what actually gets sent. + cookie_jar = CookieJar() + for _ in range(max_redirects + 1): + pinned_ip = await validate_public_url(current) + # One client (one connection pool) per hop. Sharing a pool across hops + # would let a later hop reuse a connection opened for a different + # hostname that happens to share this IP, carrying the wrong TLS SNI. + async with httpx.AsyncClient( + transport=_PinnedTransport(pinned_ip, trust_env=False), + follow_redirects=False, + trust_env=False, + timeout=timeout, + cookies=cookie_jar, + ) as client: + async with client.stream("GET", current, headers=request_headers) as resp: + if resp.is_redirect: + location = resp.headers.get("location") + if not location: + raise UnsafeURLError("NAVIGATION_FAILED", "Redirect without a Location header") + # Resolve the next hop against the logical URL, never the + # pinned-IP URL, so relative Locations keep the real host. + current = urljoin(current, location) + continue + + declared = resp.headers.get("content-length") + if declared and declared.isdigit() and int(declared) > max_bytes and not truncate: + raise UnsafeURLError("RESPONSE_TOO_LARGE", f"Response is {int(declared)} bytes; limit is {max_bytes}") + + chunks = [] + total = 0 + truncated = False + async for chunk in resp.aiter_bytes(): + total += len(chunk) + if total > max_bytes: + if truncate: + chunks.append(chunk[: len(chunk) - (total - max_bytes)]) + truncated = True + break + raise UnsafeURLError("RESPONSE_TOO_LARGE", f"Response exceeded {max_bytes} bytes") + chunks.append(chunk) + + return SafeFetchResult( + content=b"".join(chunks), + status_code=resp.status_code, + headers=resp.headers, + final_url=current, + truncated=truncated, + ) + raise UnsafeURLError("TOO_MANY_REDIRECTS", f"Exceeded {max_redirects} redirects") diff --git a/workspace/backend/app/routers/browser.py b/workspace/backend/app/routers/browser.py index 1af3c59ec..e306b9774 100644 --- a/workspace/backend/app/routers/browser.py +++ b/workspace/backend/app/routers/browser.py @@ -37,6 +37,7 @@ resolve_tab_key, ) from app.database import get_db +from app.net_security import UnsafeURLError from app.models import BrowserContext, BrowserTab, BrowserUsage, Workspace from app.response import ResponseCode, json_response, success_response from app.routers.network import ( @@ -439,6 +440,8 @@ async def open_tab( try: result = await manager.open_tab(tab_id, body.url or "about:blank", bb_context_id=bb_context_id, api_key=bf_key) + except UnsafeURLError as e: + return json_response(ResponseCode.BAD_REQUEST, str(e), data={"error_code": e.code}) except RuntimeError as e: return json_response(ResponseCode.BAD_REQUEST, str(e)) except Exception as e: @@ -619,6 +622,8 @@ async def navigate_tab( manager = BrowserManager.get() try: result = await manager.navigate(tab_id, body.url) + except UnsafeURLError as e: + return json_response(ResponseCode.BAD_REQUEST, str(e), data={"error_code": e.code}) except KeyError: return json_response(ResponseCode.NOT_FOUND, "Browser tab not found in browser") except Exception as e: diff --git a/workspace/backend/app/routers/fetch.py b/workspace/backend/app/routers/fetch.py new file mode 100644 index 000000000..1bc0cea29 --- /dev/null +++ b/workspace/backend/app/routers/fetch.py @@ -0,0 +1,694 @@ +# -*- coding: utf-8 -*- +""" +Server-side URL fetch chain for agents. + +POST /v1/fetch — read a web page without holding a shared-browser tab: + + 1. Static HTTP GET + text extraction (fast, no browser). + 2. If the page is a JS shell (Notion, Next.js apps, ...), render it in an + ephemeral browser session (created and closed within this request — + never counts against the workspace tab quota). + 3. If the rendered page is a login wall / bot challenge, return + AUTH_REQUIRED so the agent can open a shared browser tab and let a + human take over. + +Errors carry a stable error_code, so the caller can ask for the one action +that would actually help instead of a generic "fetch failed": + JS_RENDER_TIMEOUT | AUTH_REQUIRED | BOT_CHALLENGE | DNS_OR_TLS_ERROR | + CONTENT_BLOCKED | NAVIGATION_FAILED | UNSUPPORTED_CONTENT | + CLIENT_ENV_BLOCKED | SHARE_TOKEN_REQUIRED | CONTENT_UNAVAILABLE | + IP_OR_REGION_BLOCKED +""" + +import logging +import os +import re +import time +from html.parser import HTMLParser +from typing import Optional +from urllib.parse import urljoin, urlparse + +import httpx +from fastapi import APIRouter, Depends, Header +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.browser import BrowserManager, BrowserNavigationError, classify_navigation_error +from app.database import get_db +from app.net_security import ( + OUTBOUND_USER_AGENT, + UnsafeURLError, + safe_fetch, + validate_public_url, +) +from app.response import ResponseCode, json_response, success_response +from app.routers.browser import _resolve_bf_key +from app.routers.network import _resolve_workspace, _verify_workspace_access + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/v1", tags=["Fetch"]) + +STATIC_TIMEOUT_SECONDS = 15.0 +MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +DEFAULT_MAX_CHARS = 20000 +USER_AGENT = OUTBOUND_USER_AGENT + +# A static fetch that yields less text than this (from a large HTML payload) +# is treated as a JS shell and escalated to browser rendering. +JS_SHELL_MIN_TEXT_CHARS = 200 + +_JS_SHELL_MARKERS = ( + "enable javascript", + "requires javascript", + "javascript is disabled", + "javascript to run this app", + "please turn on javascript", +) + +_AUTH_MARKERS = ( + "sign in to continue", + "log in to continue", + "please sign in", + "please log in", + "authentication required", + "登录后查看", + "请先登录", +) + +_BOT_MARKERS = ( + "verify you are human", + "checking your browser", + "cloudflare", + "are you a robot", + "unusual traffic", + "captcha", + "验证码", +) + + +# --- image asset extraction ------------------------------------------------- +# +# Reading a page and downloading its images are two different endpoints, and +# only this one sees the markup. If the read returns text alone, the direct +# image URLs are gone by the time the agent wants them and +# POST /v1/files/from_url — which requires a direct URL — is unreachable in +# practice. So the read has to hand back what it saw. + +ASSET_LIMIT = 60 + +# src is the easy case. The data-* attributes are what lazy-loading pages use +# instead, and mp.weixin.qq.com is one of them: every article image sits in +# data-src with no src at all, so a src-only reader reports zero images for a +# page full of them. +_IMG_URL_ATTRS = ("src", "data-src", "data-original", "data-lazy-src", "data-actualsrc") + +_IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".ico", ".svg") + +# Checked before anything else. The host-shape arm below would otherwise +# accept a bundle off a CDN named "fe-static" as an image purely because its +# key was "url" — a real hit on xiaohongshu's note pages. +_NON_IMAGE_EXTENSIONS = ( + ".js", ".mjs", ".css", ".json", ".xml", ".txt", ".map", + ".woff", ".woff2", ".ttf", ".otf", ".eot", + ".mp4", ".webm", ".m3u8", ".ts", ".mp3", ".m4a", ".wav", + ".pdf", ".zip", ".gz", ".html", ".htm", +) + +_MIME_BY_EXTENSION = { + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", + ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", + ".avif": "image/avif", ".ico": "image/x-icon", ".svg": "image/svg+xml", +} + +# Some sites build the gallery from an embedded JSON blob and never emit an +# for it — xiaohongshu keeps its note images under +# imageList[].urlDefault, inside a + + + + + + + + + +
+ +
+ +
+
+

环境异常

+

当前环境异常,完成验证后即可继续访问。

+
+
+

+ 去验证 +

+
+
+ + + +