loki-mode 5.36.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 +310 -6
- package/autonomy/serve.sh +25 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +9 -5
- package/dashboard/auth.py +171 -17
- package/dashboard/secrets.py +152 -0
- package/dashboard/server.py +229 -9
- package/docs/INSTALLATION.md +1 -1
- package/package.json +1 -1
package/dashboard/server.py
CHANGED
|
@@ -21,6 +21,7 @@ from fastapi import (
|
|
|
21
21
|
FastAPI,
|
|
22
22
|
HTTPException,
|
|
23
23
|
Query,
|
|
24
|
+
Request,
|
|
24
25
|
WebSocket,
|
|
25
26
|
WebSocketDisconnect,
|
|
26
27
|
)
|
|
@@ -44,6 +45,14 @@ from .models import (
|
|
|
44
45
|
from . import registry
|
|
45
46
|
from . import auth
|
|
46
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
|
|
47
56
|
|
|
48
57
|
# ---------------------------------------------------------------------------
|
|
49
58
|
# Simple in-memory rate limiter for control endpoints
|
|
@@ -514,6 +523,7 @@ async def update_project(
|
|
|
514
523
|
@app.delete("/api/projects/{project_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
515
524
|
async def delete_project(
|
|
516
525
|
project_id: int,
|
|
526
|
+
request: Request,
|
|
517
527
|
db: AsyncSession = Depends(get_db),
|
|
518
528
|
) -> None:
|
|
519
529
|
"""Delete a project."""
|
|
@@ -525,6 +535,14 @@ async def delete_project(
|
|
|
525
535
|
if not project:
|
|
526
536
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
527
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
|
+
|
|
528
546
|
await db.delete(project)
|
|
529
547
|
|
|
530
548
|
# Broadcast update
|
|
@@ -731,6 +749,7 @@ async def update_task(
|
|
|
731
749
|
@app.delete("/api/tasks/{task_id}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
732
750
|
async def delete_task(
|
|
733
751
|
task_id: int,
|
|
752
|
+
request: Request,
|
|
734
753
|
db: AsyncSession = Depends(get_db),
|
|
735
754
|
) -> None:
|
|
736
755
|
"""Delete a task."""
|
|
@@ -743,6 +762,15 @@ async def delete_task(
|
|
|
743
762
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
744
763
|
|
|
745
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
|
+
|
|
746
774
|
await db.delete(task)
|
|
747
775
|
|
|
748
776
|
# Broadcast update
|
|
@@ -916,11 +944,18 @@ async def get_registered_project(identifier: str):
|
|
|
916
944
|
|
|
917
945
|
|
|
918
946
|
@app.delete("/api/registry/projects/{identifier}", status_code=204, dependencies=[Depends(auth.require_scope("control"))])
|
|
919
|
-
async def unregister_project(identifier: str):
|
|
947
|
+
async def unregister_project(identifier: str, request: Request):
|
|
920
948
|
"""Remove a project from the registry."""
|
|
921
949
|
if not registry.unregister_project(identifier):
|
|
922
950
|
raise HTTPException(status_code=404, detail="Project not found in registry")
|
|
923
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
|
+
|
|
924
959
|
|
|
925
960
|
@app.get("/api/registry/projects/{identifier}/health", response_model=HealthResponse)
|
|
926
961
|
async def get_project_health(identifier: str):
|
|
@@ -983,8 +1018,24 @@ async def get_enterprise_status():
|
|
|
983
1018
|
"""Check which enterprise features are enabled."""
|
|
984
1019
|
return {
|
|
985
1020
|
"auth_enabled": auth.is_enterprise_mode(),
|
|
1021
|
+
"oidc_enabled": auth.is_oidc_mode(),
|
|
986
1022
|
"audit_enabled": audit.is_audit_enabled(),
|
|
987
|
-
"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,
|
|
988
1039
|
}
|
|
989
1040
|
|
|
990
1041
|
|
|
@@ -1088,7 +1139,7 @@ async def revoke_token(identifier: str, permanent: bool = False):
|
|
|
1088
1139
|
return {"status": "ok", "action": action, "identifier": identifier}
|
|
1089
1140
|
|
|
1090
1141
|
|
|
1091
|
-
# Audit log endpoints (
|
|
1142
|
+
# Audit log endpoints (enabled by default, disable with LOKI_AUDIT_DISABLED=true)
|
|
1092
1143
|
class AuditQueryParams(BaseModel):
|
|
1093
1144
|
"""Query parameters for audit logs."""
|
|
1094
1145
|
start_date: Optional[str] = None
|
|
@@ -1120,7 +1171,7 @@ async def query_audit_logs(
|
|
|
1120
1171
|
if not audit.is_audit_enabled():
|
|
1121
1172
|
raise HTTPException(
|
|
1122
1173
|
status_code=403,
|
|
1123
|
-
detail="
|
|
1174
|
+
detail="Audit logging is disabled. Remove LOKI_AUDIT_DISABLED or set LOKI_ENTERPRISE_AUDIT=true"
|
|
1124
1175
|
)
|
|
1125
1176
|
|
|
1126
1177
|
return audit.query_logs(
|
|
@@ -1136,11 +1187,11 @@ async def query_audit_logs(
|
|
|
1136
1187
|
|
|
1137
1188
|
@app.get("/api/enterprise/audit/summary")
|
|
1138
1189
|
async def get_audit_summary(days: int = 7):
|
|
1139
|
-
"""Get audit activity summary
|
|
1190
|
+
"""Get audit activity summary."""
|
|
1140
1191
|
if not audit.is_audit_enabled():
|
|
1141
1192
|
raise HTTPException(
|
|
1142
1193
|
status_code=403,
|
|
1143
|
-
detail="
|
|
1194
|
+
detail="Audit logging is disabled. Remove LOKI_AUDIT_DISABLED or set LOKI_ENTERPRISE_AUDIT=true"
|
|
1144
1195
|
)
|
|
1145
1196
|
|
|
1146
1197
|
return audit.get_audit_summary(days=days)
|
|
@@ -1657,10 +1708,18 @@ async def resume_session():
|
|
|
1657
1708
|
|
|
1658
1709
|
|
|
1659
1710
|
@app.post("/api/control/stop", dependencies=[Depends(auth.require_scope("control"))])
|
|
1660
|
-
async def stop_session():
|
|
1711
|
+
async def stop_session(request: Request):
|
|
1661
1712
|
"""Stop the session by creating STOP file and sending SIGTERM."""
|
|
1662
1713
|
if not _control_limiter.check("control"):
|
|
1663
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
|
+
|
|
1664
1723
|
stop_file = _get_loki_dir() / "STOP"
|
|
1665
1724
|
stop_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1666
1725
|
stop_file.write_text(datetime.now(timezone.utc).isoformat())
|
|
@@ -1842,6 +1901,62 @@ async def get_cost():
|
|
|
1842
1901
|
}
|
|
1843
1902
|
|
|
1844
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
|
+
|
|
1845
1960
|
# =============================================================================
|
|
1846
1961
|
# Pricing API
|
|
1847
1962
|
# =============================================================================
|
|
@@ -2219,11 +2334,19 @@ async def get_agents(token: Optional[dict] = Depends(auth.get_current_token)):
|
|
|
2219
2334
|
|
|
2220
2335
|
|
|
2221
2336
|
@app.post("/api/agents/{agent_id}/kill", dependencies=[Depends(auth.require_scope("control"))])
|
|
2222
|
-
async def kill_agent(agent_id: str):
|
|
2337
|
+
async def kill_agent(agent_id: str, request: Request):
|
|
2223
2338
|
"""Kill a specific agent by ID."""
|
|
2224
2339
|
if not _control_limiter.check("control"):
|
|
2225
2340
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2226
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
|
+
)
|
|
2227
2350
|
agents_file = _get_loki_dir() / "state" / "agents.json"
|
|
2228
2351
|
if not agents_file.exists():
|
|
2229
2352
|
raise HTTPException(404, "No agents file found")
|
|
@@ -2377,6 +2500,90 @@ except ImportError as e:
|
|
|
2377
2500
|
logger.debug(f"Collaboration module not available: {e}")
|
|
2378
2501
|
|
|
2379
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
|
+
|
|
2380
2587
|
# =============================================================================
|
|
2381
2588
|
# Static File Serving (Production/Docker)
|
|
2382
2589
|
# =============================================================================
|
|
@@ -2482,7 +2689,20 @@ def run_server(host: str = None, port: int = None) -> None:
|
|
|
2482
2689
|
host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
|
|
2483
2690
|
if port is None:
|
|
2484
2691
|
port = int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
|
|
2485
|
-
|
|
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)
|
|
2486
2706
|
|
|
2487
2707
|
|
|
2488
2708
|
if __name__ == "__main__":
|
package/docs/INSTALLATION.md
CHANGED