agent-portal-2 0.1.0

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 (120) hide show
  1. package/.continue/agents/new-config.yaml +22 -0
  2. package/AGENT_STEERING.md +36 -0
  3. package/ARCHITECTURE.md +13 -0
  4. package/CHANGELOG.md +97 -0
  5. package/CLI.md +38 -0
  6. package/CONTRIBUTING.md +55 -0
  7. package/INSTALLATION.md +58 -0
  8. package/LICENSE +60 -0
  9. package/PLUGIN_SYSTEM.md +33 -0
  10. package/PYTHON_SDK.md +22 -0
  11. package/QUICKSTART.md +19 -0
  12. package/README.md +385 -0
  13. package/RELEASE_NOTES_v0.1.0.md +281 -0
  14. package/ROADMAP.md +3 -0
  15. package/RUNTIME.md +44 -0
  16. package/SAFETY_MODEL.md +24 -0
  17. package/TESTING.md +35 -0
  18. package/TROUBLESHOOTING.md +30 -0
  19. package/UPGRADE_GUIDE.md +288 -0
  20. package/VS_CODE_EXTENSION.md +47 -0
  21. package/agent-portal.config.json +20 -0
  22. package/apps/desktop/agent-portal-desktop.zip +0 -0
  23. package/apps/desktop/fixtures/local-workflow.html +151 -0
  24. package/apps/desktop/package.json +18 -0
  25. package/apps/desktop/src/main.ts +117 -0
  26. package/apps/desktop/tsconfig.json +8 -0
  27. package/apps/vscode-extension/LICENSE +60 -0
  28. package/apps/vscode-extension/README.md +20 -0
  29. package/apps/vscode-extension/media/agent-portal-logo.png +0 -0
  30. package/apps/vscode-extension/package.json +149 -0
  31. package/apps/vscode-extension/src/extension.ts +614 -0
  32. package/apps/vscode-extension/tsconfig.json +12 -0
  33. package/assets/branding/agent-portal-logo.png +0 -0
  34. package/connectors/chatgpt-tools/README.md +9 -0
  35. package/connectors/claude-mcp-server/README.md +9 -0
  36. package/connectors/gemini-connector/README.md +9 -0
  37. package/connectors/rest-websocket-api/README.md +9 -0
  38. package/docs/MCP_SERVER.md +68 -0
  39. package/docs/architecture.md +214 -0
  40. package/docs/roadmap.md +125 -0
  41. package/package.json +21 -0
  42. package/packages/agent-portal-mcp/README.md +12 -0
  43. package/packages/agent-portal-mcp/agent_portal_mcp/__init__.py +3 -0
  44. package/packages/agent-portal-mcp/agent_portal_mcp/bridge/__init__.py +1 -0
  45. package/packages/agent-portal-mcp/agent_portal_mcp/bridge/runtime_client.py +180 -0
  46. package/packages/agent-portal-mcp/agent_portal_mcp/cli.py +32 -0
  47. package/packages/agent-portal-mcp/agent_portal_mcp/doctor.py +71 -0
  48. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/__init__.py +1 -0
  49. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/actions.py +17 -0
  50. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/results.py +24 -0
  51. package/packages/agent-portal-mcp/agent_portal_mcp/schemas/risk.py +20 -0
  52. package/packages/agent-portal-mcp/agent_portal_mcp/security/__init__.py +1 -0
  53. package/packages/agent-portal-mcp/agent_portal_mcp/security/policy.py +27 -0
  54. package/packages/agent-portal-mcp/agent_portal_mcp/server.py +148 -0
  55. package/packages/agent-portal-mcp/agent_portal_mcp/tool_registry.py +58 -0
  56. package/packages/agent-portal-mcp/agent_portal_mcp/tools/__init__.py +1 -0
  57. package/packages/agent-portal-mcp/agent_portal_mcp/tools/browser.py +89 -0
  58. package/packages/agent-portal-mcp/agent_portal_mcp/tools/common.py +98 -0
  59. package/packages/agent-portal-mcp/agent_portal_mcp/tools/inspection.py +93 -0
  60. package/packages/agent-portal-mcp/agent_portal_mcp/tools/navigation.py +93 -0
  61. package/packages/agent-portal-mcp/agent_portal_mcp/tools/reports.py +34 -0
  62. package/packages/agent-portal-mcp/agent_portal_mcp/tools/steering.py +93 -0
  63. package/packages/agent-portal-mcp/pyproject.toml +20 -0
  64. package/packages/agent-portal-mcp/tests/test_doctor.py +20 -0
  65. package/packages/agent-portal-mcp/tests/test_mcp_server.py +161 -0
  66. package/packages/core/package.json +15 -0
  67. package/packages/core/src/index.ts +1842 -0
  68. package/packages/core/tsconfig.json +8 -0
  69. package/packages/mcp-server/package.json +15 -0
  70. package/packages/mcp-server/src/index.ts +73 -0
  71. package/packages/mcp-server/tsconfig.json +8 -0
  72. package/packages/sdk/package.json +15 -0
  73. package/packages/sdk/src/index.ts +544 -0
  74. package/packages/sdk/tsconfig.json +8 -0
  75. package/plugins/README.md +16 -0
  76. package/plugins/agent-portal-browser/plugin.json +19 -0
  77. package/plugins/agent-portal-python/plugin.json +16 -0
  78. package/plugins/agent-portal-skills/plugin.json +19 -0
  79. package/plugins/agent-portal-vscode/plugin.json +27 -0
  80. package/plugins/example-runtime-plugin/README.md +3 -0
  81. package/plugins/example-runtime-plugin/plugin.json +20 -0
  82. package/plugins/plugin.schema.json +53 -0
  83. package/python/README.md +18 -0
  84. package/python/agent_portal/__init__.py +5 -0
  85. package/python/agent_portal/__main__.py +5 -0
  86. package/python/agent_portal/browser.py +393 -0
  87. package/python/agent_portal/cli.py +164 -0
  88. package/python/agent_portal/config.py +31 -0
  89. package/python/agent_portal/doctor.py +165 -0
  90. package/python/agent_portal/exceptions.py +39 -0
  91. package/python/agent_portal/logging_utils.py +33 -0
  92. package/python/agent_portal/metrics.py +309 -0
  93. package/python/agent_portal/models.py +160 -0
  94. package/python/agent_portal/plugin_system.py +42 -0
  95. package/python/agent_portal/rate_limit.py +253 -0
  96. package/python/agent_portal/runtime.py +739 -0
  97. package/python/agent_portal/server.py +351 -0
  98. package/python/agent_portal/validation.py +299 -0
  99. package/python/pyproject.toml +29 -0
  100. package/python/tests/test_config.py +24 -0
  101. package/python/tests/test_doctor.py +19 -0
  102. package/python/tests/test_metrics.py +180 -0
  103. package/python/tests/test_rate_limit.py +237 -0
  104. package/python/tests/test_runtime.py +122 -0
  105. package/python/tests/test_server.py +53 -0
  106. package/python/tests/test_validation.py +170 -0
  107. package/releases/desktop/agent-portal-desktop/README.md +378 -0
  108. package/releases/desktop/agent-portal-desktop/RELEASE_NOTES.md +14 -0
  109. package/releases/desktop/agent-portal-desktop/assets/branding/agent-portal-logo.png +0 -0
  110. package/releases/desktop/agent-portal-desktop/fixtures/local-workflow.html +151 -0
  111. package/releases/desktop/agent-portal-desktop/launch-agent-portal.bat +4 -0
  112. package/releases/desktop/agent-portal-desktop.zip +0 -0
  113. package/releases/python/agent_portal-0.0.2-py3-none-any.whl +0 -0
  114. package/releases/python/agent_portal-0.0.2.tar.gz +0 -0
  115. package/scripts/package_desktop.mjs +117 -0
  116. package/scripts/release_python.py +46 -0
  117. package/tests/plugin-manifest.test.mjs +26 -0
  118. package/tests/runtime.test.mjs +41 -0
  119. package/tests/vscode-extension.test.mjs +22 -0
  120. package/tsconfig.base.json +16 -0
