loki-mode 5.35.0 → 5.37.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +317 -30
- package/autonomy/run.sh +328 -7
- package/autonomy/sandbox.sh +1 -1
- package/autonomy/serve.sh +25 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +9 -5
- package/dashboard/auth.py +189 -22
- package/dashboard/requirements.txt +7 -7
- package/dashboard/secrets.py +152 -0
- package/dashboard/server.py +280 -23
- package/docs/INSTALLATION.md +1 -1
- package/package.json +1 -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
|
|
@@ -19,6 +21,7 @@ from fastapi import (
|
|
|
19
21
|
FastAPI,
|
|
20
22
|
HTTPException,
|
|
21
23
|
Query,
|
|
24
|
+
Request,
|
|
22
25
|
WebSocket,
|
|
23
26
|
WebSocketDisconnect,
|
|
24
27
|
)
|
|
@@ -42,6 +45,37 @@ from .models import (
|
|
|
42
45
|
from . import registry
|
|
43
46
|
from . import auth
|
|
44
47
|
from . import audit
|
|
48
|
+
from . import secrets as secrets_mod
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# TLS Configuration (optional - disabled by default)
|
|
52
|
+
# Set both LOKI_TLS_CERT and LOKI_TLS_KEY to enable HTTPS
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
LOKI_TLS_CERT = os.environ.get("LOKI_TLS_CERT", "") # Path to PEM certificate
|
|
55
|
+
LOKI_TLS_KEY = os.environ.get("LOKI_TLS_KEY", "") # Path to PEM private key
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Simple in-memory rate limiter for control endpoints
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
class _RateLimiter:
|
|
62
|
+
"""Simple in-memory rate limiter for control endpoints."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, max_calls: int = 10, window_seconds: int = 60):
|
|
65
|
+
self._max_calls = max_calls
|
|
66
|
+
self._window = window_seconds
|
|
67
|
+
self._calls: dict[str, list[float]] = defaultdict(list)
|
|
68
|
+
|
|
69
|
+
def check(self, key: str) -> bool:
|
|
70
|
+
now = time.time()
|
|
71
|
+
self._calls[key] = [t for t in self._calls[key] if now - t < self._window]
|
|
72
|
+
if len(self._calls[key]) >= self._max_calls:
|
|
73
|
+
return False
|
|
74
|
+
self._calls[key].append(now)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_control_limiter = _RateLimiter(max_calls=10, window_seconds=60)
|
|
45
79
|
|
|
46
80
|
# Set up logging
|
|
47
81
|
logger = logging.getLogger(__name__)
|
|
@@ -440,7 +474,7 @@ async def get_project(
|
|
|
440
474
|
)
|
|
441
475
|
|
|
442
476
|
|
|
443
|
-
@app.put("/api/projects/{project_id}", response_model=ProjectResponse)
|
|
477
|
+
@app.put("/api/projects/{project_id}", response_model=ProjectResponse, dependencies=[Depends(auth.require_scope("control"))])
|
|
444
478
|
async def update_project(
|
|
445
479
|
project_id: int,
|
|
446
480
|
project_update: ProjectUpdate,
|
|
@@ -486,9 +520,10 @@ async def update_project(
|
|
|
486
520
|
)
|
|
487
521
|
|
|
488
522
|
|
|
489
|
-
@app.delete("/api/projects/{project_id}", status_code=204)
|
|
523
|
+
@app.delete("/api/projects/{project_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
490
524
|
async def delete_project(
|
|
491
525
|
project_id: int,
|
|
526
|
+
request: Request,
|
|
492
527
|
db: AsyncSession = Depends(get_db),
|
|
493
528
|
) -> None:
|
|
494
529
|
"""Delete a project."""
|
|
@@ -500,6 +535,14 @@ async def delete_project(
|
|
|
500
535
|
if not project:
|
|
501
536
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
502
537
|
|
|
538
|
+
audit.log_event(
|
|
539
|
+
action="delete",
|
|
540
|
+
resource_type="project",
|
|
541
|
+
resource_id=str(project_id),
|
|
542
|
+
details={"name": project.name},
|
|
543
|
+
ip_address=request.client.host if request.client else None,
|
|
544
|
+
)
|
|
545
|
+
|
|
503
546
|
await db.delete(project)
|
|
504
547
|
|
|
505
548
|
# Broadcast update
|
|
@@ -662,7 +705,7 @@ async def get_task(
|
|
|
662
705
|
return TaskResponse.model_validate(task)
|
|
663
706
|
|
|
664
707
|
|
|
665
|
-
@app.put("/api/tasks/{task_id}", response_model=TaskResponse)
|
|
708
|
+
@app.put("/api/tasks/{task_id}", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("control"))])
|
|
666
709
|
async def update_task(
|
|
667
710
|
task_id: int,
|
|
668
711
|
task_update: TaskUpdate,
|
|
@@ -703,9 +746,10 @@ async def update_task(
|
|
|
703
746
|
return TaskResponse.model_validate(task)
|
|
704
747
|
|
|
705
748
|
|
|
706
|
-
@app.delete("/api/tasks/{task_id}", status_code=204)
|
|
749
|
+
@app.delete("/api/tasks/{task_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
707
750
|
async def delete_task(
|
|
708
751
|
task_id: int,
|
|
752
|
+
request: Request,
|
|
709
753
|
db: AsyncSession = Depends(get_db),
|
|
710
754
|
) -> None:
|
|
711
755
|
"""Delete a task."""
|
|
@@ -718,6 +762,15 @@ async def delete_task(
|
|
|
718
762
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
719
763
|
|
|
720
764
|
project_id = task.project_id
|
|
765
|
+
|
|
766
|
+
audit.log_event(
|
|
767
|
+
action="delete",
|
|
768
|
+
resource_type="task",
|
|
769
|
+
resource_id=str(task_id),
|
|
770
|
+
details={"project_id": project_id, "title": task.title},
|
|
771
|
+
ip_address=request.client.host if request.client else None,
|
|
772
|
+
)
|
|
773
|
+
|
|
721
774
|
await db.delete(task)
|
|
722
775
|
|
|
723
776
|
# Broadcast update
|
|
@@ -890,12 +943,19 @@ async def get_registered_project(identifier: str):
|
|
|
890
943
|
return project
|
|
891
944
|
|
|
892
945
|
|
|
893
|
-
@app.delete("/api/registry/projects/{identifier}", status_code=204)
|
|
894
|
-
async def unregister_project(identifier: str):
|
|
946
|
+
@app.delete("/api/registry/projects/{identifier}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
947
|
+
async def unregister_project(identifier: str, request: Request):
|
|
895
948
|
"""Remove a project from the registry."""
|
|
896
949
|
if not registry.unregister_project(identifier):
|
|
897
950
|
raise HTTPException(status_code=404, detail="Project not found in registry")
|
|
898
951
|
|
|
952
|
+
audit.log_event(
|
|
953
|
+
action="delete",
|
|
954
|
+
resource_type="registry_project",
|
|
955
|
+
resource_id=identifier,
|
|
956
|
+
ip_address=request.client.host if request.client else None,
|
|
957
|
+
)
|
|
958
|
+
|
|
899
959
|
|
|
900
960
|
@app.get("/api/registry/projects/{identifier}/health", response_model=HealthResponse)
|
|
901
961
|
async def get_project_health(identifier: str):
|
|
@@ -958,8 +1018,24 @@ async def get_enterprise_status():
|
|
|
958
1018
|
"""Check which enterprise features are enabled."""
|
|
959
1019
|
return {
|
|
960
1020
|
"auth_enabled": auth.is_enterprise_mode(),
|
|
1021
|
+
"oidc_enabled": auth.is_oidc_mode(),
|
|
961
1022
|
"audit_enabled": audit.is_audit_enabled(),
|
|
962
|
-
"enterprise_mode": auth.is_enterprise_mode() or audit.is_audit_enabled(),
|
|
1023
|
+
"enterprise_mode": auth.is_enterprise_mode() or auth.is_oidc_mode() or audit.is_audit_enabled(),
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
@app.get("/api/auth/info")
|
|
1028
|
+
async def get_auth_info():
|
|
1029
|
+
"""Get authentication configuration info (public endpoint).
|
|
1030
|
+
|
|
1031
|
+
Returns which auth methods are available so clients can determine
|
|
1032
|
+
how to authenticate (token-based, OIDC/SSO, or anonymous).
|
|
1033
|
+
"""
|
|
1034
|
+
return {
|
|
1035
|
+
"token_auth_enabled": auth.ENTERPRISE_AUTH_ENABLED,
|
|
1036
|
+
"oidc_enabled": auth.OIDC_ENABLED,
|
|
1037
|
+
"oidc_issuer": auth.OIDC_ISSUER if auth.OIDC_ENABLED else None,
|
|
1038
|
+
"oidc_client_id": auth.OIDC_CLIENT_ID if auth.OIDC_ENABLED else None,
|
|
963
1039
|
}
|
|
964
1040
|
|
|
965
1041
|
|
|
@@ -1028,7 +1104,7 @@ async def list_tokens(include_revoked: bool = False):
|
|
|
1028
1104
|
return auth.list_tokens(include_revoked=include_revoked)
|
|
1029
1105
|
|
|
1030
1106
|
|
|
1031
|
-
@app.delete("/api/enterprise/tokens/{identifier}")
|
|
1107
|
+
@app.delete("/api/enterprise/tokens/{identifier}", dependencies=[Depends(auth.require_scope("admin"))])
|
|
1032
1108
|
async def revoke_token(identifier: str, permanent: bool = False):
|
|
1033
1109
|
"""
|
|
1034
1110
|
Revoke or delete a token (enterprise only).
|
|
@@ -1063,7 +1139,7 @@ async def revoke_token(identifier: str, permanent: bool = False):
|
|
|
1063
1139
|
return {"status": "ok", "action": action, "identifier": identifier}
|
|
1064
1140
|
|
|
1065
1141
|
|
|
1066
|
-
# Audit log endpoints (
|
|
1142
|
+
# Audit log endpoints (enabled by default, disable with LOKI_AUDIT_DISABLED=true)
|
|
1067
1143
|
class AuditQueryParams(BaseModel):
|
|
1068
1144
|
"""Query parameters for audit logs."""
|
|
1069
1145
|
start_date: Optional[str] = None
|
|
@@ -1095,7 +1171,7 @@ async def query_audit_logs(
|
|
|
1095
1171
|
if not audit.is_audit_enabled():
|
|
1096
1172
|
raise HTTPException(
|
|
1097
1173
|
status_code=403,
|
|
1098
|
-
detail="
|
|
1174
|
+
detail="Audit logging is disabled. Remove LOKI_AUDIT_DISABLED or set LOKI_ENTERPRISE_AUDIT=true"
|
|
1099
1175
|
)
|
|
1100
1176
|
|
|
1101
1177
|
return audit.query_logs(
|
|
@@ -1111,11 +1187,11 @@ async def query_audit_logs(
|
|
|
1111
1187
|
|
|
1112
1188
|
@app.get("/api/enterprise/audit/summary")
|
|
1113
1189
|
async def get_audit_summary(days: int = 7):
|
|
1114
|
-
"""Get audit activity summary
|
|
1190
|
+
"""Get audit activity summary."""
|
|
1115
1191
|
if not audit.is_audit_enabled():
|
|
1116
1192
|
raise HTTPException(
|
|
1117
1193
|
status_code=403,
|
|
1118
|
-
detail="
|
|
1194
|
+
detail="Audit logging is disabled. Remove LOKI_AUDIT_DISABLED or set LOKI_ENTERPRISE_AUDIT=true"
|
|
1119
1195
|
)
|
|
1120
1196
|
|
|
1121
1197
|
return audit.get_audit_summary(days=days)
|
|
@@ -1606,18 +1682,22 @@ def _read_events(time_range: str = "7d") -> list:
|
|
|
1606
1682
|
|
|
1607
1683
|
|
|
1608
1684
|
# Session control endpoints (proxy to control.py functions)
|
|
1609
|
-
@app.post("/api/control/pause")
|
|
1685
|
+
@app.post("/api/control/pause", dependencies=[Depends(auth.require_scope("control"))])
|
|
1610
1686
|
async def pause_session():
|
|
1611
1687
|
"""Pause the current session by creating PAUSE file."""
|
|
1688
|
+
if not _control_limiter.check("control"):
|
|
1689
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1612
1690
|
pause_file = _get_loki_dir() / "PAUSE"
|
|
1613
1691
|
pause_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1614
1692
|
pause_file.write_text(datetime.now(timezone.utc).isoformat())
|
|
1615
1693
|
return {"success": True, "message": "Session paused"}
|
|
1616
1694
|
|
|
1617
1695
|
|
|
1618
|
-
@app.post("/api/control/resume")
|
|
1696
|
+
@app.post("/api/control/resume", dependencies=[Depends(auth.require_scope("control"))])
|
|
1619
1697
|
async def resume_session():
|
|
1620
1698
|
"""Resume a paused session by removing PAUSE/STOP files."""
|
|
1699
|
+
if not _control_limiter.check("control"):
|
|
1700
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1621
1701
|
for fname in ["PAUSE", "STOP"]:
|
|
1622
1702
|
fpath = _get_loki_dir() / fname
|
|
1623
1703
|
try:
|
|
@@ -1627,9 +1707,19 @@ async def resume_session():
|
|
|
1627
1707
|
return {"success": True, "message": "Session resumed"}
|
|
1628
1708
|
|
|
1629
1709
|
|
|
1630
|
-
@app.post("/api/control/stop")
|
|
1631
|
-
async def stop_session():
|
|
1710
|
+
@app.post("/api/control/stop", dependencies=[Depends(auth.require_scope("control"))])
|
|
1711
|
+
async def stop_session(request: Request):
|
|
1632
1712
|
"""Stop the session by creating STOP file and sending SIGTERM."""
|
|
1713
|
+
if not _control_limiter.check("control"):
|
|
1714
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1715
|
+
|
|
1716
|
+
audit.log_event(
|
|
1717
|
+
action="stop",
|
|
1718
|
+
resource_type="session",
|
|
1719
|
+
details={"source": "api"},
|
|
1720
|
+
ip_address=request.client.host if request.client else None,
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1633
1723
|
stop_file = _get_loki_dir() / "STOP"
|
|
1634
1724
|
stop_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1635
1725
|
stop_file.write_text(datetime.now(timezone.utc).isoformat())
|
|
@@ -1811,6 +1901,62 @@ async def get_cost():
|
|
|
1811
1901
|
}
|
|
1812
1902
|
|
|
1813
1903
|
|
|
1904
|
+
@app.get("/api/budget")
|
|
1905
|
+
async def get_budget():
|
|
1906
|
+
"""Get current budget status from .loki/metrics/budget.json and cost data."""
|
|
1907
|
+
loki_dir = _get_loki_dir()
|
|
1908
|
+
budget_file = loki_dir / "metrics" / "budget.json"
|
|
1909
|
+
signals_dir = loki_dir / "signals"
|
|
1910
|
+
|
|
1911
|
+
# Read budget configuration
|
|
1912
|
+
budget_limit = None
|
|
1913
|
+
budget_used = 0.0
|
|
1914
|
+
exceeded = False
|
|
1915
|
+
exceeded_at = None
|
|
1916
|
+
|
|
1917
|
+
if budget_file.exists():
|
|
1918
|
+
try:
|
|
1919
|
+
budget_data = json.loads(budget_file.read_text())
|
|
1920
|
+
budget_limit = budget_data.get("limit") or budget_data.get("budget_limit")
|
|
1921
|
+
budget_used = budget_data.get("budget_used", 0.0)
|
|
1922
|
+
exceeded = budget_data.get("exceeded", False)
|
|
1923
|
+
exceeded_at = budget_data.get("exceeded_at")
|
|
1924
|
+
except (json.JSONDecodeError, KeyError):
|
|
1925
|
+
pass
|
|
1926
|
+
|
|
1927
|
+
# Also check env var for limit if not in file
|
|
1928
|
+
if budget_limit is None:
|
|
1929
|
+
env_limit = os.environ.get("LOKI_BUDGET_LIMIT", "")
|
|
1930
|
+
if env_limit:
|
|
1931
|
+
try:
|
|
1932
|
+
budget_limit = float(env_limit)
|
|
1933
|
+
except ValueError:
|
|
1934
|
+
pass
|
|
1935
|
+
|
|
1936
|
+
# Check for budget exceeded signal
|
|
1937
|
+
signal_file = signals_dir / "BUDGET_EXCEEDED"
|
|
1938
|
+
if signal_file.exists():
|
|
1939
|
+
exceeded = True
|
|
1940
|
+
if exceeded_at is None:
|
|
1941
|
+
try:
|
|
1942
|
+
sig_data = json.loads(signal_file.read_text())
|
|
1943
|
+
exceeded_at = sig_data.get("timestamp")
|
|
1944
|
+
except (json.JSONDecodeError, KeyError):
|
|
1945
|
+
pass
|
|
1946
|
+
|
|
1947
|
+
remaining = None
|
|
1948
|
+
if budget_limit is not None:
|
|
1949
|
+
remaining = max(0.0, float(budget_limit) - float(budget_used))
|
|
1950
|
+
|
|
1951
|
+
return {
|
|
1952
|
+
"budget_limit": float(budget_limit) if budget_limit is not None else None,
|
|
1953
|
+
"current_cost": round(float(budget_used), 4),
|
|
1954
|
+
"exceeded": exceeded,
|
|
1955
|
+
"exceeded_at": exceeded_at,
|
|
1956
|
+
"remaining": round(remaining, 4) if remaining is not None else None,
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
|
|
1814
1960
|
# =============================================================================
|
|
1815
1961
|
# Pricing API
|
|
1816
1962
|
# =============================================================================
|
|
@@ -2132,7 +2278,7 @@ async def create_checkpoint(body: CheckpointCreate = None):
|
|
|
2132
2278
|
# =============================================================================
|
|
2133
2279
|
|
|
2134
2280
|
@app.get("/api/agents")
|
|
2135
|
-
async def get_agents():
|
|
2281
|
+
async def get_agents(token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2136
2282
|
"""Get all active and recent agents."""
|
|
2137
2283
|
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
2138
2284
|
agents = []
|
|
@@ -2187,10 +2333,20 @@ async def get_agents():
|
|
|
2187
2333
|
return agents
|
|
2188
2334
|
|
|
2189
2335
|
|
|
2190
|
-
@app.post("/api/agents/{agent_id}/kill")
|
|
2191
|
-
async def kill_agent(agent_id: str):
|
|
2336
|
+
@app.post("/api/agents/{agent_id}/kill", dependencies=[Depends(auth.require_scope("control"))])
|
|
2337
|
+
async def kill_agent(agent_id: str, request: Request):
|
|
2192
2338
|
"""Kill a specific agent by ID."""
|
|
2339
|
+
if not _control_limiter.check("control"):
|
|
2340
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2193
2341
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2342
|
+
|
|
2343
|
+
audit.log_event(
|
|
2344
|
+
action="kill",
|
|
2345
|
+
resource_type="agent",
|
|
2346
|
+
resource_id=agent_id,
|
|
2347
|
+
details={"source": "api"},
|
|
2348
|
+
ip_address=request.client.host if request.client else None,
|
|
2349
|
+
)
|
|
2194
2350
|
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
2195
2351
|
if not agents_file.exists():
|
|
2196
2352
|
raise HTTPException(404, "No agents file found")
|
|
@@ -2234,9 +2390,11 @@ async def kill_agent(agent_id: str):
|
|
|
2234
2390
|
)
|
|
2235
2391
|
|
|
2236
2392
|
|
|
2237
|
-
@app.post("/api/agents/{agent_id}/pause")
|
|
2393
|
+
@app.post("/api/agents/{agent_id}/pause", dependencies=[Depends(auth.require_scope("control"))])
|
|
2238
2394
|
async def pause_agent(agent_id: str):
|
|
2239
2395
|
"""Pause a specific agent by writing a pause signal."""
|
|
2396
|
+
if not _control_limiter.check("control"):
|
|
2397
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2240
2398
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2241
2399
|
signal_dir = _get_loki_dir() / "signals"
|
|
2242
2400
|
signal_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -2246,9 +2404,11 @@ async def pause_agent(agent_id: str):
|
|
|
2246
2404
|
return {"success": True, "message": f"Pause signal sent to agent {agent_id}"}
|
|
2247
2405
|
|
|
2248
2406
|
|
|
2249
|
-
@app.post("/api/agents/{agent_id}/resume")
|
|
2407
|
+
@app.post("/api/agents/{agent_id}/resume", dependencies=[Depends(auth.require_scope("control"))])
|
|
2250
2408
|
async def resume_agent(agent_id: str):
|
|
2251
2409
|
"""Resume a paused agent."""
|
|
2410
|
+
if not _control_limiter.check("control"):
|
|
2411
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2252
2412
|
agent_id = _sanitize_agent_id(agent_id)
|
|
2253
2413
|
signal_file = _get_loki_dir() / "signals" / f"PAUSE_AGENT_{agent_id}"
|
|
2254
2414
|
try:
|
|
@@ -2259,7 +2419,7 @@ async def resume_agent(agent_id: str):
|
|
|
2259
2419
|
|
|
2260
2420
|
|
|
2261
2421
|
@app.get("/api/logs")
|
|
2262
|
-
async def get_logs(lines: int = 100):
|
|
2422
|
+
async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2263
2423
|
"""Get recent log entries from session log files."""
|
|
2264
2424
|
log_dir = _get_loki_dir() / "logs"
|
|
2265
2425
|
entries = []
|
|
@@ -2340,6 +2500,90 @@ except ImportError as e:
|
|
|
2340
2500
|
logger.debug(f"Collaboration module not available: {e}")
|
|
2341
2501
|
|
|
2342
2502
|
|
|
2503
|
+
# =============================================================================
|
|
2504
|
+
# Secrets / Credential Status
|
|
2505
|
+
# =============================================================================
|
|
2506
|
+
|
|
2507
|
+
@app.get("/api/secrets/status", dependencies=[Depends(auth.require_scope("admin"))])
|
|
2508
|
+
async def get_secrets_status():
|
|
2509
|
+
"""Get API key status (masked, validation, source). Admin only."""
|
|
2510
|
+
result = secrets_mod.load_secrets()
|
|
2511
|
+
rotated = secrets_mod.check_rotation(
|
|
2512
|
+
str(_get_loki_dir() / "state" / "key-fingerprints.json")
|
|
2513
|
+
)
|
|
2514
|
+
return {
|
|
2515
|
+
"keys": result,
|
|
2516
|
+
"rotated_since_last_check": rotated,
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
|
|
2520
|
+
# =============================================================================
|
|
2521
|
+
# Process Health / Watchdog API
|
|
2522
|
+
# =============================================================================
|
|
2523
|
+
|
|
2524
|
+
|
|
2525
|
+
@app.get("/api/health/processes")
|
|
2526
|
+
async def get_process_health(token: Optional[dict] = Depends(auth.get_current_token)):
|
|
2527
|
+
"""Get health status of all loki processes (dashboard, session, agents)."""
|
|
2528
|
+
result: dict[str, Any] = {"dashboard": None, "session": None, "agents": []}
|
|
2529
|
+
|
|
2530
|
+
loki_dir = _get_loki_dir()
|
|
2531
|
+
|
|
2532
|
+
# Dashboard PID
|
|
2533
|
+
dpid_file = loki_dir / "dashboard" / "dashboard.pid"
|
|
2534
|
+
if dpid_file.exists():
|
|
2535
|
+
try:
|
|
2536
|
+
dpid = int(dpid_file.read_text().strip())
|
|
2537
|
+
try:
|
|
2538
|
+
os.kill(dpid, 0)
|
|
2539
|
+
result["dashboard"] = {"pid": dpid, "status": "alive"}
|
|
2540
|
+
except OSError:
|
|
2541
|
+
result["dashboard"] = {"pid": dpid, "status": "dead"}
|
|
2542
|
+
except (ValueError, OSError):
|
|
2543
|
+
pass
|
|
2544
|
+
|
|
2545
|
+
# Session PID
|
|
2546
|
+
spid_file = loki_dir / "loki.pid"
|
|
2547
|
+
if spid_file.exists():
|
|
2548
|
+
try:
|
|
2549
|
+
spid = int(spid_file.read_text().strip())
|
|
2550
|
+
try:
|
|
2551
|
+
os.kill(spid, 0)
|
|
2552
|
+
result["session"] = {"pid": spid, "status": "alive"}
|
|
2553
|
+
except OSError:
|
|
2554
|
+
result["session"] = {"pid": spid, "status": "dead"}
|
|
2555
|
+
except (ValueError, OSError):
|
|
2556
|
+
pass
|
|
2557
|
+
|
|
2558
|
+
# Agent PIDs
|
|
2559
|
+
agents_file = loki_dir / "state" / "agents.json"
|
|
2560
|
+
if agents_file.exists():
|
|
2561
|
+
try:
|
|
2562
|
+
agents = json.loads(agents_file.read_text())
|
|
2563
|
+
for agent in agents:
|
|
2564
|
+
pid = agent.get("pid")
|
|
2565
|
+
status = "unknown"
|
|
2566
|
+
if pid:
|
|
2567
|
+
try:
|
|
2568
|
+
os.kill(int(pid), 0)
|
|
2569
|
+
status = "alive"
|
|
2570
|
+
except (OSError, ValueError):
|
|
2571
|
+
status = "dead"
|
|
2572
|
+
result["agents"].append({
|
|
2573
|
+
"id": agent.get("id", ""),
|
|
2574
|
+
"name": agent.get("name", ""),
|
|
2575
|
+
"pid": pid,
|
|
2576
|
+
"status": status,
|
|
2577
|
+
})
|
|
2578
|
+
except Exception:
|
|
2579
|
+
pass
|
|
2580
|
+
|
|
2581
|
+
watchdog_enabled = os.environ.get("LOKI_WATCHDOG", "false").lower() == "true"
|
|
2582
|
+
result["watchdog_enabled"] = watchdog_enabled
|
|
2583
|
+
|
|
2584
|
+
return result
|
|
2585
|
+
|
|
2586
|
+
|
|
2343
2587
|
# =============================================================================
|
|
2344
2588
|
# Static File Serving (Production/Docker)
|
|
2345
2589
|
# =============================================================================
|
|
@@ -2445,7 +2689,20 @@ def run_server(host: str = None, port: int = None) -> None:
|
|
|
2445
2689
|
host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
|
|
2446
2690
|
if port is None:
|
|
2447
2691
|
port = int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
|
|
2448
|
-
|
|
2692
|
+
|
|
2693
|
+
uvicorn_kwargs = {
|
|
2694
|
+
"host": host,
|
|
2695
|
+
"port": port,
|
|
2696
|
+
"log_level": "info",
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
# Enable TLS if both cert and key are provided
|
|
2700
|
+
if LOKI_TLS_CERT and LOKI_TLS_KEY:
|
|
2701
|
+
uvicorn_kwargs["ssl_certfile"] = LOKI_TLS_CERT
|
|
2702
|
+
uvicorn_kwargs["ssl_keyfile"] = LOKI_TLS_KEY
|
|
2703
|
+
logger.info("TLS enabled: cert=%s key=%s", LOKI_TLS_CERT, LOKI_TLS_KEY)
|
|
2704
|
+
|
|
2705
|
+
uvicorn.run(app, **uvicorn_kwargs)
|
|
2449
2706
|
|
|
2450
2707
|
|
|
2451
2708
|
if __name__ == "__main__":
|
package/docs/INSTALLATION.md
CHANGED