loki-mode 7.90.2 → 7.91.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.
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.90.2"
10
+ __version__ = "7.91.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -430,6 +430,15 @@ class StatusResponse(BaseModel):
430
430
  # correlation-only (v7.34); "resume" = LOKI_RESUME_SESSION recovery resume.
431
431
  # Empty when the run predates this field or no claude session was stamped.
432
432
  claude_session_mode: str = ""
433
+ # Track-1 (S1): the trust run_id (the proof key, format
434
+ # run-<ts>-<pid>-<rand>) of the run in THIS engine's resolved .loki dir, read
435
+ # from .loki/state/trust-run-id. Lets a caller correlate the run with its
436
+ # proof/receipt without parsing the pid. Empty when no run has minted one
437
+ # yet, or the run predates the field. NOTE for hosted callers: this reflects
438
+ # the engine's GLOBAL/cwd .loki (_get_loki_dir), so for a build started with a
439
+ # distinct workspace param it does NOT track that workspace's run -- read
440
+ # <workspace>/.loki/state/trust-run-id directly for the per-workspace build.
441
+ current_run_id: str = ""
433
442
  # Concurrent sessions (v6.4.0)
434
443
  sessions: list[SessionInfo] = []
435
444
 
@@ -1158,6 +1167,18 @@ async def get_status() -> StatusResponse:
1158
1167
  except (json.JSONDecodeError, OSError, KeyError, AttributeError):
1159
1168
  pass
1160
1169
 
1170
+ # Track-1 (S1): the trust run_id (proof key) for the run in this resolved
1171
+ # .loki dir. Best-effort read; empty when no run has minted one yet. Lets a
1172
+ # caller correlate the run with its proof without parsing the pid.
1173
+ current_run_id = ""
1174
+ trust_run_id_file = loki_dir / "state" / "trust-run-id"
1175
+ if trust_run_id_file.exists():
1176
+ try:
1177
+ _rid = trust_run_id_file.read_text(encoding="utf-8").strip()
1178
+ current_run_id = _rid if isinstance(_rid, str) else ""
1179
+ except OSError:
1180
+ pass
1181
+
1161
1182
  # Read dashboard state (with retry for concurrent writes)
1162
1183
  _has_dashboard_state = False
1163
1184
  if state_file.exists():
@@ -1412,6 +1433,7 @@ async def get_status() -> StatusResponse:
1412
1433
  current_task=current_task,
1413
1434
  claude_session_id=claude_session_id,
1414
1435
  claude_session_mode=claude_session_mode,
1436
+ current_run_id=current_run_id,
1415
1437
  sessions=active_session_list,
1416
1438
  )
1417
1439
 
@@ -3018,6 +3040,14 @@ class StartBuildRequest(BaseModel):
3018
3040
  prd_path: Optional[str] = None
3019
3041
  provider: str = "claude"
3020
3042
  parallel: bool = False
3043
+ # Track-1 (S1): an OPTIONAL per-build workspace directory. When present and
3044
+ # path-guarded, the build runs against THIS dir (its own cwd + .loki) instead
3045
+ # of the engine's own project dir, so a hosted caller (the SaaS BFF) can run a
3046
+ # build outside the engine repo without polluting it. When OMITTED, behavior
3047
+ # is byte-identical to before this field existed (the global project dir).
3048
+ # The path is path-guarded against ALLOWED_WORKSPACE_ROOTS (see
3049
+ # _validate_workspace) -- it is NOT a free-form filesystem write target.
3050
+ workspace: Optional[str] = None
3021
3051
 
3022
3052
  def validate_provider(self) -> None:
