loki-mode 9.12.5 → 9.16.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.
@@ -194,7 +194,14 @@ _loki_known_command() {
194
194
  # a first run, never the user's paths, versions, or hostnames.
195
195
  _loki_known_blocker() {
196
196
  case "${1:-}" in
197
- no_provider|node|python3|jq|git|curl|disk|skill_symlink)
197
+ # not_logged_in is distinct from no_provider on purpose. They are the two
198
+ # halves of the same wall and they need opposite fixes: no_provider means
199
+ # "install something", not_logged_in means "authenticate the thing you
200
+ # already installed". Collapsing the second into `other` would make the
201
+ # single most common post-install failure unactionable in the data --
202
+ # and it is the worst-placed one, since without this the user only learns
203
+ # of it after confirming the spend.
204
+ no_provider|not_logged_in|node|python3|jq|git|curl|disk|skill_symlink)
198
205
  printf '%s' "$1" ;;
199
206
  *)
200
207
  printf 'other' ;;
@@ -2522,9 +2522,18 @@ try:
2522
2522
  # secrets/lint/deps). It must NEVER claim "verified" when the authoritative
2523
2523
  # verdict is not VERIFIED -- a tool named Verify printing "-> verified" next
2524
2524
  # to a BLOCKED result is a fake-green-shaped surface, and this product never
2525
- # lies about done. So the banner reports the engine's partial finding,
2525
+ # lies about done. So the banner reports the partial finding of the engine,
2526
2526
  # explicitly subordinated to the authoritative verdict, and never the word
2527
2527
  # "verified" on its own when the verdict disagrees.
2528
+ #
2529
+ # NO APOSTROPHES IN THIS BODY. This heredoc sits inside "$( ... )", and bash
2530
+ # 3.2 -- the /bin/bash that every stock macOS ships -- tracks quoting THROUGH
2531
+ # the command substitution even though the heredoc is quoted with <<PYEOF.
2532
+ # One apostrophe opens a quote that never closes, so the parser swallows the
2533
+ # rest of the file and reports "syntax error near unexpected token (" at a
2534
+ # line ~270 later that is perfectly valid. The whole file then fails to
2535
+ # parse: `loki verify --help` exits 2 printing NOTHING on a stock Mac, while
2536
+ # working fine under Homebrew bash 5. Verified by minimal reproduction.
2528
2537
  verdict = (os.environ.get("_V_VERDICT") or "").strip().upper()
2529
2538
  if e.get("inconclusive"):
2530
2539
  finding = "inconclusive (%s)" % (e.get("inconclusive_reason") or "no reason")
