loki-mode 10.6.14 → 10.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +9 -5
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/acp-agent.py +128 -0
  5. package/autonomy/docker-run.sh +8 -0
  6. package/autonomy/issue-parser.sh +7 -7
  7. package/autonomy/issue-providers.sh +159 -1
  8. package/autonomy/lib/engine10-push.sh +4 -1
  9. package/autonomy/lib/review_risk.py +91 -0
  10. package/autonomy/loki +209 -19
  11. package/autonomy/mobile-verify.sh +66 -0
  12. package/autonomy/run.sh +44 -2
  13. package/completions/_loki +2 -0
  14. package/completions/loki.bash +1 -1
  15. package/dashboard/__init__.py +1 -1
  16. package/dashboard/api_runs_v1.py +80 -0
  17. package/dashboard/server.py +2 -0
  18. package/docs/INSTALLATION.md +5 -1
  19. package/docs/acp-editors.md +29 -0
  20. package/docs/api-runs-v1.md +12 -0
  21. package/docs/dev/architecture-reference.md +4 -0
  22. package/docs/merge-queue.md +13 -0
  23. package/docs/mobile-verify.md +11 -0
  24. package/docs/sentry-intake.md +13 -0
  25. package/docs/v10/BOARD.md +31 -6
  26. package/docs/v10/CONTROL-PLANE.md +7 -0
  27. package/docs/v10/CP-ENTERPRISE-UI.md +276 -0
  28. package/docs/v10/D61-SPEED.md +4 -0
  29. package/docs/v10/DECISIONS.md +115 -0
  30. package/docs/v10/ENGINE-LAWS.md +155 -0
  31. package/docs/v10/FAILURE-CLASSES.md +145 -0
  32. package/docs/v10/FOUNDER-QUEUE.md +1 -0
  33. package/docs/v10/GAP-RESEARCH-1003.md +32 -0
  34. package/docs/v10/GUIDE.md +4 -2
  35. package/docs/v10/L0-WAVE1.md +81 -0
  36. package/docs/v10/MORNING-BRIEF-1003.md +25 -0
  37. package/docs/v10/OPERATING-MODEL.md +1 -0
  38. package/docs/v10/PROGRESS.md +99 -0
  39. package/docs/v10/SWARM.md +32 -0
  40. package/docs/visual-evidence.md +5 -0
  41. package/loki-ts/dist/loki.js +805 -777
  42. package/loki-ts/package.json +1 -0
  43. package/mcp/__init__.py +1 -1
  44. package/package.json +1 -1
  45. package/packages/control-plane/dist/server.js +549 -21
  46. package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
  47. package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
  48. package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
  49. package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
  50. package/packages/control-plane/drizzle/meta/_journal.json +14 -0
  51. package/packages/control-plane/src/db/migrate.ts +6 -2
  52. package/packages/control-plane/src/db/prune.ts +91 -0
  53. package/packages/control-plane/src/db/schema.ts +13 -0
  54. package/packages/control-plane/src/server/actions.ts +60 -0
  55. package/packages/control-plane/src/server/app.ts +59 -4
  56. package/packages/control-plane/src/server/integrity.ts +147 -0
  57. package/packages/control-plane/src/server/runs.ts +46 -4
  58. package/packages/control-plane/src/shipper/discover.ts +32 -3
  59. package/packages/control-plane/src/shipper/ship.ts +15 -0
  60. package/packages/control-plane/src/shipper/watch.ts +66 -0
  61. package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +1 -0
  62. package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +129 -0
  63. package/packages/control-plane/ui/dist/index.html +3 -3
  64. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  65. package/web-app/dist/assets/{AdminPage-oAFMNfKH.js → AdminPage-BJSU5hY1.js} +1 -1
  66. package/web-app/dist/assets/{Avatar-BA2gnzyf.js → Avatar-CSOSpw9c.js} +1 -1
  67. package/web-app/dist/assets/{Badge-Dsluv8n1.js → Badge-COr7vj0D.js} +1 -1
  68. package/web-app/dist/assets/{Button-WmW8A-iG.js → Button-DGoWFERp.js} +1 -1
  69. package/web-app/dist/assets/{CockpitPage-BR5o_RgL.js → CockpitPage-CBpWkNXY.js} +1 -1
  70. package/web-app/dist/assets/{ComparePage-3Eg-pKvO.js → ComparePage-BwT8vqrL.js} +1 -1
  71. package/web-app/dist/assets/{ErrorBoundary-DAfrnnsQ.js → ErrorBoundary-D7SicDDA.js} +1 -1
  72. package/web-app/dist/assets/{EvidenceReceiptPanel-wLZI4OTr.js → EvidenceReceiptPanel-bx-5liG2.js} +1 -1
  73. package/web-app/dist/assets/{GitHubIssuesPanel-BOuadisf.js → GitHubIssuesPanel-BAeeWLqY.js} +1 -1
  74. package/web-app/dist/assets/{GitHubPRsPanel-BTVoVpB3.js → GitHubPRsPanel-CssD8lGI.js} +1 -1
  75. package/web-app/dist/assets/{HomePage-s3GWdNEe.js → HomePage-SL5PT3m5.js} +2 -2
  76. package/web-app/dist/assets/{LoginPage-BfOZ566t.js → LoginPage-C8hFOZOM.js} +1 -1
  77. package/web-app/dist/assets/{MagicPage-9EZ83xOt.js → MagicPage-xHbHkrPf.js} +1 -1
  78. package/web-app/dist/assets/{MetricsPage-OJMOYhHP.js → MetricsPage-B_2CKFcC.js} +1 -1
  79. package/web-app/dist/assets/{NotFoundPage-_LoJ6KEv.js → NotFoundPage-CXwXMq35.js} +1 -1
  80. package/web-app/dist/assets/{ProjectPage-Duj5LSt6.js → ProjectPage-AaisMQI-.js} +3 -3
  81. package/web-app/dist/assets/{ProjectsPage-BBud3JrO.js → ProjectsPage-Cej5ResX.js} +1 -1
  82. package/web-app/dist/assets/{SettingsPage-C4zdgKno.js → SettingsPage-DKv4mn9H.js} +1 -1
  83. package/web-app/dist/assets/{ShowcasePage-Nb3ACjMR.js → ShowcasePage-Dkiyzne4.js} +1 -1
  84. package/web-app/dist/assets/{SystemSettingsPage-BDi49TDh.js → SystemSettingsPage-DaBLQ5dU.js} +1 -1
  85. package/web-app/dist/assets/{TeamsPage-HkcqkjLa.js → TeamsPage-B2yUr31k.js} +1 -1
  86. package/web-app/dist/assets/{TemplatesPage-CK796Y1F.js → TemplatesPage-CfYiANhU.js} +1 -1
  87. package/web-app/dist/assets/{TerminalOutput-BkvdNuT6.js → TerminalOutput-CMXoexLM.js} +1 -1
  88. package/web-app/dist/assets/{activity-DFgMgETR.js → activity-B0dzoZtY.js} +1 -1
  89. package/web-app/dist/assets/{bell-BInGGOMZ.js → bell-DFOTaSoo.js} +1 -1
  90. package/web-app/dist/assets/{bot-D43Yjsj4.js → bot-Dz-WSQyP.js} +1 -1
  91. package/web-app/dist/assets/{check-DFAGTLM9.js → check-L1QIRueJ.js} +1 -1
  92. package/web-app/dist/assets/{chevron-left-Drwin7n1.js → chevron-left-1TrnIEZS.js} +1 -1
  93. package/web-app/dist/assets/{circle-alert-DLWdb7V9.js → circle-alert-8Bf6xEDx.js} +1 -1
  94. package/web-app/dist/assets/{clock-Dsbi59u5.js → clock-DB8NE6P5.js} +1 -1
  95. package/web-app/dist/assets/{cloud-CUU-dXTP.js → cloud-DUSL3wZS.js} +1 -1
  96. package/web-app/dist/assets/{code-xml-CqIh7Wui.js → code-xml-DbjryWNl.js} +1 -1
  97. package/web-app/dist/assets/{database-DASN5Bfh.js → database-DrezrMSs.js} +1 -1
  98. package/web-app/dist/assets/{dollar-sign-ZvXzAihc.js → dollar-sign-CGbaLMzR.js} +1 -1
  99. package/web-app/dist/assets/{file-code-corner-ED_gN-N8.js → file-code-corner-Dfypa-r4.js} +1 -1
  100. package/web-app/dist/assets/{file-plus-BmBHndXt.js → file-plus-CzV5L2Gd.js} +1 -1
  101. package/web-app/dist/assets/{globe-Ck_iEzO4.js → globe-BoYjTwPQ.js} +1 -1
  102. package/web-app/dist/assets/{hammer-B8YKMard.js → hammer-WO-HuiyJ.js} +1 -1
  103. package/web-app/dist/assets/{index-D7SxmjVQ.js → index-CjIOUjnd.js} +3 -3
  104. package/web-app/dist/assets/{layers-CGVsj3wB.js → layers-Bn6jtKNk.js} +1 -1
  105. package/web-app/dist/assets/{loader-circle-FIH7UJDF.js → loader-circle-gERWRnmW.js} +1 -1
  106. package/web-app/dist/assets/{lock-vR8HoAnK.js → lock-BIiMLJq8.js} +1 -1
  107. package/web-app/dist/assets/{package-mZ6LFwXp.js → package-dLvDSVys.js} +1 -1
  108. package/web-app/dist/assets/{plus-CtnSVu0w.js → plus-BHM3ZLhZ.js} +1 -1
  109. package/web-app/dist/assets/{refresh-cw-BIapF6Gj.js → refresh-cw-BAp_alv6.js} +1 -1
  110. package/web-app/dist/assets/{rotate-ccw-LMkSsm21.js → rotate-ccw-CBhwq_MG.js} +1 -1
  111. package/web-app/dist/assets/{scroll-text-DQVlIhr9.js → scroll-text-DoS-6dfU.js} +1 -1
  112. package/web-app/dist/assets/{server-GqyhQxmz.js → server-5JgdDWQO.js} +1 -1
  113. package/web-app/dist/assets/{shield-alert-BrFTNJ-I.js → shield-alert-DjmlLT34.js} +1 -1
  114. package/web-app/dist/assets/{thumbs-up-lgwAEJFW.js → thumbs-up-BtiEXl3p.js} +1 -1
  115. package/web-app/dist/assets/{trash-2-C1cNysK4.js → trash-2-ClsWU_Vg.js} +1 -1
  116. package/web-app/dist/assets/{trending-down-DQFZ6j5s.js → trending-down-DPS4M7_X.js} +1 -1
  117. package/web-app/dist/assets/{trending-up-BJG_Zo8V.js → trending-up-D6OJ38-V.js} +1 -1
  118. package/web-app/dist/assets/{upload-BOY1aTpA.js → upload-DOCzLUpF.js} +1 -1
  119. package/web-app/dist/assets/{usePolling-DoAmsTNy.js → usePolling-CljQTWj_.js} +1 -1
  120. package/web-app/dist/assets/{user-iOfCe0_v.js → user-C0G82Weu.js} +1 -1
  121. package/web-app/dist/index.html +1 -1
  122. package/packages/control-plane/ui/dist/assets/index-CFfrqCx2.css +0 -1
  123. package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +0 -114
