@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.
Files changed (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. 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: env AESOP_ROOT > default $HOME/aesop
38
- AESOP_ROOT = Path(os.getenv("AESOP_ROOT", Path.home() / "aesop"))
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 pipes) — silently, don't count as skipped
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 != SESSION_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, get_fleet_agents)
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. Same belt-and-suspenders shape as agents.py.
176
- try:
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": str(e)}).encode('utf-8'))
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": str(e)}).encode('utf-8'))
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": str(e)}).encode('utf-8'))
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": str(e)}).encode('utf-8'))
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": str(e)}).encode('utf-8'))
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": str(e)}).encode('utf-8'))
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": str(e)}).encode('utf-8'))
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='ignore'))
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": str(e)}).encode('utf-8'))
806
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
619
807
 
620
808
 
621
809
  class QuietThreadingHTTPServer(http.server.ThreadingHTTPServer):