@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,202 @@
|
|
|
1
|
+
"""Browser-level proof that /submit's inbox-file bootstrap writes valid UTF-8
|
|
2
|
+
(ui/serve.py, handle_submit / INBOX_FILE header write).
|
|
3
|
+
|
|
4
|
+
Bug: when state/ui-inbox.md doesn't exist yet, the header used to be written
|
|
5
|
+
with INBOX_FILE.write_text(...) with no encoding=. On Windows that falls back
|
|
6
|
+
to the locale-preferred encoding (cp1252), so the em-dash U+2014 in the header
|
|
7
|
+
is encoded as the single byte 0x97. Every subsequent append opens the file
|
|
8
|
+
with encoding='utf-8'. The result is a file that is not valid UTF-8 as a
|
|
9
|
+
whole: any reader that does ui-inbox.md.read_text(encoding="utf-8") (the
|
|
10
|
+
normal, correct thing to do — matching the encoding the append path already
|
|
11
|
+
declares) raises UnicodeDecodeError: 'utf-8' codec can't decode byte 0x97.
|
|
12
|
+
That breaks the project's "orchestrator reads state/ui-inbox.md each turn /
|
|
13
|
+
on /power" pipeline on Windows dev machines.
|
|
14
|
+
|
|
15
|
+
This drives the REAL /submit flow end-to-end in a real headless Chromium:
|
|
16
|
+
type into #inbox-input, click #inbox-button, with the real CSRF token the
|
|
17
|
+
page injects — against a FRESH fixture (no pre-existing ui-inbox.md, so the
|
|
18
|
+
header-bootstrap path is exercised) — then asserts:
|
|
19
|
+
(a) state/ui-inbox.md decodes cleanly via .read_text(encoding="utf-8")
|
|
20
|
+
with NO exception
|
|
21
|
+
(b) it contains the submitted marker text
|
|
22
|
+
|
|
23
|
+
Run: python tools/verify_submit_encoding.py (exit 0 = proven, 1 = failed)
|
|
24
|
+
python tools/verify_submit_encoding.py --allow-skip (exit 0 = proven or skipped, 1 = failed)
|
|
25
|
+
|
|
26
|
+
Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip is passed).
|
|
27
|
+
Use --allow-skip for environments that legitimately can't run a browser.
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import os
|
|
31
|
+
import shutil
|
|
32
|
+
import socket
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
import time
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
40
|
+
SERVE = REPO / "ui" / "serve.py"
|
|
41
|
+
|
|
42
|
+
MARKER = "SUBMIT-ENCODING-FIXTURE-MARKER: rebuild the flux capacitor"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def free_port():
|
|
46
|
+
s = socket.socket()
|
|
47
|
+
s.bind(("127.0.0.1", 0))
|
|
48
|
+
port = s.getsockname()[1]
|
|
49
|
+
s.close()
|
|
50
|
+
return port
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_fixture(root: Path):
|
|
54
|
+
"""Fresh fixture root — deliberately does NOT create state/ui-inbox.md,
|
|
55
|
+
so /submit exercises the header-bootstrap (write_text) path, not just
|
|
56
|
+
the append path."""
|
|
57
|
+
(root / "state").mkdir(exist_ok=True)
|
|
58
|
+
(root / "transcripts").mkdir(exist_ok=True)
|
|
59
|
+
(root / "dash").mkdir(exist_ok=True)
|
|
60
|
+
|
|
61
|
+
# Copy ui/web/dist from the real repo so the server can serve the built React app
|
|
62
|
+
repo = Path(__file__).resolve().parent.parent
|
|
63
|
+
real_dist = repo / "ui" / "web" / "dist"
|
|
64
|
+
if real_dist.is_dir():
|
|
65
|
+
fixture_dist = root / "ui" / "web" / "dist"
|
|
66
|
+
shutil.copytree(real_dist, fixture_dist)
|
|
67
|
+
|
|
68
|
+
(root / "AUDIT-BACKLOG.md").write_text(
|
|
69
|
+
"# Audit backlog — verify_submit_encoding fixture\n\n## Landing log\n- fixture\n",
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
)
|
|
72
|
+
# Minimal fake detector so the collector thread has nothing real to shell out to.
|
|
73
|
+
(root / "dash" / "dash-extra.mjs").write_text(
|
|
74
|
+
"console.log(JSON.stringify([]));\n", encoding="utf-8"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
parser = argparse.ArgumentParser(
|
|
80
|
+
description="Browser-level proof for /submit UTF-8 encoding"
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--allow-skip",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="Allow skipping if playwright/chromium is unavailable (exit 0 instead of 1)"
|
|
86
|
+
)
|
|
87
|
+
args = parser.parse_args()
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
from playwright.sync_api import sync_playwright
|
|
91
|
+
except ImportError:
|
|
92
|
+
msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
|
|
93
|
+
if args.allow_skip:
|
|
94
|
+
print(f"SKIP: {msg}")
|
|
95
|
+
return 0
|
|
96
|
+
else:
|
|
97
|
+
print(f"FAIL: {msg}")
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
root = Path(tempfile.mkdtemp(prefix="aesop-verify-submit-"))
|
|
101
|
+
state_root = root / "state"
|
|
102
|
+
|
|
103
|
+
# HARD GUARD: refuse to run if state_root looks like the real repo state dir
|
|
104
|
+
# (e.g., ~/aesop/state or an absolute path ending with /aesop/state)
|
|
105
|
+
real_state = Path.home() / "aesop" / "state"
|
|
106
|
+
if state_root.resolve() == real_state.resolve():
|
|
107
|
+
print("FAIL: state dir resolved to real repo state (~aesop/state), refusing to run")
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
inbox_file = state_root / "ui-inbox.md"
|
|
111
|
+
port = free_port()
|
|
112
|
+
env = dict(
|
|
113
|
+
os.environ,
|
|
114
|
+
AESOP_ROOT=str(root),
|
|
115
|
+
AESOP_STATE_ROOT=str(state_root),
|
|
116
|
+
AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
|
|
117
|
+
AESOP_UI_COLLECT_INTERVAL="0.3",
|
|
118
|
+
PORT=str(port),
|
|
119
|
+
)
|
|
120
|
+
build_fixture(root)
|
|
121
|
+
assert not inbox_file.exists(), "fixture must start WITHOUT ui-inbox.md"
|
|
122
|
+
|
|
123
|
+
server = subprocess.Popen(
|
|
124
|
+
[sys.executable, str(SERVE)], env=env,
|
|
125
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
126
|
+
)
|
|
127
|
+
failures = []
|
|
128
|
+
try:
|
|
129
|
+
for _ in range(50):
|
|
130
|
+
try:
|
|
131
|
+
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
|
132
|
+
break
|
|
133
|
+
except OSError:
|
|
134
|
+
time.sleep(0.2)
|
|
135
|
+
else:
|
|
136
|
+
print("FAIL: server never came up")
|
|
137
|
+
return 1
|
|
138
|
+
|
|
139
|
+
with sync_playwright() as pw:
|
|
140
|
+
try:
|
|
141
|
+
browser = pw.chromium.launch(headless=True)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
|
|
144
|
+
if args.allow_skip:
|
|
145
|
+
print(f"SKIP: {msg}")
|
|
146
|
+
return 0
|
|
147
|
+
else:
|
|
148
|
+
print(f"FAIL: {msg}")
|
|
149
|
+
return 1
|
|
150
|
+
page = browser.new_page()
|
|
151
|
+
page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
|
|
152
|
+
|
|
153
|
+
# Drive the real /submit flow: inbox-input -> inbox-submit, real
|
|
154
|
+
# CSRF token (window.__AESOP_CSRF_TOKEN__ injected server-side).
|
|
155
|
+
page.wait_for_selector("[data-testid='inbox-input']", timeout=8000)
|
|
156
|
+
page.fill("[data-testid='inbox-input']", MARKER)
|
|
157
|
+
page.click("[data-testid='inbox-submit']")
|
|
158
|
+
|
|
159
|
+
# Wait for the inbox file to land on disk (async POST).
|
|
160
|
+
for _ in range(50):
|
|
161
|
+
if inbox_file.exists():
|
|
162
|
+
break
|
|
163
|
+
time.sleep(0.2)
|
|
164
|
+
else:
|
|
165
|
+
failures.append("state/ui-inbox.md was never created by /submit")
|
|
166
|
+
|
|
167
|
+
browser.close()
|
|
168
|
+
finally:
|
|
169
|
+
server.terminate()
|
|
170
|
+
try:
|
|
171
|
+
server.wait(timeout=5)
|
|
172
|
+
except subprocess.TimeoutExpired:
|
|
173
|
+
server.kill()
|
|
174
|
+
|
|
175
|
+
if not failures:
|
|
176
|
+
raw = inbox_file.read_bytes()
|
|
177
|
+
try:
|
|
178
|
+
text = inbox_file.read_text(encoding="utf-8")
|
|
179
|
+
except UnicodeDecodeError as e:
|
|
180
|
+
bad_byte = raw[e.start:e.end].hex()
|
|
181
|
+
failures.append(
|
|
182
|
+
f"(a) state/ui-inbox.md is not valid UTF-8: {e} "
|
|
183
|
+
f"(offending byte(s): 0x{bad_byte}) — raw bytes: {raw!r}"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
if MARKER not in text:
|
|
187
|
+
failures.append(f"(b) submitted marker text not found in inbox file: {text!r}")
|
|
188
|
+
|
|
189
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
190
|
+
|
|
191
|
+
if failures:
|
|
192
|
+
print("FAIL:")
|
|
193
|
+
for f in failures:
|
|
194
|
+
print(" -", f)
|
|
195
|
+
return 1
|
|
196
|
+
print("PROVEN: (a) state/ui-inbox.md decodes cleanly as UTF-8 "
|
|
197
|
+
"(b) submitted marker text present")
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|
package/ui/CLAUDE.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# ui/ — Web dashboard
|
|
2
|
+
|
|
3
|
+
**Purpose**: Stdlib-only local observability dashboard. Serves a dark-theme HTML dashboard on a configurable port (realtime via Server-Sent Events), enabling real-time fleet monitoring without external dependencies.
|
|
4
|
+
|
|
5
|
+
## Files (wave-14 U9 cutover: new React+Vite app structure)
|
|
6
|
+
|
|
7
|
+
**Backend (Python, unchanged from wave-9 split)**:
|
|
8
|
+
- **serve.py** — Thin (~65-line) entry point + composition layer. Wires the sibling modules, calls `config.reload()` / `csrf.init()` / `sse.reset_state()`, and re-exports their symbols so `serve.X` keeps resolving for the test suite (which loads serve.py by path) and for `python ui/serve.py`.
|
|
9
|
+
- **config.py** — Path / env / `aesop.config.json` resolution. `reload()` recomputes all path globals from the current environment. **Load-bearing rule: every other module reads `config.X` at call time (`import config`), never `from config import <path>` — a frozen import would go stale after reload() (breaks test-fixture isolation).** Added in wave-14: `WEB_DIST` path pointing to `ui/web/dist/`.
|
|
10
|
+
- **csrf.py** — Session-token generation (atomic O_EXCL 0600) + `validate_csrf_request()`; `init()` sets `SESSION_TOKEN`.
|
|
11
|
+
- **collectors.py** — Read-only data collectors (heartbeats, repos, events, alerts, messages, backlog parse), tracker CRUD, and SSE section snapshots (incl. `read`-style tracker/orchestrator-status snapshots + inbox drain).
|
|
12
|
+
- **agents.py** — Agent transcript reading (`get_fleet_agents`, `extract_agent_dispatch_prompt`) and path-traversal-safe agent-id handling (`_AGENT_ID_FORBIDDEN`).
|
|
13
|
+
- **sse.py** — SSE client registry, bounded broadcast, hash-gated `_maybe_emit`, and the background `collector_loop`. `reset_state()` restores per-import collector isolation (the `sse` module is cached across test re-imports). Extended in wave-14: emits "cost" as a 6th SSE section.
|
|
14
|
+
- **render.py** — Renders `ui/web/dist/index.html` (wave-14 U9 cutover: no fallback), substituting the CSRF token via a unique sentinel (the sentinel persists through Vite's build). Requires `template_path` parameter; legacy fallback removed.
|
|
15
|
+
- **handler.py** — `DashboardHandler` (HTTP routing + all GET/POST endpoints incl. /api/tracker) and `run_server()`. Wave-14 additions: static serving (`GET /assets/*` from `ui/web/dist/assets/`), `/api/state` consolidated snapshot, `/api/session` for Vite dev server, `/api/cost` scorecard. Reads `config.X` / `csrf.SESSION_TOKEN` at call time.
|
|
16
|
+
- **cost.py** — Parser for `state/ledger/OUTCOMES-LEDGER.md` (markdown table); returns per-model and per-day aggregates, verdict scorecards, and optional dollar estimates (only if `aesop.config.json` supplies `pricing` map).
|
|
17
|
+
|
|
18
|
+
## State Store Integration (Wave-15)
|
|
19
|
+
|
|
20
|
+
The tracker uses a **dual-path mutation model** backed by an event-sourced SQLite WAL store:
|
|
21
|
+
|
|
22
|
+
- **Write path**: Mutations append events to `tracker_events.db` (WAL mode) via the StateAPI layer (`state_store/api.py`). Each mutation (create/update/delete) produces an immutable event in the event log.
|
|
23
|
+
- **Read path**: `tracker.json` is re-rendered as an export (projection) from the event log on every read. This export is committed to git for checkpoint durability.
|
|
24
|
+
- **Location**: `state/tracker_events.db` (SQLite WAL, never committed). `tracker.json` (rendered export, committed).
|
|
25
|
+
- **Render-failure recovery**: If projection rendering fails, the UI falls back to the last-known good `tracker.json`; no data loss. Stale renders are repaired on the next successful mutation (idempotent re-render).
|
|
26
|
+
|
|
27
|
+
This design provides:
|
|
28
|
+
- **Durable event audit trail** (append-only events in WAL)
|
|
29
|
+
- **Git-friendly checkpoints** (rendered tracker.json snapshots)
|
|
30
|
+
- **Atomic mutations** (events append, projection re-renders)
|
|
31
|
+
- **Graceful degradation** (fallback to last-known-good on render failure)
|
|
32
|
+
|
|
33
|
+
**Frontend (React 18 + Vite + TypeScript, wave-14 U1–U8)**:
|
|
34
|
+
- **ui/web/** — Complete React application (TypeScript, Vite, TypeScript strict mode).
|
|
35
|
+
- **src/main.tsx** — Vite entry point; renders `<App />` to `#root`.
|
|
36
|
+
- **src/App.tsx** — App shell: header + hash-routed view slots (/#/, /#/work, /#/activity, /#/cost).
|
|
37
|
+
- **src/styles/tokens.css** + **src/styles/global.css** — Design tokens (light/dark color palettes, spacing, typography) + base resets.
|
|
38
|
+
- **src/views/** — Page-level components (Overview, Work, Activity, Cost) with SSE bindings.
|
|
39
|
+
- **src/components/** — Reusable UI components (HealthHeader, AgentsPanel, TrackerBoard, Timeline, CostChart, etc.).
|
|
40
|
+
- **src/lib/api.ts** — Typed fetch helpers + CSRF header injection + `/api/session` fallback for dev server.
|
|
41
|
+
- **src/lib/useSSE.ts** — EventSource hook with reconnect logic, per-section state, connection status.
|
|
42
|
+
- **src/lib/types.ts** — TypeScript types for all API payloads (contract with backend).
|
|
43
|
+
- **src/lib/sanitizeUrl.ts** — XSS-safe URL parsing (PR links inert on bad schemes).
|
|
44
|
+
- **vite.config.ts** — Vite config with API proxy for `/data`, `/api`, `/events`, `/agent`, `/submit` to :8770.
|
|
45
|
+
- **dist/** — Built static files (committed to git; served by Python handler). Filenames are content-hashed by Vite.
|
|
46
|
+
- **README.md** — User guide and configuration reference (updated for wave-14).
|
|
47
|
+
|
|
48
|
+
## Configuration & Path Precedence
|
|
49
|
+
|
|
50
|
+
Configuration is resolved in this order (first match wins):
|
|
51
|
+
|
|
52
|
+
1. **Environment variables** (highest priority):
|
|
53
|
+
- `PORT` — HTTP server port (default: 8770)
|
|
54
|
+
- `AESOP_ROOT` — aesop installation root (default: `$HOME/aesop`)
|
|
55
|
+
- `AESOP_STATE_ROOT` — state directory (overrides config state_root)
|
|
56
|
+
- `AESOP_TRANSCRIPTS_ROOT` — Claude transcript directory (overrides config transcripts_root)
|
|
57
|
+
- `AESOP_UI_COLLECT_INTERVAL` — collector thread poll cadence in seconds (default: 1.0)
|
|
58
|
+
|
|
59
|
+
2. **Config file** (`aesop.config.json`):
|
|
60
|
+
- `state_root` — path to state/ directory
|
|
61
|
+
- `transcripts_root` — path to Claude transcript directory
|
|
62
|
+
|
|
63
|
+
3. **Built-in defaults** (lowest priority):
|
|
64
|
+
- `AESOP_ROOT/state` for state directory
|
|
65
|
+
- `~/.claude/projects` for transcripts
|
|
66
|
+
|
|
67
|
+
## CSRF & Session Protection
|
|
68
|
+
|
|
69
|
+
**Token model**:
|
|
70
|
+
- Per-session CSRF token generated at startup, persisted to `state/.ui-session-token` (mode 0600, readable only by owner)
|
|
71
|
+
- Token is 43-character URL-safe base64 string (256 bits)
|
|
72
|
+
- Persistent across server restarts (read from file if exists, generate fresh if not)
|
|
73
|
+
- Used to validate mutations via /submit endpoint
|
|
74
|
+
|
|
75
|
+
**Validation on /submit POST**:
|
|
76
|
+
1. **Origin/Referer check**: if present, must be local (http://127.0.0.1:<port>, http://localhost:<port>, or http://[::1]:<port>)
|
|
77
|
+
2. **X-Aesop-Token header check**: must match SESSION_TOKEN exactly
|
|
78
|
+
- Both checks fail-closed (missing header = rejection)
|
|
79
|
+
- GET /events (SSE, read-only) requires no token
|
|
80
|
+
|
|
81
|
+
**Local CLI access**:
|
|
82
|
+
- CLI tools read token from `state/.ui-session-token` (0600) to submit to /submit endpoint
|
|
83
|
+
- Legitimate browser clients: token injected into HTML template, sent by JavaScript
|
|
84
|
+
|
|
85
|
+
## API Endpoints (wave-14 wave additions)
|
|
86
|
+
|
|
87
|
+
**Read-only (no CSRF required)**:
|
|
88
|
+
- `GET /` — Renders `ui/web/dist/index.html` with CSRF token substituted (hard 500 if dist missing).
|
|
89
|
+
- `GET /assets/*` — Static files from `ui/web/dist/assets/` (content-hashed, immutable cache headers, path-traversal-safe).
|
|
90
|
+
- `GET /api/state` — Consolidated first-paint snapshot: `{data, backlog, agents, tracker, status, cost}` in one round trip (reuses latest snapshots).
|
|
91
|
+
- `GET /api/session` — Returns `{token}` for Vite dev server; Origin-checked fail-closed (only local origins).
|
|
92
|
+
- `GET /api/cost` — Cost/scorecard summary from `state/ledger/OUTCOMES-LEDGER.md` (per-model, per-day, verdicts, optional pricing estimates).
|
|
93
|
+
- `GET /events` — Server-Sent Events stream (read-only, no CSRF).
|
|
94
|
+
|
|
95
|
+
**Mutations (CSRF-gated)**:
|
|
96
|
+
- `POST /submit` — Append to inbox.
|
|
97
|
+
- `POST /api/tracker`, `POST /api/tracker/<id>` — Tracker CRUD.
|
|
98
|
+
|
|
99
|
+
## Server-Sent Events (SSE) Model
|
|
100
|
+
|
|
101
|
+
**Realtime streaming via GET /events**:
|
|
102
|
+
- ThreadingHTTPServer required (SSE holds one connection per client)
|
|
103
|
+
- Streams JSON frames: `event: <section>` / `data: <json>`
|
|
104
|
+
- Keepalive comment-line (`: keepalive`) sent every ~15s to prevent timeout
|
|
105
|
+
- Read-only stream; no mutations
|
|
106
|
+
|
|
107
|
+
**Sections emitted** (only on content change, in order):
|
|
108
|
+
1. **data** — heartbeat status, daemon state, log tail
|
|
109
|
+
2. **backlog** — AUDIT-BACKLOG.md parsed into tier buckets (P0/P1/P2/Needs decision)
|
|
110
|
+
3. **agents** — fleet agent activity (from Claude transcripts directory)
|
|
111
|
+
4. **tracker** — tracker items (CRUD mutations reflected in realtime)
|
|
112
|
+
5. **status** — orchestrator phase + activity
|
|
113
|
+
6. **cost** — cost/scorecard summary (wave-14 addition)
|
|
114
|
+
|
|
115
|
+
## Background Collector Thread
|
|
116
|
+
|
|
117
|
+
Daemon thread polling heartbeats/logs via mtime/fingerprint gates; re-derives SSE sections only on input change, broadcasts only when content-hash differs (avoids expensive operations on every tick). Started idempotently on first HTTP request via `start_collector_thread()`, runs with single-instance guard `_collector_lock`, wakes every `COLLECTOR_INTERVAL` (default 1.0s, `AESOP_UI_COLLECT_INTERVAL`).
|
|
118
|
+
|
|
119
|
+
## Invariants & Gotchas
|
|
120
|
+
|
|
121
|
+
- **Stdlib only**: No external dependencies (requests, flask, etc.). Uses only `http.server`, `json`, `subprocess`, `threading`.
|
|
122
|
+
- **ThreadingHTTPServer required**: SSE model requires one thread per client connection. Standard HTTPServer (processes) cannot hold SSE connections open.
|
|
123
|
+
- **Collector fail-open**: If collector thread crashes, server continues serving; realtime updates stop but dashboard remains accessible.
|
|
124
|
+
- **Token file permissions**: On Unix-like systems, token file is chmod 0600 (user-only). Windows ignores mode bits but respects file permissions via ACLs.
|
|
125
|
+
- **Paths git-ignored**: `state/.ui-session-token` is ephemeral (regenerated if missing), never committed.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ui/agents.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Aesop UI — agent transcript reading + path-traversal-safe id handling (wave-9 split)."""
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_fleet_agents():
|
|
13
|
+
"""Detect running subagents by calling dash-extra.mjs --json.
|
|
14
|
+
|
|
15
|
+
dash-extra.mjs truncates agent ids to 13 characters for display. With enough
|
|
16
|
+
concurrently-active agents, two distinct agents can share the same 13-char
|
|
17
|
+
prefix and collide onto the same id. The dashboard keys DOM rows (and the
|
|
18
|
+
click-to-expand lookup) by this id, so a collision silently merges two
|
|
19
|
+
different agents into one row and can show mismatched detail on click. Since
|
|
20
|
+
dash-extra.mjs is out of scope here, disambiguate post-hoc: keep the original
|
|
21
|
+
(display-friendly) id as a prefix, but suffix it to guarantee uniqueness.
|
|
22
|
+
"""
|
|
23
|
+
agents = []
|
|
24
|
+
try:
|
|
25
|
+
# Call the working detector (dash-extra.mjs) with --json flag
|
|
26
|
+
dash_extra_path = config.AESOP_ROOT / "dash" / "dash-extra.mjs"
|
|
27
|
+
if not dash_extra_path.exists():
|
|
28
|
+
return agents
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["node", str(dash_extra_path), "--json"],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=5
|
|
34
|
+
)
|
|
35
|
+
if result.returncode == 0 and result.stdout:
|
|
36
|
+
agents = json.loads(result.stdout.strip())
|
|
37
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
38
|
+
pass
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
seen = {}
|
|
43
|
+
for a in agents:
|
|
44
|
+
if not isinstance(a, dict):
|
|
45
|
+
continue
|
|
46
|
+
aid = a.get("id", "")
|
|
47
|
+
if aid in seen:
|
|
48
|
+
seen[aid] += 1
|
|
49
|
+
a["id"] = f"{aid}-{seen[aid]}"
|
|
50
|
+
else:
|
|
51
|
+
seen[aid] = 1
|
|
52
|
+
return agents
|
|
53
|
+
|
|
54
|
+
_AGENT_ID_FORBIDDEN = re.compile(r'\.\.|[/\\*?\[\]]')
|
|
55
|
+
|
|
56
|
+
def extract_agent_dispatch_prompt(agent_id):
|
|
57
|
+
"""
|
|
58
|
+
Extract dispatch prompt and metadata from agent jsonl transcript.
|
|
59
|
+
Returns dict with prompt, dispatcher, model, activity times, and message count.
|
|
60
|
+
Robust: missing/invalid file -> {error: "..."}
|
|
61
|
+
Security: rejects agent_id containing path-traversal or glob-metacharacter
|
|
62
|
+
sequences before building any glob pattern, and refuses to return a match
|
|
63
|
+
that resolves outside config.TRANSCRIPTS_ROOT (defense in depth). Error results
|
|
64
|
+
carry "invalid": True when the input itself was rejected, so callers can
|
|
65
|
+
map that to an HTTP 400 rather than a plain 404.
|
|
66
|
+
|
|
67
|
+
CRITICAL: Use prefix-matching via glob, not exact match. The dashboard (dash-extra.mjs)
|
|
68
|
+
scans for agent-*.jsonl files and emits a 13-char truncated ID; files on disk carry
|
|
69
|
+
full IDs (e.g., agent-a77b995bcdb953e9c1234567.jsonl). This function must search for
|
|
70
|
+
the same file format that the dashboard actually finds.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
if not agent_id or _AGENT_ID_FORBIDDEN.search(agent_id):
|
|
74
|
+
return {"error": "invalid agent id", "invalid": True}
|
|
75
|
+
|
|
76
|
+
# Prefix-match: search in config.TRANSCRIPTS_ROOT for files matching agent-agent_id*.jsonl
|
|
77
|
+
# The dashboard (dash-extra.mjs) scans for agent-*.jsonl files and emits
|
|
78
|
+
# a 13-char truncated ID; we must match against the same .jsonl files.
|
|
79
|
+
if not config.TRANSCRIPTS_ROOT.exists():
|
|
80
|
+
return {"error": f"transcripts root not found at {config.TRANSCRIPTS_ROOT}"}
|
|
81
|
+
|
|
82
|
+
# Glob for matching files (prefix-match handles truncated IDs)
|
|
83
|
+
matches = sorted(
|
|
84
|
+
config.TRANSCRIPTS_ROOT.glob(f"**/agent-{agent_id}*.jsonl"),
|
|
85
|
+
key=lambda p: p.stat().st_mtime, reverse=True,
|
|
86
|
+
)
|
|
87
|
+
if not matches:
|
|
88
|
+
return {"error": f"transcript not found for {agent_id}"}
|
|
89
|
+
output_file = matches[0]
|
|
90
|
+
|
|
91
|
+
# Containment check: the resolved match must stay inside config.TRANSCRIPTS_ROOT.
|
|
92
|
+
# Belt-and-suspenders alongside the input rejection above.
|
|
93
|
+
try:
|
|
94
|
+
is_contained = output_file.resolve().is_relative_to(config.TRANSCRIPTS_ROOT.resolve())
|
|
95
|
+
except AttributeError:
|
|
96
|
+
# Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
|
|
97
|
+
try:
|
|
98
|
+
output_file.resolve().relative_to(config.TRANSCRIPTS_ROOT.resolve())
|
|
99
|
+
is_contained = True
|
|
100
|
+
except ValueError:
|
|
101
|
+
is_contained = False
|
|
102
|
+
if not is_contained:
|
|
103
|
+
return {"error": "resolved path outside transcripts root", "invalid": True}
|
|
104
|
+
|
|
105
|
+
dispatch_prompt = None
|
|
106
|
+
message_count = 0
|
|
107
|
+
model = None
|
|
108
|
+
parent_uuid = None
|
|
109
|
+
first_seen = None
|
|
110
|
+
last_activity = None
|
|
111
|
+
|
|
112
|
+
# Parse NDJSON (one JSON per line)
|
|
113
|
+
with open(output_file, 'r', encoding='utf-8', errors='ignore') as f:
|
|
114
|
+
lines = f.readlines()
|
|
115
|
+
message_count = len(lines)
|
|
116
|
+
|
|
117
|
+
# Get file mtime for activity time
|
|
118
|
+
stat = output_file.stat()
|
|
119
|
+
first_seen = int(stat.st_mtime)
|
|
120
|
+
last_activity = int(stat.st_mtime)
|
|
121
|
+
|
|
122
|
+
# First line should be type="user" with the dispatch prompt
|
|
123
|
+
if lines:
|
|
124
|
+
try:
|
|
125
|
+
first_line = json.loads(lines[0])
|
|
126
|
+
if first_line.get('type') == 'user':
|
|
127
|
+
msg = first_line.get('message', {})
|
|
128
|
+
dispatch_prompt = msg.get('content', '')
|
|
129
|
+
parent_uuid = first_line.get('parentUuid')
|
|
130
|
+
except (json.JSONDecodeError, KeyError):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
# Scan for model info in assistant messages
|
|
134
|
+
for line in lines[1:20]: # Check first ~20 lines
|
|
135
|
+
try:
|
|
136
|
+
obj = json.loads(line)
|
|
137
|
+
if obj.get('type') == 'assistant' and not model:
|
|
138
|
+
if 'model' in obj:
|
|
139
|
+
model = obj.get('model')
|
|
140
|
+
except (json.JSONDecodeError, KeyError):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
if not dispatch_prompt:
|
|
144
|
+
return {"error": f"no dispatch prompt found"}
|
|
145
|
+
|
|
146
|
+
# Infer dispatcher: if parentUuid is null, it's main thread; otherwise parent agent
|
|
147
|
+
dispatcher = "main thread" if parent_uuid is None else "parent agent"
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"id": agent_id,
|
|
151
|
+
"dispatch_prompt": dispatch_prompt,
|
|
152
|
+
"dispatcher": dispatcher,
|
|
153
|
+
"model": model or "unknown",
|
|
154
|
+
"message_count": message_count,
|
|
155
|
+
"first_seen": first_seen,
|
|
156
|
+
"last_activity": last_activity,
|
|
157
|
+
}
|
|
158
|
+
except Exception as e:
|
|
159
|
+
return {"error": str(e)}
|
|
160
|
+
|
|
161
|
+
def _transcripts_fingerprint():
|
|
162
|
+
"""Cheap fs-stat-only fingerprint of the transcripts tree.
|
|
163
|
+
|
|
164
|
+
Used to decide whether it's worth re-invoking `node dash-extra.mjs` (which is
|
|
165
|
+
comparatively expensive: process spawn + re-parsing every agent transcript).
|
|
166
|
+
Only file count + max mtime — no file content is read.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
if not config.TRANSCRIPTS_ROOT.exists():
|
|
170
|
+
return (0, 0.0)
|
|
171
|
+
count = 0
|
|
172
|
+
latest = 0.0
|
|
173
|
+
for p in config.TRANSCRIPTS_ROOT.glob("**/agent-*.jsonl"):
|
|
174
|
+
try:
|
|
175
|
+
mtime = p.stat().st_mtime
|
|
176
|
+
except OSError:
|
|
177
|
+
continue
|
|
178
|
+
count += 1
|
|
179
|
+
if mtime > latest:
|
|
180
|
+
latest = mtime
|
|
181
|
+
return (count, latest)
|
|
182
|
+
except Exception:
|
|
183
|
+
return (0, 0.0)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aesop UI API — shared mutation-request validation (wave-10 P0 split from handler.py).
|
|
4
|
+
|
|
5
|
+
Every mutating endpoint (/submit, POST /api/tracker, POST /api/tracker/<id>)
|
|
6
|
+
repeats the same two gates before touching domain logic: CSRF validation and
|
|
7
|
+
a Content-Length bound check, followed by a JSON body parse. This module
|
|
8
|
+
centralizes those two gates + the parse so they are directly unit-testable
|
|
9
|
+
without an HTTP server (see tests/test_api_tracker.py).
|
|
10
|
+
|
|
11
|
+
SAFETY NOTE (load-bearing): callers MUST check Content-Length is within
|
|
12
|
+
[1, MAX_BODY_BYTES] *before* calling self.rfile.read() on the live socket --
|
|
13
|
+
reading an attacker-declared length without a cap first is exactly the DoS
|
|
14
|
+
hole this gate exists to close. validate_mutation() re-derives the same
|
|
15
|
+
bound from headers (cheap, no I/O) so its error response matches what the
|
|
16
|
+
caller already decided; callers should gate their own read using
|
|
17
|
+
MAX_BODY_BYTES from this module so the two stay in lockstep. This module
|
|
18
|
+
never touches the socket itself -- it only ever receives bytes the caller
|
|
19
|
+
already obtained.
|
|
20
|
+
|
|
21
|
+
Sibling modules (ui/api/tracker.py, ui/api/submit.py) that read config paths
|
|
22
|
+
must do so live via `import config` at call time, never `from config import
|
|
23
|
+
<path>` -- a frozen import goes stale after config.reload() (breaks
|
|
24
|
+
test-fixture isolation). See ui/CLAUDE.md.
|
|
25
|
+
"""
|
|
26
|
+
import json
|
|
27
|
+
|
|
28
|
+
from csrf import validate_csrf_request
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotFoundError(Exception):
|
|
32
|
+
"""Raised when a tracker item is not found."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Mutating endpoints cap request bodies at 10KB. Kept as a shared constant so
|
|
37
|
+
# handler.py's pre-read bound check and this module's own re-check never drift.
|
|
38
|
+
MAX_BODY_BYTES = 10000
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_mutation(headers, body_bytes, content_length_error="Invalid Content-Length"):
|
|
42
|
+
"""Run the CSRF + Content-Length gates shared by every mutating endpoint,
|
|
43
|
+
then JSON-decode the body.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
headers: dict-like HTTP headers (case-insensitive), as accepted by
|
|
47
|
+
csrf.validate_csrf_request.
|
|
48
|
+
body_bytes: raw request body bytes already read by the caller. The
|
|
49
|
+
caller must have bounded how many bytes it read using the same
|
|
50
|
+
Content-Length header (see SAFETY NOTE above) -- this function
|
|
51
|
+
performs no socket I/O itself.
|
|
52
|
+
content_length_error: exact error message to use on an out-of-range
|
|
53
|
+
Content-Length, so callers can preserve endpoint-specific wording
|
|
54
|
+
(e.g. /submit's "Invalid Content-Length (must be 1-10000 bytes)"
|
|
55
|
+
vs /api/tracker's "Invalid Content-Length").
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
(True, parsed) where parsed is the decoded JSON value (typically a
|
|
59
|
+
dict) on success.
|
|
60
|
+
(False, (status_code, error_dict)) on a CSRF or Content-Length gate
|
|
61
|
+
failure -- callers should write this directly as the JSON error
|
|
62
|
+
response.
|
|
63
|
+
|
|
64
|
+
Note: a malformed JSON body raises json.JSONDecodeError rather than being
|
|
65
|
+
caught here, so callers can keep their historical per-endpoint handling
|
|
66
|
+
(some endpoints report 400 "Invalid JSON", others let the parse failure
|
|
67
|
+
fall through to a generic 500) -- see ui/api/tracker.py and
|
|
68
|
+
ui/api/submit.py callers in ui/handler.py.
|
|
69
|
+
"""
|
|
70
|
+
is_valid, reason = validate_csrf_request(headers)
|
|
71
|
+
if not is_valid:
|
|
72
|
+
return False, (403, {"error": "CSRF protection: " + reason})
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
content_length = int(headers.get('Content-Length', 0) or 0)
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
content_length = 0
|
|
78
|
+
|
|
79
|
+
if content_length <= 0 or content_length > MAX_BODY_BYTES:
|
|
80
|
+
return False, (400, {"error": content_length_error})
|
|
81
|
+
|
|
82
|
+
body_str = (body_bytes or b'').decode('utf-8', errors='ignore')
|
|
83
|
+
parsed = json.loads(body_str)
|
|
84
|
+
return True, parsed
|
|
Binary file
|
|
Binary file
|
|
Binary file
|