@matt82198/aesop 0.1.0-beta.5 → 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/CHANGELOG.md +158 -5
- package/LICENSE +66 -21
- package/README.md +97 -33
- package/aesop.config.example.json +6 -0
- package/bin/CLAUDE.md +12 -3
- package/bin/cli.js +211 -40
- package/daemons/CLAUDE.md +1 -1
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +208 -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/autonomous-swe.md +149 -0
- package/docs/reproduce.md +121 -0
- package/hooks/CLAUDE.md +28 -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/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +133 -20
- package/package.json +15 -7
- package/scan/CLAUDE.md +30 -0
- package/skills/CLAUDE.md +1 -0
- package/state_store/ingest.py +18 -1
- package/state_store/projections.py +78 -4
- package/state_store/store.py +102 -7
- package/tools/CLAUDE.md +25 -18
- package/tools/alert_bridge.py +14 -10
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +215 -34
- 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/halt.py +172 -0
- package/tools/healthcheck.py +24 -21
- 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 +359 -90
- package/tools/self_stats.py +292 -4
- package/tools/status.js +187 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_prboard.py +344 -0
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +2 -0
- package/ui/agents.py +332 -33
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +125 -32
- package/ui/config.py +22 -2
- package/ui/cost.py +98 -3
- package/ui/csrf.py +2 -1
- package/ui/handler.py +209 -21
- package/ui/sse.py +102 -16
- 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 +2 -2
- 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/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- 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__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.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__/metrics_gate.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__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.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__/cost.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/web/.gitattributes +0 -13
- package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
- package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
- package/ui/web/index.html +0 -13
- package/ui/web/package-lock.json +0 -2225
- package/ui/web/package.json +0 -26
- package/ui/web/src/App.test.tsx +0 -74
- package/ui/web/src/App.tsx +0 -142
- package/ui/web/src/CONTRIBUTING-UI.md +0 -49
- package/ui/web/src/components/AgentRow.css +0 -187
- package/ui/web/src/components/AgentRow.test.tsx +0 -209
- package/ui/web/src/components/AgentRow.tsx +0 -207
- package/ui/web/src/components/AgentsPanel.css +0 -108
- package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
- package/ui/web/src/components/AgentsPanel.tsx +0 -58
- package/ui/web/src/components/AlertsPanel.css +0 -88
- package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
- package/ui/web/src/components/AlertsPanel.tsx +0 -67
- package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
- package/ui/web/src/components/BacklogPanel.tsx +0 -122
- package/ui/web/src/components/CostChart.css +0 -110
- package/ui/web/src/components/CostChart.test.tsx +0 -144
- package/ui/web/src/components/CostChart.tsx +0 -152
- package/ui/web/src/components/CostTable.css +0 -93
- package/ui/web/src/components/CostTable.test.tsx +0 -165
- package/ui/web/src/components/CostTable.tsx +0 -94
- package/ui/web/src/components/EventsFeed.css +0 -68
- package/ui/web/src/components/EventsFeed.test.tsx +0 -36
- package/ui/web/src/components/EventsFeed.tsx +0 -31
- package/ui/web/src/components/HealthHeader.css +0 -137
- package/ui/web/src/components/HealthHeader.test.tsx +0 -278
- package/ui/web/src/components/HealthHeader.tsx +0 -281
- package/ui/web/src/components/InboxForm.css +0 -135
- package/ui/web/src/components/InboxForm.test.tsx +0 -208
- package/ui/web/src/components/InboxForm.tsx +0 -116
- package/ui/web/src/components/MessagesTail.module.css +0 -144
- package/ui/web/src/components/MessagesTail.test.tsx +0 -176
- package/ui/web/src/components/MessagesTail.tsx +0 -94
- package/ui/web/src/components/ReposPanel.css +0 -90
- package/ui/web/src/components/ReposPanel.test.tsx +0 -45
- package/ui/web/src/components/ReposPanel.tsx +0 -67
- package/ui/web/src/components/Scorecard.css +0 -106
- package/ui/web/src/components/Scorecard.test.tsx +0 -117
- package/ui/web/src/components/Scorecard.tsx +0 -85
- package/ui/web/src/components/Timeline.module.css +0 -151
- package/ui/web/src/components/Timeline.test.tsx +0 -215
- package/ui/web/src/components/Timeline.tsx +0 -99
- package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
- package/ui/web/src/components/TrackerBoard.tsx +0 -107
- package/ui/web/src/components/TrackerCard.test.tsx +0 -180
- package/ui/web/src/components/TrackerCard.tsx +0 -160
- package/ui/web/src/components/TrackerForm.test.tsx +0 -189
- package/ui/web/src/components/TrackerForm.tsx +0 -144
- package/ui/web/src/lib/api.ts +0 -218
- package/ui/web/src/lib/format.test.ts +0 -89
- package/ui/web/src/lib/format.ts +0 -103
- package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
- package/ui/web/src/lib/sanitizeUrl.ts +0 -38
- package/ui/web/src/lib/types.ts +0 -230
- package/ui/web/src/lib/useHashRoute.test.ts +0 -60
- package/ui/web/src/lib/useHashRoute.ts +0 -23
- package/ui/web/src/lib/useSSE.ts +0 -175
- package/ui/web/src/main.tsx +0 -10
- package/ui/web/src/styles/global.css +0 -179
- package/ui/web/src/styles/theme.css +0 -184
- package/ui/web/src/styles/work.css +0 -572
- package/ui/web/src/test/fixtures.ts +0 -385
- package/ui/web/src/test/setup.ts +0 -49
- package/ui/web/src/views/Activity.module.css +0 -43
- package/ui/web/src/views/Activity.test.tsx +0 -89
- package/ui/web/src/views/Activity.tsx +0 -31
- package/ui/web/src/views/Cost.css +0 -87
- package/ui/web/src/views/Cost.test.tsx +0 -142
- package/ui/web/src/views/Cost.tsx +0 -54
- package/ui/web/src/views/Overview.css +0 -51
- package/ui/web/src/views/Overview.test.tsx +0 -76
- package/ui/web/src/views/Overview.tsx +0 -46
- package/ui/web/src/views/Work.test.tsx +0 -82
- package/ui/web/src/views/Work.tsx +0 -79
- package/ui/web/src/vite-env.d.ts +0 -10
- package/ui/web/tsconfig.json +0 -22
- package/ui/web/vite.config.ts +0 -25
- package/ui/web/vitest.config.ts +0 -12
package/ui/config.py
CHANGED
|
@@ -34,8 +34,28 @@ def reload():
|
|
|
34
34
|
# PORT: env PORT > default 8770
|
|
35
35
|
PORT = int(os.getenv("PORT", "8770"))
|
|
36
36
|
|
|
37
|
-
# Determine AESOP_ROOT
|
|
38
|
-
|
|
37
|
+
# Determine AESOP_ROOT with fallback tiers (matching daemons/run-watchdog.sh pattern):
|
|
38
|
+
# (1) AESOP_ROOT env var if set
|
|
39
|
+
# (2) Derive from file location: Path(__file__).resolve().parents[1]
|
|
40
|
+
# (3) Load config from derived location; if it has aesop_root, use that
|
|
41
|
+
env_root = os.getenv("AESOP_ROOT")
|
|
42
|
+
if env_root:
|
|
43
|
+
AESOP_ROOT = Path(env_root)
|
|
44
|
+
else:
|
|
45
|
+
# Derive from file location (matches daemons/run-watchdog.sh pattern)
|
|
46
|
+
AESOP_ROOT = Path(__file__).resolve().parents[1]
|
|
47
|
+
|
|
48
|
+
# Check if derived location's config has aesop_root key
|
|
49
|
+
derived_config_file = AESOP_ROOT / "aesop.config.json"
|
|
50
|
+
if derived_config_file.exists():
|
|
51
|
+
try:
|
|
52
|
+
with open(derived_config_file) as f:
|
|
53
|
+
derived_config = json.load(f)
|
|
54
|
+
if "aesop_root" in derived_config:
|
|
55
|
+
AESOP_ROOT = Path(derived_config["aesop_root"])
|
|
56
|
+
except Exception:
|
|
57
|
+
# Silently ignore config errors here; will attempt full load below
|
|
58
|
+
pass
|
|
39
59
|
|
|
40
60
|
# Try to load config file for additional settings
|
|
41
61
|
CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
|
package/ui/cost.py
CHANGED
|
@@ -63,6 +63,75 @@ from pathlib import Path
|
|
|
63
63
|
import config
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def _validate_ledger_format(lines):
|
|
67
|
+
"""Validate that ledger has the expected column structure.
|
|
68
|
+
|
|
69
|
+
Checks the first non-empty, non-separator, non-header line to ensure it looks like a data row
|
|
70
|
+
(has ISO timestamp, agent type, model, numeric fields, verdict). Returns (is_valid, error_message).
|
|
71
|
+
|
|
72
|
+
Expected format:
|
|
73
|
+
| ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
|
|
74
|
+
|---|---|---|---|---|---|---|
|
|
75
|
+
| 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
|
|
76
|
+
"""
|
|
77
|
+
for line in lines:
|
|
78
|
+
line = line.strip()
|
|
79
|
+
|
|
80
|
+
# Skip empty lines
|
|
81
|
+
if not line:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Skip separator lines (all dashes, pipes, and spaces)
|
|
85
|
+
if all(c in '|- ' for c in line):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# This is the first real line; validate it
|
|
89
|
+
if not line.startswith('|') or not line.endswith('|'):
|
|
90
|
+
return False, "First data line does not start/end with pipe"
|
|
91
|
+
|
|
92
|
+
parts = [p.strip() for p in line.split('|')]
|
|
93
|
+
|
|
94
|
+
# Should have 9 parts: [empty, col1, col2, col3, col4, col5, col6, col7, empty]
|
|
95
|
+
if len(parts) != 9:
|
|
96
|
+
return False, f"Expected 7 columns, got {len(parts) - 2}"
|
|
97
|
+
|
|
98
|
+
# Extract columns and validate types
|
|
99
|
+
try:
|
|
100
|
+
timestamp = parts[1]
|
|
101
|
+
agent_type = parts[2]
|
|
102
|
+
model = parts[3]
|
|
103
|
+
duration_str = parts[4]
|
|
104
|
+
tokens_in_str = parts[5]
|
|
105
|
+
tokens_out_str = parts[6]
|
|
106
|
+
verdict = parts[7]
|
|
107
|
+
|
|
108
|
+
# Skip header line if timestamp column contains "timestamp" or "ISO"
|
|
109
|
+
if 'timestamp' in timestamp.lower() or 'iso' in timestamp.lower():
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
# Check if timestamp looks like ISO format (contains 'T' and '-')
|
|
113
|
+
if 'T' not in timestamp or '-' not in timestamp:
|
|
114
|
+
return False, f"First data line does not have ISO timestamp in column 1: {timestamp}"
|
|
115
|
+
|
|
116
|
+
# Check if tokens are numeric
|
|
117
|
+
try:
|
|
118
|
+
int(tokens_in_str)
|
|
119
|
+
int(tokens_out_str)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return False, f"Token columns must be numeric, got tokens_in={tokens_in_str}, tokens_out={tokens_out_str}"
|
|
122
|
+
|
|
123
|
+
# Check if verdict is valid
|
|
124
|
+
if verdict not in ("OK", "FAILED", "EMPTY", "HUNG"):
|
|
125
|
+
return False, f"First data line has invalid verdict: {verdict}"
|
|
126
|
+
|
|
127
|
+
return True, ""
|
|
128
|
+
except IndexError:
|
|
129
|
+
return False, "First data line has missing columns"
|
|
130
|
+
|
|
131
|
+
# No data lines found
|
|
132
|
+
return True, "" # Empty ledger is ok
|
|
133
|
+
|
|
134
|
+
|
|
66
135
|
def get_cost_summary():
|
|
67
136
|
"""Parse the outcomes ledger and return cost/token/verdict aggregations.
|
|
68
137
|
|
|
@@ -70,13 +139,18 @@ def get_cost_summary():
|
|
|
70
139
|
Returns an empty summary with documented shape if ledger is missing or empty.
|
|
71
140
|
Malformed lines are skipped and counted in skipped_lines.
|
|
72
141
|
|
|
142
|
+
Validates ledger format on first data line. If format is invalid, returns
|
|
143
|
+
a summary containing {"error": "ledger format invalid"} and logs to stderr.
|
|
144
|
+
|
|
73
145
|
All config paths are read at call time (not import time) to ensure
|
|
74
146
|
test-fixture isolation via config.reload().
|
|
75
147
|
|
|
76
148
|
Returns:
|
|
77
149
|
dict: CostSummary with models, daily_totals, overall_scorecard,
|
|
78
|
-
skipped_lines, has_pricing, estimates_by_model.
|
|
150
|
+
skipped_lines, has_pricing, estimates_by_model (or error field if invalid).
|
|
79
151
|
"""
|
|
152
|
+
import sys
|
|
153
|
+
|
|
80
154
|
# Read ledger path at call time
|
|
81
155
|
ledger_file = config.STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
|
|
82
156
|
|
|
@@ -113,6 +187,13 @@ def get_cost_summary():
|
|
|
113
187
|
|
|
114
188
|
lines = content.strip().split('\n')
|
|
115
189
|
|
|
190
|
+
# Validate ledger format
|
|
191
|
+
is_valid, error_msg = _validate_ledger_format(lines)
|
|
192
|
+
if not is_valid:
|
|
193
|
+
print(f"[cost] Ledger format invalid: {error_msg}", file=sys.stderr, flush=True)
|
|
194
|
+
result["error"] = "ledger format invalid"
|
|
195
|
+
return result
|
|
196
|
+
|
|
116
197
|
# Parse each line
|
|
117
198
|
for line in lines:
|
|
118
199
|
line = line.strip()
|
|
@@ -121,15 +202,20 @@ def get_cost_summary():
|
|
|
121
202
|
if not line:
|
|
122
203
|
continue
|
|
123
204
|
|
|
124
|
-
# Skip header separator lines (all dashes and
|
|
125
|
-
if all(c in '|-' for c in line):
|
|
205
|
+
# Skip header separator lines (all dashes, pipes, and spaces) — silently, don't count as skipped
|
|
206
|
+
if all(c in '|- ' for c in line):
|
|
126
207
|
continue
|
|
127
208
|
|
|
128
209
|
# Parse pipe-delimited row
|
|
129
210
|
if not line.startswith('|') or not line.endswith('|'):
|
|
211
|
+
print(f"[cost] Skipping malformed line (no pipe delimiters): {line[:50]}", file=sys.stderr, flush=True)
|
|
130
212
|
result["skipped_lines"] += 1
|
|
131
213
|
continue
|
|
132
214
|
|
|
215
|
+
# Skip separator lines (all dashes, pipes, and spaces) — silently, don't count as skipped
|
|
216
|
+
if all(c in '|- ' for c in line):
|
|
217
|
+
continue
|
|
218
|
+
|
|
133
219
|
# Split by pipe and strip whitespace
|
|
134
220
|
parts = [p.strip() for p in line.split('|')]
|
|
135
221
|
|
|
@@ -137,6 +223,7 @@ def get_cost_summary():
|
|
|
137
223
|
# Format: | col1 | col2 | col3 | col4 | col5 | col6 | col7 |
|
|
138
224
|
# After split: ['', 'col1', 'col2', 'col3', 'col4', 'col5', 'col6', 'col7', '']
|
|
139
225
|
if len(parts) < 9: # Need at least 9 parts (empty + 7 columns + empty)
|
|
226
|
+
print(f"[cost] Skipping line with too few columns ({len(parts) - 2}): {line[:50]}", file=sys.stderr, flush=True)
|
|
140
227
|
result["skipped_lines"] += 1
|
|
141
228
|
continue
|
|
142
229
|
|
|
@@ -150,19 +237,26 @@ def get_cost_summary():
|
|
|
150
237
|
tokens_out_str = parts[6]
|
|
151
238
|
verdict = parts[7]
|
|
152
239
|
except IndexError:
|
|
240
|
+
print(f"[cost] Skipping line with missing columns: {line[:50]}", file=sys.stderr, flush=True)
|
|
153
241
|
result["skipped_lines"] += 1
|
|
154
242
|
continue
|
|
155
243
|
|
|
244
|
+
# Skip header line if timestamp column contains "timestamp" or "ISO" — silently, don't count as skipped
|
|
245
|
+
if 'timestamp' in timestamp.lower() or 'iso' in timestamp.lower():
|
|
246
|
+
continue
|
|
247
|
+
|
|
156
248
|
# Parse numeric fields
|
|
157
249
|
try:
|
|
158
250
|
tokens_in = int(tokens_in_str)
|
|
159
251
|
tokens_out = int(tokens_out_str)
|
|
160
252
|
except ValueError:
|
|
253
|
+
print(f"[cost] Skipping line with non-numeric tokens (in={tokens_in_str}, out={tokens_out_str}): {line[:50]}", file=sys.stderr, flush=True)
|
|
161
254
|
result["skipped_lines"] += 1
|
|
162
255
|
continue
|
|
163
256
|
|
|
164
257
|
# Validate verdict
|
|
165
258
|
if verdict not in ("OK", "FAILED", "EMPTY", "HUNG"):
|
|
259
|
+
print(f"[cost] Skipping line with invalid verdict ({verdict}): {line[:50]}", file=sys.stderr, flush=True)
|
|
166
260
|
result["skipped_lines"] += 1
|
|
167
261
|
continue
|
|
168
262
|
|
|
@@ -170,6 +264,7 @@ def get_cost_summary():
|
|
|
170
264
|
try:
|
|
171
265
|
date_str = timestamp.split('T')[0]
|
|
172
266
|
except IndexError:
|
|
267
|
+
print(f"[cost] Skipping line with invalid timestamp format: {line[:50]}", file=sys.stderr, flush=True)
|
|
173
268
|
result["skipped_lines"] += 1
|
|
174
269
|
continue
|
|
175
270
|
|
package/ui/csrf.py
CHANGED
|
@@ -9,6 +9,7 @@ CRITICAL: This module reads config paths LIVE at call time via 'import config',
|
|
|
9
9
|
never 'from config import <path>'. This ensures paths are recomputed when
|
|
10
10
|
config.reload() is called (e.g., between test fixtures).
|
|
11
11
|
"""
|
|
12
|
+
import hmac
|
|
12
13
|
import os
|
|
13
14
|
import secrets
|
|
14
15
|
import sys
|
|
@@ -138,7 +139,7 @@ def validate_csrf_request(headers):
|
|
|
138
139
|
if not token:
|
|
139
140
|
return (False, "Missing X-Aesop-Token header")
|
|
140
141
|
|
|
141
|
-
if token
|
|
142
|
+
if not hmac.compare_digest(token, SESSION_TOKEN):
|
|
142
143
|
return (False, "Invalid X-Aesop-Token")
|
|
143
144
|
|
|
144
145
|
return (True, None)
|
package/ui/handler.py
CHANGED
|
@@ -13,6 +13,7 @@ import config
|
|
|
13
13
|
import cost
|
|
14
14
|
import csrf
|
|
15
15
|
import sse
|
|
16
|
+
import wave_prs
|
|
16
17
|
import api
|
|
17
18
|
import api.tracker
|
|
18
19
|
import api.submit
|
|
@@ -25,7 +26,8 @@ from collectors import (_snapshot_data, _snapshot_tracker,
|
|
|
25
26
|
get_recent_events, get_repos_status,
|
|
26
27
|
parse_audit_backlog)
|
|
27
28
|
from agents import (_AGENT_ID_FORBIDDEN, _transcripts_fingerprint,
|
|
28
|
-
extract_agent_dispatch_prompt,
|
|
29
|
+
extract_agent_dispatch_prompt, get_agent_detail,
|
|
30
|
+
get_fleet_agents)
|
|
29
31
|
from sse import (_latest_lock, _latest_snapshots, _maybe_emit,
|
|
30
32
|
register_sse_client, unregister_sse_client)
|
|
31
33
|
|
|
@@ -34,6 +36,30 @@ from sse import (_latest_lock, _latest_snapshots, _maybe_emit,
|
|
|
34
36
|
# frontend's first paint is one round trip (plan D3.1).
|
|
35
37
|
_STATE_SECTIONS = ("data", "backlog", "agents", "tracker", "status", "cost")
|
|
36
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
|
+
|
|
37
63
|
# MIME types for the built dist's content-hashed assets (wave-14, plan D3.4).
|
|
38
64
|
_ASSET_MIME = {
|
|
39
65
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -66,6 +92,79 @@ def _is_local_origin(origin):
|
|
|
66
92
|
)
|
|
67
93
|
|
|
68
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
|
+
|
|
69
168
|
class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
70
169
|
"""HTTP request handler for dashboard."""
|
|
71
170
|
|
|
@@ -75,6 +174,17 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
75
174
|
|
|
76
175
|
def do_GET(self):
|
|
77
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
|
+
|
|
78
188
|
if self.path == "/":
|
|
79
189
|
self.serve_html()
|
|
80
190
|
elif self.path == "/data":
|
|
@@ -85,10 +195,14 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
85
195
|
self.serve_api_session()
|
|
86
196
|
elif self.path == "/api/cost":
|
|
87
197
|
self.serve_api_cost()
|
|
198
|
+
elif self.path == "/api/wave/prs":
|
|
199
|
+
self.serve_api_wave_prs()
|
|
88
200
|
elif self.path == "/api/backlog":
|
|
89
201
|
self.serve_backlog()
|
|
90
202
|
elif self.path == "/api/agents":
|
|
91
203
|
self.serve_agents()
|
|
204
|
+
elif self.path.startswith("/api/agent?"):
|
|
205
|
+
self.serve_api_agent()
|
|
92
206
|
elif self.path.startswith("/api/tracker"):
|
|
93
207
|
self.serve_tracker()
|
|
94
208
|
elif self.path.startswith("/assets/"):
|
|
@@ -107,6 +221,17 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
107
221
|
|
|
108
222
|
def do_POST(self):
|
|
109
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
|
+
|
|
110
235
|
if self.path == "/submit":
|
|
111
236
|
self.handle_submit()
|
|
112
237
|
elif self.path == "/api/tracker":
|
|
@@ -172,17 +297,8 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
172
297
|
candidate = (assets_root / rel).resolve()
|
|
173
298
|
|
|
174
299
|
# Containment check: the resolved target must stay inside
|
|
175
|
-
# WEB_DIST/assets.
|
|
176
|
-
|
|
177
|
-
is_contained = candidate.is_relative_to(assets_root.resolve())
|
|
178
|
-
except AttributeError:
|
|
179
|
-
# Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
|
|
180
|
-
try:
|
|
181
|
-
candidate.relative_to(assets_root.resolve())
|
|
182
|
-
is_contained = True
|
|
183
|
-
except ValueError:
|
|
184
|
-
is_contained = False
|
|
185
|
-
if not is_contained:
|
|
300
|
+
# WEB_DIST/assets. Uses _path_is_contained helper for clarity.
|
|
301
|
+
if not _path_is_contained(candidate, assets_root):
|
|
186
302
|
self.send_error(403)
|
|
187
303
|
return
|
|
188
304
|
|
|
@@ -239,10 +355,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
239
355
|
self.end_headers()
|
|
240
356
|
self.wfile.write(json.dumps(state, default=str).encode('utf-8'))
|
|
241
357
|
except Exception as e:
|
|
358
|
+
print(f"[serve_api_state] Uncaught exception: {e}", file=sys.stderr)
|
|
242
359
|
self.send_response(500)
|
|
243
360
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
244
361
|
self.end_headers()
|
|
245
|
-
self.wfile.write(json.dumps({"error":
|
|
362
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
246
363
|
|
|
247
364
|
def serve_api_session(self):
|
|
248
365
|
"""GET /api/session — the CSRF token for same-origin JS (plan D3.3).
|
|
@@ -283,10 +400,32 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
283
400
|
self.end_headers()
|
|
284
401
|
self.wfile.write(json.dumps(summary, default=str).encode('utf-8'))
|
|
285
402
|
except Exception as e:
|
|
403
|
+
print(f"[serve_api_cost] Uncaught exception: {e}", file=sys.stderr)
|
|
286
404
|
self.send_response(500)
|
|
287
405
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
288
406
|
self.end_headers()
|
|
289
|
-
self.wfile.write(json.dumps({"error":
|
|
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'))
|
|
290
429
|
|
|
291
430
|
def serve_data(self):
|
|
292
431
|
"""Serve dashboard data as JSON."""
|
|
@@ -321,10 +460,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
321
460
|
self.end_headers()
|
|
322
461
|
self.wfile.write(json.dumps(body, default=str).encode('utf-8'))
|
|
323
462
|
except Exception as e:
|
|
463
|
+
print(f"[serve_tracker] Uncaught exception: {e}", file=sys.stderr)
|
|
324
464
|
self.send_response(500)
|
|
325
465
|
self.send_header("Content-Type", "application/json")
|
|
326
466
|
self.end_headers()
|
|
327
|
-
self.wfile.write(json.dumps({"error":
|
|
467
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
328
468
|
|
|
329
469
|
def handle_tracker_create(self):
|
|
330
470
|
"""Handle POST /api/tracker (create item)."""
|
|
@@ -346,10 +486,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
346
486
|
self.end_headers()
|
|
347
487
|
self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
|
|
348
488
|
except Exception as e:
|
|
489
|
+
print(f"[handle_tracker_create] Uncaught exception: {e}", file=sys.stderr)
|
|
349
490
|
self.send_response(500)
|
|
350
491
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
351
492
|
self.end_headers()
|
|
352
|
-
self.wfile.write(json.dumps({"error":
|
|
493
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
353
494
|
|
|
354
495
|
def handle_tracker_mutate(self):
|
|
355
496
|
"""Handle POST /api/tracker/<id> (update or delete)."""
|
|
@@ -394,10 +535,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
394
535
|
self.end_headers()
|
|
395
536
|
self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
|
|
396
537
|
except Exception as e:
|
|
538
|
+
print(f"[handle_tracker_mutate] Uncaught exception: {e}", file=sys.stderr)
|
|
397
539
|
self.send_response(500)
|
|
398
540
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
399
541
|
self.end_headers()
|
|
400
|
-
self.wfile.write(json.dumps({"error":
|
|
542
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
401
543
|
|
|
402
544
|
|
|
403
545
|
def serve_backlog(self):
|
|
@@ -410,10 +552,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
410
552
|
self.end_headers()
|
|
411
553
|
self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
|
|
412
554
|
except Exception as e:
|
|
555
|
+
print(f"[serve_backlog] Uncaught exception: {e}", file=sys.stderr)
|
|
413
556
|
self.send_response(500)
|
|
414
557
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
415
558
|
self.end_headers()
|
|
416
|
-
self.wfile.write(json.dumps({"error":
|
|
559
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
417
560
|
|
|
418
561
|
def serve_agents(self):
|
|
419
562
|
"""Serve rich agent list with metadata via GET /api/agents."""
|
|
@@ -425,10 +568,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
425
568
|
self.end_headers()
|
|
426
569
|
self.wfile.write(json.dumps(agents, default=str).encode('utf-8'))
|
|
427
570
|
except Exception as e:
|
|
571
|
+
print(f"[serve_agents] Uncaught exception: {e}", file=sys.stderr)
|
|
428
572
|
self.send_response(500)
|
|
429
573
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
430
574
|
self.end_headers()
|
|
431
|
-
self.wfile.write(json.dumps({"error":
|
|
575
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
432
576
|
|
|
433
577
|
def serve_agent(self):
|
|
434
578
|
"""Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
|
|
@@ -471,6 +615,49 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
471
615
|
print(f"[serve_agent] Uncaught exception: {e}", file=sys.stderr)
|
|
472
616
|
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
473
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
|
+
|
|
474
661
|
def _write_sse_event(self, event_name, payload):
|
|
475
662
|
"""Write one SSE frame with timeout. Caller handles disconnect exceptions."""
|
|
476
663
|
msg = f"event: {event_name}\ndata: {payload}\n\n"
|
|
@@ -588,7 +775,7 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
588
775
|
return
|
|
589
776
|
|
|
590
777
|
body_bytes = self.rfile.read(content_length)
|
|
591
|
-
data = json.loads(body_bytes.decode('utf-8', errors='
|
|
778
|
+
data = json.loads(body_bytes.decode('utf-8', errors='replace'))
|
|
592
779
|
text = data.get("text", "").strip()
|
|
593
780
|
|
|
594
781
|
if not text:
|
|
@@ -612,10 +799,11 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
|
612
799
|
self.end_headers()
|
|
613
800
|
self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
|
|
614
801
|
except Exception as e:
|
|
802
|
+
print(f"[handle_submit] Uncaught exception: {e}", file=sys.stderr)
|
|
615
803
|
self.send_response(500)
|
|
616
804
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
617
805
|
self.end_headers()
|
|
618
|
-
self.wfile.write(json.dumps({"error":
|
|
806
|
+
self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
|
|
619
807
|
|
|
620
808
|
|
|
621
809
|
class QuietThreadingHTTPServer(http.server.ThreadingHTTPServer):
|