@loicngr/kobo 2.1.26 → 2.1.29

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 (178) hide show
  1. package/AGENTS.md +7 -4
  2. package/CHANGELOG.md +15 -0
  3. package/CONFIGURATION.md +124 -71
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +1 -1
  6. package/SECURITY.md +33 -6
  7. package/dist/mcp-server/kobo-tasks-server.js +36 -1021
  8. package/dist/mcp-server/managed-mcp-dispatcher.js +960 -0
  9. package/dist/mcp-server/workspace-dialogue-client.js +26 -1
  10. package/dist/server/index.js +52 -19
  11. package/dist/server/middleware/host-check-middleware.js +8 -5
  12. package/dist/server/middleware/network-auth-middleware.js +51 -29
  13. package/dist/server/middleware/security-headers-middleware.js +19 -0
  14. package/dist/server/middleware/traffic-limits-middleware.js +74 -0
  15. package/dist/server/routes/auth.js +73 -0
  16. package/dist/server/routes/internal-mcp.js +137 -0
  17. package/dist/server/routes/mcp-credentials.js +30 -0
  18. package/dist/server/routes/mcp.js +156 -76
  19. package/dist/server/routes/workspaces.js +19 -3
  20. package/dist/server/services/agent/engines/claude-code/resolve-binary.js +1 -1
  21. package/dist/server/services/agent/orchestrator.js +27 -17
  22. package/dist/server/services/dev-server-service.js +68 -4
  23. package/dist/server/services/environment-check-service.js +3 -2
  24. package/dist/server/services/external-mcp-credentials-service.js +110 -0
  25. package/dist/server/services/forge/github/provider.js +15 -3
  26. package/dist/server/services/forge/types.js +8 -0
  27. package/dist/server/services/internal-mcp-capability-service.js +98 -0
  28. package/dist/server/services/mcp-connection-info-service.js +1 -1
  29. package/dist/server/services/memory-mcp-service.js +9 -7
  30. package/dist/server/services/memory-service.js +15 -0
  31. package/dist/server/services/network-access-service.js +43 -73
  32. package/dist/server/services/network-session-service.js +182 -0
  33. package/dist/server/services/settings-service.js +29 -7
  34. package/dist/server/services/terminal-websocket-service.js +5 -0
  35. package/dist/server/services/websocket-service.js +5 -0
  36. package/dist/server/utils/paths.js +2 -1
  37. package/dist/server/utils/startup-display.js +1 -2
  38. package/dist/shared/models.js +6 -0
  39. package/docker-compose.vps.yml +6 -0
  40. package/docs/deployment-vps.md +24 -12
  41. package/docs/getting-started.md +1 -1
  42. package/docs/troubleshooting.md +1 -1
  43. package/package.json +14 -14
  44. package/src/client/dist/pwa/assets/ActivityFeed-CiTe2VUQ.js +2 -0
  45. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-DOr41E2Y.js → AutoLoopFinalReviewSettings-v3jXoH_Q.js} +1 -1
  46. package/src/client/dist/pwa/assets/{ChangelogPage-DOCgZMpn.js → ChangelogPage-C5KjAOMG.js} +1 -1
  47. package/src/client/dist/pwa/assets/{ClosePopup-CRV2hxHS.js → ClosePopup-rU8Bm-mC.js} +1 -1
  48. package/src/client/dist/pwa/assets/ComparisonPanel-XXyzPEzW.js +2 -0
  49. package/src/client/dist/pwa/assets/CreatePage-MYCexRJu.js +2 -0
  50. package/src/client/dist/pwa/assets/{DashboardPage-dBpJaQSb.js → DashboardPage-DyhUu26C.js} +1 -1
  51. package/src/client/dist/pwa/assets/DiffViewer-CvsmC9d5.js +8 -0
  52. package/src/client/dist/pwa/assets/{DocumentsPanel-AqLx0MzB.js → DocumentsPanel-ItndGxeB.js} +1 -1
  53. package/src/client/dist/pwa/assets/{DrawerToggleButton-BLWaFgh0.js → DrawerToggleButton-DfzS_CD8.js} +1 -1
  54. package/src/client/dist/pwa/assets/GitPanel-C1ybi9bZ.js +3 -0
  55. package/src/client/dist/pwa/assets/{GitPanel-CRaLJzjc.css → GitPanel-yHvU38dp.css} +1 -1
  56. package/src/client/dist/pwa/assets/{HealthPage-DeryVLyz.js → HealthPage-C1u35ANL.js} +1 -1
  57. package/src/client/dist/pwa/assets/MainLayout-BEAGPqei.js +2 -0
  58. package/src/client/dist/pwa/assets/{MemoryPanel-B34DENtz.js → MemoryPanel-CZzI7_nY.js} +1 -1
  59. package/src/client/dist/pwa/assets/MemoryProposalList-DqNTycY1.js +1 -0
  60. package/src/client/dist/pwa/assets/{QBadge-CW3Lw-kL.js → QBadge-DiwbRpte.js} +1 -1
  61. package/src/client/dist/pwa/assets/{QBanner-BT2ZemYu.js → QBanner-Cw89HSjk.js} +1 -1
  62. package/src/client/dist/pwa/assets/QBtn-kfhHFy7Q.js +1 -0
  63. package/src/client/dist/pwa/assets/{QBtnDropdown-BPcK_rVs.js → QBtnDropdown-D7ml7zHo.js} +1 -1
  64. package/src/client/dist/pwa/assets/{QBtnGroup-CnM4vSJA.js → QBtnGroup-Bioxjp9q.js} +1 -1
  65. package/src/client/dist/pwa/assets/{QBtnToggle-DccOOtbh.js → QBtnToggle-Bod-x9im.js} +1 -1
  66. package/src/client/dist/pwa/assets/{QChip-Be16M4Ig.js → QChip-DvhtPv_L.js} +1 -1
  67. package/src/client/dist/pwa/assets/{QExpansionItem-CpwQ8L_F.js → QExpansionItem-BVb067oc.js} +1 -1
  68. package/src/client/dist/pwa/assets/{QItemSection-MdJ1_ZK7.js → QItemSection-ahqRK4JF.js} +1 -1
  69. package/src/client/dist/pwa/assets/{QList-Cx1UfaUm.js → QList-CAUl0v66.js} +1 -1
  70. package/src/client/dist/pwa/assets/{QMarkupTable-UhP7fVUp.js → QMarkupTable-B4LLAly-.js} +1 -1
  71. package/src/client/dist/pwa/assets/{QPage-CqkGHokx.js → QPage-DIy_mRoT.js} +1 -1
  72. package/src/client/dist/pwa/assets/{QScrollArea-BkNSVl7s.js → QScrollArea-CUDWkEHh.js} +1 -1
  73. package/src/client/dist/pwa/assets/QSelect-DOnv_ry0.js +1 -0
  74. package/src/client/dist/pwa/assets/{QSpinnerDots-xdWyaE1i.js → QSpinnerDots-Bea6hAuJ.js} +1 -1
  75. package/src/client/dist/pwa/assets/{QTooltip-Dp8fitHV.js → QTooltip-B5QJIU3p.js} +1 -1
  76. package/src/client/dist/pwa/assets/{SchedulePanel-Ck8LKDCa.js → SchedulePanel-IJOcrHgk.js} +1 -1
  77. package/src/client/dist/pwa/assets/{SearchPage-BAUZg1zq.js → SearchPage-BpTRWtne.js} +1 -1
  78. package/src/client/dist/pwa/assets/{SettingsPage-Dcq1Jeoo.css → SettingsPage-B4rmd2WX.css} +1 -1
  79. package/src/client/dist/pwa/assets/SettingsPage-CFaRN9Pk.js +9 -0
  80. package/src/client/dist/pwa/assets/{SplitWorkspacePage-CH8OHmtn.js → SplitWorkspacePage-BZ8wN4ii.js} +1 -1
  81. package/src/client/dist/pwa/assets/{TerminalPanel-Dz9EE-Ph.css → TerminalPanel-BcbwuFVR.css} +1 -1
  82. package/src/client/dist/pwa/assets/TerminalPanel-pY-RGBb6.js +16 -0
  83. package/src/client/dist/pwa/assets/{ToolCallItem-CLtGTV6w.js → ToolCallItem-C9ksVseJ.js} +4 -4
  84. package/src/client/dist/pwa/assets/{TouchPan-9SH2dx6m.js → TouchPan-DRynDIve.js} +1 -1
  85. package/src/client/dist/pwa/assets/{TourReplayButton-Dw4sGf9B.js → TourReplayButton-D4h1QR6C.js} +1 -1
  86. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-Bkj2DhOn.js +1 -0
  87. package/src/client/dist/pwa/assets/WorkspacePage-BTJnap9a.js +4 -0
  88. package/src/client/dist/pwa/assets/{browser-CC4W8r76.js → browser-BcOi2ES2.js} +2 -2
  89. package/src/client/dist/pwa/assets/build-path-tree-mDco3pi7.js +1 -0
  90. package/src/client/dist/pwa/assets/custom-sounds-T5HKzrWh.js +1 -0
  91. package/src/client/dist/pwa/assets/de-suqWUSXa.js +17 -0
  92. package/src/client/dist/pwa/assets/detached-fullscreen-CHqMxB-8.js +1 -0
  93. package/src/client/dist/pwa/assets/dom-CRPVbmiy.js +2 -0
  94. package/src/client/dist/pwa/assets/ecl-94qehhXe.js +1 -0
  95. package/src/client/dist/pwa/assets/{editor.api-BwfxvQ88.js → editor.api-CRrihXg2.js} +9 -9
  96. package/src/client/dist/pwa/assets/editor.worker-xrHNhPSq.js +33 -0
  97. package/src/client/dist/pwa/assets/{efforts-B3I7w5bs.js → efforts-C-p9DzkL.js} +2 -2
  98. package/src/client/dist/pwa/assets/es-Dcptbz5U.js +17 -0
  99. package/src/client/dist/pwa/assets/expand-template-gIwrPbeS.js +1 -0
  100. package/src/client/dist/pwa/assets/{format-CJIzi3Vf.js → format-DeBnwmgW.js} +48 -48
  101. package/src/client/dist/pwa/assets/{formatters-DQ8cP7nO.js → formatters-CxZZ_WTb.js} +1 -1
  102. package/src/client/dist/pwa/assets/fr-Db70bqVq.js +17 -0
  103. package/src/client/dist/pwa/assets/{freemarker2-DbpVO-cL.js → freemarker2-h7OLlqTn.js} +1 -1
  104. package/src/client/dist/pwa/assets/{handlebars-BHve0PpA.js → handlebars-BCWmi1-q.js} +1 -1
  105. package/src/client/dist/pwa/assets/{html-BhUkdFPz.js → html-MUMpfUBd.js} +1 -1
  106. package/src/client/dist/pwa/assets/i18n-DLprfNLs.js +1 -0
  107. package/src/client/dist/pwa/assets/index-C0MApoXN.css +1 -0
  108. package/src/client/dist/pwa/assets/index-DLoUeovT.js +20 -0
  109. package/src/client/dist/pwa/assets/it-Cloo58Nn.js +17 -0
  110. package/src/client/dist/pwa/assets/{javascript-BLAorPa3.js → javascript-CHDwijEy.js} +1 -1
  111. package/src/client/dist/pwa/assets/{liquid-CJGF-h-f.js → liquid-dFrnLevt.js} +1 -1
  112. package/src/client/dist/pwa/assets/{mdx-Cm0oNz_l.js → mdx-BHS7JVD4.js} +1 -1
  113. package/src/client/dist/pwa/assets/monaco-contributions-DCWZe7nX.js +53 -0
  114. package/src/client/dist/pwa/assets/{monaco.contribution-hr9s26Ud.js → monaco.contribution-Dx0gcAn4.js} +2 -2
  115. package/src/client/dist/pwa/assets/network-auth-DDMAzwYH.js +1 -0
  116. package/src/client/dist/pwa/assets/{notify-theme-EcbxuH_8.js → notify-theme-JuVT96rq.js} +1 -1
  117. package/src/client/dist/pwa/assets/{permissionModes-DLWiBfdb.js → permissionModes-D-Nexnoo.js} +1 -1
  118. package/src/client/dist/pwa/assets/{private.use-form-DY1BkePL.js → private.use-form-BcspuzM9.js} +1 -1
  119. package/src/client/dist/pwa/assets/{project-color-vDuTaTJn.js → project-color-DihsE4yN.js} +1 -1
  120. package/src/client/dist/pwa/assets/{python-BEjbzFAQ.js → python-BtjZnK9y.js} +1 -1
  121. package/src/client/dist/pwa/assets/{razor-BJPT7Wxx.js → razor-DBcOGz0a.js} +1 -1
  122. package/src/client/dist/pwa/assets/reactivity.esm-bundler-Dplx1ILy.js +1 -0
  123. package/src/client/dist/pwa/assets/render-chat-markdown-C9ihAYBt.js +67 -0
  124. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-BAUgNzMK.js +1 -0
  125. package/src/client/dist/pwa/assets/{typescript-DPqM46Ef.js → typescript-B320c7wU.js} +1 -1
  126. package/src/client/dist/pwa/assets/{use-checkbox-v4aAbE6K.js → use-checkbox-DUxgXR4e.js} +1 -1
  127. package/src/client/dist/pwa/assets/{use-id-BrdYXce0.js → use-id-B7ieAx2m.js} +1 -1
  128. package/src/client/dist/pwa/assets/use-is-mobile-CHmaewI1.js +1 -0
  129. package/src/client/dist/pwa/assets/{use-search-index-status-BwwrB410.js → use-search-index-status-B95GPaJP.js} +1 -1
  130. package/src/client/dist/pwa/assets/use-slide-transition-BJh9xGMa.js +1 -0
  131. package/src/client/dist/pwa/assets/use-tours-CU5oA7cz.css +1 -0
  132. package/src/client/dist/pwa/assets/use-tours-C_5R1Y5D.js +2 -0
  133. package/src/client/dist/pwa/assets/{use-worktree-restore-tzd2FESU.js → use-worktree-restore-Cj8eGE9s.js} +1 -1
  134. package/src/client/dist/pwa/assets/{workspace-templates-CubzsZUU.js → workspace-templates-B9UETKER.js} +1 -1
  135. package/src/client/dist/pwa/assets/{xml-C6SVzw5_.js → xml-uK8LK4OB.js} +1 -1
  136. package/src/client/dist/pwa/assets/{yaml-OpCqnv4g.js → yaml-B2D1l_tz.js} +1 -1
  137. package/src/client/dist/pwa/index.html +10 -10
  138. package/src/client/dist/pwa/sw.js +1 -1
  139. package/src/mcp-server/README.md +13 -19
  140. package/src/mcp-server/kobo-tasks-server.ts +42 -1146
  141. package/src/mcp-server/managed-mcp-dispatcher.ts +1071 -0
  142. package/src/mcp-server/workspace-dialogue-client.ts +26 -1
  143. package/src/client/dist/pwa/assets/ActivityFeed-DfYrVNEh.js +0 -2
  144. package/src/client/dist/pwa/assets/ComparisonPanel-b5nTBjK6.js +0 -2
  145. package/src/client/dist/pwa/assets/CreatePage-B7VPHEqm.js +0 -2
  146. package/src/client/dist/pwa/assets/DiffViewer-BLQz2Qyx.js +0 -8
  147. package/src/client/dist/pwa/assets/GitPanel-Ba7ltCsM.js +0 -3
  148. package/src/client/dist/pwa/assets/MainLayout-B2XvjdWi.js +0 -2
  149. package/src/client/dist/pwa/assets/MemoryProposalList-BpN_OU8J.js +0 -1
  150. package/src/client/dist/pwa/assets/QBtn-BUSdPaf4.js +0 -1
  151. package/src/client/dist/pwa/assets/QSelect-DrLpbvcr.js +0 -1
  152. package/src/client/dist/pwa/assets/SettingsPage-Cj6kSVT5.js +0 -9
  153. package/src/client/dist/pwa/assets/TerminalPanel-CBmZ0IzQ.js +0 -16
  154. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-wd_sObJa.js +0 -1
  155. package/src/client/dist/pwa/assets/WorkspacePage-D4ODPOG-.js +0 -4
  156. package/src/client/dist/pwa/assets/build-path-tree-BBuyB-my.js +0 -1
  157. package/src/client/dist/pwa/assets/custom-sounds-BbPNRzAs.js +0 -1
  158. package/src/client/dist/pwa/assets/de-D9VzPDGF.js +0 -17
  159. package/src/client/dist/pwa/assets/detached-fullscreen-Du3TcYdX.js +0 -1
  160. package/src/client/dist/pwa/assets/dom-BtYTvLeN.js +0 -2
  161. package/src/client/dist/pwa/assets/ecl-D05T4iGw.js +0 -1
  162. package/src/client/dist/pwa/assets/editor.worker-BLJF_oQx.js +0 -33
  163. package/src/client/dist/pwa/assets/es-3R4MJuTt.js +0 -17
  164. package/src/client/dist/pwa/assets/expand-template-Ci9UjnxZ.js +0 -1
  165. package/src/client/dist/pwa/assets/fr-DXwDVnJ4.js +0 -17
  166. package/src/client/dist/pwa/assets/i18n-CGLQAADf.js +0 -1
  167. package/src/client/dist/pwa/assets/index-C8zAZ-k-.css +0 -1
  168. package/src/client/dist/pwa/assets/index-CNcXBpzz.js +0 -20
  169. package/src/client/dist/pwa/assets/it-T9AjnG42.js +0 -17
  170. package/src/client/dist/pwa/assets/monaco-contributions-nBg6wf05.js +0 -53
  171. package/src/client/dist/pwa/assets/network-auth-C850jGfI.js +0 -1
  172. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DY1JkAEA.js +0 -1
  173. package/src/client/dist/pwa/assets/render-chat-markdown-CafzCcmu.js +0 -69
  174. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-Bqg71Vll.js +0 -1
  175. package/src/client/dist/pwa/assets/use-is-mobile-CDB_rzM9.js +0 -1
  176. package/src/client/dist/pwa/assets/use-slide-transition-B_Nu6u-t.js +0 -1
  177. package/src/client/dist/pwa/assets/use-tours-B0KtCS9w.js +0 -2
  178. package/src/client/dist/pwa/assets/use-tours-CTZuBZOi.css +0 -1
