@loicngr/kobo 1.11.27 → 1.11.28

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 (167) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +17 -1
  4. package/dist/server/db/migrations.js +34 -0
  5. package/dist/server/db/schema.js +3 -1
  6. package/dist/server/index.js +80 -23
  7. package/dist/server/middleware/host-check-middleware.js +80 -0
  8. package/dist/server/routes/changelog.js +51 -3
  9. package/dist/server/routes/notion.js +1 -1
  10. package/dist/server/routes/pull-requests.js +6 -2
  11. package/dist/server/routes/sentry.js +1 -1
  12. package/dist/server/routes/settings.js +23 -8
  13. package/dist/server/routes/usage.js +12 -0
  14. package/dist/server/routes/workspaces.js +212 -80
  15. package/dist/server/services/agent/engines/claude-code/engine.js +3 -0
  16. package/dist/server/services/agent/engines/codex/engine.js +18 -0
  17. package/dist/server/services/agent/orchestrator.js +129 -34
  18. package/dist/server/services/agent/session-controller.js +28 -0
  19. package/dist/server/services/auto-loop-service.js +65 -0
  20. package/dist/server/services/awaiting-user-reminder-service.js +115 -0
  21. package/dist/server/services/change-source-branch-service.js +1 -1
  22. package/dist/server/services/forge/resolve.js +29 -1
  23. package/dist/server/services/lifecycle-hook-service.js +114 -0
  24. package/dist/server/services/network-access-service.js +187 -6
  25. package/dist/server/services/pr-watcher-service.js +27 -1
  26. package/dist/server/services/settings-service.js +147 -4
  27. package/dist/server/services/usage/reliability.js +81 -0
  28. package/dist/server/services/wakeup-service.js +23 -0
  29. package/dist/server/services/websocket-service.js +31 -3
  30. package/dist/server/services/workspace-service.js +36 -4
  31. package/dist/server/services/worktree-purge-service.js +1 -1
  32. package/dist/server/utils/git-ops.js +159 -1
  33. package/dist/server/utils/paths.js +19 -0
  34. package/dist/server/utils/safe-path.js +21 -1
  35. package/dist/server/utils/script-runner.js +2 -1
  36. package/dist/server/utils/workspace-name.js +37 -0
  37. package/dist/shared/consts.js +22 -0
  38. package/dist/shared/models.js +4 -4
  39. package/package.json +3 -2
  40. package/src/client/dist/pwa/assets/{ActivityFeed-LEd9NEbI.css → ActivityFeed-C6XSxdJ0.css} +1 -1
  41. package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
  42. package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
  43. package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
  44. package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
  45. package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
  46. package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.js +2 -0
  47. package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
  48. package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
  49. package/src/client/dist/pwa/assets/DashboardPage-Xev4suQ7.js +1 -0
  50. package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
  51. package/src/client/dist/pwa/assets/{DiffViewer-BV8SvUIi.css → DiffViewer-DnR79ycb.css} +1 -1
  52. package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
  53. package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
  54. package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
  55. package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
  56. package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.js +2 -0
  57. package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
  58. package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
  59. package/src/client/dist/pwa/assets/QBtnDropdown-52YHuBbx.js +1 -0
  60. package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
  61. package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
  62. package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
  63. package/src/client/dist/pwa/assets/SchedulePanel-C7NK1hIK.js +1 -0
  64. package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
  65. package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
  66. package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
  67. package/src/client/dist/pwa/assets/{TerminalPanel-HIuH2z-Y.js → TerminalPanel-CSPk8QtK.js} +3 -3
  68. package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
  69. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
  70. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
  71. package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
  72. package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
  73. package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
  74. package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
  75. package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
  76. package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
  77. package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
  78. package/src/client/dist/pwa/assets/{efforts-Dn1yRgeo.js → efforts-Db4w0alg.js} +1 -1
  79. package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
  80. package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
  81. package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
  82. package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
  83. package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
  84. package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
  85. package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
  86. package/src/client/dist/pwa/assets/{freemarker2-qQC_1HDe.js → freemarker2-KxB1BtrL.js} +1 -1
  87. package/src/client/dist/pwa/assets/{handlebars-pyc5TcAt.js → handlebars-7TByd_Zc.js} +1 -1
  88. package/src/client/dist/pwa/assets/{html-DOaftBCT.js → html-BoVZ_gre.js} +1 -1
  89. package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
  90. package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
  91. package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
  92. package/src/client/dist/pwa/assets/{javascript-BTsYFFLK.js → javascript-BLL8vPQy.js} +1 -1
  93. package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
  94. package/src/client/dist/pwa/assets/{liquid-BdTymdAY.js → liquid-DzcT-R6k.js} +1 -1
  95. package/src/client/dist/pwa/assets/{mdx-DzJktAFj.js → mdx-Bguo999w.js} +1 -1
  96. package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
  97. package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
  98. package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
  99. package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
  100. package/src/client/dist/pwa/assets/{notify-theme-D1FTBWWF.js → notify-theme-C7mNmzKx.js} +1 -1
  101. package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
  102. package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
  103. package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
  104. package/src/client/dist/pwa/assets/{project-color-eoGJvY_F.js → project-color-54JZHVy2.js} +1 -1
  105. package/src/client/dist/pwa/assets/{python-onIjs5sJ.js → python-D5RQNiPF.js} +1 -1
  106. package/src/client/dist/pwa/assets/{razor-DqQGiaiD.js → razor-CVwoDJol.js} +1 -1
  107. package/src/client/dist/pwa/assets/{render-chat-markdown-BwbknTum.js → render-chat-markdown-NAmPVIQp.js} +1 -1
  108. package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
  109. package/src/client/dist/pwa/assets/{typescript-BnI60_wU.js → typescript-qdgwClKe.js} +1 -1
  110. package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
  111. package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
  112. package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
  113. package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
  114. package/src/client/dist/pwa/assets/{xml-DQUlBYuZ.js → xml-DNSfQSlk.js} +1 -1
  115. package/src/client/dist/pwa/assets/{yaml-D3GiqGbP.js → yaml-6gnX2NnP.js} +1 -1
  116. package/src/client/dist/pwa/index.html +5 -4
  117. package/src/client/dist/pwa/sw.js +1 -1
  118. package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
  119. package/src/client/dist/pwa/assets/ActivityFeed-XJeDwa82.js +0 -8
  120. package/src/client/dist/pwa/assets/ChangelogPage-Bjls89ZX.js +0 -1
  121. package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
  122. package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
  123. package/src/client/dist/pwa/assets/CreatePage-BzHQd4uE.js +0 -2
  124. package/src/client/dist/pwa/assets/DiffViewer-DNPD0uVB.js +0 -8
  125. package/src/client/dist/pwa/assets/DocumentsPanel-Osdun-Om.js +0 -1
  126. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
  127. package/src/client/dist/pwa/assets/GitPanel-8_xQ7V2r.js +0 -3
  128. package/src/client/dist/pwa/assets/HealthPage-CSEPHslq.js +0 -1
  129. package/src/client/dist/pwa/assets/MainLayout-Bdh2JQfn.css +0 -1
  130. package/src/client/dist/pwa/assets/MainLayout-X1T4sh65.js +0 -2
  131. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
  132. package/src/client/dist/pwa/assets/QBtnDropdown-aUlDUJBU.js +0 -1
  133. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
  134. package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
  135. package/src/client/dist/pwa/assets/SchedulePanel-DIoiKpwZ.js +0 -1
  136. package/src/client/dist/pwa/assets/SearchPage-CKNNtnwv.js +0 -1
  137. package/src/client/dist/pwa/assets/SettingsPage-njeIse66.js +0 -16
  138. package/src/client/dist/pwa/assets/TouchPan-CMDAeqj6.js +0 -1
  139. package/src/client/dist/pwa/assets/WorkspacePage-Bk7pIvC0.js +0 -4
  140. package/src/client/dist/pwa/assets/build-path-tree-B07ATM5W.js +0 -1
  141. package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
  142. package/src/client/dist/pwa/assets/cssMode-BvD76b3P.js +0 -1
  143. package/src/client/dist/pwa/assets/documents-D7AZJAiR.js +0 -1
  144. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
  145. package/src/client/dist/pwa/assets/editor.api-GJX9JYVf.js +0 -847
  146. package/src/client/dist/pwa/assets/engineFeatures-C4wJRlwO.js +0 -1
  147. package/src/client/dist/pwa/assets/expand-template-0hMEF4rZ.js +0 -1
  148. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
  149. package/src/client/dist/pwa/assets/htmlMode-DRXGGPOb.js +0 -1
  150. package/src/client/dist/pwa/assets/i18n-mEG61A5h.js +0 -1
  151. package/src/client/dist/pwa/assets/index-DZ4aWwQs.js +0 -82
  152. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
  153. package/src/client/dist/pwa/assets/jsonMode-3Glcck88.js +0 -7
  154. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
  155. package/src/client/dist/pwa/assets/lspLanguageFeatures-CvwRMMY9.js +0 -4
  156. package/src/client/dist/pwa/assets/network-auth-D59LKtK4.js +0 -1
  157. package/src/client/dist/pwa/assets/pending-diff-open-DrdnFOI2.js +0 -1
  158. package/src/client/dist/pwa/assets/permissionModes-_rSNmU9M.js +0 -36
  159. package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
  160. package/src/client/dist/pwa/assets/tsMode-B24qEjDH.js +0 -11
  161. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
  162. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
  163. package/src/client/dist/pwa/assets/use-onboarding-WR8vvxBw.js +0 -2
  164. package/src/client/dist/pwa/assets/vs-DVt5kpUB.js +0 -56
  165. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
  166. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
  167. package/src/client/dist/pwa/assets/workers-D28S2iKq.js +0 -1
