@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5
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 +79 -95
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +126 -245
- package/aesop.config.example.json +34 -26
- package/bin/CLAUDE.md +67 -0
- package/bin/cli.js +593 -36
- package/daemons/CLAUDE.md +36 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +95 -12
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELEASING.md +159 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/archive/README.md +3 -0
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/archive/spikes/tiered-cognition/README.md +27 -0
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/docs/case-study-portfolio.md +61 -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 +61 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +75 -88
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +613 -61
- package/package.json +15 -3
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/skills/power/SKILL.md +161 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- 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/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +72 -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/tools/agent-forensics.sh +186 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +317 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +138 -31
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/stall_check.py +172 -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_dash.py +513 -0
- package/tools/verify_submit_encoding.py +202 -0
- package/ui/CLAUDE.md +125 -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/agents.py +183 -0
- package/ui/api/__init__.py +84 -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/api/submit.py +97 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +735 -0
- package/ui/config.py +123 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +144 -0
- package/ui/handler.py +676 -0
- package/ui/render.py +49 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +183 -0
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Browser-level proof for the wave-14 React dashboard (ui/web/dist/).
|
|
2
|
+
|
|
3
|
+
Drives the BUILT app served by `python ui/serve.py` against fixture fleet state,
|
|
4
|
+
asserting the contract via data-testid hooks only (never CSS internals):
|
|
5
|
+
|
|
6
|
+
Populated-state phase:
|
|
7
|
+
(a) console clean of errors across the whole run
|
|
8
|
+
(b) app serves React dist (not legacy template)
|
|
9
|
+
(c) health-header testid present and rendered
|
|
10
|
+
(d) view testids present (overview on first paint)
|
|
11
|
+
(e) inbox form testids (submit flow)
|
|
12
|
+
(f) cost view renders table/chart/scorecard from the fixture ledger
|
|
13
|
+
(g) CSS stylesheet loaded (reduced-motion media query proven by vitest)
|
|
14
|
+
(h) a11y live regions present (role=status or aria-live)
|
|
15
|
+
(j) SSE live update WITHOUT reload (tracker.json mutation appears in DOM)
|
|
16
|
+
(k) tracker round-trip through the real form: create -> proposed lane, claim/done move lanes
|
|
17
|
+
(l) hostile javascript: pr_link is inert in the real DOM (no javascript: href)
|
|
18
|
+
(m) keyboard-only agent-row expand; expansion SURVIVES an SSE agents update
|
|
19
|
+
(n) computed contrast of health-header label >= 4.5:1 in BOTH themes (toggle exercised)
|
|
20
|
+
(o) orchestrator-status phase=audit -> audit badge appears in a live region
|
|
21
|
+
|
|
22
|
+
Empty-state phase (separate boot, empty tracker/agents/alerts/backlog):
|
|
23
|
+
(i) all four views render their empty states with a clean console
|
|
24
|
+
|
|
25
|
+
Run: python tools/verify_dash.py (exit 0 = proven, 1 = failed)
|
|
26
|
+
python tools/verify_dash.py --allow-skip (exit 0 = proven or skipped, 1 = failed)
|
|
27
|
+
|
|
28
|
+
Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip is passed).
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import shutil
|
|
34
|
+
import socket
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
import time
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
42
|
+
SERVE = REPO / "ui" / "serve.py"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _real_console_errors(console_errors, failed_urls):
|
|
46
|
+
"""Drop noise from the captured console errors.
|
|
47
|
+
|
|
48
|
+
Playwright reports a failed sub-resource as a generic
|
|
49
|
+
"Failed to load resource: ... 404" console line that carries NO url, so
|
|
50
|
+
string-matching it is fragile. We instead correlate with the actual
|
|
51
|
+
failed-response urls: a generic resource-load line is ignorable when the
|
|
52
|
+
only failed responses were favicon (which the server now answers 204, so
|
|
53
|
+
this is belt-and-suspenders). Non-favicon failed responses are surfaced
|
|
54
|
+
explicitly so a real broken asset still fails the proof.
|
|
55
|
+
"""
|
|
56
|
+
non_favicon = [u for u in failed_urls if "favicon" not in u.lower()]
|
|
57
|
+
real = []
|
|
58
|
+
for e in console_errors:
|
|
59
|
+
low = e.lower()
|
|
60
|
+
if "favicon" in low:
|
|
61
|
+
continue
|
|
62
|
+
if "failed to load resource" in low and not non_favicon:
|
|
63
|
+
continue # only favicon failed → ignore the urlless generic line
|
|
64
|
+
real.append(e)
|
|
65
|
+
real.extend(f"failed resource: {u}" for u in non_favicon)
|
|
66
|
+
return real
|
|
67
|
+
|
|
68
|
+
AGENT_FULL_ID = "verifyagent0123456789ab"
|
|
69
|
+
PROMPT_MARKER = "FIXTURE-PROMPT-MARKER: rebuild the flux capacitor\n" + "\n".join(
|
|
70
|
+
f"line {i}: recalibrate subsystem {i} and verify each tolerance band carefully"
|
|
71
|
+
for i in range(60))
|
|
72
|
+
|
|
73
|
+
FIXTURE_BACKLOG = """# Audit backlog — wave-14 verify_dash fixture
|
|
74
|
+
|
|
75
|
+
**Status legend:** ⬜ unclaimed · 🔵 dispatched · ✅ merged · ⏸ user call
|
|
76
|
+
|
|
77
|
+
## P0 — correctness / security
|
|
78
|
+
|
|
79
|
+
- ✅ **[sec] Dashboard rewrite (U1 foundation).** completed.
|
|
80
|
+
- 🔵 **[ui] React component library (U4-U7).** in progress.
|
|
81
|
+
|
|
82
|
+
## P1 — observability
|
|
83
|
+
|
|
84
|
+
- ⬜ **[cost] Cost analytics per model.** todo.
|
|
85
|
+
|
|
86
|
+
## Landing log
|
|
87
|
+
- fixture
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
FIXTURE_LEDGER = """| timestamp | agent_type | model | duration_seconds | tokens_in | tokens_out | verdict |
|
|
91
|
+
| --- | --- | --- | --- | --- | --- | --- |
|
|
92
|
+
| 2026-07-13T14:00:00Z | orchestrator | claude-opus-4-20250805 | 120 | 50000 | 12000 | OK |
|
|
93
|
+
| 2026-07-13T14:02:30Z | haiku | claude-haiku-4-5-20251001 | 45 | 12000 | 3500 | OK |
|
|
94
|
+
| 2026-07-13T14:05:15Z | haiku | claude-haiku-4-5-20251001 | 50 | 14000 | 4200 | OK |
|
|
95
|
+
| 2026-07-13T14:08:00Z | sonnet | claude-sonnet-4-5-20250929 | 85 | 28000 | 8100 | OK |
|
|
96
|
+
| 2026-07-13T14:12:20Z | haiku | claude-haiku-4-5-20251001 | 40 | 11000 | 3200 | FAILED |
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
XSS_ITEM = {
|
|
100
|
+
"id": "fixturexss01",
|
|
101
|
+
"title": "fixture xss probe",
|
|
102
|
+
"priority": "P2",
|
|
103
|
+
"status": "todo",
|
|
104
|
+
"lane": "proposed",
|
|
105
|
+
"source": "verify-dash",
|
|
106
|
+
"tags": ["fixture"],
|
|
107
|
+
"notes": "hostile pr_link must render inert",
|
|
108
|
+
"pr_link": "javascript:alert(1)",
|
|
109
|
+
"created_at": "2026-07-14T00:00:00Z",
|
|
110
|
+
"completed_at": None,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def free_port():
|
|
115
|
+
s = socket.socket()
|
|
116
|
+
s.bind(("127.0.0.1", 0))
|
|
117
|
+
port = s.getsockname()[1]
|
|
118
|
+
s.close()
|
|
119
|
+
return port
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _rel_luminance(rgb):
|
|
123
|
+
def chan(c):
|
|
124
|
+
c = c / 255.0
|
|
125
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
126
|
+
r, g, b = (chan(v) for v in rgb)
|
|
127
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def contrast_ratio(rgb1, rgb2):
|
|
131
|
+
l1, l2 = _rel_luminance(rgb1), _rel_luminance(rgb2)
|
|
132
|
+
hi, lo = max(l1, l2), min(l1, l2)
|
|
133
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_rgb(css_color):
|
|
137
|
+
"""Parse 'rgb(r, g, b)' / 'rgba(r, g, b, a)' into an (r, g, b) tuple."""
|
|
138
|
+
inner = css_color[css_color.index("(") + 1: css_color.rindex(")")]
|
|
139
|
+
parts = [p.strip() for p in inner.split(",")]
|
|
140
|
+
return tuple(int(float(p)) for p in parts[:3])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def copy_dist(root: Path):
|
|
144
|
+
real_dist = REPO / "ui" / "web" / "dist"
|
|
145
|
+
if real_dist.is_dir():
|
|
146
|
+
shutil.copytree(real_dist, root / "ui" / "web" / "dist")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_fixture(root: Path, hint: str):
|
|
150
|
+
"""Populated fixture: agent + tracker (with XSS probe) + alerts + ledger + backlog."""
|
|
151
|
+
(root / "state").mkdir(exist_ok=True)
|
|
152
|
+
(root / "transcripts").mkdir(exist_ok=True)
|
|
153
|
+
(root / "dash").mkdir(exist_ok=True)
|
|
154
|
+
copy_dist(root)
|
|
155
|
+
|
|
156
|
+
(root / "AUDIT-BACKLOG.md").write_text(FIXTURE_BACKLOG, encoding="utf-8")
|
|
157
|
+
|
|
158
|
+
(root / "state" / "ledger").mkdir(parents=True, exist_ok=True)
|
|
159
|
+
(root / "state" / "ledger" / "OUTCOMES-LEDGER.md").write_text(
|
|
160
|
+
FIXTURE_LEDGER, encoding="utf-8")
|
|
161
|
+
|
|
162
|
+
(root / "state" / "SECURITY-ALERTS.log").write_text(
|
|
163
|
+
"2026-07-13T14:32:01Z | HIGH | fixture alert high\n"
|
|
164
|
+
"2026-07-13T14:30:05Z | MED | fixture alert med\n", encoding="utf-8")
|
|
165
|
+
|
|
166
|
+
(root / "state" / "tracker.json").write_text(
|
|
167
|
+
json.dumps({"version": 1, "items": [XSS_ITEM]}), encoding="utf-8")
|
|
168
|
+
|
|
169
|
+
# Fake detector: reads hint.txt so live agent updates are deterministic.
|
|
170
|
+
(root / "hint.txt").write_text(hint, encoding="utf-8")
|
|
171
|
+
fake = (
|
|
172
|
+
"import { readFileSync } from 'node:fs';\n"
|
|
173
|
+
"const hint = readFileSync(new URL('../hint.txt', import.meta.url), 'utf8').trim();\n"
|
|
174
|
+
"console.log(JSON.stringify([{id:'" + AGENT_FULL_ID[:13] + "',"
|
|
175
|
+
"status:'running',age_s:4,hint:hint,taskLabel:hint}]));\n"
|
|
176
|
+
)
|
|
177
|
+
(root / "dash" / "dash-extra.mjs").write_text(fake, encoding="utf-8")
|
|
178
|
+
transcript = root / "transcripts" / f"agent-{AGENT_FULL_ID}.jsonl"
|
|
179
|
+
lines = [
|
|
180
|
+
json.dumps({"type": "user", "parentUuid": None,
|
|
181
|
+
"message": {"content": PROMPT_MARKER}}),
|
|
182
|
+
json.dumps({"type": "assistant", "model": "claude-haiku-4-5",
|
|
183
|
+
"message": {"content": "working"}}),
|
|
184
|
+
]
|
|
185
|
+
transcript.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
186
|
+
(root / "transcripts" / "agent-seed.jsonl").write_text("{}\n", encoding="utf-8")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_empty_fixture(root: Path):
|
|
190
|
+
"""Empty fixture: no tracker items, no agents, no alerts, no backlog, no ledger."""
|
|
191
|
+
(root / "state").mkdir(exist_ok=True)
|
|
192
|
+
(root / "transcripts").mkdir(exist_ok=True)
|
|
193
|
+
(root / "dash").mkdir(exist_ok=True)
|
|
194
|
+
copy_dist(root)
|
|
195
|
+
(root / "dash" / "dash-extra.mjs").write_text(
|
|
196
|
+
"console.log(JSON.stringify([]));\n", encoding="utf-8")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def start_server(root: Path, port: int):
|
|
200
|
+
state_root = root / "state"
|
|
201
|
+
real_state = Path.home() / "aesop" / "state"
|
|
202
|
+
if state_root.resolve() == real_state.resolve():
|
|
203
|
+
raise RuntimeError("state dir resolved to real repo state (~aesop/state)")
|
|
204
|
+
env = dict(os.environ,
|
|
205
|
+
AESOP_ROOT=str(root),
|
|
206
|
+
AESOP_STATE_ROOT=str(state_root),
|
|
207
|
+
AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
|
|
208
|
+
AESOP_UI_COLLECT_INTERVAL="0.3",
|
|
209
|
+
PORT=str(port))
|
|
210
|
+
server = subprocess.Popen([sys.executable, str(SERVE)], env=env,
|
|
211
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
212
|
+
for _ in range(50):
|
|
213
|
+
try:
|
|
214
|
+
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
|
215
|
+
return server
|
|
216
|
+
except OSError:
|
|
217
|
+
time.sleep(0.2)
|
|
218
|
+
server.kill()
|
|
219
|
+
raise RuntimeError("server never came up")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def stop_server(server):
|
|
223
|
+
server.terminate()
|
|
224
|
+
try:
|
|
225
|
+
server.wait(timeout=5)
|
|
226
|
+
except subprocess.TimeoutExpired:
|
|
227
|
+
server.kill()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run_empty_phase(pw, failures):
|
|
231
|
+
"""(i) all four views render empty states with a clean console."""
|
|
232
|
+
root = Path(tempfile.mkdtemp(prefix="aesop-verify-w14-empty-"))
|
|
233
|
+
port = free_port()
|
|
234
|
+
console_errors = []
|
|
235
|
+
failed_urls = []
|
|
236
|
+
build_empty_fixture(root)
|
|
237
|
+
try:
|
|
238
|
+
server = start_server(root, port)
|
|
239
|
+
except RuntimeError as e:
|
|
240
|
+
failures.append(f"(i) empty-state server failed: {e}")
|
|
241
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
242
|
+
return
|
|
243
|
+
try:
|
|
244
|
+
browser = pw.chromium.launch(headless=True)
|
|
245
|
+
page = browser.new_page()
|
|
246
|
+
page.on("console", lambda m: console_errors.append(m.text)
|
|
247
|
+
if m.type == "error" else None)
|
|
248
|
+
page.on("pageerror", lambda e: console_errors.append(str(e)))
|
|
249
|
+
page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
|
|
250
|
+
page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
|
|
251
|
+
try:
|
|
252
|
+
page.wait_for_selector("[data-testid='health-header']", timeout=15000)
|
|
253
|
+
except Exception as e:
|
|
254
|
+
failures.append(f"(i) empty-state app never mounted: {e}")
|
|
255
|
+
views = [("#/", "view-overview"), ("#/work", "view-work"),
|
|
256
|
+
("#/activity", "view-activity"), ("#/cost", "view-cost")]
|
|
257
|
+
for hash_, testid in views:
|
|
258
|
+
page.evaluate(f"location.hash = '{hash_}'")
|
|
259
|
+
try:
|
|
260
|
+
page.wait_for_selector(f"[data-testid='{testid}']", timeout=12000)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
failures.append(f"(i) empty-state view {testid} did not render: {e}")
|
|
263
|
+
time.sleep(0.5)
|
|
264
|
+
real_errors = _real_console_errors(console_errors, failed_urls)
|
|
265
|
+
if real_errors:
|
|
266
|
+
failures.append(f"(i) empty-state console errors: {real_errors[:3]}")
|
|
267
|
+
browser.close()
|
|
268
|
+
finally:
|
|
269
|
+
stop_server(server)
|
|
270
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def main():
|
|
274
|
+
parser = argparse.ArgumentParser(
|
|
275
|
+
description="Browser-level proof for the wave-14 React dashboard (ui/web/dist/)")
|
|
276
|
+
parser.add_argument("--allow-skip", action="store_true",
|
|
277
|
+
help="Allow skipping if playwright/chromium is unavailable")
|
|
278
|
+
args = parser.parse_args()
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
from playwright.sync_api import sync_playwright
|
|
282
|
+
except ImportError:
|
|
283
|
+
msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
|
|
284
|
+
print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
|
|
285
|
+
return 0 if args.allow_skip else 1
|
|
286
|
+
|
|
287
|
+
root = Path(tempfile.mkdtemp(prefix="aesop-verify-wave14-dash-"))
|
|
288
|
+
port = free_port()
|
|
289
|
+
console_errors = []
|
|
290
|
+
failed_urls = []
|
|
291
|
+
failures = []
|
|
292
|
+
build_fixture(root, hint="fixture hint alpha")
|
|
293
|
+
try:
|
|
294
|
+
server = start_server(root, port)
|
|
295
|
+
except RuntimeError as e:
|
|
296
|
+
print(f"FAIL: {e}")
|
|
297
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
298
|
+
return 1
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
with sync_playwright() as pw:
|
|
302
|
+
try:
|
|
303
|
+
browser = pw.chromium.launch(headless=True)
|
|
304
|
+
except Exception as e:
|
|
305
|
+
msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
|
|
306
|
+
print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
|
|
307
|
+
return 0 if args.allow_skip else 1
|
|
308
|
+
|
|
309
|
+
page = browser.new_page()
|
|
310
|
+
page.on("console", lambda m: console_errors.append(m.text)
|
|
311
|
+
if m.type == "error" else None)
|
|
312
|
+
page.on("pageerror", lambda e: console_errors.append(str(e)))
|
|
313
|
+
page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
|
|
314
|
+
page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
|
|
315
|
+
|
|
316
|
+
# (b) app serves React dist, not the legacy template
|
|
317
|
+
try:
|
|
318
|
+
page_html = page.content()
|
|
319
|
+
assert "data-testid" in page_html, "React app should use data-testid hooks"
|
|
320
|
+
assert "audit-banner" not in page_html, "should not be serving old template"
|
|
321
|
+
except Exception as e:
|
|
322
|
+
failures.append(f"(b) app does not serve React dist: {e}")
|
|
323
|
+
|
|
324
|
+
# (c) health header rendered
|
|
325
|
+
try:
|
|
326
|
+
page.wait_for_selector("[data-testid='health-header']", timeout=5000)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
failures.append(f"(c) health-header testid not found: {e}")
|
|
329
|
+
|
|
330
|
+
# (d) overview view on first paint
|
|
331
|
+
try:
|
|
332
|
+
page.wait_for_selector("[data-testid='view-overview']", timeout=5000)
|
|
333
|
+
except Exception as e:
|
|
334
|
+
failures.append(f"(d) view testids not found: {e}")
|
|
335
|
+
|
|
336
|
+
# (e) inbox form present
|
|
337
|
+
try:
|
|
338
|
+
page.wait_for_selector("[data-testid='inbox-input']", timeout=5000)
|
|
339
|
+
page.wait_for_selector("[data-testid='inbox-submit']", timeout=5000)
|
|
340
|
+
except Exception as e:
|
|
341
|
+
failures.append(f"(e) inbox form testids not found: {e}")
|
|
342
|
+
|
|
343
|
+
# (m) keyboard-only agent expand + survival across an SSE agents update
|
|
344
|
+
try:
|
|
345
|
+
page.wait_for_selector("[data-testid='agent-row']", timeout=8000)
|
|
346
|
+
# keyboard-only: focus the row's real <button> and press Enter
|
|
347
|
+
expand_btn = page.locator("[data-testid='agent-row'] button").first
|
|
348
|
+
expand_btn.focus()
|
|
349
|
+
page.keyboard.press("Enter")
|
|
350
|
+
page.wait_for_selector("[data-testid='agent-row-detail']", timeout=8000)
|
|
351
|
+
# trigger a live agents update: change hint + touch the fingerprint seed
|
|
352
|
+
(root / "hint.txt").write_text("fixture hint beta", encoding="utf-8")
|
|
353
|
+
seed = root / "transcripts" / "agent-seed.jsonl"
|
|
354
|
+
seed.write_text('{"touch": %d}\n' % time.time_ns(), encoding="utf-8")
|
|
355
|
+
page.wait_for_function(
|
|
356
|
+
"document.body.innerText.includes('fixture hint beta')", timeout=10000)
|
|
357
|
+
assert page.query_selector("[data-testid='agent-row-detail']") is not None, \
|
|
358
|
+
"expansion did not survive the SSE agents update"
|
|
359
|
+
except Exception as e:
|
|
360
|
+
failures.append(f"(m) keyboard expand / expansion-survival failed: {e}")
|
|
361
|
+
|
|
362
|
+
# (j) SSE live update WITHOUT reload: mutate tracker.json directly
|
|
363
|
+
try:
|
|
364
|
+
page.evaluate("location.hash = '#/work'")
|
|
365
|
+
page.wait_for_selector("[data-testid='view-work']", timeout=5000)
|
|
366
|
+
tracker_file = root / "state" / "tracker.json"
|
|
367
|
+
data = json.loads(tracker_file.read_text(encoding="utf-8"))
|
|
368
|
+
data["items"].append(dict(XSS_ITEM, id="fixturesse001",
|
|
369
|
+
title="SSE-LIVE-MARKER item",
|
|
370
|
+
pr_link=None))
|
|
371
|
+
tracker_file.write_text(json.dumps(data), encoding="utf-8")
|
|
372
|
+
page.wait_for_function(
|
|
373
|
+
"document.body.innerText.includes('SSE-LIVE-MARKER')", timeout=10000)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
failures.append(f"(j) SSE live tracker update failed: {e}")
|
|
376
|
+
|
|
377
|
+
# (l) hostile javascript: pr_link inert in the real DOM
|
|
378
|
+
try:
|
|
379
|
+
bad = page.evaluate(
|
|
380
|
+
"Array.from(document.querySelectorAll('a[href]'))"
|
|
381
|
+
".filter(a => a.href.toLowerCase().startsWith('javascript:')).length")
|
|
382
|
+
assert bad == 0, f"{bad} anchor(s) carry a javascript: href"
|
|
383
|
+
except Exception as e:
|
|
384
|
+
failures.append(f"(l) XSS pr_link not inert: {e}")
|
|
385
|
+
|
|
386
|
+
# (k) tracker round-trip through the real form (CSRF path)
|
|
387
|
+
try:
|
|
388
|
+
# the add form sits behind a "+ Add Item" toggle
|
|
389
|
+
if page.locator("[data-testid='tracker-form-title']").count() == 0:
|
|
390
|
+
page.get_by_role("button", name="+ Add Item").click()
|
|
391
|
+
page.fill("[data-testid='tracker-form-title']", "ROUNDTRIP-MARKER item")
|
|
392
|
+
page.click("[data-testid='tracker-form-submit']")
|
|
393
|
+
page.wait_for_function(
|
|
394
|
+
"document.body.innerText.includes('ROUNDTRIP-MARKER')", timeout=10000)
|
|
395
|
+
# locate the card, then drive its lane actions by accessible name
|
|
396
|
+
card = page.locator("[data-testid='tracker-card']",
|
|
397
|
+
has_text="ROUNDTRIP-MARKER").first
|
|
398
|
+
moved = 0
|
|
399
|
+
for action in ("Claim", "Done"):
|
|
400
|
+
btn = card.get_by_role("button", name=action)
|
|
401
|
+
if btn.count() == 0:
|
|
402
|
+
# expand the card first if actions are inside the detail area
|
|
403
|
+
card.click()
|
|
404
|
+
btn = card.get_by_role("button", name=action)
|
|
405
|
+
if btn.count() > 0:
|
|
406
|
+
btn.first.click()
|
|
407
|
+
time.sleep(1.0)
|
|
408
|
+
moved += 1
|
|
409
|
+
assert moved == 2, f"only {moved}/2 lane actions (Claim/Done) were operable"
|
|
410
|
+
except Exception as e:
|
|
411
|
+
failures.append(f"(k) tracker round-trip failed: {e}")
|
|
412
|
+
|
|
413
|
+
# (f) cost view renders table/chart/scorecard from the fixture ledger
|
|
414
|
+
try:
|
|
415
|
+
page.evaluate("location.hash = '#/cost'")
|
|
416
|
+
page.wait_for_selector("[data-testid='cost-table']", timeout=8000)
|
|
417
|
+
page.wait_for_selector("[data-testid='cost-chart']", timeout=5000)
|
|
418
|
+
page.wait_for_selector("[data-testid='scorecard']", timeout=5000)
|
|
419
|
+
assert "haiku" in page.inner_text("[data-testid='cost-table']").lower(), \
|
|
420
|
+
"fixture ledger models not rendered in cost table"
|
|
421
|
+
except Exception as e:
|
|
422
|
+
failures.append(f"(f) cost view did not render fixture ledger: {e}")
|
|
423
|
+
|
|
424
|
+
# (o) orchestrator-status phase=audit -> audit badge in a live region
|
|
425
|
+
try:
|
|
426
|
+
status_file = root / "state" / "orchestrator-status.json"
|
|
427
|
+
status_file.write_text(json.dumps({
|
|
428
|
+
"id": "main", "role": "orchestrator",
|
|
429
|
+
"activity": "running audit", "phase": "audit"}), encoding="utf-8")
|
|
430
|
+
page.wait_for_function(
|
|
431
|
+
"(() => { const el = document.querySelector(\"[data-testid='health-orchestrator']\");"
|
|
432
|
+
" return el && /audit/i.test(el.textContent); })()", timeout=10000)
|
|
433
|
+
announced = page.evaluate(
|
|
434
|
+
"(() => { const el = document.querySelector(\"[data-testid='health-orchestrator']\");"
|
|
435
|
+
" const near = el.closest('[role=status],[aria-live]') || el.querySelector('[role=status],[aria-live]');"
|
|
436
|
+
" return !!near || el.getAttribute('role') === 'status' || el.hasAttribute('aria-live'); })()")
|
|
437
|
+
assert announced, "audit badge is not in/near a live region"
|
|
438
|
+
except Exception as e:
|
|
439
|
+
failures.append(f"(o) audit-phase badge failed: {e}")
|
|
440
|
+
|
|
441
|
+
# (n) computed contrast of a health-header label in BOTH themes
|
|
442
|
+
try:
|
|
443
|
+
page.evaluate("location.hash = '#/'")
|
|
444
|
+
page.wait_for_selector("[data-testid='health-watchdog']", timeout=5000)
|
|
445
|
+
ratios = {}
|
|
446
|
+
for theme_pass in ("first", "second"):
|
|
447
|
+
styles = page.evaluate(
|
|
448
|
+
"(() => { const el = document.querySelector(\"[data-testid='health-watchdog']\");"
|
|
449
|
+
" const cs = getComputedStyle(el);"
|
|
450
|
+
" let bg = 'rgba(0, 0, 0, 0)'; let node = el;"
|
|
451
|
+
" while (node) { const c = getComputedStyle(node).backgroundColor;"
|
|
452
|
+
" if (c && !c.includes('0, 0, 0, 0')) { bg = c; break; } node = node.parentElement; }"
|
|
453
|
+
" if (bg.includes('0, 0, 0, 0')) bg = getComputedStyle(document.body).backgroundColor;"
|
|
454
|
+
" return { fg: cs.color, bg: bg,"
|
|
455
|
+
" theme: document.documentElement.getAttribute('data-theme') || 'default' }; })()")
|
|
456
|
+
ratio = contrast_ratio(parse_rgb(styles["fg"]), parse_rgb(styles["bg"]))
|
|
457
|
+
ratios[styles["theme"] + "/" + theme_pass] = round(ratio, 2)
|
|
458
|
+
assert ratio >= 4.5, \
|
|
459
|
+
f"health label contrast {ratio:.2f}:1 in theme '{styles['theme']}' (< 4.5)"
|
|
460
|
+
if theme_pass == "first":
|
|
461
|
+
page.click("[data-testid='theme-toggle']")
|
|
462
|
+
time.sleep(0.5)
|
|
463
|
+
print(f" contrast ratios: {ratios}")
|
|
464
|
+
except Exception as e:
|
|
465
|
+
failures.append(f"(n) theme contrast check failed: {e}")
|
|
466
|
+
|
|
467
|
+
# (g) CSS stylesheet loaded
|
|
468
|
+
try:
|
|
469
|
+
assert page.query_selector("link[rel='stylesheet']") is not None, \
|
|
470
|
+
"page should have CSS stylesheet links"
|
|
471
|
+
except Exception as e:
|
|
472
|
+
failures.append(f"(g) CSS stylesheet check failed: {e}")
|
|
473
|
+
|
|
474
|
+
# (h) live regions present
|
|
475
|
+
try:
|
|
476
|
+
live_regions = page.evaluate(
|
|
477
|
+
"document.querySelectorAll('[role=\"status\"], [aria-live]').length")
|
|
478
|
+
assert live_regions > 0, "no live regions on page"
|
|
479
|
+
except Exception as e:
|
|
480
|
+
failures.append(f"(h) live regions check failed: {e}")
|
|
481
|
+
|
|
482
|
+
# (a) console clean across the whole populated run
|
|
483
|
+
time.sleep(0.5)
|
|
484
|
+
real_errors = _real_console_errors(console_errors, failed_urls)
|
|
485
|
+
if real_errors:
|
|
486
|
+
failures.append(f"(a) console errors: {real_errors[:3]}")
|
|
487
|
+
|
|
488
|
+
browser.close()
|
|
489
|
+
|
|
490
|
+
# (i) empty-state phase — separate boot, separate console
|
|
491
|
+
run_empty_phase(pw, failures)
|
|
492
|
+
|
|
493
|
+
finally:
|
|
494
|
+
stop_server(server)
|
|
495
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
496
|
+
|
|
497
|
+
if failures:
|
|
498
|
+
print("FAIL:")
|
|
499
|
+
for f in failures:
|
|
500
|
+
print(" -", f)
|
|
501
|
+
return 1
|
|
502
|
+
|
|
503
|
+
print("PROVEN: (a) console clean (b) React dist served (c) health-header "
|
|
504
|
+
"(d) view testids (e) inbox form (f) cost view renders fixture ledger "
|
|
505
|
+
"(g) stylesheet (h) live regions (i) empty-state pass all views "
|
|
506
|
+
"(j) SSE tracker update w/o reload (k) tracker form round-trip incl. lane actions "
|
|
507
|
+
"(l) javascript: pr_link inert (m) keyboard expand survives SSE update "
|
|
508
|
+
"(n) AA contrast both themes (o) audit badge announced")
|
|
509
|
+
return 0
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
if __name__ == "__main__":
|
|
513
|
+
sys.exit(main())
|