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.
@@ -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="${LOKI_API_PORT:-9898}"
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 "echo '$message' > ${PROJECT_DIR}/.loki/HUMAN_INPUT.md"
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:9898"
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: 8420)
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
- # LOKI_API_PORT Port (default: 8420)
20
- # LOKI_API_HOST Host (default: localhost)
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="${LOKI_API_PORT:-8420}"
32
- HOST="${LOKI_API_HOST:-localhost}"
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: 8420)
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
- LOKI_API_PORT Port (overridden by --port)
75
- LOKI_API_HOST Host (overridden by --host)
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:8420)
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:8420/health
92
+ curl -H "Authorization: Bearer \$TOKEN" http://server:57374/health
93
93
 
94
94
  EOF
95
95
  }
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "5.34.0"
10
+ __version__ = "5.36.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
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
- return hashlib.sha256(token.encode()).hexdigest()
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>=0.109.0
3
- uvicorn[standard]>=0.27.0
4
- sqlalchemy>=2.0.0
5
- aiosqlite>=0.19.0
6
- greenlet>=3.0.0
7
- pydantic>=2.0.0
8
- websockets>=12.0
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
@@ -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 contain only alphanumeric characters, hyphens, and underscores",
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 contain only alphanumeric characters, hyphens, and underscores",
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()