@loicngr/kobo 1.11.26 → 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 (182) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +10 -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 +240 -91
  15. package/dist/server/services/agent/engines/claude-code/engine.js +112 -15
  16. package/dist/server/services/agent/engines/codex/engine.js +18 -0
  17. package/dist/server/services/agent/engines/codex/server-requests.js +5 -20
  18. package/dist/server/services/agent/orchestrator.js +143 -34
  19. package/dist/server/services/agent/session-controller.js +28 -0
  20. package/dist/server/services/agent/streaming-batcher.js +1 -0
  21. package/dist/server/services/agent/turn-liveness.js +12 -1
  22. package/dist/server/services/auto-loop-service.js +65 -0
  23. package/dist/server/services/awaiting-user-reminder-service.js +115 -0
  24. package/dist/server/services/change-source-branch-service.js +1 -1
  25. package/dist/server/services/file-editor-service.js +2 -46
  26. package/dist/server/services/forge/bitbucket/provider.js +9 -8
  27. package/dist/server/services/forge/github/provider.js +4 -3
  28. package/dist/server/services/forge/gitlab/provider.js +4 -3
  29. package/dist/server/services/forge/parse-cli-json.js +14 -0
  30. package/dist/server/services/forge/resolve.js +29 -1
  31. package/dist/server/services/lifecycle-hook-service.js +114 -0
  32. package/dist/server/services/network-access-service.js +187 -6
  33. package/dist/server/services/pr-watcher-service.js +27 -1
  34. package/dist/server/services/settings-service.js +147 -4
  35. package/dist/server/services/usage/reliability.js +81 -0
  36. package/dist/server/services/wakeup-service.js +23 -0
  37. package/dist/server/services/websocket-service.js +31 -3
  38. package/dist/server/services/workspace-service.js +36 -4
  39. package/dist/server/services/worktree-purge-service.js +1 -1
  40. package/dist/server/utils/git-ops.js +159 -1
  41. package/dist/server/utils/paths.js +19 -0
  42. package/dist/server/utils/safe-path.js +21 -1
  43. package/dist/server/utils/script-runner.js +2 -1
  44. package/dist/server/utils/workspace-name.js +37 -0
  45. package/dist/shared/consts.js +22 -0
  46. package/dist/shared/models.js +4 -4
  47. package/package.json +3 -2
  48. package/src/client/dist/pwa/assets/{ActivityFeed-DUjY6lCm.css → ActivityFeed-C6XSxdJ0.css} +1 -1
  49. package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
  50. package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
  51. package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
  52. package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
  53. package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
  54. package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.js +2 -0
  55. package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
  56. package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
  57. package/src/client/dist/pwa/assets/DashboardPage-Xev4suQ7.js +1 -0
  58. package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
  59. package/src/client/dist/pwa/assets/DiffViewer-DnR79ycb.css +1 -0
  60. package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
  61. package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
  62. package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
  63. package/src/client/dist/pwa/assets/{GitPanel-BKrtkPrV.css → GitPanel-DVs5BCU-.css} +1 -1
  64. package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
  65. package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.js +2 -0
  66. package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
  67. package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
  68. package/src/client/dist/pwa/assets/QBtnDropdown-52YHuBbx.js +1 -0
  69. package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
  70. package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
  71. package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
  72. package/src/client/dist/pwa/assets/SchedulePanel-C7NK1hIK.js +1 -0
  73. package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
  74. package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
  75. package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
  76. package/src/client/dist/pwa/assets/TerminalPanel-CSPk8QtK.js +18 -0
  77. package/src/client/dist/pwa/assets/{TerminalPanel-B6xJJs8p.css → TerminalPanel-DbVZk0vD.css} +1 -1
  78. package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
  79. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
  80. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
  81. package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
  82. package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
  83. package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
  84. package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
  85. package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
  86. package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
  87. package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
  88. package/src/client/dist/pwa/assets/{efforts-BPZzMyrc.js → efforts-Db4w0alg.js} +1 -1
  89. package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
  90. package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
  91. package/src/client/dist/pwa/assets/expand-template-CTUNyJrv.css +1 -0
  92. package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
  93. package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
  94. package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
  95. package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
  96. package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
  97. package/src/client/dist/pwa/assets/{freemarker2-BqVyQSGe.js → freemarker2-KxB1BtrL.js} +1 -1
  98. package/src/client/dist/pwa/assets/{handlebars-9K762gzW.js → handlebars-7TByd_Zc.js} +1 -1
  99. package/src/client/dist/pwa/assets/{html-CRmp4AC_.js → html-BoVZ_gre.js} +1 -1
  100. package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
  101. package/src/client/dist/pwa/assets/{index-BpmxQlZi.css → index-6H3m6vH7.css} +1 -1
  102. package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
  103. package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
  104. package/src/client/dist/pwa/assets/{javascript-DB9a_ZGm.js → javascript-BLL8vPQy.js} +1 -1
  105. package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
  106. package/src/client/dist/pwa/assets/{liquid-C67ZTBj7.js → liquid-DzcT-R6k.js} +1 -1
  107. package/src/client/dist/pwa/assets/{mdx-C_K9rc9_.js → mdx-Bguo999w.js} +1 -1
  108. package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
  109. package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
  110. package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
  111. package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
  112. package/src/client/dist/pwa/assets/{notify-theme-thUiJn9n.js → notify-theme-C7mNmzKx.js} +1 -1
  113. package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
  114. package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
  115. package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
  116. package/src/client/dist/pwa/assets/{project-color-hgNWqp0x.js → project-color-54JZHVy2.js} +1 -1
  117. package/src/client/dist/pwa/assets/{python-D1CBO9Uf.js → python-D5RQNiPF.js} +1 -1
  118. package/src/client/dist/pwa/assets/{razor-BCiKSj6_.js → razor-CVwoDJol.js} +1 -1
  119. package/src/client/dist/pwa/assets/{render-chat-markdown-Cw1_NLqs.js → render-chat-markdown-NAmPVIQp.js} +1 -1
  120. package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
  121. package/src/client/dist/pwa/assets/{typescript-D-8UENST.js → typescript-qdgwClKe.js} +1 -1
  122. package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
  123. package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
  124. package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
  125. package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
  126. package/src/client/dist/pwa/assets/{xml-BBPvJ9QP.js → xml-DNSfQSlk.js} +1 -1
  127. package/src/client/dist/pwa/assets/{yaml-D4q1p7HX.js → yaml-6gnX2NnP.js} +1 -1
  128. package/src/client/dist/pwa/index.html +6 -5
  129. package/src/client/dist/pwa/sw.js +1 -1
  130. package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
  131. package/src/client/dist/pwa/assets/ActivityFeed-CGMEOMn5.js +0 -8
  132. package/src/client/dist/pwa/assets/ChangelogPage-rvISZfJb.js +0 -1
  133. package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
  134. package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
  135. package/src/client/dist/pwa/assets/CreatePage-BptAgVvu.js +0 -2
  136. package/src/client/dist/pwa/assets/DiffViewer-ZrvQ0YWN.js +0 -8
  137. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +0 -1
  138. package/src/client/dist/pwa/assets/DocumentsPanel-DuWbZiID.js +0 -1
  139. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
  140. package/src/client/dist/pwa/assets/GitPanel-BiIXQPfL.js +0 -3
  141. package/src/client/dist/pwa/assets/HealthPage-DTrmhh3X.js +0 -1
  142. package/src/client/dist/pwa/assets/MainLayout-PSmlvfJd.js +0 -2
  143. package/src/client/dist/pwa/assets/MainLayout-Vq1iUPjx.css +0 -1
  144. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
  145. package/src/client/dist/pwa/assets/QBtnDropdown-Ckvd3IVR.js +0 -1
  146. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
  147. package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
  148. package/src/client/dist/pwa/assets/SchedulePanel-DJWMsAoh.js +0 -1
  149. package/src/client/dist/pwa/assets/SearchPage-BI8w9utc.js +0 -1
  150. package/src/client/dist/pwa/assets/SettingsPage-Dr8oSevA.js +0 -16
  151. package/src/client/dist/pwa/assets/TerminalPanel-DVC58DJb.js +0 -18
  152. package/src/client/dist/pwa/assets/TouchPan-pqksSni0.js +0 -1
  153. package/src/client/dist/pwa/assets/WorkspacePage-DRP9Ypmz.js +0 -4
  154. package/src/client/dist/pwa/assets/build-path-tree-CQ4zZqo0.js +0 -1
  155. package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
  156. package/src/client/dist/pwa/assets/cssMode-MGWKgqj7.js +0 -1
  157. package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +0 -1
  158. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
  159. package/src/client/dist/pwa/assets/editor.api-Dq41Mm99.js +0 -847
  160. package/src/client/dist/pwa/assets/engineFeatures-Dd_dxoOS.js +0 -1
  161. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +0 -1
  162. package/src/client/dist/pwa/assets/expand-template-BfZqON3q.js +0 -1
  163. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
  164. package/src/client/dist/pwa/assets/htmlMode-CFxFF9iH.js +0 -1
  165. package/src/client/dist/pwa/assets/i18n-BsWCIFmf.js +0 -1
  166. package/src/client/dist/pwa/assets/index-xTf0VfFG.js +0 -82
  167. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
  168. package/src/client/dist/pwa/assets/jsonMode-JhLTE1-C.js +0 -7
  169. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
  170. package/src/client/dist/pwa/assets/lspLanguageFeatures-BIDd3ml0.js +0 -4
  171. package/src/client/dist/pwa/assets/network-auth-BPxfeLcl.js +0 -1
  172. package/src/client/dist/pwa/assets/pending-diff-open-BdA8vrDx.js +0 -1
  173. package/src/client/dist/pwa/assets/permissionModes-D09rnNgv.js +0 -36
  174. package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
  175. package/src/client/dist/pwa/assets/tsMode-Db77HzVu.js +0 -11
  176. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
  177. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
  178. package/src/client/dist/pwa/assets/use-onboarding-BafrD9nc.js +0 -2
  179. package/src/client/dist/pwa/assets/vs-Bb18eQC8.js +0 -56
  180. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
  181. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
  182. package/src/client/dist/pwa/assets/workers-CImko7IX.js +0 -1
