loki-mode 10.6.8 → 10.6.9

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 (101) hide show
  1. package/README.md +14 -13
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/backlog.py +167 -36
  5. package/autonomy/lib/workspace.py +133 -39
  6. package/autonomy/lib/workspace_comment.py +91 -0
  7. package/autonomy/loki +85 -17
  8. package/bin/loki +3 -3
  9. package/completions/_loki +1 -0
  10. package/completions/loki.bash +1 -1
  11. package/dashboard/__init__.py +1 -1
  12. package/dashboard/api_start.py +26 -1
  13. package/docs/INSTALLATION.md +1 -1
  14. package/docs/WORKSPACES.md +11 -4
  15. package/docs/control-plane-container.md +8 -3
  16. package/docs/dashboard-guide.md +4 -0
  17. package/docs/exit-codes.md +1 -0
  18. package/docs/slack.md +12 -4
  19. package/docs/trackers.md +27 -0
  20. package/docs/v10/BOARD.md +64 -29
  21. package/docs/v10/CONTROL-PLANE.md +14 -7
  22. package/docs/v10/D51-PHASE-B.md +3 -1
  23. package/docs/v10/D61-SPEED.md +4 -0
  24. package/docs/v10/D63-CARDS.md +354 -0
  25. package/docs/v10/DECISIONS.md +39 -0
  26. package/docs/v10/PROGRESS.md +50 -0
  27. package/loki-ts/dist/loki.js +583 -564
  28. package/mcp/__init__.py +1 -1
  29. package/mcp/tests/test_v10_tools.py +56 -0
  30. package/mcp/v10_tools.py +13 -3
  31. package/package.json +1 -1
  32. package/packages/control-plane/dist/server.js +76 -6
  33. package/packages/control-plane/drizzle/0001_group_id.sql +3 -0
  34. package/packages/control-plane/drizzle/meta/_journal.json +8 -1
  35. package/packages/control-plane/src/db/schema.ts +3 -0
  36. package/packages/control-plane/src/server/answer.ts +1 -1
  37. package/packages/control-plane/src/server/app.ts +6 -2
  38. package/packages/control-plane/src/server/auth.ts +51 -0
  39. package/packages/control-plane/src/server/runs.ts +4 -3
  40. package/packages/control-plane/src/server/serve.ts +11 -1
  41. package/packages/control-plane/src/shipper/discover.ts +29 -0
  42. package/packages/control-plane/ui/dist/assets/{index-53E32yrO.js → index-Jt31nrK-.js} +24 -24
  43. package/packages/control-plane/ui/dist/index.html +1 -1
  44. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  45. package/web-app/dist/assets/{AdminPage-DN_pe8vo.js → AdminPage-CZKZLuWU.js} +1 -1
  46. package/web-app/dist/assets/{Avatar-BnQsr8z-.js → Avatar-Dcy9kO2E.js} +1 -1
  47. package/web-app/dist/assets/{Badge-DzERiZn2.js → Badge-DUotUSyx.js} +1 -1
  48. package/web-app/dist/assets/{Button-CaaoxQAt.js → Button-CFz_zfMZ.js} +1 -1
  49. package/web-app/dist/assets/{CockpitPage-SXhnysJA.js → CockpitPage-BhRw_oiM.js} +1 -1
  50. package/web-app/dist/assets/{ComparePage-krxTCFJY.js → ComparePage-DfnsRTTC.js} +1 -1
  51. package/web-app/dist/assets/{ErrorBoundary-BL96ESMc.js → ErrorBoundary-Dn0gUu3D.js} +1 -1
  52. package/web-app/dist/assets/{EvidenceReceiptPanel-PUiGSpkB.js → EvidenceReceiptPanel-Co-xi282.js} +1 -1
  53. package/web-app/dist/assets/{GitHubIssuesPanel-C0hPs-P2.js → GitHubIssuesPanel-CoCpfexq.js} +1 -1
  54. package/web-app/dist/assets/{GitHubPRsPanel-CLvqNUlu.js → GitHubPRsPanel-Dg0Eg6F_.js} +1 -1
  55. package/web-app/dist/assets/{HomePage-X-sq_Rc9.js → HomePage-BysxL4mV.js} +2 -2
  56. package/web-app/dist/assets/{LoginPage-DhQuwdua.js → LoginPage-CQ366p_d.js} +1 -1
  57. package/web-app/dist/assets/{MagicPage-CP2ODjfn.js → MagicPage-DD7YcbNq.js} +1 -1
  58. package/web-app/dist/assets/{MetricsPage-C6SYiQES.js → MetricsPage-BYz49xQj.js} +1 -1
  59. package/web-app/dist/assets/{NotFoundPage-CCDxxYc-.js → NotFoundPage-CKs09MeO.js} +1 -1
  60. package/web-app/dist/assets/{ProjectPage-yDF--8FQ.js → ProjectPage-Ci2BIA_D.js} +3 -3
  61. package/web-app/dist/assets/{ProjectsPage-BBLspdLn.js → ProjectsPage-ClwMH0eu.js} +1 -1
  62. package/web-app/dist/assets/{SettingsPage-C_sWbQRw.js → SettingsPage-Cm0rCX-y.js} +1 -1
  63. package/web-app/dist/assets/{ShowcasePage-xCMuL0N8.js → ShowcasePage-CxvESt8X.js} +1 -1
  64. package/web-app/dist/assets/{SystemSettingsPage-DnJKGCx7.js → SystemSettingsPage-W_CGYPOY.js} +1 -1
  65. package/web-app/dist/assets/{TeamsPage-BBXdhrSs.js → TeamsPage-feVvRvys.js} +1 -1
  66. package/web-app/dist/assets/{TemplatesPage-AZ1Lr1rn.js → TemplatesPage-C-pUWMmF.js} +1 -1
  67. package/web-app/dist/assets/{TerminalOutput-CtIm9tXc.js → TerminalOutput-j5ibjKY3.js} +1 -1
  68. package/web-app/dist/assets/{activity-Dk1FK25Q.js → activity-TTdVYMa_.js} +1 -1
  69. package/web-app/dist/assets/{bell-dhqXyzP4.js → bell-DeoTFwpv.js} +1 -1
  70. package/web-app/dist/assets/{bot-CExHd4Kf.js → bot-B5SghxqU.js} +1 -1
  71. package/web-app/dist/assets/{check-D51zaA1a.js → check-k8SzsdbE.js} +1 -1
  72. package/web-app/dist/assets/{chevron-left-DEwnQzH1.js → chevron-left-DHnEllFz.js} +1 -1
  73. package/web-app/dist/assets/{circle-alert-C6U90Msk.js → circle-alert-DW_SLdcB.js} +1 -1
  74. package/web-app/dist/assets/{clock-6mFbMzzH.js → clock-Bmoaxy09.js} +1 -1
  75. package/web-app/dist/assets/{cloud-BrZZtE7F.js → cloud-eg3tzXTh.js} +1 -1
  76. package/web-app/dist/assets/{code-xml-DTFzeepP.js → code-xml-VAfXihhD.js} +1 -1
  77. package/web-app/dist/assets/{database-B5rEiTre.js → database-CY3_-5aK.js} +1 -1
  78. package/web-app/dist/assets/{dollar-sign-bQs4rlS_.js → dollar-sign-DYC0OLlg.js} +1 -1
  79. package/web-app/dist/assets/{file-code-corner-Mo6e6qbf.js → file-code-corner-0X3SveKz.js} +1 -1
  80. package/web-app/dist/assets/{file-plus-DzHZ0jpP.js → file-plus-B_oh_ukl.js} +1 -1
  81. package/web-app/dist/assets/{globe-DYN9UhTa.js → globe-F0tLA8WJ.js} +1 -1
  82. package/web-app/dist/assets/{hammer-bB0hSRcJ.js → hammer-DrlvBmfW.js} +1 -1
  83. package/web-app/dist/assets/{index-CIH5CqZB.js → index-YihVaZAj.js} +3 -3
  84. package/web-app/dist/assets/{layers-qhFkY8Nu.js → layers-CbJfn5UC.js} +1 -1
  85. package/web-app/dist/assets/{loader-circle-BGBkaYH7.js → loader-circle-CpmOggou.js} +1 -1
  86. package/web-app/dist/assets/{lock-DIcsNFum.js → lock-BGJ6QWrI.js} +1 -1
  87. package/web-app/dist/assets/{package-BdIIbZhS.js → package-BTmH4Xto.js} +1 -1
  88. package/web-app/dist/assets/{plus-Co1Bv8zT.js → plus-mKQ7hz-8.js} +1 -1
  89. package/web-app/dist/assets/{refresh-cw-CGF4ABWH.js → refresh-cw-vPetpLsm.js} +1 -1
  90. package/web-app/dist/assets/{rotate-ccw-BX10Q79h.js → rotate-ccw-DiJ8GFhj.js} +1 -1
  91. package/web-app/dist/assets/{scroll-text-CbXDlpGp.js → scroll-text-DpPQIlQR.js} +1 -1
  92. package/web-app/dist/assets/{server-CIhi6yUO.js → server-CiaV4drx.js} +1 -1
  93. package/web-app/dist/assets/{shield-alert-DJ5fB6Dg.js → shield-alert-BaaQqyco.js} +1 -1
  94. package/web-app/dist/assets/{thumbs-up-BEft2T94.js → thumbs-up-2zNCKp0Q.js} +1 -1
  95. package/web-app/dist/assets/{trash-2-CWGUPn7Y.js → trash-2-CiVDx5Co.js} +1 -1
  96. package/web-app/dist/assets/{trending-down-CjqstvxQ.js → trending-down-tcxvw5BH.js} +1 -1
  97. package/web-app/dist/assets/{trending-up-CTyLjMzE.js → trending-up-C2bv2_v_.js} +1 -1
  98. package/web-app/dist/assets/{upload-BmRx-uv4.js → upload-DS4VPUAp.js} +1 -1
  99. package/web-app/dist/assets/{usePolling-ChHkSJye.js → usePolling-JggBcVln.js} +1 -1
  100. package/web-app/dist/assets/{user-ZXh_f35z.js → user-CvavvOxM.js} +1 -1
  101. package/web-app/dist/index.html +1 -1
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '10.6.8'
78
+ __version__ = '10.6.9'
@@ -61,7 +61,63 @@ class RunTests(unittest.TestCase):
61
61
  self.assertTrue(r["log_path"].startswith(os.path.realpath(repo)))
