crabcode 0.1.6__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,678 @@
1
+ """BrowserTool — headless browser automation via Playwright."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import base64
7
+ import mimetypes
8
+ import shutil
9
+ import tempfile
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import urlparse
15
+
16
+ from crabcode_core.logging_utils import get_logger
17
+ from crabcode_core.types.tool import PermissionBehavior, PermissionResult, Tool, ToolContext, ToolResult
18
+
19
+ logger = get_logger(__name__)
20
+
21
+ _ACTIONS = {
22
+ "create_session",
23
+ "goto",
24
+ "click",
25
+ "fill",
26
+ "press",
27
+ "wait_for",
28
+ "extract",
29
+ "screenshot",
30
+ "evaluate",
31
+ "list_tabs",
32
+ "new_tab",
33
+ "switch_tab",
34
+ "close_tab",
35
+ "close_session",
36
+ }
37
+ _WAIT_UNTIL = {"load", "domcontentloaded", "networkidle"}
38
+ _RETURN_FORMATS = {"text", "html", "links", "json"}
39
+ _SENSITIVE_ACTIONS = {"fill", "press", "evaluate"}
40
+ _SESSION_ACTIONS = {"create_session", "goto"}
41
+
42
+
43
+ @dataclass
44
+ class _BrowserSession:
45
+ session_id: str
46
+ playwright: Any
47
+ browser: Any
48
+ context: Any
49
+ headless: bool
50
+ tabs: dict[str, Any] = field(default_factory=dict)
51
+ active_tab_id: str | None = None
52
+ user_data_dir: str | None = None
53
+ trusted_origins: set[str] = field(default_factory=set)
54
+
55
+
56
+ def _origin_from_url(url: str) -> str:
57
+ parsed = urlparse(url)
58
+ if not parsed.scheme or not parsed.netloc:
59
+ return ""
60
+ return f"{parsed.scheme}://{parsed.netloc}"
61
+
62
+
63
+ class BrowserTool(Tool):
64
+ name = "Browser"
65
+ description = "Use a Chromium browser to open pages, interact with them, extract content, and take screenshots. Headless is the default."
66
+ is_read_only = False
67
+ is_concurrency_safe = False
68
+ uses_tool_permission_policy = True
69
+ input_schema = {
70
+ "type": "object",
71
+ "properties": {
72
+ "action": {
73
+ "type": "string",
74
+ "enum": sorted(_ACTIONS),
75
+ "description": "Browser action to perform.",
76
+ },
77
+ "session_id": {
78
+ "type": "string",
79
+ "description": "Existing browser session ID. Omit for create_session.",
80
+ },
81
+ "headless": {
82
+ "type": "boolean",
83
+ "description": "Optional create_session override. true runs headless, false opens a visible browser window. Defaults to tool_settings.Browser.headless.",
84
+ },
85
+ "url": {"type": "string", "description": "Navigation target URL."},
86
+ "selector": {"type": "string", "description": "CSS selector for the target element."},
87
+ "text": {"type": "string", "description": "Text to input or key to press."},
88
+ "script": {"type": "string", "description": "JavaScript snippet to evaluate in the page."},
89
+ "timeout_seconds": {"type": "number", "description": "Optional timeout override in seconds."},
90
+ "wait_until": {
91
+ "type": "string",
92
+ "enum": sorted(_WAIT_UNTIL),
93
+ "description": "Page readiness target for navigation.",
94
+ },
95
+ "return_format": {
96
+ "type": "string",
97
+ "enum": sorted(_RETURN_FORMATS),
98
+ "description": "Format for extract/evaluate output.",
99
+ },
100
+ "path": {"type": "string", "description": "Output path for screenshots."},
101
+ "tab_id": {"type": "string", "description": "Target tab ID."},
102
+ "options": {
103
+ "type": "object",
104
+ "description": "Additional action-specific options.",
105
+ "additionalProperties": True,
106
+ },
107
+ },
108
+ "required": ["action"],
109
+ }
110
+
111
+ def __init__(self) -> None:
112
+ self._tool_config: dict[str, Any] = {}
113
+ self._sessions: dict[str, _BrowserSession] = {}
114
+ self._playwright_module: Any | None = None
115
+ self._playwright_available = False
116
+ self._dependency_error: str | None = None
117
+ self._enabled = True
118
+ self._headless = True
119
+ self._default_timeout_ms = 15_000
120
+ self._max_sessions = 3
121
+ self._default_browser = "chromium"
122
+ self._launch_options: dict[str, Any] = {}
123
+ self._context_options: dict[str, Any] = {}
124
+ self._storage_dir: str | None = None
125
+ self._block_downloads = True
126
+ self._allowed_domains: set[str] = set()
127
+ self._blocked_domains: set[str] = set()
128
+
129
+ async def setup(self, context: ToolContext) -> None:
130
+ await super().setup(context)
131
+ self._tool_config = dict(context.tool_config)
132
+ self._enabled = bool(self._tool_config.get("enabled", True))
133
+ self._default_browser = str(self._tool_config.get("default_browser", "chromium")).lower()
134
+ self._headless = bool(self._tool_config.get("headless", True))
135
+ self._default_timeout_ms = max(1, int(float(self._tool_config.get("default_timeout_seconds", 15)) * 1000))
136
+ self._max_sessions = max(1, int(self._tool_config.get("max_sessions", 3)))
137
+ self._launch_options = dict(self._tool_config.get("launch_options", {}))
138
+ self._context_options = dict(self._tool_config.get("context_options", {}))
139
+ self._storage_dir = self._tool_config.get("storage_dir")
140
+ self._block_downloads = bool(self._tool_config.get("block_downloads", True))
141
+ self._allowed_domains = {str(v) for v in self._tool_config.get("allowed_domains", []) if str(v)}
142
+ self._blocked_domains = {str(v) for v in self._tool_config.get("blocked_domains", []) if str(v)}
143
+
144
+ if not self._enabled:
145
+ self.is_enabled = False
146
+ return
147
+
148
+ try:
149
+ from playwright.async_api import async_playwright
150
+ except ImportError as exc:
151
+ self._dependency_error = (
152
+ "Browser is unavailable because Playwright is not installed. "
153
+ "Install the optional browser extra and run 'playwright install chromium'."
154
+ )
155
+ self.is_enabled = False
156
+ logger.info("Disabling Browser tool: %s", exc)
157
+ return
158
+
159
+ self._playwright_module = async_playwright
160
+ self._playwright_available = True
161
+
162
+ async def close(self) -> None:
163
+ for session_id in list(self._sessions):
164
+ await self._close_session(session_id)
165
+
166
+ async def get_prompt(self, **kwargs: Any) -> str:
167
+ return (
168
+ "Use a persistent Chromium browser session to navigate pages, interact with DOM elements, "
169
+ "extract content, evaluate page-side JavaScript, and take screenshots. "
170
+ "Prefer WebSearch for discovering URLs or searching the public web. "
171
+ "If WebSearch is unavailable, fails, or its snippets are not enough to answer, use Browser as the fallback "
172
+ "to open the relevant pages and extract their content. "
173
+ "Also use Browser when you need to open a specific page, click, fill forms, inspect DOM state, or capture a screenshot. "
174
+ "Browser sessions run headless by default, but create_session can override that with headless=false when needed. "
175
+ "Create a session once, reuse the returned session_id across actions, and close sessions when done."
176
+ )
177
+
178
+ async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
179
+ action = str(tool_input.get("action", "")).strip()
180
+ if action not in _ACTIONS:
181
+ return f"action must be one of: {', '.join(sorted(_ACTIONS))}"
182
+
183
+ if action != "create_session" and not str(tool_input.get("session_id", "")).strip():
184
+ return "session_id is required for this action"
185
+
186
+ if action in {"goto", "create_session"} and tool_input.get("url"):
187
+ url = str(tool_input["url"]).strip()
188
+ if url and urlparse(url).scheme not in {"http", "https"}:
189
+ return "url must be http or https"
190
+
191
+ wait_until = tool_input.get("wait_until")
192
+ if wait_until is not None and str(wait_until) not in _WAIT_UNTIL:
193
+ return f"wait_until must be one of: {', '.join(sorted(_WAIT_UNTIL))}"
194
+
195
+ return_format = tool_input.get("return_format")
196
+ if return_format is not None and str(return_format) not in _RETURN_FORMATS:
197
+ return f"return_format must be one of: {', '.join(sorted(_RETURN_FORMATS))}"
198
+
199
+ if "headless" in tool_input and not isinstance(tool_input["headless"], bool):
200
+ return "headless must be a boolean"
201
+
202
+ return None
203
+
204
+ def get_permission_key(self, tool_input: dict[str, Any]) -> str:
205
+ action = str(tool_input.get("action", "")).strip() or "unknown"
206
+ return f"{self.name}:{action}"
207
+
208
+ async def check_permissions(
209
+ self,
210
+ tool_input: dict[str, Any],
211
+ context: ToolContext,
212
+ ) -> PermissionResult:
213
+ action = str(tool_input.get("action", "")).strip()
214
+ permission_key = self.get_permission_key(tool_input)
215
+
216
+ if action in {"close_tab", "close_session", "list_tabs", "extract", "new_tab", "switch_tab", "wait_for"}:
217
+ return PermissionResult(behavior=PermissionBehavior.ALLOW, permission_key=permission_key)
218
+
219
+ if action == "screenshot":
220
+ path = str(tool_input.get("path", "")).strip()
221
+ if not path:
222
+ return PermissionResult(behavior=PermissionBehavior.ALLOW, permission_key=permission_key)
223
+ abs_path = Path(path)
224
+ if not abs_path.is_absolute():
225
+ abs_path = Path(context.cwd) / abs_path
226
+ try:
227
+ abs_path.relative_to(Path(context.cwd))
228
+ except ValueError:
229
+ return PermissionResult(
230
+ behavior=PermissionBehavior.ASK,
231
+ reason="Screenshot path is outside the working directory",
232
+ permission_key=permission_key,
233
+ )
234
+ return PermissionResult(behavior=PermissionBehavior.ALLOW, permission_key=permission_key)
235
+
236
+ if action in _SENSITIVE_ACTIONS:
237
+ return PermissionResult(
238
+ behavior=PermissionBehavior.ASK,
239
+ reason=f"Browser action '{action}' is sensitive and requires confirmation",
240
+ permission_key=permission_key,
241
+ )
242
+
243
+ if action in _SESSION_ACTIONS:
244
+ return PermissionResult(
245
+ behavior=PermissionBehavior.ASK,
246
+ reason=f"Browser action '{action}' requires confirmation",
247
+ permission_key=permission_key,
248
+ )
249
+
250
+ if action == "click":
251
+ session = self._sessions.get(str(tool_input.get("session_id", "")).strip())
252
+ url = str(tool_input.get("url", "")).strip()
253
+ if url and session:
254
+ target_origin = _origin_from_url(url)
255
+ if target_origin and session.trusted_origins and target_origin not in session.trusted_origins:
256
+ return PermissionResult(
257
+ behavior=PermissionBehavior.ASK,
258
+ reason="Browser click may navigate to a new origin",
259
+ permission_key=permission_key,
260
+ )
261
+ return PermissionResult(behavior=PermissionBehavior.ALLOW, permission_key=permission_key)
262
+
263
+ return PermissionResult(behavior=PermissionBehavior.ALLOW, permission_key=permission_key)
264
+
265
+ async def call(
266
+ self,
267
+ tool_input: dict[str, Any],
268
+ context: ToolContext,
269
+ ) -> ToolResult:
270
+ if not self.is_enabled or not self._enabled:
271
+ return ToolResult(
272
+ result_for_model=self._dependency_error or "Browser is disabled in this session.",
273
+ is_error=True,
274
+ )
275
+ if not self._playwright_available or self._playwright_module is None:
276
+ return ToolResult(
277
+ result_for_model=self._dependency_error or "Browser is unavailable.",
278
+ is_error=True,
279
+ )
280
+
281
+ action = str(tool_input["action"]).strip()
282
+ try:
283
+ if action == "create_session":
284
+ return await self._create_session(tool_input)
285
+ if action == "close_session":
286
+ return await self._close_session_result(str(tool_input["session_id"]).strip())
287
+
288
+ session = self._require_session(str(tool_input["session_id"]).strip())
289
+ if isinstance(session, ToolResult):
290
+ return session
291
+
292
+ if action == "goto":
293
+ return await self._goto(session, tool_input)
294
+ if action == "click":
295
+ return await self._click(session, tool_input)
296
+ if action == "fill":
297
+ return await self._fill(session, tool_input)
298
+ if action == "press":
299
+ return await self._press(session, tool_input)
300
+ if action == "wait_for":
301
+ return await self._wait_for(session, tool_input)
302
+ if action == "extract":
303
+ return await self._extract(session, tool_input)
304
+ if action == "screenshot":
305
+ return await self._screenshot(session, tool_input, context)
306
+ if action == "evaluate":
307
+ return await self._evaluate(session, tool_input)
308
+ if action == "list_tabs":
309
+ return self._list_tabs(session)
310
+ if action == "new_tab":
311
+ return await self._new_tab(session, tool_input)
312
+ if action == "switch_tab":
313
+ return self._switch_tab(session, tool_input)
314
+ if action == "close_tab":
315
+ return await self._close_tab(session, tool_input)
316
+ except Exception as exc:
317
+ logger.exception("Browser action failed: %s", action)
318
+ return ToolResult(
319
+ result_for_model=f"Browser error during {action}: {exc}",
320
+ is_error=True,
321
+ )
322
+
323
+ return ToolResult(result_for_model=f"Unsupported browser action: {action}", is_error=True)
324
+
325
+ def _timeout_ms(self, tool_input: dict[str, Any]) -> int:
326
+ raw = tool_input.get("timeout_seconds")
327
+ if raw is None:
328
+ return self._default_timeout_ms
329
+ return max(1, int(float(raw) * 1000))
330
+
331
+ def _require_session(self, session_id: str) -> _BrowserSession | ToolResult:
332
+ session = self._sessions.get(session_id)
333
+ if session is None:
334
+ return ToolResult(
335
+ result_for_model=f"Unknown browser session: {session_id}",
336
+ is_error=True,
337
+ )
338
+ return session
339
+
340
+ def _get_tab(self, session: _BrowserSession, tool_input: dict[str, Any]) -> tuple[str, Any] | ToolResult:
341
+ tab_id = str(tool_input.get("tab_id", "")).strip() or session.active_tab_id or ""
342
+ page = session.tabs.get(tab_id)
343
+ if page is None:
344
+ return ToolResult(
345
+ result_for_model=f"Unknown browser tab: {tab_id or '(none)'}",
346
+ is_error=True,
347
+ )
348
+ return tab_id, page
349
+
350
+ def _check_domain(self, url: str) -> ToolResult | None:
351
+ parsed = urlparse(url)
352
+ host = parsed.netloc.lower()
353
+ if not host:
354
+ return None
355
+ if self._allowed_domains and host not in self._allowed_domains:
356
+ return ToolResult(result_for_model=f"Blocked by Browser.allowed_domains: {host}", is_error=True)
357
+ if self._blocked_domains and host in self._blocked_domains:
358
+ return ToolResult(result_for_model=f"Blocked by Browser.blocked_domains: {host}", is_error=True)
359
+ return None
360
+
361
+ async def _create_session(self, tool_input: dict[str, Any]) -> ToolResult:
362
+ if len(self._sessions) >= self._max_sessions:
363
+ return ToolResult(
364
+ result_for_model=f"Maximum Browser sessions reached ({self._max_sessions})",
365
+ is_error=True,
366
+ )
367
+
368
+ playwright = await self._playwright_module().start()
369
+ browser_type = getattr(playwright, self._default_browser, None)
370
+ if browser_type is None:
371
+ await playwright.stop()
372
+ return ToolResult(result_for_model=f"Unsupported browser type: {self._default_browser}", is_error=True)
373
+
374
+ headless = bool(tool_input["headless"]) if "headless" in tool_input else self._headless
375
+ launch_options = dict(self._launch_options)
376
+ launch_options["headless"] = headless
377
+ browser = await browser_type.launch(**launch_options)
378
+
379
+ user_data_dir: str | None = None
380
+ if self._storage_dir:
381
+ base = Path(self._storage_dir)
382
+ base.mkdir(parents=True, exist_ok=True)
383
+ user_data_dir = str(base / f"browser-{uuid.uuid4().hex[:8]}")
384
+ Path(user_data_dir).mkdir(parents=True, exist_ok=True)
385
+ else:
386
+ user_data_dir = tempfile.mkdtemp(prefix="crabcode-browser-")
387
+
388
+ context_options = dict(self._context_options)
389
+ context_options.setdefault("accept_downloads", not self._block_downloads)
390
+ context = await browser.new_context(**context_options)
391
+ page = await context.new_page()
392
+ page.set_default_timeout(self._default_timeout_ms)
393
+ page.set_default_navigation_timeout(self._default_timeout_ms)
394
+
395
+ session_id = str(uuid.uuid4())
396
+ tab_id = str(uuid.uuid4())
397
+ session = _BrowserSession(
398
+ session_id=session_id,
399
+ playwright=playwright,
400
+ browser=browser,
401
+ context=context,
402
+ headless=headless,
403
+ tabs={tab_id: page},
404
+ active_tab_id=tab_id,
405
+ user_data_dir=user_data_dir,
406
+ )
407
+ self._sessions[session_id] = session
408
+
409
+ url = str(tool_input.get("url", "")).strip()
410
+ if url:
411
+ denied = self._check_domain(url)
412
+ if denied is not None:
413
+ await self._close_session(session_id)
414
+ return denied
415
+ await page.goto(url, wait_until=str(tool_input.get("wait_until", "load")), timeout=self._timeout_ms(tool_input))
416
+ origin = _origin_from_url(page.url)
417
+ if origin:
418
+ session.trusted_origins.add(origin)
419
+
420
+ return self._result(
421
+ session=session,
422
+ tab_id=tab_id,
423
+ status="created",
424
+ data={"tabs": [self._tab_summary(tab_id, page)]},
425
+ )
426
+
427
+ async def _goto(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
428
+ url = str(tool_input.get("url", "")).strip()
429
+ denied = self._check_domain(url)
430
+ if denied is not None:
431
+ return denied
432
+ tab = self._get_tab(session, tool_input)
433
+ if isinstance(tab, ToolResult):
434
+ return tab
435
+ tab_id, page = tab
436
+ await page.goto(url, wait_until=str(tool_input.get("wait_until", "load")), timeout=self._timeout_ms(tool_input))
437
+ origin = _origin_from_url(page.url)
438
+ if origin:
439
+ session.trusted_origins.add(origin)
440
+ session.active_tab_id = tab_id
441
+ return self._result(session=session, tab_id=tab_id, status="navigated")
442
+
443
+ async def _click(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
444
+ tab = self._get_tab(session, tool_input)
445
+ if isinstance(tab, ToolResult):
446
+ return tab
447
+ tab_id, page = tab
448
+ selector = str(tool_input.get("selector", "")).strip()
449
+ await page.click(selector, timeout=self._timeout_ms(tool_input))
450
+ origin = _origin_from_url(page.url)
451
+ if origin:
452
+ session.trusted_origins.add(origin)
453
+ return self._result(session=session, tab_id=tab_id, status="clicked")
454
+
455
+ async def _fill(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
456
+ tab = self._get_tab(session, tool_input)
457
+ if isinstance(tab, ToolResult):
458
+ return tab
459
+ tab_id, page = tab
460
+ await page.fill(
461
+ str(tool_input.get("selector", "")).strip(),
462
+ str(tool_input.get("text", "")),
463
+ timeout=self._timeout_ms(tool_input),
464
+ )
465
+ return self._result(session=session, tab_id=tab_id, status="filled")
466
+
467
+ async def _press(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
468
+ tab = self._get_tab(session, tool_input)
469
+ if isinstance(tab, ToolResult):
470
+ return tab
471
+ tab_id, page = tab
472
+ selector = str(tool_input.get("selector", "")).strip()
473
+ key = str(tool_input.get("text", "")).strip()
474
+ if selector:
475
+ await page.press(selector, key, timeout=self._timeout_ms(tool_input))
476
+ else:
477
+ await page.keyboard.press(key)
478
+ return self._result(session=session, tab_id=tab_id, status="pressed")
479
+
480
+ async def _wait_for(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
481
+ tab = self._get_tab(session, tool_input)
482
+ if isinstance(tab, ToolResult):
483
+ return tab
484
+ tab_id, page = tab
485
+ selector = str(tool_input.get("selector", "")).strip()
486
+ if selector:
487
+ await page.wait_for_selector(selector, timeout=self._timeout_ms(tool_input))
488
+ else:
489
+ await page.wait_for_load_state(str(tool_input.get("wait_until", "load")), timeout=self._timeout_ms(tool_input))
490
+ return self._result(session=session, tab_id=tab_id, status="ready")
491
+
492
+ async def _extract(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
493
+ tab = self._get_tab(session, tool_input)
494
+ if isinstance(tab, ToolResult):
495
+ return tab
496
+ tab_id, page = tab
497
+ return_format = str(tool_input.get("return_format", "text"))
498
+ if return_format == "html":
499
+ data = await page.content()
500
+ elif return_format == "links":
501
+ data = await page.eval_on_selector_all(
502
+ "a[href]",
503
+ """els => els.map(el => ({
504
+ text: (el.textContent || '').trim(),
505
+ href: el.href
506
+ }))""",
507
+ )
508
+ elif return_format == "json":
509
+ data = await page.evaluate(
510
+ """() => ({
511
+ title: document.title,
512
+ text: (document.body?.innerText || '').trim(),
513
+ url: location.href
514
+ })"""
515
+ )
516
+ else:
517
+ data = (await page.text_content("body")) or ""
518
+ return self._result(session=session, tab_id=tab_id, status="extracted", data=data)
519
+
520
+ async def _screenshot(self, session: _BrowserSession, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
521
+ tab = self._get_tab(session, tool_input)
522
+ if isinstance(tab, ToolResult):
523
+ return tab
524
+ tab_id, page = tab
525
+ path = str(tool_input.get("path", "")).strip()
526
+ if not path:
527
+ session_dir = Path(context.cwd) / ".crabcode" / "browser"
528
+ session_dir.mkdir(parents=True, exist_ok=True)
529
+ path = str(session_dir / f"{session.session_id}-{tab_id}.png")
530
+ out = Path(path)
531
+ if not out.is_absolute():
532
+ out = Path(context.cwd) / out
533
+ out.parent.mkdir(parents=True, exist_ok=True)
534
+ await page.screenshot(path=str(out), full_page=bool(tool_input.get("options", {}).get("full_page", True)))
535
+ images: list[dict[str, str]] = []
536
+ try:
537
+ raw = out.read_bytes()
538
+ if len(raw) <= 20 * 1024 * 1024:
539
+ images.append({
540
+ "media_type": mimetypes.guess_type(out.name)[0] or "image/png",
541
+ "data": base64.b64encode(raw).decode("ascii"),
542
+ })
543
+ except OSError:
544
+ logger.debug("Failed to read browser screenshot for inline display: %s", out, exc_info=True)
545
+ return self._result(
546
+ session=session,
547
+ tab_id=tab_id,
548
+ status="screenshot",
549
+ data={"path": str(out)},
550
+ images=images,
551
+ )
552
+
553
+ async def _evaluate(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
554
+ tab = self._get_tab(session, tool_input)
555
+ if isinstance(tab, ToolResult):
556
+ return tab
557
+ tab_id, page = tab
558
+ data = await page.evaluate(str(tool_input.get("script", "")))
559
+ return self._result(session=session, tab_id=tab_id, status="evaluated", data=data)
560
+
561
+ def _list_tabs(self, session: _BrowserSession) -> ToolResult:
562
+ tabs = [self._tab_summary(tab_id, page) for tab_id, page in session.tabs.items()]
563
+ return self._result(
564
+ session=session,
565
+ tab_id=session.active_tab_id,
566
+ status="listed_tabs",
567
+ data={"tabs": tabs},
568
+ )
569
+
570
+ async def _new_tab(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
571
+ page = await session.context.new_page()
572
+ page.set_default_timeout(self._default_timeout_ms)
573
+ page.set_default_navigation_timeout(self._default_timeout_ms)
574
+ tab_id = str(uuid.uuid4())
575
+ session.tabs[tab_id] = page
576
+ session.active_tab_id = tab_id
577
+ url = str(tool_input.get("url", "")).strip()
578
+ if url:
579
+ denied = self._check_domain(url)
580
+ if denied is not None:
581
+ await page.close()
582
+ session.tabs.pop(tab_id, None)
583
+ return denied
584
+ await page.goto(url, wait_until=str(tool_input.get("wait_until", "load")), timeout=self._timeout_ms(tool_input))
585
+ origin = _origin_from_url(page.url)
586
+ if origin:
587
+ session.trusted_origins.add(origin)
588
+ return self._result(session=session, tab_id=tab_id, status="new_tab")
589
+
590
+ def _switch_tab(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
591
+ tab_id = str(tool_input.get("tab_id", "")).strip()
592
+ if tab_id not in session.tabs:
593
+ return ToolResult(result_for_model=f"Unknown browser tab: {tab_id}", is_error=True)
594
+ session.active_tab_id = tab_id
595
+ return self._result(session=session, tab_id=tab_id, status="switched_tab")
596
+
597
+ async def _close_tab(self, session: _BrowserSession, tool_input: dict[str, Any]) -> ToolResult:
598
+ tab = self._get_tab(session, tool_input)
599
+ if isinstance(tab, ToolResult):
600
+ return tab
601
+ tab_id, page = tab
602
+ await page.close()
603
+ session.tabs.pop(tab_id, None)
604
+ session.active_tab_id = next(iter(session.tabs), None)
605
+ return self._result(session=session, tab_id=session.active_tab_id, status="closed_tab")
606
+
607
+ async def _close_session_result(self, session_id: str) -> ToolResult:
608
+ session = self._sessions.get(session_id)
609
+ if session is None:
610
+ return ToolResult(result_for_model=f"Unknown browser session: {session_id}", is_error=True)
611
+ await self._close_session(session_id)
612
+ return ToolResult(
613
+ data={"session_id": session_id, "status": "closed"},
614
+ result_for_model=f"session_id: {session_id}\nstatus: closed",
615
+ )
616
+
617
+ async def _close_session(self, session_id: str) -> None:
618
+ session = self._sessions.pop(session_id, None)
619
+ if session is None:
620
+ return
621
+ try:
622
+ await session.context.close()
623
+ except Exception:
624
+ logger.debug("Failed to close browser context %s", session_id, exc_info=True)
625
+ try:
626
+ await session.browser.close()
627
+ except Exception:
628
+ logger.debug("Failed to close browser %s", session_id, exc_info=True)
629
+ try:
630
+ await session.playwright.stop()
631
+ except Exception:
632
+ logger.debug("Failed to stop playwright %s", session_id, exc_info=True)
633
+ if session.user_data_dir:
634
+ shutil.rmtree(session.user_data_dir, ignore_errors=True)
635
+
636
+ def _tab_summary(self, tab_id: str, page: Any) -> dict[str, Any]:
637
+ return {
638
+ "tab_id": tab_id,
639
+ "url": page.url,
640
+ "title": "",
641
+ "active": False,
642
+ }
643
+
644
+ def _result(
645
+ self,
646
+ *,
647
+ session: _BrowserSession,
648
+ tab_id: str | None,
649
+ status: str,
650
+ data: Any = None,
651
+ images: list[dict[str, str]] | None = None,
652
+ ) -> ToolResult:
653
+ page = session.tabs.get(tab_id) if tab_id else None
654
+ url = page.url if page is not None else ""
655
+ payload = {
656
+ "session_id": session.session_id,
657
+ "tab_id": tab_id,
658
+ "url": url,
659
+ "title": "",
660
+ "headless": session.headless,
661
+ "status": status,
662
+ "data": data,
663
+ }
664
+ text = [
665
+ f"session_id: {session.session_id}",
666
+ f"tab_id: {tab_id or '(none)'}",
667
+ f"url: {url or '(blank)'}",
668
+ "title: ",
669
+ f"headless: {str(session.headless).lower()}",
670
+ f"status: {status}",
671
+ ]
672
+ if data is not None:
673
+ compact = json.dumps(data, ensure_ascii=False) if not isinstance(data, str) else data
674
+ if len(compact) > 4000:
675
+ compact = compact[:4000] + "…"
676
+ text.append("data:")
677
+ text.append(compact)
678
+ return ToolResult(data=payload, result_for_model="\n".join(text), images=images or [])