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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +19 -1
- package/autonomy/run.sh +373 -12
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +176 -4
- package/dashboard/static/index.html +162 -134
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -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
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
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
|
|