@@ -15,8 +15,8 @@ var __export = (target, all) => {
15
15
  };
16
16
 
17
17
  // src/server/app.ts
18
- import { existsSync as existsSync2, statSync } from "fs";
19
- import { join as join3, resolve, sep } from "path";
18
+ import { existsSync as existsSync5, statSync as statSync2 } from "fs";
19
+ import { join as join6, resolve as resolve2, sep } from "path";
20
20
 
21
21
  // node_modules/hono/dist/request/constants.js
22
22
  var GET_MATCH_RESULT = Symbol();
@@ -5490,7 +5490,8 @@ var exports_schema = {};
5490
5490
  __export(exports_schema, {
5491
5491
  sources: () => sources,
5492
5492
  runs: () => runs,
5493
- events: () => events
5493
+ events: () => events,
5494
+ audit: () => audit
5494
5495
  });
5495
5496
  var sources = sqliteTable("sources", {
5496
5497
  id: text("id").primaryKey(),
@@ -5537,20 +5538,35 @@ var runs = sqliteTable("runs", {
5537
5538
  tampered: integer("tampered").notNull(),
5538
5539
  conflict: integer("conflict").notNull().default(0),
5539
5540
  groupId: text("group_id"),
5540
- unitId: text("unit_id")
5541
+ unitId: text("unit_id"),
5542
+ attested: integer("attested"),
5543
+ sigChecked: integer("sig_checked"),
5544
+ integrityKeyFp: text("integrity_key_fp"),
5545
+ integrityReasons: text("integrity_reasons")
5541
5546
  }, (t) => [
5542
5547
  primaryKey({ columns: [t.sourceId, t.runId] }),
5543
5548
  index("runs_started").on(t.startedAt),
5544
5549
  index("runs_verdict").on(t.verdict),
5545
5550
  index("runs_group").on(t.groupId)
5546
5551
  ]);
5552
+ var audit = sqliteTable("audit", {
5553
+ id: integer("id").primaryKey({ autoIncrement: true }),
5554
+ ts: text("ts").notNull(),
5555
+ action: text("action").notNull(),
5556
+ actor: text("actor").notNull(),
5557
+ detail: text("detail", { mode: "json" }).notNull()
5558
+ });
5547
5559
 
5548
5560
  // src/db/migrate.ts
5549
5561
  function openDb(path) {
5550
5562
  const sqlite = new Database2(path, { create: true });
5551
5563
  sqlite.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;");
5552
5564
  const db = drizzle(sqlite, { schema: exports_schema });
5553
- const migrationsFolder = [join(import.meta.dir, "../../drizzle"), join(import.meta.dir, "../drizzle")].find((d) => existsSync(join(d, "meta/_journal.json")));
5565
+ const migrationsFolder = [
5566
+ join(import.meta.dir, "../../drizzle"),
5567
+ join(import.meta.dir, "../drizzle"),
5568
+ join(import.meta.dir, "../../packages/control-plane/drizzle")
5569
+ ].find((d) => existsSync(join(d, "meta/_journal.json")));
5554
5570
  if (!migrationsFolder)
5555
5571
  throw new Error("drizzle migrations folder not found");
5556
5572
  migrate(db, { migrationsFolder });
@@ -5558,9 +5574,10 @@ function openDb(path) {
5558
5574
  }
5559
5575
 
5560
5576
  // src/server/ingest.ts
5561
- import { createHash } from "crypto";
5577
+ import { createHash as createHash2 } from "crypto";
5562
5578
 
5563
5579
  // ../../loki-ts/src/engine10/events.ts
5580
+ import { closeSync, existsSync as existsSync2, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeSync } from "fs";
5564
5581
  var isObj = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
5565
5582
  var ISO_TS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
5566
5583
  var KEYS = ["v", "seq", "ts", "run", "type", "stage", "data"];
@@ -5591,6 +5608,28 @@ function validateEnvelope(x) {
5591
5608
  return "deps must be a string array";
5592
5609
  return null;
5593
5610
  }
5611
+ function parseLine(line) {
5612
+ if (line.trim() === "")
5613
+ return null;
5614
+ try {
5615
+ const x = JSON.parse(line);
5616
+ return validateEnvelope(x) === null ? x : null;
5617
+ } catch {
5618
+ return null;
5619
+ }
5620
+ }
5621
+ function readEvents(path) {
5622
+ if (!existsSync2(path))
5623
+ return [];
5624
+ const out = [];
5625
+ for (const line of readFileSync(path, "utf8").split(`
5626
+ `)) {
5627
+ const e = parseLine(line);
5628
+ if (e)
5629
+ out.push(e);
5630
+ }
5631
+ return out;
5632
+ }
5594
5633
  function fold(events2) {
5595
5634
  const f = {
5596
5635
  stages: {},
@@ -5667,7 +5706,7 @@ var RULES = [
5667
5706
  var redactSecrets = (s) => RULES.reduce((a, [re, to]) => a.replace(re, to), s);
5668
5707
 
5669
5708
  // src/server/answer.ts
5670
- import { chmodSync, mkdirSync, writeFileSync } from "fs";
5709
+ import { chmodSync, mkdirSync as mkdirSync2, writeFileSync } from "fs";
5671
5710
  import { homedir } from "os";
5672
5711
  import { join as join2 } from "path";
5673
5712
  var MAX_ANSWER = 4000;
@@ -5687,7 +5726,7 @@ function writeAnswer(dir, source, run, answer) {
5687
5726
  if (answer.length > MAX_ANSWER)
5688
5727
  return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
5689
5728
  const d = join2(dir, source);
5690
- mkdirSync(d, { recursive: true, mode: 448 });
5729
+ mkdirSync2(d, { recursive: true, mode: 448 });
5691
5730
  const path = join2(d, `${run}.answer.txt`);
5692
5731
  writeFileSync(path, `${answer.trim()}
5693
5732
  `, { mode: 384 });
@@ -5695,6 +5734,149 @@ function writeAnswer(dir, source, run, answer) {
5695
5734
  return { status: 200, body: { ok: true, path, resume: `loki answer ${run}` } };
5696
5735
  }
5697
5736
 
5737
+ // src/server/integrity.ts
5738
+ import { createHash, createPublicKey, verify } from "crypto";
5739
+ import { readFileSync as readFileSync2 } from "fs";
5740
+ var SHA = /^[0-9a-f]{64}$/;
5741
+ var SEAL_LINE = "log.sealed";
5742
+ var REDACTED = /\[REDACTED(?::[A-Z_]+)?\]/;
5743
+ var REDACTED_NOTE = "log redacted before ingest; seal not checkable";
5744
+ var UNATTESTED_SUFFIX = " (unattested)";
5745
+ var SUCCESS_VERDICTS = new Set(["VERIFIED", "ALREADY_SATISFIED"]);
5746
+ var normVerdict = (v) => typeof v === "string" ? v.trim().toUpperCase() : null;
5747
+ var kidOf = (pub) => createHash("sha256").update(`{"crv":"Ed25519","kty":"OKP","x":"${pub.export({ format: "jwk" }).x}"}`).digest("base64url");
5748
+ function pubkeysFromEnv(env = process.env) {
5749
+ const keys = (env["LOKI_CP_RECEIPT_PUBKEYS"] ?? "").split(":").map((f) => f.trim()).filter(Boolean).flatMap((f) => {
5750
+ try {
5751
+ return [createPublicKey(readFileSync2(f))];
5752
+ } catch {
5753
+ return [];
5754
+ }
5755
+ });
5756
+ const fn = (kid) => keys.find((k) => kidOf(k) === kid);
5757
+ fn.configured = keys.length > 0;
5758
+ fn.fingerprint = createHash("sha256").update(keys.map(kidOf).sort().join(",")).digest("hex").slice(0, 16);
5759
+ return fn;
5760
+ }
5761
+ var line = (e) => JSON.stringify({ v: e.v, seq: e.seq, ts: e.ts, run: e.run, type: e.type, stage: e.stage, data: e.data });
5762
+ var verdictOf = (e) => typeof e?.data["verdict"] === "string" ? e.data["verdict"].trim().toUpperCase() : null;
5763
+ function sealedPrefix(evs) {
5764
+ const i = evs.findIndex((e) => e.type === SEAL_LINE);
5765
+ return i < 0 ? [...evs] : evs.slice(0, i + 1);
5766
+ }
5767
+ function verifyRunIntegrity(evs, opts = {}) {
5768
+ const bad = [], notes = [];
5769
+ const doneAt = evs.findIndex((e) => e.type === "run.completed");
5770
+ const sealedAt = evs.map((e, i) => e.type === "receipt.sealed" ? i : -1).filter((i) => i >= 0);
5771
+ const finalised = doneAt >= 0 || sealedAt.length > 0;
5772
+ const lines = evs.map(line);
5773
+ const keysConfigured = opts.keysConfigured ?? opts.pubkeyFor?.configured ?? false;
5774
+ let sigChecked = false;
5775
+ if (finalised) {
5776
+ const gap = evs.findIndex((e, i) => e.seq !== i);
5777
+ if (gap >= 0)
5778
+ bad.push(`event log has a seq gap, duplicate or missing start at position ${gap} (seq ${evs[gap].seq})`);
5779
+ }
5780
+ if (evs.some((e) => e.type === "tamper.detected"))
5781
+ bad.push("the supervisor detected events.jsonl being modified during the run");
5782
+ const firstSeal = evs.findIndex((e) => e.type === SEAL_LINE);
5783
+ if (firstSeal >= 0 && evs.slice(firstSeal + 1).some((e) => e.type === "run.completed" || e.type === "receipt.sealed" || typeof e.data["verdict"] === "string"))
5784
+ bad.push("a verdict-bearing event after log.sealed is unauthenticated");
5785
+ if (evs.filter((e) => e.type === "run.completed").length > 1)
5786
+ bad.push("more than one run.completed (a verdict appended after the seal is unauthenticated)");
5787
+ const hashBad = [];
5788
+ const redacted = REDACTED.test(JSON.stringify(doneAt >= 0 ? evs.slice(0, doneAt + 2).map((e) => e.data) : evs.map((e) => e.data)));
5789
+ for (const i of sealedAt) {
5790
+ const d = evs[i].data;
5791
+ if (typeof d["receipt_sha256"] !== "string" || !SHA.test(d["receipt_sha256"]))
5792
+ hashBad.push(`receipt.sealed at seq ${evs[i].seq} has no valid receipt_sha256`);
5793
+ const bound = d["events_sha256"];
5794
+ if (typeof bound === "string") {
5795
+ const h = createHash("sha256");
5796
+ let ok = false;
5797
+ for (const l of lines) {
5798
+ if (h.copy().digest("hex") === bound) {
5799
+ ok = true;
5800
+ break;
5801
+ }
5802
+ h.update(l + `
5803
+ `);
5804
+ }
5805
+ if (!ok && h.digest("hex") === bound)
5806
+ ok = true;
5807
+ if (!ok)
5808
+ hashBad.push("receipt.sealed events_sha256 does not match any prefix of the ingested log");
5809
+ }
5810
+ }
5811
+ const sealLines = evs.map((e, i) => e.type === SEAL_LINE ? i : -1).filter((i) => i >= 0);
5812
+ if (sealLines.some((i) => i !== doneAt + 1))
5813
+ bad.push(`${SEAL_LINE} is not the line right after run.completed`);
5814
+ const ls = doneAt >= 0 && evs[doneAt + 1]?.type === SEAL_LINE ? evs[doneAt + 1].data : null;
5815
+ const signedSeals = sealedAt.map((i) => evs[i].data).filter((d) => d["signed"] === true);
5816
+ const signedReceipt = signedSeals.length > 0;
5817
+ if (ls) {
5818
+ const sha = createHash("sha256").update(lines.slice(0, doneAt + 1).join(`
5819
+ `) + `
5820
+ `).digest("hex");
5821
+ const sig = typeof ls["sig"] === "string" ? Buffer.from(ls["sig"], "base64url") : null;
5822
+ const kid = ls["kid"];
5823
+ if (ls["tampered"] !== false)
5824
+ bad.push(`${SEAL_LINE} line records a tamper or is malformed`);
5825
+ else if (typeof kid !== "string" || !sig || sig.length !== 64)
5826
+ hashBad.push(`${SEAL_LINE} line is malformed or records a tamper`);
5827
+ else {
5828
+ if (signedSeals.some((d) => typeof d["kid"] === "string" && d["kid"] !== kid))
5829
+ hashBad.push(`${SEAL_LINE} kid differs from the receipt kid`);
5830
+ if (ls["events_sha256"] !== sha)
5831
+ hashBad.push(`${SEAL_LINE} events_sha256 does not match the ingested log`);
5832
+ else {
5833
+ const pub = opts.pubkeyFor?.(kid);
5834
+ if (pub) {
5835
+ sigChecked = true;
5836
+ if (!verify(null, Buffer.from(`${sha}:false`), pub, sig))
5837
+ bad.push(`${SEAL_LINE} signature does not verify`);
5838
+ } else if (keysConfigured)
5839
+ bad.push(`${SEAL_LINE} kid ${kid} has no configured public key`);
5840
+ }
5841
+ }
5842
+ } else if (signedReceipt && doneAt >= 0)
5843
+ bad.push(`signed receipt but no ${SEAL_LINE} line after run.completed`);
5844
+ let unverifiable = false;
5845
+ if (hashBad.length > 0) {
5846
+ if (redacted) {
5847
+ unverifiable = true;
5848
+ sigChecked = false;
5849
+ notes.push(REDACTED_NOTE);
5850
+ } else
5851
+ bad.push(...hashBad);
5852
+ }
5853
+ let claimsVerified = false;
5854
+ const claimed = doneAt >= 0 ? verdictOf(evs[doneAt]) : null;
5855
+ if (claimed !== null && SUCCESS_VERDICTS.has(claimed)) {
5856
+ claimsVerified = true;
5857
+ const before = sealedAt.filter((i) => i < doneAt);
5858
+ if (before.length === 0)
5859
+ bad.push(`run.completed claims ${claimed} but no receipt.sealed precedes it`);
5860
+ else if (!before.some((i) => verdictOf(evs[i]) === claimed))
5861
+ bad.push(`run.completed claims ${claimed} but the sealed receipt does not`);
5862
+ }
5863
+ const tampered = bad.length > 0;
5864
+ const attested = !tampered && !unverifiable && (!claimsVerified || signedReceipt && ls !== null);
5865
+ return { tampered, attested, sig_checked: sigChecked && !tampered, reasons: [...bad, ...notes] };
5866
+ }
5867
+ function effectiveVerdict(r) {
5868
+ if (r.tampered)
5869
+ return "TAMPERED";
5870
+ const v = normVerdict(r.verdict);
5871
+ if (v === null)
5872
+ return r.verdict;
5873
+ if (r.attested === false)
5874
+ return SUCCESS_VERDICTS.has(v) ? "UNVERIFIED" : `${r.verdict}${UNATTESTED_SUFFIX}`;
5875
+ if (SUCCESS_VERDICTS.has(v))
5876
+ return r.sig_checked === false ? `${v} (signature not checked)` : v;
5877
+ return r.verdict;
5878
+ }
5879
+
5698
5880
  // src/server/runs.ts
5699
5881
  var str = (x) => typeof x === "string" ? x : null;
5700
5882
  function loadEvents(db, sourceId, runId) {
@@ -5704,8 +5886,11 @@ function rebuildRun(db, sourceId, runId) {
5704
5886
  const evs = loadEvents(db, sourceId, runId);
5705
5887
  if (evs.length === 0)
5706
5888
  return;
5707
- const f = fold(evs);
5708
- const pc = partialCost(evs, f.run.tampered);
5889
+ const f = fold(sealedPrefix(evs));
5890
+ const keys = pubkeysFromEnv();
5891
+ const integ = verifyRunIntegrity(evs, { pubkeyFor: keys });
5892
+ const tampered = f.run.tampered || integ.tampered;
5893
+ const pc = partialCost(evs, tampered);
5709
5894
  const sd = f.run.started?.data ?? {};
5710
5895
  const done = f.run.completed?.data ?? {};
5711
5896
  const pr = evs.find((e) => e.type === "pr.opened")?.data;
@@ -5733,10 +5918,22 @@ function rebuildRun(db, sourceId, runId) {
5733
5918
  wallS: typeof done.wall_s === "number" ? done.wall_s : null,
5734
5919
  lastSeq: f.lastSeq,
5735
5920
  lastEventAt: evs[evs.length - 1]?.ts ?? null,
5736
- tampered: Number(f.run.tampered)
5921
+ tampered: Number(tampered),
5922
+ attested: Number(integ.attested),
5923
+ sigChecked: Number(integ.sig_checked),
5924
+ integrityKeyFp: keys.fingerprint ?? "",
5925
+ integrityReasons: JSON.stringify(integ.reasons)
5737
5926
  };
5738
5927
  db.insert(runs).values(row).onConflictDoUpdate({ target: [runs.sourceId, runs.runId], set: row }).run();
5739
5928
  }
5929
+ var reasonsOf = (j) => {
5930
+ try {
5931
+ const x = JSON.parse(j ?? "[]");
5932
+ return Array.isArray(x) ? x.filter((s) => typeof s === "string") : [];
5933
+ } catch {
5934
+ return [];
5935
+ }
5936
+ };
5740
5937
  var parseRun = (r) => ({
5741
5938
  source_id: r.sourceId,
5742
5939
  run_id: r.runId,
@@ -5763,6 +5960,10 @@ var parseRun = (r) => ({
5763
5960
  last_event_at: r.lastEventAt,
5764
5961
  tampered: r.tampered === 1,
5765
5962
  conflict: r.conflict === 1,
5963
+ attested: r.attested === 1,
5964
+ sig_checked: r.sigChecked === 1,
5965
+ integrity_reasons: reasonsOf(r.integrityReasons),
5966
+ effective_verdict: effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 }),
5766
5967
  status: r.endedAt ? "completed" : "running",
5767
5968
  elapsed_s: elapsedS(r)
5768
5969
  });
@@ -5801,10 +6002,44 @@ var elapsedS = (r) => {
5801
6002
  const end = r.endedAt ? Date.parse(r.endedAt) : Date.now();
5802
6003
  return Math.max(0, (end - Date.parse(r.startedAt)) / 1000);
5803
6004
  };
6005
+ var SIG_SUFFIX = " (signature not checked)";
6006
+ var verdictFilter = (v) => {
6007
+ if (!v)
6008
+ return;
6009
+ if (v === "TAMPERED")
6010
+ return eq(runs.tampered, 1);
6011
+ if (v === "UNVERIFIED")
6012
+ return and(eq(runs.tampered, 0), sql`${runs.verdict} is not null`, sql`coalesce(${runs.attested}, 0) != 1`);
6013
+ if (SUCCESS_VERDICTS.has(v))
6014
+ return and(eq(runs.verdict, v), eq(runs.tampered, 0), eq(runs.attested, 1), eq(runs.sigChecked, 1));
6015
+ if (v.endsWith(SIG_SUFFIX) && SUCCESS_VERDICTS.has(v.slice(0, -SIG_SUFFIX.length)))
6016
+ return and(eq(runs.verdict, v.slice(0, -SIG_SUFFIX.length)), eq(runs.tampered, 0), eq(runs.attested, 1), sql`coalesce(${runs.sigChecked}, 0) != 1`);
6017
+ return and(eq(runs.verdict, v), eq(runs.tampered, 0), eq(runs.attested, 1));
6018
+ };
6019
+ var RECOMPUTE_CHUNK = 200;
6020
+ function recomputeLegacy(db) {
6021
+ const fp = pubkeysFromEnv().fingerprint ?? "";
6022
+ let n = 0;
6023
+ for (;; ) {
6024
+ const rows = db.select({ s: runs.sourceId, r: runs.runId }).from(runs).where(sql`${runs.attested} is null or ${runs.integrityKeyFp} is not ${fp}`).limit(RECOMPUTE_CHUNK).all();
6025
+ if (rows.length === 0)
6026
+ return n;
6027
+ db.transaction((tx) => {
6028
+ const t = tx;
6029
+ for (const x of rows) {
6030
+ if (loadEvents(t, x.s, x.r).length === 0) {
6031
+ t.update(runs).set({ attested: 0, sigChecked: 0, integrityKeyFp: fp, integrityReasons: JSON.stringify(["stored events are missing; integrity cannot be evaluated"]) }).where(and(eq(runs.sourceId, x.s), eq(runs.runId, x.r))).run();
6032
+ } else
6033
+ rebuildRun(t, x.s, x.r);
6034
+ }
6035
+ });
6036
+ n += rows.length;
6037
+ }
6038
+ }
5804
6039
  function listRuns(db, q) {
5805
6040
  const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
5806
6041
  const offset = Math.max(Number.parseInt(q.cursor ?? "0", 10) || 0, 0);
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);
6042
+ const where = and(verdictFilter(q.verdict), 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);
5808
6043
  const total = db.select({ n: sql`count(*)` }).from(runs).where(where).get()?.n ?? 0;
5809
6044
  const rows = db.select().from(runs).where(where).orderBy(desc(runs.startedAt), asc(runs.runId)).limit(limit).offset(offset).all();
5810
6045
  return { runs: rows.map((r) => withLive(db, r)), total, next_cursor: offset + rows.length < total ? String(offset + rows.length) : null };
@@ -5860,7 +6095,7 @@ function ingest(db, body) {
5860
6095
  if (e.run !== runId)
5861
6096
  return { status: 400, body: { error: `events[${i}]: run does not match run_id` } };
5862
6097
  const json = JSON.stringify(e);
5863
- rows.push({ e, json, sha: createHash("sha256").update(json).digest("hex") });
6098
+ rows.push({ e, json, sha: createHash2("sha256").update(json).digest("hex") });
5864
6099
  }
5865
6100
  const now = new Date().toISOString();
5866
6101
  let accepted = 0, duplicate = 0, conflict = 0;
@@ -5889,7 +6124,7 @@ function ingest(db, body) {
5889
6124
  }
5890
6125
 
5891
6126
  // src/server/auth.ts
5892
- import { createHash as createHash2, timingSafeEqual } from "crypto";
6127
+ import { createHash as createHash3, timingSafeEqual } from "crypto";
5893
6128
  var LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
5894
6129
  function isLoopbackHost(host) {
5895
6130
  return !!host && /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
@@ -5898,8 +6133,8 @@ function tokenMatches(header, token) {
5898
6133
  const m = /^Bearer (.+)$/i.exec(header ?? "");
5899
6134
  if (!m)
5900
6135
  return false;
5901
- const a = createHash2("sha256").update(m[1] ?? "").digest();
5902
- const b = createHash2("sha256").update(token).digest();
6136
+ const a = createHash3("sha256").update(m[1] ?? "").digest();
6137
+ const b = createHash3("sha256").update(token).digest();
5903
6138
  return timingSafeEqual(a, b);
5904
6139
  }
5905
6140
  function bindRefusal(env) {
@@ -5933,12 +6168,240 @@ function tokenGuard(token) {
5933
6168
  };
5934
6169
  }
5935
6170
 
6171
+ // src/shipper/backfill.ts
6172
+ import { readdirSync } from "fs";
6173
+ import { join as join4 } from "path";
6174
+
6175
+ // src/shipper/ship.ts
6176
+ import { createHash as createHash4 } from "crypto";
6177
+ import { existsSync as existsSync3, readFileSync as readFileSync3, realpathSync, renameSync, writeFileSync as writeFileSync2 } from "fs";
6178
+ import { hostname } from "os";
6179
+ import { join as join3 } from "path";
6180
+ var BATCH = 200;
6181
+ var MAX_BYTES = 800000;
6182
+ function sourceId(repoDir) {
6183
+ let real2 = repoDir;
6184
+ try {
6185
+ real2 = realpathSync(repoDir);
6186
+ } catch {}
6187
+ return createHash4("sha256").update(`${hostname()}\x00${real2}`).digest("hex").slice(0, 16);
6188
+ }
6189
+ var redactDeep = (x) => typeof x === "string" ? redactSecrets(x) : Array.isArray(x) ? x.map(redactDeep) : x && typeof x === "object" ? Object.fromEntries(Object.entries(x).map(([k, v]) => [k, redactDeep(v)])) : x;
6190
+ var redactEvent = (e) => ({ ...e, data: redactDeep(e.data) });
6191
+ var backoffMs = (failures, rnd = Math.random) => Math.min(60000, 1000 * 2 ** Math.max(0, failures - 1)) * (1 + 0.25 * rnd());
6192
+ function readShip(runDir, url) {
6193
+ try {
6194
+ const s = JSON.parse(readFileSync3(join3(runDir, "ship.json"), "utf8"));
6195
+ if (s.url === url && Number.isInteger(s.acked_seq))
6196
+ return { acked_seq: s.acked_seq, url };
6197
+ } catch {}
6198
+ return { acked_seq: -1, url };
6199
+ }
6200
+ function writeShip(runDir, s) {
6201
+ const tmp = join3(runDir, `ship.json.${process.pid}.tmp`);
6202
+ writeFileSync2(tmp, JSON.stringify(s) + `
6203
+ `);
6204
+ renameSync(tmp, join3(runDir, "ship.json"));
6205
+ }
6206
+ async function shipRun(o) {
6207
+ const st = readShip(o.runDir, o.url);
6208
+ const all = readEvents(join3(o.runDir, "events.jsonl"));
6209
+ let lines = 0;
6210
+ try {
6211
+ lines = readFileSync3(join3(o.runDir, "events.jsonl"), "utf8").split(`
6212
+ `).filter((l) => l.trim() !== "").length;
6213
+ } catch {}
6214
+ const skipped = lines - all.length;
6215
+ const todo = all.filter((e) => e.seq > st.acked_seq).sort((a, b) => a.seq - b.seq);
6216
+ if (todo.length === 0)
6217
+ return { ok: true, sent: 0, acked_seq: st.acked_seq, pending: 0 };
6218
+ const run_id = todo[0].run;
6219
+ const f = o.fetchImpl ?? fetch;
6220
+ let sent = 0;
6221
+ const fail = () => ({ ok: false, sent, acked_seq: st.acked_seq, pending: todo.length - sent });
6222
+ for (let i = 0;i < todo.length; ) {
6223
+ const batch = [];
6224
+ for (let bytes = 0;i < todo.length && batch.length < BATCH; i++) {
6225
+ bytes += JSON.stringify(todo[i]).length;
6226
+ if (batch.length > 0 && bytes > MAX_BYTES)
6227
+ break;
6228
+ batch.push(redactEvent(todo[i]));
6229
+ }
6230
+ try {
6231
+ const r = await f(`${o.url.replace(/\/+$/, "")}/v1/ingest`, {
6232
+ method: "POST",
6233
+ headers: { "content-type": "application/json", ...o.token ? { authorization: `Bearer ${o.token}` } : {} },
6234
+ body: JSON.stringify({ source: o.source, run_id, events: batch }),
6235
+ signal: AbortSignal.timeout(o.timeoutMs ?? 1e4)
6236
+ });
6237
+ if (!r.ok && r.status !== 409)
6238
+ return fail();
6239
+ } catch {
6240
+ return fail();
6241
+ }
6242
+ st.acked_seq = batch[batch.length - 1].seq;
6243
+ st.skipped = skipped;
6244
+ sent += batch.length;
6245
+ try {
6246
+ writeShip(o.runDir, st);
6247
+ } catch {
6248
+ return fail();
6249
+ }
6250
+ }
6251
+ return { ok: true, sent, acked_seq: st.acked_seq, pending: 0 };
6252
+ }
6253
+ var shipEnabled = (env) => env.LOKI_CONTROL_URL || null;
6254
+ var hasSpool = (runDir) => existsSync3(join3(runDir, "events.jsonl"));
6255
+
6256
+ // src/shipper/backfill.ts
6257
+ async function backfill(o) {
6258
+ const env = o.env ?? process.env;
6259
+ const url = shipEnabled(env);
6260
+ if (!url)
6261
+ return { runs: 0, sent: 0, failed: [] };
6262
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6263
+ const attempts = o.attempts ?? 3;
6264
+ const root = join4(o.repoDir, ".loki", "runs");
6265
+ let ids = [];
6266
+ try {
6267
+ ids = readdirSync(root).sort();
6268
+ } catch {
6269
+ return { runs: 0, sent: 0, failed: [] };
6270
+ }
6271
+ const source = sourceId(o.repoDir);
6272
+ let runs2 = 0, sent = 0;
6273
+ const failed = [];
6274
+ for (const id of ids) {
6275
+ const runDir = join4(root, id);
6276
+ if (!hasSpool(runDir))
6277
+ continue;
6278
+ runs2++;
6279
+ let res = { ok: false, sent: 0, acked_seq: -1, pending: 0 };
6280
+ for (let a = 1;a <= attempts; a++) {
6281
+ res = await shipRun({ runDir, url, source, token: env.LOKI_CONTROL_TOKEN, fetchImpl: o.fetchImpl });
6282
+ sent += res.sent;
6283
+ if (res.ok)
6284
+ break;
6285
+ if (a < attempts)
6286
+ await sleep(backoffMs(a));
6287
+ }
6288
+ if (!res.ok)
6289
+ failed.push(id);
6290
+ }
6291
+ return { runs: runs2, sent, failed };
6292
+ }
6293
+
6294
+ // src/db/prune.ts
6295
+ var n = (sqlite, sql2, ...args) => sqlite.query(sql2).get(...args).n;
6296
+ function countFor(sqlite, keys) {
6297
+ const bySource = new Map;
6298
+ for (const k of keys)
6299
+ bySource.set(k.source_id, [...bySource.get(k.source_id) ?? [], k.run_id]);
6300
+ let events2 = 0, sources2 = 0;
6301
+ for (const [src, runIds] of bySource) {
6302
+ let ev = 0;
6303
+ for (const r of runIds)
6304
+ ev += n(sqlite, "select count(*) n from events where source_id = ? and run_id = ?", src, r);
6305
+ events2 += ev;
6306
+ const runsLeft = n(sqlite, "select count(*) n from runs where source_id = ?", src) - runIds.length;
6307
+ const eventsLeft = n(sqlite, "select count(*) n from events where source_id = ?", src) - ev;
6308
+ if (runsLeft <= 0 && eventsLeft <= 0 && sqlite.query("select 1 x from sources where id = ?").get(src))
6309
+ sources2++;
6310
+ }
6311
+ return { runs: keys.length, events: events2, sources: sources2 };
6312
+ }
6313
+ function audit2(sqlite, action, actor, detail) {
6314
+ sqlite.query("insert into audit (ts, action, actor, detail) values (?, ?, ?, ?)").run(new Date().toISOString(), action, actor, JSON.stringify(detail));
6315
+ }
6316
+ var dropOrphanSources = (sqlite, ids) => {
6317
+ for (const id of ids)
6318
+ sqlite.query("delete from sources where id = ? and not exists (select 1 from runs where source_id = ?) and not exists (select 1 from events where source_id = ?)").run(id, id, id);
6319
+ };
6320
+ function removeRun(sqlite, source, run, actor = "http") {
6321
+ let out = null;
6322
+ sqlite.transaction(() => {
6323
+ if (!sqlite.query("select 1 x from runs where source_id = ? and run_id = ?").get(source, run))
6324
+ return;
6325
+ out = countFor(sqlite, [{ source_id: source, run_id: run }]);
6326
+ audit2(sqlite, "run.remove", actor, { source_id: source, run_id: run, counts: out });
6327
+ sqlite.query("delete from events where source_id = ? and run_id = ?").run(source, run);
6328
+ sqlite.query("delete from runs where source_id = ? and run_id = ?").run(source, run);
6329
+ dropOrphanSources(sqlite, [source]);
6330
+ }).immediate();
6331
+ return out;
6332
+ }
6333
+
6334
+ // src/server/actions.ts
6335
+ import { spawn } from "child_process";
6336
+ import { existsSync as existsSync4, readFileSync as readFileSync4, statSync } from "fs";
6337
+ import { homedir as homedir2 } from "os";
6338
+ import { join as join5, resolve } from "path";
6339
+ var ISSUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9._-]{1,100}#[1-9][0-9]{0,8}$/;
6340
+ var TASK = /^[A-Za-z0-9][A-Za-z0-9 .,_/#@+=-]{0,499}$/;
6341
+ var hasDotDot = (t) => t.split(/[\\/ ]/).some((seg) => seg.startsWith(".."));
6342
+ function childEnv(env = process.env) {
6343
+ const e = { ...env, LOKI_NO_BROWSER: "1" };
6344
+ for (const k of ["LOKI_CONTROL_TOKEN", "LOKI_CONTROL_DB", "LOKI_CONTROL_HOST", "PORT"])
6345
+ delete e[k];
6346
+ return e;
6347
+ }
6348
+ function registryRepos(env = process.env) {
6349
+ try {
6350
+ const j = JSON.parse(readFileSync4(join5(env.HOME || homedir2(), ".loki", "dashboard", "projects.json"), "utf8"));
6351
+ return Object.values(j.projects ?? {}).map((p) => typeof p?.path === "string" ? p.path : "").filter((p) => p && existsSync4(p) && statSync(p).isDirectory());
6352
+ } catch {
6353
+ return [];
6354
+ }
6355
+ }
6356
+ function planStart(body, known, bin = "loki") {
6357
+ const b = body && typeof body === "object" ? body : {};
6358
+ if (typeof b.target !== "string")
6359
+ return { ok: false, error: "target must be a string" };
6360
+ const target = b.target.trim();
6361
+ const issue = ISSUE.test(target);
6362
+ if (!issue && !TASK.test(target))
6363
+ return { ok: false, error: "target must be owner/repo#N or a plain task (letters, digits, spaces, . , _ / # @ + = -)" };
6364
+ if (hasDotDot(target))
6365
+ return { ok: false, error: "target must not contain a .. path segment" };
6366
+ let cwd = process.cwd();
6367
+ if (b.repo !== undefined && b.repo !== "") {
6368
+ if (typeof b.repo !== "string" || b.repo.includes("\x00"))
6369
+ return { ok: false, error: "repo must be a path string" };
6370
+ const want = resolve(b.repo);
6371
+ const hit = known.map((k) => resolve(k)).find((k) => k === want);
6372
+ if (!hit)
6373
+ return { ok: false, error: "repo is not a known project" };
6374
+ cwd = hit;
6375
+ }
6376
+ return { ok: true, argv: issue ? [bin, "start", target] : [bin, "start", "--brief", target], cwd };
6377
+ }
6378
+ function spawnStart(argv, cwd, onExit = () => {}) {
6379
+ return new Promise((done) => {
6380
+ try {
6381
+ const [cmd, ...args] = argv;
6382
+ const child = spawn(cmd, args, { cwd, detached: true, stdio: "ignore", shell: false, env: childEnv() });
6383
+ child.once("exit", onExit);
6384
+ child.once("error", (e) => {
6385
+ onExit();
6386
+ done({ error: `could not start loki: ${e.message}` });
6387
+ });
6388
+ child.once("spawn", () => {
6389
+ child.unref();
6390
+ done({ pid: child.pid ?? 0 });
6391
+ });
6392
+ } catch (e) {
6393
+ done({ error: `could not start loki: ${e.message}` });
6394
+ }
6395
+ });
6396
+ }
6397
+
5936
6398
  // src/server/app.ts
5937
6399
  var MAX_BODY = 1e6;
5938
- var defaultUiDir = () => [join3(import.meta.dir, "../../ui/dist"), join3(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join3(d, "index.html")));
6400
+ var defaultUiDir = () => [join6(import.meta.dir, "../../ui/dist"), join6(import.meta.dir, "../ui/dist")].find((d) => existsSync5(join6(d, "index.html")));
5939
6401
  function createApp(opts) {
5940
6402
  const uiDir = opts.uiDir ?? defaultUiDir();
5941
6403
  const { db, sqlite } = openDb(opts.dbPath);
6404
+ recomputeLegacy(db);
5942
6405
  let ready = true;
5943
6406
  const answerDir = opts.answerDir ?? defaultAnswerDir();
5944
6407
  const app = new Hono3;
@@ -6006,6 +6469,71 @@ function createApp(opts) {
6006
6469
  const r = writeAnswer(answerDir, source, run, body?.answer);
6007
6470
  return c.json(r.body, r.status);
6008
6471
  });
6472
+ const act = opts.loopbackOnly ? app : new Hono3;
6473
+ const peerIsLoopback = (c) => {
6474
+ const ip = c.env?.requestIP?.(c.req.raw)?.address;
6475
+ return typeof ip === "string" && /^(127\.\d+\.\d+\.\d+|::1|::ffff:127\.\d+\.\d+\.\d+)$/.test(ip);
6476
+ };
6477
+ const local = (c) => peerIsLoopback(c) && isLoopbackHost(c.req.header("host")) && (c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json");
6478
+ const repoDir = opts.repoDir ?? process.cwd();
6479
+ const inflight = new Set;
6480
+ act.post("/v1/import", async (c) => {
6481
+ if (!local(c))
6482
+ return c.json({ error: "loopback JSON requests only" }, 403);
6483
+ const r = await backfill({
6484
+ repoDir,
6485
+ env: { LOKI_CONTROL_URL: "http://127.0.0.1", LOKI_CONTROL_TOKEN: opts.token },
6486
+ fetchImpl: (u, init) => app.fetch(new Request(u, { ...init, headers: { ...init.headers, host: "127.0.0.1" } }))
6487
+ });
6488
+ return c.json({ runs: r.runs, sent: r.sent, failed: r.failed });
6489
+ });
6490
+ act.delete("/v1/runs/:source/:run", async (c) => {
6491
+ if (!peerIsLoopback(c) || !isLoopbackHost(c.req.header("host")))
6492
+ return c.json({ error: "loopback only" }, 403);
6493
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json"))
6494
+ return c.json({ error: "content-type must be application/json" }, 400);
6495
+ const origin = c.req.header("origin");
6496
+ if (origin !== undefined) {
6497
+ let oh = "";
6498
+ try {
6499
+ oh = new URL(origin).host.toLowerCase();
6500
+ } catch {}
6501
+ if (!oh || oh !== (c.req.header("host") ?? "").toLowerCase())
6502
+ return c.json({ error: "origin not allowed" }, 403);
6503
+ }
6504
+ const source = c.req.param("source"), run = c.req.param("run");
6505
+ const r = removeRun(sqlite, source, run);
6506
+ if (!r)
6507
+ return c.json({ error: "run not found" }, 404);
6508
+ const left = sqlite.query("select count(*) n from runs").get().n;
6509
+ return c.json({ ok: true, removed: r, remaining_runs: left });
6510
+ });
6511
+ act.get("/v1/repos", (c) => peerIsLoopback(c) ? c.json({ repos: [...new Set([repoDir, ...registryRepos()])] }) : c.json({ error: "loopback only" }, 403));
6512
+ act.post("/v1/start", async (c) => {
6513
+ if (!local(c))
6514
+ return c.json({ error: "loopback JSON requests only" }, 403);
6515
+ const text2 = await c.req.text();
6516
+ if (text2.length > 20000)
6517
+ return c.json({ error: "body too large" }, 413);
6518
+ let body;
6519
+ try {
6520
+ body = JSON.parse(text2);
6521
+ } catch {
6522
+ return c.json({ error: "invalid JSON" }, 400);
6523
+ }
6524
+ const plan = planStart(body, [repoDir, ...registryRepos()], opts.startBin);
6525
+ if (!plan.ok)
6526
+ return c.json({ error: plan.error }, 400);
6527
+ if (inflight.has(plan.cwd))
6528
+ return c.json({ error: "a run is already starting or running in this repo" }, 409);
6529
+ inflight.add(plan.cwd);
6530
+ const r = await (opts.spawnImpl ?? spawnStart)(plan.argv, plan.cwd, () => inflight.delete(plan.cwd));
6531
+ if ("error" in r) {
6532
+ inflight.delete(plan.cwd);
6533
+ return c.json({ error: r.error }, 500);
6534
+ }
6535
+ return c.json({ ok: true, pid: r.pid, command: plan.argv.slice(1).join(" ") });
6536
+ });
6009
6537
  app.get("/v1/runs/:id", (c) => {
6010
6538
  const id = c.req.param("id");
6011
6539
  const i = id.indexOf(":");
@@ -6020,13 +6548,13 @@ function createApp(opts) {
6020
6548
  }
6021
6549
  if (!uiDir || p.startsWith("/v1/"))
6022
6550
  return c.json({ error: "not found" }, 404);
6023
- const root = resolve(uiDir);
6024
- const f = resolve(root, `.${p}`);
6025
- if (f.startsWith(root + sep) && existsSync2(f) && statSync(f).isFile())
6551
+ const root = resolve2(uiDir);
6552
+ const f = resolve2(root, `.${p}`);
6553
+ if (f.startsWith(root + sep) && existsSync5(f) && statSync2(f).isFile())
6026
6554
  return new Response(Bun.file(f));
6027
6555
  if (/\.[a-z0-9]+$/i.test(p))
6028
6556
  return c.json({ error: "not found" }, 404);
6029
- return new Response(Bun.file(join3(root, "index.html")));
6557
+ return new Response(Bun.file(join6(root, "index.html")));
6030
6558
  });
6031
6559
  return { app, db, close: () => sqlite.close() };
6032
6560
  }