62
62
 
63
63
 
64
+ class EnvTests(unittest.TestCase):
65
+ def test_env_forces_headless_and_drops_secrets(self):
66
+ secrets = {"LOKI_CONTROL_TOKEN": "t", "SLACK_BOT_TOKEN": "xoxb-1", "SLACK_SIGNING_SECRET": "s",
67
+ "SLACK_WEBHOOK_URL": "https://hooks.slack.com/x", "LOKI_NO_BROWSER": "0"}
68
+ with mock.patch.dict(os.environ, secrets):
69
+ env = v10_tools._env()
70
+ self.assertEqual(env["LOKI_NO_BROWSER"], "1")
71
+ self.assertEqual(env["LOKI_ENGINE"], "v10")
72
+ for k in ("LOKI_CONTROL_TOKEN", "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_WEBHOOK_URL"):
73
+ self.assertNotIn(k, env)
74
+
75
+ def test_run_child_env_has_no_secrets(self):
76
+ with tempfile.TemporaryDirectory() as repo, \
77
+ mock.patch.dict(os.environ, {"SLACK_BOT_TOKEN": "xoxb-1"}), \
78
+ mock.patch.object(v10_tools, "V10_RUN_ID_WAIT_S", 0), \
79
+ mock.patch("subprocess.Popen") as popen:
80
+ popen.return_value.pid = 1
81
+ v10_tools.v10_run("fix it", repo, _ok)
82
+ self.assertNotIn("SLACK_BOT_TOKEN", popen.call_args[1]["env"])
83
+
84
+
85
+ class RefPassThroughTests(unittest.TestCase):
86
+ def test_jira_and_linear_refs_pass_through(self):
87
+ for ref in ("jira:PROJ-123", "linear:ENG-45"):
88
+ with tempfile.TemporaryDirectory() as repo, \
89
+ mock.patch.object(v10_tools, "V10_RUN_ID_WAIT_S", 0), \
90
+ mock.patch("subprocess.Popen") as popen:
91
+ popen.return_value.pid = 7
92
+ r = v10_tools.v10_run(ref, repo, _ok)
93
+ self.assertNotIn("error", r)
94
+ self.assertEqual(popen.call_args[0][0][1], ref)
95
+
96
+ def test_leading_dash_still_rejected(self):
97
+ with mock.patch("subprocess.Popen") as popen:
98
+ self.assertIn("error", v10_tools.v10_run("-jira:X", "/x", _ok))
99
+ popen.assert_not_called()
100
+
101
+
64
102
  class StatusTests(unittest.TestCase):
