@matt82198/aesop 0.1.0-beta.4 → 0.1.0-rc.1
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/CHANGELOG.md +187 -5
- package/LICENSE +66 -21
- package/README.md +143 -23
- package/aesop.config.example.json +14 -1
- package/bin/CLAUDE.md +42 -5
- package/bin/cli.js +479 -95
- package/daemons/CLAUDE.md +5 -2
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +211 -62
- package/dash/dash-extra.mjs +73 -4
- package/dash/watchdog-gui.sh +41 -35
- package/docs/ARCHITECTURE.md +296 -0
- package/docs/CONCEPTS.md +254 -0
- package/docs/CONFIGURE.md +256 -0
- package/docs/FIRST-WAVE.md +276 -0
- package/docs/INSTALL.md +224 -0
- package/docs/README.md +176 -27
- package/docs/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/autonomous-swe.md +149 -0
- package/docs/case-study-portfolio.md +61 -0
- package/docs/reproduce.md +121 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +51 -0
- package/hooks/install-waveguard.sh +68 -0
- package/hooks/pre-commit-waveguard.sh +26 -0
- package/hooks/pre-push-policy.sh +253 -15
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +218 -20
- package/package.json +17 -6
- package/scan/CLAUDE.md +30 -0
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/CLAUDE.md +1 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +41 -0
- package/state_store/projections.py +126 -0
- package/state_store/store.py +197 -0
- package/tools/CLAUDE.md +37 -149
- package/tools/alert_bridge.py +453 -0
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +308 -14
- package/tools/common.py +62 -0
- package/tools/cost_ceiling.py +191 -0
- package/tools/dash.js +102 -0
- package/tools/doctor.js +220 -0
- package/tools/fleet_ledger.py +189 -17
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/halt.py +172 -0
- package/tools/healthcheck.py +299 -0
- package/tools/heartbeat.py +4 -7
- package/tools/metrics_gate.py +8 -2
- package/tools/orchestrator_status.py +4 -9
- package/tools/reconcile.py +277 -0
- package/tools/rotate_logs.py +152 -62
- package/tools/scanner_selftest.py +28 -3
- package/tools/secret_scan.py +367 -91
- package/tools/self_stats.py +797 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/status.js +187 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_prboard.py +344 -0
- package/tools/verify_submit_encoding.py +12 -4
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +59 -41
- package/ui/agents.py +337 -34
- package/ui/api/submit.py +44 -5
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +294 -52
- package/ui/config.py +31 -2
- package/ui/cost.py +355 -0
- package/ui/csrf.py +9 -4
- package/ui/handler.py +450 -12
- package/ui/render.py +26 -6
- package/ui/sse.py +114 -13
- package/ui/wave_prs.py +230 -0
- package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
- package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/monitor/.signal-state.json +0 -3
- package/monitor/ACTIONS.log +0 -1
- package/monitor/BRIEF.md +0 -24
- package/monitor/SIGNALS.json +0 -54
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
package/ui/handler.py
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
import http.server
|
|
4
4
|
import json
|
|
5
5
|
import queue
|
|
6
|
+
import socketserver
|
|
6
7
|
import sys
|
|
7
8
|
import threading
|
|
8
9
|
import urllib.parse
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
import config
|
|
13
|
+
import cost
|
|
12
14
|
import csrf
|
|
13
15
|
import sse
|
|
16
|
+
import wave_prs
|
|
14
17
|
import api
|
|
15
18
|
import api.tracker
|
|
16
19
|
import api.submit
|
|
@@ -23,11 +26,145 @@ from collectors import (_snapshot_data, _snapshot_tracker,
|
|
|
23
26
|
get_recent_events, get_repos_status,
|
|
24
27
|
parse_audit_backlog)
|
|
25
28
|
from agents import (_AGENT_ID_FORBIDDEN, _transcripts_fingerprint,
|
|
26
|
-
extract_agent_dispatch_prompt,
|
|
29
|
+
extract_agent_dispatch_prompt, get_agent_detail,
|
|
30
|
+
get_fleet_agents)
|
|
27
31
|
from sse import (_latest_lock, _latest_snapshots, _maybe_emit,
|
|
28
32
|
register_sse_client, unregister_sse_client)
|
|
29
33
|
|
|
30
34
|
|
|
35
|
+
# SSE section names, in emit order. /api/state returns the same sections so the
|
|
36
|
+
# frontend's first paint is one round trip (plan D3.1).
|
|
37
|
+
_STATE_SECTIONS = ("data", "backlog", "agents", "tracker", "status", "cost")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _path_is_contained(child, root):
|
|
41
|
+
"""Check if child path is contained within root path (no traversal).
|
|
42
|
+
|
|
43
|
+
Returns True if child is under root, False if it escapes (e.g., via ..).
|
|
44
|
+
Uses Path.is_relative_to (Python 3.9+) with a fallback for older runtimes.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
child: Path object (typically resolved)
|
|
48
|
+
root: Path object (typically resolved)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
bool: True if child is contained within root, False otherwise
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
return child.is_relative_to(root.resolve())
|
|
55
|
+
except AttributeError:
|
|
56
|
+
# Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
|
|
57
|
+
try:
|
|
58
|
+
child.relative_to(root.resolve())
|
|
59
|
+
return True
|
|
60
|
+
except ValueError:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
# MIME types for the built dist's content-hashed assets (wave-14, plan D3.4).
|
|
64
|
+
_ASSET_MIME = {
|
|
65
|
+
".js": "text/javascript; charset=utf-8",
|
|
66
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
67
|
+
".css": "text/css; charset=utf-8",
|
|
68
|
+
".svg": "image/svg+xml",
|
|
69
|
+
".map": "application/json; charset=utf-8",
|
|
70
|
+
".json": "application/json; charset=utf-8",
|
|
71
|
+
".woff2": "font/woff2",
|
|
72
|
+
".woff": "font/woff",
|
|
73
|
+
".png": "image/png",
|
|
74
|
+
".ico": "image/x-icon",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_local_origin(origin):
|
|
79
|
+
"""True if an Origin header value is on the local allowlist.
|
|
80
|
+
|
|
81
|
+
Keep in sync with csrf.validate_csrf_request(): same six local forms
|
|
82
|
+
(http[s]://127.0.0.1:<port>, http[s]://localhost:<port>, http[s]://[::1]:<port>).
|
|
83
|
+
Both http:// and https:// schemes are accepted for the same loopback hosts.
|
|
84
|
+
"""
|
|
85
|
+
return bool(origin) and (
|
|
86
|
+
origin.startswith("http://127.0.0.1:") or
|
|
87
|
+
origin.startswith("https://127.0.0.1:") or
|
|
88
|
+
origin.startswith("http://localhost:") or
|
|
89
|
+
origin.startswith("https://localhost:") or
|
|
90
|
+
origin.startswith("http://[::1]:") or
|
|
91
|
+
origin.startswith("https://[::1]:")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_valid_host_header(host_header, expected_port):
|
|
96
|
+
"""True if Host header value is on the loopback allowlist.
|
|
97
|
+
|
|
98
|
+
DNS-rebinding mitigation (wave-19): validates that the Host header
|
|
99
|
+
matches one of the allowed loopback addresses with the correct port.
|
|
100
|
+
|
|
101
|
+
Allowed forms (where port matches expected_port):
|
|
102
|
+
- 127.0.0.1 (no port, implies http default)
|
|
103
|
+
- 127.0.0.1:<port>
|
|
104
|
+
- localhost (no port)
|
|
105
|
+
- localhost:<port>
|
|
106
|
+
- [::1] (IPv6 loopback, no port)
|
|
107
|
+
- [::1]:<port>
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
host_header: The value of the Host header (may be None/empty)
|
|
111
|
+
expected_port: the port the server is actually bound to (validate
|
|
112
|
+
against the live socket, not config.PORT — a test or a
|
|
113
|
+
dynamically-assigned ephemeral port can differ from config)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
bool: True if host is on the local allowlist, False otherwise
|
|
117
|
+
"""
|
|
118
|
+
if not host_header:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
host_header = host_header.strip()
|
|
122
|
+
|
|
123
|
+
# Extract host and port from the header value
|
|
124
|
+
# Handle IPv6 format [::1]:port
|
|
125
|
+
if host_header.startswith("["):
|
|
126
|
+
# IPv6 format: [::1] or [::1]:port
|
|
127
|
+
if "]" not in host_header:
|
|
128
|
+
return False
|
|
129
|
+
bracket_end = host_header.index("]")
|
|
130
|
+
host_part = host_header[:bracket_end + 1] # Include brackets
|
|
131
|
+
remainder = host_header[bracket_end + 1:]
|
|
132
|
+
if remainder:
|
|
133
|
+
# Port must follow immediately with a colon
|
|
134
|
+
if not remainder.startswith(":"):
|
|
135
|
+
return False
|
|
136
|
+
try:
|
|
137
|
+
port_part = int(remainder[1:])
|
|
138
|
+
except (ValueError, IndexError):
|
|
139
|
+
return False
|
|
140
|
+
else:
|
|
141
|
+
port_part = None
|
|
142
|
+
else:
|
|
143
|
+
# IPv4 or hostname format: 127.0.0.1, 127.0.0.1:port, localhost, localhost:port
|
|
144
|
+
if ":" in host_header:
|
|
145
|
+
host_part, port_str = host_header.rsplit(":", 1)
|
|
146
|
+
try:
|
|
147
|
+
port_part = int(port_str)
|
|
148
|
+
except ValueError:
|
|
149
|
+
return False
|
|
150
|
+
else:
|
|
151
|
+
host_part = host_header
|
|
152
|
+
port_part = None
|
|
153
|
+
|
|
154
|
+
# Check if host is on allowlist (127.0.0.1, localhost, or [::1])
|
|
155
|
+
# RFC 7230: hostnames are case-insensitive, so lowercase before comparing
|
|
156
|
+
allowed_hosts = ("127.0.0.1", "localhost", "[::1]")
|
|
157
|
+
if host_part.lower() not in allowed_hosts:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
# If a port was specified, it must match the server's actual bound port
|
|
161
|
+
if port_part is not None:
|
|
162
|
+
if port_part != expected_port:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
|
|
31
168
|
class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
32
169
|
"""HTTP request handler for dashboard."""
|
|
33
170
|
|
|
@@ -37,25 +174,64 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
37
174
|
|
|
38
175
|
def do_GET(self):
|
|
39
176
|
"""Handle GET requests."""
|
|
177
|
+
# DNS-rebinding mitigation: validate Host header against allowlist
|
|
178
|
+
host_header = self.headers.get("Host", "").strip()
|
|
179
|
+
if not _is_valid_host_header(host_header, self.server.server_address[1]):
|
|
180
|
+
self.send_response(403)
|
|
181
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
182
|
+
self.end_headers()
|
|
183
|
+
self.wfile.write(json.dumps(
|
|
184
|
+
{"error": "Forbidden: invalid Host header"}
|
|
185
|
+
).encode('utf-8'))
|
|
186
|
+
return
|
|
187
|
+
|
|
40
188
|
if self.path == "/":
|
|
41
189
|
self.serve_html()
|
|
42
190
|
elif self.path == "/data":
|
|
43
191
|
self.serve_data()
|
|
192
|
+
elif self.path == "/api/state":
|
|
193
|
+
self.serve_api_state()
|
|
194
|
+
elif self.path == "/api/session":
|
|
195
|
+
self.serve_api_session()
|
|
196
|
+
elif self.path == "/api/cost":
|
|
197
|
+
self.serve_api_cost()
|
|
198
|
+
elif self.path == "/api/wave/prs":
|
|
199
|
+
self.serve_api_wave_prs()
|
|
44
200
|
elif self.path == "/api/backlog":
|
|
45
201
|
self.serve_backlog()
|
|
46
202
|
elif self.path == "/api/agents":
|
|
47
203
|
self.serve_agents()
|
|
204
|
+
elif self.path.startswith("/api/agent?"):
|
|
205
|
+
self.serve_api_agent()
|
|
48
206
|
elif self.path.startswith("/api/tracker"):
|
|
49
207
|
self.serve_tracker()
|
|
208
|
+
elif self.path.startswith("/assets/"):
|
|
209
|
+
self.serve_asset()
|
|
50
210
|
elif self.path.startswith("/agent?"):
|
|
51
211
|
self.serve_agent()
|
|
52
212
|
elif self.path == "/events":
|
|
53
213
|
self.serve_events()
|
|
214
|
+
elif self.path == "/favicon.ico":
|
|
215
|
+
# Browsers auto-request this; answer 204 so it never 404s (keeps the
|
|
216
|
+
# console clean — the dashboard ships no favicon asset).
|
|
217
|
+
self.send_response(204)
|
|
218
|
+
self.end_headers()
|
|
54
219
|
else:
|
|
55
220
|
self.send_error(404)
|
|
56
221
|
|
|
57
222
|
def do_POST(self):
|
|
58
223
|
"""Handle POST requests."""
|
|
224
|
+
# DNS-rebinding mitigation: validate Host header against allowlist
|
|
225
|
+
host_header = self.headers.get("Host", "").strip()
|
|
226
|
+
if not _is_valid_host_header(host_header, self.server.server_address[1]):
|
|
227
|
+
self.send_response(403)
|
|
228
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
229
|
+
self.end_headers()
|
|
230
|
+
self.wfile.write(json.dumps(
|
|
231
|
+
{"error": "Forbidden: invalid Host header"}
|
|
232
|
+
).encode('utf-8'))
|
|
233
|
+
return
|
|
234
|
+
|
|
59
235
|
if self.path == "/submit":
|
|
60
236
|
self.handle_submit()
|
|
61
237
|
elif self.path == "/api/tracker":
|
|
@@ -66,14 +242,191 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
66
242
|
self.send_error(404)
|
|
67
243
|
|
|
68
244
|
def serve_html(self):
|
|
69
|
-
"""Serve the dashboard HTML.
|
|
70
|
-
|
|
245
|
+
"""Serve the dashboard HTML.
|
|
246
|
+
|
|
247
|
+
Wave-14 (plan D3.4/D7 U9 cutover): the built frontend at
|
|
248
|
+
config.WEB_DIST/index.html is always required and must be present;
|
|
249
|
+
if missing, return a hard 500 with a clear error (never fall back to
|
|
250
|
+
a legacy template). config.WEB_DIST is read at call time so
|
|
251
|
+
config.reload() keeps working across fixtures.
|
|
252
|
+
"""
|
|
253
|
+
dist_index = config.WEB_DIST / "index.html"
|
|
254
|
+
if not dist_index.is_file():
|
|
255
|
+
error_msg = (
|
|
256
|
+
"built dashboard missing — run npm run build in ui/web"
|
|
257
|
+
)
|
|
258
|
+
self.send_response(500)
|
|
259
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
260
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
261
|
+
self.end_headers()
|
|
262
|
+
self.wfile.write(error_msg.encode('utf-8'))
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
html = render_dashboard(csrf.SESSION_TOKEN, template_path=dist_index)
|
|
71
266
|
self.send_response(200)
|
|
72
267
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
73
268
|
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
74
269
|
self.end_headers()
|
|
75
270
|
self.wfile.write(html.encode('utf-8'))
|
|
76
271
|
|
|
272
|
+
def serve_asset(self):
|
|
273
|
+
"""GET /assets/* — static files from the built dist (config.WEB_DIST/assets).
|
|
274
|
+
|
|
275
|
+
Security: path-traversal containment via resolve() + is_relative_to,
|
|
276
|
+
mirroring the pattern in agents.py — the resolved candidate must stay
|
|
277
|
+
inside WEB_DIST/assets or the request is refused. URL-encoded (%2e%2e,
|
|
278
|
+
%2f, %5c) and absolute-path inputs are decoded first so the containment
|
|
279
|
+
check sees the real target.
|
|
280
|
+
|
|
281
|
+
Caching: filenames are content-hashed by the Vite build, so responses
|
|
282
|
+
are immutable (Cache-Control: public, max-age=31536000, immutable).
|
|
283
|
+
"""
|
|
284
|
+
try:
|
|
285
|
+
assets_root = config.WEB_DIST / "assets"
|
|
286
|
+
if not assets_root.is_dir():
|
|
287
|
+
# No built dist present (pre-cutover main) — nothing to serve.
|
|
288
|
+
self.send_error(404)
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
raw_path = urllib.parse.urlparse(self.path).path
|
|
292
|
+
rel = urllib.parse.unquote(raw_path[len("/assets/"):])
|
|
293
|
+
if not rel or "\x00" in rel:
|
|
294
|
+
self.send_error(404)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
candidate = (assets_root / rel).resolve()
|
|
298
|
+
|
|
299
|
+
# Containment check: the resolved target must stay inside
|
|
300
|
+
# WEB_DIST/assets. Uses _path_is_contained helper for clarity.
|
|
301
|
+
if not _path_is_contained(candidate, assets_root):
|
|
302
|
+
self.send_error(403)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
if not candidate.is_file():
|
|
306
|
+
self.send_error(404)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
content = candidate.read_bytes()
|
|
310
|
+
mime = _ASSET_MIME.get(candidate.suffix.lower(), "application/octet-stream")
|
|
311
|
+
self.send_response(200)
|
|
312
|
+
self.send_header("Content-Type", mime)
|
|
313
|
+
self.send_header("Content-Length", str(len(content)))
|
|
314
|
+
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
|
|
315
|
+
self.end_headers()
|
|
316
|
+
self.wfile.write(content)
|
|
317
|
+
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
|
|
318
|
+
pass # client went away mid-response — normal, not an error
|
|
319
|
+
except Exception as e:
|
|
320
|
+
print(f"[serve_asset] Uncaught exception: {e}", file=sys.stderr)
|
|
321
|
+
self.send_error(500)
|
|
322
|
+
|
|
323
|
+
def serve_api_state(self):
|
|
324
|
+
"""GET /api/state — consolidated snapshot of all six SSE sections.
|
|
325
|
+
|
|
326
|
+
One round trip for the frontend's first paint (plan D3.1). Reuses the
|
|
327
|
+
collectors' latest-snapshot mechanism: sections the background collector
|
|
328
|
+
has already produced are returned as-is; anything not yet snapshotted
|
|
329
|
+
(first-ever request) is computed inline, mirroring serve_events.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
sse.start_collector_thread()
|
|
333
|
+
with _latest_lock:
|
|
334
|
+
latest = dict(_latest_snapshots)
|
|
335
|
+
|
|
336
|
+
computers = {
|
|
337
|
+
"data": _snapshot_data,
|
|
338
|
+
"backlog": parse_audit_backlog,
|
|
339
|
+
"agents": get_fleet_agents,
|
|
340
|
+
"tracker": _snapshot_tracker,
|
|
341
|
+
"status": _snapshot_orchestrator_status,
|
|
342
|
+
"cost": cost.get_cost_summary,
|
|
343
|
+
}
|
|
344
|
+
state = {}
|
|
345
|
+
for name in _STATE_SECTIONS:
|
|
346
|
+
payload = latest.get(name)
|
|
347
|
+
if payload is not None:
|
|
348
|
+
state[name] = json.loads(payload)
|
|
349
|
+
else:
|
|
350
|
+
state[name] = computers[name]()
|
|
351
|
+
|
|
352
|
+
self.send_response(200)
|
|
353
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
354
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
355
|
+
self.end_headers()
|
|
356
|
+
self.wfile.write(json.dumps(state, default=str).encode('utf-8'))
|
|
357
|
+
except Exception as e:
|
|
358
|
+
print(f"[serve_api_state] Uncaught exception: {e}", file=sys.stderr)
|
|
359
|
+
self.send_response(500)
|
|
360
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
361
|
+
self.end_headers()
|
|
362
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
363
|
+
|
|
364
|
+
def serve_api_session(self):
|
|
365
|
+
"""GET /api/session — the CSRF token for same-origin JS (plan D3.3).
|
|
366
|
+
|
|
367
|
+
Exists so the Vite dev server (which cannot do the sentinel
|
|
368
|
+
substitution) still gets a working token; the built app keeps the
|
|
369
|
+
sentinel-injection path as primary.
|
|
370
|
+
|
|
371
|
+
SECURITY — Origin-checked FAIL-CLOSED: unlike the mutation endpoints
|
|
372
|
+
(where a missing Origin is tolerated because the token itself gates the
|
|
373
|
+
write), this endpoint HANDS OUT the token, so absence of an Origin
|
|
374
|
+
header is a refusal. Only the same local allowlist csrf.py uses
|
|
375
|
+
(127.0.0.1 / localhost / [::1]) is accepted.
|
|
376
|
+
"""
|
|
377
|
+
origin = self.headers.get("Origin", "").strip()
|
|
378
|
+
if not _is_local_origin(origin):
|
|
379
|
+
self.send_response(403)
|
|
380
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
381
|
+
self.end_headers()
|
|
382
|
+
self.wfile.write(json.dumps(
|
|
383
|
+
{"error": "Forbidden: /api/session requires a local Origin header"}
|
|
384
|
+
).encode('utf-8'))
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
self.send_response(200)
|
|
388
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
389
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
390
|
+
self.end_headers()
|
|
391
|
+
self.wfile.write(json.dumps({"token": csrf.SESSION_TOKEN}).encode('utf-8'))
|
|
392
|
+
|
|
393
|
+
def serve_api_cost(self):
|
|
394
|
+
"""GET /api/cost — cost/scorecard summary from the outcomes ledger (plan D3.2)."""
|
|
395
|
+
try:
|
|
396
|
+
summary = cost.get_cost_summary()
|
|
397
|
+
self.send_response(200)
|
|
398
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
399
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
400
|
+
self.end_headers()
|
|
401
|
+
self.wfile.write(json.dumps(summary, default=str).encode('utf-8'))
|
|
402
|
+
except Exception as e:
|
|
403
|
+
print(f"[serve_api_cost] Uncaught exception: {e}", file=sys.stderr)
|
|
404
|
+
self.send_response(500)
|
|
405
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
406
|
+
self.end_headers()
|
|
407
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
408
|
+
|
|
409
|
+
def serve_api_wave_prs(self):
|
|
410
|
+
"""GET /api/wave/prs — open PRs + PR-less feat/* branches for the PR board.
|
|
411
|
+
|
|
412
|
+
Read-only; runs `gh pr list` / `git for-each-ref` (short timeout, cached
|
|
413
|
+
a few seconds). Degrades to a well-formed {available:false, error:...}
|
|
414
|
+
payload when gh is missing or un-authenticated — never a 500 for those.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
payload = wave_prs.get_wave_prs()
|
|
418
|
+
self.send_response(200)
|
|
419
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
420
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
421
|
+
self.end_headers()
|
|
422
|
+
self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
|
|
423
|
+
except Exception as e:
|
|
424
|
+
print(f"[serve_api_wave_prs] Uncaught exception: {e}", file=sys.stderr)
|
|
425
|
+
self.send_response(500)
|
|
426
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
427
|
+
self.end_headers()
|
|
428
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
429
|
+
|
|
77
430
|
def serve_data(self):
|
|
78
431
|
"""Serve dashboard data as JSON."""
|
|
79
432
|
data = {
|
|
@@ -107,10 +460,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
107
460
|
self.end_headers()
|
|
108
461
|
self.wfile.write(json.dumps(body, default=str).encode('utf-8'))
|
|
109
462
|
except Exception as e:
|
|
463
|
+
print(f"[serve_tracker] Uncaught exception: {e}", file=sys.stderr)
|
|
110
464
|
self.send_response(500)
|
|
111
465
|
self.send_header("Content-Type", "application/json")
|
|
112
466
|
self.end_headers()
|
|
113
|
-
self.wfile.write(json.dumps({"error":
|
|
467
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
114
468
|
|
|
115
469
|
def handle_tracker_create(self):
|
|
116
470
|
"""Handle POST /api/tracker (create item)."""
|
|
@@ -132,10 +486,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
132
486
|
self.end_headers()
|
|
133
487
|
self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
|
|
134
488
|
except Exception as e:
|
|
489
|
+
print(f"[handle_tracker_create] Uncaught exception: {e}", file=sys.stderr)
|
|
135
490
|
self.send_response(500)
|
|
136
491
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
137
492
|
self.end_headers()
|
|
138
|
-
self.wfile.write(json.dumps({"error":
|
|
493
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
139
494
|
|
|
140
495
|
def handle_tracker_mutate(self):
|
|
141
496
|
"""Handle POST /api/tracker/<id> (update or delete)."""
|
|
@@ -180,10 +535,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
180
535
|
self.end_headers()
|
|
181
536
|
self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
|
|
182
537
|
except Exception as e:
|
|
538
|
+
print(f"[handle_tracker_mutate] Uncaught exception: {e}", file=sys.stderr)
|
|
183
539
|
self.send_response(500)
|
|
184
540
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
185
541
|
self.end_headers()
|
|
186
|
-
self.wfile.write(json.dumps({"error":
|
|
542
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
187
543
|
|
|
188
544
|
|
|
189
545
|
def serve_backlog(self):
|
|
@@ -196,10 +552,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
196
552
|
self.end_headers()
|
|
197
553
|
self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
|
|
198
554
|
except Exception as e:
|
|
555
|
+
print(f"[serve_backlog] Uncaught exception: {e}", file=sys.stderr)
|
|
199
556
|
self.send_response(500)
|
|
200
557
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
201
558
|
self.end_headers()
|
|
202
|
-
self.wfile.write(json.dumps({"error":
|
|
559
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
203
560
|
|
|
204
561
|
def serve_agents(self):
|
|
205
562
|
"""Serve rich agent list with metadata via GET /api/agents."""
|
|
@@ -211,10 +568,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
211
568
|
self.end_headers()
|
|
212
569
|
self.wfile.write(json.dumps(agents, default=str).encode('utf-8'))
|
|
213
570
|
except Exception as e:
|
|
571
|
+
print(f"[serve_agents] Uncaught exception: {e}", file=sys.stderr)
|
|
214
572
|
self.send_response(500)
|
|
215
573
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
216
574
|
self.end_headers()
|
|
217
|
-
self.wfile.write(json.dumps({"error":
|
|
575
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
218
576
|
|
|
219
577
|
def serve_agent(self):
|
|
220
578
|
"""Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
|
|
@@ -257,6 +615,49 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
257
615
|
print(f"[serve_agent] Uncaught exception: {e}", file=sys.stderr)
|
|
258
616
|
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
259
617
|
|
|
618
|
+
def serve_api_agent(self):
|
|
619
|
+
"""GET /api/agent?id=<agent_id> — agent detail for the Inspector drawer.
|
|
620
|
+
|
|
621
|
+
Read-only. Returns the dispatch prompt/metadata PLUS a bounded, secret-
|
|
622
|
+
redacted transcript tail (last ~40 NDJSON lines, seek-read so a huge
|
|
623
|
+
transcript is never fully loaded). Same id-safety + status-code contract
|
|
624
|
+
as GET /agent: rejected input -> 400, well-formed id with no transcript
|
|
625
|
+
-> 404, unexpected failure -> 500. Never leaks a raw traceback.
|
|
626
|
+
"""
|
|
627
|
+
try:
|
|
628
|
+
query = urllib.parse.urlparse(self.path).query
|
|
629
|
+
params = urllib.parse.parse_qs(query)
|
|
630
|
+
agent_id = params.get('id', [None])[0]
|
|
631
|
+
|
|
632
|
+
if not agent_id:
|
|
633
|
+
self.send_response(400)
|
|
634
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
635
|
+
self.end_headers()
|
|
636
|
+
self.wfile.write(json.dumps({"error": "missing id parameter"}).encode('utf-8'))
|
|
637
|
+
return
|
|
638
|
+
|
|
639
|
+
data = get_agent_detail(agent_id)
|
|
640
|
+
|
|
641
|
+
if "error" in data:
|
|
642
|
+
status = 400 if data.get("invalid") else 404
|
|
643
|
+
self.send_response(status)
|
|
644
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
645
|
+
self.end_headers()
|
|
646
|
+
self.wfile.write(json.dumps({"error": data["error"]}).encode('utf-8'))
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
self.send_response(200)
|
|
650
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
651
|
+
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
652
|
+
self.end_headers()
|
|
653
|
+
self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
|
|
654
|
+
except Exception as e:
|
|
655
|
+
self.send_response(500)
|
|
656
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
657
|
+
self.end_headers()
|
|
658
|
+
print(f"[serve_api_agent] Uncaught exception: {e}", file=sys.stderr)
|
|
659
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
660
|
+
|
|
260
661
|
def _write_sse_event(self, event_name, payload):
|
|
261
662
|
"""Write one SSE frame with timeout. Caller handles disconnect exceptions."""
|
|
262
663
|
msg = f"event: {event_name}\ndata: {payload}\n\n"
|
|
@@ -325,10 +726,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
325
726
|
initial["agents"] = json.dumps(get_fleet_agents(), default=str, sort_keys=True)
|
|
326
727
|
initial["tracker"] = json.dumps(_snapshot_tracker(), default=str, sort_keys=True)
|
|
327
728
|
initial["status"] = json.dumps(_snapshot_orchestrator_status(), default=str, sort_keys=True)
|
|
729
|
+
initial["cost"] = json.dumps(cost.get_cost_summary(), default=str, sort_keys=True)
|
|
328
730
|
with _latest_lock:
|
|
329
731
|
_latest_snapshots.update(initial)
|
|
330
732
|
|
|
331
|
-
for name in
|
|
733
|
+
for name in _STATE_SECTIONS:
|
|
332
734
|
payload = initial.get(name)
|
|
333
735
|
if payload is not None:
|
|
334
736
|
self._write_sse_event(name, payload)
|
|
@@ -373,7 +775,7 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
373
775
|
return
|
|
374
776
|
|
|
375
777
|
body_bytes = self.rfile.read(content_length)
|
|
376
|
-
data = json.loads(body_bytes.decode('utf-8', errors='
|
|
778
|
+
data = json.loads(body_bytes.decode('utf-8', errors='replace'))
|
|
377
779
|
text = data.get("text", "").strip()
|
|
378
780
|
|
|
379
781
|
if not text:
|
|
@@ -397,10 +799,44 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
397
799
|
self.end_headers()
|
|
398
800
|
self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
|
|
399
801
|
except Exception as e:
|
|
802
|
+
print(f"[handle_submit] Uncaught exception: {e}", file=sys.stderr)
|
|
400
803
|
self.send_response(500)
|
|
401
804
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
402
805
|
self.end_headers()
|
|
403
|
-
self.wfile.write(json.dumps({"error":
|
|
806
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
class QuietThreadingHTTPServer(http.server.ThreadingHTTPServer):
|
|
810
|
+
"""ThreadingHTTPServer that suppresses expected socket disconnect exceptions.
|
|
811
|
+
|
|
812
|
+
During normal operation and especially during shutdown, ThreadingHTTPServer may
|
|
813
|
+
encounter ConnectionAbortedError (WinError 10053) or ConnectionResetError
|
|
814
|
+
(WinError 10054) when clients disconnect abruptly. These are expected, not errors,
|
|
815
|
+
and clutter stderr with tracebacks.
|
|
816
|
+
|
|
817
|
+
This server overrides handle_error() to suppress only these two exception types
|
|
818
|
+
while still reporting all other exceptions (real bugs, timeouts, etc.).
|
|
819
|
+
"""
|
|
820
|
+
|
|
821
|
+
def handle_error(self, request, client_address):
|
|
822
|
+
"""Suppress client disconnect exceptions; report all others.
|
|
823
|
+
|
|
824
|
+
Args:
|
|
825
|
+
request: The socket request object
|
|
826
|
+
client_address: The client address tuple
|
|
827
|
+
"""
|
|
828
|
+
exc_type, exc_value, exc_tb = sys.exc_info()
|
|
829
|
+
|
|
830
|
+
# Suppress only the two disconnect exception types that occur during
|
|
831
|
+
# normal client aborts (especially on shutdown). All other exceptions
|
|
832
|
+
# (real bugs, timeouts, etc.) still get logged via super().
|
|
833
|
+
if exc_type in (ConnectionAbortedError, ConnectionResetError):
|
|
834
|
+
# Expected client disconnect; silent is correct.
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
# All other exceptions get the default handler (logged to stderr)
|
|
838
|
+
super().handle_error(request, client_address)
|
|
839
|
+
|
|
404
840
|
|
|
405
841
|
def run_server():
|
|
406
842
|
"""Start the HTTP server.
|
|
@@ -409,9 +845,11 @@ def run_server():
|
|
|
409
845
|
connection open for the life of the client, so a single-threaded server would
|
|
410
846
|
wedge every other request (including the initial page load and /submit)
|
|
411
847
|
behind that one held connection.
|
|
848
|
+
|
|
849
|
+
Uses QuietThreadingHTTPServer to suppress expected socket disconnect exceptions.
|
|
412
850
|
"""
|
|
413
851
|
addr = ("127.0.0.1", config.PORT)
|
|
414
|
-
httpd =
|
|
852
|
+
httpd = QuietThreadingHTTPServer(addr, DashboardHandler)
|
|
415
853
|
httpd.daemon_threads = True
|
|
416
854
|
sse.start_collector_thread()
|
|
417
855
|
print(f"Dashboard: http://localhost:{config.PORT}")
|
package/ui/render.py
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Aesop UI template rendering — stdlib-only.
|
|
4
4
|
|
|
5
|
-
The dashboard HTML/CSS/JS lives in templates/dashboard.html (extracted
|
|
6
|
-
serve.py in the wave-9 split).
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
The legacy dashboard HTML/CSS/JS lives in templates/dashboard.html (extracted
|
|
6
|
+
from serve.py in the wave-9 split). Wave-14 (dashboard rewrite, plan D3/D7)
|
|
7
|
+
retargets the same mechanism at the built React app: the handler passes
|
|
8
|
+
ui/web/dist/index.html as template_path when a committed dist is present, and
|
|
9
|
+
falls back to the legacy template otherwise. The only server-side substitution
|
|
10
|
+
is the per-session CSRF token, injected via a unique sentinel so the template
|
|
11
|
+
stays a plain static file (no .format()/% — the CSS/JS is full of { } and %
|
|
12
|
+
literals; the Vite build passes the sentinel-carrying inline script through
|
|
13
|
+
verbatim).
|
|
9
14
|
"""
|
|
10
15
|
import json
|
|
11
16
|
import os
|
|
@@ -17,13 +22,28 @@ _DASHBOARD_HTML = os.path.join(_TEMPLATE_DIR, "dashboard.html")
|
|
|
17
22
|
_CSRF_SENTINEL = "__AESOP_CSRF_SENTINEL__"
|
|
18
23
|
|
|
19
24
|
|
|
20
|
-
def render_dashboard(session_token):
|
|
25
|
+
def render_dashboard(session_token, template_path=None):
|
|
21
26
|
"""Return the dashboard HTML with the CSRF token substituted in.
|
|
22
27
|
|
|
28
|
+
Args:
|
|
29
|
+
session_token: the per-session CSRF token to inject.
|
|
30
|
+
template_path: path to the template file carrying the CSRF sentinel
|
|
31
|
+
(wave-14 U9 cutover: dist/index.html is always required; no
|
|
32
|
+
fallback to templates/dashboard.html).
|
|
33
|
+
|
|
23
34
|
Reads the template fresh each call (cheap; keeps edits live in dev). The
|
|
24
35
|
token is inserted as a JS string literal via json.dumps so it is always a
|
|
25
36
|
valid, properly-quoted value.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
TypeError: if template_path is None (legacy fallback removed at U9).
|
|
40
|
+
FileNotFoundError: if the template file does not exist.
|
|
26
41
|
"""
|
|
27
|
-
|
|
42
|
+
if template_path is None:
|
|
43
|
+
raise TypeError(
|
|
44
|
+
"render_dashboard() requires template_path after wave-14 U9 cutover; "
|
|
45
|
+
"legacy fallback to templates/dashboard.html has been removed"
|
|
46
|
+
)
|
|
47
|
+
with open(template_path, encoding="utf-8") as f:
|
|
28
48
|
html = f.read()
|
|
29
49
|
return html.replace(_CSRF_SENTINEL, json.dumps(session_token))
|