3023
3053
  """Validate provider is from the supported list.
@@ -3071,6 +3101,88 @@ def _validate_prd_path(raw_path: str, project_dir: _Path) -> _Path:
3071
3101
  raise ValueError(f"PRD path is outside allowed directories: {raw_path}")
3072
3102
 
3073
3103
 
3104
+ def _allowed_workspace_roots() -> list[_Path]:
3105
+ """Resolved roots a caller-supplied workspace may live under (Track-1 S1).
3106
+
3107
+ Configured via LOKI_WORKSPACE_ROOTS (os.pathsep-separated absolute dirs).
3108
+ Each entry is expanded and resolved; nonexistent / non-absolute / relative
3109
+ entries are dropped (a misconfigured root must NOT silently widen the guard
3110
+ to the CWD). When the env var is unset or yields no valid root, the list is
3111
+ empty and _validate_workspace rejects every workspace -- the feature is
3112
+ opt-in by configuration, fail-closed by default.
3113
+
3114
+ This is deliberately NARROWER than the PRD-path roots ([project_dir, home]):
3115
+ a build cwd runs an autonomous agent, so its root must be an explicitly
3116
+ operator-allowed location (e.g. the BFF's ENGINE_WORKSPACE_ROOT, default
3117
+ /workspaces), never the engine repo or the whole home directory.
3118
+ """
3119
+ raw = os.environ.get("LOKI_WORKSPACE_ROOTS", "").strip()
3120
+ if not raw:
3121
+ return []
3122
+ roots: list[_Path] = []
3123
+ for entry in raw.split(os.pathsep):
3124
+ entry = entry.strip()
3125
+ if not entry:
3126
+ continue
3127
+ candidate = _Path(entry).expanduser()
3128
+ if not candidate.is_absolute():
3129
+ continue
3130
+ try:
3131
+ resolved = candidate.resolve()
3132
+ except OSError:
3133
+ continue
3134
+ if resolved.is_dir():
3135
+ roots.append(resolved)
3136
+ return roots
3137
+
3138
+
3139
+ def _validate_workspace(raw_path: str) -> _Path:
3140
+ """Path-guard a caller-supplied per-build workspace dir (Track-1 S1).
3141
+
3142
+ Unlike _validate_prd_path, the workspace need NOT exist yet (the BFF passes
3143
+ <ENGINE_WORKSPACE_ROOT>/<buildId>, created on first build); this guard
3144
+ normalizes a possibly-nonexistent path and verifies CONTAINMENT under one of
3145
+ LOKI_WORKSPACE_ROOTS, then the caller mkdirs it. Returns the resolved,
3146
+ verified, absolute workspace path. Raises ValueError on any unsafe path.
3147
+
3148
+ Guard order:
3149
+ 1. Reject literal ".." traversal sequences before any resolution.
3150
+ 2. Require an absolute input (a relative path would resolve against the
3151
+ dashboard CWD -- never an intended workspace).
3152
+ 3. resolve() (follows symlinks on existing parents) then assert the real
3153
+ path is under an allowed root -- catches a symlinked parent that
3154
+ escaped the no-".." check.
3155
+ """
3156
+ if not raw_path or not raw_path.strip():
3157
+ raise ValueError("workspace must be a non-empty path")
3158
+ raw_path = raw_path.strip()
3159
+ if ".." in raw_path:
3160
+ raise ValueError("workspace contains path traversal sequence (..)")
3161
+
3162
+ candidate = _Path(raw_path).expanduser()
3163
+ if not candidate.is_absolute():
3164
+ raise ValueError("workspace must be an absolute path")
3165
+
3166
+ try:
3167
+ ws = candidate.resolve()
3168
+ except OSError as e:
3169
+ raise ValueError(f"workspace path could not be resolved: {e}")
3170
+
3171
+ roots = _allowed_workspace_roots()
3172
+ if not roots:
3173
+ raise ValueError(
3174
+ "workspace param is not enabled: set LOKI_WORKSPACE_ROOTS to one or "
3175
+ "more absolute directories before passing a workspace"
3176
+ )
3177
+ for root in roots:
3178
+ try:
3179
+ ws.relative_to(root)
3180
+ return ws
3181
+ except ValueError:
3182
+ continue
3183
+ raise ValueError(f"workspace is outside allowed roots: {raw_path}")
3184
+
3185
+
3074
3186
  def _write_spec_text(prd_text: str, project_dir: _Path) -> _Path:
3075
3187
  """Persist an inline spec to .loki/specs/ and return its path.
3076
3188
 
@@ -3153,10 +3265,34 @@ async def start_build(request: Request, body: StartBuildRequest):
3153
3265
  detail="Provide exactly one of prd_text or prd_path",
3154
3266
  )
3155
3267
 
3156
- # Resolve the target project directory from the active dashboard project.
3157
- loki_dir = _get_loki_dir()
3158
- project_dir = loki_dir.parent if loki_dir.name == ".loki" else _Path.cwd()
3159
- project_dir = project_dir.resolve()
3268
+ # Resolve the target project directory. When the caller supplies a
3269
+ # path-guarded workspace (Track-1 S1), the build runs against THAT dir (its
3270
+ # own cwd + .loki) so a hosted caller can build outside the engine repo.
3271
+ # When omitted, behavior is byte-identical to before: the active dashboard
3272
+ # project (the engine's own _get_loki_dir).
3273
+ workspace_dir: Optional[_Path] = None
3274
+ if body.workspace and body.workspace.strip():
3275
+ try:
3276
+ workspace_dir = _validate_workspace(body.workspace)
3277
+ except ValueError as e:
3278
+ raise HTTPException(status_code=400, detail=str(e))
3279
+
3280
+ if workspace_dir is not None:
3281
+ # The build's project dir IS the workspace; its .loki lives inside it.
3282
+ # Create it now so single-flight + spec-write below operate on the real
3283
+ # build dir (the BFF passes <root>/<buildId>, absent on first build).
3284
+ try:
3285
+ workspace_dir.mkdir(parents=True, exist_ok=True)
3286
+ except OSError as e:
3287
+ raise HTTPException(
3288
+ status_code=500, detail=f"Could not create workspace: {e}"
3289
+ )
3290
+ project_dir = workspace_dir
3291
+ loki_dir = workspace_dir / ".loki"
3292
+ else:
3293
+ loki_dir = _get_loki_dir()
3294
+ project_dir = loki_dir.parent if loki_dir.name == ".loki" else _Path.cwd()
3295
+ project_dir = project_dir.resolve()
3160
3296
 
