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: `` — 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 —  — 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: . ' +
+ '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: `` — 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: `` — 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
+
+
+
+
+
+
+
+