loki-mode 6.36.6 → 6.37.1
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 +27 -0
- package/autonomy/run.sh +17 -6
- package/dashboard/__init__.py +1 -1
- package/dashboard/auth.py +61 -11
- package/dashboard/control.py +7 -4
- package/dashboard/server.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/events/bus.py +22 -2
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +28 -8
- package/memory/namespace.py +12 -0
- package/memory/storage.py +31 -4
- package/memory/token_economics.py +4 -0
- package/package.json +1 -1
- package/providers/codex.sh +5 -1
- package/web-app/server.py +31 -14
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v6.
|
|
6
|
+
# Loki Mode v6.37.1
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
|
|
|
267
267
|
| Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
|
|
268
268
|
| Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
|
|
269
269
|
|
|
270
|
-
**v6.
|
|
270
|
+
**v6.37.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.
|
|
1
|
+
6.37.1
|
package/autonomy/loki
CHANGED
|
@@ -2635,6 +2635,21 @@ cmd_dashboard_start() {
|
|
|
2635
2635
|
echo "$host" > "${DASHBOARD_PID_DIR}/host"
|
|
2636
2636
|
echo "$url_scheme" > "${DASHBOARD_PID_DIR}/scheme"
|
|
2637
2637
|
|
|
2638
|
+
# Wait for PID file to be written (up to 10 seconds)
|
|
2639
|
+
# Fixes race condition where PID file may not exist yet when checked
|
|
2640
|
+
local pid_wait_retries=20
|
|
2641
|
+
local pid_wait_interval=0.5
|
|
2642
|
+
while [[ $pid_wait_retries -gt 0 ]]; do
|
|
2643
|
+
if [[ -f "$DASHBOARD_PID_FILE" ]]; then
|
|
2644
|
+
break
|
|
2645
|
+
fi
|
|
2646
|
+
sleep "$pid_wait_interval"
|
|
2647
|
+
pid_wait_retries=$((pid_wait_retries - 1))
|
|
2648
|
+
done
|
|
2649
|
+
if [[ ! -f "$DASHBOARD_PID_FILE" ]]; then
|
|
2650
|
+
echo -e "${YELLOW}Warning: Dashboard PID file not written within timeout${NC}"
|
|
2651
|
+
fi
|
|
2652
|
+
|
|
2638
2653
|
# Wait a moment and check if process is still running
|
|
2639
2654
|
sleep 1
|
|
2640
2655
|
|
|
@@ -3002,6 +3017,16 @@ cmd_web_start() {
|
|
|
3002
3017
|
esac
|
|
3003
3018
|
done
|
|
3004
3019
|
|
|
3020
|
+
# Validate port: must be numeric and in valid range
|
|
3021
|
+
if [[ ! "$port" =~ ^[0-9]+$ ]]; then
|
|
3022
|
+
echo -e "${RED}Error: Port must be a number, got '$port'${NC}"
|
|
3023
|
+
exit 1
|
|
3024
|
+
fi
|
|
3025
|
+
if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
|
3026
|
+
echo -e "${RED}Error: Port must be between 1 and 65535, got $port${NC}"
|
|
3027
|
+
exit 1
|
|
3028
|
+
fi
|
|
3029
|
+
|
|
3005
3030
|
# Validate --prd file if provided
|
|
3006
3031
|
if [ -n "$prd_file" ]; then
|
|
3007
3032
|
if [ ! -f "$prd_file" ]; then
|
|
@@ -4624,6 +4649,7 @@ cmd_export() {
|
|
|
4624
4649
|
exit 0
|
|
4625
4650
|
;;
|
|
4626
4651
|
json)
|
|
4652
|
+
require_jq || return 1
|
|
4627
4653
|
_export_json "$output_path"
|
|
4628
4654
|
;;
|
|
4629
4655
|
markdown|md)
|
|
@@ -4633,6 +4659,7 @@ cmd_export() {
|
|
|
4633
4659
|
_export_csv "$output_path"
|
|
4634
4660
|
;;
|
|
4635
4661
|
timeline)
|
|
4662
|
+
require_jq || return 1
|
|
4636
4663
|
_export_timeline "$output_path"
|
|
4637
4664
|
;;
|
|
4638
4665
|
*)
|
package/autonomy/run.sh
CHANGED
|
@@ -5887,14 +5887,20 @@ run_adversarial_testing() {
|
|
|
5887
5887
|
echo "$diff_content" > "$diff_file"
|
|
5888
5888
|
echo "$changed_files" > "$files_file"
|
|
5889
5889
|
|
|
5890
|
-
# Build adversarial prompt
|
|
5891
|
-
|
|
5890
|
+
# Build adversarial prompt -- use heredoc with quoted delimiter to prevent
|
|
5891
|
+
# shell variable expansion in diff content (fixes #78)
|
|
5892
|
+
local files_content changed_content
|
|
5893
|
+
files_content=$(cat "$files_file")
|
|
5894
|
+
changed_content=$(head -500 "$diff_file")
|
|
5895
|
+
local adversarial_prompt
|
|
5896
|
+
read -r -d '' adversarial_prompt <<'ADVERSARIAL_EOF' || true
|
|
5897
|
+
You are an ADVERSARIAL TESTER. Your goal is to BREAK the implementation.
|
|
5892
5898
|
|
|
5893
5899
|
CHANGED FILES:
|
|
5894
|
-
|
|
5900
|
+
__FILES_PLACEHOLDER__
|
|
5895
5901
|
|
|
5896
5902
|
DIFF:
|
|
5897
|
-
|
|
5903
|
+
__DIFF_PLACEHOLDER__
|
|
5898
5904
|
|
|
5899
5905
|
YOUR MISSION:
|
|
5900
5906
|
1. Find edge cases that will cause crashes or incorrect behavior
|
|
@@ -5913,7 +5919,11 @@ ATTACK_VECTORS:
|
|
|
5913
5919
|
SUGGESTED_TESTS:
|
|
5914
5920
|
- Test description that would catch this issue
|
|
5915
5921
|
|
|
5916
|
-
OVERALL_RISK: HIGH or MEDIUM or LOW
|
|
5922
|
+
OVERALL_RISK: HIGH or MEDIUM or LOW
|
|
5923
|
+
ADVERSARIAL_EOF
|
|
5924
|
+
# Substitute placeholders with actual content (safe from shell expansion)
|
|
5925
|
+
adversarial_prompt="${adversarial_prompt/__FILES_PLACEHOLDER__/$files_content}"
|
|
5926
|
+
adversarial_prompt="${adversarial_prompt/__DIFF_PLACEHOLDER__/$changed_content}"
|
|
5917
5927
|
|
|
5918
5928
|
local result_file="$adversarial_dir/$test_id/result.txt"
|
|
5919
5929
|
|
|
@@ -8862,7 +8872,8 @@ if __name__ == "__main__":
|
|
|
8862
8872
|
# Uses positional prompt after exec subcommand
|
|
8863
8873
|
# Note: Effort is set via env var, not CLI flag
|
|
8864
8874
|
# Uses dynamic tier from RARV phase (tier_param already set above)
|
|
8865
|
-
{
|
|
8875
|
+
{ LOKI_CODEX_REASONING_EFFORT="$tier_param" \
|
|
8876
|
+
CODEX_MODEL_REASONING_EFFORT="$tier_param" \
|
|
8866
8877
|
codex exec --full-auto \
|
|
8867
8878
|
"$prompt" 2>&1 | tee -a "$log_file" "$agent_log"; \
|
|
8868
8879
|
} && exit_code=0 || exit_code=$?
|
package/dashboard/__init__.py
CHANGED
package/dashboard/auth.py
CHANGED
|
@@ -468,17 +468,69 @@ def validate_oidc_token(token_str: str) -> Optional[dict]:
|
|
|
468
468
|
- Audience matches OIDC_AUDIENCE or OIDC_CLIENT_ID
|
|
469
469
|
- Token is not expired
|
|
470
470
|
|
|
471
|
-
SECURITY
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
471
|
+
SECURITY CRITICAL: Without PyJWT, JWT signatures are NOT cryptographically
|
|
472
|
+
verified. An attacker can forge tokens with arbitrary claims. For any
|
|
473
|
+
production deployment, you MUST install PyJWT + cryptography so that
|
|
474
|
+
this function verifies the RS256/RS384/RS512 signature against the
|
|
475
|
+
provider's JWKS endpoint. Without signature verification, OIDC
|
|
476
|
+
authentication provides ZERO security.
|
|
477
|
+
|
|
478
|
+
For production: pip install PyJWT cryptography
|
|
478
479
|
"""
|
|
479
480
|
if not OIDC_ENABLED:
|
|
480
481
|
return None
|
|
481
482
|
|
|
483
|
+
import logging as _logging
|
|
484
|
+
import sys
|
|
485
|
+
_auth_logger = _logging.getLogger("loki.auth")
|
|
486
|
+
|
|
487
|
+
# -- Attempt PyJWT-based cryptographic verification first --
|
|
488
|
+
# This is the ONLY secure path. Without this, tokens are NOT verified.
|
|
489
|
+
try:
|
|
490
|
+
import jwt as _pyjwt
|
|
491
|
+
from jwt import PyJWKClient
|
|
492
|
+
|
|
493
|
+
jwks_config = _get_oidc_config()
|
|
494
|
+
jwks_uri = jwks_config.get("jwks_uri")
|
|
495
|
+
if not jwks_uri:
|
|
496
|
+
_auth_logger.error("OIDC discovery document missing jwks_uri -- cannot verify token")
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
jwks_client = PyJWKClient(jwks_uri)
|
|
500
|
+
signing_key = jwks_client.get_signing_key_from_jwt(token_str)
|
|
501
|
+
|
|
502
|
+
expected_aud = OIDC_AUDIENCE or OIDC_CLIENT_ID
|
|
503
|
+
decoded = _pyjwt.decode(
|
|
504
|
+
token_str,
|
|
505
|
+
signing_key.key,
|
|
506
|
+
algorithms=["RS256", "RS384", "RS512"],
|
|
507
|
+
audience=expected_aud,
|
|
508
|
+
issuer=OIDC_ISSUER,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
"id": decoded.get("sub", ""),
|
|
513
|
+
"name": decoded.get("name", decoded.get("email", decoded.get("sub", ""))),
|
|
514
|
+
"email": decoded.get("email", ""),
|
|
515
|
+
"scopes": ["*"], # OIDC users get full access
|
|
516
|
+
"auth_method": "oidc",
|
|
517
|
+
"issuer": decoded.get("iss"),
|
|
518
|
+
}
|
|
519
|
+
except ImportError:
|
|
520
|
+
# PyJWT not installed -- fall through to claims-only path with loud warning
|
|
521
|
+
_warning_msg = (
|
|
522
|
+
"WARNING: OIDC JWT signatures are NOT cryptographically verified. "
|
|
523
|
+
"Install PyJWT with 'pip install PyJWT cryptography' for production use."
|
|
524
|
+
)
|
|
525
|
+
_auth_logger.warning(_warning_msg)
|
|
526
|
+
# Also print to stderr so operators notice even without log config
|
|
527
|
+
print(_warning_msg, file=sys.stderr)
|
|
528
|
+
except Exception as exc:
|
|
529
|
+
_auth_logger.error("PyJWT signature verification failed: %s", exc)
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
# -- Fallback: claims-only validation (INSECURE without PyJWT) --
|
|
533
|
+
|
|
482
534
|
try:
|
|
483
535
|
parts = token_str.split(".")
|
|
484
536
|
if len(parts) != 3:
|
|
@@ -487,16 +539,14 @@ def validate_oidc_token(token_str: str) -> Optional[dict]:
|
|
|
487
539
|
# Basic sanity check: signature part must not be empty
|
|
488
540
|
header_b64, payload_b64, signature_b64 = parts
|
|
489
541
|
if not signature_b64 or len(signature_b64) < 10:
|
|
490
|
-
|
|
491
|
-
_logging.getLogger("loki.auth").error(
|
|
542
|
+
_auth_logger.error(
|
|
492
543
|
"OIDC token rejected: signature part is missing or too short"
|
|
493
544
|
)
|
|
494
545
|
return None
|
|
495
546
|
|
|
496
547
|
# CRITICAL: Check if signature verification is explicitly skipped
|
|
497
548
|
if not OIDC_SKIP_SIGNATURE_VERIFY:
|
|
498
|
-
|
|
499
|
-
_logging.getLogger("loki.auth").critical(
|
|
549
|
+
_auth_logger.critical(
|
|
500
550
|
"OIDC token received but signature verification is NOT implemented. "
|
|
501
551
|
"Set LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true to explicitly allow "
|
|
502
552
|
"unverified tokens (INSECURE - local testing only), or install "
|
package/dashboard/control.py
CHANGED
|
@@ -266,7 +266,8 @@ def get_status() -> StatusResponse:
|
|
|
266
266
|
running = True
|
|
267
267
|
else:
|
|
268
268
|
running = True
|
|
269
|
-
except (json.JSONDecodeError, KeyError):
|
|
269
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
270
|
+
# Partial JSON from concurrent write -- treat as unavailable
|
|
270
271
|
pass
|
|
271
272
|
|
|
272
273
|
# Determine state
|
|
@@ -304,7 +305,8 @@ def get_status() -> StatusResponse:
|
|
|
304
305
|
pending_tasks = len(pending)
|
|
305
306
|
elif isinstance(pending, dict):
|
|
306
307
|
pending_tasks = len(pending.get("tasks", []))
|
|
307
|
-
except (json.JSONDecodeError, KeyError):
|
|
308
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
309
|
+
# Partial JSON from concurrent write -- treat as unavailable
|
|
308
310
|
pass
|
|
309
311
|
|
|
310
312
|
# Read provider from state
|
|
@@ -318,7 +320,8 @@ def get_status() -> StatusResponse:
|
|
|
318
320
|
try:
|
|
319
321
|
orch = json.loads(orch_file.read_text())
|
|
320
322
|
current_task = orch.get("currentTask", "")
|
|
321
|
-
except (json.JSONDecodeError, KeyError):
|
|
323
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
324
|
+
# Partial JSON from concurrent write -- treat as unavailable
|
|
322
325
|
pass
|
|
323
326
|
|
|
324
327
|
return StatusResponse(
|
|
@@ -458,7 +461,7 @@ async def stop_session():
|
|
|
458
461
|
|
|
459
462
|
# Atomic write with file locking to prevent race conditions
|
|
460
463
|
atomic_write_json(session_file, session_data, use_lock=True)
|
|
461
|
-
except (json.JSONDecodeError, KeyError, RuntimeError):
|
|
464
|
+
except (json.JSONDecodeError, OSError, KeyError, RuntimeError):
|
|
462
465
|
pass
|
|
463
466
|
|
|
464
467
|
# Emit stop event
|
package/dashboard/server.py
CHANGED
|
@@ -263,7 +263,7 @@ class ConnectionManager:
|
|
|
263
263
|
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
264
264
|
"""Broadcast a message to all connected clients."""
|
|
265
265
|
disconnected = []
|
|
266
|
-
for connection in self.active_connections:
|
|
266
|
+
for connection in list(self.active_connections):
|
|
267
267
|
try:
|
|
268
268
|
await connection.send_json(message)
|
|
269
269
|
except Exception as e:
|
package/docs/INSTALLATION.md
CHANGED
package/events/bus.py
CHANGED
|
@@ -139,9 +139,10 @@ class EventBus:
|
|
|
139
139
|
"""Save event ID as processed."""
|
|
140
140
|
self._processed_ids.add(event_id)
|
|
141
141
|
|
|
142
|
-
# Prune to last 1000
|
|
142
|
+
# Prune to last 1000 (always, regardless of disk write outcome)
|
|
143
143
|
if len(self._processed_ids) > 1000:
|
|
144
|
-
|
|
144
|
+
pruned = list(self._processed_ids)[-1000:]
|
|
145
|
+
self._processed_ids = set(pruned)
|
|
145
146
|
|
|
146
147
|
try:
|
|
147
148
|
with open(self.processed_file, 'w') as f:
|
|
@@ -151,6 +152,7 @@ class EventBus:
|
|
|
151
152
|
finally:
|
|
152
153
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
153
154
|
except IOError:
|
|
155
|
+
# Disk write failed -- set is already pruned in memory above
|
|
154
156
|
pass
|
|
155
157
|
|
|
156
158
|
def emit(self, event: LokiEvent) -> str:
|
|
@@ -338,6 +340,24 @@ class EventBus:
|
|
|
338
340
|
self._thread.join(timeout=2.0)
|
|
339
341
|
self._thread = None
|
|
340
342
|
|
|
343
|
+
def close(self) -> None:
|
|
344
|
+
"""Clean up all threading resources."""
|
|
345
|
+
self.stop_background_processing()
|
|
346
|
+
self._subscribers.clear()
|
|
347
|
+
|
|
348
|
+
def __del__(self) -> None:
|
|
349
|
+
"""Ensure threads are cleaned up on garbage collection."""
|
|
350
|
+
try:
|
|
351
|
+
self.close()
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
def __enter__(self) -> 'EventBus':
|
|
356
|
+
return self
|
|
357
|
+
|
|
358
|
+
def __exit__(self, *exc) -> None:
|
|
359
|
+
self.close()
|
|
360
|
+
|
|
341
361
|
def get_event_history(
|
|
342
362
|
self,
|
|
343
363
|
types: Optional[List[EventType]] = None,
|
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -143,11 +143,31 @@ def validate_path(path: str, allowed_dirs: List[str] = None) -> str:
|
|
|
143
143
|
|
|
144
144
|
project_root = get_project_root()
|
|
145
145
|
|
|
146
|
-
#
|
|
146
|
+
# Build absolute path without resolving symlinks first
|
|
147
147
|
if os.path.isabs(path):
|
|
148
|
-
|
|
148
|
+
abs_path = path
|
|
149
149
|
else:
|
|
150
|
-
|
|
150
|
+
abs_path = os.path.join(project_root, path)
|
|
151
|
+
|
|
152
|
+
# Walk each component to detect symlink chains escaping allowed dirs
|
|
153
|
+
# This prevents symlinks that hop through directories outside the project
|
|
154
|
+
parts = os.path.normpath(abs_path).split(os.sep)
|
|
155
|
+
current = os.sep if abs_path.startswith(os.sep) else ''
|
|
156
|
+
for part in parts:
|
|
157
|
+
if not part:
|
|
158
|
+
continue
|
|
159
|
+
current = os.path.join(current, part)
|
|
160
|
+
if os.path.islink(current):
|
|
161
|
+
link_target = os.path.realpath(current)
|
|
162
|
+
# Each symlink target must resolve within the project root
|
|
163
|
+
if not link_target.startswith(project_root + os.sep) and link_target != project_root:
|
|
164
|
+
raise PathTraversalError(
|
|
165
|
+
f"Access denied: Symlink '{current}' escapes project root "
|
|
166
|
+
f"(target: '{link_target}')"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Resolve to absolute path, following all symlinks for final check
|
|
170
|
+
resolved_path = os.path.realpath(abs_path)
|
|
151
171
|
|
|
152
172
|
# Check if path is within any of the allowed directories
|
|
153
173
|
for allowed_dir in allowed_dirs:
|
|
@@ -1022,11 +1042,11 @@ async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
|
|
|
1022
1042
|
try:
|
|
1023
1043
|
content = prd_content
|
|
1024
1044
|
if not content and prd_path:
|
|
1025
|
-
# Resolve
|
|
1026
|
-
|
|
1027
|
-
resolved =
|
|
1028
|
-
|
|
1029
|
-
|
|
1045
|
+
# Resolve symlinks and validate path is within project root
|
|
1046
|
+
try:
|
|
1047
|
+
resolved = validate_path(prd_path, allowed_dirs=['.'])
|
|
1048
|
+
except PathTraversalError as e:
|
|
1049
|
+
return json.dumps({"error": str(e)})
|
|
1030
1050
|
if os.path.exists(resolved) and os.path.isfile(resolved):
|
|
1031
1051
|
with open(resolved, 'r', encoding='utf-8') as f:
|
|
1032
1052
|
content = f.read()
|
package/memory/namespace.py
CHANGED
|
@@ -458,9 +458,21 @@ class NamespaceManager:
|
|
|
458
458
|
|
|
459
459
|
Returns:
|
|
460
460
|
Path to the namespace's storage directory
|
|
461
|
+
|
|
462
|
+
Raises:
|
|
463
|
+
ValueError: If namespace contains path traversal characters
|
|
461
464
|
"""
|
|
462
465
|
if namespace == DEFAULT_NAMESPACE:
|
|
463
466
|
return self.base_path
|
|
467
|
+
# Block path traversal -- only allow alphanumeric, hyphen, underscore
|
|
468
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
|
|
469
|
+
raise ValueError(
|
|
470
|
+
f"Invalid namespace '{namespace}': "
|
|
471
|
+
"only alphanumeric characters, hyphens, and underscores are allowed"
|
|
472
|
+
)
|
|
473
|
+
resolved = (self.base_path / namespace).resolve()
|
|
474
|
+
if not str(resolved).startswith(str(self.base_path.resolve())):
|
|
475
|
+
raise ValueError(f"Namespace '{namespace}' resolves outside base path")
|
|
464
476
|
return self.base_path / namespace
|
|
465
477
|
|
|
466
478
|
def ensure_namespace_exists(self, namespace: str) -> Path:
|
package/memory/storage.py
CHANGED
|
@@ -66,6 +66,15 @@ class MemoryStorage:
|
|
|
66
66
|
self._root_path = Path(base_path)
|
|
67
67
|
self._namespace = namespace
|
|
68
68
|
|
|
69
|
+
# Validate namespace to prevent path traversal
|
|
70
|
+
if namespace and namespace != DEFAULT_NAMESPACE:
|
|
71
|
+
import re
|
|
72
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Invalid namespace '{namespace}': "
|
|
75
|
+
"only alphanumeric characters, hyphens, and underscores are allowed"
|
|
76
|
+
)
|
|
77
|
+
|
|
69
78
|
# Calculate effective base path (with namespace if specified)
|
|
70
79
|
if namespace and namespace != DEFAULT_NAMESPACE:
|
|
71
80
|
self.base_path = self._root_path / namespace
|
|
@@ -97,7 +106,17 @@ class MemoryStorage:
|
|
|
97
106
|
|
|
98
107
|
Returns:
|
|
99
108
|
New MemoryStorage instance for the specified namespace
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ValueError: If namespace contains path traversal characters
|
|
100
112
|
"""
|
|
113
|
+
import re
|
|
114
|
+
if namespace and namespace != DEFAULT_NAMESPACE:
|
|
115
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', namespace):
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Invalid namespace '{namespace}': "
|
|
118
|
+
"only alphanumeric characters, hyphens, and underscores are allowed"
|
|
119
|
+
)
|
|
101
120
|
return MemoryStorage(
|
|
102
121
|
base_path=str(self._root_path),
|
|
103
122
|
namespace=namespace,
|
|
@@ -122,13 +141,21 @@ class MemoryStorage:
|
|
|
122
141
|
self._cleanup_stale_locks()
|
|
123
142
|
|
|
124
143
|
def _cleanup_stale_locks(self) -> None:
|
|
125
|
-
"""Remove stale .lock files older than 5 minutes (safe with concurrent processes).
|
|
144
|
+
"""Remove stale .lock files older than 5 minutes (safe with concurrent processes).
|
|
145
|
+
|
|
146
|
+
Uses file age in seconds (monotonic comparison) instead of wall-clock
|
|
147
|
+
datetime comparison, which breaks when the system clock jumps.
|
|
148
|
+
"""
|
|
126
149
|
try:
|
|
127
|
-
|
|
150
|
+
import time
|
|
151
|
+
now_mono = time.monotonic()
|
|
152
|
+
now_real = time.time()
|
|
153
|
+
stale_seconds = 300 # 5 minutes
|
|
128
154
|
for lock_file in self.base_path.rglob("*.lock"):
|
|
129
155
|
try:
|
|
130
|
-
|
|
131
|
-
|
|
156
|
+
file_mtime = lock_file.stat().st_mtime
|
|
157
|
+
age_seconds = now_real - file_mtime
|
|
158
|
+
if age_seconds > stale_seconds:
|
|
132
159
|
lock_file.unlink()
|
|
133
160
|
except OSError:
|
|
134
161
|
pass
|
|
@@ -19,8 +19,11 @@ import os
|
|
|
19
19
|
from dataclasses import dataclass, field, asdict
|
|
20
20
|
from datetime import datetime, timezone
|
|
21
21
|
from pathlib import Path
|
|
22
|
+
import logging
|
|
22
23
|
from typing import Any, Dict, List, Optional
|
|
23
24
|
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
# -----------------------------------------------------------------------------
|
|
26
29
|
# Constants
|
|
@@ -367,6 +370,7 @@ def evaluate_thresholds(metrics: dict) -> List[Action]:
|
|
|
367
370
|
|
|
368
371
|
metric_value = metrics.get(metric_name)
|
|
369
372
|
if metric_value is None:
|
|
373
|
+
logger.warning("Threshold metric '%s' not found in metrics; skipping evaluation", metric_name)
|
|
370
374
|
continue
|
|
371
375
|
|
|
372
376
|
triggered = False
|
package/package.json
CHANGED
package/providers/codex.sh
CHANGED
|
@@ -153,11 +153,15 @@ resolve_model_for_tier() {
|
|
|
153
153
|
|
|
154
154
|
# Tier-aware invocation
|
|
155
155
|
# Codex CLI uses CODEX_MODEL_REASONING_EFFORT env var for effort control
|
|
156
|
+
# LOKI_CODEX_REASONING_EFFORT is the canonical namespaced env var (v6.37.1+)
|
|
157
|
+
# CODEX_MODEL_REASONING_EFFORT is supported for backward compatibility (deprecated)
|
|
156
158
|
provider_invoke_with_tier() {
|
|
157
159
|
local tier="$1"
|
|
158
160
|
local prompt="$2"
|
|
159
161
|
shift 2
|
|
160
162
|
local effort
|
|
161
163
|
effort=$(resolve_model_for_tier "$tier")
|
|
162
|
-
|
|
164
|
+
LOKI_CODEX_REASONING_EFFORT="$effort" \
|
|
165
|
+
CODEX_MODEL_REASONING_EFFORT="$effort" \
|
|
166
|
+
codex exec --full-auto "$prompt" "$@"
|
|
163
167
|
}
|
package/web-app/server.py
CHANGED
|
@@ -316,7 +316,8 @@ async def start_session(req: StartRequest) -> JSONResponse:
|
|
|
316
316
|
text=True,
|
|
317
317
|
cwd=project_dir,
|
|
318
318
|
env={**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")},
|
|
319
|
-
start_new_session
|
|
319
|
+
**({"start_new_session": True} if sys.platform != "win32"
|
|
320
|
+
else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
|
|
320
321
|
)
|
|
321
322
|
except FileNotFoundError:
|
|
322
323
|
return JSONResponse(
|
|
@@ -371,23 +372,37 @@ async def stop_session() -> JSONResponse:
|
|
|
371
372
|
await session.cleanup()
|
|
372
373
|
|
|
373
374
|
# 3. Kill the process group (catches child processes too)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
375
|
+
if sys.platform != "win32":
|
|
376
|
+
try:
|
|
377
|
+
pgid = os.getpgid(proc.pid)
|
|
378
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
379
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
380
|
+
try:
|
|
381
|
+
proc.terminate()
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
else:
|
|
379
385
|
try:
|
|
380
|
-
proc.
|
|
386
|
+
subprocess.call(["taskkill", "/F", "/T", "/PID", str(proc.pid)])
|
|
381
387
|
except Exception:
|
|
382
|
-
|
|
388
|
+
try:
|
|
389
|
+
proc.terminate()
|
|
390
|
+
except Exception:
|
|
391
|
+
pass
|
|
383
392
|
|
|
384
393
|
try:
|
|
385
394
|
proc.wait(timeout=5)
|
|
386
395
|
except subprocess.TimeoutExpired:
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
396
|
+
if sys.platform != "win32":
|
|
397
|
+
try:
|
|
398
|
+
pgid = os.getpgid(proc.pid)
|
|
399
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
400
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
401
|
+
try:
|
|
402
|
+
proc.kill()
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
else:
|
|
391
406
|
try:
|
|
392
407
|
proc.kill()
|
|
393
408
|
except Exception:
|
|
@@ -966,7 +981,7 @@ async def get_session_detail(session_id: str) -> JSONResponse:
|
|
|
966
981
|
"""Get details of a past session for read-only viewing."""
|
|
967
982
|
import re
|
|
968
983
|
# Validate session_id format (prevent path traversal)
|
|
969
|
-
if not re.match(r"^[a-zA-Z0-
|
|
984
|
+
if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
|
|
970
985
|
return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
|
|
971
986
|
|
|
972
987
|
search_dirs = [
|
|
@@ -1032,7 +1047,9 @@ async def onboard_session(req: OnboardRequest) -> JSONResponse:
|
|
|
1032
1047
|
except (ValueError, OSError):
|
|
1033
1048
|
return JSONResponse(status_code=400, content={"error": "Invalid path"})
|
|
1034
1049
|
home = Path.home().resolve()
|
|
1035
|
-
|
|
1050
|
+
try:
|
|
1051
|
+
target.relative_to(home)
|
|
1052
|
+
except ValueError:
|
|
1036
1053
|
return JSONResponse(status_code=400, content={"error": "Path must be within your home directory"})
|
|
1037
1054
|
if not target.exists():
|
|
1038
1055
|
return JSONResponse(status_code=400, content={"error": "Path does not exist"})
|