loki-mode 7.43.0 → 7.45.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/autonomy/run.sh CHANGED
@@ -1550,7 +1550,24 @@ validate_api_keys() {
1550
1550
 
1551
1551
  local key_var=""
1552
1552
  case "$provider" in
1553
- claude) key_var="ANTHROPIC_API_KEY" ;;
1553
+ claude)
1554
+ # Inside Docker, the Claude Code CLI can authenticate either via
1555
+ # ANTHROPIC_API_KEY OR via a mounted OAuth credentials file (the
1556
+ # zero-friction `loki docker` wrapper mounts the host login at
1557
+ # ~/.claude/.credentials.json). If that file is present, the CLI
1558
+ # has a valid login and we must NOT block on the env var -- doing
1559
+ # so was the bug that made OAuth-based Docker runs exit at
1560
+ # pre-flight with "Required API key ... is not set". Mirror the
1561
+ # OAuth-aware doctor check (run.sh:9268).
1562
+ if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
1563
+ local _claude_creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
1564
+ if [[ -s "$_claude_creds" ]]; then
1565
+ log_info "Claude Code OAuth credentials detected ($_claude_creds); using CLI login."
1566
+ return 0
1567
+ fi
1568
+ fi
1569
+ key_var="ANTHROPIC_API_KEY"
1570
+ ;;
1554
1571
  codex) key_var="OPENAI_API_KEY" ;;
1555
1572
  cline) # Cline manages its own keys via `cline auth`
1556
1573
  if ! command -v cline &>/dev/null; then
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.43.0"
10
+ __version__ = "7.45.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -53,7 +53,7 @@ from . import auth
53
53
  from . import audit
54
54
  from . import app_secrets as secrets_mod
55
55
  from . import telemetry as _telemetry
56
- from .control import atomic_write_json
56
+ from .control import atomic_write_json, find_skill_dir, is_process_running
57
57
  from .activity_logger import get_activity_logger
58
58
 
59
59
  try:
@@ -2629,6 +2629,231 @@ async def list_running_projects():
2629
2629
  return {"projects": out, "active_project_dir": active}
2630
2630
 
2631
2631
 
