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,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
9
|
+
|
|
10
|
+
from agent_portal.config import load_config, save_default_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigTests(unittest.TestCase):
|
|
14
|
+
def test_default_config_can_be_created_and_loaded(self) -> None:
|
|
15
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
16
|
+
root = Path(temp_dir)
|
|
17
|
+
config_path = save_default_config(root)
|
|
18
|
+
config = load_config(root)
|
|
19
|
+
self.assertTrue(config_path.exists())
|
|
20
|
+
self.assertEqual(config.runtime_port, 8765)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
unittest.main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
8
|
+
|
|
9
|
+
from agent_portal.doctor import run_doctor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DoctorTests(unittest.TestCase):
|
|
13
|
+
def test_doctor_returns_checks(self) -> None:
|
|
14
|
+
report = run_doctor()
|
|
15
|
+
self.assertGreater(len(report.checks), 0)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
unittest.main()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Tests for metrics and telemetry module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
13
|
+
|
|
14
|
+
from agent_portal.metrics import (
|
|
15
|
+
MetricsCollector,
|
|
16
|
+
TimerContext,
|
|
17
|
+
MetricType,
|
|
18
|
+
get_metrics,
|
|
19
|
+
reset_metrics,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MetricsCollectorTests(unittest.TestCase):
|
|
24
|
+
"""Test cases for the MetricsCollector class."""
|
|
25
|
+
|
|
26
|
+
def setUp(self) -> None:
|
|
27
|
+
"""Reset metrics before each test."""
|
|
28
|
+
reset_metrics()
|
|
29
|
+
self.metrics = MetricsCollector()
|
|
30
|
+
|
|
31
|
+
def test_increment_counter(self) -> None:
|
|
32
|
+
self.metrics.increment("test.counter", 5.0)
|
|
33
|
+
self.assertEqual(self.metrics.get_counter("test.counter"), 5.0)
|
|
34
|
+
|
|
35
|
+
self.metrics.increment("test.counter", 3.0)
|
|
36
|
+
self.assertEqual(self.metrics.get_counter("test.counter"), 8.0)
|
|
37
|
+
|
|
38
|
+
def test_set_gauge(self) -> None:
|
|
39
|
+
self.metrics.set_gauge("test.gauge", 42.0)
|
|
40
|
+
self.assertEqual(self.metrics.get_gauge("test.gauge"), 42.0)
|
|
41
|
+
|
|
42
|
+
self.metrics.set_gauge("test.gauge", 99.0)
|
|
43
|
+
self.assertEqual(self.metrics.get_gauge("test.gauge"), 99.0)
|
|
44
|
+
|
|
45
|
+
def test_record_histogram(self) -> None:
|
|
46
|
+
values = [1.0, 2.0, 3.0, 4.0, 5.0]
|
|
47
|
+
for value in values:
|
|
48
|
+
self.metrics.record_histogram("test.histogram", value)
|
|
49
|
+
|
|
50
|
+
stats = self.metrics.get_histogram("test.histogram")
|
|
51
|
+
self.assertEqual(stats["count"], 5)
|
|
52
|
+
self.assertEqual(stats["sum"], 15.0)
|
|
53
|
+
self.assertEqual(stats["min"], 1.0)
|
|
54
|
+
self.assertEqual(stats["max"], 5.0)
|
|
55
|
+
self.assertEqual(stats["avg"], 3.0)
|
|
56
|
+
|
|
57
|
+
def test_record_timer(self) -> None:
|
|
58
|
+
durations = [0.1, 0.2, 0.3, 0.4, 0.5]
|
|
59
|
+
for duration in durations:
|
|
60
|
+
self.metrics.record_timer("test.timer", duration)
|
|
61
|
+
|
|
62
|
+
stats = self.metrics.get_timer_stats("test.timer")
|
|
63
|
+
self.assertEqual(stats["count"], 5)
|
|
64
|
+
self.assertEqual(stats["min"], 0.1)
|
|
65
|
+
self.assertEqual(stats["max"], 0.5)
|
|
66
|
+
self.assertAlmostEqual(stats["avg"], 0.3, places=2)
|
|
67
|
+
|
|
68
|
+
def test_timer_context(self) -> None:
|
|
69
|
+
with self.metrics.start_timer("test.context"):
|
|
70
|
+
time.sleep(0.05)
|
|
71
|
+
|
|
72
|
+
stats = self.metrics.get_timer_stats("test.context")
|
|
73
|
+
self.assertEqual(stats["count"], 1)
|
|
74
|
+
self.assertGreater(stats["min"], 0.04)
|
|
75
|
+
self.assertLess(stats["max"], 0.15)
|
|
76
|
+
|
|
77
|
+
def test_percentiles(self) -> None:
|
|
78
|
+
durations = list(range(100)) # 0 to 99
|
|
79
|
+
for duration in durations:
|
|
80
|
+
self.metrics.record_timer("test.percentiles", float(duration))
|
|
81
|
+
|
|
82
|
+
stats = self.metrics.get_timer_stats("test.percentiles")
|
|
83
|
+
self.assertEqual(stats["count"], 100)
|
|
84
|
+
self.assertAlmostEqual(stats["p50"], 49.0, places=0)
|
|
85
|
+
self.assertAlmostEqual(stats["p95"], 94.0, places=0)
|
|
86
|
+
self.assertAlmostEqual(stats["p99"], 98.0, places=0)
|
|
87
|
+
|
|
88
|
+
def test_get_all_metrics(self) -> None:
|
|
89
|
+
self.metrics.increment("test.counter", 1.0)
|
|
90
|
+
self.metrics.set_gauge("test.gauge", 42.0)
|
|
91
|
+
self.metrics.record_histogram("test.histogram", 5.0)
|
|
92
|
+
|
|
93
|
+
all_metrics = self.metrics.get_all_metrics()
|
|
94
|
+
self.assertIn("counters", all_metrics)
|
|
95
|
+
self.assertIn("gauges", all_metrics)
|
|
96
|
+
self.assertIn("histograms", all_metrics)
|
|
97
|
+
self.assertIn("timers", all_metrics)
|
|
98
|
+
|
|
99
|
+
self.assertEqual(all_metrics["counters"]["test.counter"], 1.0)
|
|
100
|
+
self.assertEqual(all_metrics["gauges"]["test.gauge"], 42.0)
|
|
101
|
+
|
|
102
|
+
def test_reset(self) -> None:
|
|
103
|
+
self.metrics.increment("test.counter", 100.0)
|
|
104
|
+
self.assertEqual(self.metrics.get_counter("test.counter"), 100.0)
|
|
105
|
+
|
|
106
|
+
self.metrics.reset()
|
|
107
|
+
self.assertEqual(self.metrics.get_counter("test.counter"), 0.0)
|
|
108
|
+
|
|
109
|
+
def test_export_to_file(self) -> None:
|
|
110
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
111
|
+
self.metrics.increment("test.counter", 42.0)
|
|
112
|
+
|
|
113
|
+
export_path = Path(temp_dir) / "metrics.json"
|
|
114
|
+
self.metrics.export_to_file(export_path)
|
|
115
|
+
|
|
116
|
+
self.assertTrue(export_path.exists())
|
|
117
|
+
|
|
118
|
+
import json
|
|
119
|
+
content = json.loads(export_path.read_text(encoding="utf8"))
|
|
120
|
+
self.assertIn("counters", content)
|
|
121
|
+
self.assertIn("exported_at", content)
|
|
122
|
+
|
|
123
|
+
def test_get_recent_samples(self) -> None:
|
|
124
|
+
self.metrics.increment("test.counter", 1.0)
|
|
125
|
+
self.metrics.set_gauge("test.gauge", 42.0)
|
|
126
|
+
|
|
127
|
+
samples = self.metrics.get_recent_samples(limit=10)
|
|
128
|
+
self.assertEqual(len(samples), 2)
|
|
129
|
+
self.assertEqual(samples[0]["name"], "test.counter")
|
|
130
|
+
self.assertEqual(samples[1]["name"], "test.gauge")
|
|
131
|
+
|
|
132
|
+
def test_builtin_metrics(self) -> None:
|
|
133
|
+
"""Check that built-in metrics are initialized."""
|
|
134
|
+
self.assertIsNotNone(self.metrics.get_counter("actions.total"))
|
|
135
|
+
self.assertIsNotNone(self.metrics.get_counter("actions.completed"))
|
|
136
|
+
self.assertIsNotNone(self.metrics.get_gauge("runtime.uptime_seconds"))
|
|
137
|
+
|
|
138
|
+
def test_histogram_empty(self) -> None:
|
|
139
|
+
stats = self.metrics.get_histogram("nonexistent")
|
|
140
|
+
self.assertEqual(stats["count"], 0)
|
|
141
|
+
self.assertEqual(stats["sum"], 0.0)
|
|
142
|
+
|
|
143
|
+
def test_timer_empty(self) -> None:
|
|
144
|
+
stats = self.metrics.get_timer_stats("nonexistent")
|
|
145
|
+
self.assertEqual(stats["count"], 0)
|
|
146
|
+
|
|
147
|
+
def test_max_samples_limit(self) -> None:
|
|
148
|
+
"""Test that samples don't grow beyond max_samples."""
|
|
149
|
+
small_metrics = MetricsCollector(max_samples=100)
|
|
150
|
+
|
|
151
|
+
for i in range(200):
|
|
152
|
+
small_metrics.increment("test", 1.0)
|
|
153
|
+
|
|
154
|
+
samples = small_metrics.get_recent_samples(limit=1000)
|
|
155
|
+
self.assertLessEqual(len(samples), 100)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class GlobalMetricsTests(unittest.TestCase):
|
|
159
|
+
"""Test cases for global metrics functions."""
|
|
160
|
+
|
|
161
|
+
def test_get_metrics_singleton(self) -> None:
|
|
162
|
+
reset_metrics()
|
|
163
|
+
m1 = get_metrics()
|
|
164
|
+
m2 = get_metrics()
|
|
165
|
+
self.assertIs(m1, m2)
|
|
166
|
+
|
|
167
|
+
def test_reset_metrics(self) -> None:
|
|
168
|
+
reset_metrics()
|
|
169
|
+
metrics = get_metrics()
|
|
170
|
+
|
|
171
|
+
metrics.increment("test", 100.0)
|
|
172
|
+
self.assertEqual(metrics.get_counter("test"), 100.0)
|
|
173
|
+
|
|
174
|
+
reset_metrics()
|
|
175
|
+
new_metrics = get_metrics()
|
|
176
|
+
self.assertEqual(new_metrics.get_counter("test"), 0.0)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
unittest.main()
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Tests for rate limiting module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
12
|
+
|
|
13
|
+
from agent_portal.rate_limit import (
|
|
14
|
+
RateLimiter,
|
|
15
|
+
RateLimitConfig,
|
|
16
|
+
ActionThrottler,
|
|
17
|
+
ClientInfo,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimiterTests(unittest.TestCase):
|
|
22
|
+
"""Test cases for the RateLimiter class."""
|
|
23
|
+
|
|
24
|
+
def test_basic_request_allowed(self) -> None:
|
|
25
|
+
limiter = RateLimiter()
|
|
26
|
+
allowed, error = limiter.check_rate_limit("client1")
|
|
27
|
+
self.assertTrue(allowed)
|
|
28
|
+
self.assertIsNone(error)
|
|
29
|
+
|
|
30
|
+
def test_request_within_limits(self) -> None:
|
|
31
|
+
config = RateLimitConfig(requests_per_minute=10, burst_limit=5)
|
|
32
|
+
limiter = RateLimiter(config)
|
|
33
|
+
|
|
34
|
+
# Should allow up to burst limit
|
|
35
|
+
for i in range(5):
|
|
36
|
+
allowed, error = limiter.check_rate_limit("client1")
|
|
37
|
+
self.assertTrue(allowed, f"Request {i} should be allowed")
|
|
38
|
+
self.assertIsNone(error)
|
|
39
|
+
|
|
40
|
+
def test_burst_limit_exceeded(self) -> None:
|
|
41
|
+
config = RateLimitConfig(requests_per_minute=100, burst_limit=3)
|
|
42
|
+
limiter = RateLimiter(config)
|
|
43
|
+
|
|
44
|
+
# Exhaust burst limit
|
|
45
|
+
for i in range(3):
|
|
46
|
+
allowed, _ = limiter.check_rate_limit("client1")
|
|
47
|
+
self.assertTrue(allowed)
|
|
48
|
+
|
|
49
|
+
# Next request should be blocked
|
|
50
|
+
allowed, error = limiter.check_rate_limit("client1")
|
|
51
|
+
self.assertFalse(allowed)
|
|
52
|
+
self.assertIn("Burst limit exceeded", error or "")
|
|
53
|
+
|
|
54
|
+
def test_per_minute_limit(self) -> None:
|
|
55
|
+
config = RateLimitConfig(requests_per_minute=5, burst_limit=10)
|
|
56
|
+
limiter = RateLimiter(config)
|
|
57
|
+
|
|
58
|
+
# This should be within burst but hit per-minute limit
|
|
59
|
+
for i in range(6):
|
|
60
|
+
allowed, _ = limiter.check_rate_limit("client1")
|
|
61
|
+
|
|
62
|
+
# Should be blocked due to per-minute limit
|
|
63
|
+
allowed, error = limiter.check_rate_limit("client1")
|
|
64
|
+
self.assertFalse(allowed)
|
|
65
|
+
self.assertIn("per minute", error or "")
|
|
66
|
+
|
|
67
|
+
def test_different_clients_independent(self) -> None:
|
|
68
|
+
config = RateLimitConfig(requests_per_minute=2, burst_limit=2)
|
|
69
|
+
limiter = RateLimiter(config)
|
|
70
|
+
|
|
71
|
+
# Client 1 makes 2 requests
|
|
72
|
+
for _ in range(2):
|
|
73
|
+
limiter.check_rate_limit("client1")
|
|
74
|
+
|
|
75
|
+
# Client 1 should be blocked on third request
|
|
76
|
+
allowed, _ = limiter.check_rate_limit("client1")
|
|
77
|
+
self.assertFalse(allowed)
|
|
78
|
+
|
|
79
|
+
# Client 2 should still be allowed
|
|
80
|
+
allowed, _ = limiter.check_rate_limit("client2")
|
|
81
|
+
self.assertTrue(allowed)
|
|
82
|
+
|
|
83
|
+
def test_block_timeout(self) -> None:
|
|
84
|
+
config = RateLimitConfig(
|
|
85
|
+
requests_per_minute=10,
|
|
86
|
+
burst_limit=2,
|
|
87
|
+
burst_window_seconds=0.5
|
|
88
|
+
)
|
|
89
|
+
limiter = RateLimiter(config)
|
|
90
|
+
|
|
91
|
+
# Exhaust burst limit
|
|
92
|
+
for _ in range(2):
|
|
93
|
+
limiter.check_rate_limit("client1")
|
|
94
|
+
|
|
95
|
+
# Should be blocked
|
|
96
|
+
allowed, _ = limiter.check_rate_limit("client1")
|
|
97
|
+
self.assertFalse(allowed)
|
|
98
|
+
|
|
99
|
+
# Wait for block to expire
|
|
100
|
+
time.sleep(1.1)
|
|
101
|
+
|
|
102
|
+
# Should be allowed again
|
|
103
|
+
allowed, _ = limiter.check_rate_limit("client1")
|
|
104
|
+
self.assertTrue(allowed)
|
|
105
|
+
|
|
106
|
+
def test_reset_client(self) -> None:
|
|
107
|
+
config = RateLimitConfig(requests_per_minute=2, burst_limit=2)
|
|
108
|
+
limiter = RateLimiter(config)
|
|
109
|
+
|
|
110
|
+
# Exhaust limits
|
|
111
|
+
for _ in range(2):
|
|
112
|
+
limiter.check_rate_limit("client1")
|
|
113
|
+
|
|
114
|
+
# Should be blocked
|
|
115
|
+
allowed, _ = limiter.check_rate_limit("client1")
|
|
116
|
+
self.assertFalse(allowed)
|
|
117
|
+
|
|
118
|
+
# Reset client
|
|
119
|
+
limiter.reset_client("client1")
|
|
120
|
+
|
|
121
|
+
# Should be allowed again
|
|
122
|
+
allowed, _ = limiter.check_rate_limit("client1")
|
|
123
|
+
self.assertTrue(allowed)
|
|
124
|
+
|
|
125
|
+
def test_cleanup_old_clients(self) -> None:
|
|
126
|
+
limiter = RateLimiter()
|
|
127
|
+
|
|
128
|
+
# Add some clients
|
|
129
|
+
limiter.check_rate_limit("client1")
|
|
130
|
+
limiter.check_rate_limit("client2")
|
|
131
|
+
|
|
132
|
+
# They should exist
|
|
133
|
+
self.assertIn("client1", limiter.clients)
|
|
134
|
+
self.assertIn("client2", limiter.clients)
|
|
135
|
+
|
|
136
|
+
# Cleanup with max age of 0 hours (should remove all)
|
|
137
|
+
removed = limiter.cleanup_old_clients(max_age_hours=0)
|
|
138
|
+
self.assertGreater(removed, 0)
|
|
139
|
+
|
|
140
|
+
# Clients should be removed
|
|
141
|
+
self.assertNotIn("client1", limiter.clients)
|
|
142
|
+
|
|
143
|
+
def test_default_config(self) -> None:
|
|
144
|
+
limiter = RateLimiter()
|
|
145
|
+
config = limiter.config
|
|
146
|
+
|
|
147
|
+
self.assertEqual(config.requests_per_minute, 60)
|
|
148
|
+
self.assertEqual(config.requests_per_hour, 1000)
|
|
149
|
+
self.assertEqual(config.burst_limit, 10)
|
|
150
|
+
self.assertEqual(config.burst_window_seconds, 1.0)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ActionThrottlerTests(unittest.TestCase):
|
|
154
|
+
"""Test cases for the ActionThrottler class."""
|
|
155
|
+
|
|
156
|
+
def setUp(self) -> None:
|
|
157
|
+
self.throttler = ActionThrottler()
|
|
158
|
+
|
|
159
|
+
def test_click_action_allowed(self) -> None:
|
|
160
|
+
allowed, error = self.throttler.check_action_allowed("click")
|
|
161
|
+
self.assertTrue(allowed)
|
|
162
|
+
self.assertIsNone(error)
|
|
163
|
+
|
|
164
|
+
def test_execute_action_stricter_limits(self) -> None:
|
|
165
|
+
# Execute action has stricter limits (5 per minute)
|
|
166
|
+
for _ in range(5):
|
|
167
|
+
allowed, _ = self.throttler.check_action_allowed("execute")
|
|
168
|
+
self.assertTrue(allowed)
|
|
169
|
+
|
|
170
|
+
# 6th request should be blocked
|
|
171
|
+
allowed, error = self.throttler.check_action_allowed("execute")
|
|
172
|
+
self.assertFalse(allowed)
|
|
173
|
+
self.assertIn("execute", error or "")
|
|
174
|
+
|
|
175
|
+
def test_different_actions_independent(self) -> None:
|
|
176
|
+
# Exhaust execute limit
|
|
177
|
+
for _ in range(6):
|
|
178
|
+
self.throttler.check_action_allowed("execute")
|
|
179
|
+
|
|
180
|
+
# Execute should be blocked
|
|
181
|
+
allowed, _ = self.throttler.check_action_allowed("execute")
|
|
182
|
+
self.assertFalse(allowed)
|
|
183
|
+
|
|
184
|
+
# Click should still be allowed
|
|
185
|
+
allowed, _ = self.throttler.check_action_allowed("click")
|
|
186
|
+
self.assertTrue(allowed)
|
|
187
|
+
|
|
188
|
+
def test_per_client_throttling(self) -> None:
|
|
189
|
+
# Exhaust execute for client1
|
|
190
|
+
for _ in range(6):
|
|
191
|
+
self.throttler.check_action_allowed("execute", "client1")
|
|
192
|
+
|
|
193
|
+
# Client1 should be blocked
|
|
194
|
+
allowed, _ = self.throttler.check_action_allowed("execute", "client1")
|
|
195
|
+
self.assertFalse(allowed)
|
|
196
|
+
|
|
197
|
+
# Client2 should still be allowed
|
|
198
|
+
allowed, _ = self.throttler.check_action_allowed("execute", "client2")
|
|
199
|
+
self.assertTrue(allowed)
|
|
200
|
+
|
|
201
|
+
def test_cleanup_old_entries(self) -> None:
|
|
202
|
+
self.throttler.check_action_allowed("click", "client1")
|
|
203
|
+
self.throttler.check_action_allowed("type", "client1")
|
|
204
|
+
|
|
205
|
+
# Should have entries
|
|
206
|
+
self.assertGreater(len(self.throttler._action_counts), 0)
|
|
207
|
+
|
|
208
|
+
# Cleanup with max age 0
|
|
209
|
+
removed = self.throttler.cleanup_old_entries(max_age_hours=0)
|
|
210
|
+
|
|
211
|
+
# Entries should be removed
|
|
212
|
+
self.assertEqual(removed, 2)
|
|
213
|
+
|
|
214
|
+
def test_unknown_action_type_defaults(self) -> None:
|
|
215
|
+
# Unknown actions should get default limits
|
|
216
|
+
for _ in range(70):
|
|
217
|
+
allowed, _ = self.throttler.check_action_allowed("unknown_action")
|
|
218
|
+
|
|
219
|
+
# Should be blocked at default 60/min limit
|
|
220
|
+
allowed, _ = self.throttler.check_action_allowed("unknown_action")
|
|
221
|
+
self.assertFalse(allowed)
|
|
222
|
+
|
|
223
|
+
def test_screenshot_stricter_than_click(self) -> None:
|
|
224
|
+
# Screenshot has limit of 10 per minute, click has 60
|
|
225
|
+
for _ in range(11):
|
|
226
|
+
self.throttler.check_action_allowed("screenshot")
|
|
227
|
+
|
|
228
|
+
allowed, _ = self.throttler.check_action_allowed("screenshot")
|
|
229
|
+
self.assertFalse(allowed)
|
|
230
|
+
|
|
231
|
+
# Click should still be allowed
|
|
232
|
+
allowed, _ = self.throttler.check_action_allowed("click")
|
|
233
|
+
self.assertTrue(allowed)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
unittest.main()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
10
|
+
|
|
11
|
+
from agent_portal.models import ActionRequest, RuntimeConfigModel
|
|
12
|
+
from agent_portal.runtime import PortalRuntime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StubBrowser:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self.connected = False
|
|
18
|
+
self.url: str | None = None
|
|
19
|
+
self.console_errors: list[str] = []
|
|
20
|
+
self.network_errors: list[str] = []
|
|
21
|
+
|
|
22
|
+
def available(self) -> bool:
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def start(self) -> None:
|
|
26
|
+
self.connected = True
|
|
27
|
+
|
|
28
|
+
def stop(self) -> None:
|
|
29
|
+
self.connected = False
|
|
30
|
+
|
|
31
|
+
def open_url(self, url: str) -> None:
|
|
32
|
+
self.url = url
|
|
33
|
+
|
|
34
|
+
def screenshot(self, label: str) -> str:
|
|
35
|
+
return f"{label}.png"
|
|
36
|
+
|
|
37
|
+
def click(self, selector: str) -> None:
|
|
38
|
+
self.url = self.url or "http://localhost/test"
|
|
39
|
+
|
|
40
|
+
def type_text(self, selector: str, value: str) -> None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def scroll(self, selector: str | None = None) -> None:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def hover(self, selector: str) -> None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def wait(self, selector: str) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def inspect(self) -> dict[str, object]:
|
|
53
|
+
return {
|
|
54
|
+
"url": self.url,
|
|
55
|
+
"title": "Fixture",
|
|
56
|
+
"dom": "<html></html>",
|
|
57
|
+
"consoleErrors": self.console_errors,
|
|
58
|
+
"networkErrors": self.network_errors,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def current_url(self) -> str | None:
|
|
62
|
+
return self.url
|
|
63
|
+
|
|
64
|
+
def current_title(self) -> str | None:
|
|
65
|
+
return "Fixture" if self.url else None
|
|
66
|
+
|
|
67
|
+
def read_console(self) -> list[str]:
|
|
68
|
+
return list(self.console_errors)
|
|
69
|
+
|
|
70
|
+
def read_network(self) -> list[str]:
|
|
71
|
+
return list(self.network_errors)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RuntimeTests(unittest.TestCase):
|
|
75
|
+
def build_runtime(self, root: Path) -> PortalRuntime:
|
|
76
|
+
config = RuntimeConfigModel(
|
|
77
|
+
screenshot_directory="shots",
|
|
78
|
+
report_directory="reports",
|
|
79
|
+
sensitive_screenshots_enabled=True,
|
|
80
|
+
safe_mode=False,
|
|
81
|
+
)
|
|
82
|
+
runtime = PortalRuntime(root, config)
|
|
83
|
+
runtime.browser = StubBrowser() # type: ignore[assignment]
|
|
84
|
+
return runtime
|
|
85
|
+
|
|
86
|
+
def test_runtime_proposes_and_rejects_action(self) -> None:
|
|
87
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
88
|
+
runtime = self.build_runtime(Path(temp_dir))
|
|
89
|
+
action = runtime.propose_action(
|
|
90
|
+
ActionRequest("click", "Click a safe element", target="#ok")
|
|
91
|
+
)
|
|
92
|
+
rejected = runtime.reject_action(action.action_id, "Rejected for test")
|
|
93
|
+
self.assertEqual(rejected.status, "rejected")
|
|
94
|
+
self.assertEqual(runtime.session.runtime_status, "blocked")
|
|
95
|
+
|
|
96
|
+
def test_runtime_blocks_password_typing(self) -> None:
|
|
97
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
98
|
+
runtime = self.build_runtime(Path(temp_dir))
|
|
99
|
+
action = runtime.propose_action(
|
|
100
|
+
ActionRequest("type", "Enter password", target="#password", payload="secret")
|
|
101
|
+
)
|
|
102
|
+
self.assertEqual(action.status, "blocked")
|
|
103
|
+
|
|
104
|
+
def test_runtime_open_and_report_generation(self) -> None:
|
|
105
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
106
|
+
root = Path(temp_dir)
|
|
107
|
+
runtime = self.build_runtime(root)
|
|
108
|
+
runtime.start()
|
|
109
|
+
runtime.ensure_browser()
|
|
110
|
+
result = runtime.open_url("http://localhost:3000")
|
|
111
|
+
report_path = runtime.generate_report()
|
|
112
|
+
|
|
113
|
+
self.assertEqual(result.status, "completed")
|
|
114
|
+
self.assertTrue(report_path.exists())
|
|
115
|
+
|
|
116
|
+
report = json.loads(report_path.read_text(encoding="utf8"))
|
|
117
|
+
self.assertEqual(report["current_url"], "http://localhost:3000")
|
|
118
|
+
self.assertGreaterEqual(len(report["actions_completed"]), 1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
unittest.main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
14
|
+
|
|
15
|
+
from agent_portal.models import RuntimeConfigModel
|
|
16
|
+
from agent_portal.runtime import PortalRuntime
|
|
17
|
+
from agent_portal.server import build_server
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ServerTests(unittest.TestCase):
|
|
21
|
+
def test_status_endpoint_responds(self) -> None:
|
|
22
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
23
|
+
root = Path(temp_dir)
|
|
24
|
+
with socket.socket() as probe:
|
|
25
|
+
probe.bind(("127.0.0.1", 0))
|
|
26
|
+
port = probe.getsockname()[1]
|
|
27
|
+
|
|
28
|
+
config = RuntimeConfigModel(runtime_host="127.0.0.1", runtime_port=port)
|
|
29
|
+
runtime = PortalRuntime(root, config)
|
|
30
|
+
runtime.start()
|
|
31
|
+
server = build_server(runtime)
|
|
32
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
33
|
+
thread.start()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
time.sleep(0.1)
|
|
37
|
+
with urlopen(f"http://127.0.0.1:{port}/status") as response:
|
|
38
|
+
payload = json.loads(response.read().decode("utf8"))
|
|
39
|
+
self.assertEqual(payload["session"]["runtime_status"], "idle")
|
|
40
|
+
finally:
|
|
41
|
+
request = Request(
|
|
42
|
+
f"http://127.0.0.1:{port}/control/stop",
|
|
43
|
+
data=b"{}",
|
|
44
|
+
headers={"Content-Type": "application/json"},
|
|
45
|
+
)
|
|
46
|
+
with urlopen(request):
|
|
47
|
+
pass
|
|
48
|
+
thread.join(timeout=2)
|
|
49
|
+
server.server_close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
unittest.main()
|