@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.4
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 +37 -94
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +89 -264
- package/aesop.config.example.json +27 -26
- package/bin/CLAUDE.md +39 -0
- package/bin/cli.js +368 -24
- package/daemons/CLAUDE.md +33 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +90 -10
- 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/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/spikes/tiered-cognition/README.md +29 -0
- package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/hooks/CLAUDE.md +38 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +29 -8
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +529 -62
- package/package.json +12 -3
- package/skills/CLAUDE.md +3 -0
- package/skills/power/SKILL.md +161 -0
- package/tools/CLAUDE.md +191 -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__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +204 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -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 +131 -31
- package/tools/stall_check.py +172 -0
- package/tools/verify_dash.py +694 -0
- package/tools/verify_submit_encoding.py +194 -0
- package/ui/CLAUDE.md +109 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +179 -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 +58 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +586 -0
- package/ui/config.py +114 -0
- package/ui/csrf.py +140 -0
- package/ui/handler.py +426 -0
- package/ui/render.py +29 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +168 -0
- package/ui/templates/dashboard.html +1202 -0
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
(root / "AUDIT-BACKLOG.md").write_text(
|
|
61
|
+
"# Audit backlog — verify_submit_encoding fixture\n\n## Landing log\n- fixture\n",
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
# Minimal fake detector so the collector thread has nothing real to shell out to.
|
|
65
|
+
(root / "dash" / "dash-extra.mjs").write_text(
|
|
66
|
+
"console.log(JSON.stringify([]));\n", encoding="utf-8"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main():
|
|
71
|
+
parser = argparse.ArgumentParser(
|
|
72
|
+
description="Browser-level proof for /submit UTF-8 encoding"
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--allow-skip",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Allow skipping if playwright/chromium is unavailable (exit 0 instead of 1)"
|
|
78
|
+
)
|
|
79
|
+
args = parser.parse_args()
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
from playwright.sync_api import sync_playwright
|
|
83
|
+
except ImportError:
|
|
84
|
+
msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
|
|
85
|
+
if args.allow_skip:
|
|
86
|
+
print(f"SKIP: {msg}")
|
|
87
|
+
return 0
|
|
88
|
+
else:
|
|
89
|
+
print(f"FAIL: {msg}")
|
|
90
|
+
return 1
|
|
91
|
+
|
|
92
|
+
root = Path(tempfile.mkdtemp(prefix="aesop-verify-submit-"))
|
|
93
|
+
state_root = root / "state"
|
|
94
|
+
|
|
95
|
+
# HARD GUARD: refuse to run if state_root looks like the real repo state dir
|
|
96
|
+
# (e.g., ~/aesop/state or an absolute path ending with /aesop/state)
|
|
97
|
+
real_state = Path.home() / "aesop" / "state"
|
|
98
|
+
if state_root.resolve() == real_state.resolve():
|
|
99
|
+
print("FAIL: state dir resolved to real repo state (~aesop/state), refusing to run")
|
|
100
|
+
return 1
|
|
101
|
+
|
|
102
|
+
inbox_file = state_root / "ui-inbox.md"
|
|
103
|
+
port = free_port()
|
|
104
|
+
env = dict(
|
|
105
|
+
os.environ,
|
|
106
|
+
AESOP_ROOT=str(root),
|
|
107
|
+
AESOP_STATE_ROOT=str(state_root),
|
|
108
|
+
AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
|
|
109
|
+
AESOP_UI_COLLECT_INTERVAL="0.3",
|
|
110
|
+
PORT=str(port),
|
|
111
|
+
)
|
|
112
|
+
build_fixture(root)
|
|
113
|
+
assert not inbox_file.exists(), "fixture must start WITHOUT ui-inbox.md"
|
|
114
|
+
|
|
115
|
+
server = subprocess.Popen(
|
|
116
|
+
[sys.executable, str(SERVE)], env=env,
|
|
117
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
118
|
+
)
|
|
119
|
+
failures = []
|
|
120
|
+
try:
|
|
121
|
+
for _ in range(50):
|
|
122
|
+
try:
|
|
123
|
+
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
|
124
|
+
break
|
|
125
|
+
except OSError:
|
|
126
|
+
time.sleep(0.2)
|
|
127
|
+
else:
|
|
128
|
+
print("FAIL: server never came up")
|
|
129
|
+
return 1
|
|
130
|
+
|
|
131
|
+
with sync_playwright() as pw:
|
|
132
|
+
try:
|
|
133
|
+
browser = pw.chromium.launch(headless=True)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
|
|
136
|
+
if args.allow_skip:
|
|
137
|
+
print(f"SKIP: {msg}")
|
|
138
|
+
return 0
|
|
139
|
+
else:
|
|
140
|
+
print(f"FAIL: {msg}")
|
|
141
|
+
return 1
|
|
142
|
+
page = browser.new_page()
|
|
143
|
+
page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
|
|
144
|
+
|
|
145
|
+
# Drive the real /submit flow: #inbox-input -> #inbox-button, real
|
|
146
|
+
# CSRF token (window.__AESOP_CSRF_TOKEN__ injected server-side).
|
|
147
|
+
page.wait_for_selector("#inbox-input", timeout=8000)
|
|
148
|
+
page.fill("#inbox-input", MARKER)
|
|
149
|
+
page.click("#inbox-button")
|
|
150
|
+
|
|
151
|
+
# Wait for the inbox file to land on disk (async POST).
|
|
152
|
+
for _ in range(50):
|
|
153
|
+
if inbox_file.exists():
|
|
154
|
+
break
|
|
155
|
+
time.sleep(0.2)
|
|
156
|
+
else:
|
|
157
|
+
failures.append("state/ui-inbox.md was never created by /submit")
|
|
158
|
+
|
|
159
|
+
browser.close()
|
|
160
|
+
finally:
|
|
161
|
+
server.terminate()
|
|
162
|
+
try:
|
|
163
|
+
server.wait(timeout=5)
|
|
164
|
+
except subprocess.TimeoutExpired:
|
|
165
|
+
server.kill()
|
|
166
|
+
|
|
167
|
+
if not failures:
|
|
168
|
+
raw = inbox_file.read_bytes()
|
|
169
|
+
try:
|
|
170
|
+
text = inbox_file.read_text(encoding="utf-8")
|
|
171
|
+
except UnicodeDecodeError as e:
|
|
172
|
+
bad_byte = raw[e.start:e.end].hex()
|
|
173
|
+
failures.append(
|
|
174
|
+
f"(a) state/ui-inbox.md is not valid UTF-8: {e} "
|
|
175
|
+
f"(offending byte(s): 0x{bad_byte}) — raw bytes: {raw!r}"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
if MARKER not in text:
|
|
179
|
+
failures.append(f"(b) submitted marker text not found in inbox file: {text!r}")
|
|
180
|
+
|
|
181
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
182
|
+
|
|
183
|
+
if failures:
|
|
184
|
+
print("FAIL:")
|
|
185
|
+
for f in failures:
|
|
186
|
+
print(" -", f)
|
|
187
|
+
return 1
|
|
188
|
+
print("PROVEN: (a) state/ui-inbox.md decodes cleanly as UTF-8 "
|
|
189
|
+
"(b) submitted marker text present")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
sys.exit(main())
|
package/ui/CLAUDE.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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-9 split: serve.py monolith decomposed into focused modules)
|
|
6
|
+
|
|
7
|
+
- **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`.
|
|
8
|
+
- **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).**
|
|
9
|
+
- **csrf.py** — Session-token generation (atomic O_EXCL 0600) + `validate_csrf_request()`; `init()` sets `SESSION_TOKEN`.
|
|
10
|
+
- **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).
|
|
11
|
+
- **agents.py** — Agent transcript reading (`get_fleet_agents`, `extract_agent_dispatch_prompt`) and path-traversal-safe agent-id handling (`_AGENT_ID_FORBIDDEN`).
|
|
12
|
+
- **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).
|
|
13
|
+
- **render.py** — Renders `templates/dashboard.html`, substituting the CSRF token via a unique sentinel (no `.format`/% — the CSS/JS is full of `{}`/`%`).
|
|
14
|
+
- **handler.py** — `DashboardHandler` (HTTP routing + all GET/POST endpoints incl. /api/tracker) and `run_server()`. Reads `config.X` / `csrf.SESSION_TOKEN` at call time.
|
|
15
|
+
- **templates/dashboard.html** — The dashboard HTML/CSS/JS (extracted from the serve.py string), incl. tracker panel + orchestrator-status panel + audit-cycle ASCII banner.
|
|
16
|
+
- **README.md** — User guide and configuration reference.
|
|
17
|
+
|
|
18
|
+
## API Package (ui/api/)
|
|
19
|
+
|
|
20
|
+
Shared mutation-request validation and domain-logic handlers (wave-10 split from handler.py). Every mutating endpoint (`/submit`, `POST /api/tracker`, `POST /api/tracker/<id>`) gates requests identically: CSRF token validation, Content-Length bounds-check, then JSON decode. This module centralizes those gates so they are directly unit-testable without HTTP coupling.
|
|
21
|
+
|
|
22
|
+
- **__init__.py** — Shared mutation gate (`validate_mutation()`). Performs CSRF validation + Content-Length bounds-check (10 KB cap) + JSON decode. Returns `(True, parsed_json)` on success or `(False, (status_code, error_dict))` on gate failure. **Load-bearing rule**: callers must bound the socket read using the same Content-Length header before invoking, to prevent DoS; MAX_BODY_BYTES is re-derived here for validation but socket read is caller's responsibility.
|
|
23
|
+
- **submit.py** — Inbox-append logic (`append_to_inbox(text)`). Appends one timestamped line to `config.INBOX_FILE`, rejects symlinks (TOCTOU defense), creates file with header if missing.
|
|
24
|
+
- **tracker.py** — Tracker CRUD handlers (`list_items()`, `create()`, `update()`, `delete()`). Calls `collectors` tracker functions; returns `(status_code, response_dict)` for direct HTTP response writing. **Load-bearing**: all tracker mutations pass through `validate_mutation()` in create; update/delete check CSRF via `csrf.validate_csrf_request()` performed by caller before path is parsed.
|
|
25
|
+
|
|
26
|
+
**Key invariant**: All three modules read `config.X` live via `import config` at call time, never `from config import X`. A frozen import goes stale after `config.reload()` (breaks test-fixture isolation). See ui/CLAUDE.md "load-bearing rule" note above.
|
|
27
|
+
|
|
28
|
+
## Configuration & Path Precedence
|
|
29
|
+
|
|
30
|
+
Configuration is resolved in this order (first match wins):
|
|
31
|
+
|
|
32
|
+
1. **Environment variables** (highest priority):
|
|
33
|
+
- `PORT` — HTTP server port (default: 8770)
|
|
34
|
+
- `AESOP_ROOT` — aesop installation root (default: `$HOME/aesop`)
|
|
35
|
+
- `AESOP_STATE_ROOT` — state directory (overrides config state_root)
|
|
36
|
+
- `AESOP_TRANSCRIPTS_ROOT` — Claude transcript directory (overrides config transcripts_root)
|
|
37
|
+
- `AESOP_UI_COLLECT_INTERVAL` — collector thread poll cadence in seconds (default: 1.0)
|
|
38
|
+
|
|
39
|
+
2. **Config file** (`aesop.config.json`):
|
|
40
|
+
- `state_root` — path to state/ directory
|
|
41
|
+
- `transcripts_root` — path to Claude transcript directory
|
|
42
|
+
|
|
43
|
+
3. **Built-in defaults** (lowest priority):
|
|
44
|
+
- `AESOP_ROOT/state` for state directory
|
|
45
|
+
- `~/.claude/projects` for transcripts
|
|
46
|
+
|
|
47
|
+
## CSRF & Session Protection
|
|
48
|
+
|
|
49
|
+
**Token model**:
|
|
50
|
+
- Per-session CSRF token generated at startup, persisted to `state/.ui-session-token` (mode 0600, readable only by owner)
|
|
51
|
+
- Token is 43-character URL-safe base64 string (256 bits)
|
|
52
|
+
- Persistent across server restarts (read from file if exists, generate fresh if not)
|
|
53
|
+
- Used to validate mutations via /submit endpoint
|
|
54
|
+
|
|
55
|
+
**Validation on /submit POST**:
|
|
56
|
+
1. **Origin/Referer check**: if present, must be local (http://127.0.0.1:<port>, http://localhost:<port>, or http://[::1]:<port>)
|
|
57
|
+
2. **X-Aesop-Token header check**: must match SESSION_TOKEN exactly
|
|
58
|
+
- Both checks fail-closed (missing header = rejection)
|
|
59
|
+
- GET /events (SSE, read-only) requires no token
|
|
60
|
+
|
|
61
|
+
**Local CLI access**:
|
|
62
|
+
- CLI tools read token from `state/.ui-session-token` (0600) to submit to /submit endpoint
|
|
63
|
+
- Legitimate browser clients: token injected into HTML template, sent by JavaScript
|
|
64
|
+
|
|
65
|
+
## Server-Sent Events (SSE) Model
|
|
66
|
+
|
|
67
|
+
**Realtime streaming via GET /events**:
|
|
68
|
+
- ThreadingHTTPServer required (SSE holds one connection per client)
|
|
69
|
+
- Streams JSON frames: `event: data|backlog|agents` / `data: <json>`
|
|
70
|
+
- Keepalive comment-line (`: keepalive`) sent every ~15s to prevent timeout
|
|
71
|
+
- Read-only stream; no mutations
|
|
72
|
+
|
|
73
|
+
**Sections emitted** (only on content change):
|
|
74
|
+
1. **data** — heartbeat status, daemon state, log tail
|
|
75
|
+
2. **backlog** — AUDIT-BACKLOG.md parsed into tier buckets (P0/P1/P2/Needs decision)
|
|
76
|
+
3. **agents** — fleet agent activity (from Claude transcripts directory)
|
|
77
|
+
|
|
78
|
+
## Background Collector Thread
|
|
79
|
+
|
|
80
|
+
**Lifecycle**:
|
|
81
|
+
- Started idempotently on first HTTP request (via `start_collector_thread()`)
|
|
82
|
+
- Runs as daemon thread (terminates when main process exits)
|
|
83
|
+
- Single-instance guard via `_collector_lock` (prevents double-start)
|
|
84
|
+
|
|
85
|
+
**Polling strategy**:
|
|
86
|
+
- Wakes every `COLLECTOR_INTERVAL` seconds (default: 1.0, overridable via `AESOP_UI_COLLECT_INTERVAL`)
|
|
87
|
+
- Polls cheap sources: heartbeat file mtimes, log tails, file fingerprints
|
|
88
|
+
- Only re-derives sections when underlying input changed (mtime/fingerprint gate)
|
|
89
|
+
- Only emits to clients when section content-hash changed (avoids redundant broadcasts)
|
|
90
|
+
|
|
91
|
+
**Cached data**:
|
|
92
|
+
- Last computed hash per section (data/backlog/agents)
|
|
93
|
+
- Last mtime of AUDIT-BACKLOG.md
|
|
94
|
+
- Last fingerprint of transcripts directory (to detect agent activity changes)
|
|
95
|
+
- Cached parsed backlog and agent snapshot (reused until inputs change)
|
|
96
|
+
|
|
97
|
+
**Content-change detection**:
|
|
98
|
+
- Mtime-gated: backlog section re-derived only if AUDIT-BACKLOG.md mtime changed
|
|
99
|
+
- Fingerprint-gated: agents section re-derived only if transcripts directory content changed
|
|
100
|
+
- Hash-gated: broadcast only sent if content-hash differs from last broadcast
|
|
101
|
+
- This avoids expensive operations (subprocess for dash-extra.mjs, directory walk) on every tick
|
|
102
|
+
|
|
103
|
+
## Invariants & Gotchas
|
|
104
|
+
|
|
105
|
+
- **Stdlib only**: No external dependencies (requests, flask, etc.). Uses only `http.server`, `json`, `subprocess`, `threading`.
|
|
106
|
+
- **ThreadingHTTPServer required**: SSE model requires one thread per client connection. Standard HTTPServer (processes) cannot hold SSE connections open.
|
|
107
|
+
- **Collector fail-open**: If collector thread crashes, server continues serving; realtime updates stop but dashboard remains accessible.
|
|
108
|
+
- **Token file permissions**: On Unix-like systems, token file is chmod 0600 (user-only). Windows ignores mode bits but respects file permissions via ACLs.
|
|
109
|
+
- **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
|
package/ui/agents.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
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 output file.
|
|
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 supplies
|
|
68
|
+
truncated agent IDs; files on disk carry full IDs (e.g., a77b995bcdb953e9c.output).
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
if not agent_id or _AGENT_ID_FORBIDDEN.search(agent_id):
|
|
72
|
+
return {"error": "invalid agent id", "invalid": True}
|
|
73
|
+
|
|
74
|
+
# Prefix-match: search in config.TRANSCRIPTS_ROOT for files matching agent_id*.output
|
|
75
|
+
if not config.TRANSCRIPTS_ROOT.exists():
|
|
76
|
+
return {"error": f"transcripts root not found at {config.TRANSCRIPTS_ROOT}"}
|
|
77
|
+
|
|
78
|
+
# Glob for matching files (prefix-match handles truncated IDs)
|
|
79
|
+
matches = sorted(
|
|
80
|
+
config.TRANSCRIPTS_ROOT.glob(f"**/{agent_id}*.output"),
|
|
81
|
+
key=lambda p: p.stat().st_mtime, reverse=True,
|
|
82
|
+
)
|
|
83
|
+
if not matches:
|
|
84
|
+
return {"error": f"transcript not found for {agent_id}"}
|
|
85
|
+
output_file = matches[0]
|
|
86
|
+
|
|
87
|
+
# Containment check: the resolved match must stay inside config.TRANSCRIPTS_ROOT.
|
|
88
|
+
# Belt-and-suspenders alongside the input rejection above.
|
|
89
|
+
try:
|
|
90
|
+
is_contained = output_file.resolve().is_relative_to(config.TRANSCRIPTS_ROOT.resolve())
|
|
91
|
+
except AttributeError:
|
|
92
|
+
# Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
|
|
93
|
+
try:
|
|
94
|
+
output_file.resolve().relative_to(config.TRANSCRIPTS_ROOT.resolve())
|
|
95
|
+
is_contained = True
|
|
96
|
+
except ValueError:
|
|
97
|
+
is_contained = False
|
|
98
|
+
if not is_contained:
|
|
99
|
+
return {"error": "resolved path outside transcripts root", "invalid": True}
|
|
100
|
+
|
|
101
|
+
dispatch_prompt = None
|
|
102
|
+
message_count = 0
|
|
103
|
+
model = None
|
|
104
|
+
parent_uuid = None
|
|
105
|
+
first_seen = None
|
|
106
|
+
last_activity = None
|
|
107
|
+
|
|
108
|
+
# Parse NDJSON (one JSON per line)
|
|
109
|
+
with open(output_file, 'r', encoding='utf-8', errors='ignore') as f:
|
|
110
|
+
lines = f.readlines()
|
|
111
|
+
message_count = len(lines)
|
|
112
|
+
|
|
113
|
+
# Get file mtime for activity time
|
|
114
|
+
stat = output_file.stat()
|
|
115
|
+
first_seen = int(stat.st_mtime)
|
|
116
|
+
last_activity = int(stat.st_mtime)
|
|
117
|
+
|
|
118
|
+
# First line should be type="user" with the dispatch prompt
|
|
119
|
+
if lines:
|
|
120
|
+
try:
|
|
121
|
+
first_line = json.loads(lines[0])
|
|
122
|
+
if first_line.get('type') == 'user':
|
|
123
|
+
msg = first_line.get('message', {})
|
|
124
|
+
dispatch_prompt = msg.get('content', '')
|
|
125
|
+
parent_uuid = first_line.get('parentUuid')
|
|
126
|
+
except (json.JSONDecodeError, KeyError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# Scan for model info in assistant messages
|
|
130
|
+
for line in lines[1:20]: # Check first ~20 lines
|
|
131
|
+
try:
|
|
132
|
+
obj = json.loads(line)
|
|
133
|
+
if obj.get('type') == 'assistant' and not model:
|
|
134
|
+
if 'model' in obj:
|
|
135
|
+
model = obj.get('model')
|
|
136
|
+
except (json.JSONDecodeError, KeyError):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
if not dispatch_prompt:
|
|
140
|
+
return {"error": f"no dispatch prompt found"}
|
|
141
|
+
|
|
142
|
+
# Infer dispatcher: if parentUuid is null, it's main thread; otherwise parent agent
|
|
143
|
+
dispatcher = "main thread" if parent_uuid is None else "parent agent"
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"id": agent_id,
|
|
147
|
+
"dispatch_prompt": dispatch_prompt,
|
|
148
|
+
"dispatcher": dispatcher,
|
|
149
|
+
"model": model or "unknown",
|
|
150
|
+
"message_count": message_count,
|
|
151
|
+
"first_seen": first_seen,
|
|
152
|
+
"last_activity": last_activity,
|
|
153
|
+
}
|
|
154
|
+
except Exception as e:
|
|
155
|
+
return {"error": str(e)}
|
|
156
|
+
|
|
157
|
+
def _transcripts_fingerprint():
|
|
158
|
+
"""Cheap fs-stat-only fingerprint of the transcripts tree.
|
|
159
|
+
|
|
160
|
+
Used to decide whether it's worth re-invoking `node dash-extra.mjs` (which is
|
|
161
|
+
comparatively expensive: process spawn + re-parsing every agent transcript).
|
|
162
|
+
Only file count + max mtime — no file content is read.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
if not config.TRANSCRIPTS_ROOT.exists():
|
|
166
|
+
return (0, 0.0)
|
|
167
|
+
count = 0
|
|
168
|
+
latest = 0.0
|
|
169
|
+
for p in config.TRANSCRIPTS_ROOT.glob("**/agent-*.jsonl"):
|
|
170
|
+
try:
|
|
171
|
+
mtime = p.stat().st_mtime
|
|
172
|
+
except OSError:
|
|
173
|
+
continue
|
|
174
|
+
count += 1
|
|
175
|
+
if mtime > latest:
|
|
176
|
+
latest = mtime
|
|
177
|
+
return (count, latest)
|
|
178
|
+
except Exception:
|
|
179
|
+
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
|
package/ui/api/submit.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aesop UI API — inbox-submit logic (wave-10 P0 split from handler.py).
|
|
4
|
+
|
|
5
|
+
Free function: given already-validated, non-empty text, append it to the UI
|
|
6
|
+
inbox file. UTF-8/LF-safe (matches the encoding used to read the file back
|
|
7
|
+
elsewhere) and guarded against a symlinked inbox file (TOCTOU defense --
|
|
8
|
+
reject rather than follow a symlink another local user/process may have
|
|
9
|
+
planted at config.INBOX_FILE).
|
|
10
|
+
|
|
11
|
+
Reads config.INBOX_FILE live via `import config` at call time, never
|
|
12
|
+
`from config import INBOX_FILE` -- a frozen import goes stale after
|
|
13
|
+
config.reload() (breaks test-fixture isolation). See ui/CLAUDE.md.
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
import config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def append_to_inbox(text):
|
|
22
|
+
"""Append one inbox line for `text` to config.INBOX_FILE.
|
|
23
|
+
|
|
24
|
+
Creates the file (with a header comment) if it doesn't exist yet.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
text: non-empty, already-stripped text to record (caller is
|
|
28
|
+
responsible for validating it's non-empty -- this function does
|
|
29
|
+
not re-check).
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
(True, None) on success.
|
|
33
|
+
(False, (400, {"error": "..."})) if config.INBOX_FILE exists and is
|
|
34
|
+
a symlink (rejected for security).
|
|
35
|
+
"""
|
|
36
|
+
inbox_content = f"- [{datetime.now().isoformat()}] {text}\n"
|
|
37
|
+
|
|
38
|
+
# Security: reject symlinks (TOCTOU defense) FIRST — check islink independent
|
|
39
|
+
# of exists(). Path.exists() follows the link, so a DANGLING symlink (target
|
|
40
|
+
# not yet created) returns False and would otherwise skip this check, then
|
|
41
|
+
# open(...,'w') would follow the link and create the attacker's target.
|
|
42
|
+
if os.path.islink(str(config.INBOX_FILE)):
|
|
43
|
+
return False, (400, {"error": "Inbox file is a symlink (rejected for security)"})
|
|
44
|
+
|
|
45
|
+
if not config.INBOX_FILE.exists():
|
|
46
|
+
config.INBOX_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
# Must match the encoding (utf-8) AND newline convention (LF) of the
|
|
48
|
+
# append below -- text-mode write_text() with no encoding= falls back
|
|
49
|
+
# to the locale-preferred encoding (cp1252 on Windows), which mangles
|
|
50
|
+
# non-ASCII bytes like the em-dash and leaves the file as a whole not
|
|
51
|
+
# valid UTF-8 for anything that reads it with encoding="utf-8".
|
|
52
|
+
with open(config.INBOX_FILE, 'w', encoding='utf-8', newline='\n') as f:
|
|
53
|
+
f.write("# UI Inbox — orchestrator reads each turn / on /power\n\n")
|
|
54
|
+
|
|
55
|
+
with open(config.INBOX_FILE, 'a', encoding='utf-8') as f:
|
|
56
|
+
f.write(inbox_content)
|
|
57
|
+
|
|
58
|
+
return True, None
|