loki-mode 10.5.17 → 10.5.19

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.
Files changed (125) hide show
  1. package/README.md +877 -30
  2. package/SKILL.md +3 -3
  3. package/VERSION +1 -1
  4. package/autonomy/lib/proof-generator.py +251 -0
  5. package/autonomy/lib/proof-verify.py +1 -0
  6. package/autonomy/loki +52 -6
  7. package/autonomy/run.sh +27 -2
  8. package/dashboard/__init__.py +1 -1
  9. package/dashboard/api_start.py +463 -0
  10. package/dashboard/server.py +31 -0
  11. package/dashboard/static/start.html +100 -0
  12. package/docs/COMPARISON.md +6 -19
  13. package/docs/COMPETITIVE-ANALYSIS.md +6 -41
  14. package/docs/CRASH-REPORTING-PLAN.md +1 -1
  15. package/docs/ENTERPRISE-IDENTITY-ROADMAP.md +1 -1
  16. package/docs/INSTALLATION.md +1 -1
  17. package/docs/PRIVACY.md +1 -1
  18. package/docs/R8-SHAREABLE-TEAM-ASSETS-PLAN.md +1 -1
  19. package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
  20. package/docs/architecture/STATE-MACHINES.md +18 -27
  21. package/docs/architecture/bmad-integration-validation.md +2 -2
  22. package/docs/audit-logging.md +12 -86
  23. package/docs/authentication.md +4 -4
  24. package/docs/authorization.md +15 -47
  25. package/docs/auto-claude-comparison.md +1 -33
  26. package/docs/certification/01-core-concepts/lab.md +3 -3
  27. package/docs/certification/01-core-concepts/lesson.md +11 -22
  28. package/docs/certification/01-core-concepts/quiz.md +7 -7
  29. package/docs/certification/02-enterprise-features/lab.md +0 -2
  30. package/docs/certification/02-enterprise-features/lesson.md +0 -10
  31. package/docs/certification/04-production-deployment/lab.md +1 -1
  32. package/docs/certification/05-troubleshooting/lab.md +2 -2
  33. package/docs/certification/README.md +1 -1
  34. package/docs/certification/answer-key.md +37 -20
  35. package/docs/certification/certification-exam.md +7 -7
  36. package/docs/competitive/emergence-others-analysis.md +3 -3
  37. package/docs/competitive/replit-lovable-analysis.md +2 -2
  38. package/docs/cursor-comparison.md +2 -2
  39. package/docs/dashboard-guide.md +14 -2
  40. package/docs/dev/architecture-reference.md +2 -2
  41. package/docs/dev/project-structure.md +1 -1
  42. package/docs/enterprise/architecture.md +11 -8
  43. package/docs/enterprise/integration-cookbook.md +23 -14
  44. package/docs/enterprise/migration.md +5 -9
  45. package/docs/enterprise/performance.md +3 -6
  46. package/docs/enterprise/sdk-guide.md +1 -1
  47. package/docs/enterprise/security.md +8 -17
  48. package/docs/exit-codes.md +14 -0
  49. package/docs/git-workflow.md +4 -62
  50. package/docs/network-security.md +3 -13
  51. package/docs/plans/ULTRAPLAN-FORGE-BAAS.md +1 -1
  52. package/docs/prd-purple-lab-platform-v2.md +1 -1
  53. package/docs/prd-purple-lab-platform.md +1 -3
  54. package/docs/research-2026-07/GAP-ANALYSIS-BACKLOG.md +1 -1
  55. package/docs/show-hn-post.md +3 -4
  56. package/docs/siem-integration.md +8 -10
  57. package/docs/test-scenarios/edge-cases.md +2 -2
  58. package/docs/test-scenarios/enterprise-scenarios.md +4 -4
  59. package/docs/v10/BOARD.md +2 -0
  60. package/docs/v10/GUIDE.md +5 -3
  61. package/docs/v10/METRICS.md +36 -0
  62. package/docs/v10/PROGRESS.md +35 -0
  63. package/docs/walkthrough/README.md +1 -7
  64. package/loki-ts/dist/loki.js +370 -370
  65. package/mcp/__init__.py +1 -1
  66. package/package.json +1 -1
  67. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  68. package/web-app/dist/assets/{AdminPage-DxGZSpFg.js → AdminPage-BH_7fpoz.js} +1 -1
  69. package/web-app/dist/assets/{Avatar-BZEp23D8.js → Avatar-BzyhrX0s.js} +1 -1
  70. package/web-app/dist/assets/{Badge-DnDwqDLu.js → Badge-CL05xwgC.js} +1 -1
  71. package/web-app/dist/assets/{Button-C9KDP4c1.js → Button-BVHrGLGH.js} +1 -1
  72. package/web-app/dist/assets/{CockpitPage-DRlh3LV2.js → CockpitPage-B9BAEqD-.js} +1 -1
  73. package/web-app/dist/assets/{ComparePage-C_gE45dQ.js → ComparePage-D4grckhY.js} +1 -1
  74. package/web-app/dist/assets/{ErrorBoundary-CL-J9-Cs.js → ErrorBoundary-Bg52u7eg.js} +1 -1
  75. package/web-app/dist/assets/{EvidenceReceiptPanel-DABd176Y.js → EvidenceReceiptPanel-B15kRZbJ.js} +1 -1
  76. package/web-app/dist/assets/{GitHubIssuesPanel-C4lYbm6O.js → GitHubIssuesPanel-DP_BhJSC.js} +1 -1
  77. package/web-app/dist/assets/{GitHubPRsPanel-CgiUixwo.js → GitHubPRsPanel-DJBu83u9.js} +1 -1
  78. package/web-app/dist/assets/{HomePage-DC99RIrz.js → HomePage-6mw4uvLs.js} +2 -2
  79. package/web-app/dist/assets/{LoginPage-Bd72QWNk.js → LoginPage-BWWzFHif.js} +1 -1
  80. package/web-app/dist/assets/{MagicPage-CBouFs2K.js → MagicPage-CSyo7k5t.js} +1 -1
  81. package/web-app/dist/assets/{MetricsPage-c9MyDcBp.js → MetricsPage-Buq03cch.js} +1 -1
  82. package/web-app/dist/assets/{NotFoundPage-ZPhiebiW.js → NotFoundPage-BG8vuV4g.js} +1 -1
  83. package/web-app/dist/assets/{ProjectPage-DKwrALUb.js → ProjectPage-D7eJn--6.js} +3 -3
  84. package/web-app/dist/assets/{ProjectsPage-X1uZKZC_.js → ProjectsPage-DXPycvjA.js} +1 -1
  85. package/web-app/dist/assets/{SettingsPage-CiZKZRto.js → SettingsPage-Blvav87U.js} +1 -1
  86. package/web-app/dist/assets/{ShowcasePage-k_imKfeh.js → ShowcasePage-ZCAKRsNN.js} +1 -1
  87. package/web-app/dist/assets/{SystemSettingsPage-Wx9ESLll.js → SystemSettingsPage-ClzjKfI4.js} +1 -1
  88. package/web-app/dist/assets/{TeamsPage-B7upn-I7.js → TeamsPage-CKp1-WGX.js} +1 -1
  89. package/web-app/dist/assets/{TemplatesPage-oJf_VKgh.js → TemplatesPage-C0yAp2Yl.js} +1 -1
  90. package/web-app/dist/assets/{TerminalOutput-BdfPlgLI.js → TerminalOutput-C3KbV8E7.js} +1 -1
  91. package/web-app/dist/assets/{activity-BcZTsgWH.js → activity-CG5PtpVI.js} +1 -1
  92. package/web-app/dist/assets/{bell-JNEkjWQ2.js → bell-CyLp--NR.js} +1 -1
  93. package/web-app/dist/assets/{bot-DMAJobP_.js → bot-Dp0zY9t7.js} +1 -1
  94. package/web-app/dist/assets/{check-DPTry-z2.js → check-ipranz8L.js} +1 -1
  95. package/web-app/dist/assets/{chevron-left-DSqOEtTV.js → chevron-left-DhYs9D3t.js} +1 -1
  96. package/web-app/dist/assets/{circle-alert-BbL8Vysv.js → circle-alert-BLAxBNgz.js} +1 -1
  97. package/web-app/dist/assets/{clock-BHEr5lGW.js → clock-DzocnapF.js} +1 -1
  98. package/web-app/dist/assets/{cloud-C48PunYo.js → cloud-B3YsIUwq.js} +1 -1
  99. package/web-app/dist/assets/{code-xml-Boa7kRqT.js → code-xml-BPcy_Eqm.js} +1 -1
  100. package/web-app/dist/assets/{database-Eu35Gv7z.js → database-DOuuclZf.js} +1 -1
  101. package/web-app/dist/assets/{dollar-sign-BO9fCnHW.js → dollar-sign-o77cj-xB.js} +1 -1
  102. package/web-app/dist/assets/{file-code-corner-DHsSDIL_.js → file-code-corner-BZ2VMnYA.js} +1 -1
  103. package/web-app/dist/assets/{file-plus-BYiQLe5l.js → file-plus-DeHfGncj.js} +1 -1
  104. package/web-app/dist/assets/{globe-BPZ5sQI2.js → globe-DmIW-phf.js} +1 -1
  105. package/web-app/dist/assets/{hammer-DlOLTsH3.js → hammer-CoW8R8V5.js} +1 -1
  106. package/web-app/dist/assets/{index-BrgkuqSA.js → index-5rCTAnsF.js} +3 -3
  107. package/web-app/dist/assets/{layers-DMNdn1GR.js → layers-DGEk-2RQ.js} +1 -1
  108. package/web-app/dist/assets/{loader-circle-_e9fypHL.js → loader-circle-DXn-F-H5.js} +1 -1
  109. package/web-app/dist/assets/{lock-D_elxMZb.js → lock-D5nAjeUu.js} +1 -1
  110. package/web-app/dist/assets/{package-Bw-7g3JI.js → package-D8Drpzi8.js} +1 -1
  111. package/web-app/dist/assets/{plus-CZeq0pgJ.js → plus-C-0T9dAu.js} +1 -1
  112. package/web-app/dist/assets/{refresh-cw-D0WMyffs.js → refresh-cw-AyYQQPZG.js} +1 -1
  113. package/web-app/dist/assets/{rotate-ccw-CcKyNwXG.js → rotate-ccw-DYbslLfT.js} +1 -1
  114. package/web-app/dist/assets/{scroll-text-C0L9Vqpm.js → scroll-text-CQFQhF_6.js} +1 -1
  115. package/web-app/dist/assets/{server-SgwFv6cd.js → server-DxNytQEJ.js} +1 -1
  116. package/web-app/dist/assets/{shield-alert-D1tESlfI.js → shield-alert-Xj5wGUSK.js} +1 -1
  117. package/web-app/dist/assets/{thumbs-up-bUtUA15O.js → thumbs-up-DW0j9Yxz.js} +1 -1
  118. package/web-app/dist/assets/{trash-2-C9Nv1kKk.js → trash-2-VOdpiJ1e.js} +1 -1
  119. package/web-app/dist/assets/{trending-down-Bh107AJe.js → trending-down-eTizH4_b.js} +1 -1
  120. package/web-app/dist/assets/{trending-up-25BqhLVq.js → trending-up-agYVl7bU.js} +1 -1
  121. package/web-app/dist/assets/{upload-DDYtMbN5.js → upload-DQ3NfXA_.js} +1 -1
  122. package/web-app/dist/assets/{usePolling-BSDT0il_.js → usePolling-BKETnkDT.js} +1 -1
  123. package/web-app/dist/assets/{user-CKpUEUhK.js → user-Cv6BNmOT.js} +1 -1
  124. package/web-app/dist/index.html +1 -1
  125. package/docs/README-FULL.md +0 -977
