loki-mode 10.6.8 → 10.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -14
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +167 -36
- package/autonomy/lib/workspace.py +133 -39
- package/autonomy/lib/workspace_comment.py +91 -0
- package/autonomy/loki +92 -22
- package/bin/loki +3 -3
- package/completions/_loki +1 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +26 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/TOOL-INTEGRATION.md +3 -3
- package/docs/WORKSPACES.md +11 -4
- package/docs/control-plane-container.md +8 -3
- package/docs/dashboard-guide.md +4 -0
- package/docs/exit-codes.md +1 -0
- package/docs/slack.md +12 -4
- package/docs/trackers.md +27 -0
- package/docs/v10/BOARD.md +70 -29
- package/docs/v10/CONTROL-PLANE.md +14 -7
- package/docs/v10/D51-PHASE-B.md +3 -1
- package/docs/v10/D61-SPEED.md +4 -0
- package/docs/v10/D63-CARDS.md +354 -0
- package/docs/v10/DECISIONS.md +44 -0
- package/docs/v10/E2E-D65.md +28 -0
- package/docs/v10/PROGRESS.md +63 -0
- package/loki-ts/dist/loki.js +584 -565
- package/mcp/__init__.py +1 -1
- package/mcp/tests/test_v10_tools.py +56 -0
- package/mcp/v10_tools.py +13 -3
- package/package.json +2 -1
- package/packages/control-plane/dist/server.js +76 -6
- package/packages/control-plane/drizzle/0001_group_id.sql +3 -0
- package/packages/control-plane/drizzle/meta/_journal.json +8 -1
- package/packages/control-plane/src/db/schema.ts +3 -0
- package/packages/control-plane/src/server/answer.ts +1 -1
- package/packages/control-plane/src/server/app.ts +6 -2
- package/packages/control-plane/src/server/auth.ts +51 -0
- package/packages/control-plane/src/server/runs.ts +4 -3
- package/packages/control-plane/src/server/serve.ts +11 -1
- package/packages/control-plane/src/shipper/discover.ts +29 -0
- package/packages/control-plane/ui/dist/assets/{index-53E32yrO.js → index-Jt31nrK-.js} +24 -24
- package/packages/control-plane/ui/dist/index.html +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/schemas/features.schema.json +71 -0
- package/schemas/loki-yaml.schema.json +123 -0
- package/schemas/manifest.schema.json +77 -0
- package/schemas/migration-plan.schema.json +109 -0
- package/schemas/outcome-contract.schema.json +73 -0
- package/schemas/seams.schema.json +90 -0
- package/schemas/status-result.schema.json +30 -0
- package/schemas/why-result.schema.json +51 -0
- package/web-app/dist/assets/{AdminPage-DN_pe8vo.js → AdminPage-OaSCF3M4.js} +1 -1
- package/web-app/dist/assets/{Avatar-BnQsr8z-.js → Avatar-xa3G7_vs.js} +1 -1
- package/web-app/dist/assets/{Badge-DzERiZn2.js → Badge-Ci_UcHij.js} +1 -1
- package/web-app/dist/assets/{Button-CaaoxQAt.js → Button-CFrrmbvL.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-SXhnysJA.js → CockpitPage-6hyVl_QZ.js} +1 -1
- package/web-app/dist/assets/{ComparePage-krxTCFJY.js → ComparePage-B5H56KCG.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-BL96ESMc.js → ErrorBoundary-CBbRHsYr.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-PUiGSpkB.js → EvidenceReceiptPanel-D-Zoneum.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-C0hPs-P2.js → GitHubIssuesPanel-D7XPRsic.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-CLvqNUlu.js → GitHubPRsPanel-CzHbBPAn.js} +1 -1
- package/web-app/dist/assets/{HomePage-X-sq_Rc9.js → HomePage-ClOx5qF1.js} +2 -2
- package/web-app/dist/assets/{LoginPage-DhQuwdua.js → LoginPage-3Yz_9j8E.js} +1 -1
- package/web-app/dist/assets/{MagicPage-CP2ODjfn.js → MagicPage-Dumn48gC.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-C6SYiQES.js → MetricsPage-CYCYVG_8.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-CCDxxYc-.js → NotFoundPage-CV-x_1qH.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-yDF--8FQ.js → ProjectPage-CeO7dnyM.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-BBLspdLn.js → ProjectsPage-BT9mz6iD.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-C_sWbQRw.js → SettingsPage-BL8X9fAB.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-xCMuL0N8.js → ShowcasePage-8f3lnSpc.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-DnJKGCx7.js → SystemSettingsPage-BUNAAzh2.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-BBXdhrSs.js → TeamsPage-Qj-VEZJz.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-AZ1Lr1rn.js → TemplatesPage-b8lKqe5q.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-CtIm9tXc.js → TerminalOutput-jOnfQKB4.js} +1 -1
- package/web-app/dist/assets/{activity-Dk1FK25Q.js → activity-w7GnyBNp.js} +1 -1
- package/web-app/dist/assets/{bell-dhqXyzP4.js → bell-2i4ejMOA.js} +1 -1
- package/web-app/dist/assets/{bot-CExHd4Kf.js → bot-jaPm4NIs.js} +1 -1
- package/web-app/dist/assets/{check-D51zaA1a.js → check-CvNtxeKM.js} +1 -1
- package/web-app/dist/assets/{chevron-left-DEwnQzH1.js → chevron-left-zzBjHAtX.js} +1 -1
- package/web-app/dist/assets/{circle-alert-C6U90Msk.js → circle-alert-CCU0IU2m.js} +1 -1
- package/web-app/dist/assets/{clock-6mFbMzzH.js → clock-BvZckMwN.js} +1 -1
- package/web-app/dist/assets/{cloud-BrZZtE7F.js → cloud-hrMWDdYp.js} +1 -1
- package/web-app/dist/assets/{code-xml-DTFzeepP.js → code-xml-v6l40T-K.js} +1 -1
- package/web-app/dist/assets/{database-B5rEiTre.js → database-l2TMUrQd.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-bQs4rlS_.js → dollar-sign-C2V6OvpO.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Mo6e6qbf.js → file-code-corner-Bytb5JGA.js} +1 -1
- package/web-app/dist/assets/{file-plus-DzHZ0jpP.js → file-plus-BynCYQB8.js} +1 -1
- package/web-app/dist/assets/{globe-DYN9UhTa.js → globe-DifDdNMH.js} +1 -1
- package/web-app/dist/assets/{hammer-bB0hSRcJ.js → hammer-D7TsOJD6.js} +1 -1
- package/web-app/dist/assets/{index-CIH5CqZB.js → index-Zohfqaz4.js} +3 -3
- package/web-app/dist/assets/{layers-qhFkY8Nu.js → layers-Bk8c2YLS.js} +1 -1
- package/web-app/dist/assets/{loader-circle-BGBkaYH7.js → loader-circle-BrLdbMwP.js} +1 -1
- package/web-app/dist/assets/{lock-DIcsNFum.js → lock-COpmjMyJ.js} +1 -1
- package/web-app/dist/assets/{package-BdIIbZhS.js → package-BMzYdY7F.js} +1 -1
- package/web-app/dist/assets/{plus-Co1Bv8zT.js → plus-B9xpu1Pg.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-CGF4ABWH.js → refresh-cw-C2Lzh-AA.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-BX10Q79h.js → rotate-ccw-2Yyy-uQd.js} +1 -1
- package/web-app/dist/assets/{scroll-text-CbXDlpGp.js → scroll-text-DiK2yhsT.js} +1 -1
- package/web-app/dist/assets/{server-CIhi6yUO.js → server-xlJBgleU.js} +1 -1
- package/web-app/dist/assets/{shield-alert-DJ5fB6Dg.js → shield-alert-D9w4yBCi.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-BEft2T94.js → thumbs-up-CcWfvcE9.js} +1 -1
- package/web-app/dist/assets/{trash-2-CWGUPn7Y.js → trash-2-_hk_pEAp.js} +1 -1
- package/web-app/dist/assets/{trending-down-CjqstvxQ.js → trending-down-CDZT99tC.js} +1 -1
- package/web-app/dist/assets/{trending-up-CTyLjMzE.js → trending-up-A0oreTXc.js} +1 -1
- package/web-app/dist/assets/{upload-BmRx-uv4.js → upload-DfLwhGG2.js} +1 -1
- package/web-app/dist/assets/{usePolling-ChHkSJye.js → usePolling-B7gW2YY_.js} +1 -1
- package/web-app/dist/assets/{user-ZXh_f35z.js → user-C-y7_Oon.js} +1 -1
- package/web-app/dist/index.html +1 -1
package/mcp/__init__.py
CHANGED
|
@@ -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 =
|
|
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"] =
|
|
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
|
-
|
|
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.
|
|
4
|
+
"version": "10.6.10",
|
|
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",
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
"skills/",
|
|
80
80
|
"references/",
|
|
81
81
|
"docs/**/*.md",
|
|
82
|
+
"schemas/",
|
|
82
83
|
"bin/",
|
|
83
84
|
"bin/loki",
|
|
84
85
|
"loki-ts/dist/",
|
|
@@ -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
|
|
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
|
|
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 {
|
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|