2632
+ class StartBuildRequest(BaseModel):
2633
+ """Schema for starting a build from a spec via the dashboard.
2634
+
2635
+ Absorbs the browser PRD-input capability: the caller supplies a spec as
2636
+ inline text (prd_text -- a one-line brief or a full PRD) OR as a path to an
2637
+ existing spec file (prd_path). Exactly one is required. provider is
2638
+ optional and validated against the supported provider list.
2639
+ """
2640
+ # prd_text is capped at 1 MiB: a real PRD is well under this, and the cap
2641
+ # bounds disk-fill / oversized-spawn from browser input (pydantic returns
2642
+ # 422 on overflow before any file write or subprocess spawn).
2643
+ prd_text: Optional[str] = Field(default=None, max_length=1_048_576)
2644
+ prd_path: Optional[str] = None
2645
+ provider: str = "claude"
2646
+ parallel: bool = False
2647
+
2648
+ def validate_provider(self) -> None:
2649
+ """Validate provider is from the supported list.
2650
+
2651
+ Mirrors dashboard/control.py StartRequest.validate_provider so the
2652
+ dashboard and the standalone control app accept the same set.
2653
+ """
2654
+ allowed = ["claude", "codex", "gemini", "cline", "aider"]
2655
+ if self.provider not in allowed:
2656
+ raise ValueError(
2657
+ f"Invalid provider: {self.provider}. "
2658
+ f"Must be one of: {', '.join(allowed)}"
2659
+ )
2660
+
2661
+
2662
+ def _validate_prd_path(raw_path: str, project_dir: _Path) -> _Path:
2663
+ """Path-guard a caller-supplied PRD path.
2664
+
2665
+ Ports the proven traversal-safety logic from
2666
+ dashboard/control.py:StartRequest.validate_prd_path, but anchors the
2667
+ allowed roots to the resolved target project directory (the active
2668
+ dashboard project) plus the user's home, rather than the dashboard
2669
+ process CWD. Returns the resolved, verified path. Raises ValueError on
2670
+ any unsafe / nonexistent / non-file path.
2671
+ """
2672
+ # Reject literal traversal sequences before any resolution.
2673
+ if ".." in raw_path:
2674
+ raise ValueError("PRD path contains path traversal sequence (..)")
2675
+
2676
+ prd_path = _Path(raw_path).expanduser().resolve()
2677
+
2678
+ if not prd_path.exists():
2679
+ raise ValueError(f"PRD file does not exist: {raw_path}")
2680
+ if not prd_path.is_file():
2681
+ raise ValueError(f"PRD path is not a file: {raw_path}")
2682
+
2683
+ # Must resolve within the target project dir or the user's home. This is
2684
+ # the post-resolution containment check: even a symlink that escaped the
2685
+ # no-".." check is caught here because relative_to is computed on the
2686
+ # fully-resolved real path.
2687
+ roots = [project_dir.resolve(), _Path.home().resolve()]
2688
+ for root in roots:
2689
+ try:
2690
+ prd_path.relative_to(root)
2691
+ return prd_path
2692
+ except ValueError:
2693
+ continue
2694
+ raise ValueError(f"PRD path is outside allowed directories: {raw_path}")
2695
+
2696
+
2697
+ def _write_spec_text(prd_text: str, project_dir: _Path) -> _Path:
2698
+ """Persist an inline spec to .loki/specs/ and return its path.
2699
+
2700
+ The browser one-line-brief / PRD-textarea flow lands here. The file is
2701
+ written inside the target project's .loki/specs so it is contained and
2702
+ auditable; run.sh is then started against that file path.
2703
+ """
2704
+ specs_dir = project_dir / ".loki" / "specs"
2705
+ specs_dir.mkdir(parents=True, exist_ok=True)
2706
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
2707
+ spec_file = specs_dir / f"dashboard-spec-{stamp}.md"
2708
+ spec_file.write_text(prd_text, encoding="utf-8")
2709
+ return spec_file
2710
+
2711
+
2712
+ def _project_run_active(loki_dir: _Path) -> Optional[int]:
2713
+ """Single-flight check: return the live orchestrator PID if a run is active
2714
+ in this project, else None.
2715
+
2716
+ Honest: checks loki.pid (process alive) first, then session.json
2717
+ status=running with a 6h staleness window (mirrors control.get_status), so
2718
+ a crashed run that left a stale session.json does not block a fresh start.
2719
+ """
2720
+ pid_file = loki_dir / "loki.pid"
2721
+ pid_str = _safe_read_text(pid_file).strip()
2722
+ if pid_str.isdigit():
2723
+ pid = int(pid_str)
2724
+ if is_process_running(pid):
2725
+ return pid
2726
+
2727
+ session_file = loki_dir / "session.json"
2728
+ if session_file.exists():
2729
+ try:
2730
+ sd = json.loads(session_file.read_text())
2731
+ if sd.get("status") == "running":
2732
+ started_at = sd.get("startedAt", "")
2733
+ if started_at:
2734
+ try:
2735
+ st = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
2736
+ age_h = (datetime.now(timezone.utc) - st).total_seconds() / 3600
2737
+ if age_h <= 6:
2738
+ return -1 # running per session.json, no usable pid
2739
+ except (ValueError, TypeError):
2740
+ return -1
2741
+ else:
2742
+ return -1
2743
+ except (json.JSONDecodeError, OSError, KeyError):
2744
+ pass
2745
+ return None
2746
+
2747
+
2748
+ @app.post("/api/control/start", dependencies=[Depends(auth.require_scope("control"))])
2749
+ async def start_build(request: Request, body: StartBuildRequest):
2750
+ """Start a Loki Mode build from a spec, kicked off from the browser.
2751
+
2752
+ Absorbs the one unique browser capability: PRD-input to kick off a build.
2753
+ Accepts a spec as inline text (prd_text) OR as a path (prd_path), validates
2754
+ and path-guards it, writes inline text into .loki/specs/, then spawns
2755
+ `run.sh` against the resolved spec via subprocess (same mechanism the
2756
+ standalone control app uses).
2757
+
2758
+ Single-flight: refuses with 409 if a run is already active in the target
2759
+ project.
2760
+ """
2761
+ if not _control_limiter.check("control"):
2762
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
2763
+
2764
+ # Validate provider.
2765
+ try:
2766
+ body.validate_provider()
2767
+ except ValueError as e:
2768
+ raise HTTPException(status_code=400, detail=str(e))
2769
+
2770
+ # Exactly one spec source.
2771
+ has_text = bool(body.prd_text and body.prd_text.strip())
2772
+ has_path = bool(body.prd_path and body.prd_path.strip())
2773
+ if has_text == has_path:
2774
+ raise HTTPException(
2775
+ status_code=400,
2776
+ detail="Provide exactly one of prd_text or prd_path",
2777
+ )
2778
+
2779
+ # Resolve the target project directory from the active dashboard project.
2780
+ loki_dir = _get_loki_dir()
2781
+ project_dir = loki_dir.parent if loki_dir.name == ".loki" else _Path.cwd()
2782
+ project_dir = project_dir.resolve()
2783
+
2784
+ # Single-flight: refuse if a run is already active in this project.
2785
+ active_pid = _project_run_active(loki_dir)
2786
+ if active_pid is not None:
2787
+ detail = "A build is already running in this project"
2788
+ if active_pid > 0:
2789
+ detail += f" (PID {active_pid})"
2790
+ raise HTTPException(status_code=409, detail=detail)
2791
+
2792
+ # Resolve the spec to a concrete, path-guarded file.
2793
+ try:
2794
+ if has_path:
2795
+ spec_file = _validate_prd_path(body.prd_path.strip(), project_dir)
2796
+ else:
2797
+ spec_file = _write_spec_text(body.prd_text, project_dir)
2798
+ except ValueError as e:
2799
+ raise HTTPException(status_code=400, detail=str(e))
2800
+ except OSError as e:
2801
+ raise HTTPException(status_code=500, detail=f"Could not write spec: {e}")
2802
+
2803
+ # Locate run.sh (same resolver the standalone control app uses).
2804
+ skill_dir = find_skill_dir()
2805
+ run_sh = skill_dir / "autonomy" / "run.sh"
2806
+ if not run_sh.exists():
2807
+ raise HTTPException(status_code=500, detail=f"run.sh not found at {run_sh}")
2808
+
2809
+ # Build args: mirror control.py:start_session (provider, optional parallel,
2810
+ # background, then the spec path).
2811
+ args = [str(run_sh), "--provider", body.provider]
2812
+ if body.parallel:
2813
+ args.append("--parallel")
2814
+ args.append("--bg")
2815
+ args.append(str(spec_file))
2816
+
2817
+ try:
2818
+ process = subprocess.Popen(
2819
+ args,
2820
+ stdout=subprocess.DEVNULL,
2821
+ stderr=subprocess.DEVNULL,
2822
+ start_new_session=True,
2823
+ cwd=str(project_dir),
2824
+ )
2825
+ except (OSError, subprocess.SubprocessError) as e:
2826
+ raise HTTPException(status_code=500, detail=f"Failed to start build: {e}")
2827
+
2828
+ # Persist provider for status tracking (same as control.py).
2829
+ try:
2830
+ state_dir = loki_dir / "state"
2831
+ state_dir.mkdir(parents=True, exist_ok=True)
2832
+ (state_dir / "provider").write_text(body.provider)
2833
+ except OSError:
2834
+ pass
2835
+
2836
+ audit.log_event(
2837
+ action="start",
2838
+ resource_type="session",
2839
+ details={
2840
+ "source": "dashboard",
2841
+ "provider": body.provider,
2842
+ "spec": str(spec_file),
2843
+ "pid": process.pid,
2844
+ },
2845
+ ip_address=request.client.host if request.client else None,
2846
+ )
2847
+
2848
+ return {
2849
+ "success": True,
2850
+ "message": f"Build started with provider {body.provider}",
2851
+ "pid": process.pid,
2852
+ "spec": str(spec_file),
2853
+ "provider": body.provider,
2854
+ }
2855
+
2856
+
2632
2857
  class RunningProjectStopRequest(BaseModel):
