@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
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)
|