103
+ def test_blocked_returns_question(self):
104
+ ev = [
105
+ {"seq": 0, "type": "stage.completed", "stage": "implement",
106
+ "data": {"spec_conflict_reason": "spec says A\nbut tests say B"}},
107
+ {"seq": 1, "type": "run.completed", "stage": None, "data": {"verdict": "BLOCKED"}},
108
+ ]
109
+ with tempfile.TemporaryDirectory() as repo:
110
+ _write_events(repo, "e10-blk", ev)
111
+ r = v10_tools.v10_status("e10-blk", repo, _ok)
112
+ self.assertEqual(r["verdict"], "BLOCKED")
113
+ self.assertEqual(r["blocked_question"], "spec says A but tests say B")
114
+
115
+ def test_verified_has_no_blocked_question(self):
116
+ ev = [{"seq": 0, "type": "run.completed", "stage": None, "data": {"verdict": "VERIFIED"}}]
117
+ with tempfile.TemporaryDirectory() as repo:
118
+ _write_events(repo, "e10-ok", ev)
119
+ self.assertNotIn("blocked_question", v10_tools.v10_status("e10-ok", repo, _ok))
120
+
65
121
  def test_done_run(self):
66
122
  ev = [
67
123
  {"seq": 0, "type": "run.started", "stage": None, "data": {}},
package/mcp/v10_tools.py CHANGED
@@ -8,6 +8,7 @@ validator before use.
8
8
 
9
9
  import json
10
10
  import os
11
+ import re
11
12
  import subprocess
12
13
  import time
13
14
  from pathlib import Path
@@ -20,10 +21,13 @@ V10_VERIFY_TIMEOUT_S = 120
20
21
  _TERMINAL = {"stage.completed", "stage.failed", "stage.skipped"}
21
22
 
22
23
 
24
+ _DROPPED_ENV = ("LOKI_CONTROL_TOKEN", "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_WEBHOOK_URL")
25
+
26
+
23
27
  def _env() -> Dict[str, str]:
24
- env = dict(os.environ)
28
+ env = {k: v for k, v in os.environ.items() if k not in _DROPPED_ENV}
25
29
  env["LOKI_ENGINE"] = "v10"
26
- env["LOKI_NO_BROWSER"] = env.get("LOKI_NO_BROWSER", "1")
30
+ env["LOKI_NO_BROWSER"] = "1"
27
31
  return env
28
32
 
29
33
 
@@ -110,6 +114,7 @@ def v10_status(run_id: str, repo_path: str, validate: Callable[[str], str]) -> d
110
114
  events = _read_events(events_path)
111
115
  open_stages: Dict[str, bool] = {}
112
116
  verdict = None
117
+ question = None
113
118
  done = False
114
119
  usd = 0.0
115
120
  saw_cost = False
@@ -122,6 +127,8 @@ def v10_status(run_id: str, repo_path: str, validate: Callable[[str], str]) -> d
122
127
  open_stages[stage] = True
123
128
  elif t in _TERMINAL:
124
129
  open_stages.pop(stage, None)
130
+ if t == "stage.completed" and stage == "implement" and isinstance(data.get("spec_conflict_reason"), str):
131
+ question = re.sub(r"[\x00-\x1f\x7f]+", " ", data["spec_conflict_reason"])[:500]
125
132
  if t == "run.completed":
126
133
  done = True
127
134
  verdict = data.get("verdict") if isinstance(data.get("verdict"), str) else None
@@ -132,7 +139,7 @@ def v10_status(run_id: str, repo_path: str, validate: Callable[[str], str]) -> d
132
139
  else:
133
140
  unmeasured = True
134
141
  phase = "done" if done else ("+".join(sorted(open_stages)) or "starting")
135
- return {
142
+ result = {
136
143
  "run_id": run_id,
137
144
  "phase": phase,
138
145
  "done": done,
@@ -140,6 +147,9 @@ def v10_status(run_id: str, repo_path: str, validate: Callable[[str], str]) -> d
140
147
  "cost_usd": usd if saw_cost and not unmeasured else None,
141
148
  "events": len(events),
142
149
  }
150
+ if verdict == "BLOCKED":
151
+ result["blocked_question"] = question or "see the receipt"
152
+ return result
143
153
 
144
154
 
145
155
  def v10_verify(receipt_path: str, repo_path: str, validate: Callable[[str], str]) -> dict:
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "10.6.8",
4
+ "version": "10.6.9",
5
5
  "description": "An autonomous software factory that knows what it is supposed to deliver, and proves it did.",
6
6
  "keywords": [
7
7
  "agent",
@@ -5535,11 +5535,14 @@ var runs = sqliteTable("runs", {
5535
5535
  lastSeq: integer("last_seq").notNull(),
5536
5536
  lastEventAt: text("last_event_at"),
5537
5537
  tampered: integer("tampered").notNull(),
5538
- conflict: integer("conflict").notNull().default(0)
5538
+ conflict: integer("conflict").notNull().default(0),
5539
+ groupId: text("group_id"),
5540
+ unitId: text("unit_id")
5539
5541
  }, (t) => [
5540
5542
  primaryKey({ columns: [t.sourceId, t.runId] }),
5541
5543
  index("runs_started").on(t.startedAt),
5542
- index("runs_verdict").on(t.verdict)
5544
+ index("runs_verdict").on(t.verdict),
5545
+ index("runs_group").on(t.groupId)
5543
5546
  ]);
5544
5547
 
5545
5548
  // src/db/migrate.ts
@@ -5581,6 +5584,11 @@ function validateEnvelope(x) {
5581
5584
  return "stage must be a string or null";
5582
5585
  if (!isObj(x.data))
5583
5586
  return "data must be an object";
5587
+ for (const k of ["group_id", "unit_id"])
5588
+ if (k in x.data && (typeof x.data[k] !== "string" || x.data[k] === ""))
5589
+ return `${k} must be a non-empty string`;
5590
+ if ("deps" in x.data && (!Array.isArray(x.data.deps) || x.data.deps.some((d) => typeof d !== "string")))
5591
+ return "deps must be a string array";
5584
5592
  return null;
5585
5593
  }
5586
5594
  function fold(events2) {
@@ -5684,7 +5692,7 @@ function writeAnswer(dir, source, run, answer) {
5684
5692
  writeFileSync(path, `${answer.trim()}
5685
5693
  `, { mode: 384 });
5686
5694
  chmodSync(path, 384);
5687
- return { status: 200, body: { ok: true, path, resume: `loki "$(cat '${path}')" # re-run the task with the answer, or paste it into the issue` } };
5695
+ return { status: 200, body: { ok: true, path, resume: `loki answer ${run}` } };
5688
5696
  }
5689
5697
 
5690
5698
  // src/server/runs.ts
@@ -5709,6 +5717,8 @@ function rebuildRun(db, sourceId, runId) {
5709
5717
  taskSource: str(sd.task_source),
5710
5718
  provider: str(sd.provider),
5711
5719
  model: str(sd.model),
5720
+ groupId: str(sd.group_id),
5721
+ unitId: str(sd.unit_id),
5712
5722
  startedAt: f.run.started?.ts ?? evs[0]?.ts ?? null,
5713
5723
  endedAt: f.run.completed?.ts ?? null,
5714
5724
  verdict: f.run.verdict,
@@ -5733,6 +5743,8 @@ var parseRun = (r) => ({
5733
5743
  origin_repo: r.originRepo,
5734
5744
  issue_ref: r.issueRef,
5735
5745
  task_source: r.taskSource,
5746
+ group_id: r.groupId,
5747
+ unit_id: r.unitId,
5736
5748
  provider: r.provider,
5737
5749
  model: r.model,
5738
5750
  started_at: r.startedAt,
@@ -5792,7 +5804,7 @@ var elapsedS = (r) => {
5792
5804
  function listRuns(db, q) {
5793
5805
  const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
5794
5806
  const offset = Math.max(Number.parseInt(q.cursor ?? "0", 10) || 0, 0);
5795
- const where = and(q.verdict ? eq(runs.verdict, q.verdict) : undefined, q.repo ? eq(runs.originRepo, q.repo) : undefined, q.since ? gte(runs.startedAt, q.since) : undefined, q.until ? lt(runs.startedAt, q.until) : undefined);
5807
+ const where = and(q.verdict ? eq(runs.verdict, q.verdict) : undefined, q.repo ? eq(runs.originRepo, q.repo) : undefined, q.group_id ? eq(runs.groupId, q.group_id) : undefined, q.since ? gte(runs.startedAt, q.since) : undefined, q.until ? lt(runs.startedAt, q.until) : undefined);
5796
5808
  const total = db.select({ n: sql`count(*)` }).from(runs).where(where).get()?.n ?? 0;
5797
5809
  const rows = db.select().from(runs).where(where).orderBy(desc(runs.startedAt), asc(runs.runId)).limit(limit).offset(offset).all();
5798
5810
  return { runs: rows.map((r) => withLive(db, r)), total, next_cursor: offset + rows.length < total ? String(offset + rows.length) : null };
@@ -5876,6 +5888,51 @@ function ingest(db, body) {
5876
5888
  return conflict > 0 ? { status: 409, body: { ...out, error: "conflicting content for an existing event id" } } : { status: 200, body: out };
5877
5889
  }
5878
5890
 
5891
+ // src/server/auth.ts
5892
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
5893
+ var LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
5894
+ function isLoopbackHost(host) {
5895
+ return !!host && /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
5896
+ }
5897
+ function tokenMatches(header, token) {
5898
+ const m = /^Bearer (.+)$/i.exec(header ?? "");
5899
+ if (!m)
5900
+ return false;
5901
+ const a = createHash2("sha256").update(m[1] ?? "").digest();
5902
+ const b = createHash2("sha256").update(token).digest();
5903
+ return timingSafeEqual(a, b);
5904
+ }
5905
+ function bindRefusal(env) {
5906
+ const host = env.LOKI_CONTROL_HOST;
5907
+ if (!host || LOOPBACK_BINDS.has(host.toLowerCase()))
5908
+ return null;
5909
+ if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND === "1")
5910
+ return null;
5911
+ return `loki-control: refusing to listen on ${host} without LOKI_CONTROL_TOKEN. Set LOKI_CONTROL_TOKEN, or LOKI_CONTROL_ALLOW_INSECURE_BIND=1 to accept an unauthenticated non-loopback bind.`;
5912
+ }
5913
+ function insecureBindWarning(env) {
5914
+ const host = env.LOKI_CONTROL_HOST;
5915
+ if (!host || LOOPBACK_BINDS.has(host.toLowerCase()))
5916
+ return null;
5917
+ if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND !== "1")
5918
+ return null;
5919
+ return `loki-control: WARNING listening on ${host} with no LOKI_CONTROL_TOKEN (LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Anyone who can reach this port can read and write runs.`;
5920
+ }
5921
+ function hostGuard() {
5922
+ return async (c, next) => {
5923
+ if (!isLoopbackHost(c.req.header("host")))
5924
+ return c.json({ error: "host not allowed" }, 403);
5925
+ await next();
5926
+ };
5927
+ }
5928
+ function tokenGuard(token) {
5929
+ return async (c, next) => {
5930
+ if (!tokenMatches(c.req.header("authorization"), token))
5931
+ return c.json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
5932
+ await next();
5933
+ };
5934
+ }
5935
+
5879
5936
  // src/server/app.ts
5880
5937
  var MAX_BODY = 1e6;
5881
5938
  var defaultUiDir = () => [join3(import.meta.dir, "../../ui/dist"), join3(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join3(d, "index.html")));
@@ -5885,6 +5942,10 @@ function createApp(opts) {
5885
5942
  let ready = true;
5886
5943
  const answerDir = opts.answerDir ?? defaultAnswerDir();
5887
5944
  const app = new Hono3;
5945
+ if (opts.loopbackOnly)
5946
+ app.use("*", hostGuard());
5947
+ if (opts.token)
5948
+ app.use("/v1/*", tokenGuard(opts.token));
5888
5949
  app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
5889
5950
  app.get("/ready", (c) => {
5890
5951
  try {
@@ -5909,7 +5970,7 @@ function createApp(opts) {
5909
5970
  });
5910
5971
  app.get("/v1/runs", (c) => {
5911
5972
  const q = c.req.query();
5912
- return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
5973
+ return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, group_id: q.group_id, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
5913
5974
  });
5914
5975
  const detail = (c, source, run) => {
5915
5976
  const d = runDetail(db, source, run);
@@ -5958,7 +6019,16 @@ function createApp(opts) {
5958
6019
  }
5959
6020
 
5960
6021
  // src/server/serve.ts
5961
- var { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
6022
+ var refusal = bindRefusal(process.env);
6023
+ if (refusal) {
6024
+ console.error(refusal);
6025
+ process.exit(2);
6026
+ }
6027
+ var warning = insecureBindWarning(process.env);
6028
+ if (warning)
6029
+ console.error(warning);
6030
+ var bindHost = process.env.LOKI_CONTROL_HOST || "127.0.0.1";
6031
+ var { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db", token: process.env.LOKI_CONTROL_TOKEN || undefined, loopbackOnly: isLoopbackHost(bindHost.includes(":") && !bindHost.startsWith("[") ? `[${bindHost}]` : bindHost) });
5962
6032
  var serve = (port) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
5963
6033
  var s;
5964
6034
  try {
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `runs` ADD `group_id` text;--> statement-breakpoint
2
+ ALTER TABLE `runs` ADD `unit_id` text;--> statement-breakpoint
3
+ CREATE INDEX `runs_group` ON `runs` (`group_id`);
@@ -8,6 +8,13 @@
8
8
  "when": 1790870373167,
9
9
  "tag": "0000_new_maelstrom",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1790956800000,
16
+ "tag": "0001_group_id",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
- }
20
+ }
@@ -47,8 +47,11 @@ export const runs = sqliteTable("runs", {
47
47
  lastEventAt: text("last_event_at"),
48
48
  tampered: integer("tampered").notNull(),
49
49
  conflict: integer("conflict").notNull().default(0),
50
+ groupId: text("group_id"),
51
+ unitId: text("unit_id"),
50
52
  }, (t) => [
51
53
  primaryKey({ columns: [t.sourceId, t.runId] }),
52
54
  index("runs_started").on(t.startedAt),
53
55
  index("runs_verdict").on(t.verdict),
56
+ index("runs_group").on(t.groupId),
54
57
  ]);
@@ -29,5 +29,5 @@ export function writeAnswer(dir: string, source: string, run: string, answer: un
29
29
  const path = join(d, `${run}.answer.txt`);
30
30
  writeFileSync(path, `${answer.trim()}\n`, { mode: 0o600 });
31
31
  chmodSync(path, 0o600);
32
- return { status: 200, body: { ok: true, path, resume: `loki "$(cat '${path}')" # re-run the task with the answer, or paste it into the issue` } };
32
+ return { status: 200, body: { ok: true, path, resume: `loki answer ${run}` } };
33
33
  }
@@ -5,6 +5,7 @@ import { openDb } from "../db/migrate.ts";
5
5
  import { ingest } from "./ingest.ts";
6
6
  import { defaultAnswerDir, writeAnswer } from "./answer.ts";
7
7
  import { listRuns, runDetail } from "./runs.ts";
8
+ import { hostGuard, tokenGuard } from "./auth.ts";
8
9
 
9
10
  const MAX_BODY = 1_000_000;
10
11
 
@@ -12,12 +13,15 @@ const MAX_BODY = 1_000_000;
12
13
  const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.meta.dir, "../ui/dist")].find((d) => existsSync(join(d, "index.html")));
13
14
 
14
15
  /** dbPath ":memory:" for tests. Migrations run here, so /ready is true as soon as this returns. uiDir overrides the built-UI location. */
15
- export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string }) {
16
+ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean }) {
16
17
  const uiDir = opts.uiDir ?? defaultUiDir();
17
18
  const { db, sqlite } = openDb(opts.dbPath);
18
19
  let ready = true;
19
20
  const answerDir = opts.answerDir ?? defaultAnswerDir();
20
21
  const app = new Hono();
22
+ // token: bearer required on /v1/*. loopbackOnly: reject a non-loopback Host (DNS rebinding). Both off by default; serve.ts sets them from env.
23
+ if (opts.loopbackOnly) app.use("*", hostGuard());
24
+ if (opts.token) app.use("/v1/*", tokenGuard(opts.token));
21
25
 
22
26
  app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
23
27
  app.get("/ready", (c) => {
@@ -36,7 +40,7 @@ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: st
36
40
 
37
41
  app.get("/v1/runs", (c) => {
38
42
  const q = c.req.query();
39
- return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
43
+ return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, group_id: q.group_id, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
40
44
  });
41
45
  const detail = (c: { json: (b: unknown, s?: 404) => Response }, source: string, run: string) => {
42
46
  const d = runDetail(db, source, run);
@@ -0,0 +1,51 @@
1
+ // Control Plane auth: bearer token on /v1, Host allowlist on loopback (DNS rebinding), bind refusal without a token.
2
+ import { createHash, timingSafeEqual } from "node:crypto";
3
+ import type { MiddlewareHandler } from "hono";
4
+
5
+ const LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
6
+
7
+ /** True when a Host header value (optional port) names this machine's loopback. Strict: nothing may follow the name or port. */
8
+ export function isLoopbackHost(host: string | undefined | null): boolean {
9
+ return !!host && /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
10
+ }
11
+
12
+ /** Constant-time check of an Authorization header against the token. Hashing first makes the compare length-safe. */
13
+ export function tokenMatches(header: string | undefined | null, token: string): boolean {
14
+ const m = /^Bearer (.+)$/i.exec(header ?? "");
15
+ if (!m) return false;
16
+ const a = createHash("sha256").update(m[1] ?? "").digest();
17
+ const b = createHash("sha256").update(token).digest();
18
+ return timingSafeEqual(a, b);
19
+ }
20
+
21
+ /** Message to print before exiting 2 when the env asks for a non-loopback bind with no token, else null. */
22
+ export function bindRefusal(env: Record<string, string | undefined>): string | null {
23
+ const host = env.LOKI_CONTROL_HOST;
24
+ if (!host || LOOPBACK_BINDS.has(host.toLowerCase())) return null;
25
+ if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND === "1") return null;
26
+ return `loki-control: refusing to listen on ${host} without LOKI_CONTROL_TOKEN. Set LOKI_CONTROL_TOKEN, or LOKI_CONTROL_ALLOW_INSECURE_BIND=1 to accept an unauthenticated non-loopback bind.`;
27
+ }
28
+
29
+ /** Stderr warning when ALLOW_INSECURE_BIND is what permits a non-loopback bind with no token, else null. Never includes a token. */
30
+ export function insecureBindWarning(env: Record<string, string | undefined>): string | null {
31
+ const host = env.LOKI_CONTROL_HOST;
32
+ if (!host || LOOPBACK_BINDS.has(host.toLowerCase())) return null;
33
+ if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND !== "1") return null;
34
+ return `loki-control: WARNING listening on ${host} with no LOKI_CONTROL_TOKEN (LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Anyone who can reach this port can read and write runs.`;
35
+ }
36
+
37
+ /** Host allowlist guard (apply on "*"; /health and /ready still need the Host check on loopback). */
38
+ export function hostGuard(): MiddlewareHandler {
39
+ return async (c, next) => {
40
+ if (!isLoopbackHost(c.req.header("host"))) return c.json({ error: "host not allowed" }, 403);
41
+ await next();
42
+ };
43
+ }
44
+
45
+ /** Bearer guard. Register it with app.use("/v1/*", ...) so Hono's decoded route matching decides coverage, never the raw URL. */
46
+ export function tokenGuard(token: string): MiddlewareHandler {
47
+ return async (c, next) => {
48
+ if (!tokenMatches(c.req.header("authorization"), token)) return c.json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
49
+ await next();
50
+ };
51
+ }
@@ -24,7 +24,7 @@ export function rebuildRun(db: Db, sourceId: string, runId: string): void {
24
24
  const row = {
25
25
  sourceId, runId,
26
26
  originRepo: str(sd.origin_repo), issueRef: str(sd.issue_ref), taskSource: str(sd.task_source),
27
- provider: str(sd.provider), model: str(sd.model),
27
+ provider: str(sd.provider), model: str(sd.model), groupId: str(sd.group_id), unitId: str(sd.unit_id),
28
28
  startedAt: f.run.started?.ts ?? evs[0]?.ts ?? null, endedAt: f.run.completed?.ts ?? null,
29
29
  verdict: f.run.verdict,
30
30
  prUrl: str(done.pr_url) ?? str(pr?.url), prDraft: typeof pr?.draft === "boolean" ? Number(pr.draft) : null,
@@ -39,7 +39,7 @@ export function rebuildRun(db: Db, sourceId: string, runId: string): void {
39
39
 
40
40
  const parseRun = (r: typeof runs.$inferSelect) => ({
41
41
  source_id: r.sourceId, run_id: r.runId, origin_repo: r.originRepo, issue_ref: r.issueRef, task_source: r.taskSource,
42
- provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
42
+ group_id: r.groupId, unit_id: r.unitId, provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
43
43
  pr_url: r.prUrl, pr_draft: r.prDraft === null ? null : r.prDraft === 1,
44
44
  cost_usd: r.costUsd, partial_usd: r.partialUsd, measured_sessions: r.measuredSessions, total_sessions: r.totalSessions,
45
45
  input_tokens: r.inputTokens, output_tokens: r.outputTokens, wall_s: r.wallS, last_seq: r.lastSeq,
@@ -75,7 +75,7 @@ const elapsedS = (r: typeof runs.$inferSelect): number | null => {
75
75
  return Math.max(0, (end - Date.parse(r.startedAt)) / 1000);
76
76
  };
77
77
 
78
- export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; limit?: number; cursor?: string }
78
+ export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; group_id?: string; limit?: number; cursor?: string }
79
79
 
80
80
  export function listRuns(db: Db, q: ListQuery) {
81
81
  const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
@@ -83,6 +83,7 @@ export function listRuns(db: Db, q: ListQuery) {
83
83
  const where = and(
84
84
  q.verdict ? eq(runs.verdict, q.verdict) : undefined,
85
85
  q.repo ? eq(runs.originRepo, q.repo) : undefined,
86
+ q.group_id ? eq(runs.groupId, q.group_id) : undefined,
86
87
  q.since ? gte(runs.startedAt, q.since) : undefined,
87
88
  q.until ? lt(runs.startedAt, q.until) : undefined,
88
89
  );
@@ -2,8 +2,18 @@
2
2
  // Binds loopback unless LOKI_CONTROL_HOST is set (the container image sets 0.0.0.0). LOKI_CONTROL_PORT_FALLBACK=1 (set for the default port, never for an explicit --port)
3
3
  // retries on any free port when PORT is taken; the URL printed below is always the real one.
4
4
  import { createApp } from "./app.ts";
5
+ import { bindRefusal, insecureBindWarning, isLoopbackHost } from "./auth.ts";
5
6
 
6
- const { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
7
+ // A non-loopback bind needs LOKI_CONTROL_TOKEN (or LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Refuse before opening the DB or binding.
8
+ const refusal = bindRefusal(process.env);
9
+ if (refusal) {
10
+ console.error(refusal);
11
+ process.exit(2);
12
+ }
13
+ const warning = insecureBindWarning(process.env);
14
+ if (warning) console.error(warning);
15
+ const bindHost = process.env.LOKI_CONTROL_HOST || "127.0.0.1";
16
+ const { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db", token: process.env.LOKI_CONTROL_TOKEN || undefined, loopbackOnly: isLoopbackHost(bindHost.includes(":") && !bindHost.startsWith("[") ? `[${bindHost}]` : bindHost) });
7
17
  const serve = (port: number) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
8
18
  let s: ReturnType<typeof serve>;
9
19
  try {
@@ -0,0 +1,29 @@
1
+ // C2 CP-DEFAULT: find a running local Control Plane (docs/v10/CONTROL-PLANE.md section 6). Reads
2
+ // ~/.loki/control/instance.json; returns its url only when the pid is alive and /health answers service=loki-control
3
+ // within 300 ms. Never starts a server, never throws. LOKI_CONTROL=0 disables discovery.
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ export const instancePath = (env: NodeJS.ProcessEnv): string => join(env.HOME || homedir(), ".loki", "control", "instance.json");
9
+
10
+ export interface DiscoverOpts { fetchImpl?: typeof fetch | undefined; alive?: ((pid: number) => boolean) | undefined }
11
+
12
+ const isAlive = (pid: number): boolean => { try { process.kill(pid, 0); return true; } catch { return false; } };
13
+
14
+ export function isLoopbackHttp(u: string): boolean {
15
+ try { const p = new URL(u); return p.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(p.hostname); } catch { return false; }
16
+ }
17
+
18
+ export async function discoverControlUrl(env: NodeJS.ProcessEnv, o: DiscoverOpts = {}): Promise<string | null> {
19
+ if (env.LOKI_CONTROL === "0") return null;
20
+ if (env.LOKI_CONTROL_URL) return env.LOKI_CONTROL_URL;
21
+ try {
22
+ const inst = JSON.parse(readFileSync(instancePath(env), "utf8")) as { pid?: unknown; url?: unknown };
23
+ if (!Number.isInteger(inst.pid) || typeof inst.url !== "string" || !(o.alive ?? isAlive)(inst.pid as number)) return null;
24
+ if (!isLoopbackHttp(inst.url)) return null; // a planted instance.json must never redirect events off this machine
25
+ const url = inst.url.replace(/\/+$/, "");
26
+ const h = (await (await (o.fetchImpl ?? fetch)(`${url}/health`, { signal: AbortSignal.timeout(300) })).json()) as { service?: string };
27
+ return h.service === "loki-control" ? url : null;
28
+ } catch { return null; }
29
+ }