package/completions/_loki CHANGED
@@ -171,6 +171,9 @@ function _loki_commands {
171
171
  'metrics:Session productivity report'
172
172
  'share:Share session report as GitHub Gist'
173
173
  'proof:Inspect/share proof-of-run artifacts'
174
+ 'outcomes:What happened to the work AFTER the receipt (reverted/reworked/survived)'
175
+ 'verdict:The five measured trust signals in one readable block'
176
+ 'readiness:Can an agent verify its own work in this repo? (measured, not LLM-scored)'
174
177
  'preview:Preview the locally-running app'
175
178
  'deploy:Deploy the built product (CI/CD-aware)'
176
179
  'context:Context window management'
@@ -190,6 +193,7 @@ function _loki_commands {
190
193
  'magic:Spec-driven component generation'
191
194
  'assets:Export/import shareable team assets'
192
195
  'spec:Living spec drift detection'
196
+ 'intent:Does the spec still say what was actually wanted'
193
197
  'verify:Deterministic PR verification'
194
198
  'grill:Interrogate a spec before building'
195
199
  'trust:Visible trust trajectory'
@@ -5,7 +5,8 @@ _loki_completion() {
5
5
  _init_completion || return
6
6
 
7
7
  # Main subcommands (must match autonomy/loki main case statement)
8
- local main_commands="start quick monitor demo tour welcome init stop pause resume steer status next ship dashboard web serve api sandbox notify import github issue config provider reset memory compound checkpoint council dogfood projects enterprise secrets cockpit secure own handoff doctor watchdog audit metrics syslog onboard share proof receipt explain plan report cost estimate kpis stats test ci watch telemetry agent context ctx code run export review optimize heal modernize migrate cluster worktree wt trigger failover remote deploy docker mcp magic assets analyze compliance crash open otel preview quickstart rc rollback self-update sentrux setup-skill spec state template trust trust-metrics ultracode update verify voice why wiki bench cleanup logs grill docs cp version completions help"
8
+ local main_commands="start quick monitor demo tour welcome init stop pause resume steer status next ship dashboard web serve api sandbox notify import github issue config provider reset memory compound checkpoint council dogfood projects enterprise secrets cockpit secure own handoff doctor watchdog audit metrics syslog onboard share proof receipt outcomes verdict readiness explain plan report cost estimate kpis stats test ci watch telemetry agent context ctx code run export review optimize heal modernize migrate cluster worktree wt trigger failover remote deploy docker mcp magic assets analyze compliance crash open otel preview quickstart rc rollback self-update sentrux setup-skill spec state template trust trust-metrics ultracode update verify voice why wiki bench cleanup logs grill docs cp version completions help"
9
+ local main_commands="start quick monitor demo tour welcome init stop pause resume steer status next ship dashboard web serve api sandbox notify import github issue intent config provider reset memory compound checkpoint council dogfood projects enterprise secrets cockpit secure own handoff doctor watchdog audit metrics syslog onboard share proof receipt explain plan report cost estimate kpis stats test ci watch telemetry agent context ctx code run export review optimize heal modernize migrate cluster worktree wt trigger failover remote deploy docker mcp magic assets analyze compliance crash open otel preview quickstart rc rollback self-update sentrux setup-skill spec state template trust trust-metrics ultracode update verify voice why wiki bench cleanup logs grill docs cp version completions help"
9
10
 
10
11
  # 1. If we are on the first argument (subcommand)
11
12
  if [[ $cword -eq 1 ]]; then
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.12.5"
10
+ __version__ = "9.16.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -77,11 +77,24 @@ def _loki_dir() -> str:
77
77
  others, and an import-time snapshot would pin whichever was true when the
78
78
  module first loaded.
79
79
  """
80
- return os.environ.get("LOKI_DIR") or os.path.join(os.getcwd(), ".loki")
80
+ return os.environ.get("LOKI_DIR") or os.path.join(_safe_getcwd(), ".loki")
81
+
82
+
83
+ def _safe_getcwd() -> str:
84
+ """os.getcwd() raises FileNotFoundError once the cwd is deleted.
85
+
86
+ These helpers run per request, so letting that propagate turns a stale
87
+ working directory into a blanket 500 across the whole API. Fall back to
88
+ this package's tree, which is on disk by definition.
89
+ """
90
+ try:
91
+ return os.getcwd()
92
+ except OSError:
93
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
81
94
 
82
95
 
83
96
  def _repo_dir() -> str:
84
- return os.environ.get("LOKI_REPO_DIR") or os.getcwd()
97
+ return os.environ.get("LOKI_REPO_DIR") or _safe_getcwd()
85
98
 
86
99
 
87
100
  # Ceilings for the receipt walk on the HTTP path. Generous enough that a real
@@ -527,7 +527,12 @@ async def list_runs(
527
527
  try:
528
528
  from . import api_runs as _fs_runs
529
529
 
530
- _loki = os.environ.get("LOKI_DIR") or os.path.join(os.getcwd(), ".loki")
530
+ # A deleted cwd raises from os.getcwd(); this runs per request.
531
+ try:
532
+ _cwd = os.getcwd()
533
+ except OSError:
534
+ _cwd = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
535
+ _loki = os.environ.get("LOKI_DIR") or os.path.join(_cwd, ".loki")
531
536
  _fs = _fs_runs.list_runs(_loki)
532
537
  # The adapter returns an envelope carrying source, freshness
533
538
  # and an explicit reason when empty. Never fabricate a row:
@@ -72,22 +72,64 @@ def find_skill_dir() -> Path:
72
72
  return configured
73
73
  raise RuntimeError(f"LOKI_SKILL_DIR is not a Loki source tree: {configured}")
74
74
 
75
+ # os.getcwd() RAISES FileNotFoundError when the working directory has been
76
+ # deleted out from under the process. That is not hypothetical: a dashboard
77
+ # started inside a temp workspace keeps running after the workspace is
78
+ # cleaned up, and then EVERY endpoint that resolves a path 500s at once --
79
+ # observed as ~60 simultaneous 500s including /api/status, whose handler
80
+ # touches almost nothing. A long-lived server must survive losing its cwd.
81
+ def _cwd_or_none() -> "Path | None":
82
+ try:
83
+ return Path.cwd()
84
+ except OSError:
85
+ return None
86
+
75
87
  candidates = [
76
88
  Path.home() / ".claude" / "skills" / "loki-mode",
77
89
  Path(__file__).parent.parent,
78
- Path.cwd()
90
+ _cwd_or_none(),
79
91
  ]
80
92
  for candidate in candidates:
81
- if (candidate / "SKILL.md").exists() and (candidate / "autonomy" / "run.sh").exists():
82
- return candidate
83
- return Path.cwd()
93
+ if candidate is None:
94
+ continue
95
+ try:
96
+ if ((candidate / "SKILL.md").exists()
97
+ and (candidate / "autonomy" / "run.sh").exists()):
98
+ return candidate
99
+ except OSError:
100
+ continue
101
+
102
+ # The fallback must not be the one path that can still raise. When the cwd
103
+ # is gone, resolve to this file's own tree -- it is on disk by definition,
104
+ # since we are executing out of it.
105
+ return _cwd_or_none() or Path(__file__).resolve().parent.parent
84
106
 
85
107
  SKILL_DIR = find_skill_dir()
86
108
  RUN_SH = SKILL_DIR / "autonomy" / "run.sh"
87
109
 
88
- # Ensure directories exist
89
- STATE_DIR.mkdir(parents=True, exist_ok=True)
90
- LOG_DIR.mkdir(parents=True, exist_ok=True)
110
+
111
+ def _cwd_or_skill_dir() -> Path:
112
+ """The cwd, or the skill tree when the cwd has been deleted.
113
+
114
+ Used where a real directory is required (subprocess cwd=), as opposed to
115
+ the confinement check in validate(), which must fail closed instead.
116
+ """
117
+ try:
118
+ return Path.cwd()
119
+ except OSError:
120
+ return SKILL_DIR
121
+
122
+ # Ensure directories exist.
123
+ # Best-effort at IMPORT time. LOKI_DIR defaults to the relative path ".loki",
124
+ # so when the working directory has been deleted these mkdirs raise
125
+ # FileNotFoundError and the whole module fails to import -- which takes the
126
+ # entire dashboard down rather than the one feature that needs the directory.
127
+ # Whoever actually writes into these paths still surfaces its own error.
128
+ for _startup_dir in (STATE_DIR, LOG_DIR):
129
+ try:
130
+ _startup_dir.mkdir(parents=True, exist_ok=True)
131
+ except OSError:
132
+ pass
91
133
 
92
134
  # Utility: atomic write with optional file locking
93
135
  def atomic_write_json(file_path: Path, data: dict, use_lock: bool = True):
@@ -202,8 +244,16 @@ class StartRequest(BaseModel):
202
244
  if not prd_path.is_file():
203
245
  raise ValueError(f"PRD path is not a file: {self.prd}")
204
246
 
205
- # Verify path resolves within CWD or a reasonable parent
206
- cwd = Path.cwd().resolve()
247
+ # Verify path resolves within CWD or a reasonable parent.
248
+ # FAIL CLOSED if the cwd is gone: this is a path-confinement check, so
249
+ # an unresolvable base must reject the path, never skip the comparison.
250
+ try:
251
+ cwd = Path.cwd().resolve()
252
+ except OSError as exc:
253
+ raise ValueError(
254
+ "cannot validate PRD path: the working directory no longer "
255
+ "exists (%s)" % exc
256
+ ) from exc
207
257
  try:
208
258
  prd_path.relative_to(cwd)
209
259
  except ValueError:
@@ -450,7 +500,9 @@ async def start_session(request: StartRequest):
450
500
  stdout=subprocess.DEVNULL,
451
501
  stderr=subprocess.DEVNULL,
452
502
  start_new_session=True,
453
- cwd=str(Path.cwd())
503
+ # A deleted cwd would make Popen itself raise; fall back to the
504
+ # skill tree rather than failing to launch the run at all.
505
+ cwd=str(_cwd_or_skill_dir())
454
506
  )
455
507
 
456
508
  # Save provider for status tracking
package/dashboard/run.py CHANGED
@@ -8,15 +8,26 @@ Usage:
8
8
  """
9
9
 
10
10
  import argparse
11
+ import os
11
12
  import sys
12
13
 
13
14
 
14
15
  def main():
15
16
  parser = argparse.ArgumentParser(description="Loki Mode Dashboard Server")
17
+ # Loopback by default, matching server.py:run_server and control.py. This
18
+ # launcher defaulted to 0.0.0.0, which published an UNAUTHENTICATED control
19
+ # plane on every interface: dashboard auth is opt-in
20
+ # (LOKI_ENTERPRISE_AUTH, default false in dashboard/auth.py), so with it
21
+ # unset -- the default -- anyone routable to the host could reach endpoints
22
+ # that stop builds and read receipts. A default must be safe on its own;
23
+ # exposing the network is a decision an operator makes explicitly, so
24
+ # --host 0.0.0.0 and LOKI_DASHBOARD_HOST both still work for the container
25
+ # and Helm paths that genuinely need it.
16
26
  parser.add_argument(
17
27
  "--host",
18
- default="0.0.0.0",
19
- help="Host to bind to (default: 0.0.0.0)",
28
+ default=os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1"),
29
+ help="Host to bind to (default: $LOKI_DASHBOARD_HOST or 127.0.0.1). "
30
+ "Use 0.0.0.0 only with authentication enabled.",
20
31
  )
21
32
  parser.add_argument(
22
33
  "--port",
@@ -0,0 +1,221 @@
1
+ """SCIM 2.0 user provisioning: deprovisioning that actually revokes access.
2
+
3
+ WHY THIS EXISTS. Measured against our own code before writing any: OIDC is real
4
+ here (76 references in dashboard/auth.py) and SAML and SCIM are both ZERO. That
5
+ is the whole enterprise identity gap -- not authentication as a category, which
6
+ we have, but PROVISIONING, which we do not.
7
+
8
+ It matters more than its size suggests. With OIDC alone, an employee removed
9
+ from the IdP stops being able to LOG IN, but any token already issued to them
10
+ keeps working until it expires. For a buyer whose security review asks "show me
11
+ that a terminated employee loses access within minutes", that is the answer that
12
+ fails the review. SCIM is the protocol that answers it.
13
+
14
+ WORTH KNOWING ABOUT THE COMPETITION. Factory ships SCIM. Devin's SCIM is
15
+ documented only for Devin DESKTOP -- the page instructs you to configure it at
16
+ windsurf.com/team/settings under the app name "Windsurf" -- and no page documents
17
+ SCIM provisioning for Devin CLOUD sessions, which get SAML/OIDC plus IdP-group
18
+ sync only. So this is table stakes against one competitor and a genuine gap in
19
+ the other.
20
+
21
+ WHAT THIS IS NOT. It is not a second identity system. It maps IdP group
22
+ membership onto the scope hierarchy dashboard/auth.py already enforces
23
+ (* -> control -> write -> read), so there is exactly one authorization model and
24
+ SCIM only decides who holds which scope. A parallel permission model would
25
+ eventually disagree with the real one, and the disagreement would be invisible
26
+ until an audit.
27
+
28
+ DEPROVISIONING IS SOFT BY DEFAULT, AND THAT IS DELIBERATE. `active: false`
29
+ revokes access immediately but PRESERVES the record, because an audit trail that
30
+ loses the identity of whoever performed past actions is not an audit trail. Hard
31
+ delete is available and separate, so destroying history is always an explicit act.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import os
38
+ from datetime import datetime, timezone
39
+ from typing import Optional
40
+
41
+ SCIM_SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User"
42
+ SCIM_SCHEMA_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
43
+ SCIM_SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error"
44
+
45
+ # IdP group -> our scope. Mirrors the hierarchy in dashboard/auth.py rather than
46
+ # inventing a second one: * grants everything, control grants write and read.
47
+ # An unrecognised group maps to "read", never to a higher scope -- an unknown
48
+ # group is not evidence of privilege, and defaulting upward is how a
49
+ # misconfigured IdP silently grants admin.
50
+ GROUP_SCOPE_MAP = {
51
+ "loki-admins": "*",
52
+ "loki-owners": "*",
53
+ "loki-operators": "control",
54
+ "loki-writers": "write",
55
+ "loki-readers": "read",
56
+ }
57
+ DEFAULT_SCOPE = "read"
58
+
59
+
60
+ def _now():
61
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
62
+
63
+
64
+ def _store_path(loki_dir):
65
+ return os.path.join(loki_dir, "scim", "users.json")
66
+
67
+
68
+ def _load(loki_dir):
69
+ p = _store_path(loki_dir)
70
+ if not os.path.isfile(p):
71
+ return {}
72
+ try:
73
+ with open(p, "r", encoding="utf-8") as fh:
74
+ return json.load(fh)
75
+ except (OSError, ValueError):
76
+ # A corrupt store must not silently become an empty one: returning {}
77
+ # here would read as "no users provisioned" and could re-grant access to
78
+ # someone who was deprovisioned. Signalled to the caller instead.
79
+ return None
80
+
81
+
82
+ def _save(loki_dir, data):
83
+ p = _store_path(loki_dir)
84
+ os.makedirs(os.path.dirname(p), exist_ok=True)
85
+ tmp = p + ".tmp"
86
+ with open(tmp, "w", encoding="utf-8") as fh:
87
+ json.dump(data, fh, indent=2, sort_keys=True)
88
+ fh.write("\n")
89
+ # Atomic replace: a half-written user store is an access-control decision
90
+ # made from corrupt data.
91
+ os.replace(tmp, p)
92
+
93
+
94
+ def scope_for_groups(groups):
95
+ """Highest scope any of the user's groups grants.
96
+
97
+ Highest-wins, because a user in both loki-readers and loki-admins is an
98
+ admin. Unknown groups contribute DEFAULT_SCOPE and never more.
99
+ """
100
+ rank = {"read": 1, "write": 2, "control": 3, "*": 4}
101
+ best = DEFAULT_SCOPE
102
+ for g in groups or []:
103
+ s = GROUP_SCOPE_MAP.get(str(g).strip().lower(), DEFAULT_SCOPE)
104
+ if rank.get(s, 1) > rank.get(best, 1):
105
+ best = s
106
+ return best
107
+
108
+
109
+ def provision(loki_dir, user_name, external_id=None, groups=None, active=True):
110
+ """Create or update a user. Returns a SCIM User resource."""
111
+ if not user_name:
112
+ return {"schemas": [SCIM_SCHEMA_ERROR], "status": "400",
113
+ "detail": "userName is required"}
114
+
115
+ data = _load(loki_dir)
116
+ if data is None:
117
+ return {"schemas": [SCIM_SCHEMA_ERROR], "status": "500",
118
+ "detail": "user store is unreadable; refusing to make an access "
119
+ "decision from corrupt data"}
120
+
121
+ uid = external_id or user_name
122
+ existing = data.get(uid, {})
123
+ rec = {
124
+ "id": uid,
125
+ "userName": user_name,
126
+ "externalId": external_id or "",
127
+ "groups": list(groups or []),
128
+ "scope": scope_for_groups(groups),
129
+ "active": bool(active),
130
+ "created": existing.get("created") or _now(),
131
+ "lastModified": _now(),
132
+ }
133
+ data[uid] = rec
134
+ _save(loki_dir, data)
135
+ return to_scim_user(rec)
136
+
137
+
138
+ def deprovision(loki_dir, uid, hard=False):
139
+ """Revoke access. Soft by default so the audit trail survives.
140
+
141
+ An audit log that cannot say WHO performed an action is not an audit log, so
142
+ a departed user's record is retained by default and merely marked inactive.
143
+ Hard delete exists and is separate, so destroying history is always explicit.
144
+ """
145
+ data = _load(loki_dir)
146
+ if data is None:
147
+ return {"schemas": [SCIM_SCHEMA_ERROR], "status": "500",
148
+ "detail": "user store is unreadable"}
149
+ if uid not in data:
150
+ return {"schemas": [SCIM_SCHEMA_ERROR], "status": "404",
151
+ "detail": f"no such user: {uid}"}
152
+
153
+ if hard:
154
+ data.pop(uid)
155
+ _save(loki_dir, data)
156
+ return {"status": "204", "deleted": uid, "audit_trail_preserved": False}
157
+
158
+ data[uid]["active"] = False
159
+ data[uid]["lastModified"] = _now()
160
+ _save(loki_dir, data)
161
+ return {"status": "200", "deactivated": uid, "audit_trail_preserved": True}
162
+
163
+
164
+ def is_active(loki_dir, uid):
165
+ """Authorization answer for one user. Fails CLOSED on any doubt.
166
+
167
+ An unreadable store, an unknown user, and an inactive user all return False.
168
+ A provisioning system that fails open is worse than none, because it creates
169
+ the belief that deprovisioning works.
170
+ """
171
+ data = _load(loki_dir)
172
+ if data is None:
173
+ return False
174
+ rec = data.get(uid)
175
+ if not rec:
176
+ return False
177
+ return bool(rec.get("active"))
178
+
179
+
180
+ def effective_scope(loki_dir, uid):
181
+ """The scope a user actually holds, or None when they hold none."""
182
+ data = _load(loki_dir)
183
+ if data is None:
184
+ return None
185
+ rec = data.get(uid)
186
+ if not rec or not rec.get("active"):
187
+ return None
188
+ return rec.get("scope") or DEFAULT_SCOPE
189
+
190
+
191
+ def to_scim_user(rec):
192
+ return {
193
+ "schemas": [SCIM_SCHEMA_USER],
194
+ "id": rec["id"],
195
+ "userName": rec["userName"],
196
+ "externalId": rec.get("externalId", ""),
197
+ "active": rec.get("active", False),
198
+ "groups": [{"value": g} for g in rec.get("groups", [])],
199
+ "meta": {
200
+ "resourceType": "User",
201
+ "created": rec.get("created"),
202
+ "lastModified": rec.get("lastModified"),
203
+ },
204
+ # Not part of the SCIM spec, surfaced because an operator reading this
205
+ # response needs to see what the group mapping actually granted rather
206
+ # than deriving it.
207
+ "lokiScope": rec.get("scope"),
208
+ }
209
+
210
+
211
+ def list_users(loki_dir, active_only=False):
212
+ data = _load(loki_dir)
213
+ if data is None:
214
+ return {"schemas": [SCIM_SCHEMA_ERROR], "status": "500",
215
+ "detail": "user store is unreadable"}
216
+ recs = [r for r in data.values() if (not active_only or r.get("active"))]
217
+ return {
218
+ "schemas": [SCIM_SCHEMA_LIST],
219
+ "totalResults": len(recs),
220
+ "Resources": [to_scim_user(r) for r in sorted(recs, key=lambda r: r["id"])],
221
+ }
@@ -1224,6 +1224,28 @@ class WebSocketBoundaryMiddleware:
1224
1224
  # accept-then-drop, which reads to a client as a flaky network.
1225
1225
  await send({"type": "websocket.close", "code": 1008})
1226
1226
  return
1227
+ # Cross-Site WebSocket Hijacking. The address check above proves the
1228
+ # PEER is local; it says nothing about who told that peer to connect.
1229
+ # A browser on any origin can open ws://127.0.0.1:57374/ws -- the
1230
+ # request comes FROM the loopback interface and passes the check
1231
+ # above, and CORS does not apply to WebSocket upgrades, so no
1232
+ # existing control stops it. /ws/collab is writable, so this is not
1233
+ # read-only exposure.
1234
+ #
1235
+ # Same-origin browsers send Origin; non-browser clients (CLI, tests,
1236
+ # health probes) send none. An ABSENT Origin is therefore allowed --
1237
+ # it cannot be forged by a page, since browsers always attach it --
1238
+ # while a PRESENT one must be in the allowlist the HTTP path already
1239
+ # uses. That keeps every scripted client working and closes the
1240
+ # browser-driven path, without a second source of truth for origins.
1241
+ origin = None
1242
+ for raw_name, raw_value in scope.get("headers", []):
1243
+ if raw_name == b"origin":
1244
+ origin = raw_value.decode("latin-1").strip()
1245
+ break
1246
+ if origin and "*" not in _cors_origins and origin not in _cors_origins:
1247
+ await send({"type": "websocket.close", "code": 1008})
1248
+ return
1227
1249
  await self.app(scope, receive, send)
1228
1250
 
1229
1251
 
@@ -4417,7 +4439,8 @@ async def start_build(request: Request, body: StartBuildRequest):
4417
4439
  loki_dir = workspace_dir / ".loki"
4418
4440
  else:
4419
4441
  loki_dir = _get_loki_dir()
4420
- project_dir = loki_dir.parent if loki_dir.name == ".loki" else _Path.cwd()
4442
+ project_dir = (loki_dir.parent if loki_dir.name == ".loki"
4443
+ else (_safe_cwd() or _Path(".")))
4421
4444
  project_dir = project_dir.resolve()
4422
4445
 
4423
4446
  # Legacy no-workspace starts keep their existing single-flight behavior.
@@ -5637,6 +5660,22 @@ _active_project_dir: Optional[str] = None
5637
5660
  _DASHBOARD_AUTOSTARTED: bool = os.environ.get("LOKI_DASHBOARD_AUTOSTARTED") == "1"
5638
5661
 
5639
5662
 
5663
+ def _safe_cwd() -> "_Path | None":
5664
+ """The current directory, or None if it no longer exists.
5665
+
5666
+ os.getcwd() RAISES FileNotFoundError when the working directory has been
5667
+ deleted out from under a live process. A dashboard started inside a temp
5668
+ workspace keeps serving after that workspace is cleaned up, and every
5669
+ endpoint resolving a path then 500s simultaneously -- observed as ~60
5670
+ concurrent 500s including /api/status, whose handler touches almost
5671
+ nothing. Losing the cwd must degrade to a fallback, never to a stack trace.
5672
+ """
5673
+ try:
5674
+ return _Path.cwd()
5675
+ except OSError:
5676
+ return None
5677
+
5678
+
5640
5679
  def _get_loki_dir() -> _Path:
5641
5680
  """Get LOKI_DIR, refreshing from env on each call for consistency.
5642
5681
 
@@ -5661,10 +5700,14 @@ def _get_loki_dir() -> _Path:
5661
5700
  if env_dir and _Path(env_dir).is_absolute():
5662
5701
  return _Path(env_dir)
5663
5702
 
5664
- # Check CWD first
5665
- cwd_loki = _Path.cwd() / ".loki"
5666
- if cwd_loki.is_dir():
5667
- return cwd_loki
5703
+ # Check CWD first. _safe_cwd() rather than _Path.cwd(): this function runs
5704
+ # on nearly every request, and a deleted working directory would otherwise
5705
+ # raise FileNotFoundError here and 500 the entire API at once.
5706
+ _cwd = _safe_cwd()
5707
+ if _cwd is not None:
5708
+ cwd_loki = _cwd / ".loki"
5709
+ if cwd_loki.is_dir():
5710
+ return cwd_loki
5668
5711
 
5669
5712
  # Check home directory fallback
5670
5713
  home_loki = _Path.home() / ".loki"