2633
2858
  """Schema for stopping a specific registered project from the switcher.
2634
2859
 
@@ -2686,13 +2911,28 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
2686
2911
 
2687
2912
  pid = project.get("pid")
2688
2913
  if not isinstance(pid, int) or pid <= 0:
2689
- # Already not running: nothing to signal, just reconcile the registry.
2914
+ # No host pid. Two sub-cases:
2915
+ # - A `loki docker` project registers with pid=None but may still be
2916
+ # actively building inside a container. Its runner polls .loki/STOP
2917
+ # (bind-mounted to the host path), so writing STOP here actually
2918
+ # stops the containerized build -- this is the unified-dashboard Stop
2919
+ # parity for Docker projects (the container pid is meaningless on the
2920
+ # host, so os.kill is not an option).
2921
+ # - A genuinely stopped project: the STOP write is harmless.
2922
+ # Write STOP when we resolved a real .loki dir, then reconcile.
2923
+ stop_signaled = False
2924
+ if loki_dir is not None:
2925
+ try:
2926
+ (loki_dir / "STOP").write_text(datetime.now(timezone.utc).isoformat())
2927
+ stop_signaled = True
2928
+ except OSError:
2929
+ pass
2690
2930
  registry.mark_project_stopped(project_id)
2691
2931
  return {
2692
2932
  "success": True,
2693
2933
  "project_id": project_id,
2694
- "stopped": False,
2695
- "already_stopped": True,
2934
+ "stopped": stop_signaled,
2935
+ "already_stopped": not stop_signaled,
2696
2936
  }
2697
2937
 
2698
2938
  # Write the STOP file so the runner's own cleanup STOP-branch fires for a