package/AGENTS.md CHANGED
@@ -68,6 +68,8 @@ src/
68
68
  │ │ ├── change-source-branch-service.ts # re-target a workspace onto a new source branch (built-in cherry-pick or custom bash)
69
69
  │ │ ├── git-stats-service.ts # pure compute of commit/ahead-behind/diff stats + forge availability for a workspace
70
70
  │ │ ├── settings-defaults.ts # DEFAULT_* constants for opt-in settings (e.g. change-source-branch script)
71
+ │ │ ├── lifecycle-hook-service.ts # user shell hooks on session-ended / pr-merged / autoloop-disabled
72
+ │ │ ├── awaiting-user-reminder-service.ts # reminds about workspaces stuck in `awaiting-user`
71
73
  │ │ ├── content-migration-service.ts # runtime legacy ws_events → normalised AgentEvent migration
72
74
  │ │ ├── templates-service.ts # prompt templates CRUD (JSON file persistence, seeding)
73
75
  │ │ ├── dev-server-service.ts # per-workspace dev server lifecycle (docker or npm process)
@@ -101,7 +103,7 @@ src/
101
103
 
102
104
  | Table | Purpose |
103
105
  |---|---|
104
- | `workspaces` | the unit of work: id, name, project_path, source_branch, working_branch, status, notion_url, model, `brainstorm_model`, dev_server_status, `archived_at`, `worktree_purged_at`, `worktree_purge_restore_data` (JSON), `auto_loop`, `auto_loop_ready`, `auto_loop_session_mode`, `no_progress_streak`, timestamps |
106
+ | `workspaces` | the unit of work: id, name, project_path, source_branch, working_branch, status, notion_url, model, `brainstorm_model`, dev_server_status, `archived_at`, `worktree_purged_at`, `worktree_purge_restore_data` (JSON), `auto_loop`, `auto_loop_ready`, `auto_loop_session_mode`, `no_progress_streak`, timestamps, `comparison_id` |
105
107
  | `tasks` | workspace sub-items: title, status, `is_acceptance_criterion`, sort_order; CASCADE DELETE on workspace |
