loki-mode 10.7.1 → 10.9.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 (185) hide show
  1. package/README.md +2 -2
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/loki +20 -32
  5. package/autonomy/run.sh +9 -5
  6. package/autonomy/tui.sh +5 -1
  7. package/dashboard/__init__.py +1 -1
  8. package/dashboard/api_phases.py +1 -1
  9. package/dashboard/api_start.py +0 -1
  10. package/dashboard/server.py +28 -146
  11. package/docs/COCKPIT-SPEC.md +3 -1
  12. package/docs/DASHBOARD-9.12-EVIDENCE.md +1 -1
  13. package/docs/DASHBOARD-ARCHITECTURE.md +13 -11
  14. package/docs/DASHBOARD_V2_CHECKLIST.md +2 -0
  15. package/docs/INSTALLATION.md +12 -11
  16. package/docs/MERGE-DEDUP-MAP.md +1 -1
  17. package/docs/MERGE-ROUTE-MAP.md +1 -1
  18. package/docs/MERGE3-PLAN.md +2 -2
  19. package/docs/R3-COST-OBSERVABILITY-DESIGN.md +2 -2
  20. package/docs/R4-TRUST-TRAJECTORY-DESIGN.md +1 -1
  21. package/docs/R5-AUTO-WIKI-DESIGN.md +1 -1
  22. package/docs/R6-ROLLBACK-CHECKPOINT-PLAN.md +1 -1
  23. package/docs/RARV-C-100X-PLAN.md +3 -3
  24. package/docs/SONNET5-DEFAULT-PLAN.md +2 -2
  25. package/docs/SYNERGY-TASKS.md +1 -1
  26. package/docs/TOOL-INTEGRATION.md +1 -1
  27. package/docs/TOP-100-BACKLOG.md +1 -1
  28. package/docs/alternative-installations.md +1 -1
  29. package/docs/architecture/ADR-001-runtime-migration.md +1 -1
  30. package/docs/architecture/DASHBOARD_V2_ARCHITECTURE.md +2 -0
  31. package/docs/audit-logging.md +2 -0
  32. package/docs/authentication.md +2 -0
  33. package/docs/authorization.md +2 -0
  34. package/docs/certification/02-enterprise-features/lab.md +2 -0
  35. package/docs/certification/02-enterprise-features/lesson.md +2 -0
  36. package/docs/certification/04-production-deployment/lab.md +2 -0
  37. package/docs/certification/04-production-deployment/lesson.md +2 -0
  38. package/docs/control-plane-migration.md +97 -0
  39. package/docs/dashboard-guide.md +6 -7
  40. package/docs/dev/release-checklist.md +9 -11
  41. package/docs/enterprise/integration-cookbook.md +2 -0
  42. package/docs/enterprise/migration.md +1 -1
  43. package/docs/enterprise/performance.md +3 -1
  44. package/docs/enterprise/sdk-guide.md +2 -0
  45. package/docs/enterprise/security.md +2 -0
  46. package/docs/plans/FORGE-AUTONOMOUS-QUEUE.md +2 -2
  47. package/docs/research-2026-07/OBSERVABILITY-TOOL-SPEC.md +1 -1
  48. package/docs/retrospectives/v7.5.15-honesty-audit.md +2 -2
  49. package/docs/test-scenarios/enterprise-scenarios.md +2 -2
  50. package/docs/v10/ARCHITECT-CUTS.md +9 -9
  51. package/docs/v10/BACKLOG.md +7 -7
  52. package/docs/v10/BOARD.md +25 -25
  53. package/docs/v10/CONTROL-PLANE.md +4 -4
  54. package/docs/v10/CP-ENTERPRISE-UI.md +270 -9
  55. package/docs/v10/D51-PHASE-B.md +1 -1
  56. package/docs/v10/DECISIONS.md +3 -3
  57. package/docs/v10/DEPS.md +10 -10
  58. package/docs/v10/FAILURE-CLASSES.md +66 -1
  59. package/docs/v10/LEGACY-REMOVAL.md +2 -2
  60. package/docs/v10/METRICS.md +6 -0
  61. package/docs/v10/SWARM.md +2 -2
  62. package/loki-ts/data/model-pricing.json +6 -6
  63. package/loki-ts/dist/cockpit.js +11 -11
  64. package/loki-ts/dist/loki.js +819 -809
  65. package/loki-ts/package.json +1 -1
  66. package/magic/core/design_tokens.py +1 -2
  67. package/magic/core/generator.py +2 -2
  68. package/magic/tokens/README.md +1 -3
  69. package/mcp/__init__.py +1 -1
  70. package/package.json +3 -8
  71. package/packages/control-plane/dist/server.js +17954 -5696
  72. package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
  73. package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
  74. package/packages/control-plane/drizzle/meta/_journal.json +9 -2
  75. package/packages/control-plane/package.json +4 -1
  76. package/packages/control-plane/src/db/schema.ts +19 -0
  77. package/packages/control-plane/src/server/app.ts +11 -22
  78. package/packages/control-plane/src/server/audit.ts +15 -0
  79. package/packages/control-plane/src/server/auth.ts +7 -1
  80. package/packages/control-plane/src/server/legacy/routes-audit.ts +50 -0
  81. package/packages/control-plane/src/server/legacy/routes-control.ts +26 -0
  82. package/packages/control-plane/src/server/legacy/routes-memory.ts +30 -0
  83. package/packages/control-plane/src/server/legacy/routes-metrics.ts +18 -0
  84. package/packages/control-plane/src/server/legacy/routes.ts +237 -0
  85. package/packages/control-plane/src/server/legacy/shim.ts +201 -0
  86. package/packages/control-plane/src/server/repos.ts +27 -0
  87. package/packages/control-plane/src/server/routes/artifacts.ts +104 -0
  88. package/packages/control-plane/src/server/routes/audit.ts +17 -0
  89. package/packages/control-plane/src/server/routes/checkpoints.ts +171 -0
  90. package/packages/control-plane/src/server/routes/config.ts +319 -0
  91. package/packages/control-plane/src/server/routes/control.ts +75 -0
  92. package/packages/control-plane/src/server/routes/cost.ts +70 -0
  93. package/packages/control-plane/src/server/routes/cost_ledger.ts +210 -0
  94. package/packages/control-plane/src/server/routes/fleet.ts +101 -0
  95. package/packages/control-plane/src/server/routes/index.ts +71 -0
  96. package/packages/control-plane/src/server/routes/integrations.ts +123 -0
  97. package/packages/control-plane/src/server/routes/memory.ts +219 -0
  98. package/packages/control-plane/src/server/routes/merge_risk.ts +209 -0
  99. package/packages/control-plane/src/server/routes/metrics.ts +93 -0
  100. package/packages/control-plane/src/server/routes/notify.ts +48 -0
  101. package/packages/control-plane/src/server/routes/providers.ts +111 -0
  102. package/packages/control-plane/src/server/routes/session_control.ts +155 -0
  103. package/packages/control-plane/src/server/routes/start.ts +39 -0
  104. package/packages/control-plane/src/server/routes/stats.ts +76 -0
  105. package/packages/control-plane/src/server/routes/stream.ts +85 -0
  106. package/packages/control-plane/src/server/routes/verify.ts +66 -0
  107. package/packages/control-plane/src/server/spawn.ts +79 -0
  108. package/packages/control-plane/src/shipper/discover.ts +19 -1
  109. package/packages/control-plane/ui/dist/assets/index-BQbFv65M.css +1 -0
  110. package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +155 -0
  111. package/packages/control-plane/ui/dist/index.html +2 -2
  112. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  113. package/references/magic-modules-patterns.md +2 -2
  114. package/skills/factory-operations.md +3 -3
  115. package/skills/magic-modules.md +1 -1
  116. package/skills/release-cadence.md +1 -1
  117. package/tools/audit-docs.py +1 -1
  118. package/tools/index-codebase.py +1 -1
  119. package/web-app/dist/assets/{AdminPage-BJSU5hY1.js → AdminPage-DqMnfZrH.js} +1 -1
  120. package/web-app/dist/assets/{Avatar-CSOSpw9c.js → Avatar-BEof29du.js} +1 -1
  121. package/web-app/dist/assets/{Badge-COr7vj0D.js → Badge-WD53OL5m.js} +1 -1
  122. package/web-app/dist/assets/{Button-DGoWFERp.js → Button-Rzk5flhU.js} +1 -1
  123. package/web-app/dist/assets/{CockpitPage-CBpWkNXY.js → CockpitPage-Bqz3PCMh.js} +1 -1
  124. package/web-app/dist/assets/{ComparePage-BwT8vqrL.js → ComparePage-Byo2NzAZ.js} +1 -1
  125. package/web-app/dist/assets/{ErrorBoundary-D7SicDDA.js → ErrorBoundary-BuE0kH3W.js} +1 -1
  126. package/web-app/dist/assets/{EvidenceReceiptPanel-bx-5liG2.js → EvidenceReceiptPanel-CKFHOPBx.js} +1 -1
  127. package/web-app/dist/assets/{GitHubIssuesPanel-BAeeWLqY.js → GitHubIssuesPanel-Df_kTMqI.js} +1 -1
  128. package/web-app/dist/assets/{GitHubPRsPanel-CssD8lGI.js → GitHubPRsPanel-CouvcKrZ.js} +1 -1
  129. package/web-app/dist/assets/{HomePage-SL5PT3m5.js → HomePage-dEpLzj0x.js} +2 -2
  130. package/web-app/dist/assets/{LoginPage-C8hFOZOM.js → LoginPage-6DVjUND5.js} +1 -1
  131. package/web-app/dist/assets/{MagicPage-xHbHkrPf.js → MagicPage-BDQ9MOfY.js} +1 -1
  132. package/web-app/dist/assets/{MetricsPage-B_2CKFcC.js → MetricsPage-CXiJ04G_.js} +1 -1
  133. package/web-app/dist/assets/{NotFoundPage-CXwXMq35.js → NotFoundPage-BaXhZvyj.js} +1 -1
  134. package/web-app/dist/assets/{ProjectPage-AaisMQI-.js → ProjectPage-BzE4V7Ti.js} +3 -3
  135. package/web-app/dist/assets/{ProjectsPage-Cej5ResX.js → ProjectsPage-ipf6TLMX.js} +1 -1
  136. package/web-app/dist/assets/{SettingsPage-DKv4mn9H.js → SettingsPage-BDxkUKN6.js} +1 -1
  137. package/web-app/dist/assets/{ShowcasePage-Dkiyzne4.js → ShowcasePage-YQXD72SM.js} +1 -1
  138. package/web-app/dist/assets/{SystemSettingsPage-DaBLQ5dU.js → SystemSettingsPage-Dou7zHS0.js} +1 -1
  139. package/web-app/dist/assets/{TeamsPage-B2yUr31k.js → TeamsPage-CQarfcYr.js} +1 -1
  140. package/web-app/dist/assets/{TemplatesPage-CfYiANhU.js → TemplatesPage-CPi1R8Xj.js} +1 -1
  141. package/web-app/dist/assets/{TerminalOutput-CMXoexLM.js → TerminalOutput-WiGhWdYD.js} +1 -1
  142. package/web-app/dist/assets/{activity-B0dzoZtY.js → activity-DOa7ejQA.js} +1 -1
  143. package/web-app/dist/assets/{bell-DFOTaSoo.js → bell-CX-VAlYN.js} +1 -1
  144. package/web-app/dist/assets/{bot-Dz-WSQyP.js → bot-EDXaZmHc.js} +1 -1
  145. package/web-app/dist/assets/{check-L1QIRueJ.js → check-CnBdKk3L.js} +1 -1
  146. package/web-app/dist/assets/{chevron-left-1TrnIEZS.js → chevron-left-BJ_ydRQW.js} +1 -1
  147. package/web-app/dist/assets/{circle-alert-8Bf6xEDx.js → circle-alert-tz8ufhc_.js} +1 -1
  148. package/web-app/dist/assets/{clock-DB8NE6P5.js → clock-DFBZPSsH.js} +1 -1
  149. package/web-app/dist/assets/{cloud-DUSL3wZS.js → cloud-uy3p_5Q7.js} +1 -1
  150. package/web-app/dist/assets/{code-xml-DbjryWNl.js → code-xml-3CbYD9YL.js} +1 -1
  151. package/web-app/dist/assets/{database-DrezrMSs.js → database-YiNBXc9c.js} +1 -1
  152. package/web-app/dist/assets/{dollar-sign-CGbaLMzR.js → dollar-sign-D0wBa4Cv.js} +1 -1
  153. package/web-app/dist/assets/{file-code-corner-Dfypa-r4.js → file-code-corner-DDdZVZ5v.js} +1 -1
  154. package/web-app/dist/assets/{file-plus-CzV5L2Gd.js → file-plus-D09LTBqt.js} +1 -1
  155. package/web-app/dist/assets/{globe-BoYjTwPQ.js → globe-BirTPwff.js} +1 -1
  156. package/web-app/dist/assets/{hammer-WO-HuiyJ.js → hammer-DaE5qgnd.js} +1 -1
  157. package/web-app/dist/assets/{index-CjIOUjnd.js → index-D8t7KnQf.js} +3 -3
  158. package/web-app/dist/assets/{layers-Bn6jtKNk.js → layers-DVVrckRY.js} +1 -1
  159. package/web-app/dist/assets/{loader-circle-gERWRnmW.js → loader-circle-2-nMGmcb.js} +1 -1
  160. package/web-app/dist/assets/{lock-BIiMLJq8.js → lock-D0Xj43pC.js} +1 -1
  161. package/web-app/dist/assets/{package-dLvDSVys.js → package-BB7S3egm.js} +1 -1
  162. package/web-app/dist/assets/{plus-BHM3ZLhZ.js → plus-BLhoMrn3.js} +1 -1
  163. package/web-app/dist/assets/{refresh-cw-BAp_alv6.js → refresh-cw-DMc2uPLw.js} +1 -1
  164. package/web-app/dist/assets/{rotate-ccw-CBhwq_MG.js → rotate-ccw-DM3IP9x7.js} +1 -1
  165. package/web-app/dist/assets/{scroll-text-DoS-6dfU.js → scroll-text-lH2uW7rB.js} +1 -1
  166. package/web-app/dist/assets/{server-5JgdDWQO.js → server-BkerZcdR.js} +1 -1
  167. package/web-app/dist/assets/{shield-alert-DjmlLT34.js → shield-alert-B8ohcUfu.js} +1 -1
  168. package/web-app/dist/assets/{thumbs-up-BtiEXl3p.js → thumbs-up-CTIxLzPD.js} +1 -1
  169. package/web-app/dist/assets/{trash-2-ClsWU_Vg.js → trash-2-C3zIpsH5.js} +1 -1
  170. package/web-app/dist/assets/{trending-down-DPS4M7_X.js → trending-down-BTT7wV4Y.js} +1 -1
  171. package/web-app/dist/assets/{trending-up-D6OJ38-V.js → trending-up-BzHdCDvF.js} +1 -1
  172. package/web-app/dist/assets/{upload-DOCzLUpF.js → upload-HxR34-A8.js} +1 -1
  173. package/web-app/dist/assets/{usePolling-CljQTWj_.js → usePolling-DDj6Fupy.js} +1 -1
  174. package/web-app/dist/assets/{user-C0G82Weu.js → user-qH5y9yNy.js} +1 -1
  175. package/web-app/dist/index.html +1 -1
  176. package/dashboard/static/assets/mermaid.min.js +0 -2030
  177. package/dashboard/static/cost.html +0 -344
  178. package/dashboard/static/favicon.svg +0 -5
  179. package/dashboard/static/index.html +0 -15577
  180. package/dashboard/static/proofs.html +0 -194
  181. package/dashboard/static/start.html +0 -131
  182. package/dashboard/static/trust.html +0 -325
  183. package/packages/control-plane/src/server/actions.ts +0 -60
  184. package/packages/control-plane/ui/dist/assets/index-DHDI_dHM.css +0 -1
  185. package/packages/control-plane/ui/dist/assets/index-DaQr0MPS.js +0 -129