@@ -0,0 +1,110 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { EXTERNAL_MEMORY_TOOL_DEFINITIONS } from '../../shared/memory-tools.js';
5
+ import { PROGRESS_BAR_TOOLS } from '../../shared/progress-bar-tools.js';
6
+ import { WORKSPACE_CREATION_TOOLS } from '../../shared/workspace-creation-tools.js';
7
+ import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
8
+ import { WORKSPACE_GROUP_MESSAGE_TOOLS } from '../../shared/workspace-group-message-tools.js';
9
+ import { WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
10
+ import { ensureKoboHome, getKoboHome } from '../utils/paths.js';
11
+ export const EXTERNAL_TOOL_CATALOGUE = [
12
+ ...EXTERNAL_MCP_TOOLS,
13
+ ...WORKSPACE_CREATION_TOOLS,
14
+ ...WORKSPACE_LIFECYCLE_TOOLS,
15
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS,
16
+ ...EXTERNAL_MEMORY_TOOL_DEFINITIONS,
17
+ ...PROGRESS_BAR_TOOLS,
18
+ ];
19
+ const MAX_KEYS = 100;
20
+ const filename = () => path.join(getKoboHome(), 'mcp-credentials.json');
21
+ function load() {
22
+ try {
23
+ const data = JSON.parse(fs.readFileSync(filename(), 'utf8'));
24
+ if (!Array.isArray(data) || data.length > MAX_KEYS)
25
+ throw new Error('Invalid MCP credential store');
26
+ return data;
27
+ }
28
+ catch (error) {
29
+ if (error.code === 'ENOENT')
30
+ return [];
31
+ throw error;
32
+ }
33
+ }
34
+ function save(keys) {
35
+ ensureKoboHome();
36
+ const tmp = `${filename()}.${randomUUID()}.tmp`;
37
+ try {
38
+ fs.writeFileSync(tmp, JSON.stringify(keys), { mode: 0o600, flag: 'wx' });
39
+ fs.renameSync(tmp, filename());
40
+ }
41
+ finally {
42
+ if (fs.existsSync(tmp))
43
+ fs.unlinkSync(tmp);
44
+ }
45
+ }
46
+ function publicCredential({ hash: _hash, ...credential }) {
47
+ return credential;
48
+ }
49
+ export function listExternalMcpCredentials() {
50
+ return load().map(publicCredential);
51
+ }
52
+ export function createExternalMcpCredential(input) {
53
+ if (!input || typeof input !== 'object' || Array.isArray(input))
54
+ throw new Error('Invalid credential request');
55
+ const args = input;
56
+ if (Object.keys(args).some((key) => !['name', 'tools', 'allWorkspaces', 'workspaceIds', 'expiresAt'].includes(key)))
57
+ throw new Error('Unknown credential field');
58
+ if (typeof args.name !== 'string' || !args.name.trim() || args.name.length > 120)
59
+ throw new Error('Name must contain 1–120 characters');
60
+ if (args.allWorkspaces !== undefined && typeof args.allWorkspaces !== 'boolean')
61
+ throw new Error('Invalid workspace scope');
62
+ const allWorkspaces = args.allWorkspaces === true;
63
+ const workspaceIds = args.workspaceIds ?? [];
64
+ if (!Array.isArray(workspaceIds) ||
65
+ workspaceIds.length > 200 ||
66
+ workspaceIds.some((id) => typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(id)) ||
67
+ (!allWorkspaces && workspaceIds.length === 0) ||
68
+ (allWorkspaces && workspaceIds.length > 0))
69
+ throw new Error('Select workspace IDs or explicitly grant all workspaces');
70
+ const tools = args.tools ??
71
+ EXTERNAL_TOOL_CATALOGUE.filter((tool) => tool.annotations?.readOnlyHint === true).map((tool) => tool.name);
72
+ if (!Array.isArray(tools) ||
73
+ !tools.length ||
74
+ tools.length > EXTERNAL_TOOL_CATALOGUE.length ||
75
+ tools.some((name) => !EXTERNAL_TOOL_CATALOGUE.some((tool) => tool.name === name)))
76
+ throw new Error('Invalid tool grant');
77
+ let expiresAt = null;
78
+ if (args.expiresAt !== undefined && args.expiresAt !== null) {
79
+ const time = typeof args.expiresAt === 'string' ? Date.parse(args.expiresAt) : NaN;
80
+ if (!Number.isFinite(time) || time <= Date.now() || time > Date.now() + 366 * 86400000)
81
+ throw new Error('Expiry must be within the next 366 days');
82
+ expiresAt = new Date(time).toISOString();
83
+ }
84
+ const keys = load();
85
+ if (keys.length >= MAX_KEYS)
86
+ throw new Error('Revoke an existing MCP key before creating another');
87
+ const token = `kobo_mcp_${randomBytes(32).toString('base64url')}`;
88
+ const key = {
89
+ id: randomUUID(),
90
+ name: args.name.trim(),
91
+ tools: [...new Set(tools)],
92
+ allWorkspaces,
93
+ workspaceIds: [...new Set(workspaceIds)],
94
+ createdAt: new Date().toISOString(),
95
+ expiresAt,
96
+ hash: createHash('sha256').update(token).digest('hex'),
97
+ };
98
+ save([...keys, key]);
99
+ return { token, credential: publicCredential(key) };
100
+ }
101
+ export function revokeExternalMcpCredential(id) {
102
+ save(load().filter((key) => key.id !== id));
103
+ }
104
+ export function authenticateExternalMcpToken(token) {
105
+ if (!/^kobo_mcp_[A-Za-z0-9_-]{43}$/.test(token))
106
+ return null;
107
+ const hash = createHash('sha256').update(token).digest('hex');
108
+ const key = load().find((candidate) => candidate.hash === hash);
109
+ return key && (!key.expiresAt || Date.parse(key.expiresAt) > Date.now()) ? publicCredential(key) : null;
110
+ }
@@ -3,7 +3,7 @@ import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { MERGE_METHODS } from '../../../../shared/forge-merge.js';
5
5
  import { parseCliJson } from '../parse-cli-json.js';
6
- import { deriveReadyToMerge, truncatePrBody } from '../types.js';
6
+ import { deriveReadyToMerge, MergePolicyError, truncatePrBody } from '../types.js';
7
7
  const execFileAsync = promisify(execFile);
8
8
  const CLI_TIMEOUT_MS = 30_000;
9
9
  function cliOptions(repoPath) {
@@ -194,8 +194,20 @@ export const githubProvider = {
194
194
  async changePrBase(repoPath, base) {
195
195
  await execFileAsync('gh', ['pr', 'edit', '--base', base], cliOptions(repoPath));
196
196
  },
197
- async mergeRequest(repoPath, number, method = 'merge') {
198
- await execFileAsync('gh', ['pr', 'merge', String(number), `--${method}`], cliOptions(repoPath));
197
+ async mergeRequest(repoPath, number, method = 'merge', options) {
198
+ const args = ['pr', 'merge', String(number), `--${method}`];
199
+ if (options?.admin === true)
200
+ args.push('--admin');
201
+ try {
202
+ await execFileAsync('gh', args, cliOptions(repoPath));
203
+ }
204
+ catch (error) {
205
+ // Only known policy refusals offer an override, never network/auth/unknown failures.
206
+ const message = error instanceof Error ? error.message : '';
207
+ if (/the base branch policy prohibits the merge/i.test(message))
208
+ throw new MergePolicyError();
209
+ throw error;
210
+ }
199
211
  },
200
212
  async deleteRemoteBranch(repoPath, branch) {
201
213
  await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
@@ -35,3 +35,11 @@ export class ForgeUnavailableError extends Error {
35
35
  this.name = 'ForgeUnavailableError';
36
36
  }
37
37
  }
38
+ /** A policy refusal that can be presented as an explicit administrator choice. */
39
+ export class MergePolicyError extends Error {
40
+ code = 'merge_policy_blocked';
41
+ constructor() {
42
+ super('GitHub branch protection rules prevent this merge.');
43
+ this.name = 'MergePolicyError';
44
+ }
45
+ }
@@ -0,0 +1,98 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { isProgressBarTool } from '../../shared/progress-bar-tools.js';
3
+ import { WORKSPACE_CREATION_TOOLS } from '../../shared/workspace-creation-tools.js';
4
+ import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
5
+ import { WORKSPACE_GROUP_MESSAGE_TOOLS } from '../../shared/workspace-group-message-tools.js';
6
+ import { WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
7
+ const capabilities = new Map();
8
+ const readTools = new Set([
9
+ 'list_tasks',
10
+ 'get_settings',
11
+ 'get_dev_server_status',
12
+ 'get_workspace_info',
13
+ 'cron_list',
14
+ 'get_dev_server_logs',
15
+ 'list_workspace_images',
16
+ 'get_ticket',
17
+ 'get_notion_ticket',
18
+ 'get_git_info',
19
+ 'list_documents',
20
+ 'read_document',
21
+ 'get_session_usage',
22
+ 'read_workspace_events_csv',
23
+ 'search_codebase',
24
+ 'list_workspaces',
25
+ 'list_memory_scopes',
26
+ 'list_memories',
27
+ 'read_memory',
28
+ 'search_memories',
29
+ 'list_memory_operations',
30
+ ...EXTERNAL_MCP_TOOLS.filter((t) => t.annotations?.readOnlyHint).map((t) => t.name),
31
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS.filter((t) => t.annotations?.readOnlyHint).map((t) => t.name),
32
+ ...WORKSPACE_CREATION_TOOLS.filter((t) => t.annotations?.readOnlyHint).map((t) => t.name),
33
+ ]);
34
+ const writeTools = new Set([
35
+ 'mark_task_done',
36
+ 'mark_auto_loop_ready',
37
+ 'create_task',
38
+ 'update_task',
39
+ 'delete_task',
40
+ 'set_auto_loop',
41
+ 'set_workspace_agent_description',
42
+ 'set_workspace_name',
43
+ 'cron_create',
44
+ 'cron_delete',
45
+ 'start_dev_server',
46
+ 'stop_dev_server',
47
+ 'set_workspace_status',
48
+ 'log_thought',
49
+ 'schedule_wakeup',
50
+ 'cancel_wakeup',
51
+ 'stop_workspace',
52
+ 'remember',
53
+ ...EXTERNAL_MCP_TOOLS.map((t) => t.name),
54
+ ...WORKSPACE_CREATION_TOOLS.map((t) => t.name),
55
+ ...WORKSPACE_LIFECYCLE_TOOLS.map((t) => t.name),
56
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS.map((t) => t.name),
57
+ ]);
58
+ const externalTools = new Set([
59
+ ...EXTERNAL_MCP_TOOLS.map((t) => t.name),
60
+ ...WORKSPACE_CREATION_TOOLS.map((t) => t.name),
61
+ ...WORKSPACE_LIFECYCLE_TOOLS.map((t) => t.name),
62
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS.map((t) => t.name),
63
+ 'stop_workspace',
64
+ ]);
65
+ export function createInternalMcpCapability(input) {
66
+ const capability = { ...input, env: { ...input.env }, token: randomBytes(32).toString('base64url') };
67
+ capabilities.set(capability.token, capability);
68
+ return capability;
69
+ }
70
+ export function revokeInternalMcpCapability(token) {
71
+ capabilities.delete(token);
72
+ }
73
+ export function resolveInternalMcpCapability(token) {
74
+ const capability = token ? capabilities.get(token) : undefined;
75
+ if (!capability)
76
+ throw new Error('Invalid or revoked managed MCP capability');
77
+ return capability;
78
+ }
79
+ export function authorizeInternalMcpTool(token, name, args) {
80
+ const capability = resolveInternalMcpCapability(token);
81
+ const report = (name === 'submit_final_review' &&
82
+ capability.env.KOBO_FINAL_REVIEW_TOKEN &&
83
+ capability.env.KOBO_FINAL_REVIEW_SESSION_ID === capability.sessionId) ||
84
+ (name === 'submit_session_handoff' && capability.env.KOBO_HANDOFF_TOKEN && capability.env.KOBO_HANDOFF_ID);
85
+ const progress = isProgressBarTool(name) && capability.env.KOBO_PROGRESS_SESSION_TOKEN;
86
+ if (!report && !progress && !readTools.has(name) && !writeTools.has(name))
87
+ throw new Error(`Unknown or unavailable tool: ${name}`);
88
+ if (capability.readOnly && !readTools.has(name) && !report && !progress)
89
+ throw new Error('This managed MCP launch is read-only');
90
+ if (!externalTools.has(name) && args.workspace_id !== undefined && args.workspace_id !== capability.workspaceId)
91
+ throw new Error('Tool is bound to its launch workspace');
92
+ if ((name === 'send_workspace_message' && args.workspace_id === capability.workspaceId) ||
93
+ (name === 'send_workspace_group_message' &&
94
+ Array.isArray(args.workspace_ids) &&
95
+ args.workspace_ids.includes(capability.workspaceId)))
96
+ throw new Error('Use the normal conversation in your own workspace');
97
+ return capability;
98
+ }
@@ -29,7 +29,7 @@ export function buildMcpConnectionInfo(input) {
29
29
  networkEnabled: input.networkEnabled,
30
30
  behindProxy: input.behindProxy,
31
31
  proxyHostname: input.proxyHostname,
32
- localRequiresToken: input.behindProxy,
32
+ localRequiresToken: true,
33
33
  stdio: args
34
34
  ? {
35
35
  command: input.command,
@@ -2,7 +2,7 @@ import { deriveMemoryActor } from '../../shared/memory.js';
2
2
  import { isMemoryToolName, validateMemoryToolArguments } from '../../shared/memory-tools.js';
3
3
  import { prepareMemoryControlReply, prepareMemoryToolEnvelope, sliceMemoryBodyFragment, } from '../utils/memory-token-budget.js';
4
4
  import { createExternalMemoryBudgetContext, reserveMemoryBudget, reserveMemoryBudgetDenial, wasMemoryFragmentDelivered, } from './memory-budget-service.js';
5
- import { listMemories, listMemoryOperations, listMemoryScopes, listWorkspaceMemoryOperations, readMemory, remember, searchMemories, } from './memory-service.js';
5
+ import { listMemories, listMemoryOperations, listMemoryScopes, listWorkspaceMemoryOperations, listWorkspaceMemoryScopes, readMemory, remember, searchMemories, } from './memory-service.js';
6
6
  function externalActor(source) {
7
7
  if (source.kind !== 'mcp')
8
8
  throw new TypeError('External memory tools require MCP client context');
@@ -176,7 +176,7 @@ function chargeExternalOutput(input) {
176
176
  return addBudgetReceipt(sized.data, input.contextId, reservation.estimatedTokens, reservation.remainingTokens);
177
177
  }
178
178
  /** Execute the external memory surface; caller identity is always derived from MCP transport. */
179
- export function executeMemoryMcpTool(name, input, source) {
179
+ export function executeMemoryMcpTool(name, input, source, allowedWorkspaceIds) {
180
180
  if (!isMemoryToolName(name))
181
181
  throw new TypeError(`Unknown memory tool '${name}'`);
182
182
  const actor = externalActor(source);
@@ -212,11 +212,13 @@ export function executeMemoryMcpTool(name, input, source) {
212
212
  try {
213
213
  switch (name) {
214
214
  case 'list_memory_scopes':
215
- data = boundedScopes(listMemoryScopes({
216
- ...(args.workspace_id ? { workspaceId: args.workspace_id } : {}),
217
- ...(cursor ? { cursor } : {}),
218
- ...(limit ? { limit } : {}),
219
- }));
215
+ data = boundedScopes(allowedWorkspaceIds
216
+ ? listWorkspaceMemoryScopes(args.workspace_id ? allowedWorkspaceIds.filter((id) => id === args.workspace_id) : allowedWorkspaceIds, { ...(cursor ? { cursor } : {}), ...(limit ? { limit } : {}) })
217
+ : listMemoryScopes({
218
+ ...(args.workspace_id ? { workspaceId: args.workspace_id } : {}),
219
+ ...(cursor ? { cursor } : {}),
220
+ ...(limit ? { limit } : {}),
221
+ }));
220
222
  break;
221
223
  case 'list_memories': {
222
224
  const page = listMemories({ ...selection, actor, ...(cursor ? { cursor } : {}), ...(limit ? { limit } : {}) });
@@ -210,6 +210,21 @@ export function resolveMemoryScope(input) {
210
210
  }
211
211
  return materializeScope(input);
212
212
  }
213
+ /** Resolve only the workspace-owned scope, without creating or exposing ancestors. */
214
+ export function resolveWorkspaceMemoryScope(workspaceId) {
215
+ return materializeScope({ level: 'workspace', workspaceId });
216
+ }
217
+ /** Catalogue for a caller whose authority is explicitly restricted to these workspaces. */
218
+ export function listWorkspaceMemoryScopes(workspaceIds, options = {}) {
219
+ const limit = requireBoundedPage(options.limit);
220
+ const offset = requireCursor(options.cursor);
221
+ const exists = getDb().prepare('SELECT id FROM workspaces WHERE id = ?');
222
+ const existing = [...new Set(workspaceIds)].sort().filter((id) => exists.get(id));
223
+ return {
224
+ items: existing.slice(offset, offset + limit).map(resolveWorkspaceMemoryScope),
225
+ ...(offset + limit < existing.length ? { nextCursor: String(offset + limit) } : {}),
226
+ };
227
+ }
213
228
  /** Lists persisted scopes; a workspace view always includes the exact applicable ancestors. */
214
229
  export function listMemoryScopes(options = {}) {
215
230
  const limit = requireBoundedPage(options.limit);
@@ -64,18 +64,29 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '::ffff:127.0.
64
64
  * scheme (`http://127.0.0.1:3000`); adding a scheme when none is present runs
65
65
  * both through the same URL rules, including the bracketed IPv6 form.
66
66
  */
67
- function headerHostname(value) {
67
+ function parseOrigin(value) {
68
+ if (!/^https?:\/\/[^/?#\\\s]+$/i.test(value))
69
+ return null;
68
70
  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;
71
+ const url = new URL(value);
72
+ return url.hostname && !url.username && !url.password ? url : null;
74
73
  }
75
74
  catch {
76
75
  return null;
77
76
  }
78
77
  }
78
+ function parseHost(value) {
79
+ if (!value || /[/?#@\\\s]/.test(value))
80
+ return null;
81
+ return parseOrigin(`http://${value}`);
82
+ }
83
+ function headerHostname(value) {
84
+ const url = value.includes('://') ? parseOrigin(value) : parseHost(value);
85
+ const hostname = url?.hostname;
86
+ if (!hostname)
87
+ return null;
88
+ return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
89
+ }
79
90
  /**
80
91
  * True when a raw `Host`/`Origin` value names the machine itself. Always
81
92
  * allowed, whatever the settings say, which lets a caller settle the common
@@ -83,7 +94,7 @@ function headerHostname(value) {
83
94
  * both synchronous, and this runs on every request.
84
95
  */
85
96
  export function isLocalRequestHost(value) {
86
- if (!value)
97
+ if (!value || !parseHost(value))
87
98
  return false;
88
99
  const hostname = headerHostname(value);
89
100
  return hostname !== null && LOCAL_HOSTNAMES.has(hostname);
@@ -95,52 +106,35 @@ function isAllowedHostname(hostname, enabled, lanHostnames) {
95
106
  return true;
96
107
  return enabled && lanHostnames.includes(hostname);
97
108
  }
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
- */
109
+ /** Reject malformed/rebinding hosts. Proxy mode requires a declared public host;
110
+ * loopback remains reachable for authenticated internal clients and health checks. */
116
111
  export function isAllowedRequestHost(params) {
117
112
  if (!params.host)
118
113
  return false;
119
- const hostname = headerHostname(params.host);
114
+ const hostname = parseHost(params.host) ? headerHostname(params.host) : null;
120
115
  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.
116
+ if (!hostname)
117
+ return false;
123
118
  if (!params.proxyHostname)
124
- return true;
119
+ return LOCAL_HOSTNAMES.has(hostname);
125
120
  return hostname === params.proxyHostname || (hostname !== null && LOCAL_HOSTNAMES.has(hostname));
126
121
  }
127
122
  return isAllowedHostname(hostname, params.enabled, params.lanHostnames);
128
123
  }
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
- */
124
+ /** Validated hostname declared by the operator; invalid/missing values fail closed. */
138
125
  export function resolveProxyHostname(env = process.env) {
139
126
  const raw = env.KOBO_NETWORK_ACCESS_PROXY_HOST?.trim();
140
127
  if (!raw)
141
128
  return null;
142
129
  return headerHostname(raw);
143
130
  }
131
+ /** Exact public origin. A bare configured hostname implies HTTPS. */
132
+ export function resolveProxyOrigin(env = process.env) {
133
+ const raw = env.KOBO_NETWORK_ACCESS_PROXY_HOST?.trim();
134
+ if (!raw)
135
+ return null;
136
+ return parseOrigin(raw.includes('://') ? raw : `https://${raw}`)?.origin ?? null;
137
+ }
144
138
  /**
145
139
  * Names the site a request was driven from. Guards two things.
146
140
  *
@@ -173,29 +167,22 @@ export function resolveProxyHostname(env = process.env) {
173
167
  export function isAllowedOrigin(params) {
174
168
  if (params.origin === undefined)
175
169
  return true;
176
- const origin = authority(params.origin);
177
- if (origin === null)
170
+ const origin = parseOrigin(params.origin)?.origin;
171
+ if (!origin)
178
172
  return false;
179
173
  if (params.behindProxy) {
180
174
  if (!params.proxyHostname)
181
- return true;
182
- return headerHostname(params.origin) === params.proxyHostname;
175
+ return false;
176
+ const expected = params.proxyOrigin ?? parseOrigin(`https://${params.proxyHostname}`)?.origin;
177
+ return origin === expected;
183
178
  }
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))
179
+ // The explicit Quasar development origin can differ from the backend port.
180
+ if (params.devOrigin && origin === parseOrigin(params.devOrigin)?.origin)
188
181
  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
- }
182
+ if (!params.requestHost || !parseHost(params.requestHost))
183
+ return false;
184
+ const protocol = params.requestProtocol ?? 'http:';
185
+ return origin === parseOrigin(`${protocol}//${params.requestHost}`)?.origin;
199
186
  }
200
187
  /**
201
188
  * Origin the development client is served from, declared by the `dev` script
@@ -231,23 +218,6 @@ export function evaluateNetworkAccess(params) {
231
218
  return { allow: true, status: 200 };
232
219
  return { allow: false, status: 401 };
233
220
  }
234
- /** WS upgrade authorization: parses `?token=` from the raw URL. */
235
- export function authorizeWsUpgrade(params) {
236
- let providedToken;
237
- try {
238
- providedToken = new URL(params.rawUrl ?? '/', 'http://localhost').searchParams.get('token') ?? undefined;
239
- }
240
- catch {
241
- providedToken = undefined;
242
- }
243
- return evaluateNetworkAccess({
244
- address: params.address,
245
- enabled: params.enabled,
246
- expectedToken: params.expectedToken,
247
- providedToken,
248
- trustLoopback: params.trustLoopback,
249
- }).allow;
250
- }
251
221
  /**
252
222
  * Reads KOBO_NETWORK_ACCESS_ENABLED / KOBO_NETWORK_ACCESS_BEHIND_PROXY and
253
223
  * returns only the fields an env var actually specifies — an unset env var