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,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()