@@ -0,0 +1,463 @@
1
+ """First-run onboarding and the issue backlog (D51 Phase A).
2
+
3
+ Three onboarding steps (provider, GitHub PAT, repo) and a backlog that queues
4
+ v10 issue-mode runs through the existing launcher: `bin/loki owner/repo#N
5
+ --json` with LOKI_ENGINE=v10, one git worktree per issue, so each run gets its
6
+ own branch and PR from the engine.
7
+
8
+ Secrets: the PAT and any provider API key live in ~/.loki/credentials/ (dir
9
+ 0700, files 0600). No response, log line or event carries them; the PAT reaches
10
+ GitHub only in an Authorization header and a child process env, never a URL or
11
+ argv.
12
+
13
+ ponytail: run state is in memory (a dashboard restart forgets the queue; the
14
+ runs themselves keep going and their worktrees keep the receipts). The
15
+ v10 engine has no resume-with-answer (--resume was removed), so a BLOCKED
16
+ question is shown with the re-run command instead of an answer box.
17
+ """
18
+ import json
19
+ import logging
20
+ import os
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import threading
25
+ import time
26
+ import urllib.error
27
+ import urllib.request
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ from fastapi import APIRouter, Depends, HTTPException
32
+ from pydantic import BaseModel
33
+
34
+ from . import auth
35
+
36
+ logger = logging.getLogger(__name__)
37
+ router = APIRouter(tags=["start"])
38
+
39
+ START_HTML = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "start.html")
40
+ _LOKI_BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "loki")
41
+ _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
42
+ _TOKEN_RE = re.compile(r"^[A-Za-z0-9_\-]{10,255}$")
43
+ _KEY_RE = re.compile(r"^[A-Za-z0-9_\-.]{10,512}$")
44
+ _PROVIDER_KEY_ENV = {"claude": "ANTHROPIC_API_KEY", "codex": "OPENAI_API_KEY"}
45
+ _PROVIDER_BIN = {"claude": "claude", "codex": "codex"}
46
+ _SECRET_FILES = {"github": "github", "claude_api_key": "claude_api_key", "codex_api_key": "codex_api_key"}
47
+ _lock = threading.RLock()
48
+
49
+
50
+ def _pick(table: dict, key):
51
+ """Return the table's own constant equal to key, so request data never reaches a sink."""
52
+ for const in table.values():
53
+ if const == key:
54
+ return const
55
+ return None
56
+
57
+
58
+ def _secret_path(name: str) -> Path:
59
+ const = _pick(_SECRET_FILES, name)
60
+ if const is None:
61
+ raise ValueError("unknown secret name")
62
+ base = _creds_dir().resolve()
63
+ dest = (base / const).resolve()
64
+ if dest.parent != base:
65
+ raise ValueError("secret path escapes credentials dir")
66
+ return dest
67
+ _RUNS: dict = {}
68
+
69
+
70
+ class GitHubError(Exception):
71
+ pass
72
+
73
+
74
+ # --- storage -----------------------------------------------------------------
75
+
76
+ def _home() -> Path:
77
+ return Path(os.path.expanduser("~")) / ".loki"
78
+
79
+
80
+ def _creds_dir() -> Path:
81
+ d = _home() / "credentials"
82
+ d.mkdir(parents=True, exist_ok=True)
83
+ os.chmod(d, 0o700)
84
+ return d
85
+
86
+
87
+ def _write_secret(name: str, value: str) -> None:
88
+ dest = _secret_path(name)
89
+ tmp = dest.with_name(".%s.%d.tmp" % (dest.name, os.getpid()))
90
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
91
+ try:
92
+ os.fchmod(fd, 0o600)
93
+ os.write(fd, (value + "\n").encode())
94
+ finally:
95
+ os.close(fd)
96
+ os.replace(tmp, dest)
97
+
98
+
99
+ def _read_secret(name: str) -> Optional[str]:
100
+ try:
101
+ return _secret_path(name).read_text().strip() or None
102
+ except (OSError, ValueError):
103
+ return None
104
+
105
+
106
+ def _cfg() -> dict:
107
+ try:
108
+ return json.loads((_home() / "onboarding.json").read_text())
109
+ except (OSError, ValueError):
110
+ return {}
111
+
112
+
113
+ def _save_cfg(**kv) -> None:
114
+ c = _cfg()
115
+ c.update(kv)
116
+ _home().mkdir(parents=True, exist_ok=True)
117
+ (_home() / "onboarding.json").write_text(json.dumps(c))
118
+
119
+
120
+ # --- external edges (mocked in tests) ----------------------------------------
121
+
122
+ def _gh_api(path: str, token: str):
123
+ req = urllib.request.Request(
124
+ "https://api.github.com" + path,
125
+ headers={"Authorization": "Bearer " + token, "Accept": "application/vnd.github+json",
126
+ "User-Agent": "loki-mode"})
127
+ try:
128
+ with urllib.request.urlopen(req, timeout=15) as r:
129
+ return json.load(r)
130
+ except urllib.error.HTTPError as e:
131
+ raise GitHubError("GitHub returned HTTP %d" % e.code)
132
+ except (urllib.error.URLError, OSError, ValueError):
133
+ raise GitHubError("could not reach GitHub")
134
+
135
+
136
+ def _cli_version(name: str) -> Optional[str]:
137
+ """Version probe only. Never reads the CLI's credential files."""
138
+ binary = _pick(_PROVIDER_BIN, name)
139
+ if binary is None or not shutil.which(binary):
140
+ return None
141
+ try:
142
+ out = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=8)
143
+ except (OSError, subprocess.SubprocessError):
144
+ return None
145
+ return (out.stdout.strip().splitlines() or ["unknown"])[0] if out.returncode == 0 else None
146
+
147
+
148
+ def _git(*args, cwd=None, env=None) -> str:
149
+ r = subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True, timeout=300)
150
+ if r.returncode != 0:
151
+ raise RuntimeError("git %s failed" % args[0])
152
+ return r.stdout.strip()
153
+
154
+
155
+ def _project_dir() -> str:
156
+ return os.environ.get("LOKI_PROJECT_DIR") or os.getcwd()
157
+
158
+
159
+ def _prepare_workdir(repo: str, number: int, token: str) -> str:
160
+ """One git worktree per issue, off the repo's default branch."""
161
+ env = dict(os.environ, GH_TOKEN=token, GIT_TERMINAL_PROMPT="0")
162
+ slug = repo.replace("/", "__")
163
+ base = _project_dir()
164
+ try:
165
+ origin = _git("remote", "get-url", "origin", cwd=base)
166
+ except (RuntimeError, OSError):
167
+ origin = ""
168
+ if not re.search(r"[:/]" + re.escape(repo) + r"(\.git)?/?$", origin):
169
+ base = str(_home() / "repos" / slug)
170
+ if not os.path.isdir(base):
171
+ os.makedirs(os.path.dirname(base), exist_ok=True)
172
+ subprocess.run(["gh", "repo", "clone", "--", repo, base], env=env, check=True,
173
+ capture_output=True, timeout=600)
174
+ try:
175
+ _git("fetch", "origin", cwd=base, env=env)
176
+ start = "origin/HEAD"
177
+ _git("rev-parse", "--verify", start, cwd=base)
178
+ except (RuntimeError, OSError):
179
+ start = "HEAD"
180
+ stamp = time.strftime("%Y%m%d%H%M%S")
181
+ wt = _home() / "worktrees" / slug / ("issue-%d-%s" % (number, stamp))
182
+ wt.parent.mkdir(parents=True, exist_ok=True)
183
+ _git("worktree", "add", "-b", "loki/backlog-%d-%s" % (number, stamp), str(wt), start, cwd=base)
184
+ return str(wt)
185
+
186
+
187
+ def _out_path(workdir: str) -> Path:
188
+ """Run output lives beside, never inside, the worktree the engine commits from."""
189
+ w = Path(workdir)
190
+ d = _home() / "backlog"
191
+ d.mkdir(parents=True, exist_ok=True)
192
+ return d / ("%s__%s.out" % (w.parent.name, w.name))
193
+
194
+
195
+ def launch_run(repo: str, number: int, workdir: str, env: dict):
196
+ """The existing launcher: `loki owner/repo#N --json` in the issue's worktree."""
197
+ with open(_out_path(workdir), "w") as out:
198
+ return subprocess.Popen([_LOKI_BIN, "%s#%d" % (repo, number), "--json"], cwd=workdir, env=env,
199
+ stdout=out, stderr=subprocess.STDOUT, start_new_session=True)
200
+
201
+
202
+ def _read_outcome(workdir: str, rc: int) -> dict:
203
+ run_id, outcome = None, None
204
+ try:
205
+ j = json.loads(_out_path(workdir).read_text().strip().splitlines()[-1])
206
+ run_id, outcome = j.get("run_id"), j.get("outcome")
207
+ except (OSError, ValueError, IndexError, AttributeError):
208
+ pass
209
+ pr_url, question = None, None
210
+ runs = Path(workdir, ".loki", "runs")
211
+ ev = runs / run_id / "events.jsonl" if run_id else None
212
+ if ev is None or not ev.is_file():
213
+ found = sorted(runs.glob("*/events.jsonl")) if runs.is_dir() else []
214
+ ev = found[-1] if found else None
215
+ if ev:
216
+ for line in ev.read_text(errors="replace").splitlines():
217
+ try:
218
+ e = json.loads(line)
219
+ except ValueError:
220
+ continue
221
+ d = e.get("data") or {}
222
+ if e.get("type") == "pr.opened" and d.get("url"):
223
+ pr_url = d["url"] if str(d["url"]).startswith("https://") else None
224
+ if e.get("type") == "stage.completed" and e.get("stage") == "implement" and d.get("spec_conflict_reason"):
225
+ question = str(d["spec_conflict_reason"])
226
+ if rc == 0:
227
+ return {"status": "pr_open", "pr_url": pr_url} if pr_url else {"status": "done", "detail": outcome or "no change needed"}
228
+ if rc == 4:
229
+ return {"status": "blocked", "question": question or "see the run receipt"}
230
+ return {"status": "failed", "detail": "%s (exit %s)" % (outcome or "FAILED", rc)}
231
+
232
+
233
+ # --- onboarding --------------------------------------------------------------
234
+
235
+ def _scope(s):
236
+ return [Depends(auth.require_scope(s))]
237
+
238
+
239
+ def _github() -> dict:
240
+ tok = _read_secret("github")
241
+ return {"configured": bool(tok), "login": _cfg().get("github_login") if tok else None}
242
+
243
+
244
+ class ProviderIn(BaseModel):
245
+ provider: str
246
+ api_key: Optional[str] = None
247
+
248
+
249
+ class TokenIn(BaseModel):
250
+ token: str
251
+
252
+
253
+ class RepoIn(BaseModel):
254
+ repo: str
255
+
256
+
257
+ class RunIn(BaseModel):
258
+ issues: list[int] = []
259
+ all: bool = False
260
+
261
+
262
+ @router.get("/api/onboarding/state", dependencies=_scope("read"))
263
+ def onboarding_state():
264
+ detected = []
265
+ for n in ("claude", "codex"):
266
+ v = _cli_version(n)
267
+ if v:
268
+ detected.append({"name": n, "version": v})
269
+ c = _cfg()
270
+ return {"provider": {"configured": bool(c.get("provider")), "name": c.get("provider"), "detected": detected},
271
+ "github": _github(), "repo": c.get("repo")}
272
+
273
+
274
+ @router.post("/api/onboarding/provider", dependencies=_scope("control"))
275
+ def set_provider(body: ProviderIn):
276
+ if body.provider not in _PROVIDER_KEY_ENV:
277
+ raise HTTPException(400, "provider must be claude or codex")
278
+ if body.api_key:
279
+ if not _KEY_RE.match(body.api_key):
280
+ raise HTTPException(400, "api key format not accepted")
281
+ _write_secret(body.provider + "_api_key", body.api_key)
282
+ elif not _cli_version(body.provider):
283
+ raise HTTPException(400, "%s CLI not found on PATH; install it or provide an API key" % body.provider)
284
+ _save_cfg(provider=body.provider)
285
+ return {"configured": True, "name": body.provider}
286
+
287
+
288
+ @router.post("/api/onboarding/github", dependencies=_scope("control"))
289
+ def set_github(body: TokenIn):
290
+ tok = body.token.strip()
291
+ if not _TOKEN_RE.match(tok):
292
+ raise HTTPException(400, "token format not accepted")
293
+ try:
294
+ login = _gh_api("/user", tok).get("login")
295
+ except GitHubError as e:
296
+ raise HTTPException(400, str(e))
297
+ _write_secret("github", tok)
298
+ _save_cfg(github_login=login)
299
+ return {"configured": True, "login": login}
300
+
301
+
302
+ def _token() -> str:
303
+ tok = _read_secret("github")
304
+ if not tok:
305
+ raise HTTPException(409, "GitHub is not connected yet")
306
+ return tok
307
+
308
+
309
+ @router.get("/api/onboarding/repos", dependencies=_scope("read"))
310
+ def list_repos():
311
+ try:
312
+ rows = _gh_api("/user/repos?per_page=100&sort=updated", _token())
313
+ except GitHubError as e:
314
+ raise HTTPException(502, str(e))
315
+ return {"repos": [r["full_name"] for r in rows]}
316
+
317
+
318
+ @router.post("/api/onboarding/repo", dependencies=_scope("control"))
319
+ def set_repo(body: RepoIn):
320
+ if not _REPO_RE.match(body.repo) or ".." in body.repo or body.repo.startswith("-"):
321
+ raise HTTPException(400, "repo must look like owner/repo")
322
+ _save_cfg(repo=body.repo)
323
+ return {"repo": body.repo}
324
+
325
+
326
+ # --- backlog -----------------------------------------------------------------
327
+
328
+ def _size(body: str) -> str:
329
+ # ponytail: no issue-text sizing rule found in autonomy/ or dashboard/ (grep size_estimate,
330
+ # estimate_size); body length is the proxy. Swap in a real rule if one appears.
331
+ n = len(body or "")
332
+ return "S" if n < 500 else "M" if n < 2000 else "L"
333
+
334
+
335
+ def _open_issues() -> list:
336
+ repo = _cfg().get("repo")
337
+ if not repo:
338
+ raise HTTPException(409, "choose a repo first")
339
+ try:
340
+ rows = _gh_api("/repos/%s/issues?state=open&per_page=100" % repo, _token())
341
+ except GitHubError as e:
342
+ raise HTTPException(502, str(e))
343
+ return [{"number": r["number"], "title": r.get("title", ""), "url": r.get("html_url"),
344
+ "labels": [l["name"] for l in r.get("labels", [])], "size": _size(r.get("body"))}
345
+ for r in rows if "pull_request" not in r]
346
+
347
+
348
+ @router.get("/api/backlog/issues", dependencies=_scope("read"))
349
+ def backlog_issues():
350
+ return {"repo": _cfg().get("repo"), "issues": _open_issues()}
351
+
352
+
353
+ def _concurrency() -> int:
354
+ # ponytail: env now; loki.yaml (D51 item 2) owns this when it lands. No
355
+ # provider rate-limit mechanism exists to honour, so this is the only cap.
356
+ try:
357
+ return max(1, int(os.environ.get("LOKI_BACKLOG_CONCURRENCY", "2")))
358
+ except ValueError:
359
+ return 2
360
+
361
+
362
+ def _child_env(token: str) -> dict:
363
+ e = dict(os.environ, GH_TOKEN=token, GITHUB_TOKEN=token, LOKI_ENGINE="v10", LOKI_NO_BROWSER="1")
364
+ prov = _cfg().get("provider")
365
+ if prov:
366
+ e["LOKI_PROVIDER"] = prov
367
+ key = _read_secret(prov + "_api_key")
368
+ if key:
369
+ e[_PROVIDER_KEY_ENV[prov]] = key
370
+ return e
371
+
372
+
373
+ def _pump() -> None:
374
+ """Reap finished runs, then start queued ones up to the concurrency cap.
375
+
376
+ Clone/worktree prep happens outside the lock (a first clone can take
377
+ minutes); a row is marked "preparing" so a concurrent pump cannot double-start it.
378
+ """
379
+ with _lock:
380
+ for r in _RUNS.values():
381
+ if r["status"] == "running" and r["proc"].poll() is not None:
382
+ r.update(_read_outcome(r["workdir"], r["proc"].returncode))
383
+ busy = sum(1 for r in _RUNS.values() if r["status"] in ("running", "preparing"))
384
+ batch = [r for r in _RUNS.values() if r["status"] == "queued"][:max(0, _concurrency() - busy)]
385
+ for r in batch:
386
+ r["status"] = "preparing"
387
+ repo, token = _cfg().get("repo"), _read_secret("github")
388
+ for r in batch:
389
+ try:
390
+ wd = _prepare_workdir(repo, r["number"], token)
391
+ proc = launch_run(repo, r["number"], wd, _child_env(token))
392
+ with _lock:
393
+ r.update(workdir=wd, proc=proc, status="running")
394
+ except Exception as e: # never echo env or token
395
+ logger.error("backlog launch failed for #%s: %s", r["number"], type(e).__name__)
396
+ with _lock:
397
+ r.update(status="failed", detail="could not start: %s" % type(e).__name__)
398
+
399
+
400
+ _thread = None
401
+
402
+
403
+ def _ensure_pump_thread() -> None:
404
+ """Advance the queue without a browser polling (close the tab, runs still drain)."""
405
+ global _thread
406
+ with _lock:
407
+ if _thread is not None and _thread.is_alive():
408
+ return
409
+
410
+ def loop():
411
+ while True:
412
+ time.sleep(3)
413
+ try:
414
+ _pump()
415
+ except Exception as e:
416
+ logger.error("backlog pump failed: %s", type(e).__name__)
417
+ with _lock:
418
+ if not any(r["status"] in ("queued", "preparing", "running") for r in _RUNS.values()):
419
+ return
420
+
421
+ _thread = threading.Thread(target=loop, daemon=True, name="backlog-pump")
422
+ _thread.start()
423
+
424
+
425
+ @router.post("/api/backlog/run", dependencies=_scope("control"))
426
+ def backlog_run(body: RunIn):
427
+ _token()
428
+ issues = {i["number"]: i for i in _open_issues()}
429
+ nums = list(issues) if body.all else [n for n in body.issues if n in issues]
430
+ queued = []
431
+ with _lock:
432
+ for n in nums:
433
+ if _RUNS.get(n, {}).get("status") in ("queued", "preparing", "running"):
434
+ continue
435
+ _RUNS[n] = {"number": n, "title": issues[n]["title"], "status": "queued", "proc": None, "workdir": None}
436
+ queued.append(n)
437
+ _pump()
438
+ _ensure_pump_thread()
439
+ return {"queued": queued}
440
+
441
+
442
+ @router.get("/api/backlog/status", dependencies=_scope("read"))
443
+ def backlog_status():
444
+ _pump()
445
+ repo = _cfg().get("repo")
446
+ out = []
447
+ with _lock:
448
+ for r in _RUNS.values():
449
+ row = {k: r.get(k) for k in ("number", "title", "status", "pr_url", "question", "detail")}
450
+ if r["status"] == "blocked":
451
+ row["resume_hint"] = ("The engine cannot resume with an answer (--resume was removed). "
452
+ "Answer on the issue, then re-run: loki %s#%d" % (repo, r["number"]))
453
+ out.append(row)
454
+ return {"repo": repo, "concurrency": _concurrency(), "issues": out}
455
+
456
+
457
+ def reset_runs_for_tests() -> None:
458
+ with _lock:
459
+ _RUNS.clear()
460
+
461
+
462
+ def reset_for_tests() -> None:
463
+ reset_runs_for_tests()
@@ -1264,6 +1264,14 @@ class WebSocketBoundaryMiddleware:
1264
1264
  app.add_middleware(WebSocketBoundaryMiddleware)