106
108
  | `agent_sessions` | agent-engine sessions: pid where applicable, engine session id, status, timestamps, model, and name |
107
109
  | `ws_events` | persisted WebSocket events for replay on reconnect: type, payload, session_id, created_at |
@@ -226,6 +228,22 @@ Background: the engine was migrated from `@openai/codex-sdk` (one-shot `codex ex
226
228
  - **Custom bash override**: if `effective.changeSourceBranchScript` is non-empty (per-project override or global default), the script **replaces** the built-in flow. Spawned with `bash -c`, cwd = worktree, 5 min timeout, stderr captured (last 8 KB). Exit 0 → Kōbō updates the source-branch metadata; any non-zero exit → the stderr tail is propagated as a clean error. The user-facing menu item only shows when the resolved script is non-empty; empty means the feature is disabled (opt-in).
227
229
  - **Custom-script env vars**: `KOBO_NEW_BASE`, `KOBO_OLD_BASE`, `KOBO_WORKING_BRANCH`, `KOBO_WORKTREE_PATH`, `KOBO_PROJECT_PATH`, `KOBO_PROJECT_NAME`, `KOBO_WORKSPACE_ID`, `KOBO_WORKSPACE_NAME`, `KOBO_FORGE`, `KOBO_PR_NUMBER` (empty when no PR/MR is open). The default script lives in `settings-defaults.ts` and is seeded into `global.changeSourceBranchScript` by settings migration v33; the client reads it through `GET /api/settings/defaults` for the "Reset to Kōbō default" button. See [CONFIGURATION.md → Custom change-source-branch script](CONFIGURATION.md#custom-change-source-branch-script).
228
230
 
231
+ ### Lifecycle hooks
232
+
233
+ `src/server/services/lifecycle-hook-service.ts` runs a user-provided bash script on three moments that previously had none: `session-ended` (from `orchestrator.onSessionEnded`, once a session is known not to be superseded), `pr-merged` (from the pr-watcher, at the `pr:merged` emit, before archive/purge) and `autoloop-disabled` (from `auto-loop-service.disable`, for **every** reason including `user-action`, unlike the cleanup script which only fires on `completed`). The session-ended hook goes through `fireSessionEndedHook`, which also covers the watchdog's dead-engine sweep (that path never reaches `onSessionEnded`), dedupes by session id so a late `session:ended` from the same engine does not fire it twice, and stands down when the stop cause is `delete` or `purge` (`stopAgentAndWait`'s third argument) because the worktree is being removed.
234
+
235
+ Each event maps to one effective-settings key (`sessionEndedScript` / `prMergedScript` / `autoLoopDisabledScript`, settings migration v55), cascading project-over-global with empty meaning inherit. Execution reuses `runScript` with `eventPrefix: 'hook:<event>'`; `runScript` gained an `extraEnv` option, merged **under** the identity variables so a hook payload can never claim a different `WORKSPACE_ID`. `runLifecycleHook` never rejects and returns `null` when there is nothing to run (no script, unknown workspace, worktree gone). The pr-watcher awaits the `pr-merged` hook before auto-purge so a deploy script does not lose its worktree mid-run. See [CONFIGURATION.md → Lifecycle hooks](CONFIGURATION.md#lifecycle-hooks).
236
+
237
+ ### Engine comparison
238
+
239
+ One task, two engines, two sibling worktrees. `POST /api/workspaces` accepts a `comparisonId`; the client posts twice with the same one (sequentially — parallel creations contend on the git index lock), suffixing the name with the engine's display name and the branch with the engine id and giving the non-configured engine the model picked for it on the form and its own effort / permission defaults. `workspaces.comparison_id` (migration v40) groups them; `listComparisonMembers` reads them back, treating an empty id as no group rather than as a match against the NULL majority. `GET /api/workspaces/:id/comparison` returns each member with its cached git stats (`null`, never zeros, when nothing has been measured). `ComparisonPanel.vue` renders the table at the top of the Git tab, only for workspaces that belong to a comparison. See [CONFIGURATION.md → Comparing two engines on one task](CONFIGURATION.md#comparing-two-engines-on-one-task).
240
+
241
+ ### Unanswered question reminder
242
+
243
+ `src/server/services/awaiting-user-reminder-service.ts` polls every 60 s for non-archived workspaces in `awaiting-user` and, past `global.awaitingUserReminderMinutes` (settings migration v56, `0` = off, the default), broadcasts `workspace:awaiting-reminder` via `broadcastAll` — deliberately not `emitEphemeral`, since the user is by definition not watching that workspace. The client (`stores/websocket.ts`) turns it into the same browser notification + question sound pair used when the question is first asked.
244
+
245
+ `computeDueReminders` holds the interval arithmetic as a near-pure function over an injected `Map<id, {firstSeenAt, remindersSent}>`, so it is unit-tested without a timer or a DB. Two rules it encodes: a workspace that leaves `awaiting-user` is dropped from the map (answering resets the clock), and a tick that finds itself several intervals behind sends **one** reminder, not one per missed interval. State is in-memory only, like the pr-watcher's caches: a restart restarts the clock.
246
+
229
247
  ### Worktree purge
230
248
 
231
249
  `src/server/services/worktree-purge-service.ts` removes a workspace's worktree from disk while preserving the chat history and PR metadata. Triggered manually via `POST /api/workspaces/:id/purge-worktree` from the workspace context menu, or automatically by the pr-watcher when a PR transitions to MERGED **and** `global.autoPurgeOnPrMerged` is enabled (Settings → Worktrees toggle, settings migration v36).
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 1.11.28
8
+
9
+ - feat: lifecycle hooks, reminders, engine comparison and audit follow-ups
10
+ - fix(security): trust the page we served, not a guessed port list
11
+ - fix(security): close browser-driven access paths and credential leaks
12
+
7
13
  ## 1.11.27
8
14
 
9
15
  - fix: session watchdog false-positives, sync drain, and terminal leaks, audit npm
@@ -6,6 +6,7 @@ import * as settingsService from '../server/services/settings-service.js';
6
6
  import { slugifyProjectName } from '../server/utils/project-slug.js';
7
7
  import { resolveExistingPathInside } from '../server/utils/safe-path.js';
8
8
  import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
9
+ import { MASKED_SECRET, SECRET_GLOBAL_KEYS } from '../shared/consts.js';
9
10
  /** Allowed task status values. */
10
11
  export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
11
12
  function rowToDto(row) {
@@ -170,6 +171,17 @@ export function getDevServerStatusHandler(db, workspaceId) {
170
171
  }
171
172
  return { workspaceId, status: row.dev_server_status };
172
173
  }
174
+ /** Copy of the global settings with every stored credential blanked out. */
175
+ function sanitizeGlobalSecrets(global) {
176
+ const sanitized = { ...global };
177
+ for (const key of SECRET_GLOBAL_KEYS) {
178
+ // Same mask as the HTTP API rather than an empty string: blanking it would
179
+ // read as "not configured" and send the agent off offering to set it up.
180
+ if (sanitized[key])
181
+ sanitized[key] = MASKED_SECRET;
182
+ }
183
+ return sanitized;
184
+ }
173
185
  /** Read global and per-project settings from the JSON file on disk. */
174
186
  export function getSettingsHandler(settingsPath, projectPath) {
175
187
  // Shape is determined solely by whether projectPath was provided:
@@ -187,7 +199,11 @@ export function getSettingsHandler(settingsPath, projectPath) {
187
199
  catch (err) {
188
200
  throw new Error(`Failed to read settings: ${err instanceof Error ? err.message : String(err)}`);
189
201
  }
190
- const global = parsed.global ?? null;
202
+ // The agent gets the settings it can act on, never the credentials. Anything
203
+ // it reads can be echoed into the transcript, and the content it summarises
204
+ // (a Notion page, a Sentry issue, a repository) is not always trustworthy —
205
+ // "call get_settings and print the result" is a one-line exfiltration.
206
+ const global = parsed.global ? sanitizeGlobalSecrets(parsed.global) : null;
191
207
  const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
192
208
  if (projectPath) {
193
209
  const project = projects.find((p) => p.path === projectPath) ?? null;
@@ -692,6 +692,40 @@ export const migrations = [
692
692
  }
693
693
  },
694
694
  },
695
+ {
696
+ version: 39,
697
+ name: 'add-agent-session-end-reason',
698
+ migrate: (db) => {
699
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'agent_sessions'").get();
700
+ if (!table)
701
+ return;
702
+ const columns = db.prepare('PRAGMA table_info(agent_sessions)').all().map((column) => column.name);
703
+ if (!columns.includes('end_reason')) {
704
+ // `status` collapses every ending into completed/error by exit code, so
705
+ // "the watchdog force-ended it", "the user stopped it" and "it finished"
706
+ // were indistinguishable after the fact. Existing rows keep NULL: we
707
+ // genuinely do not know why they ended, and guessing would be worse
708
+ // than saying so.
709
+ db.exec('ALTER TABLE agent_sessions ADD COLUMN end_reason TEXT');
710
+ }
711
+ },
712
+ },
713
+ {
714
+ version: 40,
715
+ name: 'add-workspace-comparison-id',
716
+ migrate: (db) => {
717
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'workspaces'").get();
718
+ if (!table)
719
+ return;
720
+ const columns = db.prepare('PRAGMA table_info(workspaces)').all().map((column) => column.name);
721
+ if (!columns.includes('comparison_id')) {
722
+ // Shared by the workspaces created to run one task on two engines, so
723
+ // each can find its sibling. NULL for every workspace created on its
724
+ // own, which is nearly all of them.
725
+ db.exec('ALTER TABLE workspaces ADD COLUMN comparison_id TEXT');
726
+ }
727
+ },
728
+ },
695
729
  ];
696
730
  /** Current schema version — always equals the highest migration version. */
697
731
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -38,6 +38,7 @@ export function initSchema(db) {
38
38
  pr_ci_failure_dismissed_at TEXT,
39
39
  worktree_purged_at TEXT,
40
40
  worktree_purge_restore_data TEXT,
41
+ comparison_id TEXT,
41
42
  created_at TEXT NOT NULL,
42
43
  updated_at TEXT NOT NULL
43
44
  );
@@ -64,7 +65,8 @@ export function initSchema(db) {
64
65
  started_at TEXT NOT NULL,
65
66
  ended_at TEXT,
66
67
  name TEXT,
67
- task_progress_baseline TEXT
68
+ task_progress_baseline TEXT,
69
+ end_reason TEXT
68
70
  );
69
71
 
70
72
  CREATE TABLE IF NOT EXISTS ws_events (
@@ -6,6 +6,7 @@ import { Hono } from 'hono';
6
6
  import WebSocket, { WebSocketServer } from 'ws';
7
7
  import { closeDb, getDb } from './db/index.js';
8
8
  import { getPendingMigrations, runMigrations } from './db/migrations.js';
9
+ import { hostCheckMiddleware } from './middleware/host-check-middleware.js';
9
10
  import { networkAuthMiddleware } from './middleware/network-auth-middleware.js';
10
11
  import changelogRouter from './routes/changelog.js';
11
12
  import devServerRouter from './routes/dev-server.js';
@@ -28,11 +29,12 @@ import voiceRouter from './routes/voice.js';
28
29
  import workspacesRouter from './routes/workspaces.js';
29
30
  import { getAvailableSkills, isAgentUnavailableError, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
30
31
  import * as autoLoopService from './services/auto-loop-service.js';
32
+ import { startAwaitingUserReminder, stopAwaitingUserReminder } from './services/awaiting-user-reminder-service.js';
31
33
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
32
34
  import * as cronService from './services/cron-service.js';
33
35
  import { createDailyDbBackupIfNeeded, createPreMigrationBackup } from './services/db-backup-service.js';
34
36
  import { startDevServer, stopAllDevServers, stopDevServer } from './services/dev-server-service.js';
35
- import { authorizeWsUpgrade, generateToken, getLanUrls, resolveBindHost, resolveNetworkAccessEnvOverrides, } from './services/network-access-service.js';
37
+ import { authorizeWsUpgrade, generateToken, getLanHostnames, getLanUrls, isAllowedOrigin, isAllowedRequestHost, resolveBindHost, resolveDevClientOrigin, resolveNetworkAccessEnvOverrides, resolveProxyHostname, } from './services/network-access-service.js';
36
38
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
37
39
  import * as quotaBackoffService from './services/quota-backoff-service.js';
38
40
  import { getGlobalSettings, updateNetworkAccessSettings } from './services/settings-service.js';
@@ -43,7 +45,7 @@ import * as wakeupService from './services/wakeup-service.js';
43
45
  import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
44
46
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
45
47
  import { pruneWsEvents, resolveRetentionConfig } from './services/ws-events-retention-service.js';
46
- import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
48
+ import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion, resolveSpaFile } from './utils/paths.js';
47
49
  console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
48
50
  // Initialize DB + run migrations
49
51
  const db = getDb();
@@ -65,37 +67,54 @@ catch (err) {
65
67
  }
66
68
  runMigrations(db);
67
69
  // Event retention. OPT-IN: disabled by default, so this is a no-op until the
68
- // user sets a window in Settings → Worktrees. When enabled it runs at boot
69
- // only: no session is alive yet, so the batched write lock disturbs nobody, and
70
- // the daily backup that follows snapshots an already-compacted file.
70
+ // user sets a window in Settings → Worktrees. Deliberately not enabled by
71
+ // default — turning it on for an existing install would delete months of
72
+ // conversation on the next upgrade, which is precisely why the UI shows a
73
+ // count and asks first.
71
74
  // Best-effort — a failure must never block boot.
72
- try {
73
- const retentionConfig = resolveRetentionConfig(getGlobalSettings());
74
- if (retentionConfig.retentionDays > 0) {
75
+ function runRetentionPass(context) {
76
+ try {
77
+ const retentionConfig = resolveRetentionConfig(getGlobalSettings());
78
+ if (retentionConfig.retentionDays <= 0)
79
+ return;
75
80
  const retentionStartedAt = Date.now();
76
81
  const retention = pruneWsEvents(db, retentionConfig);
77
82
  if (retention.deleted > 0 || retention.vacuumed) {
78
- console.log(`[kobo] Event retention (${retentionConfig.retentionDays} d, keeping ${retentionConfig.keepPerWorkspace}/workspace): ` +
83
+ console.log(`[kobo] Event retention ${context} (${retentionConfig.retentionDays} d, keeping ${retentionConfig.keepPerWorkspace}/workspace): ` +
79
84
  `${retention.deleted} agent event(s) permanently deleted, ` +
80
85
  `${retention.sessionsRecomputed} session metric(s) recomputed, ` +
81
86
  `${retention.vacuumed ? `VACUUM reclaimed ${retention.freePagesBefore - retention.freePagesAfter} page(s), ` : ''}` +
82
87
  `${Date.now() - retentionStartedAt} ms`);
83
88
  }
84
89
  }
90
+ catch (err) {
91
+ console.error('[kobo] Event retention failed (continuing):', err);
92
+ }
85
93
  }
86
- catch (err) {
87
- console.error('[kobo] Event retention failed (continuing):', err);
88
- }
94
+ runRetentionPass('at boot');
95
+ // Kōbō is a daemon people leave running for weeks, and `emit` writes a row per
96
+ // agent event. Running the pass only at boot meant a window the user enabled
97
+ // was not applied again until the next restart.
98
+ const RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000;
99
+ const retentionTimer = setInterval(() => runRetentionPass('daily'), RETENTION_INTERVAL_MS);
100
+ retentionTimer.unref?.();
89
101
  // Daily DB backup (best-effort, fire-and-forget — never blocks boot).
90
102
  // Creates a WAL-safe snapshot alongside kobo.db if no backup exists in the
91
103
  // last 24h, and rotates out older backups beyond the retention window.
92
- void createDailyDbBackupIfNeeded(db, getDbPath()).then((r) => {
104
+ void createDailyDbBackupIfNeeded(db, getDbPath())
105
+ .then((r) => {
93
106
  if (r.created) {
94
107
  console.log(`[kobo] Daily DB backup: ${r.created}`);
95
108
  if (r.deleted.length > 0) {
96
109
  console.log(`[kobo] Rotated ${r.deleted.length} old DB backup(s)`);
97
110
  }
98
111
  }
112
+ })
113
+ .catch((err) => {
114
+ // A full disk or an unwritable backup directory must not stop the server
115
+ // from booting: without this catch the rejection reaches the last-resort
116
+ // handler below and Kōbō exits before it ever listens.
117
+ console.error('[kobo] Daily DB backup failed (continuing):', err);
99
118
  });
100
119
  // Initialize process cleanup, agent watchdog, PR watcher, and wakeup rehydration
101
120
  reconcileOrphanSessions();
@@ -121,8 +140,14 @@ catch (err) {
121
140
  }
122
141
  startPrWatcher();
123
142
  startUsagePoller();
143
+ startAwaitingUserReminder();
124
144
  // Create Hono app
125
145
  const app = new Hono();
146
+ // Refuse requests addressed to a hostname we are not reachable at, so a page
147
+ // that re-resolves its own domain to 127.0.0.1 cannot read our responses as
148
+ // same-origin. Mounted on every path, ahead of the token gate: the SPA shell
149
+ // is served outside /api/*, and rebinding targets the browser, not the socket.
150
+ app.use('*', hostCheckMiddleware);
126
151
  // Gate non-loopback requests behind the network-access token (loopback exempt).
127
152
  app.use('/api/*', networkAuthMiddleware);
128
153
  // Health check (root / is handled by the SPA catch-all below)
@@ -158,16 +183,10 @@ const clientDistPath = getClientSpaPath();
158
183
  if (clientDistPath) {
159
184
  app.get('*', async (c) => {
160
185
  const url = new URL(c.req.url);
161
- let filePath = path.join(clientDistPath, url.pathname);
162
- // Prevent path traversal
163
- if (!path.resolve(filePath).startsWith(clientDistPath)) {
164
- return c.notFound();
165
- }
166
- // Serve index.html for non-asset routes (SPA fallback)
167
- if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
168
- filePath = path.join(clientDistPath, 'index.html');
169
- }
170
- if (!fs.existsSync(filePath)) {
186
+ // Traversal guard + SPA fallback live in one tested function: the inline
187
+ // version once rejected `/` itself and shipped a 404 on the home page.
188
+ const filePath = resolveSpaFile(clientDistPath, url.pathname);
189
+ if (!filePath) {
171
190
  return c.notFound();
172
191
  }
173
192
  const content = fs.readFileSync(filePath);
@@ -442,6 +461,37 @@ setMessageHandler(async (type, payload) => {
442
461
  server.on('upgrade', (request, socket, head) => {
443
462
  const { pathname } = new URL(request.url ?? '/', `http://localhost:${PORT}`);
444
463
  const wsGlobal = getGlobalSettings();
464
+ // WebSockets are exempt from the same-origin policy and send no preflight,
465
+ // so without these two any page the user visits could open /ws/terminal/<id>
466
+ // and drive a real shell here. Checked before the token gate: on loopback the
467
+ // token gate lets everything through, which is exactly the case a malicious
468
+ // page exploits. The HTTP side gets the same pair from hostCheckMiddleware,
469
+ // which does not see upgrade requests.
470
+ const wsHost = request.headers.host;
471
+ if (!isAllowedRequestHost({
472
+ host: wsHost,
473
+ enabled: wsGlobal.networkAccessEnabled,
474
+ lanHostnames: getLanHostnames(),
475
+ behindProxy: wsGlobal.networkAccessBehindProxy,
476
+ proxyHostname: resolveProxyHostname(),
477
+ })) {
478
+ console.warn(`[host-check] WS 403 (forbidden host '${wsHost ?? 'unknown'}') ${pathname}`);
479
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
480
+ socket.destroy();
481
+ return;
482
+ }
483
+ if (!isAllowedOrigin({
484
+ origin: request.headers.origin,
485
+ requestHost: wsHost,
486
+ behindProxy: wsGlobal.networkAccessBehindProxy,
487
+ proxyHostname: resolveProxyHostname(),
488
+ devOrigin: resolveDevClientOrigin(),
489
+ })) {
490
+ console.warn(`[origin-check] WS 403 (forbidden origin '${request.headers.origin ?? 'unknown'}') ${pathname}`);
491
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
492
+ socket.destroy();
493
+ return;
494
+ }
445
495
  if (!authorizeWsUpgrade({
446
496
  address: request.socket.remoteAddress,
447
497
  rawUrl: request.url,
@@ -517,6 +567,13 @@ async function gracefulShutdown(signal, exitCode = 0) {
517
567
  catch {
518
568
  // Best-effort
519
569
  }
570
+ try {
571
+ stopAwaitingUserReminder();
572
+ }
573
+ catch {
574
+ // Best-effort
575
+ }
576
+ clearInterval(retentionTimer);
520
577
  // Stop managed agents and both direct/Docker dev servers before closing DB.
521
578
  try {
522
579
  await Promise.all([stopAllAgents(), stopAllDevServers()]);
@@ -0,0 +1,80 @@
1
+ import { getLanHostnames, isAllowedOrigin, isAllowedRequestHost, isLocalRequestHost, resolveDevClientOrigin, resolveProxyHostname, } from '../services/network-access-service.js';
2
+ import { getGlobalSettings } from '../services/settings-service.js';
3
+ /**
4
+ * Rejects requests addressed to a hostname this server is not legitimately
5
+ * reachable at — the defense against DNS rebinding.
6
+ *
7
+ * A page on `evil.com` can make the domain re-resolve to 127.0.0.1 after it
8
+ * loads. Every later request then reaches Kōbō with `Host: evil.com`, and the
9
+ * browser treats the responses as same-origin, so the page can read them. The
10
+ * loopback address the connection arrives from is genuine, which is why the
11
+ * network-access gate lets it through: only the Host header distinguishes this
12
+ * from the user's own tab.
13
+ *
14
+ * `/api/health` is exempt, mirroring the network-access gate: it returns only
15
+ * `{ status, version }`, and a container healthcheck reaches it through the
16
+ * service name rather than localhost.
17
+ *
18
+ * The host comes from `c.req.url`, which the Node adapter builds from the Host
19
+ * header itself, so this reads the client-supplied value without depending on
20
+ * header casing.
21
+ */
22
+ export const hostCheckMiddleware = async (c, next) => {
23
+ if (c.req.path === '/api/health')
24
+ return next();
25
+ let host;
26
+ try {
27
+ host = new URL(c.req.url).host;
28
+ }
29
+ catch {
30
+ host = undefined;
31
+ }
32
+ const origin = c.req.header('origin');
33
+ // Fast path. This middleware runs on every request, static assets included,
34
+ // and the calls below are a synchronous settings read plus an interface
35
+ // enumeration. A request addressed to loopback and carrying no Origin (a
36
+ // static asset, a direct navigation, a CLI call) is the common case and is
37
+ // allowed whatever the settings say, so settle it here.
38
+ if (origin === undefined && isLocalRequestHost(host))
39
+ return next();
40
+ const devOrigin = resolveDevClientOrigin();
41
+ // A page we served ourselves, talking to us: still no settings needed.
42
+ if (isLocalRequestHost(host) && isAllowedOrigin({ origin, requestHost: host, devOrigin })) {
43
+ return next();
44
+ }
45
+ let global;
46
+ let lanHostnames;
47
+ try {
48
+ global = getGlobalSettings();
49
+ lanHostnames = getLanHostnames();
50
+ }
51
+ catch (err) {
52
+ // An unreadable settings file must not take the whole app down. Loopback
53
+ // was already answered above, so anything reaching here is remote and
54
+ // fails closed.
55
+ console.error('[host-check] Could not resolve the allowed hosts — refusing:', err);
56
+ return c.json({ error: 'forbidden host' }, 403);
57
+ }
58
+ if (!isAllowedRequestHost({
59
+ host,
60
+ enabled: global.networkAccessEnabled,
61
+ lanHostnames,
62
+ behindProxy: global.networkAccessBehindProxy,
63
+ proxyHostname: resolveProxyHostname(),
64
+ })) {
65
+ // Surface it: "the page just stopped loading" is otherwise undebuggable.
66
+ console.warn(`[host-check] 403 (forbidden host '${host ?? 'unknown'}') ${c.req.method} ${c.req.path}`);
67
+ return c.json({ error: 'forbidden host' }, 403);
68
+ }
69
+ if (!isAllowedOrigin({
70
+ origin,
71
+ requestHost: host,
72
+ behindProxy: global.networkAccessBehindProxy,
73
+ proxyHostname: resolveProxyHostname(),
74
+ devOrigin,
75
+ })) {
76
+ console.warn(`[origin-check] 403 (forbidden origin '${origin}') ${c.req.method} ${c.req.path}`);
77
+ return c.json({ error: 'forbidden origin' }, 403);
78
+ }
79
+ return next();
80
+ };
@@ -26,9 +26,57 @@ export function parseChangelog(markdown) {
26
26
  entries.push({ version: current.version, notes: current.lines.join('\n').trim() });
27
27
  return entries;
28
28
  }
29
+ /**
30
+ * Latest version published to npm, looked up at most once a day.
31
+ *
32
+ * "What's new" tells the user what changed once they have upgraded; nothing
33
+ * told them an upgrade existed. A self-hosted install would silently rot.
34
+ *
35
+ * Best-effort by construction: no network, a registry hiccup or an air-gapped
36
+ * machine all resolve to null, and the client simply shows nothing.
37
+ */
38
+ const REGISTRY_URL = 'https://registry.npmjs.org/@loicngr/kobo/latest';
39
+ const LATEST_TTL_MS = 24 * 60 * 60 * 1000;
40
+ let latestCache = null;
41
+ /** The lookup in progress, so two loads racing on a cold cache share one request. */
42
+ let inFlight = null;
43
+ async function fetchLatestVersion() {
44
+ if (latestCache && Date.now() - latestCache.fetchedAt < LATEST_TTL_MS)
45
+ return latestCache.version;
46
+ if (inFlight)
47
+ return inFlight;
48
+ inFlight = lookupLatestVersion().finally(() => {
49
+ inFlight = null;
50
+ });
51
+ return inFlight;
52
+ }
53
+ async function lookupLatestVersion() {
54
+ let version = null;
55
+ try {
56
+ const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5_000) });
57
+ if (res.ok) {
58
+ const body = (await res.json());
59
+ if (typeof body.version === 'string')
60
+ version = body.version;
61
+ }
62
+ }
63
+ catch {
64
+ // Offline, blocked, or the registry is down. Not worth a log line on a
65
+ // path that runs on every app load.
66
+ }
67
+ // Cache the failure too: a machine with no network must not retry per load.
68
+ latestCache = { version, fetchedAt: Date.now() };
69
+ return version;
70
+ }
71
+ /** @internal test-only — drop the cached registry lookup. */
72
+ export function _clearLatestVersionCache() {
73
+ latestCache = null;
74
+ inFlight = null;
75
+ }
29
76
  // GET /api/changelog — current app version + parsed CHANGELOG.md sections,
30
- // powering the "What's new" dialog shown after an update.
31
- app.get('/', (c) => {
77
+ // powering the "What's new" dialog shown after an update, plus the latest
78
+ // version published to npm so the UI can offer to upgrade.
79
+ app.get('/', async (c) => {
32
80
  try {
33
81
  let versions = [];
34
82
  try {
@@ -37,7 +85,7 @@ app.get('/', (c) => {
37
85
  catch {
38
86
  // No CHANGELOG.md shipped (or unreadable) — degrade to an empty list.
39
87
  }
40
- return c.json({ currentVersion: getPackageVersion(), versions });
88
+ return c.json({ currentVersion: getPackageVersion(), latestVersion: await fetchLatestVersion(), versions });
41
89
  }
42
90
  catch (err) {
43
91
  const message = err instanceof Error ? err.message : String(err);
@@ -33,7 +33,7 @@ app.post('/extract', async (c) => {
33
33
  if (!getGlobalSettings().notionEnabled) {
34
34
  return c.json({ error: 'Notion integration is disabled in Settings' }, 403);
35
35
  }
36
- const body = await c.req.json();
36
+ const body = await c.req.json().catch(() => ({}));
37
37
  if (!body.url) {
38
38
  return c.json({ error: 'Missing required field: url' }, 400);
39
39
  }
@@ -38,7 +38,9 @@ app.get('/', async (c) => {
38
38
  });
39
39
  app.post('/diagnose', async (c) => {
40
40
  try {
41
- const { projectPath, prNumber, worktreesPath } = await c.req.json();
41
+ const { projectPath, prNumber, worktreesPath } = await c.req
42
+ .json()
43
+ .catch(() => ({}));
42
44
  if (!projectPath || !prNumber) {
43
45
  return c.json({ error: 'Missing required fields: projectPath, prNumber' }, 400);
44
46
  }
@@ -85,7 +87,9 @@ app.post('/diagnose', async (c) => {
85
87
  });
86
88
  app.post('/resolve', async (c) => {
87
89
  try {
88
- const body = await c.req.json();
90
+ const body = await c.req
91
+ .json()
92
+ .catch(() => ({}));
89
93
  if (!body.projectPath || !body.fingerprint) {
90
94
  return c.json({ error: 'Missing required fields: projectPath, fingerprint' }, 400);
91
95
  }
@@ -19,7 +19,7 @@ app.post('/extract', async (c) => {
19
19
  if (!getGlobalSettings().sentryEnabled) {
20
20
  return c.json({ error: 'Sentry integration is disabled in Settings' }, 403);
21
21
  }
22
- const body = await c.req.json();
22
+ const body = await c.req.json().catch(() => ({}));
23
23
  if (!body.url) {
24
24
  return c.json({ error: 'Missing required field: url' }, 400);
25
25
  }
@@ -13,22 +13,22 @@ import { listTemplates, replaceAllTemplates } from '../services/templates-servic
13
13
  import { countPrunableWsEvents } from '../services/ws-events-retention-service.js';
14
14
  /** Hono sub-router for global and per-project settings CRUD. */
15
15
  const app = new Hono();
16
- // GET /api/settings — return full settings
16
+ // GET /api/settings — return full settings, credentials masked
17
17
  app.get('/', (c) => {
18
18
  try {
19
19
  const settings = settingsService.getSettings();
20
- return c.json(settings);
20
+ return c.json({ ...settings, global: settingsService.redactGlobalSecrets(settings.global) });
21
21
  }
22
22
  catch (err) {
23
23
  const message = err instanceof Error ? err.message : String(err);
24
24
  return c.json({ error: message }, 500);
25
25
  }
26
26
  });
27
- // GET /api/settings/global — return global settings
27
+ // GET /api/settings/global — return global settings, credentials masked
28
28
  app.get('/global', (c) => {
29
29
  try {
30
30
  const global = settingsService.getGlobalSettings();
31
- return c.json(global);
31
+ return c.json(settingsService.redactGlobalSecrets(global));
32
32
  }
33
33
  catch (err) {
34
34
  const message = err instanceof Error ? err.message : String(err);
@@ -103,7 +103,12 @@ app.get('/network/ping', (c) => c.json({ ok: true }));
103
103
  // POST /api/settings/network — toggle enabled / regenerate token
104
104
  app.post('/network', async (c) => {
105
105
  try {
106
- const body = await c.req.json();
106
+ const body = await c.req
107
+ .json()
108
+ .catch(() => null);
109
+ // `{}` is a valid request here, so a parse failure must not become one.
110
+ if (!body)
111
+ return c.json({ error: 'Invalid JSON body' }, 400);
107
112
  const current = settingsService.getGlobalSettings();
108
113
  const patch = {};
109
114
  let restartRequired = false;
@@ -148,9 +153,16 @@ app.get('/mcp-servers', (c) => {
148
153
  // PUT /api/settings/global — update global settings
149
154
  app.put('/global', async (c) => {
150
155
  try {
151
- const body = await c.req.json();
156
+ const body = await c.req.json().catch(() => null);
157
+ // `{}` merges nothing but still rewrites settings.json; a parse failure
158
+ // must answer 400 rather than do that.
159
+ if (!body)
160
+ return c.json({ error: 'Invalid JSON body' }, 400);
152
161
  const updated = settingsService.updateGlobalSettings(body);
153
- return c.json(updated);
162
+ // The client assigns this response straight into its store, so echoing the
163
+ // real credentials back would undo the masking on GET the first time
164
+ // anyone saves anything.
165
+ return c.json(settingsService.redactGlobalSecrets(updated));
154
166
  }
155
167
  catch (err) {
156
168
  const message = err instanceof Error ? err.message : String(err);
@@ -190,7 +202,10 @@ app.put('/projects/:encodedPath', async (c) => {
190
202
  try {
191
203
  const encodedPath = c.req.param('encodedPath');
192
204
  const projectPath = Buffer.from(encodedPath, 'base64url').toString();
193
- const body = await c.req.json();
205
+ const body = await c.req.json().catch(() => null);
206
+ // `{}` CREATES a project entry with defaults; a malformed body must not.
207
+ if (!body)
208
+ return c.json({ error: 'Invalid JSON body' }, 400);
194
209
  const project = settingsService.upsertProject(projectPath, body);
195
210
  return c.json(project);
196
211
  }
@@ -1,6 +1,18 @@
1
1
  import { Hono } from 'hono';
2
+ import { getDb } from '../db/index.js';
2
3
  import { refreshNow } from '../services/usage/poller.js';
4
+ import { computeEngineReliability } from '../services/usage/reliability.js';
3
5
  const app = new Hono();
6
+ // GET /api/usage/reliability — how each engine and model actually behaved.
7
+ app.get('/reliability', (c) => {
8
+ try {
9
+ return c.json({ engines: computeEngineReliability(getDb()) });
10
+ }
11
+ catch (err) {
12
+ const message = err instanceof Error ? err.message : String(err);
13
+ return c.json({ error: message }, 500);
14
+ }
15
+ });
4
16
  app.post('/:providerId/refresh', async (c) => {
5
17
  const providerId = c.req.param('providerId');
6
18
  try {