@@ -0,0 +1,739 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from .browser import BrowserController
11
+ from .config import load_config
12
+ from .exceptions import AgentPortalError, PolicyBlockedError, RuntimeStartupError
13
+ from .logging_utils import build_logger
14
+ from .models import (
15
+ ActionRequest,
16
+ ActionResult,
17
+ BrowserState,
18
+ RiskPolicy,
19
+ RuntimeConfigModel,
20
+ SessionReport,
21
+ SessionState,
22
+ utc_now,
23
+ )
24
+ from .plugin_system import discover_plugins, validate_plugin_manifest
25
+
26
+
27
+ RISK_ORDER = ["safe", "low", "medium", "high", "blocked"]
28
+
29
+
30
+ class PortalRuntime:
31
+ def __init__(self, base_path: Path | None = None, config: RuntimeConfigModel | None = None) -> None:
32
+ self.base_path = base_path or Path.cwd()
33
+ self.config = config or load_config(self.base_path)
34
+ self.logger = build_logger("agent_portal.runtime", self.config.log_level)
35
+ self.session = SessionState(session_id=f"session-{utc_now().replace(':', '-')}")
36
+ self.browser_state = BrowserState(browser_name=self.config.default_browser)
37
+ self.risk_policy = RiskPolicy(
38
+ mode=self.config.action_mode,
39
+ approval_threshold=self.config.approval_policy,
40
+ read_only=self.config.action_mode == "read-only",
41
+ )
42
+ self.browser = BrowserController(
43
+ screenshot_directory=self.base_path / self.config.screenshot_directory
44
+ )
45
+ self._lock_path = self.base_path / ".agent-portal-runtime.lock"
46
+ self._started = False
47
+
48
+ def startup_validation(self) -> None:
49
+ if self.config.runtime_host == "0.0.0.0":
50
+ self.logger.warning(
51
+ "Runtime is configured to bind publicly.",
52
+ extra={"context": {"host": self.config.runtime_host}},
53
+ )
54
+
55
+ try:
56
+ self._ensure_single_instance()
57
+ self._ensure_port_available()
58
+ except OSError as exc:
59
+ raise RuntimeStartupError(
60
+ "Runtime could not bind to the requested port.",
61
+ module="agent_portal.runtime",
62
+ likely_cause="Another runtime instance is already running.",
63
+ suggested_fix="Run `agent-portal stop` or change the configured port.",
64
+ can_continue=False,
65
+ ) from exc
66
+
67
+ def start(self) -> None:
68
+ if self._started:
69
+ return
70
+ self.startup_validation()
71
+ self.session.started_at = utc_now()
72
+ self.session.ended_at = None
73
+ self.session.runtime_status = "idle"
74
+ self._started = True
75
+ self.logger.info("Runtime started")
76
+
77
+ def stop(self) -> None:
78
+ self.session.runtime_status = "stopped"
79
+ self.session.ended_at = utc_now()
80
+ self.browser_state.connected = False
81
+ try:
82
+ self.browser.stop()
83
+ finally:
84
+ self._remove_lock()
85
+ self._started = False
86
+ self.logger.info("Runtime stopped")
87
+
88
+ def pause(self) -> None:
89
+ self.session.runtime_status = "paused"
90
+ self.session.logs.insert(0, "Runtime paused")
91
+
92
+ def resume(self) -> None:
93
+ self.session.runtime_status = "acting"
94
+ self.session.logs.insert(0, "Runtime resumed")
95
+
96
+ def restart(self) -> None:
97
+ self.stop()
98
+ self.start()
99
+
100
+ def set_goal(self, goal: str) -> None:
101
+ self.session.current_goal = goal
102
+ self.session.logs.insert(0, f"Goal set to {goal}")
103
+
104
+ def get_current_goal(self) -> str | None:
105
+ return self.session.current_goal
106
+
107
+ def set_action_mode(self, mode: str) -> None:
108
+ if mode not in {"read-only", "assisted", "autonomous", "manual-override"}:
109
+ raise AgentPortalError(
110
+ f"Unsupported action mode: {mode}",
111
+ module="agent_portal.runtime",
112
+ likely_cause="The requested mode is not part of the runtime steering model.",
113
+ suggested_fix="Use one of: read-only, assisted, autonomous, manual-override.",
114
+ can_continue=True,
115
+ )
116
+ self.config.action_mode = mode # type: ignore[assignment]
117
+ self.risk_policy.mode = mode # type: ignore[assignment]
118
+ self.risk_policy.read_only = mode == "read-only"
119
+ self.session.logs.insert(0, f"Action mode set to {mode}")
120
+
121
+ def get_action_queue(self) -> list[dict[str, Any]]:
122
+ return [asdict(action) for action in self._all_actions()]
123
+
124
+ def status(self) -> dict[str, Any]:
125
+ return {
126
+ "session": asdict(self.session),
127
+ "browser": asdict(self.browser_state),
128
+ "config": asdict(self.config),
129
+ "policy": asdict(self.risk_policy),
130
+ "plugins": self.list_plugins(),
131
+ "runtimeVersion": "0.0.3",
132
+ }
133
+
134
+ def health(self) -> dict[str, Any]:
135
+ return {
136
+ "ok": True,
137
+ "runtimeVersion": "0.0.3",
138
+ "runtimeStatus": self.session.runtime_status,
139
+ "browserConnected": self.browser_state.connected,
140
+ "currentUrl": self.browser_state.current_url,
141
+ "pageTitle": self.browser_state.page_title,
142
+ "pluginCount": len(discover_plugins(self.base_path)),
143
+ }
144
+
145
+ def ensure_browser(self) -> None:
146
+ if not self.browser.available():
147
+ raise RuntimeStartupError(
148
+ "Python Playwright dependency is missing.",
149
+ module="agent_portal.runtime",
150
+ likely_cause="The Python runtime dependencies were not installed.",
151
+ suggested_fix="Run `pip install -e ./python`.",
152
+ can_continue=False,
153
+ )
154
+ try:
155
+ self.browser.start()
156
+ self.browser_state.connected = True
157
+ self.session.runtime_status = "acting"
158
+ except AgentPortalError as exc:
159
+ self.browser_state.last_error = exc.message
160
+ self.browser_state.connected = False
161
+ raise
162
+
163
+ def open_url(self, url: str) -> ActionResult:
164
+ return self._execute_action(
165
+ ActionRequest("open_url", "Open a URL", target=url),
166
+ lambda action: self._open_url_impl(url, action),
167
+ )
168
+
169
+ def screenshot(self, label: str = "manual") -> ActionResult:
170
+ return self._execute_action(
171
+ ActionRequest("screenshot", "Capture a screenshot", risk_level="safe"),
172
+ lambda action: self.complete_action(
173
+ action.action_id,
174
+ "Captured screenshot",
175
+ after_screenshot=self.browser.screenshot(label),
176
+ ),
177
+ )
178
+
179
+ def click(self, selector: str, reason: str) -> ActionResult:
180
+ return self._execute_action(
181
+ ActionRequest("click", reason, target=selector),
182
+ lambda action: self._click_impl(selector, action),
183
+ )
184
+
185
+ def type_text(self, selector: str, value: str, reason: str) -> ActionResult:
186
+ risk = "blocked" if "password" in selector.lower() else "low"
187
+ action = self.propose_action(
188
+ ActionRequest("type", reason, target=selector, payload=value, risk_level=risk)
189
+ )
190
+ if action.status == "blocked":
191
+ raise PolicyBlockedError(
192
+ "Action was blocked because it attempted to type into a protected field.",
193
+ module="agent_portal.runtime",
194
+ likely_cause="The target appears to be a password field or protected input.",
195
+ suggested_fix="Use manual override only if you intend to enter a sensitive value.",
196
+ can_continue=True,
197
+ )
198
+ return self._execute_approved_action(
199
+ action.action_id,
200
+ lambda approved: self._type_impl(selector, value, approved),
201
+ )
202
+
203
+ def scroll(self, selector: str | None = None, reason: str = "Scroll the page") -> ActionResult:
204
+ return self._execute_action(
205
+ ActionRequest("scroll", reason, target=selector, risk_level="safe"),
206
+ lambda action: self._scroll_impl(selector, action),
207
+ )
208
+
209
+ def hover(self, selector: str, reason: str = "Hover over element") -> ActionResult:
210
+ return self._execute_action(
211
+ ActionRequest("hover", reason, target=selector, risk_level="safe"),
212
+ lambda action: self._hover_impl(selector, action),
213
+ )
214
+
215
+ def wait(self, selector: str, reason: str = "Wait for element") -> ActionResult:
216
+ return self._execute_action(
217
+ ActionRequest("wait", reason, target=selector, risk_level="safe"),
218
+ lambda action: self._wait_impl(selector, action),
219
+ )
220
+
221
+ def inspect(self) -> dict[str, Any]:
222
+ snapshot = self.browser.inspect()
223
+ self.session.console_errors = self.browser.read_console()
224
+ self.session.network_errors = self.browser.read_network()
225
+ self.browser_state.current_url = snapshot.get("url")
226
+ self.browser_state.page_title = snapshot.get("title")
227
+ return snapshot
228
+
229
+ def read_text(self, selector: str, reason: str = "Read text from element") -> dict[str, Any]:
230
+ holder: dict[str, Any] = {}
231
+ action = self._execute_action(
232
+ ActionRequest("inspect", reason, target=selector, risk_level="safe"),
233
+ lambda approved: self._read_text_impl(selector, approved, holder),
234
+ )
235
+ return {
236
+ "action": asdict(action),
237
+ "selector": selector,
238
+ "text": holder.get("text"),
239
+ }
240
+
241
+ def execute(self, script: str, reason: str = "Execute script in page context") -> dict[str, Any]:
242
+ holder: dict[str, Any] = {}
243
+ action = self._execute_action(
244
+ ActionRequest("execute", reason, payload=script, risk_level="medium"),
245
+ lambda approved: self._execute_script_impl(script, approved, holder),
246
+ )
247
+ return {
248
+ "action": asdict(action),
249
+ "result": holder.get("result"),
250
+ }
251
+
252
+ def capture_snapshot(self, label: str = "capture") -> dict[str, Any]:
253
+ screenshot = self.screenshot(label)
254
+ inspection = self.inspect()
255
+ return {
256
+ "action": asdict(screenshot),
257
+ "inspection": inspection,
258
+ "screenshotPath": screenshot.after_screenshot,
259
+ }
260
+
261
+ def propose_action(self, request: ActionRequest) -> ActionResult:
262
+ self._enforce_policy(request)
263
+ next_index = (
264
+ len(self.session.pending_actions)
265
+ + len(self.session.approved_actions)
266
+ + len(self.session.completed_actions)
267
+ + len(self.session.rejected_actions)
268
+ + len(self.session.blocked_actions)
269
+ + len(self.session.failed_actions)
270
+ + 1
271
+ )
272
+ action = ActionResult(
273
+ action_id=f"{request.action_type}-{next_index}",
274
+ action_type=request.action_type,
275
+ status="pending",
276
+ reason=request.reason,
277
+ target=request.target,
278
+ payload=request.payload,
279
+ risk_level=request.risk_level,
280
+ )
281
+ if request.risk_level == "blocked":
282
+ action.status = "blocked"
283
+ self.session.blocked_actions.append(action)
284
+ self.session.runtime_status = "blocked"
285
+ else:
286
+ self.session.pending_actions.append(action)
287
+ self.session.runtime_status = "waiting-approval"
288
+ return action
289
+
290
+ def approve_action(self, action_id: str) -> ActionResult:
291
+ action = self._remove_pending(action_id)
292
+ action.status = "approved"
293
+ self.session.approved_actions.append(action)
294
+ self.session.runtime_status = "acting"
295
+ return action
296
+
297
+ def reject_action(self, action_id: str, reason: str) -> ActionResult:
298
+ action = self._remove_pending(action_id)
299
+ action.status = "rejected"
300
+ action.result = reason
301
+ self.session.rejected_actions.append(action)
302
+ self.session.runtime_status = "blocked"
303
+ return action
304
+
305
+ def execute_action(self, action_id: str) -> ActionResult:
306
+ action = self._find_action(action_id)
307
+ if action.status == "pending":
308
+ action = self.approve_action(action_id)
309
+ if action.status != "approved":
310
+ raise AgentPortalError(
311
+ f"Action {action_id} is not approved for execution.",
312
+ module="agent_portal.runtime",
313
+ likely_cause="The action is blocked, rejected, completed, or failed.",
314
+ suggested_fix="Approve a pending action before trying to execute it.",
315
+ can_continue=True,
316
+ )
317
+
318
+ return self._execute_approved_action(
319
+ action.action_id,
320
+ lambda approved: self._dispatch_approved_action(approved),
321
+ )
322
+
323
+ def complete_action(
324
+ self,
325
+ action_id: str,
326
+ result: str,
327
+ before_screenshot: str | None = None,
328
+ after_screenshot: str | None = None,
329
+ ) -> ActionResult:
330
+ action = next(entry for entry in self.session.approved_actions if entry.action_id == action_id)
331
+ action.status = "completed"
332
+ action.result = result
333
+ action.before_screenshot = before_screenshot
334
+ action.after_screenshot = after_screenshot
335
+ self.session.approved_actions = [
336
+ entry for entry in self.session.approved_actions if entry.action_id != action_id
337
+ ]
338
+ self.session.completed_actions.append(action)
339
+ self.session.console_errors = self.browser.read_console()
340
+ self.session.network_errors = self.browser.read_network()
341
+ self.session.runtime_status = "idle"
342
+ return action
343
+
344
+ def fail_action(self, action_id: str, error: AgentPortalError) -> ActionResult:
345
+ action = next(entry for entry in self.session.approved_actions if entry.action_id == action_id)
346
+ action.status = "failed"
347
+ action.error = error.to_dict()
348
+ action.result = str(error)
349
+ self.session.approved_actions = [
350
+ entry for entry in self.session.approved_actions if entry.action_id != action_id
351
+ ]
352
+ self.session.failed_actions.append(action)
353
+ self.browser_state.last_error = str(error)
354
+ self.session.runtime_status = "failed"
355
+ return action
356
+
357
+ def generate_report(self) -> Path:
358
+ report_dir = self.base_path / self.config.report_directory
359
+ report_dir.mkdir(parents=True, exist_ok=True)
360
+ report = SessionReport(
361
+ project_name=self.base_path.name,
362
+ runtime_version="0.0.2",
363
+ session_id=self.session.session_id,
364
+ start_time=self.session.started_at,
365
+ end_time=utc_now(),
366
+ browser_used=self.browser_state.browser_name,
367
+ current_url=self.browser_state.current_url,
368
+ goals=[self.session.current_goal] if self.session.current_goal else [],
369
+ actions_proposed=self.session.pending_actions + self.session.completed_actions,
370
+ actions_approved=self.session.approved_actions + self.session.completed_actions,
371
+ actions_rejected=self.session.rejected_actions,
372
+ actions_blocked=self.session.blocked_actions,
373
+ actions_completed=[action for action in self.session.completed_actions if action.status == "completed"],
374
+ console_errors=self.session.console_errors,
375
+ network_errors=self.session.network_errors,
376
+ screenshots=[
377
+ action.after_screenshot
378
+ for action in self.session.completed_actions + self.session.failed_actions
379
+ if action.after_screenshot
380
+ ],
381
+ risk_events=[
382
+ f"{action.action_type}:{action.risk_level}"
383
+ for action in self.session.completed_actions + self.session.blocked_actions + self.session.failed_actions
384
+ ],
385
+ failed_steps=[action.reason for action in self.session.failed_actions],
386
+ suggested_fixes=[
387
+ "Review blocked or rejected actions before retrying.",
388
+ "Check local development server health if navigation or network actions fail.",
389
+ ],
390
+ reproduction_steps=[
391
+ action.action_type for action in self.session.completed_actions
392
+ ],
393
+ environment_details={
394
+ "host": self.config.runtime_host,
395
+ "port": str(self.config.runtime_port),
396
+ "browser": self.config.default_browser,
397
+ },
398
+ )
399
+ report_path = report_dir / f"{self.session.session_id}.json"
400
+ report_path.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf8")
401
+ return report_path
402
+
403
+ def list_plugins(self) -> list[dict[str, Any]]:
404
+ manifests: list[dict[str, Any]] = []
405
+ for plugin_path in discover_plugins(self.base_path):
406
+ manifests.append(
407
+ {
408
+ "path": str(plugin_path),
409
+ "errors": validate_plugin_manifest(plugin_path),
410
+ }
411
+ )
412
+ return manifests
413
+
414
+ def close_browser(self) -> ActionResult:
415
+ return self._execute_action(
416
+ ActionRequest("browser_close", "Close the browser session", risk_level="medium"),
417
+ lambda action: self._close_browser_impl(action),
418
+ )
419
+
420
+ def refresh(self) -> ActionResult:
421
+ return self._execute_action(
422
+ ActionRequest("browser_refresh", "Refresh the current page", risk_level="low"),
423
+ lambda action: self._refresh_impl(action),
424
+ )
425
+
426
+ def back(self) -> ActionResult:
427
+ return self._execute_action(
428
+ ActionRequest("browser_back", "Navigate backward in browser history", risk_level="low"),
429
+ lambda action: self._back_impl(action),
430
+ )
431
+
432
+ def forward(self) -> ActionResult:
433
+ return self._execute_action(
434
+ ActionRequest("browser_forward", "Navigate forward in browser history", risk_level="low"),
435
+ lambda action: self._forward_impl(action),
436
+ )
437
+
438
+ def browser_status(self) -> dict[str, Any]:
439
+ return asdict(self.browser_state)
440
+
441
+ def read_dom(self) -> dict[str, Any]:
442
+ return {
443
+ "url": self.browser.current_url(),
444
+ "dom": self.browser.read_dom(),
445
+ }
446
+
447
+ def read_accessibility_tree(self) -> dict[str, Any]:
448
+ return {
449
+ "url": self.browser.current_url(),
450
+ "accessibilityTree": self.browser.read_accessibility_tree(),
451
+ }
452
+
453
+ def read_console_errors(self) -> dict[str, Any]:
454
+ return {
455
+ "consoleErrors": self.browser.read_console(),
456
+ }
457
+
458
+ def read_network_failures(self) -> dict[str, Any]:
459
+ return {
460
+ "networkFailures": self.browser.read_network(),
461
+ }
462
+
463
+ def inspect_element(self, selector: str) -> dict[str, Any]:
464
+ return self.browser.inspect_element(selector)
465
+
466
+ def list_reports(self) -> list[dict[str, str]]:
467
+ report_dir = self.base_path / self.config.report_directory
468
+ if not report_dir.exists():
469
+ return []
470
+ return [
471
+ {"name": report.name, "path": str(report)}
472
+ for report in sorted(report_dir.glob("*.json"), reverse=True)
473
+ ]
474
+
475
+ def read_report(self, report_name: str) -> dict[str, Any]:
476
+ report_path = self._resolve_report_path(report_name)
477
+ return json.loads(report_path.read_text(encoding="utf8"))
478
+
479
+ def export_report(self, report_name: str, destination: str | None = None) -> dict[str, str]:
480
+ report_path = self._resolve_report_path(report_name)
481
+ export_dir = self.base_path / self.config.report_directory / "exports"
482
+ export_dir.mkdir(parents=True, exist_ok=True)
483
+ destination_path = Path(destination) if destination else export_dir / report_path.name
484
+ destination_path.write_text(report_path.read_text(encoding="utf8"), encoding="utf8")
485
+ return {"exportPath": str(destination_path)}
486
+
487
+ def _enforce_policy(self, request: ActionRequest) -> None:
488
+ haystack = f"{request.reason} {request.target or ''} {request.payload or ''}".lower()
489
+ if self.risk_policy.read_only and request.action_type not in {"inspect", "screenshot", "wait"}:
490
+ request.risk_level = "blocked"
491
+ elif any(token in haystack for token in ["payment", "billing", "checkout", "card"]):
492
+ request.risk_level = "blocked"
493
+ elif any(token in haystack for token in ["delete", "drop database", "destroy"]):
494
+ request.risk_level = "high"
495
+ elif any(token in haystack for token in ["submit", "send", "publish", "login"]):
496
+ request.risk_level = max_risk(request.risk_level, "medium")
497
+
498
+ if request.target and "password" in request.target.lower():
499
+ request.risk_level = "blocked"
500
+
501
+ if self.risk_policy.domain_lock and request.action_type == "open_url" and request.target:
502
+ if self.risk_policy.domain_lock not in request.target:
503
+ request.risk_level = "blocked"
504
+
505
+ def _remove_pending(self, action_id: str) -> ActionResult:
506
+ for index, action in enumerate(self.session.pending_actions):
507
+ if action.action_id == action_id:
508
+ return self.session.pending_actions.pop(index)
509
+ raise AgentPortalError(
510
+ f"Pending action {action_id} was not found.",
511
+ module="agent_portal.runtime",
512
+ likely_cause="The action was already handled or the ID is incorrect.",
513
+ suggested_fix="Refresh the action queue and retry the operation.",
514
+ can_continue=True,
515
+ )
516
+
517
+ def _capture_if_allowed(self, label: str) -> str | None:
518
+ if not self.config.sensitive_screenshots_enabled and self.config.safe_mode:
519
+ return None
520
+ return self.browser.screenshot(label)
521
+
522
+ def _find_action(self, action_id: str) -> ActionResult:
523
+ for action in self._all_actions():
524
+ if action.action_id == action_id:
525
+ return action
526
+ raise AgentPortalError(
527
+ f"Action {action_id} was not found.",
528
+ module="agent_portal.runtime",
529
+ likely_cause="The action id is incorrect or the queue has already been rotated.",
530
+ suggested_fix="Refresh the action queue and use a current action id.",
531
+ can_continue=True,
532
+ )
533
+
534
+ def _all_actions(self) -> list[ActionResult]:
535
+ return (
536
+ self.session.pending_actions
537
+ + self.session.approved_actions
538
+ + self.session.completed_actions
539
+ + self.session.rejected_actions
540
+ + self.session.blocked_actions
541
+ + self.session.failed_actions
542
+ )
543
+
544
+ def _execute_action(
545
+ self,
546
+ request: ActionRequest,
547
+ callback: Callable[[ActionResult], ActionResult],
548
+ ) -> ActionResult:
549
+ action = self.propose_action(request)
550
+ if action.status == "blocked":
551
+ raise PolicyBlockedError(
552
+ "Action was blocked by the runtime policy engine.",
553
+ module="agent_portal.runtime",
554
+ likely_cause="The action exceeded the current safety policy or approval boundary.",
555
+ suggested_fix="Review the queue, adjust steering mode, or manually override the action if appropriate.",
556
+ can_continue=True,
557
+ )
558
+ return self._execute_approved_action(action.action_id, callback)
559
+
560
+ def _execute_approved_action(
561
+ self,
562
+ action_id: str,
563
+ callback: Callable[[ActionResult], ActionResult],
564
+ ) -> ActionResult:
565
+ approved = self.approve_action(action_id)
566
+ try:
567
+ result = callback(approved)
568
+ self.browser_state.current_url = self.browser.current_url()
569
+ self.browser_state.page_title = self.browser.current_title()
570
+ return result
571
+ except AgentPortalError as exc:
572
+ self.fail_action(action_id, exc)
573
+ raise
574
+ except Exception as exc:
575
+ wrapped = AgentPortalError(
576
+ "Browser action failed unexpectedly.",
577
+ module="agent_portal.runtime",
578
+ likely_cause=str(exc),
579
+ suggested_fix="Inspect the runtime logs and browser state, then retry the action.",
580
+ can_continue=True,
581
+ )
582
+ self.fail_action(action_id, wrapped)
583
+ raise wrapped from exc
584
+
585
+ def _open_url_impl(self, url: str, action: ActionResult) -> ActionResult:
586
+ before = self._capture_if_allowed(f"{action.action_id}-before")
587
+ self.browser.open_url(url)
588
+ after = self._capture_if_allowed(f"{action.action_id}-after")
589
+ return self.complete_action(action.action_id, "Opened URL", before, after)
590
+
591
+ def _click_impl(self, selector: str, action: ActionResult) -> ActionResult:
592
+ before = self._capture_if_allowed(f"{action.action_id}-before")
593
+ self.browser.click(selector)
594
+ after = self._capture_if_allowed(f"{action.action_id}-after")
595
+ return self.complete_action(action.action_id, "Clicked element", before, after)
596
+
597
+ def _type_impl(self, selector: str, value: str, action: ActionResult) -> ActionResult:
598
+ before = self._capture_if_allowed(f"{action.action_id}-before")
599
+ self.browser.type_text(selector, value)
600
+ after = self._capture_if_allowed(f"{action.action_id}-after")
601
+ return self.complete_action(action.action_id, "Typed into element", before, after)
602
+
603
+ def _scroll_impl(self, selector: str | None, action: ActionResult) -> ActionResult:
604
+ before = self._capture_if_allowed(f"{action.action_id}-before")
605
+ self.browser.scroll(selector)
606
+ after = self._capture_if_allowed(f"{action.action_id}-after")
607
+ return self.complete_action(action.action_id, "Scrolled", before, after)
608
+
609
+ def _hover_impl(self, selector: str, action: ActionResult) -> ActionResult:
610
+ before = self._capture_if_allowed(f"{action.action_id}-before")
611
+ self.browser.hover(selector)
612
+ after = self._capture_if_allowed(f"{action.action_id}-after")
613
+ return self.complete_action(action.action_id, "Hovered element", before, after)
614
+
615
+ def _wait_impl(self, selector: str, action: ActionResult) -> ActionResult:
616
+ self.browser.wait(selector)
617
+ return self.complete_action(action.action_id, "Waited for element")
618
+
619
+ def _read_text_impl(
620
+ self,
621
+ selector: str,
622
+ action: ActionResult,
623
+ holder: dict[str, Any],
624
+ ) -> ActionResult:
625
+ holder["text"] = self.browser.read_text(selector)
626
+ after = self._capture_if_allowed(f"{action.action_id}-after")
627
+ return self.complete_action(action.action_id, "Read text from element", after_screenshot=after)
628
+
629
+ def _execute_script_impl(
630
+ self,
631
+ script: str,
632
+ action: ActionResult,
633
+ holder: dict[str, Any],
634
+ ) -> ActionResult:
635
+ holder["result"] = self.browser.execute(script)
636
+ after = self._capture_if_allowed(f"{action.action_id}-after")
637
+ return self.complete_action(action.action_id, "Executed browser script", after_screenshot=after)
638
+
639
+ def _close_browser_impl(self, action: ActionResult) -> ActionResult:
640
+ self.browser.close_page()
641
+ self.browser_state.connected = False
642
+ self.browser_state.current_url = None
643
+ self.browser_state.page_title = None
644
+ return self.complete_action(action.action_id, "Closed browser session")
645
+
646
+ def _refresh_impl(self, action: ActionResult) -> ActionResult:
647
+ before = self._capture_if_allowed(f"{action.action_id}-before")
648
+ self.browser.refresh()
649
+ after = self._capture_if_allowed(f"{action.action_id}-after")
650
+ return self.complete_action(action.action_id, "Refreshed page", before, after)
651
+
652
+ def _back_impl(self, action: ActionResult) -> ActionResult:
653
+ before = self._capture_if_allowed(f"{action.action_id}-before")
654
+ self.browser.back()
655
+ after = self._capture_if_allowed(f"{action.action_id}-after")
656
+ return self.complete_action(action.action_id, "Navigated backward", before, after)
657
+
658
+ def _forward_impl(self, action: ActionResult) -> ActionResult:
659
+ before = self._capture_if_allowed(f"{action.action_id}-before")
660
+ self.browser.forward()
661
+ after = self._capture_if_allowed(f"{action.action_id}-after")
662
+ return self.complete_action(action.action_id, "Navigated forward", before, after)
663
+
664
+ def _dispatch_approved_action(self, action: ActionResult) -> ActionResult:
665
+ if action.action_type == "open_url":
666
+ return self._open_url_impl(action.target or "", action)
667
+ if action.action_type == "click":
668
+ return self._click_impl(action.target or "", action)
669
+ if action.action_type == "type":
670
+ return self._type_impl(action.target or "", action.payload or "", action)
671
+ if action.action_type == "scroll":
672
+ return self._scroll_impl(action.target, action)
673
+ if action.action_type == "hover":
674
+ return self._hover_impl(action.target or "", action)
675
+ if action.action_type == "wait":
676
+ return self._wait_impl(action.target or "", action)
677
+ if action.action_type == "screenshot":
678
+ return self.complete_action(
679
+ action.action_id,
680
+ "Captured screenshot",
681
+ after_screenshot=self.browser.screenshot(action.payload or "manual"),
682
+ )
683
+ if action.action_type == "browser_refresh":
684
+ return self._refresh_impl(action)
685
+ if action.action_type == "browser_back":
686
+ return self._back_impl(action)
687
+ if action.action_type == "browser_forward":
688
+ return self._forward_impl(action)
689
+ if action.action_type == "browser_close":
690
+ return self._close_browser_impl(action)
691
+ raise AgentPortalError(
692
+ f"Unsupported queued action type: {action.action_type}",
693
+ module="agent_portal.runtime",
694
+ likely_cause="The queued action type is not yet wired to a runtime implementation.",
695
+ suggested_fix="Use a supported browser action type or extend the runtime dispatcher.",
696
+ can_continue=True,
697
+ )
698
+
699
+ def _resolve_report_path(self, report_name: str) -> Path:
700
+ candidate = Path(report_name)
701
+ if candidate.is_absolute() and candidate.exists():
702
+ return candidate
703
+ report_path = self.base_path / self.config.report_directory / report_name
704
+ if report_path.exists():
705
+ return report_path
706
+ raise AgentPortalError(
707
+ f"Report {report_name} was not found.",
708
+ module="agent_portal.runtime",
709
+ likely_cause="The report name is stale or the file has been removed.",
710
+ suggested_fix="List available reports and retry with a current report name.",
711
+ can_continue=True,
712
+ )
713
+
714
+ def _ensure_single_instance(self) -> None:
715
+ if self._lock_path.exists():
716
+ try:
717
+ stale_pid = int(self._lock_path.read_text(encoding="utf8").strip())
718
+ os.kill(stale_pid, 0)
719
+ except (ValueError, ProcessLookupError, PermissionError):
720
+ self._remove_lock()
721
+ else:
722
+ raise OSError("Runtime lock file already exists")
723
+ self._lock_path.write_text(str(os.getpid()), encoding="utf8")
724
+
725
+ def _ensure_port_available(self) -> None:
726
+ sock = socket.socket()
727
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
728
+ try:
729
+ sock.bind((self.config.runtime_host, self.config.runtime_port))
730
+ finally:
731
+ sock.close()
732
+
733
+ def _remove_lock(self) -> None:
734
+ if self._lock_path.exists():
735
+ self._lock_path.unlink()
736
+
737
+
738
+ def max_risk(left: str, right: str) -> str:
739
+ return left if RISK_ORDER.index(left) >= RISK_ORDER.index(right) else right