1265
1265
 
1266
1266
 
1267
+ _bind_host_override: Optional[str] = None
1268
+
1269
+
1270
+ def _bound_non_loopback() -> bool:
1271
+ bind = (_bind_host_override or os.environ.get("LOKI_DASHBOARD_HOST", "")).strip().lower()
1272
+ return bool(bind) and bind not in ("127.0.0.1", "localhost", "::1", "[::1]")
1273
+
1274
+
1267
1275
  @app.middleware("http")
1268
1276
  async def dashboard_control_boundary(request: Request, call_next):
1269
1277
  # EVERY mutation, plus reads that expose operational or credential-adjacent
@@ -1273,6 +1281,17 @@ async def dashboard_control_boundary(request: Request, call_next):
1273
1281
  #
1274
1282
  # /health and /metrics are intentionally NOT in the sensitive list, so a
1275
1283
  # container health probe and a Prometheus scrape keep working unconfigured.
1284
+ # DNS rebinding: a page rebound to 127.0.0.1 sends its own Host, which the
1285
+ # origin check would trust. Refuse any Host not loopback or explicitly allowed.
1286
+ if request.method == "GET" and request.url.path in ("/health", "/metrics"):
1287
+ return await call_next(request) # probes send Host = pod IP
1288
+ host = request.headers.get("host", "").strip().lower()
1289
+ host = host[:host.index("]") + 1] if host.startswith("[") else host.rsplit(":", 1)[0]
1290
+ extra = {h.strip().lower() for h in os.environ.get("LOKI_DASHBOARD_ALLOWED_HOSTS", "").split(",") if h.strip()}
1291
+ # Non-loopback bind with auth on: any Host is fine (auth is the boundary).
1292
+ open_host = _bound_non_loopback() and (auth.ENTERPRISE_AUTH_ENABLED or auth.OIDC_ENABLED)
1293
+ if not open_host and host not in {"127.0.0.1", "localhost", "[::1]"} | extra:
1294
+ return JSONResponse(status_code=403, content={"detail": "host not allowed"})
1276
1295
  gated = request.method in ("POST", "PUT", "PATCH", "DELETE")
