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.
- package/README.md +9 -5
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/acp-agent.py +128 -0
- package/autonomy/docker-run.sh +8 -0
- package/autonomy/issue-parser.sh +7 -7
- package/autonomy/issue-providers.sh +159 -1
- package/autonomy/lib/engine10-push.sh +4 -1
- package/autonomy/lib/review_risk.py +91 -0
- package/autonomy/loki +209 -19
- package/autonomy/mobile-verify.sh +66 -0
- package/autonomy/run.sh +44 -2
- package/completions/_loki +2 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_runs_v1.py +80 -0
- package/dashboard/server.py +2 -0
- package/docs/INSTALLATION.md +5 -1
- package/docs/acp-editors.md +29 -0
- package/docs/api-runs-v1.md +12 -0
- package/docs/dev/architecture-reference.md +4 -0
- package/docs/merge-queue.md +13 -0
- package/docs/mobile-verify.md +11 -0
- package/docs/sentry-intake.md +13 -0
- package/docs/v10/BOARD.md +31 -6
- package/docs/v10/CONTROL-PLANE.md +7 -0
- package/docs/v10/CP-ENTERPRISE-UI.md +276 -0
- package/docs/v10/D61-SPEED.md +4 -0
- package/docs/v10/DECISIONS.md +115 -0
- package/docs/v10/ENGINE-LAWS.md +155 -0
- package/docs/v10/FAILURE-CLASSES.md +145 -0
- package/docs/v10/FOUNDER-QUEUE.md +1 -0
- package/docs/v10/GAP-RESEARCH-1003.md +32 -0
- package/docs/v10/GUIDE.md +4 -2
- package/docs/v10/L0-WAVE1.md +81 -0
- package/docs/v10/MORNING-BRIEF-1003.md +25 -0
- package/docs/v10/OPERATING-MODEL.md +1 -0
- package/docs/v10/PROGRESS.md +99 -0
- package/docs/v10/SWARM.md +32 -0
- package/docs/visual-evidence.md +5 -0
- package/loki-ts/dist/loki.js +805 -777
- package/loki-ts/package.json +1 -0
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +549 -21
- package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
- package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
- package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
- package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
- package/packages/control-plane/drizzle/meta/_journal.json +14 -0
- package/packages/control-plane/src/db/migrate.ts +6 -2
- package/packages/control-plane/src/db/prune.ts +91 -0
- package/packages/control-plane/src/db/schema.ts +13 -0
- package/packages/control-plane/src/server/actions.ts +60 -0
- package/packages/control-plane/src/server/app.ts +59 -4
- package/packages/control-plane/src/server/integrity.ts +147 -0
- package/packages/control-plane/src/server/runs.ts +46 -4
- package/packages/control-plane/src/shipper/discover.ts +32 -3
- package/packages/control-plane/src/shipper/ship.ts +15 -0
- package/packages/control-plane/src/shipper/watch.ts +66 -0
- package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +1 -0
- package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +129 -0
- package/packages/control-plane/ui/dist/index.html +3 -3
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-oAFMNfKH.js → AdminPage-BJSU5hY1.js} +1 -1
- package/web-app/dist/assets/{Avatar-BA2gnzyf.js → Avatar-CSOSpw9c.js} +1 -1
- package/web-app/dist/assets/{Badge-Dsluv8n1.js → Badge-COr7vj0D.js} +1 -1
- package/web-app/dist/assets/{Button-WmW8A-iG.js → Button-DGoWFERp.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-BR5o_RgL.js → CockpitPage-CBpWkNXY.js} +1 -1
- package/web-app/dist/assets/{ComparePage-3Eg-pKvO.js → ComparePage-BwT8vqrL.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-DAfrnnsQ.js → ErrorBoundary-D7SicDDA.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-wLZI4OTr.js → EvidenceReceiptPanel-bx-5liG2.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-BOuadisf.js → GitHubIssuesPanel-BAeeWLqY.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BTVoVpB3.js → GitHubPRsPanel-CssD8lGI.js} +1 -1
- package/web-app/dist/assets/{HomePage-s3GWdNEe.js → HomePage-SL5PT3m5.js} +2 -2
- package/web-app/dist/assets/{LoginPage-BfOZ566t.js → LoginPage-C8hFOZOM.js} +1 -1
- package/web-app/dist/assets/{MagicPage-9EZ83xOt.js → MagicPage-xHbHkrPf.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-OJMOYhHP.js → MetricsPage-B_2CKFcC.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-_LoJ6KEv.js → NotFoundPage-CXwXMq35.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-Duj5LSt6.js → ProjectPage-AaisMQI-.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-BBud3JrO.js → ProjectsPage-Cej5ResX.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-C4zdgKno.js → SettingsPage-DKv4mn9H.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-Nb3ACjMR.js → ShowcasePage-Dkiyzne4.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-BDi49TDh.js → SystemSettingsPage-DaBLQ5dU.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-HkcqkjLa.js → TeamsPage-B2yUr31k.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-CK796Y1F.js → TemplatesPage-CfYiANhU.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-BkvdNuT6.js → TerminalOutput-CMXoexLM.js} +1 -1
- package/web-app/dist/assets/{activity-DFgMgETR.js → activity-B0dzoZtY.js} +1 -1
- package/web-app/dist/assets/{bell-BInGGOMZ.js → bell-DFOTaSoo.js} +1 -1
- package/web-app/dist/assets/{bot-D43Yjsj4.js → bot-Dz-WSQyP.js} +1 -1
- package/web-app/dist/assets/{check-DFAGTLM9.js → check-L1QIRueJ.js} +1 -1
- package/web-app/dist/assets/{chevron-left-Drwin7n1.js → chevron-left-1TrnIEZS.js} +1 -1
- package/web-app/dist/assets/{circle-alert-DLWdb7V9.js → circle-alert-8Bf6xEDx.js} +1 -1
- package/web-app/dist/assets/{clock-Dsbi59u5.js → clock-DB8NE6P5.js} +1 -1
- package/web-app/dist/assets/{cloud-CUU-dXTP.js → cloud-DUSL3wZS.js} +1 -1
- package/web-app/dist/assets/{code-xml-CqIh7Wui.js → code-xml-DbjryWNl.js} +1 -1
- package/web-app/dist/assets/{database-DASN5Bfh.js → database-DrezrMSs.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-ZvXzAihc.js → dollar-sign-CGbaLMzR.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-ED_gN-N8.js → file-code-corner-Dfypa-r4.js} +1 -1
- package/web-app/dist/assets/{file-plus-BmBHndXt.js → file-plus-CzV5L2Gd.js} +1 -1
- package/web-app/dist/assets/{globe-Ck_iEzO4.js → globe-BoYjTwPQ.js} +1 -1
- package/web-app/dist/assets/{hammer-B8YKMard.js → hammer-WO-HuiyJ.js} +1 -1
- package/web-app/dist/assets/{index-D7SxmjVQ.js → index-CjIOUjnd.js} +3 -3
- package/web-app/dist/assets/{layers-CGVsj3wB.js → layers-Bn6jtKNk.js} +1 -1
- package/web-app/dist/assets/{loader-circle-FIH7UJDF.js → loader-circle-gERWRnmW.js} +1 -1
- package/web-app/dist/assets/{lock-vR8HoAnK.js → lock-BIiMLJq8.js} +1 -1
- package/web-app/dist/assets/{package-mZ6LFwXp.js → package-dLvDSVys.js} +1 -1
- package/web-app/dist/assets/{plus-CtnSVu0w.js → plus-BHM3ZLhZ.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BIapF6Gj.js → refresh-cw-BAp_alv6.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-LMkSsm21.js → rotate-ccw-CBhwq_MG.js} +1 -1
- package/web-app/dist/assets/{scroll-text-DQVlIhr9.js → scroll-text-DoS-6dfU.js} +1 -1
- package/web-app/dist/assets/{server-GqyhQxmz.js → server-5JgdDWQO.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BrFTNJ-I.js → shield-alert-DjmlLT34.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-lgwAEJFW.js → thumbs-up-BtiEXl3p.js} +1 -1
- package/web-app/dist/assets/{trash-2-C1cNysK4.js → trash-2-ClsWU_Vg.js} +1 -1
- package/web-app/dist/assets/{trending-down-DQFZ6j5s.js → trending-down-DPS4M7_X.js} +1 -1
- package/web-app/dist/assets/{trending-up-BJG_Zo8V.js → trending-up-D6OJ38-V.js} +1 -1
- package/web-app/dist/assets/{upload-BOY1aTpA.js → upload-DOCzLUpF.js} +1 -1
- package/web-app/dist/assets/{usePolling-DoAmsTNy.js → usePolling-CljQTWj_.js} +1 -1
- package/web-app/dist/assets/{user-iOfCe0_v.js → user-C0G82Weu.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/ui/dist/assets/index-CFfrqCx2.css +0 -1
- 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
|
|
19
|
-
import { join as
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
|
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 =
|
|
5902
|
-
const b =
|
|
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 = () => [
|
|
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 =
|
|
6024
|
-
const f =
|
|
6025
|
-
if (f.startsWith(root + sep) &&
|
|
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(
|
|
6557
|
+
return new Response(Bun.file(join6(root, "index.html")));
|
|
6030
6558
|
});
|
|
6031
6559
|
return { app, db, close: () => sqlite.close() };
|
|
6032
6560
|
}
|