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.
- package/.continue/agents/new-config.yaml +22 -0
- package/AGENT_STEERING.md +36 -0
- package/ARCHITECTURE.md +13 -0
- package/CHANGELOG.md +97 -0
- package/CLI.md +38 -0
- package/CONTRIBUTING.md +55 -0
- package/INSTALLATION.md +58 -0
- package/LICENSE +60 -0
- package/PLUGIN_SYSTEM.md +33 -0
- package/PYTHON_SDK.md +22 -0
- package/QUICKSTART.md +19 -0
- package/README.md +385 -0
- package/RELEASE_NOTES_v0.1.0.md +281 -0
- package/ROADMAP.md +3 -0
- package/RUNTIME.md +44 -0
- package/SAFETY_MODEL.md +24 -0
- package/TESTING.md +35 -0
- package/TROUBLESHOOTING.md +30 -0
- package/UPGRADE_GUIDE.md +288 -0
- package/VS_CODE_EXTENSION.md +47 -0
- package/agent-portal.config.json +20 -0
- package/apps/desktop/agent-portal-desktop.zip +0 -0
- package/apps/desktop/fixtures/local-workflow.html +151 -0
- package/apps/desktop/package.json +18 -0
- package/apps/desktop/src/main.ts +117 -0
- package/apps/desktop/tsconfig.json +8 -0
- package/apps/vscode-extension/LICENSE +60 -0
- package/apps/vscode-extension/README.md +20 -0
- package/apps/vscode-extension/media/agent-portal-logo.png +0 -0
- package/apps/vscode-extension/package.json +149 -0
- package/apps/vscode-extension/src/extension.ts +614 -0
- package/apps/vscode-extension/tsconfig.json +12 -0
- package/assets/branding/agent-portal-logo.png +0 -0
- package/connectors/chatgpt-tools/README.md +9 -0
- package/connectors/claude-mcp-server/README.md +9 -0
- package/connectors/gemini-connector/README.md +9 -0
- package/connectors/rest-websocket-api/README.md +9 -0
- package/docs/MCP_SERVER.md +68 -0
- package/docs/architecture.md +214 -0
- package/docs/roadmap.md +125 -0
- package/package.json +21 -0
- package/packages/agent-portal-mcp/README.md +12 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/__init__.py +3 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/bridge/__init__.py +1 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/bridge/runtime_client.py +180 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/cli.py +32 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/doctor.py +71 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/schemas/__init__.py +1 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/schemas/actions.py +17 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/schemas/results.py +24 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/schemas/risk.py +20 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/security/__init__.py +1 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/security/policy.py +27 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/server.py +148 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tool_registry.py +58 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/__init__.py +1 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/browser.py +89 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/common.py +98 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/inspection.py +93 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/navigation.py +93 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/reports.py +34 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/steering.py +93 -0
- package/packages/agent-portal-mcp/pyproject.toml +20 -0
- package/packages/agent-portal-mcp/tests/test_doctor.py +20 -0
- package/packages/agent-portal-mcp/tests/test_mcp_server.py +161 -0
- package/packages/core/package.json +15 -0
- package/packages/core/src/index.ts +1842 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/mcp-server/package.json +15 -0
- package/packages/mcp-server/src/index.ts +73 -0
- package/packages/mcp-server/tsconfig.json +8 -0
- package/packages/sdk/package.json +15 -0
- package/packages/sdk/src/index.ts +544 -0
- package/packages/sdk/tsconfig.json +8 -0
- package/plugins/README.md +16 -0
- package/plugins/agent-portal-browser/plugin.json +19 -0
- package/plugins/agent-portal-python/plugin.json +16 -0
- package/plugins/agent-portal-skills/plugin.json +19 -0
- package/plugins/agent-portal-vscode/plugin.json +27 -0
- package/plugins/example-runtime-plugin/README.md +3 -0
- package/plugins/example-runtime-plugin/plugin.json +20 -0
- package/plugins/plugin.schema.json +53 -0
- package/python/README.md +18 -0
- package/python/agent_portal/__init__.py +5 -0
- package/python/agent_portal/__main__.py +5 -0
- package/python/agent_portal/browser.py +393 -0
- package/python/agent_portal/cli.py +164 -0
- package/python/agent_portal/config.py +31 -0
- package/python/agent_portal/doctor.py +165 -0
- package/python/agent_portal/exceptions.py +39 -0
- package/python/agent_portal/logging_utils.py +33 -0
- package/python/agent_portal/metrics.py +309 -0
- package/python/agent_portal/models.py +160 -0
- package/python/agent_portal/plugin_system.py +42 -0
- package/python/agent_portal/rate_limit.py +253 -0
- package/python/agent_portal/runtime.py +739 -0
- package/python/agent_portal/server.py +351 -0
- package/python/agent_portal/validation.py +299 -0
- package/python/pyproject.toml +29 -0
- package/python/tests/test_config.py +24 -0
- package/python/tests/test_doctor.py +19 -0
- package/python/tests/test_metrics.py +180 -0
- package/python/tests/test_rate_limit.py +237 -0
- package/python/tests/test_runtime.py +122 -0
- package/python/tests/test_server.py +53 -0
- package/python/tests/test_validation.py +170 -0
- package/releases/desktop/agent-portal-desktop/README.md +378 -0
- package/releases/desktop/agent-portal-desktop/RELEASE_NOTES.md +14 -0
- package/releases/desktop/agent-portal-desktop/assets/branding/agent-portal-logo.png +0 -0
- package/releases/desktop/agent-portal-desktop/fixtures/local-workflow.html +151 -0
- package/releases/desktop/agent-portal-desktop/launch-agent-portal.bat +4 -0
- package/releases/desktop/agent-portal-desktop.zip +0 -0
- package/releases/python/agent_portal-0.0.2-py3-none-any.whl +0 -0
- package/releases/python/agent_portal-0.0.2.tar.gz +0 -0
- package/scripts/package_desktop.mjs +117 -0
- package/scripts/release_python.py +46 -0
- package/tests/plugin-manifest.test.mjs +26 -0
- package/tests/runtime.test.mjs +41 -0
- package/tests/vscode-extension.test.mjs +22 -0
- package/tsconfig.base.json +16 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from .exceptions import AgentPortalError
|
|
12
|
+
from .models import ActionRequest
|
|
13
|
+
from .runtime import PortalRuntime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_server(runtime: PortalRuntime) -> HTTPServer:
|
|
17
|
+
class AgentPortalHandler(BaseHTTPRequestHandler):
|
|
18
|
+
server_version = "AgentPortalRuntime/0.0.3"
|
|
19
|
+
|
|
20
|
+
def _send_json(self, status: int, payload: object) -> None:
|
|
21
|
+
encoded = json.dumps(payload, indent=2).encode("utf8")
|
|
22
|
+
self.send_response(status)
|
|
23
|
+
self.send_header("Content-Type", "application/json")
|
|
24
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
25
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
26
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
27
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
28
|
+
self.end_headers()
|
|
29
|
+
self.wfile.write(encoded)
|
|
30
|
+
|
|
31
|
+
def _read_json(self) -> dict[str, object]:
|
|
32
|
+
content_length = int(self.headers.get("Content-Length", "0"))
|
|
33
|
+
if content_length == 0:
|
|
34
|
+
return {}
|
|
35
|
+
return json.loads(self.rfile.read(content_length).decode("utf8"))
|
|
36
|
+
|
|
37
|
+
def _authorize(self) -> bool:
|
|
38
|
+
token = runtime.config.api_token
|
|
39
|
+
if not token:
|
|
40
|
+
return True
|
|
41
|
+
header = self.headers.get("Authorization", "")
|
|
42
|
+
return header == f"Bearer {token}"
|
|
43
|
+
|
|
44
|
+
def _unauthorized(self) -> None:
|
|
45
|
+
self._send_json(
|
|
46
|
+
HTTPStatus.UNAUTHORIZED,
|
|
47
|
+
{
|
|
48
|
+
"error": "Unauthorized",
|
|
49
|
+
"suggestedFix": "Provide the configured runtime API token as a Bearer token.",
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def do_OPTIONS(self) -> None: # noqa: N802
|
|
54
|
+
self.send_response(HTTPStatus.NO_CONTENT)
|
|
55
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
56
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
57
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
58
|
+
self.end_headers()
|
|
59
|
+
|
|
60
|
+
def do_GET(self) -> None: # noqa: N802
|
|
61
|
+
parsed = urlparse(self.path)
|
|
62
|
+
if parsed.path != "/health" and not self._authorize():
|
|
63
|
+
self._unauthorized()
|
|
64
|
+
return
|
|
65
|
+
if parsed.path == "/health":
|
|
66
|
+
self._send_json(HTTPStatus.OK, runtime.health())
|
|
67
|
+
return
|
|
68
|
+
if parsed.path == "/status":
|
|
69
|
+
self._send_json(HTTPStatus.OK, runtime.status())
|
|
70
|
+
return
|
|
71
|
+
if parsed.path == "/report/latest":
|
|
72
|
+
report_path = runtime.generate_report()
|
|
73
|
+
self._send_json(
|
|
74
|
+
HTTPStatus.OK,
|
|
75
|
+
{
|
|
76
|
+
"reportPath": str(report_path),
|
|
77
|
+
"report": json.loads(report_path.read_text(encoding="utf8")),
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown path"})
|
|
82
|
+
|
|
83
|
+
def do_POST(self) -> None: # noqa: N802
|
|
84
|
+
parsed = urlparse(self.path)
|
|
85
|
+
if not self._authorize():
|
|
86
|
+
self._unauthorized()
|
|
87
|
+
return
|
|
88
|
+
payload = self._read_json()
|
|
89
|
+
try:
|
|
90
|
+
if parsed.path == "/control/start":
|
|
91
|
+
runtime.start()
|
|
92
|
+
self._send_json(HTTPStatus.OK, runtime.status())
|
|
93
|
+
return
|
|
94
|
+
if parsed.path == "/control/stop":
|
|
95
|
+
self._send_json(HTTPStatus.OK, {"status": "stopping"})
|
|
96
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
97
|
+
runtime.stop()
|
|
98
|
+
return
|
|
99
|
+
if parsed.path == "/control/pause":
|
|
100
|
+
runtime.pause()
|
|
101
|
+
self._send_json(HTTPStatus.OK, runtime.status())
|
|
102
|
+
return
|
|
103
|
+
if parsed.path == "/control/resume":
|
|
104
|
+
runtime.resume()
|
|
105
|
+
self._send_json(HTTPStatus.OK, runtime.status())
|
|
106
|
+
return
|
|
107
|
+
if parsed.path == "/control/restart":
|
|
108
|
+
runtime.restart()
|
|
109
|
+
self._send_json(HTTPStatus.OK, runtime.status())
|
|
110
|
+
return
|
|
111
|
+
if parsed.path == "/control/goal":
|
|
112
|
+
runtime.set_goal(str(payload.get("goal", "")))
|
|
113
|
+
self._send_json(HTTPStatus.OK, runtime.status())
|
|
114
|
+
return
|
|
115
|
+
if parsed.path == "/control/goal/current":
|
|
116
|
+
self._send_json(
|
|
117
|
+
HTTPStatus.OK,
|
|
118
|
+
{"goal": runtime.get_current_goal()},
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
if parsed.path == "/control/action-mode":
|
|
122
|
+
runtime.set_action_mode(str(payload.get("mode", "")))
|
|
123
|
+
self._send_json(HTTPStatus.OK, runtime.status())
|
|
124
|
+
return
|
|
125
|
+
if parsed.path == "/control/action-queue":
|
|
126
|
+
self._send_json(HTTPStatus.OK, {"actions": runtime.get_action_queue()})
|
|
127
|
+
return
|
|
128
|
+
if parsed.path == "/control/propose-action":
|
|
129
|
+
action = runtime.propose_action(
|
|
130
|
+
ActionRequest(
|
|
131
|
+
action_type=str(payload.get("actionType", "")),
|
|
132
|
+
reason=str(payload.get("reason", "")),
|
|
133
|
+
target=str(payload["target"]) if "target" in payload and payload["target"] is not None else None,
|
|
134
|
+
payload=str(payload["payload"]) if "payload" in payload and payload["payload"] is not None else None,
|
|
135
|
+
risk_level=str(payload.get("riskLevel", "low")),
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
self._send_json(HTTPStatus.OK, asdict(action))
|
|
139
|
+
return
|
|
140
|
+
if parsed.path == "/control/approve-action":
|
|
141
|
+
action_id = str(payload.get("actionId", ""))
|
|
142
|
+
execute_now = bool(payload.get("execute", False))
|
|
143
|
+
result = runtime.execute_action(action_id) if execute_now else runtime.approve_action(action_id)
|
|
144
|
+
self._send_json(HTTPStatus.OK, asdict(result))
|
|
145
|
+
return
|
|
146
|
+
if parsed.path == "/control/reject-action":
|
|
147
|
+
result = runtime.reject_action(
|
|
148
|
+
str(payload.get("actionId", "")),
|
|
149
|
+
str(payload.get("reason", "Rejected by user")),
|
|
150
|
+
)
|
|
151
|
+
self._send_json(HTTPStatus.OK, asdict(result))
|
|
152
|
+
return
|
|
153
|
+
if parsed.path == "/browser/start":
|
|
154
|
+
runtime.ensure_browser()
|
|
155
|
+
self._send_json(HTTPStatus.OK, runtime.status())
|
|
156
|
+
return
|
|
157
|
+
if parsed.path == "/browser/close":
|
|
158
|
+
self._send_json(HTTPStatus.OK, asdict(runtime.close_browser()))
|
|
159
|
+
return
|
|
160
|
+
if parsed.path == "/browser/refresh":
|
|
161
|
+
self._send_json(HTTPStatus.OK, asdict(runtime.refresh()))
|
|
162
|
+
return
|
|
163
|
+
if parsed.path == "/browser/back":
|
|
164
|
+
self._send_json(HTTPStatus.OK, asdict(runtime.back()))
|
|
165
|
+
return
|
|
166
|
+
if parsed.path == "/browser/forward":
|
|
167
|
+
self._send_json(HTTPStatus.OK, asdict(runtime.forward()))
|
|
168
|
+
return
|
|
169
|
+
if parsed.path == "/browser/status":
|
|
170
|
+
self._send_json(HTTPStatus.OK, runtime.browser_status())
|
|
171
|
+
return
|
|
172
|
+
if parsed.path == "/browser/open":
|
|
173
|
+
self._send_json(
|
|
174
|
+
HTTPStatus.OK,
|
|
175
|
+
asdict(runtime.open_url(str(payload.get("url", "")))),
|
|
176
|
+
)
|
|
177
|
+
return
|
|
178
|
+
if parsed.path == "/browser/click":
|
|
179
|
+
self._send_json(
|
|
180
|
+
HTTPStatus.OK,
|
|
181
|
+
asdict(
|
|
182
|
+
runtime.click(
|
|
183
|
+
str(payload.get("selector", "")),
|
|
184
|
+
str(payload.get("reason", "Click element")),
|
|
185
|
+
)
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
return
|
|
189
|
+
if parsed.path == "/browser/type":
|
|
190
|
+
self._send_json(
|
|
191
|
+
HTTPStatus.OK,
|
|
192
|
+
asdict(
|
|
193
|
+
runtime.type_text(
|
|
194
|
+
str(payload.get("selector", "")),
|
|
195
|
+
str(payload.get("value", "")),
|
|
196
|
+
str(payload.get("reason", "Type into element")),
|
|
197
|
+
)
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
return
|
|
201
|
+
if parsed.path == "/browser/scroll":
|
|
202
|
+
self._send_json(
|
|
203
|
+
HTTPStatus.OK,
|
|
204
|
+
asdict(
|
|
205
|
+
runtime.scroll(
|
|
206
|
+
str(payload["selector"]) if "selector" in payload and payload["selector"] is not None else None,
|
|
207
|
+
str(payload.get("reason", "Scroll page")),
|
|
208
|
+
)
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
if parsed.path == "/browser/hover":
|
|
213
|
+
self._send_json(
|
|
214
|
+
HTTPStatus.OK,
|
|
215
|
+
asdict(
|
|
216
|
+
runtime.hover(
|
|
217
|
+
str(payload.get("selector", "")),
|
|
218
|
+
str(payload.get("reason", "Hover over element")),
|
|
219
|
+
)
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
return
|
|
223
|
+
if parsed.path == "/browser/wait":
|
|
224
|
+
self._send_json(
|
|
225
|
+
HTTPStatus.OK,
|
|
226
|
+
asdict(
|
|
227
|
+
runtime.wait(
|
|
228
|
+
str(payload.get("selector", "")),
|
|
229
|
+
str(payload.get("reason", "Wait for element")),
|
|
230
|
+
)
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
if parsed.path == "/browser/screenshot":
|
|
235
|
+
self._send_json(
|
|
236
|
+
HTTPStatus.OK,
|
|
237
|
+
asdict(runtime.screenshot(str(payload.get("label", "manual")))),
|
|
238
|
+
)
|
|
239
|
+
return
|
|
240
|
+
if parsed.path == "/browser/capture":
|
|
241
|
+
self._send_json(
|
|
242
|
+
HTTPStatus.OK,
|
|
243
|
+
runtime.capture_snapshot(str(payload.get("label", "capture"))),
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
if parsed.path == "/browser/inspect":
|
|
247
|
+
self._send_json(HTTPStatus.OK, runtime.inspect())
|
|
248
|
+
return
|
|
249
|
+
if parsed.path == "/browser/read-dom":
|
|
250
|
+
self._send_json(HTTPStatus.OK, runtime.read_dom())
|
|
251
|
+
return
|
|
252
|
+
if parsed.path == "/browser/read-accessibility-tree":
|
|
253
|
+
self._send_json(HTTPStatus.OK, runtime.read_accessibility_tree())
|
|
254
|
+
return
|
|
255
|
+
if parsed.path == "/browser/read-console-errors":
|
|
256
|
+
self._send_json(HTTPStatus.OK, runtime.read_console_errors())
|
|
257
|
+
return
|
|
258
|
+
if parsed.path == "/browser/read-network-failures":
|
|
259
|
+
self._send_json(HTTPStatus.OK, runtime.read_network_failures())
|
|
260
|
+
return
|
|
261
|
+
if parsed.path == "/browser/read-text":
|
|
262
|
+
self._send_json(
|
|
263
|
+
HTTPStatus.OK,
|
|
264
|
+
runtime.read_text(
|
|
265
|
+
str(payload.get("selector", "")),
|
|
266
|
+
str(payload.get("reason", "Read text from element")),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
if parsed.path == "/browser/execute":
|
|
271
|
+
self._send_json(
|
|
272
|
+
HTTPStatus.OK,
|
|
273
|
+
runtime.execute(
|
|
274
|
+
str(payload.get("script", "")),
|
|
275
|
+
str(payload.get("reason", "Execute script in page context")),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
if parsed.path == "/browser/inspect-element":
|
|
280
|
+
self._send_json(
|
|
281
|
+
HTTPStatus.OK,
|
|
282
|
+
runtime.inspect_element(str(payload.get("selector", ""))),
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
if parsed.path == "/control/approve-next":
|
|
286
|
+
if runtime.session.pending_actions:
|
|
287
|
+
result = runtime.approve_action(runtime.session.pending_actions[0].action_id)
|
|
288
|
+
self._send_json(HTTPStatus.OK, asdict(result))
|
|
289
|
+
return
|
|
290
|
+
self._send_json(HTTPStatus.OK, {"message": "No pending action"})
|
|
291
|
+
return
|
|
292
|
+
if parsed.path == "/control/reject-next":
|
|
293
|
+
if runtime.session.pending_actions:
|
|
294
|
+
result = runtime.reject_action(
|
|
295
|
+
runtime.session.pending_actions[0].action_id,
|
|
296
|
+
str(payload.get("reason", "Rejected by user")),
|
|
297
|
+
)
|
|
298
|
+
self._send_json(HTTPStatus.OK, asdict(result))
|
|
299
|
+
return
|
|
300
|
+
self._send_json(HTTPStatus.OK, {"message": "No pending action"})
|
|
301
|
+
return
|
|
302
|
+
if parsed.path == "/report/generate":
|
|
303
|
+
report_path = runtime.generate_report()
|
|
304
|
+
self._send_json(HTTPStatus.OK, {"reportPath": str(report_path)})
|
|
305
|
+
return
|
|
306
|
+
if parsed.path == "/report/list":
|
|
307
|
+
self._send_json(HTTPStatus.OK, {"reports": runtime.list_reports()})
|
|
308
|
+
return
|
|
309
|
+
if parsed.path == "/report/read":
|
|
310
|
+
self._send_json(
|
|
311
|
+
HTTPStatus.OK,
|
|
312
|
+
runtime.read_report(str(payload.get("report", ""))),
|
|
313
|
+
)
|
|
314
|
+
return
|
|
315
|
+
if parsed.path == "/report/export":
|
|
316
|
+
self._send_json(
|
|
317
|
+
HTTPStatus.OK,
|
|
318
|
+
runtime.export_report(
|
|
319
|
+
str(payload.get("report", "")),
|
|
320
|
+
str(payload["destination"]) if "destination" in payload and payload["destination"] is not None else None,
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
return
|
|
324
|
+
self._send_json(HTTPStatus.NOT_FOUND, {"error": "Unknown path"})
|
|
325
|
+
except AgentPortalError as exc:
|
|
326
|
+
self._send_json(HTTPStatus.BAD_REQUEST, exc.to_dict())
|
|
327
|
+
|
|
328
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A003
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
server = HTTPServer(
|
|
332
|
+
(runtime.config.runtime_host, runtime.config.runtime_port),
|
|
333
|
+
AgentPortalHandler,
|
|
334
|
+
)
|
|
335
|
+
server.allow_reuse_address = True
|
|
336
|
+
return server
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def serve(runtime: PortalRuntime | None = None) -> None:
|
|
340
|
+
active_runtime = runtime or PortalRuntime(Path.cwd())
|
|
341
|
+
active_runtime.start()
|
|
342
|
+
server = build_server(active_runtime)
|
|
343
|
+
try:
|
|
344
|
+
server.serve_forever()
|
|
345
|
+
finally:
|
|
346
|
+
server.server_close()
|
|
347
|
+
active_runtime.stop()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def main() -> None:
|
|
351
|
+
serve()
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Input validation and sanitization utilities for Agent Portal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ValidationResult:
|
|
13
|
+
"""Result of a validation check."""
|
|
14
|
+
is_valid: bool
|
|
15
|
+
errors: list[str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ValidationError(Exception):
|
|
19
|
+
"""Raised when validation fails."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# URL validation patterns
|
|
24
|
+
SAFE_PROTOCOLS = {"http", "https"}
|
|
25
|
+
DOMAIN_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$")
|
|
26
|
+
LOCALHOST_PATTERNS = {
|
|
27
|
+
"localhost",
|
|
28
|
+
"127.0.0.1",
|
|
29
|
+
"0.0.0.0",
|
|
30
|
+
"::1",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_url(url: str, allow_local: bool = True) -> ValidationResult:
|
|
35
|
+
"""
|
|
36
|
+
Validate a URL for safety and structure.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
url: The URL to validate
|
|
40
|
+
allow_local: Whether to allow localhost/local network addresses
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
ValidationResult indicating if the URL is valid
|
|
44
|
+
"""
|
|
45
|
+
errors: list[str] = []
|
|
46
|
+
|
|
47
|
+
if not url or not isinstance(url, str):
|
|
48
|
+
errors.append("URL must be a non-empty string")
|
|
49
|
+
return ValidationResult(False, errors)
|
|
50
|
+
|
|
51
|
+
# Check for dangerous patterns first (before parsing)
|
|
52
|
+
url_lower = url.lower()
|
|
53
|
+
if any(pattern in url_lower for pattern in ["javascript:", "data:", "file:", "ftp:", "vbscript:"]):
|
|
54
|
+
errors.append("Potentially dangerous URL pattern detected")
|
|
55
|
+
return ValidationResult(False, errors)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
parsed = urlparse(url)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
errors.append(f"Invalid URL format: {exc}")
|
|
61
|
+
return ValidationResult(False, errors)
|
|
62
|
+
|
|
63
|
+
if not parsed.scheme:
|
|
64
|
+
errors.append("URL must include a protocol (http:// or https://)")
|
|
65
|
+
elif parsed.scheme not in SAFE_PROTOCOLS:
|
|
66
|
+
errors.append(f"Unsupported protocol: {parsed.scheme}. Only http:// and https:// are allowed")
|
|
67
|
+
|
|
68
|
+
if not parsed.netloc:
|
|
69
|
+
errors.append("URL must include a hostname")
|
|
70
|
+
return ValidationResult(False, errors)
|
|
71
|
+
|
|
72
|
+
# Check for localhost if not explicitly allowed
|
|
73
|
+
if not allow_local:
|
|
74
|
+
if parsed.hostname in LOCALHOST_PATTERNS or parsed.hostname == "0.0.0.0":
|
|
75
|
+
errors.append("Localhost addresses are not allowed in this mode")
|
|
76
|
+
|
|
77
|
+
return ValidationResult(len(errors) == 0, errors)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_selector(selector: str | None) -> ValidationResult:
|
|
81
|
+
"""
|
|
82
|
+
Validate a CSS selector for injection safety.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
selector: The CSS selector to validate
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
ValidationResult indicating if the selector is safe
|
|
89
|
+
"""
|
|
90
|
+
errors: list[str] = []
|
|
91
|
+
|
|
92
|
+
if not selector:
|
|
93
|
+
return ValidationResult(True, errors)
|
|
94
|
+
|
|
95
|
+
if not isinstance(selector, str):
|
|
96
|
+
errors.append("Selector must be a string")
|
|
97
|
+
return ValidationResult(False, errors)
|
|
98
|
+
|
|
99
|
+
if len(selector) > 1000:
|
|
100
|
+
errors.append("Selector is too long (max 1000 characters)")
|
|
101
|
+
|
|
102
|
+
# Check for potential XSS patterns
|
|
103
|
+
xss_patterns = [
|
|
104
|
+
"<script",
|
|
105
|
+
"javascript:",
|
|
106
|
+
"onerror=",
|
|
107
|
+
"onload=",
|
|
108
|
+
"onclick=",
|
|
109
|
+
"fromCharCode",
|
|
110
|
+
"eval(",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
selector_lower = selector.lower()
|
|
114
|
+
if any(pattern in selector_lower for pattern in xss_patterns):
|
|
115
|
+
errors.append("Selector contains potentially dangerous patterns")
|
|
116
|
+
|
|
117
|
+
return ValidationResult(len(errors) == 0, errors)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_script(script: str) -> ValidationResult:
|
|
121
|
+
"""
|
|
122
|
+
Validate a JavaScript script for basic safety.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
script: The JavaScript code to validate
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
ValidationResult indicating if the script passes basic checks
|
|
129
|
+
"""
|
|
130
|
+
errors: list[str] = []
|
|
131
|
+
|
|
132
|
+
if not script or not isinstance(script, str):
|
|
133
|
+
errors.append("Script must be a non-empty string")
|
|
134
|
+
return ValidationResult(False, errors)
|
|
135
|
+
|
|
136
|
+
if len(script) > 10000:
|
|
137
|
+
errors.append("Script is too long (max 10000 characters)")
|
|
138
|
+
|
|
139
|
+
# Check for obviously dangerous operations
|
|
140
|
+
dangerous_patterns = [
|
|
141
|
+
"document.cookie",
|
|
142
|
+
"localStorage",
|
|
143
|
+
"sessionStorage",
|
|
144
|
+
"window.location",
|
|
145
|
+
"eval(",
|
|
146
|
+
"Function(",
|
|
147
|
+
"document.write",
|
|
148
|
+
"XMLHttpRequest",
|
|
149
|
+
"fetch(",
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
script_lower = script.lower()
|
|
153
|
+
matches = [pattern for pattern in dangerous_patterns if pattern.lower() in script_lower]
|
|
154
|
+
|
|
155
|
+
if matches:
|
|
156
|
+
errors.append(f"Script contains potentially dangerous operations: {', '.join(matches)}")
|
|
157
|
+
|
|
158
|
+
return ValidationResult(len(errors) == 0, errors)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def validate_action_type(action_type: str) -> ValidationResult:
|
|
162
|
+
"""
|
|
163
|
+
Validate that an action type is supported.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
action_type: The action type to validate
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
ValidationResult indicating if the action type is valid
|
|
170
|
+
"""
|
|
171
|
+
errors: list[str] = []
|
|
172
|
+
|
|
173
|
+
valid_types = {
|
|
174
|
+
"open_url", "click", "type", "scroll", "hover", "wait",
|
|
175
|
+
"screenshot", "inspect", "read_text", "execute",
|
|
176
|
+
"browser_close", "browser_refresh", "browser_back", "browser_forward",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if not action_type or not isinstance(action_type, str):
|
|
180
|
+
errors.append("Action type must be a non-empty string")
|
|
181
|
+
return ValidationResult(False, errors)
|
|
182
|
+
|
|
183
|
+
if action_type not in valid_types:
|
|
184
|
+
errors.append(f"Unknown action type: {action_type}. Valid types: {', '.join(sorted(valid_types))}")
|
|
185
|
+
|
|
186
|
+
return ValidationResult(len(errors) == 0, errors)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def validate_risk_level(risk_level: str) -> ValidationResult:
|
|
190
|
+
"""
|
|
191
|
+
Validate that a risk level is within the allowed range.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
risk_level: The risk level to validate
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
ValidationResult indicating if the risk level is valid
|
|
198
|
+
"""
|
|
199
|
+
errors: list[str] = []
|
|
200
|
+
|
|
201
|
+
valid_levels = {"safe", "low", "medium", "high", "blocked"}
|
|
202
|
+
|
|
203
|
+
if not risk_level or not isinstance(risk_level, str):
|
|
204
|
+
errors.append("Risk level must be a non-empty string")
|
|
205
|
+
return ValidationResult(False, errors)
|
|
206
|
+
|
|
207
|
+
if risk_level not in valid_levels:
|
|
208
|
+
errors.append(f"Invalid risk level: {risk_level}. Valid levels: {', '.join(sorted(valid_levels))}")
|
|
209
|
+
|
|
210
|
+
return ValidationResult(len(errors) == 0, errors)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def validate_text_input(value: str, max_length: int = 500, field_name: str = "value") -> ValidationResult:
|
|
214
|
+
"""
|
|
215
|
+
Validate a general text input field.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
value: The text value to validate
|
|
219
|
+
max_length: Maximum allowed length
|
|
220
|
+
field_name: Name of the field for error messages
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
ValidationResult indicating if the input is valid
|
|
224
|
+
"""
|
|
225
|
+
errors: list[str] = []
|
|
226
|
+
|
|
227
|
+
if not isinstance(value, str):
|
|
228
|
+
errors.append(f"{field_name} must be a string")
|
|
229
|
+
return ValidationResult(False, errors)
|
|
230
|
+
|
|
231
|
+
if len(value) > max_length:
|
|
232
|
+
errors.append(f"{field_name} is too long (max {max_length} characters)")
|
|
233
|
+
|
|
234
|
+
return ValidationResult(len(errors) == 0, errors)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def sanitize_text(text: str) -> str:
|
|
238
|
+
"""
|
|
239
|
+
Sanitize text by removing or escaping potentially harmful characters.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
text: The text to sanitize
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Sanitized text
|
|
246
|
+
"""
|
|
247
|
+
if not text or not isinstance(text, str):
|
|
248
|
+
return ""
|
|
249
|
+
|
|
250
|
+
# Remove null bytes and other control characters except newlines and tabs
|
|
251
|
+
sanitized = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
|
|
252
|
+
|
|
253
|
+
# Trim whitespace
|
|
254
|
+
sanitized = sanitized.strip()
|
|
255
|
+
|
|
256
|
+
return sanitized
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def validate_config(config: dict[str, Any]) -> ValidationResult:
|
|
260
|
+
"""
|
|
261
|
+
Validate runtime configuration.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
config: Configuration dictionary to validate
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
ValidationResult indicating if the configuration is valid
|
|
268
|
+
"""
|
|
269
|
+
errors: list[str] = []
|
|
270
|
+
|
|
271
|
+
# Validate host
|
|
272
|
+
if "runtime_host" in config:
|
|
273
|
+
host = config["runtime_host"]
|
|
274
|
+
if not isinstance(host, str):
|
|
275
|
+
errors.append("runtime_host must be a string")
|
|
276
|
+
elif host == "0.0.0.0":
|
|
277
|
+
errors.append("runtime_host cannot be 0.0.0.0 for security reasons")
|
|
278
|
+
|
|
279
|
+
# Validate port
|
|
280
|
+
if "runtime_port" in config:
|
|
281
|
+
port = config["runtime_port"]
|
|
282
|
+
if not isinstance(port, int) or not (1024 <= port <= 65535):
|
|
283
|
+
errors.append("runtime_port must be an integer between 1024 and 65535")
|
|
284
|
+
|
|
285
|
+
# Validate action_mode
|
|
286
|
+
if "action_mode" in config:
|
|
287
|
+
mode = config["action_mode"]
|
|
288
|
+
valid_modes = {"read-only", "assisted", "autonomous", "manual-override"}
|
|
289
|
+
if mode not in valid_modes:
|
|
290
|
+
errors.append(f"action_mode must be one of: {', '.join(sorted(valid_modes))}")
|
|
291
|
+
|
|
292
|
+
# Validate approval_policy
|
|
293
|
+
if "approval_policy" in config:
|
|
294
|
+
policy = config["approval_policy"]
|
|
295
|
+
valid_policies = {"safe", "low", "medium", "high", "blocked"}
|
|
296
|
+
if policy not in valid_policies:
|
|
297
|
+
errors.append(f"approval_policy must be one of: {', '.join(sorted(valid_policies))}")
|
|
298
|
+
|
|
299
|
+
return ValidationResult(len(errors) == 0, errors)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agent-portal"
|
|
3
|
+
version = "0.0.3"
|
|
4
|
+
description = "Python runtime package for Agent Portal."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Magnexis" }
|
|
10
|
+
]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"playwright>=1.54.0"
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/magnexis/agent-portal"
|
|
17
|
+
Repository = "https://github.com/magnexis/agent-portal.git"
|
|
18
|
+
Issues = "https://github.com/magnexis/agent-portal/issues"
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
agent-portal-runtime = "agent_portal.server:main"
|
|
22
|
+
agent-portal = "agent_portal.cli:main"
|
|
23
|
+
|
|
24
|
+
[tool.flit.module]
|
|
25
|
+
name = "agent_portal"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["flit_core>=3.12.0,<4"]
|
|
29
|
+
build-backend = "flit_core.buildapi"
|