@@ -0,0 +1,111 @@
1
+ // GET /v1/providers (CPE-15): which provider CLIs are installed, their versions, which auth env var NAMES are set (never values), and the model tiers.
2
+ // Behind the /v1 token guard. Every probe is bounded by a timeout; nothing here reads or returns a secret value.
3
+ import { spawn } from "node:child_process";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { delimiter, join } from "node:path";
6
+ import { probeAllowed, ttlCache, type RouteCtx } from "./index.ts";
7
+
8
+ const ORDER = ["claude", "codex", "cline", "aider", "opencode"] as const;
9
+ const DEPRECATED = ["gemini"] as const;
10
+ const AUTH_ENV: Record<string, string[]> = {
11
+ claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"],
12
+ codex: ["OPENAI_API_KEY"],
13
+ cline: ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"],
14
+ aider: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"],
15
+ opencode: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"],
16
+ gemini: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
17
+ };
18
+ const DEFAULT_TIMEOUT_MS = 3000;
19
+
20
+ export interface ProbeResult { installed: boolean; version: string | null; probe: "ok" | "timeout" | "error" | "not_found" }
21
+
22
+ function findOnPath(bin: string, pathVar: string): string | null {
23
+ for (const d of pathVar.split(delimiter)) {
24
+ if (!d) continue;
25
+ const p = join(d, bin);
26
+ if (existsSync(p)) return p;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /** Run `<bin> --version` with a hard timeout. Only a version-looking token is returned, never raw output. */
32
+ export function probeCli(bin: string, timeoutMs: number, pathVar = process.env.PATH ?? ""): Promise<ProbeResult> {
33
+ const exe = findOnPath(bin, pathVar);
34
+ if (!exe) return Promise.resolve({ installed: false, version: null, probe: "not_found" });
35
+ return new Promise((resolve) => {
36
+ let out = "";
37
+ let done = false;
38
+ let timer: ReturnType<typeof setTimeout> | undefined;
39
+ const finish = (r: ProbeResult): void => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
40
+ let child: ReturnType<typeof spawn>;
41
+ try {
42
+ child = spawn(exe, ["--version"], { stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, NO_COLOR: "1" } });
43
+ } catch {
44
+ return resolve({ installed: true, version: null, probe: "error" });
45
+ }
46
+ timer = setTimeout(() => {
47
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
48
+ finish({ installed: true, version: null, probe: "timeout" });
49
+ }, timeoutMs);
50
+ child.stdout?.on("data", (d: Buffer) => { if (out.length < 2048) out += d.toString("utf8"); });
51
+ child.on("error", () => finish({ installed: true, version: null, probe: "error" }));
52
+ child.on("close", () => {
53
+ const m = /\d+\.\d+(?:\.\d+)?(?:[-+.][0-9A-Za-z.]+)?/.exec(out);
54
+ finish({ installed: true, version: m ? m[0] : null, probe: "ok" });
55
+ });
56
+ });
57
+ }
58
+
59
+ function providersDir(): string | null {
60
+ const cands = [process.env.LOKI_PROVIDERS_DIR, join(import.meta.dir, "../../../../../providers"), join(import.meta.dir, "../../../providers")];
61
+ return cands.find((d): d is string => !!d && existsSync(join(d, "model_catalog.json"))) ?? null;
62
+ }
63
+
64
+ interface CatalogModel { id?: string; alias?: string; tier?: string }
65
+ interface CatalogProvider { models?: CatalogModel[]; latest_planning?: string; latest_development?: string; latest_fast?: string }
66
+
67
+ function loadCatalog(dir: string | null): { providers: Record<string, CatalogProvider>; updated: string | null } {
68
+ if (!dir) return { providers: {}, updated: null };
69
+ try {
70
+ const j = JSON.parse(readFileSync(join(dir, "model_catalog.json"), "utf8"));
71
+ return { providers: j.providers ?? {}, updated: typeof j.updated === "string" ? j.updated : null };
72
+ } catch { return { providers: {}, updated: null }; }
73
+ }
74
+
75
+ export async function listProviders(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
76
+ const cat = loadCatalog(providersDir());
77
+ const names = [...ORDER, ...DEPRECATED];
78
+ const rows = await Promise.all(names.map(async (name) => {
79
+ const probe = await probeCli(name, timeoutMs);
80
+ const envNames = AUTH_ENV[name] ?? [];
81
+ const envSet = envNames.filter((n) => (process.env[n] ?? "") !== "");
82
+ const cp = cat.providers[name];
83
+ const tiers: Record<string, string | null> = cp
84
+ ? { planning: cp.latest_planning ?? null, development: cp.latest_development ?? null, fast: cp.latest_fast ?? null }
85
+ : {};
86
+ const models = (cp?.models ?? []).filter((m) => m.id).map((m) => ({ id: m.id as string, alias: m.alias ?? null, tier: m.tier ?? null }));
87
+ return {
88
+ id: name,
89
+ deprecated: (DEPRECATED as readonly string[]).includes(name),
90
+ installed: probe.installed,
91
+ version: probe.version,
92
+ probe: probe.probe,
93
+ auth_env_names: envNames,
94
+ auth_env_set: envSet,
95
+ auth_present: envSet.length > 0,
96
+ tiers,
97
+ models,
98
+ };
99
+ }));
100
+ return { providers: rows, catalog_updated: cat.updated, probe_timeout_ms: timeoutMs };
101
+ }
102
+
103
+ export function mount(ctx: RouteCtx): void {
104
+ const cached = ttlCache<unknown>();
105
+ ctx.app.get("/v1/providers", async (c) => {
106
+ if (!probeAllowed(ctx, c)) return c.json({ error: "loopback only without a token" }, 403);
107
+ const t = Number.parseInt(process.env.LOKI_PROVIDER_PROBE_TIMEOUT_MS ?? "", 10);
108
+ const ms = t > 0 ? t : DEFAULT_TIMEOUT_MS;
109
+ return c.json(await cached(String(ms), () => listProviders(ms)));
110
+ });
111
+ }
@@ -0,0 +1,155 @@
1
+ // CPE24-P4: session control and the Completion Council, ported from dashboard/server.py (/api/control/{pause,resume,stop}, /api/council/*).
2
+ // Trust (HIGH tier): control accepts only the fixed ACTIONS enum, never a command string, and writes only the signal files run.sh already
3
+ // reads (.loki/PAUSE, .loki/STOP, .loki/signals/COUNCIL_REVIEW_REQUESTED) holding a timestamp. It spawns and signals nothing.
4
+ // Council reads are read-only, take no path from the caller, and cap what they return.
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import type { Context } from "hono";
8
+ import { audit } from "../audit.ts";
9
+ import type { Db } from "../../db/migrate.ts";
10
+ import { probeAllowed, type RouteCtx } from "./index.ts";
11
+ import { pidAlive } from "./fleet.ts";
12
+ import { lokiDirOf } from "./cost_ledger.ts";
13
+
14
+ type Rec = Record<string, unknown>;
15
+ const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null && !Array.isArray(v);
16
+
17
+ export const ACTIONS = ["pause", "resume", "stop", "council-review"] as const;
18
+ export type ControlAction = (typeof ACTIONS)[number];
19
+ export const isAction = (v: string): v is ControlAction => (ACTIONS as readonly string[]).includes(v);
20
+
21
+ /** A browser Origin, when present, must be this server's own host; a cross-site fetch is refused. Absent Origin (curl, the CLI, the extension host) is allowed. */
22
+ export function sameOrigin(c: Context): boolean {
23
+ if (c.req.header("sec-fetch-site") === "cross-site") return false;
24
+ const origin = c.req.header("origin");
25
+ if (origin === undefined) return true;
26
+ try { return new URL(origin).host.toLowerCase() === (c.req.header("host") ?? "").toLowerCase(); } catch { return false; }
27
+ }
28
+
29
+ const readJson = (p: string): unknown => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
30
+
31
+ function runnerPid(loki: string): number | null {
32
+ try { const pid = parseInt(readFileSync(join(loki, "loki.pid"), "utf8").trim(), 10); return pidAlive(pid) ? pid : null; } catch { return null; }
33
+ }
34
+
35
+ export interface ControlResult { status: 200 | 503; body: Rec }
36
+
37
+ /** Applies one enum action to <repoDir>/.loki by writing or removing a signal file. Never signals a process. */
38
+ export function applyControl(repoDir: string, action: ControlAction): ControlResult {
39
+ const loki = lokiDirOf(repoDir);
40
+ const running = runnerPid(loki) !== null;
41
+ const put = (rel: string) => { const f = join(loki, rel); mkdirSync(dirname(f), { recursive: true }); writeFileSync(f, new Date().toISOString()); };
42
+ if (action === "council-review") { put(join("signals", "COUNCIL_REVIEW_REQUESTED")); return { status: 200, body: { success: true, message: "Council review requested" } }; }
43
+ if (action === "resume") {
44
+ for (const n of ["PAUSE", "STOP"]) { try { unlinkSync(join(loki, n)); } catch { /* absent */ } }
45
+ return running ? { status: 200, body: { success: true, message: "Session resumed", process_verified: true } }
46
+ : { status: 503, body: { success: false, message: "Session process is not running; the signal files were cleared" } };
47
+ }
48
+ put(action === "pause" ? "PAUSE" : "STOP");
49
+ if (!running) return { status: 503, body: { success: false, message: `Session process is not running; the ${action} signal may have no effect` } };
50
+ return { status: 200, body: { success: true, message: action === "pause" ? "Session paused" : "Stop requested; the runner exits at its next check", process_verified: true } };
51
+ }
52
+
53
+ /** Runs the guarded, audited control action; shared by /v1/control/:action and the legacy mappings. */
54
+ export function runControl(db: Db, repoDir: string, action: ControlAction, c: Context, via: string): Response {
55
+ if (!sameOrigin(c)) { audit(db, { kind: `session.${action}`, target: via, result: "refused", detail: "origin not allowed" }); return c.json({ error: "origin not allowed" }, 403); }
56
+ const r = applyControl(repoDir, action);
57
+ audit(db, { kind: `session.${action}`, target: via, result: r.status === 200 ? "ok" : "unverified" });
58
+ return c.json(r.body, r.status);
59
+ }
60
+
61
+ export function councilState(loki: string): Rec {
62
+ const f = join(loki, "council", "state.json");
63
+ if (!existsSync(f)) return { enabled: false, total_votes: 0, verdicts: [] };
64
+ const d = readJson(f);
65
+ return isObj(d) ? d : { enabled: null, total_votes: null, verdicts: null, error: "unreadable_state" };
66
+ }
67
+
68
+ export function councilVerdicts(loki: string, limit = 20): Rec {
69
+ const st = readJson(join(loki, "council", "state.json"));
70
+ const verdicts = isObj(st) && Array.isArray(st.verdicts) ? st.verdicts : [];
71
+ const votes = join(loki, "council", "votes");
72
+ const details: Rec[] = [];
73
+ let names: string[] = [];
74
+ try { names = readdirSync(votes, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort().reverse(); } catch { /* none */ }
75
+ const text = (p: string) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
76
+ for (const n of names.slice(0, limit)) {
77
+ const dir = join(votes, n), d: Rec = { iteration: n };
78
+ const ev = text(join(dir, "evidence.md"));
79
+ if (ev !== null) d.evidence_preview = ev.slice(0, 500);
80
+ let files: string[] = [];
81
+ try { files = readdirSync(dir).filter((f) => /^member-.*\.txt$/.test(f)).sort(); } catch { /* none */ }
82
+ d.members = files.flatMap((f) => { const t = text(join(dir, f)); return t === null ? [] : [{ member: f.replace(/\.txt$/, ""), content: t.trim() }]; });
83
+ const con = text(join(dir, "contrarian.txt"));
84
+ if (con !== null) d.contrarian = con.trim();
85
+ details.push(d);
86
+ }
87
+ return { verdicts, details };
88
+ }
89
+
90
+ export function councilConvergence(loki: string): Rec {
91
+ const dataPoints: Rec[] = [];
92
+ let raw = "";
93
+ try { raw = readFileSync(join(loki, "council", "convergence.log"), "utf8"); } catch { /* none */ }
94
+ for (const line of raw.trim().split("\n")) {
95
+ const p = line.split("|");
96
+ if (p.length < 5) continue;
97
+ const n = p.slice(1, 5).map((x) => (/^-?\d+$/.test(x.trim()) ? Number(x) : NaN));
98
+ if (n.some(Number.isNaN)) continue;
99
+ dataPoints.push({ timestamp: p[0], iteration: n[0], files_changed: n[1], no_change_streak: n[2], done_signals: n[3] });
100
+ }
101
+ return { dataPoints };
102
+ }
103
+
104
+ export function councilReport(loki: string): Rec {
105
+ try { return { report: readFileSync(join(loki, "council", "report.md"), "utf8") }; } catch { return { report: null }; }
106
+ }
107
+
108
+ export function councilTranscripts(loki: string, q: { limit?: number; since?: string; iter_min?: number }): Rec {
109
+ const limit = Math.min(Math.max(q.limit ?? 20, 1), 200);
110
+ let since: number | null = null;
111
+ if (q.since) { since = Date.parse(q.since); if (Number.isNaN(since)) return { error: "Invalid 'since' timestamp format; expected ISO8601" }; }
112
+ const dir = join(loki, "council", "transcripts");
113
+ let names: string[] = [];
114
+ try { names = readdirSync(dir).filter((f) => /^iter-.*\.json$/.test(f)).sort().reverse(); } catch { return { transcripts: [], total: 0, latest_id: null }; }
115
+ const out: Rec[] = [];
116
+ for (const n of names) {
117
+ const rec = readJson(join(dir, n));
118
+ if (!isObj(rec) || typeof rec.iteration_id !== "string") continue;
119
+ if (since !== null) { const t = Date.parse(String(rec.timestamp ?? "")); if (Number.isNaN(t) || t <= since) continue; }
120
+ if (q.iter_min !== undefined && Number(rec.iteration ?? 0) < q.iter_min) continue;
121
+ out.push(rec);
122
+ if (out.length >= limit) break;
123
+ }
124
+ return { transcripts: out, total: out.length, latest_id: out.length ? out[0]!.iteration_id : null };
125
+ }
126
+
127
+ /** Parses an optional non-negative integer query param: undefined when absent, null when malformed or above max. */
128
+ export const intQ = (v: string | undefined, max: number): number | undefined | null => (v === undefined ? undefined : /^\d+$/.test(v) && Number(v) <= max ? Number(v) : null);
129
+
130
+ export function mount(ctx: RouteCtx): void {
131
+ const deny = { error: "loopback only without a token" };
132
+ const loki = () => lokiDirOf(ctx.repoDir);
133
+ // Control: registered on `act` (loopback-bound servers only); real loopback peer plus JSON content type (ctx.local), the CP bearer via tokenGuard, and same-origin.
134
+ ctx.act.post("/v1/control/:action", (c) => {
135
+ if (!ctx.local(c)) return c.json({ error: "loopback JSON requests only" }, 403);
136
+ const action = c.req.param("action");
137
+ if (!isAction(action)) return c.json({ error: `action must be one of ${ACTIONS.join(", ")}` }, 400);
138
+ return runControl(ctx.db, ctx.repoDir, action, c, "v1");
139
+ });
140
+ const read = (path: string, f: (c: Context) => Response | Rec) => ctx.app.get(path, (c) => {
141
+ if (!probeAllowed(ctx, c)) return c.json(deny, 403);
142
+ const r = f(c);
143
+ return r instanceof Response ? r : c.json(r);
144
+ });
145
+ read("/v1/council/state", () => councilState(loki()));
146
+ read("/v1/council/verdicts", (c) => { const n = intQ(c.req.query("limit"), 1000); return n === null || n === 0 ? c.json({ error: "limit must be 1..1000" }, 422) : councilVerdicts(loki(), n); });
147
+ read("/v1/council/convergence", () => councilConvergence(loki()));
148
+ read("/v1/council/report", () => councilReport(loki()));
149
+ read("/v1/council/transcripts", (c) => {
150
+ const limit = intQ(c.req.query("limit"), 200), iterMin = intQ(c.req.query("iter_min"), Number.MAX_SAFE_INTEGER);
151
+ if (limit === null || limit === 0 || iterMin === null) return c.json({ error: "limit must be 1..200 and iter_min a non-negative integer" }, 422);
152
+ const r = councilTranscripts(loki(), { limit, since: c.req.query("since"), iter_min: iterMin });
153
+ return "error" in r ? c.json(r, 400) : r;
154
+ });
155
+ }
@@ -0,0 +1,39 @@
1
+ // CPE-07: POST /v1/runs (and the /v1/start alias) starts a run. Registered on the loopback-only router, so a non-loopback bind never has it.
2
+ import type { Context } from "hono";
3
+ import { isLoopbackHost } from "../auth.ts";
4
+ import { audit } from "../audit.ts";
5
+ import { planStart, registryRepos, spawnStart } from "../spawn.ts";
6
+ import type { RouteCtx } from "./index.ts";
7
+
8
+ /** A browser always sends Origin on a cross-site POST. Absent is a non-browser client (allowed, the peer check still applies); present must be a loopback http(s) origin. */
9
+ export function originOk(origin: string | undefined | null): boolean {
10
+ if (origin === undefined || origin === null) return true;
11
+ try {
12
+ const u = new URL(origin);
13
+ return (u.protocol === "http:" || u.protocol === "https:") && isLoopbackHost(u.host);
14
+ } catch { return false; }
15
+ }
16
+
17
+ export function mount(ctx: RouteCtx): void {
18
+ const { act, db, repoDir, local } = ctx;
19
+ const inflight = new Set<string>();
20
+ const handler = async (c: Context) => {
21
+ if (!local(c)) return c.json({ error: "loopback JSON requests only" }, 403);
22
+ if (!originOk(c.req.header("origin"))) return c.json({ error: "origin not allowed" }, 403);
23
+ const text = await c.req.text();
24
+ if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
25
+ let body: unknown;
26
+ try { body = JSON.parse(text); } catch { return c.json({ error: "invalid JSON" }, 400); }
27
+ const target = typeof (body as { target?: unknown } | null)?.target === "string" ? (body as { target: string }).target : undefined;
28
+ const plan = planStart(body, [repoDir, ...registryRepos()], ctx.startBin);
29
+ if (!plan.ok) { audit(db, { kind: "run.start", target, result: "refused", detail: plan.error }); return c.json({ error: plan.error }, 400); }
30
+ if (inflight.has(plan.cwd)) { audit(db, { kind: "run.start", target, result: "conflict", detail: "start already in flight" }); return c.json({ error: "a run is already starting or running in this repo" }, 409); }
31
+ inflight.add(plan.cwd);
32
+ const r = await (ctx.spawnImpl ?? spawnStart)(plan.argv, plan.cwd, () => inflight.delete(plan.cwd), plan.env);
33
+ if ("error" in r) { inflight.delete(plan.cwd); audit(db, { kind: "run.start", target, result: "error", detail: r.error }); return c.json({ error: r.error }, 500); }
34
+ audit(db, { kind: "run.start", target, result: "started", detail: `pid ${r.pid}; ${plan.argv.slice(1).join(" ")}` });
35
+ return c.json({ ok: true, pid: r.pid, command: plan.argv.slice(1).join(" ") });
36
+ };
37
+ act.post("/v1/runs", handler);
38
+ act.post("/v1/start", handler);
39
+ }
@@ -0,0 +1,76 @@
1
+ // GET /v1/stats?since=<ISO>: counts, verified rate, cost (measured and partial kept apart) and the receipts count, folded from the runs projection and stored events.
2
+ // An unpriced run is never summed as zero: its known part is reported as partial, and a window with nothing priced reads null.
3
+ import { eq } from "drizzle-orm";
4
+ import type { Db } from "../../db/migrate.ts";
5
+ import { events, runs } from "../../db/schema.ts";
6
+ import { effectiveVerdict, pubkeysFromEnv } from "../integrity.ts";
7
+ import type { RouteCtx } from "./index.ts";
8
+
9
+ export interface Stats {
10
+ since: string | null;
11
+ runs_total: number;
12
+ runs_finished: number;
13
+ runs_running: number;
14
+ by_verdict: Record<string, number>;
15
+ blocked_waiting: number;
16
+ /** Plain VERIFIED (attested, signature-checked) over finished runs that carry a verdict; null when there are none. */
17
+ verified_rate: number | null;
18
+ /** Finished successes whose signature could not be checked (VERIFIED (signature not checked)); a rate of 0 with these is "not measured", not "failing". */
19
+ verified_unchecked: number;
20
+ /** At least one receipt verification key is configured (LOKI_CP_RECEIPT_PUBKEYS). */
21
+ keys_configured: boolean;
22
+ cost: {
23
+ /** Sum over runs whose cost is fully measured; null when no run in the window is. */
24
+ measured_usd: number | null;
25
+ measured_runs: number;
26
+ /** Sum of the known part of runs with an unpriced session (a lower bound); null when there are none. */
27
+ partial_usd: number | null;
28
+ partial_runs: number;
29
+ label: "measured" | "partial" | "not measured";
30
+ };
31
+ receipts: { total: number; signed: number };
32
+ }
33
+
34
+ export function computeStats(db: Db, since: string | null): Stats {
35
+ const all = db.select().from(runs).all().filter((r) => !since || (r.startedAt !== null && r.startedAt >= since));
36
+ const by: Record<string, number> = {};
37
+ let finished = 0, running = 0, blocked = 0, withVerdict = 0, verified = 0, unchecked = 0;
38
+ let mUsd = 0, mRuns = 0, pUsd = 0, pRuns = 0;
39
+ for (const r of all) {
40
+ if (r.endedAt) finished++; else running++;
41
+ // FC-08 effective verdict: a tampered log reads TAMPERED, an unattested or unchecked success is never counted as plain VERIFIED (L3, L7)
42
+ const verdict = r.verdict ? effectiveVerdict({ verdict: r.verdict, tampered: r.tampered === 1, attested: r.attested === 1, sig_checked: r.sigChecked === 1 }) : null;
43
+ if (verdict) {
44
+ by[verdict] = (by[verdict] ?? 0) + 1;
45
+ if (r.endedAt) { withVerdict++; if (verdict === "VERIFIED") verified++; else if (verdict === "VERIFIED (signature not checked)") unchecked++; }
46
+ }
47
+ if (r.verdict === "SPEC_CONFLICT") blocked++;
48
+ if (r.costUsd !== null) { mUsd += r.costUsd; mRuns++; }
49
+ else if (r.totalSessions > 0) { pUsd += r.partialUsd; pRuns++; }
50
+ }
51
+ const keys = new Set(all.map((r) => `${r.sourceId}/${r.runId}`));
52
+ let total = 0, signed = 0;
53
+ for (const e of db.select({ s: events.sourceId, r: events.runId, data: events.data }).from(events).where(eq(events.type, "receipt.sealed")).all()) {
54
+ if (!keys.has(`${e.s}/${e.r}`)) continue;
55
+ total++;
56
+ if ((e.data as { signed?: unknown } | null)?.signed === true) signed++;
57
+ }
58
+ const r6 = (n: number): number => Math.round(n * 1e6) / 1e6;
59
+ return {
60
+ since, runs_total: all.length, runs_finished: finished, runs_running: running, by_verdict: by, blocked_waiting: blocked,
61
+ verified_rate: withVerdict ? verified / withVerdict : null, verified_unchecked: unchecked, keys_configured: pubkeysFromEnv().configured === true,
62
+ cost: {
63
+ measured_usd: mRuns ? r6(mUsd) : null, measured_runs: mRuns, partial_usd: pRuns ? r6(pUsd) : null, partial_runs: pRuns,
64
+ label: pRuns ? "partial" : mRuns ? "measured" : "not measured",
65
+ },
66
+ receipts: { total, signed },
67
+ };
68
+ }
69
+
70
+ export function mount(ctx: RouteCtx): void {
71
+ ctx.app.get("/v1/stats", (c) => {
72
+ const since = c.req.query("since") ?? null;
73
+ if (since !== null && (!/^\d{4}-\d{2}-\d{2}/.test(since) || Number.isNaN(Date.parse(since)))) return c.json({ error: "since must be an ISO date or timestamp" }, 400);
74
+ return c.json(computeStats(ctx.db, since === null ? null : new Date(since).toISOString()));
75
+ });
76
+ }
@@ -0,0 +1,85 @@
1
+ // CPE-05: Server-Sent Events for one run's new events and for runs-list changes.
2
+ // Driven by the store: a short poll of the events table sees rows from ingest (and so from the shipper watcher) with no coupling to the writer.
3
+ // Auth: /v1/* is already behind tokenGuard in app.ts. Streams are bounded; a disconnect frees its slot.
4
+ import { and, asc, eq, gt, sql } from "drizzle-orm";
5
+ import { events, runs } from "../../db/schema.ts";
6
+ import type { RouteCtx } from "./index.ts";
7
+
8
+ const num = (v: string | undefined, d: number) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : d; };
9
+
10
+ export function mount(ctx: RouteCtx): void {
11
+ const pollMs = num(process.env.LOKI_CONTROL_STREAM_POLL_MS, 1000);
12
+ const heartbeatMs = num(process.env.LOKI_CONTROL_STREAM_HEARTBEAT_MS, 15000);
13
+ const maxStreams = num(process.env.LOKI_CONTROL_STREAM_MAX, 32);
14
+ const { db } = ctx;
15
+ const enc = new TextEncoder();
16
+ let open = 0;
17
+
18
+ const headers = { "content-type": "text/event-stream", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" };
19
+
20
+ /** Open one SSE response. tick() runs each poll and returns frames to send; the slot is freed exactly once on abort, cancel or error. */
21
+ const sse = (signal: AbortSignal, hello: string, tick: () => string[]): Response => {
22
+ if (open >= maxStreams) return Response.json({ error: "too many streams" }, { status: 429, headers: { "retry-after": "5" } });
23
+ open++;
24
+ let freed = false;
25
+ let poll: ReturnType<typeof setInterval> | undefined;
26
+ let beat: ReturnType<typeof setInterval> | undefined;
27
+ const free = () => { if (freed) return; freed = true; clearInterval(poll); clearInterval(beat); open--; };
28
+ const body = new ReadableStream<Uint8Array>({
29
+ start(ctrl) {
30
+ const send = (s: string) => { try { ctrl.enqueue(enc.encode(s)); } catch { free(); } };
31
+ const close = () => { free(); try { ctrl.close(); } catch { /* already closed */ } };
32
+ send(hello);
33
+ poll = setInterval(() => { try { for (const f of tick()) send(f); } catch { close(); } }, pollMs);
34
+ beat = setInterval(() => send(": heartbeat\n\n"), heartbeatMs);
35
+ if (signal.aborted) close(); else signal.addEventListener("abort", close, { once: true });
36
+ },
37
+ cancel: free,
38
+ });
39
+ return new Response(body, { headers });
40
+ };
41
+
42
+ const maxSeq = (source: string, run: string): number =>
43
+ db.select({ m: sql<number | null>`max(${events.seq})` }).from(events).where(and(eq(events.sourceId, source), eq(events.runId, run))).get()?.m ?? -1;
44
+
45
+ ctx.app.get("/v1/runs/:source/:run/stream", (c) => {
46
+ const source = c.req.param("source"), run = c.req.param("run");
47
+ if (!db.select({ r: runs.runId }).from(runs).where(and(eq(runs.sourceId, source), eq(runs.runId, run))).get()) return c.json({ error: "run not found" }, 404);
48
+ // Resume point: Last-Event-ID (reconnect) or ?after=; a fresh client with neither gets only events from now on.
49
+ const given = c.req.header("last-event-id") ?? c.req.query("after");
50
+ const parsed = given === undefined || given === "" ? NaN : Number(given);
51
+ let cursor = Number.isInteger(parsed) ? parsed : maxSeq(source, run);
52
+ const tick = (): string[] => {
53
+ const rows = db.select().from(events).where(and(eq(events.sourceId, source), eq(events.runId, run), gt(events.seq, cursor))).orderBy(asc(events.seq)).limit(200).all();
54
+ if (rows.length) cursor = rows[rows.length - 1]!.seq;
55
+ return rows.map((e) => `id: ${e.seq}\nevent: event\ndata: ${JSON.stringify({ run: e.runId, seq: e.seq, ts: e.ts, type: e.type, stage: e.stage, data: e.data })}\n\n`);
56
+ };
57
+ const first = tick(); // replay for a resuming client is flushed with the hello
58
+ return sse(c.req.raw.signal, `retry: 3000\n: connected\n\n${first.join("")}`, tick);
59
+ });
60
+
61
+ ctx.app.get("/v1/stream", (c) => {
62
+ const snapshot = (): Map<string, string> => {
63
+ const m = new Map<string, string>();
64
+ for (const e of db.select({ s: events.sourceId, r: events.runId, m: sql<number>`max(${events.seq})` }).from(events).groupBy(events.sourceId, events.runId).all()) m.set(`${e.s}:${e.r}`, String(e.m));
65
+ for (const r of db.select({ s: runs.sourceId, r: runs.runId, v: runs.verdict, e: runs.endedAt }).from(runs).all()) {
66
+ const k = `${r.s}:${r.r}`;
67
+ m.set(k, `${m.get(k) ?? ""}|${r.v ?? ""}|${r.e ?? ""}`);
68
+ }
69
+ return m;
70
+ };
71
+ let seen = snapshot();
72
+ const tick = (): string[] => {
73
+ const now = snapshot();
74
+ const out: string[] = [];
75
+ for (const [k, sig] of now) {
76
+ if (seen.get(k) === sig) continue;
77
+ const i = k.indexOf(":");
78
+ out.push(`event: run\ndata: ${JSON.stringify({ source: k.slice(0, i), run: k.slice(i + 1) })}\n\n`);
79
+ }
80
+ seen = now;
81
+ return out;
82
+ };
83
+ return sse(c.req.raw.signal, "retry: 3000\n: connected\n\n", tick);
84
+ });
85
+ }
@@ -0,0 +1,66 @@
1
+ // Receipt verify and public key (CPE-16). Calls the engine's own verifyReceipt in process (loki-ts verify_cmd.ts), never reimplements the crypto.
2
+ // Read-only: it reads <repo>/.loki/runs/<run>/receipt.json (and events.jsonl beside it) and writes nothing. Loopback-only (on `act`), same path containment as the artifacts route.
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { createPublicKey } from "node:crypto";
5
+ import { join, sep } from "node:path";
6
+ import { and, eq } from "drizzle-orm";
7
+ import { localRepos, runs } from "../../db/schema.ts";
8
+ import { verifyReceipt } from "../../../../../loki-ts/src/engine10/verify_cmd.ts";
9
+ import { kidOf, loadSigningKey } from "../../../../../loki-ts/src/engine10/stages/seal.ts";
10
+ import { outcomeOf, runIdGuard } from "../../../../../loki-ts/src/features/receipt_dsse.ts";
11
+ import { audit } from "../audit.ts";
12
+ import type { RouteCtx } from "./index.ts";
13
+
14
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
15
+ const safeId = (s: string) => ID.test(s) && !s.includes("..");
16
+
17
+ export function mount(ctx: RouteCtx): void {
18
+ const { act, db } = ctx;
19
+
20
+ act.post("/v1/runs/:source/:run/verify", async (c) => {
21
+ const source = c.req.param("source"), run = c.req.param("run");
22
+ const target = `${source}/${run}`;
23
+ const done = (status: 403 | 404, error: string, result: string) => { audit(db, { kind: "receipt.verify", target, result, detail: error }); return c.json({ error }, status); };
24
+ if (!ctx.local(c)) return done(403, "loopback only", "refused");
25
+ if (!safeId(source) || !safeId(run)) return done(404, "run not found", "not_found");
26
+ const known = db.select({ r: runs.runId }).from(runs).where(and(eq(runs.sourceId, source), eq(runs.runId, run))).get();
27
+ if (!known) return done(404, "run not found", "not_found");
28
+ const repo = db.select({ p: localRepos.realpath }).from(localRepos).where(eq(localRepos.sourceId, source)).get();
29
+ if (!repo) return done(404, "receipt not available", "not_found");
30
+ let runDir: string, file: string;
31
+ try {
32
+ const root = realpathSync(join(repo.p, ".loki", "runs"));
33
+ runDir = realpathSync(join(root, run));
34
+ if (runDir !== join(root, run)) return done(404, "receipt not found", "not_found");
35
+ file = realpathSync(join(runDir, "receipt.json"));
36
+ } catch { return done(404, "receipt not found", "not_found"); }
37
+ if (!file.startsWith(runDir + sep)) return done(404, "receipt not found", "not_found");
38
+ // Same order as `loki verify <run-id>`: the envelope must describe this run, then integrity, then the run outcome.
39
+ let integrity: string, reasons: string[], sha: string | null = null, outcome = "UNREADABLE";
40
+ try {
41
+ const bad = runIdGuard(file, run);
42
+ if (bad) { integrity = "TAMPERED"; reasons = [bad]; }
43
+ else { const r = await verifyReceipt(file); integrity = r.verdict; reasons = r.reasons; sha = r.receiptSha256 ?? null; }
44
+ try { outcome = outcomeOf(JSON.parse(readFileSync(file, "utf8"))); } catch { outcome = "UNREADABLE"; }
45
+ } catch (e) {
46
+ integrity = "UNCHECKED"; reasons = [`verifier error: ${(e as Error).message}`];
47
+ }
48
+ // Intact does not mean passing: an intact receipt of a run that did not verify is NOT_VERIFIED, as in the CLI (exit 4).
49
+ const intact = integrity === "VERIFIED" || integrity === "UNSIGNED";
50
+ const passing = outcome === "VERIFIED" || outcome === "ALREADY_SATISFIED";
51
+ let verdict = integrity;
52
+ if (intact && !passing) { verdict = "NOT_VERIFIED"; reasons = [`run outcome ${outcome}; receipt integrity ${integrity === "UNSIGNED" ? "unattested" : "intact"}`, ...reasons]; }
53
+ audit(db, { kind: "receipt.verify", target, result: verdict.toLowerCase(), detail: reasons[0] });
54
+ return c.json({ run, verdict, integrity, outcome, reasons, receipt_sha256: sha, verified_at: new Date().toISOString() });
55
+ });
56
+
57
+ // The public half of the receipt signer as a JWK. The private key never leaves loadSigningKey.
58
+ act.get("/v1/keys", (c) => {
59
+ if (!ctx.peerIsLoopback(c)) { audit(db, { kind: "keys.read", result: "refused", detail: "loopback only" }); return c.json({ error: "loopback only" }, 403); }
60
+ const priv = loadSigningKey(false);
61
+ if (!priv) { audit(db, { kind: "keys.read", result: "no_key" }); return c.json({ error: "no signing key on this machine (run a sealed build first or set LOKI_RECEIPT_SIGNING_KEY_FILE)" }, 404); }
62
+ const pub = createPublicKey(priv), { kty, crv, x } = pub.export({ format: "jwk" });
63
+ audit(db, { kind: "keys.read", result: "ok" });
64
+ return c.json({ kty, crv, x, kid: kidOf(pub), alg: "EdDSA", use: "sig" });
65
+ });
66
+ }
@@ -0,0 +1,79 @@
1
+ // Start-a-run planning and spawning for the UI (CPE-07). A run is started by spawning the same `loki start` path an operator would type (argv array, no shell).
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, join, resolve } from "node:path";
6
+
7
+ const ISSUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9._-]{1,100}#[1-9][0-9]{0,8}$/;
8
+ // A free-text task: letters, digits, spaces and a small punctuation set. No colon, quotes, backticks, $, ;, &, |, <, >, parens, braces, backslash or control chars.
9
+ const TASK = /^[A-Za-z0-9][A-Za-z0-9 .,_/#@+=-]{0,499}$/;
10
+ const hasDotDot = (t: string): boolean => t.split(/[\\/ ]/).some((seg) => seg.startsWith(".."));
11
+
12
+ /** Child env: the server's own secrets and bind config never reach a spawned run. */
13
+ export function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
14
+ const e: NodeJS.ProcessEnv = { ...env, LOKI_NO_BROWSER: "1" };
15
+ for (const k of ["LOKI_CONTROL_TOKEN", "LOKI_CONTROL_DB", "LOKI_CONTROL_HOST", "PORT"]) delete e[k];
16
+ return e;
17
+ }
18
+
19
+ // Optional run fields, each mapped to one known loki flag (or env var) and validated against an allowlist. Nothing else is accepted.
20
+ const PROVIDERS = new Set(["claude", "codex", "cline", "aider", "opencode"]);
21
+ const MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
22
+ const BUDGET = /^(?:[1-9][0-9]{0,5}|0)(?:\.[0-9]{1,2})?$/;
23
+ const WORKSPACE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
24
+
25
+ export type StartPlan = { ok: true; argv: string[]; cwd: string; env: Record<string, string> } | { ok: false; error: string };
26
+
27
+ /** Registered project paths from ~/.loki/dashboard/projects.json (the `loki projects` registry), existing directories only. */
28
+ export function registryRepos(env: NodeJS.ProcessEnv = process.env): string[] {
29
+ try {
30
+ const j = JSON.parse(readFileSync(join(env.HOME || homedir(), ".loki", "dashboard", "projects.json"), "utf8")) as { projects?: Record<string, { path?: unknown }> };
31
+ return Object.values(j.projects ?? {}).map((p) => (typeof p?.path === "string" ? p.path : "")).filter((p) => p && existsSync(p) && statSync(p).isDirectory());
32
+ } catch { return []; }
33
+ }
34
+
35
+ /** Validate a start request. The repo must be one the server already knows (registry or cwd), never an arbitrary path. */
36
+ export function planStart(body: unknown, known: string[], bin = "loki"): StartPlan {
37
+ const b = (body && typeof body === "object" ? body : {}) as { target?: unknown; repo?: unknown; model?: unknown; provider?: unknown; budget?: unknown; workspace?: unknown };
38
+ if (typeof b.target !== "string") return { ok: false, error: "target must be a string" };
39
+ const target = b.target.trim();
40
+ const issue = ISSUE.test(target);
41
+ if (!issue && !TASK.test(target)) return { ok: false, error: "target must be owner/repo#N or a plain task (letters, digits, spaces, . , _ / # @ + = -)" };
42
+ if (hasDotDot(target)) return { ok: false, error: "target must not contain a .. path segment" };
43
+ let cwd = process.cwd();
44
+ if (b.repo !== undefined && b.repo !== "") {
45
+ if (typeof b.repo !== "string" || b.repo.includes("\0")) return { ok: false, error: "repo must be a path string" };
46
+ // GET /v1/repos exposes display names only, so a repo may be given as a known path or as the unique display name of a known project.
47
+ const want = resolve(b.repo);
48
+ const paths = known.map((k) => resolve(k));
49
+ const named = paths.filter((k) => basename(k) === b.repo);
50
+ const hit = paths.find((k) => k === want) ?? (named.length === 1 ? named[0] : undefined);
51
+ if (!hit) return { ok: false, error: named.length > 1 ? "repo name is ambiguous" : "repo is not a known project" };
52
+ cwd = hit;
53
+ }
54
+ const opt = (v: unknown): string | undefined => (v === undefined || v === null || v === "" ? undefined : typeof v === "number" && Number.isFinite(v) ? String(v) : typeof v === "string" ? v : "\0bad");
55
+ const provider = opt(b.provider), model = opt(b.model), budget = opt(b.budget), workspace = opt(b.workspace);
56
+ if (provider !== undefined && !PROVIDERS.has(provider)) return { ok: false, error: "provider must be one of: " + [...PROVIDERS].join(", ") };
57
+ if (model !== undefined && !MODEL.test(model)) return { ok: false, error: "model must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}" };
58
+ if (budget !== undefined && (!BUDGET.test(budget) || Number(budget) <= 0)) return { ok: false, error: "budget must be a positive USD amount such as 5 or 5.00" };
59
+ if (workspace !== undefined && !WORKSPACE.test(workspace)) return { ok: false, error: "workspace must be a workspace name from loki.yaml (letters, digits, _ -)" };
60
+ if (workspace !== undefined && (provider !== undefined || budget !== undefined)) return { ok: false, error: "workspace runs do not take provider or budget; set them per repo in loki.yaml" };
61
+ const env: Record<string, string> = model !== undefined ? { LOKI_SESSION_MODEL: model } : {};
62
+ // A workspace run is `loki workspace run <name> <ref>` (the existing multi-repo path). Otherwise free text goes as an explicit brief, never auto-detected as a PRD path.
63
+ if (workspace !== undefined) return { ok: true, argv: [bin, "workspace", "run", workspace, target], cwd, env };
64
+ const flags = [...(provider !== undefined ? ["--provider", provider] : []), ...(budget !== undefined ? ["--budget", budget] : [])];
65
+ return { ok: true, argv: issue ? [bin, "start", target, ...flags] : [bin, "start", "--brief", target, ...flags], cwd, env };
66
+ }
67
+
68
+ /** Spawn detached with no shell; resolves with the pid once the process exists, or an error if it cannot be launched. */
69
+ export function spawnStart(argv: string[], cwd: string, onExit: () => void = () => {}, extraEnv: Record<string, string> = {}): Promise<{ pid: number } | { error: string }> {
70
+ return new Promise((done) => {
71
+ try {
72
+ const [cmd, ...args] = argv;
73
+ const child = spawn(cmd!, args, { cwd, detached: true, stdio: "ignore", shell: false, env: { ...childEnv(), ...extraEnv } });
74
+ child.once("exit", onExit);
75
+ child.once("error", (e) => { onExit(); done({ error: `could not start loki: ${e.message}` }); });
76
+ child.once("spawn", () => { child.unref(); done({ pid: child.pid ?? 0 }); });
77
+ } catch (e) { done({ error: `could not start loki: ${(e as Error).message}` }); }
78
+ });
79
+ }