1277
1296
  if gated and not browser_mutation_origin_allowed(request, _cors_origins):
1278
1297
  return JSONResponse(
@@ -1324,6 +1343,16 @@ except ImportError as _operator_exc: # pragma: no cover - optional dep absent
1324
1343
  else:
1325
1344
  app.include_router(api_operator_router)
1326
1345
 
1346
+ # D51 Phase A: first-run onboarding + backlog API and its /start page.
1347
+ from .api_start import router as api_start_router, START_HTML as _START_HTML
1348
+ app.include_router(api_start_router)
1349
+
1350
+
1351
+ @app.get("/start", include_in_schema=False, dependencies=[Depends(auth.require_scope("read"))])
1352
+ async def serve_start_page():
1353
+ return FileResponse(_START_HTML, media_type="text/html")
1354
+
1355
+
1327
1356
  # Phase Merge-4: Mount Purple Lab FastAPI app under /lab/ so it appears as a
1328
1357
  # sidebar entry in Dashboard. Same `app` is also wrapped by `standalone_app`
1329
1358
  # in web-app/server.py for `loki web` (port 57375). One source of truth, no
@@ -13076,11 +13105,13 @@ async def serve_spa_catchall(full_path: str):
13076
13105
  def run_server(host: str = None, port: int = None) -> None:
13077
13106
  """Run the dashboard server."""
13078
13107
  import uvicorn
13108
+ global _bind_host_override
13079
13109
  if host is None:
13080
13110
  # Default to localhost-only for security
13081
13111
  host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
13082
13112
  if port is None:
13083
13113
  port = _safe_int_env("LOKI_DASHBOARD_PORT", 57374)
13114
+ _bind_host_override = host
13084
13115
 
13085
13116
  uvicorn_kwargs = {
13086
13117
  "host": host,
@@ -0,0 +1,100 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Loki Mode - Get started</title>
7
+ <style>
8
+ :root{--bg:#fafafa;--fg:#1a1a24;--mut:#6b6b7a;--card:#fff;--bd:#dcdce4;--acc:#553DE9;--ok:#1a7f37;--bad:#c62828;--warn:#9a6700}
9
+ @media (prefers-color-scheme:dark){:root{--bg:#14141b;--fg:#ececf3;--mut:#9a9aab;--card:#1d1d27;--bd:#33333f;--acc:#8b78ff;--ok:#4ac26b;--bad:#ff6b6b;--warn:#e3b341}}
10
+ body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 system-ui,sans-serif}
11
+ main{max-width:860px;margin:0 auto;padding:24px 16px}
12
+ h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:0 0 8px}
13
+ .card{background:var(--card);border:1px solid var(--bd);border-radius:8px;padding:16px;margin:16px 0}
14
+ .card.off{opacity:.55}
15
+ .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0}
16
+ input,select{padding:8px;border:1px solid var(--bd);border-radius:6px;background:var(--bg);color:var(--fg);min-width:220px}
17
+ button{padding:8px 14px;border:0;border-radius:6px;background:var(--acc);color:#fff;cursor:pointer}
18
+ button.sec{background:transparent;color:var(--fg);border:1px solid var(--bd)}
19
+ .mut{color:var(--mut)}.ok{color:var(--ok)}.bad{color:var(--bad)}
20
+ table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--bd);vertical-align:top}
21
+ .tag{display:inline-block;padding:0 6px;border:1px solid var(--bd);border-radius:10px;font-size:12px;margin-right:4px}
22
+ .st{font-weight:600}.st.blocked,.st.failed{color:var(--bad)}.st.pr_open,.st.done{color:var(--ok)}.st.running{color:var(--acc)}.st.queued{color:var(--warn)}
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <main>
27
+ <h1>Loki Mode</h1>
28
+ <p class="mut">Connect once, then complete your backlog. Each issue gets its own worktree, branch and PR.</p>
29
+
30
+ <section class="card" id="s1"><h2>1. Provider</h2>
31
+ <div id="s1-state" class="mut">Checking...</div>
32
+ <div class="row"><select id="prov"><option value="claude">claude</option><option value="codex">codex</option></select>
33
+ <input id="key" type="password" autocomplete="off" placeholder="API key (optional if the CLI is logged in)">
34
+ <button id="s1-go">Use provider</button></div><div id="s1-msg"></div></section>
35
+
36
+ <section class="card off" id="s2"><h2>2. GitHub</h2>
37
+ <div id="s2-state" class="mut">Not connected</div>
38
+ <div class="row"><input id="pat" type="password" autocomplete="off" placeholder="Personal access token (repo scope)">
39
+ <button id="s2-go">Connect</button></div>
40
+ <div class="mut">Stored on this machine only (mode 0600). It is never shown again.</div><div id="s2-msg"></div></section>
41
+
42
+ <section class="card off" id="s3"><h2>3. Repository</h2>
43
+ <div class="row"><select id="repos"></select><input id="repo" placeholder="or type owner/repo">
44
+ <button id="s3-go">Choose</button></div><div id="s3-msg"></div></section>
45
+
46
+ <section class="card off" id="s4"><h2>Backlog <span id="bl-repo" class="mut"></span></h2>
47
+ <div class="row"><button id="sel">Complete selected</button><button id="all">Complete all</button>
48
+ <button class="sec" id="refresh">Refresh</button><span class="mut" id="conc"></span></div>
49
+ <table><thead><tr><th></th><th>#</th><th>Issue</th><th>Size</th><th>Status</th></tr></thead><tbody id="rows"></tbody></table>
50
+ <div id="bl-msg"></div></section>
51
+ </main>
52
+ <script>
53
+ const $=id=>document.getElementById(id);
54
+ const api=async(m,p,b)=>{const r=await fetch(p,{method:m,headers:{'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});
55
+ let j={};try{j=await r.json()}catch(e){}if(!r.ok)throw new Error(j.detail||('HTTP '+r.status));return j};
56
+ const say=(id,t,bad)=>{$(id).textContent=t;$(id).className=bad?'bad':'ok'};
57
+ const el=(t,txt,cls)=>{const e=document.createElement(t);if(txt!=null)e.textContent=txt;if(cls)e.className=cls;return e};
58
+ let issues=[],status={};
59
+ async function state(){
60
+ const s=await api('GET','/api/onboarding/state');
61
+ $('s1-state').textContent=s.provider.configured?('Using '+s.provider.name):
62
+ (s.provider.detected.length?'Detected: '+s.provider.detected.map(d=>d.name+' '+d.version).join(', '):'No claude or codex CLI found; enter an API key.');
63
+ if(s.provider.detected.length&&!s.provider.configured)$('prov').value=s.provider.detected[0].name;
64
+ $('s2').classList.toggle('off',!s.provider.configured);
65
+ $('s2-state').textContent=s.github.configured?('Connected as '+s.github.login):'Not connected';
66
+ $('s3').classList.toggle('off',!s.github.configured);
67
+ if(s.github.configured&&!$('repos').options.length){try{const r=await api('GET','/api/onboarding/repos');
68
+ r.repos.forEach(x=>$('repos').appendChild(el('option',x)))}catch(e){say('s3-msg',e.message,1)}}
69
+ $('s4').classList.toggle('off',!s.repo);
70
+ if(s.repo){$('bl-repo').textContent=s.repo;if(!issues.length)await loadIssues()}
71
+ }
72
+ async function loadIssues(){try{issues=(await api('GET','/api/backlog/issues')).issues;render()}catch(e){say('bl-msg',e.message,1)}}
73
+ function render(){
74
+ const tb=$('rows');tb.replaceChildren();
75
+ issues.forEach(i=>{const st=status[i.number]||{};const tr=el('tr');
76
+ const cb=el('input');cb.type='checkbox';cb.dataset.n=i.number;cb.style.minWidth='0';tr.appendChild(el('td')).appendChild(cb);
77
+ tr.appendChild(el('td','#'+i.number));
78
+ const td=el('td');const a=el('a',i.title);a.href=i.url||'#';a.target='_blank';a.rel='noopener';td.appendChild(a);
79
+ i.labels.forEach(l=>td.appendChild(el('span',l,'tag')));tr.appendChild(td);
80
+ tr.appendChild(el('td',i.size));
81
+ const sc=el('td');sc.appendChild(el('span',st.status||'-','st '+(st.status||'')));
82
+ if(st.pr_url){sc.appendChild(document.createTextNode(' '));const p=el('a','PR');p.href=st.pr_url;p.target='_blank';p.rel='noopener';sc.appendChild(p)}
83
+ if(st.question)sc.appendChild(el('div','Question: '+st.question,'mut'));
84
+ if(st.resume_hint)sc.appendChild(el('div',st.resume_hint,'mut'));
85
+ if(st.detail)sc.appendChild(el('div',st.detail,'mut'));
86
+ tr.appendChild(sc);tb.appendChild(tr)})}
87
+ async function poll(){try{const s=await api('GET','/api/backlog/status');status={};s.issues.forEach(i=>status[i.number]=i);
88
+ $('conc').textContent='concurrency '+s.concurrency;if(issues.length)render()}catch(e){}}
89
+ async function go(btn,msg,fn){btn.disabled=true;try{await fn();say(msg,'Done')}catch(e){say(msg,e.message,1)}btn.disabled=false;await state()}
90
+ $('s1-go').onclick=e=>go(e.target,'s1-msg',async()=>{await api('POST','/api/onboarding/provider',{provider:$('prov').value,api_key:$('key').value||null});$('key').value=''});
91
+ $('s2-go').onclick=e=>go(e.target,'s2-msg',async()=>{await api('POST','/api/onboarding/github',{token:$('pat').value});$('pat').value=''});
92
+ $('s3-go').onclick=e=>go(e.target,'s3-msg',async()=>{await api('POST','/api/onboarding/repo',{repo:$('repo').value||$('repos').value});issues=[]});
93
+ $('sel').onclick=e=>go(e.target,'bl-msg',async()=>{const n=[...document.querySelectorAll('#rows input:checked')].map(c=>+c.dataset.n);
94
+ if(!n.length)throw new Error('Select at least one issue');await api('POST','/api/backlog/run',{issues:n});await poll()});
95
+ $('all').onclick=e=>go(e.target,'bl-msg',async()=>{await api('POST','/api/backlog/run',{all:true});await poll()});
96
+ $('refresh').onclick=loadIssues;
97
+ state();setInterval(poll,3000);
98
+ </script>
99
+ </body>
100
+ </html>