3161
3297
  # Single-flight: refuse if a run is already active in this project.
3162
3298
  active_pid = _project_run_active(loki_dir)
@@ -3191,6 +3327,19 @@ async def start_build(request: Request, body: StartBuildRequest):
3191
3327
  args.append("--bg")
3192
3328
  args.append(str(spec_file))
3193
3329
 
3330
+ # When a workspace is given, pass an explicit env that PINS run.sh to the
3331
+ # workspace. cwd alone is not enough: `loki` exports LOKI_DIR (default
3332
+ # .loki) into the dashboard process, and run.sh resolves its workspace as
3333
+ # ${LOKI_DIR:-${LOKI_TARGET_DIR:-$(pwd)}/.loki} -- an inherited LOKI_DIR
3334
+ # would override the workspace cwd and the build would write into the
3335
+ # engine's own .loki. Setting both LOKI_DIR and LOKI_TARGET_DIR makes the
3336
+ # workspace authoritative. When no workspace is given, env=None (inherit) so
3337
+ # behavior is byte-identical to before this feature.
3338
+ popen_env = None
3339
+ if workspace_dir is not None:
3340
+ popen_env = dict(os.environ)
3341
+ popen_env["LOKI_TARGET_DIR"] = str(workspace_dir)
3342
+ popen_env["LOKI_DIR"] = str(loki_dir)
3194
3343
  try:
3195
3344
  process = subprocess.Popen(
3196
3345
  args,
@@ -3198,6 +3347,7 @@ async def start_build(request: Request, body: StartBuildRequest):
3198
3347
  stderr=subprocess.DEVNULL,
3199
3348
  start_new_session=True,
3200
3349
  cwd=str(project_dir),
3350
+ env=popen_env,
3201
3351
  )
3202
3352
  except (OSError, subprocess.SubprocessError) as e:
3203
3353
  raise HTTPException(status_code=500, detail=f"Failed to start build: {e}")
@@ -3242,12 +3392,34 @@ async def start_build(request: Request, body: StartBuildRequest):
3242
3392
  ip_address=request.client.host if request.client else None,
3243
3393
  )
3244
3394
 
3395
+ # Best-effort: surface the trust run_id (the proof key) so the caller can
3396
+ # correlate this build to its Evidence Receipt without parsing the pid.
3397
+ # run.sh mints it into <loki_dir>/state/trust-run-id at run-start; the 0.3s
3398
+ # liveness poll above usually does NOT outlast the mint, so this is often
3399
+ # null at start time. It is RELIABLY derivable later by the caller, which
3400
+ # owns the workspace path: read <workspace>/.loki/state/trust-run-id (or the
3401
+ # newest proof under <workspace>/.loki). Null here is expected, not an error.
3402
+ run_id = ""
3403
+ try:
3404
+ rid_file = loki_dir / "state" / "trust-run-id"
3405
+ if rid_file.is_file():
3406
+ run_id = rid_file.read_text(encoding="utf-8").strip()
3407
+ except OSError:
3408
+ pass
3409
+
3245
3410
  return {
3246
3411
  "success": True,
3247
3412
  "message": f"Build started with provider {body.provider}",
3248
3413
  "pid": process.pid,
3249
3414
  "spec": str(spec_file),
3250
3415
  "provider": body.provider,
3416
+ # The workspace this build runs in (echoed back; empty == engine's own
3417
+ # project dir, today's default). The caller derives the proof path from
3418
+ # this: <workspace>/.loki/state/trust-run-id.
3419
+ "workspace": str(workspace_dir) if workspace_dir is not None else "",
3420
+ # The trust run_id if already minted, else "" (see note above). When "",
3421
+ # derive it from the workspace's .loki/state/trust-run-id.
3422
+ "run_id": run_id,
3251
3423
  }
3252
3424
 
3253
3425