@matt82198/aesop 0.1.0-beta.2 → 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
package/ui/config.py ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aesop UI Configuration — Path and environment resolution.
4
+
5
+ This module centralizes all path configuration, environment variable resolution,
6
+ and config file loading for the UI dashboard. It provides a reload() function to
7
+ recompute all paths when environment changes (e.g., between test fixtures).
8
+
9
+ Config precedence: env vars > aesop.config.json > built-in defaults.
10
+ """
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ # ==============================================================================
18
+ # Path Resolution Functions
19
+ # ==============================================================================
20
+
21
+ def reload():
22
+ """Recompute all configuration from current environment.
23
+
24
+ Called at module load and whenever environment changes (test fixtures).
25
+ Mutates module-level globals in place so that importers see the current state.
26
+ """
27
+ global PORT, AESOP_ROOT, CONFIG_FILE, STATE_DIR, TRANSCRIPTS_ROOT
28
+ global WATCHDOG_HEARTBEAT, MONITOR_HEARTBEAT, REPOS_JSON, BACKUP_LOG
29
+ global ALERTS_LOG, INBOX_FILE, AUDIT_BACKLOG_FILE
30
+ global UI_SESSION_TOKEN_FILE, TRACKER_FILE, ORCH_STATUS_FILE
31
+ global COLLECTOR_INTERVAL, SSE_KEEPALIVE_SECONDS, SSE_MAX_CLIENTS, SSE_QUEUE_MAXSIZE, SSE_WRITE_TIMEOUT
32
+
33
+ # PORT: env PORT > default 8770
34
+ PORT = int(os.getenv("PORT", "8770"))
35
+
36
+ # Determine AESOP_ROOT: env AESOP_ROOT > default $HOME/aesop
37
+ AESOP_ROOT = Path(os.getenv("AESOP_ROOT", Path.home() / "aesop"))
38
+
39
+ # Try to load config file for additional settings
40
+ CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
41
+ config_data = {}
42
+ if CONFIG_FILE.exists():
43
+ try:
44
+ with open(CONFIG_FILE) as f:
45
+ config_data = json.load(f)
46
+ except Exception as e:
47
+ print(f"[config] Failed to load {CONFIG_FILE}: {e}", file=sys.stderr)
48
+
49
+ # Derive paths with precedence: env var > config file > built-in default
50
+ # STATE_DIR: env AESOP_STATE_ROOT > config state_root > AESOP_ROOT/state
51
+ STATE_DIR = Path(
52
+ os.getenv(
53
+ "AESOP_STATE_ROOT",
54
+ config_data.get("state_root", str(AESOP_ROOT / "state"))
55
+ )
56
+ )
57
+
58
+ # TRANSCRIPTS_ROOT: env AESOP_TRANSCRIPTS_ROOT > config transcripts_root > ~/.claude/projects
59
+ TRANSCRIPTS_ROOT = Path(
60
+ os.getenv(
61
+ "AESOP_TRANSCRIPTS_ROOT",
62
+ config_data.get("transcripts_root", "~/.claude/projects")
63
+ )
64
+ ).expanduser()
65
+
66
+ # Data file paths (all derived from STATE_DIR and AESOP_ROOT)
67
+ WATCHDOG_HEARTBEAT = STATE_DIR / ".watchdog-heartbeat"
68
+ MONITOR_HEARTBEAT = STATE_DIR / ".monitor-heartbeat"
69
+ REPOS_JSON = STATE_DIR / ".watchdog-repos.json"
70
+ BACKUP_LOG = STATE_DIR / "FLEET-BACKUP.log"
71
+ ALERTS_LOG = STATE_DIR / "SECURITY-ALERTS.log"
72
+ INBOX_FILE = STATE_DIR / "ui-inbox.md"
73
+ AUDIT_BACKLOG_FILE = AESOP_ROOT / "AUDIT-BACKLOG.md"
74
+ UI_SESSION_TOKEN_FILE = STATE_DIR / ".ui-session-token"
75
+ TRACKER_FILE = STATE_DIR / "tracker.json"
76
+ ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
77
+
78
+ # Collector and SSE configuration
79
+ COLLECTOR_INTERVAL = float(os.getenv("AESOP_UI_COLLECT_INTERVAL", "1.0"))
80
+ SSE_KEEPALIVE_SECONDS = 15
81
+ SSE_MAX_CLIENTS = 100 # Resource cap: reject new connections past this
82
+ SSE_QUEUE_MAXSIZE = 50 # Per-client bounded queue (drops oldest on overflow)
83
+ SSE_WRITE_TIMEOUT = 5.0 # Write timeout in seconds to prevent stalled clients
84
+
85
+
86
+ # ==============================================================================
87
+ # Module-level initialization
88
+ # ==============================================================================
89
+
90
+ # Initialize configuration at module load time
91
+ # These globals are recomputed by reload() and accessed by other modules
92
+ PORT = 8770
93
+ AESOP_ROOT = Path.home() / "aesop"
94
+ CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
95
+ STATE_DIR = AESOP_ROOT / "state"
96
+ TRANSCRIPTS_ROOT = Path("~/.claude/projects").expanduser()
97
+ WATCHDOG_HEARTBEAT = STATE_DIR / ".watchdog-heartbeat"
98
+ MONITOR_HEARTBEAT = STATE_DIR / ".monitor-heartbeat"
99
+ REPOS_JSON = STATE_DIR / ".watchdog-repos.json"
100
+ BACKUP_LOG = STATE_DIR / "FLEET-BACKUP.log"
101
+ ALERTS_LOG = STATE_DIR / "SECURITY-ALERTS.log"
102
+ INBOX_FILE = STATE_DIR / "ui-inbox.md"
103
+ AUDIT_BACKLOG_FILE = AESOP_ROOT / "AUDIT-BACKLOG.md"
104
+ UI_SESSION_TOKEN_FILE = STATE_DIR / ".ui-session-token"
105
+ TRACKER_FILE = STATE_DIR / "tracker.json"
106
+ ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
107
+ COLLECTOR_INTERVAL = 1.0
108
+ SSE_KEEPALIVE_SECONDS = 15
109
+ SSE_MAX_CLIENTS = 100
110
+ SSE_QUEUE_MAXSIZE = 50
111
+ SSE_WRITE_TIMEOUT = 5.0
112
+
113
+ # Perform initial load from environment
114
+ reload()
package/ui/csrf.py ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aesop UI CSRF Protection — Token generation and validation.
4
+
5
+ Session token is generated once at startup and persisted to state/.ui-session-token (0600).
6
+ Token is used to validate mutations via /submit POST endpoint.
7
+
8
+ CRITICAL: This module reads config paths LIVE at call time via 'import config',
9
+ never 'from config import <path>'. This ensures paths are recomputed when
10
+ config.reload() is called (e.g., between test fixtures).
11
+ """
12
+ import os
13
+ import secrets
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # Must import config module, never "from config import <paths>"
18
+ import config
19
+
20
+
21
+ # Module-level session token (set by init())
22
+ SESSION_TOKEN = None
23
+
24
+
25
+ def init():
26
+ """Initialize CSRF protection: generate or load the per-session token.
27
+
28
+ This should be called once at startup (after config.reload()).
29
+ Token is persisted to state/.ui-session-token (mode 0600) and reused
30
+ across server restarts.
31
+
32
+ SECURITY: File is created atomically with restricted permissions using
33
+ os.open(O_CREAT|O_EXCL) to avoid TOCTOU window where file exists with
34
+ world-readable permissions.
35
+ """
36
+ global SESSION_TOKEN
37
+ SESSION_TOKEN = _generate_session_token()
38
+
39
+
40
+ def _generate_session_token():
41
+ """Generate or load the per-session CSRF token.
42
+
43
+ Token is generated once at startup and persisted to state/.ui-session-token (mode 0600).
44
+ Subsequent calls return the same token (in-memory).
45
+
46
+ SECURITY: File is created atomically with restricted permissions using os.open(O_CREAT|O_EXCL)
47
+ to avoid TOCTOU window where file exists with world-readable permissions.
48
+
49
+ Returns:
50
+ str: 43-character base64-like random token (256 bits / 3 bytes per char = ~43 chars)
51
+ """
52
+ # LIVE CONFIG READ: access config.UI_SESSION_TOKEN_FILE at call time
53
+ token_file = config.UI_SESSION_TOKEN_FILE
54
+
55
+ # Check if token file exists and is readable
56
+ if token_file.exists():
57
+ try:
58
+ token = token_file.read_text().strip()
59
+ if token and len(token) >= 32:
60
+ return token
61
+ except Exception as e:
62
+ print(f"[csrf] Failed to read token file: {e}", file=sys.stderr)
63
+
64
+ # Generate new token: 32 random bytes → 43-char base64-like string
65
+ token = secrets.token_urlsafe(32)
66
+
67
+ # Persist to file with restricted permissions (0600) using atomic creation
68
+ try:
69
+ # LIVE CONFIG READ: access config.STATE_DIR at call time
70
+ config.STATE_DIR.mkdir(parents=True, exist_ok=True)
71
+
72
+ # Atomically create file with 0600 permissions using os.open with O_CREAT|O_EXCL.
73
+ # This ensures the file is never world-readable (no TOCTOU window).
74
+ # On Windows, mode bits are largely ignored, which is fine.
75
+ try:
76
+ fd = os.open(
77
+ str(token_file),
78
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
79
+ 0o600
80
+ )
81
+ # Write token via the file descriptor (no separate chmod needed)
82
+ with os.fdopen(fd, 'w') as f:
83
+ f.write(token)
84
+ except FileExistsError:
85
+ # File already exists (race condition or previous run).
86
+ # Try to read it and use that token instead.
87
+ try:
88
+ token = token_file.read_text().strip()
89
+ if token and len(token) >= 32:
90
+ return token
91
+ except Exception as e:
92
+ print(f"[csrf] Failed to read existing token file: {e}", file=sys.stderr)
93
+ # If we can't read the existing file, fall back to in-memory token
94
+ except Exception:
95
+ pass # Fail-open: token exists in memory even if file write fails
96
+
97
+ return token
98
+
99
+
100
+ def validate_csrf_request(headers):
101
+ """Validate CSRF protections on /submit POST request.
102
+
103
+ Performs two checks:
104
+ 1. Origin/Referer validation: if Origin or Referer header is present, must be local
105
+ (http://127.0.0.1:<port>, http://localhost:<port>)
106
+ 2. X-Aesop-Token validation: must match SESSION_TOKEN
107
+
108
+ Args:
109
+ headers: dict-like object with HTTP headers (case-insensitive)
110
+
111
+ Returns:
112
+ tuple: (is_valid: bool, reason: str or None)
113
+ - (True, None) if CSRF checks pass
114
+ - (False, reason) if either check fails
115
+ """
116
+ # Check 1: Origin/Referer header validation
117
+ origin = headers.get("Origin", "").strip()
118
+ referer = headers.get("Referer", "").strip()
119
+
120
+ # If Origin or Referer is present, validate it's local
121
+ if origin or referer:
122
+ check_value = origin or referer
123
+ # Check if it's a local origin: http://127.0.0.1:<PORT> or http://localhost:<PORT>
124
+ is_local = (
125
+ check_value.startswith("http://127.0.0.1:") or
126
+ check_value.startswith("http://localhost:") or
127
+ check_value.startswith("http://[::1]:") # IPv6 localhost
128
+ )
129
+ if not is_local:
130
+ return (False, "Foreign Origin/Referer rejected")
131
+
132
+ # Check 2: X-Aesop-Token validation
133
+ token = headers.get("X-Aesop-Token", "").strip()
134
+ if not token:
135
+ return (False, "Missing X-Aesop-Token header")
136
+
137
+ if token != SESSION_TOKEN:
138
+ return (False, "Invalid X-Aesop-Token")
139
+
140
+ return (True, None)
package/ui/handler.py ADDED
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env python3
2
+ """Aesop UI — HTTP request handler (DashboardHandler) + server entry (wave-9 split)."""
3
+ import http.server
4
+ import json
5
+ import queue
6
+ import sys
7
+ import threading
8
+ import urllib.parse
9
+ from pathlib import Path
10
+
11
+ import config
12
+ import csrf
13
+ import sse
14
+ import api
15
+ import api.tracker
16
+ import api.submit
17
+ from render import render_dashboard
18
+ from csrf import validate_csrf_request
19
+ from collectors import (_snapshot_data, _snapshot_tracker,
20
+ _snapshot_orchestrator_status, drain_tracker_inbox,
21
+ get_alerts, get_heartbeat_status,
22
+ get_main_thread_messages, get_monitor_heartbeat_status,
23
+ get_recent_events, get_repos_status,
24
+ parse_audit_backlog)
25
+ from agents import (_AGENT_ID_FORBIDDEN, _transcripts_fingerprint,
26
+ extract_agent_dispatch_prompt, get_fleet_agents)
27
+ from sse import (_latest_lock, _latest_snapshots, _maybe_emit,
28
+ register_sse_client, unregister_sse_client)
29
+
30
+
31
+ class DashboardHandler(http.server.BaseHTTPRequestHandler):
32
+ """HTTP request handler for dashboard."""
33
+
34
+ def log_message(self, format, *args):
35
+ """Suppress default logging."""
36
+ pass
37
+
38
+ def do_GET(self):
39
+ """Handle GET requests."""
40
+ if self.path == "/":
41
+ self.serve_html()
42
+ elif self.path == "/data":
43
+ self.serve_data()
44
+ elif self.path == "/api/backlog":
45
+ self.serve_backlog()
46
+ elif self.path == "/api/agents":
47
+ self.serve_agents()
48
+ elif self.path.startswith("/api/tracker"):
49
+ self.serve_tracker()
50
+ elif self.path.startswith("/agent?"):
51
+ self.serve_agent()
52
+ elif self.path == "/events":
53
+ self.serve_events()
54
+ else:
55
+ self.send_error(404)
56
+
57
+ def do_POST(self):
58
+ """Handle POST requests."""
59
+ if self.path == "/submit":
60
+ self.handle_submit()
61
+ elif self.path == "/api/tracker":
62
+ self.handle_tracker_create()
63
+ elif self.path.startswith("/api/tracker/"):
64
+ self.handle_tracker_mutate()
65
+ else:
66
+ self.send_error(404)
67
+
68
+ def serve_html(self):
69
+ """Serve the dashboard HTML."""
70
+ html = render_dashboard(csrf.SESSION_TOKEN)
71
+ self.send_response(200)
72
+ self.send_header("Content-Type", "text/html; charset=utf-8")
73
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
74
+ self.end_headers()
75
+ self.wfile.write(html.encode('utf-8'))
76
+
77
+ def serve_data(self):
78
+ """Serve dashboard data as JSON."""
79
+ data = {
80
+ "watchdog": get_heartbeat_status(),
81
+ "monitor": get_monitor_heartbeat_status(),
82
+ "agents": get_fleet_agents(),
83
+ "repos": get_repos_status(),
84
+ "events": get_recent_events(),
85
+ "alerts": get_alerts(),
86
+ "messages": get_main_thread_messages(),
87
+ }
88
+ self.send_response(200)
89
+ self.send_header("Content-Type", "application/json; charset=utf-8")
90
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
91
+ self.end_headers()
92
+ self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
93
+
94
+ def serve_tracker(self):
95
+ """Serve tracker items as JSON via GET /api/tracker."""
96
+ try:
97
+ # Parse query string for filters
98
+ query = urllib.parse.urlparse(self.path).query
99
+ params = urllib.parse.parse_qs(query)
100
+ status = params.get('status', [None])[0]
101
+ priority = params.get('priority', [None])[0]
102
+
103
+ status_code, body = api.tracker.list_items(status=status, priority=priority)
104
+ self.send_response(status_code)
105
+ self.send_header("Content-Type", "application/json; charset=utf-8")
106
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
107
+ self.end_headers()
108
+ self.wfile.write(json.dumps(body, default=str).encode('utf-8'))
109
+ except Exception as e:
110
+ self.send_response(500)
111
+ self.send_header("Content-Type", "application/json")
112
+ self.end_headers()
113
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
114
+
115
+ def handle_tracker_create(self):
116
+ """Handle POST /api/tracker (create item)."""
117
+ try:
118
+ is_valid, reason = validate_csrf_request(self.headers)
119
+ if not is_valid:
120
+ self.send_response(403)
121
+ self.send_header("Content-Type", "application/json; charset=utf-8")
122
+ self.end_headers()
123
+ self.wfile.write(json.dumps({"error": "CSRF protection: " + reason}).encode('utf-8'))
124
+ return
125
+
126
+ content_length = int(self.headers.get('Content-Length', 0))
127
+ # Read bounded amount; api.validate_mutation() validates Content-Length
128
+ body_bytes = self.rfile.read(min(max(content_length, 0), api.MAX_BODY_BYTES))
129
+ status_code, result = api.tracker.create(self.headers, body_bytes)
130
+ self.send_response(status_code)
131
+ self.send_header("Content-Type", "application/json; charset=utf-8")
132
+ self.end_headers()
133
+ self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
134
+ except Exception as e:
135
+ self.send_response(500)
136
+ self.send_header("Content-Type", "application/json; charset=utf-8")
137
+ self.end_headers()
138
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
139
+
140
+ def handle_tracker_mutate(self):
141
+ """Handle POST /api/tracker/<id> (update or delete)."""
142
+ try:
143
+ is_valid, reason = validate_csrf_request(self.headers)
144
+ if not is_valid:
145
+ self.send_response(403)
146
+ self.send_header("Content-Type", "application/json; charset=utf-8")
147
+ self.end_headers()
148
+ self.wfile.write(json.dumps({"error": "CSRF protection: " + reason}).encode('utf-8'))
149
+ return
150
+
151
+ # Extract item_id from path
152
+ path_parts = self.path.strip("/").split("/")
153
+ if len(path_parts) < 3:
154
+ self.send_response(404)
155
+ self.send_header("Content-Type", "application/json; charset=utf-8")
156
+ self.end_headers()
157
+ self.wfile.write(json.dumps({"error": "Not found"}).encode('utf-8'))
158
+ return
159
+
160
+ item_id = path_parts[2]
161
+
162
+ # Parse query for action (update or delete)
163
+ query = urllib.parse.urlparse(self.path).query
164
+ params = urllib.parse.parse_qs(query)
165
+ action = params.get('action', ['update'])[0]
166
+
167
+ if action == "delete":
168
+ status_code, result = api.tracker.delete(item_id)
169
+ self.send_response(status_code)
170
+ self.send_header("Content-Type", "application/json; charset=utf-8")
171
+ self.end_headers()
172
+ self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
173
+ else:
174
+ content_length = int(self.headers.get('Content-Length', 0))
175
+ # Read bounded amount; api.validate_mutation() validates Content-Length
176
+ body_bytes = self.rfile.read(min(max(content_length, 0), api.MAX_BODY_BYTES))
177
+ status_code, result = api.tracker.update(item_id, body_bytes)
178
+ self.send_response(status_code)
179
+ self.send_header("Content-Type", "application/json; charset=utf-8")
180
+ self.end_headers()
181
+ self.wfile.write(json.dumps(result, default=str).encode('utf-8'))
182
+ except Exception as e:
183
+ self.send_response(500)
184
+ self.send_header("Content-Type", "application/json; charset=utf-8")
185
+ self.end_headers()
186
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
187
+
188
+
189
+ def serve_backlog(self):
190
+ """Serve audit backlog data as JSON via GET /api/backlog."""
191
+ try:
192
+ data = parse_audit_backlog()
193
+ self.send_response(200)
194
+ self.send_header("Content-Type", "application/json; charset=utf-8")
195
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
196
+ self.end_headers()
197
+ self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
198
+ except Exception as e:
199
+ self.send_response(500)
200
+ self.send_header("Content-Type", "application/json; charset=utf-8")
201
+ self.end_headers()
202
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
203
+
204
+ def serve_agents(self):
205
+ """Serve rich agent list with metadata via GET /api/agents."""
206
+ try:
207
+ agents = get_fleet_agents()
208
+ self.send_response(200)
209
+ self.send_header("Content-Type", "application/json; charset=utf-8")
210
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
211
+ self.end_headers()
212
+ self.wfile.write(json.dumps(agents, default=str).encode('utf-8'))
213
+ except Exception as e:
214
+ self.send_response(500)
215
+ self.send_header("Content-Type", "application/json; charset=utf-8")
216
+ self.end_headers()
217
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
218
+
219
+ def serve_agent(self):
220
+ """Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
221
+ try:
222
+ # Parse query string
223
+ query = urllib.parse.urlparse(self.path).query
224
+ params = urllib.parse.parse_qs(query)
225
+ agent_id = params.get('id', [None])[0]
226
+
227
+ if not agent_id:
228
+ self.send_response(400)
229
+ self.send_header("Content-Type", "application/json; charset=utf-8")
230
+ self.end_headers()
231
+ self.wfile.write(json.dumps({"error": "missing id parameter"}).encode('utf-8'))
232
+ return
233
+
234
+ # Extract dispatch prompt and metadata
235
+ data = extract_agent_dispatch_prompt(agent_id)
236
+
237
+ if "error" in data:
238
+ # Rejected input (path traversal, glob metacharacters, or a match
239
+ # that resolved outside config.TRANSCRIPTS_ROOT) -> 400. A well-formed id
240
+ # with no matching transcript -> 404. Never 200 on error.
241
+ status = 400 if data.get("invalid") else 404
242
+ self.send_response(status)
243
+ self.send_header("Content-Type", "application/json; charset=utf-8")
244
+ self.end_headers()
245
+ self.wfile.write(json.dumps({"error": data["error"]}).encode('utf-8'))
246
+ return
247
+
248
+ self.send_response(200)
249
+ self.send_header("Content-Type", "application/json; charset=utf-8")
250
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
251
+ self.end_headers()
252
+ self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
253
+ except Exception as e:
254
+ self.send_response(500)
255
+ self.send_header("Content-Type", "application/json; charset=utf-8")
256
+ self.end_headers()
257
+ print(f"[serve_agent] Uncaught exception: {e}", file=sys.stderr)
258
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
259
+
260
+ def _write_sse_event(self, event_name, payload):
261
+ """Write one SSE frame with timeout. Caller handles disconnect exceptions."""
262
+ msg = f"event: {event_name}\ndata: {payload}\n\n"
263
+ # Set socket timeout to prevent stalled writes from blocking the server
264
+ old_timeout = None
265
+ try:
266
+ old_timeout = self.connection.gettimeout()
267
+ self.connection.settimeout(config.SSE_WRITE_TIMEOUT)
268
+ except (AttributeError, OSError):
269
+ pass
270
+ try:
271
+ self.wfile.write(msg.encode("utf-8"))
272
+ self.wfile.flush()
273
+ finally:
274
+ # Restore original timeout
275
+ try:
276
+ if old_timeout is not None:
277
+ self.connection.settimeout(old_timeout)
278
+ except (AttributeError, OSError):
279
+ pass
280
+
281
+ def serve_events(self):
282
+ """GET /events — Server-Sent Events stream.
283
+
284
+ No CSRF token required: this is a read-only stream, not a mutation (POST
285
+ /submit keeps its token requirement unchanged). Holds the connection open
286
+ for the life of the client; requires ThreadingHTTPServer (see run_server)
287
+ so one SSE client can't block every other request.
288
+
289
+ Returns HTTP 503 if concurrent connection cap (config.SSE_MAX_CLIENTS) is exceeded.
290
+ """
291
+ sse.start_collector_thread()
292
+
293
+ q = register_sse_client()
294
+ if q is None:
295
+ # Connection cap exceeded; return 503 Service Unavailable
296
+ try:
297
+ self.send_response(503)
298
+ self.send_header("Content-Type", "text/plain")
299
+ self.send_header("Retry-After", "30")
300
+ self.end_headers()
301
+ self.wfile.write(b"Service overloaded: too many concurrent clients\n")
302
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
303
+ pass
304
+ return
305
+
306
+ try:
307
+ self.send_response(200)
308
+ self.send_header("Content-Type", "text/event-stream")
309
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
310
+ self.send_header("Connection", "keep-alive")
311
+ self.send_header("X-Accel-Buffering", "no")
312
+ self.end_headers()
313
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
314
+ unregister_sse_client(q)
315
+ return
316
+ try:
317
+ # Send an immediate full snapshot so first paint isn't empty. If the
318
+ # collector hasn't produced anything yet (first-ever request), compute
319
+ # it inline once.
320
+ with _latest_lock:
321
+ initial = dict(_latest_snapshots)
322
+ if all(v is None for v in initial.values()):
323
+ initial["data"] = json.dumps(_snapshot_data(), default=str, sort_keys=True)
324
+ initial["backlog"] = json.dumps(parse_audit_backlog(), default=str, sort_keys=True)
325
+ initial["agents"] = json.dumps(get_fleet_agents(), default=str, sort_keys=True)
326
+ initial["tracker"] = json.dumps(_snapshot_tracker(), default=str, sort_keys=True)
327
+ initial["status"] = json.dumps(_snapshot_orchestrator_status(), default=str, sort_keys=True)
328
+ with _latest_lock:
329
+ _latest_snapshots.update(initial)
330
+
331
+ for name in ("data", "backlog", "agents", "tracker", "status"):
332
+ payload = initial.get(name)
333
+ if payload is not None:
334
+ self._write_sse_event(name, payload)
335
+
336
+ while True:
337
+ try:
338
+ event_name, payload = q.get(timeout=config.SSE_KEEPALIVE_SECONDS)
339
+ self._write_sse_event(event_name, payload)
340
+ except queue.Empty:
341
+ self.wfile.write(b": keepalive\n\n")
342
+ self.wfile.flush()
343
+ except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError, OSError):
344
+ # Client disconnected (tab closed, network drop) — normal, not an error.
345
+ pass
346
+ except Exception:
347
+ pass
348
+ finally:
349
+ unregister_sse_client(q)
350
+
351
+ def handle_submit(self):
352
+ """Handle /submit POST with CSRF protection."""
353
+ try:
354
+ # CSRF validation: Check Origin/Referer + X-Aesop-Token
355
+ is_valid, reason = validate_csrf_request(self.headers)
356
+ if not is_valid:
357
+ self.send_response(403)
358
+ self.send_header("Content-Type", "application/json; charset=utf-8")
359
+ self.end_headers()
360
+ self.wfile.write(json.dumps({
361
+ "error": "CSRF protection: " + reason
362
+ }).encode('utf-8'))
363
+ return
364
+
365
+ content_length = int(self.headers.get('Content-Length', 0))
366
+ if content_length <= 0 or content_length > 10000: # 10KB limit, must be positive
367
+ self.send_response(400)
368
+ self.send_header("Content-Type", "application/json; charset=utf-8")
369
+ self.end_headers()
370
+ self.wfile.write(json.dumps({
371
+ "error": "Invalid Content-Length (must be 1-10000 bytes)"
372
+ }).encode('utf-8'))
373
+ return
374
+
375
+ body_bytes = self.rfile.read(content_length)
376
+ data = json.loads(body_bytes.decode('utf-8', errors='ignore'))
377
+ text = data.get("text", "").strip()
378
+
379
+ if not text:
380
+ self.send_response(400)
381
+ self.send_header("Content-Type", "application/json; charset=utf-8")
382
+ self.end_headers()
383
+ self.wfile.write(json.dumps({"error": "No text provided"}).encode('utf-8'))
384
+ return
385
+
386
+ ok, result = api.submit.append_to_inbox(text)
387
+ if not ok:
388
+ status_code, error = result
389
+ self.send_response(status_code)
390
+ self.send_header("Content-Type", "application/json; charset=utf-8")
391
+ self.end_headers()
392
+ self.wfile.write(json.dumps(error).encode('utf-8'))
393
+ return
394
+
395
+ self.send_response(200)
396
+ self.send_header("Content-Type", "application/json; charset=utf-8")
397
+ self.end_headers()
398
+ self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
399
+ except Exception as e:
400
+ self.send_response(500)
401
+ self.send_header("Content-Type", "application/json; charset=utf-8")
402
+ self.end_headers()
403
+ self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
404
+
405
+ def run_server():
406
+ """Start the HTTP server.
407
+
408
+ Must be ThreadingHTTPServer, not HTTPServer: GET /events (SSE) holds its
409
+ connection open for the life of the client, so a single-threaded server would
410
+ wedge every other request (including the initial page load and /submit)
411
+ behind that one held connection.
412
+ """
413
+ addr = ("127.0.0.1", config.PORT)
414
+ httpd = http.server.ThreadingHTTPServer(addr, DashboardHandler)
415
+ httpd.daemon_threads = True
416
+ sse.start_collector_thread()
417
+ print(f"Dashboard: http://localhost:{config.PORT}")
418
+ print(f"config.AESOP_ROOT: {config.AESOP_ROOT}")
419
+ print(f"Transcripts: {config.TRANSCRIPTS_ROOT}")
420
+ print(f"Press Ctrl-C to stop")
421
+ try:
422
+ httpd.serve_forever()
423
+ except KeyboardInterrupt:
424
+ sse._collector_stop_event.set()
425
+ print("\nShutdown complete.")
426
+ sys.exit(0)