loki-mode 5.34.0 → 5.36.0
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/README.md +5 -5
- package/SKILL.md +4 -4
- package/VERSION +1 -1
- package/api/README.md +1 -1
- package/api/server.js +1 -1
- package/api/server.ts +5 -5
- package/api/test.js +1 -1
- package/autonomy/api-server.js +6 -4
- package/autonomy/completion-council.sh +4 -2
- package/autonomy/hooks/store-episode.sh +2 -2
- package/autonomy/loki +84 -54
- package/autonomy/run.sh +597 -36
- package/autonomy/sandbox.sh +4 -10
- package/autonomy/serve.sh +10 -10
- package/dashboard/__init__.py +1 -1
- package/dashboard/auth.py +18 -5
- package/dashboard/requirements.txt +7 -7
- package/dashboard/server.py +67 -30
- package/dashboard/static/index.html +359 -58
- package/docs/INSTALLATION.md +4 -4
- package/docs/SYNERGY-ROADMAP.md +1 -1
- package/docs/architecture/DASHBOARD_V2_ARCHITECTURE.md +4 -3
- package/docs/dashboard-guide.md +1 -1
- package/memory/layers/index_layer.py +4 -4
- package/memory/layers/timeline_layer.py +5 -5
- package/memory/retrieval.py +10 -2
- package/memory/storage.py +1 -1
- package/memory/token_economics.py +12 -8
- package/package.json +1 -1
package/autonomy/sandbox.sh
CHANGED
|
@@ -56,7 +56,7 @@ SANDBOX_MEMORY="${LOKI_SANDBOX_MEMORY:-4g}"
|
|
|
56
56
|
SANDBOX_READONLY="${LOKI_SANDBOX_READONLY:-false}"
|
|
57
57
|
|
|
58
58
|
# API ports
|
|
59
|
-
API_PORT="${
|
|
59
|
+
API_PORT="${LOKI_DASHBOARD_PORT:-57374}"
|
|
60
60
|
DASHBOARD_PORT="${LOKI_DASHBOARD_PORT:-57374}"
|
|
61
61
|
|
|
62
62
|
# Security: Prompt injection disabled by default for enterprise security
|
|
@@ -727,7 +727,7 @@ docker_desktop_sandbox_prompt() {
|
|
|
727
727
|
docker sandbox exec -w "$PROJECT_DIR" \
|
|
728
728
|
${DESKTOP_ENV_ARGS[@]+"${DESKTOP_ENV_ARGS[@]}"} \
|
|
729
729
|
"$DESKTOP_SANDBOX_NAME" \
|
|
730
|
-
bash -c "
|
|
730
|
+
bash -c "printf '%s\n' \"\$1\" > ${PROJECT_DIR}/.loki/HUMAN_INPUT.md" -- "$message"
|
|
731
731
|
|
|
732
732
|
log_success "Prompt sent to sandbox"
|
|
733
733
|
}
|
|
@@ -766,12 +766,7 @@ start_sandbox() {
|
|
|
766
766
|
validate_project_dir || return 1
|
|
767
767
|
ensure_image
|
|
768
768
|
|
|
769
|
-
# Check port availability
|
|
770
|
-
if ! check_port_available "$API_PORT"; then
|
|
771
|
-
log_error "Port $API_PORT is already in use"
|
|
772
|
-
log_info "Set LOKI_API_PORT to use a different port"
|
|
773
|
-
return 1
|
|
774
|
-
fi
|
|
769
|
+
# Check port availability (unified dashboard/API port)
|
|
775
770
|
if ! check_port_available "$DASHBOARD_PORT"; then
|
|
776
771
|
log_error "Port $DASHBOARD_PORT is already in use"
|
|
777
772
|
log_info "Set LOKI_DASHBOARD_PORT to use a different port"
|
|
@@ -898,8 +893,7 @@ start_sandbox() {
|
|
|
898
893
|
|
|
899
894
|
# Expose ports
|
|
900
895
|
docker_args+=(
|
|
901
|
-
"--publish" "$API_PORT:
|
|
902
|
-
"--publish" "$DASHBOARD_PORT:57374"
|
|
896
|
+
"--publish" "$API_PORT:57374"
|
|
903
897
|
)
|
|
904
898
|
|
|
905
899
|
# Expose additional ports for testing (e.g., LOKI_EXTRA_PORTS="3000:3000,8080:8080")
|
package/autonomy/serve.sh
CHANGED
|
@@ -9,15 +9,15 @@
|
|
|
9
9
|
# loki serve [OPTIONS]
|
|
10
10
|
#
|
|
11
11
|
# Options:
|
|
12
|
-
# --port, -p <port> Port to listen on (default:
|
|
12
|
+
# --port, -p <port> Port to listen on (default: 57374)
|
|
13
13
|
# --host, -h <host> Host to bind to (default: localhost)
|
|
14
14
|
# --no-cors Disable CORS
|
|
15
15
|
# --no-auth Disable authentication
|
|
16
16
|
# --help Show help message
|
|
17
17
|
#
|
|
18
18
|
# Environment Variables:
|
|
19
|
-
#
|
|
20
|
-
#
|
|
19
|
+
# LOKI_DASHBOARD_PORT Port (default: 57374)
|
|
20
|
+
# LOKI_DASHBOARD_HOST Host (default: localhost)
|
|
21
21
|
# LOKI_API_TOKEN API token for remote access
|
|
22
22
|
#===============================================================================
|
|
23
23
|
|
|
@@ -28,8 +28,8 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
|
28
28
|
API_DIR="$PROJECT_DIR/api"
|
|
29
29
|
|
|
30
30
|
# Default configuration
|
|
31
|
-
PORT="${
|
|
32
|
-
HOST="${
|
|
31
|
+
PORT="${LOKI_DASHBOARD_PORT:-57374}"
|
|
32
|
+
HOST="${LOKI_DASHBOARD_HOST:-localhost}"
|
|
33
33
|
CORS="true"
|
|
34
34
|
AUTH="true"
|
|
35
35
|
|
|
@@ -63,7 +63,7 @@ Usage:
|
|
|
63
63
|
loki serve [OPTIONS]
|
|
64
64
|
|
|
65
65
|
Options:
|
|
66
|
-
--port, -p <port> Port to listen on (default:
|
|
66
|
+
--port, -p <port> Port to listen on (default: 57374)
|
|
67
67
|
--host <host> Host to bind to (default: localhost)
|
|
68
68
|
--no-cors Disable CORS
|
|
69
69
|
--no-auth Disable authentication
|
|
@@ -71,14 +71,14 @@ Options:
|
|
|
71
71
|
--help Show this help message
|
|
72
72
|
|
|
73
73
|
Environment Variables:
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
LOKI_DASHBOARD_PORT Port (overridden by --port)
|
|
75
|
+
LOKI_DASHBOARD_HOST Host (overridden by --host)
|
|
76
76
|
LOKI_API_TOKEN API token for remote access
|
|
77
77
|
LOKI_DIR Loki installation directory
|
|
78
78
|
LOKI_DEBUG Enable debug output
|
|
79
79
|
|
|
80
80
|
Examples:
|
|
81
|
-
# Start with defaults (localhost:
|
|
81
|
+
# Start with defaults (localhost:57374)
|
|
82
82
|
loki serve
|
|
83
83
|
|
|
84
84
|
# Custom port
|
|
@@ -89,7 +89,7 @@ Examples:
|
|
|
89
89
|
loki serve --host 0.0.0.0
|
|
90
90
|
|
|
91
91
|
# Connect from another machine
|
|
92
|
-
curl -H "Authorization: Bearer \$TOKEN" http://server:
|
|
92
|
+
curl -H "Authorization: Bearer \$TOKEN" http://server:57374/health
|
|
93
93
|
|
|
94
94
|
EOF
|
|
95
95
|
}
|
package/dashboard/__init__.py
CHANGED
package/dashboard/auth.py
CHANGED
|
@@ -53,9 +53,20 @@ def _save_tokens(tokens: dict) -> None:
|
|
|
53
53
|
json.dump(tokens, f, indent=2, default=str)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def _hash_token(token: str) -> str:
|
|
57
|
-
"""Hash a token for storage.
|
|
58
|
-
|
|
56
|
+
def _hash_token(token: str, salt: str = None) -> tuple[str, str]:
|
|
57
|
+
"""Hash a token for storage with a per-token random salt.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
token: The raw token string to hash.
|
|
61
|
+
salt: Optional salt. If None, a new random salt is generated.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (hex_digest, salt).
|
|
65
|
+
"""
|
|
66
|
+
if salt is None:
|
|
67
|
+
salt = secrets.token_hex(16)
|
|
68
|
+
digest = hashlib.sha256((salt + token).encode()).hexdigest()
|
|
69
|
+
return digest, salt
|
|
59
70
|
|
|
60
71
|
|
|
61
72
|
def _constant_time_compare(a: str, b: str) -> bool:
|
|
@@ -94,7 +105,7 @@ def generate_token(
|
|
|
94
105
|
|
|
95
106
|
# Generate secure random token
|
|
96
107
|
raw_token = f"loki_{secrets.token_urlsafe(32)}"
|
|
97
|
-
token_hash = _hash_token(raw_token)
|
|
108
|
+
token_hash, token_salt = _hash_token(raw_token)
|
|
98
109
|
token_id = token_hash[:12]
|
|
99
110
|
|
|
100
111
|
tokens = _load_tokens()
|
|
@@ -114,6 +125,7 @@ def generate_token(
|
|
|
114
125
|
"id": token_id,
|
|
115
126
|
"name": name,
|
|
116
127
|
"hash": token_hash,
|
|
128
|
+
"salt": token_salt,
|
|
117
129
|
"scopes": scopes or ["*"],
|
|
118
130
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
119
131
|
"expires_at": expires_at,
|
|
@@ -229,11 +241,12 @@ def validate_token(raw_token: str) -> Optional[dict]:
|
|
|
229
241
|
if not raw_token or not raw_token.startswith("loki_"):
|
|
230
242
|
return None
|
|
231
243
|
|
|
232
|
-
token_hash = _hash_token(raw_token)
|
|
233
244
|
tokens = _load_tokens()
|
|
234
245
|
|
|
235
246
|
# Find matching token (using constant-time comparison to prevent timing attacks)
|
|
236
247
|
for token in tokens["tokens"].values():
|
|
248
|
+
stored_salt = token.get("salt", "")
|
|
249
|
+
token_hash, _ = _hash_token(raw_token, salt=stored_salt)
|
|
237
250
|
if _constant_time_compare(token["hash"], token_hash):
|
|
238
251
|
# Check if revoked
|
|
239
252
|
if token.get("revoked"):
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Loki Mode Dashboard Dependencies
|
|
2
|
-
fastapi
|
|
3
|
-
uvicorn[standard]
|
|
4
|
-
sqlalchemy
|
|
5
|
-
aiosqlite
|
|
6
|
-
greenlet
|
|
7
|
-
pydantic
|
|
8
|
-
websockets
|
|
2
|
+
fastapi==0.115.6
|
|
3
|
+
uvicorn[standard]==0.34.0
|
|
4
|
+
sqlalchemy==2.0.36
|
|
5
|
+
aiosqlite==0.20.0
|
|
6
|
+
greenlet==3.1.1
|
|
7
|
+
pydantic==2.10.4
|
|
8
|
+
websockets==14.1
|
package/dashboard/server.py
CHANGED
|
@@ -8,6 +8,8 @@ import asyncio
|
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
|
+
import time
|
|
12
|
+
from collections import defaultdict
|
|
11
13
|
from contextlib import asynccontextmanager
|
|
12
14
|
from datetime import datetime, timedelta, timezone
|
|
13
15
|
from pathlib import Path as _Path
|
|
@@ -43,6 +45,29 @@ from . import registry
|
|
|
43
45
|
from . import auth
|
|
44
46
|
from . import audit
|
|
45
47
|
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Simple in-memory rate limiter for control endpoints
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
class _RateLimiter:
|
|
53
|
+
"""Simple in-memory rate limiter for control endpoints."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, max_calls: int = 10, window_seconds: int = 60):
|
|
56
|
+
self._max_calls = max_calls
|
|
57
|
+
self._window = window_seconds
|
|
58
|
+
self._calls: dict[str, list[float]] = defaultdict(list)
|
|
59
|
+
|
|
60
|
+
def check(self, key: str) -> bool:
|
|
61
|
+
now = time.time()
|
|
62
|
+
self._calls[key] = [t for t in self._calls[key] if now - t < self._window]
|
|
63
|
+
if len(self._calls[key]) >= self._max_calls:
|
|
64
|
+
return False
|
|
65
|
+
self._calls[key].append(now)
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_control_limiter = _RateLimiter(max_calls=10, window_seconds=60)
|
|
70
|
+
|
|
46
71
|
# Set up logging
|
|
47
72
|
logger = logging.getLogger(__name__)
|
|
48
73
|
|
|
@@ -188,7 +213,7 @@ class ConnectionManager:
|
|
|
188
213
|
|
|
189
214
|
|
|
190
215
|
manager = ConnectionManager()
|
|
191
|
-
start_time = datetime.now()
|
|
216
|
+
start_time = datetime.now(timezone.utc)
|
|
192
217
|
|
|
193
218
|
|
|
194
219
|
@asynccontextmanager
|
|
@@ -217,8 +242,8 @@ app.add_middleware(
|
|
|
217
242
|
CORSMiddleware,
|
|
218
243
|
allow_origins=[o.strip() for o in _cors_origins if o.strip()],
|
|
219
244
|
allow_credentials=True,
|
|
220
|
-
allow_methods=["
|
|
221
|
-
allow_headers=["
|
|
245
|
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
246
|
+
allow_headers=["Content-Type", "Authorization", "X-Requested-With"],
|
|
222
247
|
)
|
|
223
248
|
|
|
224
249
|
# Static file serving is configured at the end of the file (after all API routes)
|
|
@@ -236,7 +261,7 @@ async def health_check() -> dict[str, str]:
|
|
|
236
261
|
async def get_status() -> StatusResponse:
|
|
237
262
|
"""Get system status from .loki/ session files."""
|
|
238
263
|
loki_dir = _get_loki_dir()
|
|
239
|
-
uptime = (datetime.now() - start_time).total_seconds()
|
|
264
|
+
uptime = (datetime.now(timezone.utc) - start_time).total_seconds()
|
|
240
265
|
|
|
241
266
|
# Read dashboard-state.json (written by run.sh every 2 seconds)
|
|
242
267
|
state_file = loki_dir / "dashboard-state.json"
|
|
@@ -440,7 +465,7 @@ async def get_project(
|
|
|
440
465
|
)
|
|
441
466
|
|
|
442
467
|
|
|
443
|
-
@app.put("/api/projects/{project_id}", response_model=ProjectResponse)
|
|
468
|
+
@app.put("/api/projects/{project_id}", response_model=ProjectResponse, dependencies=[Depends(auth.require_scope("control"))])
|
|
444
469
|
async def update_project(
|
|
445
470
|
project_id: int,
|
|
446
471
|
project_update: ProjectUpdate,
|
|
@@ -486,7 +511,7 @@ async def update_project(
|
|
|
486
511
|
)
|
|
487
512
|
|
|
488
513
|
|
|
489
|
-
@app.delete("/api/projects/{project_id}", status_code=204)
|
|
514
|
+
@app.delete("/api/projects/{project_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
490
515
|
async def delete_project(
|
|
491
516
|
project_id: int,
|
|
492
517
|
db: AsyncSession = Depends(get_db),
|
|
@@ -662,7 +687,7 @@ async def get_task(
|
|
|
662
687
|
return TaskResponse.model_validate(task)
|
|
663
688
|
|
|
664
689
|
|
|
665
|
-
@app.put("/api/tasks/{task_id}", response_model=TaskResponse)
|
|
690
|
+
@app.put("/api/tasks/{task_id}", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("control"))])
|
|
666
691
|
async def update_task(
|
|
667
692
|
task_id: int,
|
|
668
693
|
task_update: TaskUpdate,
|
|
@@ -681,7 +706,7 @@ async def update_task(
|
|
|
681
706
|
|
|
682
707
|
# Handle status change to completed
|
|
683
708
|
if "status" in update_data and update_data["status"] == TaskStatus.DONE:
|
|
684
|
-
update_data["completed_at"] = datetime.now()
|
|
709
|
+
update_data["completed_at"] = datetime.now(timezone.utc)
|
|
685
710
|
|
|
686
711
|
for field, value in update_data.items():
|
|
687
712
|
setattr(task, field, value)
|
|
@@ -703,7 +728,7 @@ async def update_task(
|
|
|
703
728
|
return TaskResponse.model_validate(task)
|
|
704
729
|
|
|
705
730
|
|
|
706
|
-
@app.delete("/api/tasks/{task_id}", status_code=204)
|
|
731
|
+
@app.delete("/api/tasks/{task_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
707
732
|
async def delete_task(
|
|
708
733
|
task_id: int,
|
|
709
734
|
db: AsyncSession = Depends(get_db),
|
|
@@ -748,7 +773,7 @@ async def move_task(
|
|
|
748
773
|
|
|
749
774
|
# Set completed_at if moving to completed
|
|
750
775
|
if move.status == TaskStatus.DONE and old_status != TaskStatus.DONE:
|
|
751
|
-
task.completed_at = datetime.now()
|
|
776
|
+
task.completed_at = datetime.now(timezone.utc)
|
|
752
777
|
elif move.status != TaskStatus.DONE:
|
|
753
778
|
task.completed_at = None
|
|
754
779
|
|
|
@@ -890,7 +915,7 @@ async def get_registered_project(identifier: str):
|
|
|
890
915
|
return project
|
|
891
916
|
|
|
892
917
|
|
|
893
|
-
@app.delete("/api/registry/projects/{identifier}", status_code=204)
|
|
918
|
+
@app.delete("/api/registry/projects/{identifier}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
894
919
|
async def unregister_project(identifier: str):
|
|
895
920
|
"""Remove a project from the registry."""
|
|
896
921
|
if not registry.unregister_project(identifier):
|
|
@@ -1028,7 +1053,7 @@ async def list_tokens(include_revoked: bool = False):
|
|
|
1028
1053
|
return auth.list_tokens(include_revoked=include_revoked)
|
|
1029
1054
|
|
|
1030
1055
|
|
|
1031
|
-
@app.delete("/api/enterprise/tokens/{identifier}")
|
|
1056
|
+
@app.delete("/api/enterprise/tokens/{identifier}", dependencies=[Depends(auth.require_scope("admin"))])
|
|
1032
1057
|
async def revoke_token(identifier: str, permanent: bool = False):
|
|
1033
1058
|
"""
|
|
1034
1059
|
Revoke or delete a token (enterprise only).
|
|
@@ -1135,10 +1160,10 @@ _SAFE_ID_RE = re.compile(r'^[a-zA-Z0-9_-]+$')
|
|
|
1135
1160
|
|
|
1136
1161
|
def _sanitize_agent_id(agent_id: str) -> str:
|
|
1137
1162
|
"""Validate agent_id contains only safe characters for file paths."""
|
|
1138
|
-
if not agent_id or ".." in agent_id or not _SAFE_ID_RE.match(agent_id):
|
|
1163
|
+
if not agent_id or len(agent_id) > 128 or ".." in agent_id or not _SAFE_ID_RE.match(agent_id):
|
|
1139
1164
|
raise HTTPException(
|
|
1140
1165
|
status_code=400,
|
|
1141
|
-
detail="Invalid agent_id: must
|
|
1166
|
+
detail="Invalid agent_id: must be 1-128 chars of alphanumeric, hyphens, and underscores",
|
|
1142
1167
|
)
|
|
1143
1168
|
return agent_id
|
|
1144
1169
|
|
|
@@ -1606,18 +1631,22 @@ def _read_events(time_range: str = "7d") -> list:
|
|
|
1606
1631
|
|
|
1607
1632
|
|
|
1608
1633
|
# Session control endpoints (proxy to control.py functions)
|
|
1609
|
-
@app.post("/api/control/pause")
|
|
1634
|
+
@app.post("/api/control/pause", dependencies=[Depends(auth.require_scope("control"))])
|
|
1610
1635
|
async def pause_session():
|
|
1611
1636
|
"""Pause the current session by creating PAUSE file."""
|
|
1637
|
+
if not _control_limiter.check("control"):
|
|
1638
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1612
1639
|
pause_file = _get_loki_dir() / "PAUSE"
|
|
1613
1640
|
pause_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1614
|
-
pause_file.write_text(datetime.now().isoformat())
|
|
1641
|
+
pause_file.write_text(datetime.now(timezone.utc).isoformat())
|
|
1615
1642
|
return {"success": True, "message": "Session paused"}
|
|
1616
1643
|
|
|
1617
1644
|
|
|
1618
|
-
@app.post("/api/control/resume")
|
|
1645
|
+
@app.post("/api/control/resume", dependencies=[Depends(auth.require_scope("control"))])
|
|
1619
1646
|
async def resume_session():
|
|
1620
1647
|
"""Resume a paused session by removing PAUSE/STOP files."""
|
|
1648
|
+
if not _control_limiter.check("control"):
|
|
1649
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1621
1650
|
for fname in ["PAUSE", "STOP"]:
|
|
1622
1651
|
fpath = _get_loki_dir() / fname
|
|
1623
1652
|
try:
|
|
@@ -1627,12 +1656,14 @@ async def resume_session():
|
|
|
1627
1656
|
return {"success": True, "message": "Session resumed"}
|
|
1628
1657
|
|
|
1629
1658
|
|
|
1630
|
-
@app.post("/api/control/stop")
|
|
1659
|
+
@app.post("/api/control/stop", dependencies=[Depends(auth.require_scope("control"))])
|
|
1631
1660
|
async def stop_session():
|
|
1632
1661
|
"""Stop the session by creating STOP file and sending SIGTERM."""
|
|
1662
|
+
if not _control_limiter.check("control"):
|
|
1663
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1633
1664
|
stop_file = _get_loki_dir() / "STOP"
|
|
1634
1665
|
stop_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1635
|
-
stop_file.write_text(datetime.now().isoformat())
|
|
1666
|
+
stop_file.write_text(datetime.now(timezone.utc).isoformat())
|
|
1636
1667
|
|
|
1637
1668
|
# Try to kill the process
|
|
1638
1669
|
pid_file = _get_loki_dir() / "loki.pid"
|
|
@@ -1979,7 +2010,7 @@ async def force_council_review():
|
|
|
1979
2010
|
signal_dir = _get_loki_dir() / "signals"
|
|
1980
2011
|
signal_dir.mkdir(parents=True, exist_ok=True)
|
|
1981
2012
|
(signal_dir / "COUNCIL_REVIEW_REQUESTED").write_text(
|
|
1982
|
-
datetime.now().isoformat()
|
|
2013
|
+
datetime.now(timezone.utc).isoformat()
|
|
1983
2014
|
)
|
|
1984
2015
|
return {"success": True, "message": "Council review requested"}
|
|
1985
2016
|
|
|
@@ -1990,15 +2021,15 @@ async def force_council_review():
|
|
|
1990
2021
|
|
|
1991
2022
|
class CheckpointCreate(BaseModel):
|
|
1992
2023
|
"""Schema for creating a checkpoint."""
|
|
1993
|
-
message: Optional[str] = Field(None, description="Optional description for the checkpoint")
|
|
2024
|
+
message: Optional[str] = Field(None, max_length=500, description="Optional description for the checkpoint")
|
|
1994
2025
|
|
|
1995
2026
|
|
|
1996
2027
|
def _sanitize_checkpoint_id(checkpoint_id: str) -> str:
|
|
1997
2028
|
"""Validate checkpoint_id contains only safe characters for file paths."""
|
|
1998
|
-
if not checkpoint_id or ".." in checkpoint_id or not _SAFE_ID_RE.match(checkpoint_id):
|
|
2029
|
+
if not checkpoint_id or len(checkpoint_id) > 128 or ".." in checkpoint_id or not _SAFE_ID_RE.match(checkpoint_id):
|
|
1999
2030
|
raise HTTPException(
|
|
2000
2031
|
status_code=400,
|
|
2001
|
-
detail="Invalid checkpoint_id: must
|
|
2032
|
+
detail="Invalid checkpoint_id: must be 1-128 chars of alphanumeric, hyphens, and underscores",
|
|
2002
2033
|
)
|
|
2003
2034
|
return checkpoint_id
|
|
2004
2035
|
|
|
@@ -2132,7 +2163,7 @@ async def create_checkpoint(body: CheckpointCreate = None):
|
|
|
2132
2163
|
# =============================================================================
|
|
2133
2164
|
|
|
2134
2165
|
@app.get("/api/agents")
|
|
2135
|
-
async def get_agents():
|
|
2166
|
+
async def get_agents(token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2136
2167
|
"""Get all active and recent agents."""
|
|
2137
2168
|
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
2138
2169
|
agents = []
|
|
@@ -2187,9 +2218,11 @@ async def get_agents():
|
|
|
2187
2218
|
return agents
|
|
2188
2219
|
|
|
2189
2220
|
|
|
2190
|
-
@app.post("/api/agents/{agent_id}/kill")
|
|
2221
|
+
@app.post("/api/agents/{agent_id}/kill", dependencies=[Depends(auth.require_scope("control"))])
|
|
2191
2222
|
async def kill_agent(agent_id: str):
|
|
2192
2223
|
"""Kill a specific agent by ID."""
|
|
2224
|
+
if not _control_limiter.check("control"):
|
|
2225
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2193
2226
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2194
2227
|
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
2195
2228
|
if not agents_file.exists():
|
|
@@ -2234,21 +2267,25 @@ async def kill_agent(agent_id: str):
|
|
|
2234
2267
|
)
|
|
2235
2268
|
|
|
2236
2269
|
|
|
2237
|
-
@app.post("/api/agents/{agent_id}/pause")
|
|
2270
|
+
@app.post("/api/agents/{agent_id}/pause", dependencies=[Depends(auth.require_scope("control"))])
|
|
2238
2271
|
async def pause_agent(agent_id: str):
|
|
2239
2272
|
"""Pause a specific agent by writing a pause signal."""
|
|
2273
|
+
if not _control_limiter.check("control"):
|
|
2274
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2240
2275
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2241
2276
|
signal_dir = _get_loki_dir() / "signals"
|
|
2242
2277
|
signal_dir.mkdir(parents=True, exist_ok=True)
|
|
2243
2278
|
(signal_dir / f"PAUSE_AGENT_{agent_id}").write_text(
|
|
2244
|
-
datetime.now().isoformat()
|
|
2279
|
+
datetime.now(timezone.utc).isoformat()
|
|
2245
2280
|
)
|
|
2246
2281
|
return {"success": True, "message": f"Pause signal sent to agent {agent_id}"}
|
|
2247
2282
|
|
|
2248
2283
|
|
|
2249
|
-
@app.post("/api/agents/{agent_id}/resume")
|
|
2284
|
+
@app.post("/api/agents/{agent_id}/resume", dependencies=[Depends(auth.require_scope("control"))])
|
|
2250
2285
|
async def resume_agent(agent_id: str):
|
|
2251
2286
|
"""Resume a paused agent."""
|
|
2287
|
+
if not _control_limiter.check("control"):
|
|
2288
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2252
2289
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2253
2290
|
signal_file = _get_loki_dir() / "signals" / f"PAUSE_AGENT_{agent_id}"
|
|
2254
2291
|
try:
|
|
@@ -2259,7 +2296,7 @@ async def resume_agent(agent_id: str):
|
|
|
2259
2296
|
|
|
2260
2297
|
|
|
2261
2298
|
@app.get("/api/logs")
|
|
2262
|
-
async def get_logs(lines: int = 100):
|
|
2299
|
+
async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2263
2300
|
"""Get recent log entries from session log files."""
|
|
2264
2301
|
log_dir = _get_loki_dir() / "logs"
|
|
2265
2302
|
entries = []
|
|
@@ -2290,7 +2327,7 @@ async def get_logs(lines: int = 100):
|
|
|
2290
2327
|
for log_file in log_files[:1]:
|
|
2291
2328
|
try:
|
|
2292
2329
|
# Use file mtime as fallback timestamp
|
|
2293
|
-
file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime).strftime(
|
|
2330
|
+
file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime, tz=timezone.utc).strftime(
|
|
2294
2331
|
"%Y-%m-%dT%H:%M:%S"
|
|
2295
2332
|
)
|
|
2296
2333
|
content = log_file.read_text()
|