@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.
- package/AGENTS.md +19 -1
- package/CHANGELOG.md +6 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +17 -1
- package/dist/server/db/migrations.js +34 -0
- package/dist/server/db/schema.js +3 -1
- package/dist/server/index.js +80 -23
- package/dist/server/middleware/host-check-middleware.js +80 -0
- package/dist/server/routes/changelog.js +51 -3
- package/dist/server/routes/notion.js +1 -1
- package/dist/server/routes/pull-requests.js +6 -2
- package/dist/server/routes/sentry.js +1 -1
- package/dist/server/routes/settings.js +23 -8
- package/dist/server/routes/usage.js +12 -0
- package/dist/server/routes/workspaces.js +212 -80
- package/dist/server/services/agent/engines/claude-code/engine.js +3 -0
- package/dist/server/services/agent/engines/codex/engine.js +18 -0
- package/dist/server/services/agent/orchestrator.js +129 -34
- package/dist/server/services/agent/session-controller.js +28 -0
- package/dist/server/services/auto-loop-service.js +65 -0
- package/dist/server/services/awaiting-user-reminder-service.js +115 -0
- package/dist/server/services/change-source-branch-service.js +1 -1
- package/dist/server/services/forge/resolve.js +29 -1
- package/dist/server/services/lifecycle-hook-service.js +114 -0
- package/dist/server/services/network-access-service.js +187 -6
- package/dist/server/services/pr-watcher-service.js +27 -1
- package/dist/server/services/settings-service.js +147 -4
- package/dist/server/services/usage/reliability.js +81 -0
- package/dist/server/services/wakeup-service.js +23 -0
- package/dist/server/services/websocket-service.js +31 -3
- package/dist/server/services/workspace-service.js +36 -4
- package/dist/server/services/worktree-purge-service.js +1 -1
- package/dist/server/utils/git-ops.js +159 -1
- package/dist/server/utils/paths.js +19 -0
- package/dist/server/utils/safe-path.js +21 -1
- package/dist/server/utils/script-runner.js +2 -1
- package/dist/server/utils/workspace-name.js +37 -0
- package/dist/shared/consts.js +22 -0
- package/dist/shared/models.js +4 -4
- package/package.json +3 -2
- package/src/client/dist/pwa/assets/{ActivityFeed-LEd9NEbI.css → ActivityFeed-C6XSxdJ0.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
- package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
- package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-Xev4suQ7.js +1 -0
- package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
- package/src/client/dist/pwa/assets/{DiffViewer-BV8SvUIi.css → DiffViewer-DnR79ycb.css} +1 -1
- package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
- package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
- package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
- package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.js +2 -0
- package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
- package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
- package/src/client/dist/pwa/assets/QBtnDropdown-52YHuBbx.js +1 -0
- package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
- package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
- package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-C7NK1hIK.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
- package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
- package/src/client/dist/pwa/assets/{TerminalPanel-HIuH2z-Y.js → TerminalPanel-CSPk8QtK.js} +3 -3
- package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
- package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
- package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
- package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
- package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
- package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
- package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
- package/src/client/dist/pwa/assets/{efforts-Dn1yRgeo.js → efforts-Db4w0alg.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
- package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
- package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
- package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
- package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
- package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-qQC_1HDe.js → freemarker2-KxB1BtrL.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-pyc5TcAt.js → handlebars-7TByd_Zc.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DOaftBCT.js → html-BoVZ_gre.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
- package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
- package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-BTsYFFLK.js → javascript-BLL8vPQy.js} +1 -1
- package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
- package/src/client/dist/pwa/assets/{liquid-BdTymdAY.js → liquid-DzcT-R6k.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DzJktAFj.js → mdx-Bguo999w.js} +1 -1
- package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
- package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
- package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
- package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-D1FTBWWF.js → notify-theme-C7mNmzKx.js} +1 -1
- package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
- package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-eoGJvY_F.js → project-color-54JZHVy2.js} +1 -1
- package/src/client/dist/pwa/assets/{python-onIjs5sJ.js → python-D5RQNiPF.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DqQGiaiD.js → razor-CVwoDJol.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-BwbknTum.js → render-chat-markdown-NAmPVIQp.js} +1 -1
- package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/src/client/dist/pwa/assets/{typescript-BnI60_wU.js → typescript-qdgwClKe.js} +1 -1
- package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
- package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
- package/src/client/dist/pwa/assets/{xml-DQUlBYuZ.js → xml-DNSfQSlk.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-D3GiqGbP.js → yaml-6gnX2NnP.js} +1 -1
- package/src/client/dist/pwa/index.html +5 -4
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
- package/src/client/dist/pwa/assets/ActivityFeed-XJeDwa82.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-Bjls89ZX.js +0 -1
- package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BzHQd4uE.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-DNPD0uVB.js +0 -8
- package/src/client/dist/pwa/assets/DocumentsPanel-Osdun-Om.js +0 -1
- package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
- package/src/client/dist/pwa/assets/GitPanel-8_xQ7V2r.js +0 -3
- package/src/client/dist/pwa/assets/HealthPage-CSEPHslq.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-Bdh2JQfn.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-X1T4sh65.js +0 -2
- package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
- package/src/client/dist/pwa/assets/QBtnDropdown-aUlDUJBU.js +0 -1
- package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
- package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-DIoiKpwZ.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CKNNtnwv.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-njeIse66.js +0 -16
- package/src/client/dist/pwa/assets/TouchPan-CMDAeqj6.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Bk7pIvC0.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-B07ATM5W.js +0 -1
- package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
- package/src/client/dist/pwa/assets/cssMode-BvD76b3P.js +0 -1
- package/src/client/dist/pwa/assets/documents-D7AZJAiR.js +0 -1
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
- package/src/client/dist/pwa/assets/editor.api-GJX9JYVf.js +0 -847
- package/src/client/dist/pwa/assets/engineFeatures-C4wJRlwO.js +0 -1
- package/src/client/dist/pwa/assets/expand-template-0hMEF4rZ.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
- package/src/client/dist/pwa/assets/htmlMode-DRXGGPOb.js +0 -1
- package/src/client/dist/pwa/assets/i18n-mEG61A5h.js +0 -1
- package/src/client/dist/pwa/assets/index-DZ4aWwQs.js +0 -82
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
- package/src/client/dist/pwa/assets/jsonMode-3Glcck88.js +0 -7
- package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-CvwRMMY9.js +0 -4
- package/src/client/dist/pwa/assets/network-auth-D59LKtK4.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DrdnFOI2.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-_rSNmU9M.js +0 -36
- package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
- package/src/client/dist/pwa/assets/tsMode-B24qEjDH.js +0 -11
- package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
- package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-WR8vvxBw.js +0 -2
- package/src/client/dist/pwa/assets/vs-DVt5kpUB.js +0 -56
- package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
- package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
- 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
|
-
|
|
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;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -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 (
|
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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())
|
|
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
|
-
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|