@@ -11,19 +11,200 @@ export function isLoopbackAddress(address) {
11
11
  export function resolveBindHost(enabled) {
12
12
  return enabled ? undefined : '127.0.0.1';
13
13
  }
14
- /** Non-internal IPv4 URLs for the running server, for display + QR. */
14
+ /**
15
+ * Non-internal addresses of the running server, one per LAN interface. Both
16
+ * families: with network access on, the server binds every interface, so an
17
+ * IPv6 address is as legitimate a way to reach it as an IPv4 one. A scoped
18
+ * link-local address (`fe80::1%eth0`) loses its zone, which is the form a URL
19
+ * carries.
20
+ */
21
+ export function getLanHostnames() {
22
+ return collectLanAddresses(null);
23
+ }
24
+ /**
25
+ * Non-internal IPv4 URLs for the running server, for display + QR. IPv4 only:
26
+ * these are meant to be read off a screen or scanned from a phone, and an IPv6
27
+ * literal is neither.
28
+ */
15
29
  export function getLanUrls(port) {
16
- const urls = [];
30
+ return collectLanAddresses('IPv4').map((address) => `http://${address}:${port}`);
31
+ }
32
+ function collectLanAddresses(family) {
33
+ const addresses = [];
17
34
  for (const infos of Object.values(os.networkInterfaces())) {
18
35
  if (!infos)
19
36
  continue;
20
37
  for (const info of infos) {
21
- if (info.family === 'IPv4' && !info.internal) {
22
- urls.push(`http://${info.address}:${port}`);
23
- }
38
+ if (info.internal)
39
+ continue;
40
+ if (family !== null && info.family !== family)
41
+ continue;
42
+ // A scoped link-local address (`fe80::1%eth0`) loses its zone, which is
43
+ // the form a URL carries.
44
+ addresses.push(info.address.split('%')[0]);
24
45
  }
25
46
  }
26
- return urls;
47
+ return addresses;
48
+ }
49
+ /**
50
+ * Hostnames that always designate the host machine itself. A request or a page
51
+ * carrying one of these is, by construction, already running on the user's own
52
+ * machine, whatever port it came from — which is what keeps the Quasar dev
53
+ * server (localhost:8080 proxying to localhost:3000) working.
54
+ */
55
+ // `::ffff:7f00:1` is what the URL parser normalises `::ffff:127.0.0.1` into,
56
+ // so both spellings of the IPv4-mapped loopback land here. `0.0.0.0` and `::`
57
+ // name the wildcard bind rather than a host, but Linux routes them to loopback
58
+ // and `http://0.0.0.0:3000` is a common bookmark; a rebinding page can never
59
+ // produce them, since the browser puts its own domain in the Host.
60
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '0.0.0.0', '::']);
61
+ /**
62
+ * Hostname carried by a raw `Host` or `Origin` header, or null when the value
63
+ * cannot be parsed. `Host` arrives bare (`127.0.0.1:3000`), `Origin` with a
64
+ * scheme (`http://127.0.0.1:3000`); adding a scheme when none is present runs
65
+ * both through the same URL rules, including the bracketed IPv6 form.
66
+ */
67
+ function headerHostname(value) {
68
+ try {
69
+ const hostname = new URL(value.includes('://') ? value : `http://${value}`).hostname;
70
+ if (!hostname)
71
+ return null;
72
+ // The URL parser keeps IPv6 hostnames bracketed; compare on the bare address.
73
+ return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * True when a raw `Host`/`Origin` value names the machine itself. Always
81
+ * allowed, whatever the settings say, which lets a caller settle the common
82
+ * case without reading the settings file or listing the network interfaces —
83
+ * both synchronous, and this runs on every request.
84
+ */
85
+ export function isLocalRequestHost(value) {
86
+ if (!value)
87
+ return false;
88
+ const hostname = headerHostname(value);
89
+ return hostname !== null && LOCAL_HOSTNAMES.has(hostname);
90
+ }
91
+ function isAllowedHostname(hostname, enabled, lanHostnames) {
92
+ if (!hostname)
93
+ return false;
94
+ if (LOCAL_HOSTNAMES.has(hostname))
95
+ return true;
96
+ return enabled && lanHostnames.includes(hostname);
97
+ }
98
+ /**
99
+ * Guards against DNS rebinding: a page on `evil.com` whose domain re-resolves
100
+ * to 127.0.0.1 reaches this server with `Host: evil.com`, and the browser then
101
+ * treats every response as same-origin. Only the names by which the machine is
102
+ * legitimately reachable are accepted — its own loopback names, plus its LAN
103
+ * addresses once network access is enabled.
104
+ *
105
+ * Behind a reverse proxy the Host is whatever domain the operator chose. When
106
+ * they declare it via `KOBO_NETWORK_ACCESS_PROXY_HOST` it is enforced like any
107
+ * other name; without it we cannot tell their domain from an attacker's, so
108
+ * anything is accepted. That mode also turns off the loopback exemption, so
109
+ * every `/api/*` request has to carry the token.
110
+ *
111
+ * Residual risk when the hostname is left undeclared: the SPA shell is served
112
+ * outside `/api/*` and therefore behind no token, so a rebinding page can be
113
+ * handed the real interface and ask the user to paste their token into it.
114
+ * Declaring the hostname closes that; otherwise, authenticate at the proxy.
115
+ */
116
+ export function isAllowedRequestHost(params) {
117
+ if (!params.host)
118
+ return false;
119
+ const hostname = headerHostname(params.host);
120
+ if (params.behindProxy) {
121
+ // No declared hostname means we cannot tell the operator's domain from an
122
+ // attacker's, so anything goes — the historical behaviour.
123
+ if (!params.proxyHostname)
124
+ return true;
125
+ return hostname === params.proxyHostname || (hostname !== null && LOCAL_HOSTNAMES.has(hostname));
126
+ }
127
+ return isAllowedHostname(hostname, params.enabled, params.lanHostnames);
128
+ }
129
+ /**
130
+ * Hostname declared via `KOBO_NETWORK_ACCESS_PROXY_HOST`, or null when unset.
131
+ *
132
+ * Behind a reverse proxy we otherwise have to accept any Host, which leaves the
133
+ * door open to a rebinding page being served the real interface and asking the
134
+ * user to paste their token into it. Declaring the domain the proxy serves
135
+ * closes that, and costs one environment variable in the compose file where
136
+ * the other network-access settings already live.
137
+ */
138
+ export function resolveProxyHostname(env = process.env) {
139
+ const raw = env.KOBO_NETWORK_ACCESS_PROXY_HOST?.trim();
140
+ if (!raw)
141
+ return null;
142
+ return headerHostname(raw);
143
+ }
144
+ /**
145
+ * Names the site a request was driven from. Guards two things.
146
+ *
147
+ * The WebSocket upgrade: WebSockets are exempt from the same-origin policy and
148
+ * send no preflight, so without this any page the user visits could open
149
+ * `/ws/terminal/<id>` and drive a real shell on the host.
150
+ *
151
+ * And cross-site writes: a page on another site reaching `http://localhost:3000`
152
+ * sends a perfectly legitimate `Host: localhost:3000` — the browser puts it
153
+ * there — so the Host check cannot see it. Reading the reply is already blocked
154
+ * (we send no CORS headers), but the write would still land, because Hono parses
155
+ * a JSON body whatever the Content-Type: a `text/plain` POST is a CORS simple
156
+ * request and needs no preflight.
157
+ *
158
+ * A missing Origin is allowed. Browsers attach one to every WebSocket handshake
159
+ * and every state-changing request, so its absence means a non-browser caller —
160
+ * curl, or Kōbō's own MCP server calling back into the API. Such a caller on
161
+ * loopback already has the machine, and over the LAN still needs the token.
162
+ *
163
+ * The rule is that the page must have been served by us: its authority has to
164
+ * match the Host this very request carries. That survives a port remap
165
+ * (`docker -p 3001:3000`, `ssh -L`) which a fixed port list would break, while
166
+ * still rejecting a page on another loopback port — including the per-workspace
167
+ * dev servers Kōbō itself spawns, serving whatever code an agent just wrote.
168
+ *
169
+ * Matching the Host is not a defense on its own, since a rebinding page matches
170
+ * it too. The Host check above is what rejects that one; this must simply not
171
+ * undo it.
172
+ */
173
+ export function isAllowedOrigin(params) {
174
+ if (params.origin === undefined)
175
+ return true;
176
+ const origin = authority(params.origin);
177
+ if (origin === null)
178
+ return false;
179
+ if (params.behindProxy) {
180
+ if (!params.proxyHostname)
181
+ return true;
182
+ return headerHostname(params.origin) === params.proxyHostname;
183
+ }
184
+ // `quasar dev` proxies to the backend with changeOrigin, so it rewrites the
185
+ // Host to ours while the browser stays on the Quasar port. The two cannot
186
+ // match there, hence the explicit declaration.
187
+ if (params.devOrigin && origin === authority(params.devOrigin))
188
+ return true;
189
+ return params.requestHost !== undefined && origin === authority(params.requestHost);
190
+ }
191
+ /** Host and port of a raw `Host`/`Origin` value, or null when unparseable. */
192
+ function authority(value) {
193
+ try {
194
+ return new URL(value.includes('://') ? value : `http://${value}`).host || null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ /**
201
+ * Origin the development client is served from, declared by the `dev` script
202
+ * via `KOBO_DEV_CLIENT_ORIGIN`. Never set in a published build, so it cannot
203
+ * widen anything in production.
204
+ */
205
+ export function resolveDevClientOrigin(env = process.env) {
206
+ const raw = env.KOBO_DEV_CLIENT_ORIGIN?.trim();
207
+ return raw ? raw : null;
27
208
  }
28
209
  /** ~32-char url-safe random token. */
29
210
  export function generateToken() {
@@ -1,10 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import { fetchSourceBranchAsync, isGitWorktree } from '../utils/git-ops.js';
3
3
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
4
+ import { hasController } from './agent/orchestrator.js';
4
5
  import { stopDevServer } from './dev-server-service.js';
5
6
  import { getForgeProvider } from './forge/registry.js';
6
7
  import { resolveForge } from './forge/resolve.js';
7
8
  import { computeGitStats } from './git-stats-service.js';
9
+ import * as lifecycleHookService from './lifecycle-hook-service.js';
8
10
  import { getGlobalSettings } from './settings-service.js';
9
11
  import { destroyTerminal } from './terminal-service.js';
10
12
  import { emitEphemeral } from './websocket-service.js';
@@ -21,6 +23,8 @@ import { purgeWorktree } from './worktree-purge-service.js';
21
23
  const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
22
24
  const WORKSPACE_CHECK_CONCURRENCY = 4;
23
25
  let timer = null;
26
+ /** Latched by `stopPrWatcher` so a tick already in flight does not re-arm. */
27
+ let stopped = false;
24
28
  let checking = false;
25
29
  async function runBounded(items, concurrency, worker) {
26
30
  let nextIndex = 0;
@@ -186,16 +190,28 @@ export async function checkPrStatuses() {
186
190
  // Archive on a transition FROM OPEN to CLOSED/MERGED. Skips the
187
191
  // base-change detection below — archiving wins.
188
192
  if (prev?.state === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
193
+ // Started here, awaited just before the purge below: the worktree is
194
+ // still on disk at this point, and a hook that deploys or tags from it
195
+ // must not have the ground removed under it by auto-purge.
196
+ let prMergedHook = Promise.resolve();
189
197
  if (pr.state === 'MERGED') {
190
198
  emitEphemeral(ws.id, 'pr:merged', {
191
199
  prNumber: pr.number,
192
200
  prUrl: pr.url,
193
201
  });
202
+ prMergedHook = lifecycleHookService.onPrMerged(ws.id, { prNumber: pr.number, prUrl: pr.url });
194
203
  }
195
- if (['extracting', 'brainstorming', 'executing'].includes(ws.status)) {
204
+ if (['extracting', 'brainstorming', 'executing'].includes(ws.status) || hasController(ws.id)) {
196
205
  // Agent is working — update the cache but skip auto-archive.
197
206
  // (The defensive base preservation from the no-base branch doesn't apply here
198
207
  // because we ARE in the OPEN→MERGED/CLOSED branch which always has a base.)
208
+ //
209
+ // The status list alone is not enough: `awaiting-user` is a live
210
+ // session parked on a tool approval, and a controller can still be
211
+ // draining while the status already reads idle or error. Unlike the
212
+ // manual archive route and purge-worktree, this path never stops the
213
+ // agent, so archiving under one orphans its pending question and
214
+ // leaves it writing into a workspace the UI now shows as archived.
199
215
  lastKnownPr.set(ws.id, pr);
200
216
  return;
201
217
  }
@@ -226,6 +242,9 @@ export async function checkPrStatuses() {
226
242
  try {
227
243
  const { autoPurgeOnPrMerged } = getGlobalSettings();
228
244
  if (autoPurgeOnPrMerged) {
245
+ // `onPrMerged` never rejects and is capped by the script-runner's
246
+ // own timeout, so this cannot hang the watcher indefinitely.
247
+ await prMergedHook;
229
248
  void purgeWorktree(ws.id)
230
249
  .then((result) => {
231
250
  // Auto-purge runs with nobody watching: without this trace an
@@ -360,6 +379,11 @@ async function runOneCheck() {
360
379
  function scheduleNext() {
361
380
  timer = setTimeout(async () => {
362
381
  await runOneCheck();
382
+ // A stop can land while the tick above is awaiting a `gh` call, which takes
383
+ // seconds. Without this the loop re-arms itself after shutdown was asked
384
+ // for, and `startPrWatcher`'s `if (timer) return` then sees a live timer.
385
+ if (stopped)
386
+ return;
363
387
  scheduleNext();
364
388
  }, POLL_INTERVAL_MS);
365
389
  timer.unref?.();
@@ -368,6 +392,7 @@ function scheduleNext() {
368
392
  export function startPrWatcher() {
369
393
  if (timer)
370
394
  return;
395
+ stopped = false;
371
396
  // Kick off an immediate check so the front-end has fresh PR data on boot
372
397
  // without waiting for the first 30s tick. Fire-and-forget; the recurring
373
398
  // loop is scheduled independently and the `checking` guard prevents overlap.
@@ -376,6 +401,7 @@ export function startPrWatcher() {
376
401
  }
377
402
  /** Stop the PR watcher polling loop. */
378
403
  export function stopPrWatcher() {
404
+ stopped = true;
379
405
  if (timer) {
380
406
  clearTimeout(timer);
381
407
  timer = null;
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { WORKTREES_PATH } from '../../shared/consts.js';
3
+ import { MASK_CHARACTER, MASKED_SECRET, SECRET_GLOBAL_KEYS, WORKTREES_PATH } from '../../shared/consts.js';
4
4
  import { isValidProjectColor } from '../../shared/project-colors.js';
5
5
  import { isValidSkillSuite } from '../../shared/skill-suite-prompts.js';
6
6
  import { DEFAULT_WHIP_SHORTCUT, isValidWhipShortcut } from '../../shared/whip-shortcut.js';
@@ -853,6 +853,36 @@ const settingsMigrations = [
853
853
  }
854
854
  },
855
855
  },
856
+ {
857
+ version: 55,
858
+ name: 'add-lifecycle-hook-scripts',
859
+ migrate: ({ global, projects }) => {
860
+ // Seeded empty everywhere: a hook runs arbitrary shell on the user's
861
+ // machine, so it only ever exists because the user wrote it.
862
+ for (const key of ['sessionEndedScript', 'prMergedScript', 'autoLoopDisabledScript']) {
863
+ if (typeof global[key] !== 'string')
864
+ global[key] = '';
865
+ for (const p of projects) {
866
+ if (typeof p[key] !== 'string')
867
+ p[key] = '';
868
+ }
869
+ }
870
+ },
871
+ },
872
+ {
873
+ version: 56,
874
+ name: 'add-awaiting-user-reminder',
875
+ migrate: ({ global }) => {
876
+ // Seeded off. Same reasoning as the retention window: a feature that
877
+ // spends the user's attention without being asked is not a default.
878
+ if (typeof global.awaitingUserReminderMinutes !== 'number' ||
879
+ !Number.isInteger(global.awaitingUserReminderMinutes) ||
880
+ global.awaitingUserReminderMinutes < 0 ||
881
+ global.awaitingUserReminderMinutes > 1440) {
882
+ global.awaitingUserReminderMinutes = 0;
883
+ }
884
+ },
885
+ },
856
886
  ];
857
887
  /** Current settings schema version — always equals the highest migration version. */
858
888
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -875,6 +905,8 @@ function ensureSettingsPathInitialized() {
875
905
  /** Override the settings file path (used by tests). */
876
906
  export function _setSettingsPath(p) {
877
907
  settingsFilePath = p;
908
+ // Pointing at a different file must not serve the previous one's contents.
909
+ invalidateSettingsCache();
878
910
  }
879
911
  function defaultSettings() {
880
912
  return {
@@ -895,11 +927,16 @@ function defaultSettings() {
895
927
  cleanupScriptOnlyOnChanges: false,
896
928
  archiveScript: '',
897
929
  changeSourceBranchScript: DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT,
930
+ sessionEndedScript: '',
931
+ prMergedScript: '',
932
+ autoLoopDisabledScript: '',
898
933
  editorCommand: '',
899
934
  fileManagerCommand: '',
900
935
  terminalCommand: '',
901
936
  autoPurgeOnPrMerged: false,
902
937
  autoLoopMaxRetries: 5,
938
+ awaitingUserReminderMinutes: 0,
939
+ maxConcurrentAgents: 0,
903
940
  wsEventsRetentionDays: 0,
904
941
  wsEventsKeepPerWorkspace: 0,
905
942
  networkAccessEnabled: false,
@@ -998,6 +1035,9 @@ function defaultProjectSettings(projectPath) {
998
1035
  cleanupScriptMode: '',
999
1036
  archiveScript: '',
1000
1037
  changeSourceBranchScript: '',
1038
+ sessionEndedScript: '',
1039
+ prMergedScript: '',
1040
+ autoLoopDisabledScript: '',
1001
1041
  devServer: {
1002
1042
  startCommand: '',
1003
1043
  stopCommand: '',
@@ -1043,7 +1083,47 @@ export function runSettingsMigrations(raw) {
1043
1083
  current.schemaVersion = version;
1044
1084
  return current;
1045
1085
  }
1086
+ /**
1087
+ * Parsed settings, kept between calls. `getGlobalSettings` runs on every HTTP
1088
+ * request through the auth middleware and 50-odd other call sites, while
1089
+ * `readSettings` below does existsSync + readFileSync + JSON.parse + the whole
1090
+ * migration chain — all synchronous, on the event loop that also drives the
1091
+ * WebSocket streams.
1092
+ *
1093
+ * The file stays hand-editable (CONFIGURATION.md says so), so the cache is
1094
+ * keyed on the file's mtime and size rather than held blindly: an external edit
1095
+ * is picked up on the very next call, and the check costs one stat instead of a
1096
+ * full parse and migration pass.
1097
+ */
1098
+ let settingsCache = null;
1099
+ /** Drop the cached settings, so the next read goes back to disk. */
1100
+ function invalidateSettingsCache() {
1101
+ settingsCache = null;
1102
+ }
1103
+ /** Identity of the settings file on disk, or null when it is unreadable. */
1104
+ function settingsFileStamp() {
1105
+ try {
1106
+ const stat = fs.statSync(settingsFilePath);
1107
+ return { mtimeMs: stat.mtimeMs, size: stat.size };
1108
+ }
1109
+ catch {
1110
+ return null;
1111
+ }
1112
+ }
1046
1113
  function readSettings() {
1114
+ ensureSettingsPathInitialized();
1115
+ const stamp = settingsFileStamp();
1116
+ if (stamp && settingsCache && settingsCache.mtimeMs === stamp.mtimeMs && settingsCache.size === stamp.size) {
1117
+ return settingsCache.value;
1118
+ }
1119
+ const settings = readSettingsFromDisk();
1120
+ // Re-stat: readSettingsFromDisk writes the file when it was missing or
1121
+ // corrupt, so the stamp taken above would be stale.
1122
+ const after = settingsFileStamp();
1123
+ settingsCache = after ? { value: settings, ...after } : null;
1124
+ return settings;
1125
+ }
1126
+ function readSettingsFromDisk() {
1047
1127
  ensureSettingsPathInitialized();
1048
1128
  if (!fs.existsSync(settingsFilePath)) {
1049
1129
  const defaults = defaultSettings();
@@ -1124,6 +1204,8 @@ function createSettingsBackupIfPresent() {
1124
1204
  pruneSettingsBackups(dir);
1125
1205
  }
1126
1206
  function writeSettings(settings, options) {
1207
+ // Our own writes must be visible at once, whatever the TTL says.
1208
+ invalidateSettingsCache();
1127
1209
  ensureSettingsPathInitialized();
1128
1210
  const tmpPath = `${settingsFilePath}.tmp`;
1129
1211
  const dir = path.dirname(settingsFilePath);
@@ -1140,8 +1222,29 @@ function writeSettings(settings, options) {
1140
1222
  export function getSettings() {
1141
1223
  return readSettings();
1142
1224
  }
1143
- /** Keys stripped from exports — secrets that should stay on the machine. */
1144
- const SECRET_GLOBAL_KEYS = ['notionMcpKey', 'sentryMcpKey', 'networkAccessToken', 'bitbucketToken'];
1225
+ /** True for a value made of nothing but mask characters (never a real token). */
1226
+ function isMaskOnly(value) {
1227
+ return typeof value === 'string' && value.length > 0 && [...value].every((c) => c === MASK_CHARACTER);
1228
+ }
1229
+ /**
1230
+ * Copy of the global settings safe to hand to a client: every stored credential
1231
+ * becomes `MASKED_SECRET`, an unset one stays empty so the UI can tell "not
1232
+ * configured" from "configured, hidden". The caller's object is left alone.
1233
+ *
1234
+ * The credentials are of no use to the settings screen — it only ever shows
1235
+ * them in a password field — but they are very useful to anything else that
1236
+ * gets to read a response: a paired device on the LAN, a proxy log, a
1237
+ * screenshot. `updateGlobalSettings` reads the mask back as "keep the stored
1238
+ * value", so a round-trip through the form is lossless.
1239
+ */
1240
+ export function redactGlobalSecrets(global) {
1241
+ const redacted = { ...global };
1242
+ for (const key of SECRET_GLOBAL_KEYS) {
1243
+ if (redacted[key])
1244
+ redacted[key] = MASKED_SECRET;
1245
+ }
1246
+ return redacted;
1247
+ }
1145
1248
  /** Build an export bundle with settings + templates. MCP keys are stripped. */
1146
1249
  export function exportConfigBundle(templates) {
1147
1250
  const settings = readSettings();
@@ -1235,6 +1338,9 @@ export function getEffectiveSettings(projectPath) {
1235
1338
  cleanupScriptOnlyOnChanges: settings.global.cleanupScriptOnlyOnChanges,
1236
1339
  archiveScript: settings.global.archiveScript,
1237
1340
  changeSourceBranchScript: settings.global.changeSourceBranchScript,
1341
+ sessionEndedScript: settings.global.sessionEndedScript,
1342
+ prMergedScript: settings.global.prMergedScript,
1343
+ autoLoopDisabledScript: settings.global.autoLoopDisabledScript,
1238
1344
  notionStatusProperty: settings.global.notionStatusProperty,
1239
1345
  notionInProgressStatus: settings.global.notionInProgressStatus,
1240
1346
  };
@@ -1262,13 +1368,32 @@ export function getEffectiveSettings(projectPath) {
1262
1368
  cleanupScriptOnlyOnChanges: settings.global.cleanupScriptOnlyOnChanges,
1263
1369
  archiveScript: project.archiveScript || settings.global.archiveScript,
1264
1370
  changeSourceBranchScript: project.changeSourceBranchScript || settings.global.changeSourceBranchScript,
1371
+ sessionEndedScript: project.sessionEndedScript || settings.global.sessionEndedScript,
1372
+ prMergedScript: project.prMergedScript || settings.global.prMergedScript,
1373
+ autoLoopDisabledScript: project.autoLoopDisabledScript || settings.global.autoLoopDisabledScript,
1265
1374
  notionStatusProperty: settings.global.notionStatusProperty,
1266
1375
  notionInProgressStatus: settings.global.notionInProgressStatus,
1267
1376
  };
1268
1377
  }
1269
1378
  /** Merge partial updates into global settings and persist. */
1270
- export function updateGlobalSettings(data) {
1379
+ export function updateGlobalSettings(input) {
1271
1380
  const settings = readSettings();
1381
+ // Work on a copy: the validation below drops keys, and mutating the caller's
1382
+ // object would be a surprise (the route hands us its parsed request body).
1383
+ const data = { ...input };
1384
+ // A client only ever sees the mask in place of a stored credential, so it
1385
+ // hands the mask back on every save. Dropping the key preserves the stored
1386
+ // value (same "drop it and keep the previous one" pattern as below), while a
1387
+ // real value still overwrites and an empty one still clears.
1388
+ //
1389
+ // Any run of mask characters counts, not just the exact sentinel: a stray
1390
+ // Backspace in the field leaves a shorter run, and writing that as the new
1391
+ // credential would break the integration silently, long after the fact. No
1392
+ // real token is made of nothing but bullets.
1393
+ for (const key of SECRET_GLOBAL_KEYS) {
1394
+ if (isMaskOnly(data[key]))
1395
+ delete data[key];
1396
+ }
1272
1397
  // Validate skillSuite before merging: drop invalid values so the previous
1273
1398
  // value is preserved (same pattern as `upsertProject`'s color validation).
1274
1399
  if ('skillSuite' in data) {
@@ -1293,6 +1418,16 @@ export function updateGlobalSettings(data) {
1293
1418
  delete data.autoLoopMaxRetries;
1294
1419
  }
1295
1420
  }
1421
+ // 0 is a legitimate value here — it means "never remind me" — so the floor is
1422
+ // 0, not 1. The ceiling of a day keeps a typo from arming a reminder that
1423
+ // never fires and looks like a broken feature.
1424
+ if ('awaitingUserReminderMinutes' in data) {
1425
+ const value = data.awaitingUserReminderMinutes;
1426
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 1440) {
1427
+ console.warn(`[settings] Invalid awaitingUserReminderMinutes value rejected: ${value}`);
1428
+ delete data.awaitingUserReminderMinutes;
1429
+ }
1430
+ }
1296
1431
  // Same shape as the autoLoopMaxRetries guard above: an invalid value is
1297
1432
  // dropped so the stored one survives. Retention deletes history — a bad write
1298
1433
  // here would delete more than the user ever asked for. `0` is NOT invalid:
@@ -1323,11 +1458,16 @@ export function updateGlobalSettings(data) {
1323
1458
  'cleanupScriptOnlyOnChanges',
1324
1459
  'archiveScript',
1325
1460
  'changeSourceBranchScript',
1461
+ 'sessionEndedScript',
1462
+ 'prMergedScript',
1463
+ 'autoLoopDisabledScript',
1326
1464
  'editorCommand',
1327
1465
  'fileManagerCommand',
1328
1466
  'terminalCommand',
1329
1467
  'autoPurgeOnPrMerged',
1330
1468
  'autoLoopMaxRetries',
1469
+ 'awaitingUserReminderMinutes',
1470
+ 'maxConcurrentAgents',
1331
1471
  'wsEventsRetentionDays',
1332
1472
  'wsEventsKeepPerWorkspace',
1333
1473
  'browserNotifications',
@@ -1532,6 +1672,9 @@ export function upsertProject(projectPath, data) {
1532
1672
  'cleanupScriptMode',
1533
1673
  'archiveScript',
1534
1674
  'changeSourceBranchScript',
1675
+ 'sessionEndedScript',
1676
+ 'prMergedScript',
1677
+ 'autoLoopDisabledScript',
1535
1678
  'devServer',
1536
1679
  'e2e',
1537
1680
  'finalization',
@@ -0,0 +1,81 @@
1
+ function median(values) {
2
+ if (values.length === 0)
3
+ return null;
4
+ const sorted = [...values].sort((a, b) => a - b);
5
+ const mid = Math.floor(sorted.length / 2);
6
+ return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid];
7
+ }
8
+ /**
9
+ * Outcome of every ended session, grouped by engine and model.
10
+ *
11
+ * You pick a model when creating a workspace with nothing to go on. This says
12
+ * how each one actually behaved on *your* code: how often it finished by
13
+ * itself, how often the watchdog had to end it, how long it typically ran.
14
+ *
15
+ * Median rather than mean: one session left open overnight would drag an
16
+ * average far from anything representative.
17
+ */
18
+ export function computeEngineReliability(db) {
19
+ const rows = db
20
+ .prepare(`SELECT engine, model, end_reason, started_at, ended_at
21
+ FROM agent_sessions
22
+ WHERE ended_at IS NOT NULL`)
23
+ .all();
24
+ const groups = new Map();
25
+ for (const session of rows) {
26
+ const engine = session.engine ?? 'unknown';
27
+ const model = session.model ?? 'unknown';
28
+ const key = `${engine}\u0000${model}`;
29
+ let group = groups.get(key);
30
+ if (!group) {
31
+ group = {
32
+ row: {
33
+ engine,
34
+ model,
35
+ total: 0,
36
+ completed: 0,
37
+ error: 0,
38
+ killed: 0,
39
+ watchdog: 0,
40
+ unknown: 0,
41
+ completedRatio: 0,
42
+ medianDurationMs: null,
43
+ },
44
+ durations: [],
45
+ };
46
+ groups.set(key, group);
47
+ }
48
+ group.row.total += 1;
49
+ switch (session.end_reason) {
50
+ case 'completed':
51
+ group.row.completed += 1;
52
+ break;
53
+ case 'error':
54
+ group.row.error += 1;
55
+ break;
56
+ case 'killed':
57
+ group.row.killed += 1;
58
+ break;
59
+ case 'watchdog':
60
+ group.row.watchdog += 1;
61
+ break;
62
+ default:
63
+ // Predates migration 39, or an unrecognised value: counted, never guessed.
64
+ group.row.unknown += 1;
65
+ }
66
+ const started = Date.parse(session.started_at);
67
+ const ended = session.ended_at ? Date.parse(session.ended_at) : Number.NaN;
68
+ if (!Number.isNaN(started) && !Number.isNaN(ended) && ended >= started) {
69
+ group.durations.push(ended - started);
70
+ }
71
+ }
72
+ return ([...groups.values()]
73
+ .map(({ row, durations }) => ({
74
+ ...row,
75
+ completedRatio: row.total > 0 ? row.completed / row.total : 0,
76
+ medianDurationMs: median(durations),
77
+ }))
78
+ // Ties broken by name: SQLite's read order is rowid order, which is not a
79
+ // guarantee, and a table that reorders itself between refreshes is noise.
80
+ .sort((a, b) => b.total - a.total || a.engine.localeCompare(b.engine) || a.model.localeCompare(b.model)));
81
+ }