@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. 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.
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
@@ -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