@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,30 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import { createExternalMcpCredential, EXTERNAL_TOOL_CATALOGUE, listExternalMcpCredentials, revokeExternalMcpCredential, } from '../services/external-mcp-credentials-service.js';
4
+ // Mounted behind the operator authentication gate; MCP credentials cannot reach it.
5
+ const app = new Hono();
6
+ app.use('*', bodyLimit({ maxSize: 32 * 1024 }));
7
+ app.use('*', async (c, next) => {
8
+ c.header('Cache-Control', 'no-store');
9
+ await next();
10
+ });
11
+ app.get('/', (c) => c.json({
12
+ credentials: listExternalMcpCredentials(),
13
+ tools: EXTERNAL_TOOL_CATALOGUE.map((tool) => ({
14
+ name: tool.name,
15
+ readOnly: tool.annotations?.readOnlyHint === true,
16
+ })),
17
+ }));
18
+ app.post('/', async (c) => {
19
+ try {
20
+ return c.json(createExternalMcpCredential(await c.req.json()), 201);
21
+ }
22
+ catch (error) {
23
+ return c.json({ error: error instanceof Error ? error.message : 'Invalid credential request' }, 400);
24
+ }
25
+ });
26
+ app.delete('/:id', (c) => {
27
+ revokeExternalMcpCredential(c.req.param('id'));
28
+ return c.json({ ok: true });
29
+ });
30
+ export default app;
@@ -4,14 +4,16 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
4
4
  import { Hono } from 'hono';
5
5
  import { bodyLimit } from 'hono/body-limit';
6
6
  import { MAX_MCP_ATTACHMENT_REQUEST_BYTES } from '../../shared/mcp-attachments.js';
7
- import { EXTERNAL_MEMORY_TOOL_DEFINITIONS, isMemoryToolName } from '../../shared/memory-tools.js';
8
- import { isProgressBarTool, PROGRESS_BAR_TOOLS } from '../../shared/progress-bar-tools.js';
9
- import { isWorkspaceCreationTool, WORKSPACE_CREATION_TOOLS } from '../../shared/workspace-creation-tools.js';
10
- import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
11
- import { isWorkspaceGroupMessageTool, WORKSPACE_GROUP_MESSAGE_TOOLS, } from '../../shared/workspace-group-message-tools.js';
12
- import { isWorkspaceLifecycleTool, WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
7
+ import { isMemoryToolName } from '../../shared/memory-tools.js';
8
+ import { isProgressBarTool } from '../../shared/progress-bar-tools.js';
9
+ import { isWorkspaceCreationTool } from '../../shared/workspace-creation-tools.js';
10
+ import { isWorkspaceGroupMessageTool } from '../../shared/workspace-group-message-tools.js';
11
+ import { isWorkspaceLifecycleTool } from '../../shared/workspace-lifecycle-tools.js';
12
+ import { getDb } from '../db/index.js';
13
+ import { authenticateExternalMcpToken, EXTERNAL_TOOL_CATALOGUE, } from '../services/external-mcp-credentials-service.js';
13
14
  import { createMcpClientContext } from '../services/mcp-client-context.js';
14
15
  import { executeMemoryMcpTool } from '../services/memory-mcp-service.js';
16
+ import { resolveWorkspaceMemoryScope } from '../services/memory-service.js';
15
17
  import { executeProgressBarTool, progressErrorData } from '../services/progress-bar-mcp-service.js';
16
18
  import { ProgressBarError } from '../services/progress-bar-service.js';
17
19
  import { executeWorkspaceCreationTool, WorkspaceCreationMcpError } from '../services/workspace-creation-mcp-service.js';
@@ -19,7 +21,16 @@ import { executeWorkspaceDialogueTool } from '../services/workspace-dialogue-ser
19
21
  import { executeWorkspaceGroupMessageTool, WorkspaceGroupMessageMcpError, } from '../services/workspace-group-message-mcp-service.js';
20
22
  import { executeWorkspaceLifecycleTool, WorkspaceLifecycleMcpError, } from '../services/workspace-lifecycle-mcp-service.js';
21
23
  import { prepareMemoryToolEnvelope } from '../utils/memory-token-budget.js';
24
+ function authenticateRequest(authorization = '') {
25
+ return authenticateExternalMcpToken(authorization.startsWith('Bearer ') ? authorization.slice(7) : '');
26
+ }
22
27
  const app = new Hono();
28
+ app.use('*', async (c, next) => {
29
+ if (!authenticateRequest(c.req.header('Authorization')))
30
+ return c.json({ error: 'Dedicated MCP credential required' }, 401);
31
+ c.header('Cache-Control', 'no-store');
32
+ return next();
33
+ });
23
34
  // Only creation and message envelopes may carry the larger, base64 attachment payload.
24
35
  // Other tools retain their existing 1 MiB limit, including malformed envelopes.
25
36
  app.use('*', bodyLimit({ maxSize: MAX_MCP_ATTACHMENT_REQUEST_BYTES }));
@@ -44,6 +55,9 @@ app.use('*', async (c, next) => {
44
55
  return c.json({ error: 'MCP request is too large' }, 413);
45
56
  }
46
57
  }
58
+ // Uploads can outlive their credential. Never retain the pre-body principal.
59
+ if (!authenticateRequest(c.req.header('Authorization')))
60
+ return c.json({ error: 'Dedicated MCP credential required' }, 401);
47
61
  return next();
48
62
  });
49
63
  /** Dispatch only the supported MCP operations through the existing UI handlers. */
@@ -66,83 +80,27 @@ async function dispatchWorkspaceRequest(pathname, init) {
66
80
  throw new Error('Unsupported workspace operation');
67
81
  }
68
82
  app.all('/', async (c) => {
83
+ const currentPrincipal = () => {
84
+ const principal = authenticateRequest(c.req.header('Authorization'));
85
+ if (!principal)
86
+ throw new Error('MCP credential expired or revoked');
87
+ return principal;
88
+ };
69
89
  const server = new Server({ name: 'kobo-workspaces', version: '1.0.0' }, { capabilities: { tools: {} } });
70
90
  const transport = new WebStandardStreamableHTTPServerTransport({
71
91
  sessionIdGenerator: undefined,
72
92
  enableJsonResponse: true,
73
93
  maxRequestBodySize: MAX_MCP_ATTACHMENT_REQUEST_BYTES,
74
94
  });
75
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
76
- tools: [
77
- ...EXTERNAL_MCP_TOOLS,
78
- ...WORKSPACE_CREATION_TOOLS,
79
- ...WORKSPACE_LIFECYCLE_TOOLS,
80
- ...WORKSPACE_GROUP_MESSAGE_TOOLS,
81
- ...EXTERNAL_MEMORY_TOOL_DEFINITIONS,
82
- ...PROGRESS_BAR_TOOLS,
83
- ],
84
- }));
95
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
96
+ const principal = currentPrincipal();
97
+ return { tools: EXTERNAL_TOOL_CATALOGUE.filter((tool) => canUseExternalMcpTool(principal, tool.name)) };
98
+ });
85
99
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
86
- const isMemory = isMemoryToolName(request.params.name);
87
- try {
88
- const source = createMcpClientContext(c.req.header('X-Kobo-Client-Name'), c.req.header('X-Kobo-Client-Transport') === 'stdio' ? 'stdio' : 'http', c.req.header('X-Kobo-Client-Name-Encoding'));
89
- const data = isProgressBarTool(request.params.name)
90
- ? executeProgressBarTool(request.params.name, request.params.arguments ?? {})
91
- : isMemory
92
- ? executeMemoryMcpTool(request.params.name, request.params.arguments ?? {}, source)
93
- : isWorkspaceCreationTool(request.params.name)
94
- ? await executeWorkspaceCreationTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
95
- : isWorkspaceLifecycleTool(request.params.name)
96
- ? await executeWorkspaceLifecycleTool(request.params.name, request.params.arguments ?? {}, dispatchWorkspaceRequest)
97
- : isWorkspaceGroupMessageTool(request.params.name)
98
- ? executeWorkspaceGroupMessageTool(request.params.name, request.params.arguments ?? {}, source)
99
- : await executeWorkspaceDialogueTool(request.params.name, request.params.arguments ?? {}, source);
100
- if (isMemory) {
101
- const bounded = prepareMemoryToolEnvelope(data, { targetTokens: 1_000, transport: 'external' });
102
- if (bounded.data.memoryOutputSuppressed === true)
103
- return { content: [], isError: true };
104
- const { __mcpError, ...publicData } = bounded.data;
105
- return {
106
- content: [{ type: 'text', text: JSON.stringify(publicData) }],
107
- structuredContent: publicData,
108
- ...(__mcpError === true || publicData.budgetExhausted === true ? { isError: true } : {}),
109
- };
110
- }
111
- const structuredContent = data && typeof data === 'object' && !Array.isArray(data) ? data : undefined;
112
- return {
113
- content: [{ type: 'text', text: JSON.stringify(data) }],
114
- ...(structuredContent ? { structuredContent } : {}),
115
- ...(structuredContent?.accepted === false ? { isError: true } : {}),
116
- };
117
- }
118
- catch (error) {
119
- if (error instanceof ProgressBarError) {
120
- const data = progressErrorData(error);
121
- return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
122
- }
123
- if (error instanceof WorkspaceGroupMessageMcpError) {
124
- const data = { error: error.message, status: error.status };
125
- return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
126
- }
127
- if (error instanceof WorkspaceCreationMcpError || error instanceof WorkspaceLifecycleMcpError) {
128
- const data = { error: error.message, status: error.status, stage: error.stage, details: error.details };
129
- return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
130
- }
131
- if (isMemory) {
132
- const bounded = prepareMemoryToolEnvelope({
133
- error: error instanceof Error ? error.message : String(error),
134
- }, { targetTokens: 1_000, transport: 'external' });
135
- return {
136
- isError: true,
137
- content: [{ type: 'text', text: JSON.stringify(bounded.data) }],
138
- structuredContent: bounded.data,
139
- };
140
- }
141
- return {
142
- isError: true,
143
- content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
144
- };
145
- }
100
+ const source = createMcpClientContext(c.req.header('X-Kobo-Client-Name'), c.req.header('X-Kobo-Client-Transport') === 'stdio' ? 'stdio' : 'http', c.req.header('X-Kobo-Client-Name-Encoding'));
101
+ // This is the acceptance boundary: already-dispatched operations are not
102
+ // cancelled on later revocation, but every new call reloads the live grant.
103
+ return executeExternalMcpTool(request.params.name, request.params.arguments ?? {}, source, currentPrincipal());
146
104
  });
147
105
  try {
148
106
  await server.connect(transport);
@@ -155,4 +113,126 @@ app.all('/', async (c) => {
155
113
  await server.close();
156
114
  }
157
115
  });
116
+ export async function executeExternalMcpTool(name, args, source, principal) {
117
+ const isMemory = isMemoryToolName(name);
118
+ try {
119
+ if (principal)
120
+ args = authorizeExternalMcpCall(principal, name, args);
121
+ let data = isProgressBarTool(name)
122
+ ? executeProgressBarTool(name, args)
123
+ : isMemory
124
+ ? executeMemoryMcpTool(name, args, source, principal && !principal.allWorkspaces ? principal.workspaceIds : undefined)
125
+ : isWorkspaceCreationTool(name)
126
+ ? await executeWorkspaceCreationTool(name, args, dispatchWorkspaceRequest)
127
+ : isWorkspaceLifecycleTool(name)
128
+ ? await executeWorkspaceLifecycleTool(name, args, dispatchWorkspaceRequest)
129
+ : isWorkspaceGroupMessageTool(name)
130
+ ? executeWorkspaceGroupMessageTool(name, args, source)
131
+ : await executeWorkspaceDialogueTool(name, args, source);
132
+ if (principal && !principal.allWorkspaces && name === 'list_workspaces' && Array.isArray(data)) {
133
+ data = data.filter((workspace) => principal.workspaceIds.includes(workspace.id));
134
+ }
135
+ if (isMemory) {
136
+ const bounded = prepareMemoryToolEnvelope(data, { targetTokens: 1_000, transport: 'external' });
137
+ if (bounded.data.memoryOutputSuppressed === true)
138
+ return { content: [], isError: true };
139
+ const { __mcpError, ...publicData } = bounded.data;
140
+ return {
141
+ content: [{ type: 'text', text: JSON.stringify(publicData) }],
142
+ structuredContent: publicData,
143
+ ...(__mcpError === true || publicData.budgetExhausted === true ? { isError: true } : {}),
144
+ };
145
+ }
146
+ const structuredContent = data && typeof data === 'object' && !Array.isArray(data) ? data : undefined;
147
+ return {
148
+ content: [{ type: 'text', text: JSON.stringify(data) }],
149
+ ...(structuredContent ? { structuredContent } : {}),
150
+ ...(structuredContent?.accepted === false ? { isError: true } : {}),
151
+ };
152
+ }
153
+ catch (error) {
154
+ if (error instanceof ProgressBarError) {
155
+ const data = progressErrorData(error);
156
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
157
+ }
158
+ if (error instanceof WorkspaceGroupMessageMcpError) {
159
+ const data = { error: error.message, status: error.status };
160
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
161
+ }
162
+ if (error instanceof WorkspaceCreationMcpError || error instanceof WorkspaceLifecycleMcpError) {
163
+ const data = { error: error.message, status: error.status, stage: error.stage, details: error.details };
164
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data };
165
+ }
166
+ if (isMemory) {
167
+ const bounded = prepareMemoryToolEnvelope({
168
+ error: error instanceof Error ? error.message : String(error),
169
+ }, { targetTokens: 1_000, transport: 'external' });
170
+ return {
171
+ isError: true,
172
+ content: [{ type: 'text', text: JSON.stringify(bounded.data) }],
173
+ structuredContent: bounded.data,
174
+ };
175
+ }
176
+ return {
177
+ isError: true,
178
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
179
+ };
180
+ }
181
+ }
158
182
  export default app;
183
+ const allWorkspaceOnly = new Set([
184
+ 'create_workspace',
185
+ 'diagnose_workspace_pr',
186
+ 'preview_workspace_group_message',
187
+ 'get_workspace_group_message',
188
+ ]);
189
+ export function canUseExternalMcpTool(principal, name) {
190
+ return principal.tools.includes(name) && (principal.allWorkspaces || !allWorkspaceOnly.has(name));
191
+ }
192
+ export function authorizeExternalMcpCall(principal, name, args) {
193
+ const deny = () => {
194
+ throw new Error('MCP credential does not authorize this operation');
195
+ };
196
+ if (!canUseExternalMcpTool(principal, name))
197
+ return deny();
198
+ // Resource-wide grants still use each service's normal argument validation.
199
+ if (principal.allWorkspaces)
200
+ return args;
201
+ const definition = EXTERNAL_TOOL_CATALOGUE.find((tool) => tool.name === name);
202
+ if (!definition || Object.keys(args).some((key) => !Object.hasOwn(definition.inputSchema.properties ?? {}, key)))
203
+ return deny();
204
+ if (name === 'list_workspaces')
205
+ return args;
206
+ if (name === 'send_workspace_group_message') {
207
+ if (!Array.isArray(args.workspace_ids) ||
208
+ args.workspace_ids.some((id) => typeof id !== 'string' || !principal.workspaceIds.includes(id)))
209
+ return deny();
210
+ return args;
211
+ }
212
+ if (isMemoryToolName(name)) {
213
+ if (name === 'list_memory_scopes') {
214
+ if (args.workspace_id !== undefined &&
215
+ (typeof args.workspace_id !== 'string' || !principal.workspaceIds.includes(args.workspace_id)))
216
+ return deny();
217
+ return args;
218
+ }
219
+ // Workspace aggregate memory also includes global/project entries. Restricted
220
+ // keys use only the workspace-owned scope, never inherited memory.
221
+ let scope;
222
+ if (typeof args.scope_id === 'string')
223
+ scope = getDb()
224
+ .prepare("SELECT id, workspace_id FROM memory_scopes WHERE id = ? AND level = 'workspace'")
225
+ .get(args.scope_id);
226
+ else if (typeof args.workspace_id === 'string' && principal.workspaceIds.includes(args.workspace_id)) {
227
+ const resolved = resolveWorkspaceMemoryScope(args.workspace_id);
228
+ scope = { id: resolved.id, workspace_id: args.workspace_id };
229
+ }
230
+ if (!scope?.workspace_id || !principal.workspaceIds.includes(scope.workspace_id))
231
+ return deny();
232
+ const { workspace_id: _workspace, ...rest } = args;
233
+ return { ...rest, scope_id: scope.id };
234
+ }
235
+ if (typeof args.workspace_id !== 'string' || !principal.workspaceIds.includes(args.workspace_id))
236
+ return deny();
237
+ return args;
238
+ }
@@ -36,7 +36,7 @@ import { buildEngineHandoff } from '../services/engine-handoff-service.js';
36
36
  import { saveWorkspaceFile, shaOf } from '../services/file-editor-service.js';
37
37
  import { getForgeProvider } from '../services/forge/registry.js';
38
38
  import { resolveForge } from '../services/forge/resolve.js';
39
- import { ForgeUnavailableError } from '../services/forge/types.js';
39
+ import { ForgeUnavailableError, MergePolicyError } from '../services/forge/types.js';
40
40
  import { computeGitStats } from '../services/git-stats-service.js';
41
41
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, renderNotionInitialPrompt, renderSentryInitialPrompt, } from '../services/initial-prompt-template-service.js';
42
42
  import * as notionService from '../services/notion-service.js';
@@ -4233,11 +4233,18 @@ app.post('/:id/merge-pr', async (c) => {
4233
4233
  }
4234
4234
  body = parsed;
4235
4235
  }
4236
+ if (body.admin !== undefined && typeof body.admin !== 'boolean') {
4237
+ return c.json({ error: 'admin must be a boolean' }, 400);
4238
+ }
4239
+ const admin = body.admin === true;
4236
4240
  const method = body.method;
4237
4241
  if (method !== undefined && !isMergeMethod(method)) {
4238
4242
  return c.json({ error: 'method must be merge, squash or rebase' }, 400);
4239
4243
  }
4240
4244
  const provider = getForgeProvider(resolveForge(workspace.projectPath));
4245
+ if (admin && provider.id !== 'github') {
4246
+ return c.json({ error: 'Administrator merge is only supported for GitHub', code: 'merge_admin_unsupported' }, 409);
4247
+ }
4241
4248
  if (!provider.capabilities.canMergeRequest) {
4242
4249
  return c.json({ error: 'This project has no forge that can merge the PR', code: 'forge_unsupported' }, 409);
4243
4250
  }
@@ -4255,10 +4262,16 @@ app.post('/:id/merge-pr', async (c) => {
4255
4262
  if (snapshot?.state !== 'OPEN') {
4256
4263
  return c.json({ error: 'No open PR found for this workspace', code: 'pr_not_open' }, 409);
4257
4264
  }
4258
- if (!snapshot.readyToMerge) {
4265
+ if (admin && snapshot.mergeable === 'CONFLICTING') {
4266
+ return c.json({ error: 'Resolve merge conflicts before merging', code: 'pr_not_ready' }, 409);
4267
+ }
4268
+ if (!snapshot.readyToMerge && !admin) {
4259
4269
  return c.json({ error: 'This PR is not ready to merge', code: 'pr_not_ready' }, 409);
4260
4270
  }
4261
- if (method === undefined) {
4271
+ if (admin) {
4272
+ await provider.mergeRequest(workspace.worktreePath, snapshot.number, method, { admin: true });
4273
+ }
4274
+ else if (method === undefined) {
4262
4275
  await provider.mergeRequest(workspace.worktreePath, snapshot.number);
4263
4276
  }
4264
4277
  else {
@@ -4268,6 +4281,9 @@ app.post('/:id/merge-pr', async (c) => {
4268
4281
  return c.json({ success: true, merged: merged?.state === 'MERGED', branch: workspace.workingBranch });
4269
4282
  }
4270
4283
  catch (err) {
4284
+ if (err instanceof MergePolicyError) {
4285
+ return c.json({ error: err.message, code: err.code }, 409);
4286
+ }
4271
4287
  if (err instanceof ForgeUnavailableError) {
4272
4288
  return c.json({ error: err.message, code: 'forge_unavailable' }, 409);
4273
4289
  }
@@ -3,7 +3,7 @@ const localRequire = createRequire(import.meta.url);
3
3
  /**
4
4
  * Detect the live host's platform/arch/libc from `process`. `glibcVersionRuntime`
5
5
  * lives in the diagnostic report header on glibc systems and is absent on musl.
6
- * Available since Node 12; Kōbō requires Node ≥ 20 (see AGENTS.md).
6
+ * Available since Node 12; Kōbō requires Node ≥ 26.12.1 (see AGENTS.md).
7
7
  */
8
8
  export function detectPlatform() {
9
9
  const report = process.report;
@@ -3,7 +3,7 @@ import { nanoid } from 'nanoid';
3
3
  import { renderWorkflowPolicy } from '../../../shared/workflow-policy.js';
4
4
  import { getDb } from '../../db/index.js';
5
5
  import { assertAgentStopped } from '../../utils/agent-stop-result.js';
6
- import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
6
+ import { ensureKoboHome, getCompiledMcpServerPath, getKoboHome, getMcpServerSourcePath, getSkillsPath, } from '../../utils/paths.js';
7
7
  import { assertWorkspaceLifecycleAvailable, workspaceLifecycleReason } from '../../utils/workspace-lifecycle-guard.js';
8
8
  import { cancelAutoLoopFinalReview, getAutoLoopReviewLaunch, isAutoLoopReview, prepareAutoLoopReviewReturn, retryAutoLoopReviewReturn, } from '../auto-loop-final-review-service.js';
9
9
  import { listLoopMessages, settleLoopMessages } from '../auto-loop-message-service.js';
@@ -11,6 +11,7 @@ import * as autoLoopService from '../auto-loop-service.js';
11
11
  import * as cleanupScriptService from '../cleanup-script-service.js';
12
12
  import * as cronService from '../cron-service.js';
13
13
  import { resolveForge } from '../forge/resolve.js';
14
+ import { createInternalMcpCapability, revokeInternalMcpCapability } from '../internal-mcp-capability-service.js';
14
15
  import * as lifecycleHookService from '../lifecycle-hook-service.js';
15
16
  import { advanceMemoryConversationEpoch, allocateMemoryConversationKey, bindMemoryNativeConversation, createMemoryCapability, revokeMemoryCapability, } from '../memory-agent-runtime.js';
16
17
  import { buildMemoryContext, markMemoryContextFailed, markMemoryContextInitialized, markMemoryContextSubmitted, } from '../memory-context-service.js';
@@ -494,27 +495,19 @@ function readEffectiveSettingsSafe(projectPath) {
494
495
  };
495
496
  }
496
497
  }
497
- function buildMcpServers(workspaceId, extraEnv, memoryCapability) {
498
+ function buildMcpServers(workspaceId, internalCapability, launchEnv) {
498
499
  const mcpServerCompiled = getCompiledMcpServerPath();
499
500
  const mcpServerSource = getMcpServerSourcePath();
500
- const global = getGlobalSettings();
501
501
  return [
502
502
  {
503
503
  name: 'kobo-tasks',
504
504
  command: mcpServerCompiled ? 'node' : 'npx',
505
505
  args: mcpServerCompiled ? [mcpServerCompiled] : ['tsx', mcpServerSource],
506
506
  env: {
507
+ ...Object.fromEntries(Object.entries(launchEnv ?? {}).filter(([key, value]) => key !== 'KOBO_NETWORK_TOKEN' && typeof value === 'string')),
507
508
  KOBO_WORKSPACE_ID: workspaceId,
508
- ...extraEnv,
509
- ...(memoryCapability ? { KOBO_MEMORY_SESSION_TOKEN: memoryCapability } : {}),
510
- KOBO_DB_PATH: getDbPath(),
511
- KOBO_SETTINGS_PATH: getSettingsPath(),
509
+ ...(internalCapability ? { KOBO_MCP_SESSION_TOKEN: internalCapability } : {}),
512
510
  KOBO_BACKEND_URL: `http://127.0.0.1:${backendPort}`,
513
- // Behind a proxy, even internal loopback requests require network auth.
514
- // Per-launch memory/progress capabilities remain independently required.
515
- ...(global.networkAccessBehindProxy && global.networkAccessToken
516
- ? { KOBO_NETWORK_TOKEN: global.networkAccessToken }
517
- : {}),
518
511
  },
519
512
  },
520
513
  ];
@@ -1361,11 +1354,16 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
1361
1354
  }
1362
1355
  const launchOptions = new WeakMap();
1363
1356
  const controllerProgressCapabilities = new WeakMap();
1357
+ const controllerInternalMcpCapabilities = new WeakMap();
1364
1358
  const controllerMemoryCapabilities = new WeakMap();
1365
1359
  const controllerMemoryConversationKeys = new WeakMap();
1366
1360
  const controllerMemoryContextIds = new WeakMap();
1367
1361
  const memoryCompactionStates = new WeakMap();
1368
1362
  function revokeControllerCapabilities(controller) {
1363
+ const internalToken = controllerInternalMcpCapabilities.get(controller);
1364
+ if (internalToken)
1365
+ revokeInternalMcpCapability(internalToken);
1366
+ controllerInternalMcpCapabilities.delete(controller);
1369
1367
  const progress = controllerProgressCapabilities.get(controller);
1370
1368
  if (progress)
1371
1369
  revokeProgressCapability(progress.token);
@@ -1574,6 +1572,17 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1574
1572
  }),
1575
1573
  readOnly: effectivePermissionMode === 'plan' || !!launch?.handoffGeneration,
1576
1574
  });
1575
+ const progressCapability = createProgressCapability(workspaceId, agentSessionId);
1576
+ const internalMcpCapability = createInternalMcpCapability({
1577
+ workspaceId,
1578
+ sessionId: agentSessionId,
1579
+ readOnly: effectivePermissionMode === 'plan' || !!launch?.handoffGeneration || !!launch?.mcpEnv?.KOBO_FINAL_REVIEW_TOKEN,
1580
+ env: {
1581
+ ...launch?.mcpEnv,
1582
+ KOBO_MEMORY_SESSION_TOKEN: memoryCapability.token,
1583
+ KOBO_PROGRESS_SESSION_TOKEN: progressCapability.token,
1584
+ },
1585
+ });
1577
1586
  const unavailableIntegrations = [];
1578
1587
  const integrationServers = buildIntegrationMcpServers(getGlobalSettings(), engine.id, (name) => unavailableIntegrations.push(name));
1579
1588
  const options = {
@@ -1598,7 +1607,10 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1598
1607
  }
1599
1608
  })(),
1600
1609
  settings,
1601
- mcpServers: [...buildMcpServers(workspaceId, launch?.mcpEnv, memoryCapability.token), ...integrationServers],
1610
+ mcpServers: [
1611
+ ...buildMcpServers(workspaceId, internalMcpCapability.token, internalMcpCapability.env),
1612
+ ...integrationServers,
1613
+ ],
1602
1614
  env: ws ? buildAgentEnv(ws.projectPath) : undefined,
1603
1615
  };
1604
1616
  // Snapshot only application-owned launch instructions. Memory is appended
@@ -1629,20 +1641,18 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
1629
1641
  .run(new Date().toISOString(), workspaceId, prompt);
1630
1642
  };
1631
1643
  }
1632
- const progressCapability = createProgressCapability(workspaceId, agentSessionId);
1633
- const managedMcp = options.mcpServers?.find((server) => server.name === 'kobo-tasks');
1634
- if (managedMcp)
1635
- managedMcp.env.KOBO_PROGRESS_SESSION_TOKEN = progressCapability.token;
1636
1644
  try {
1637
1645
  beforeDispatch?.();
1638
1646
  }
1639
1647
  catch (error) {
1648
+ revokeInternalMcpCapability(internalMcpCapability.token);
1640
1649
  revokeMemoryCapability(memoryCapability.token);
1641
1650
  revokeProgressCapability(progressCapability.token);
1642
1651
  throw error;
1643
1652
  }
1644
1653
  let controller;
1645
1654
  controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, controller, ev));
1655
+ controllerInternalMcpCapabilities.set(controller, internalMcpCapability.token);
1646
1656
  controllerProgressCapabilities.set(controller, progressCapability);
1647
1657
  controllerMemoryCapabilities.set(controller, memoryCapability.token);
1648
1658
  controllerMemoryConversationKeys.set(controller, memoryCapability.conversationKey);
@@ -1,5 +1,7 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ import { stripVTControlCharacters } from 'node:util';
3
5
  import { resolveInstance, sanitizeBranchName } from '../utils/dev-server-instance.js';
4
6
  import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
5
7
  import { getProjectSettings } from './settings-service.js';
@@ -25,6 +27,7 @@ function runCommand(command, args, options = {}) {
25
27
  // ── State ──────────────────────────────────────────────────────────────────────
26
28
  /** workspaceId -> spawned dev-server process */
27
29
  const trackedProcesses = new Map();
30
+ const directServerStatuses = new Map();
28
31
  const stoppingProcesses = new Map();
29
32
  const generations = new Map();
30
33
  let shuttingDown = false;
@@ -34,6 +37,7 @@ export function beginDevServerShutdown() {
34
37
  /** Test-only: clear the tracked-processes map between tests. */
35
38
  export function _resetTrackedProcessesForTests() {
36
39
  trackedProcesses.clear();
40
+ directServerStatuses.clear();
37
41
  stoppingProcesses.clear();
38
42
  generations.clear();
39
43
  shuttingDown = false;
@@ -74,14 +78,18 @@ export async function listRunningContainers(projectName) {
74
78
  * still running (e.g. `docker compose up -d` is pulling/building images), the
75
79
  * status is reported as `'starting'` even if no matching container is visible
76
80
  * in `docker ps` yet. This prevents the UI from flashing to `'stopped'` during
77
- * long build phases.
81
+ * long build phases. Direct processes report running and their URL after a
82
+ * recognized local readiness announcement on stdout or stderr.
78
83
  */
79
84
  export async function getStatus(projectPath, workingBranch, workspaceId) {
80
85
  const config = resolveInstance(projectPath, workingBranch);
86
+ const directStatus = workspaceId ? directServerStatuses.get(workspaceId) : undefined;
87
+ if (workspaceId && directStatus && trackedProcesses.has(workspaceId))
88
+ return { ...directStatus };
81
89
  if (!config) {
82
90
  return {
83
- status: 'unknown',
84
- instanceName: '',
91
+ status: workspaceId && trackedProcesses.has(workspaceId) ? 'starting' : 'unknown',
92
+ instanceName: workspaceId && trackedProcesses.has(workspaceId) ? sanitizeBranchName(workingBranch) : '',
85
93
  projectName: '',
86
94
  httpPort: '',
87
95
  url: '',
@@ -163,12 +171,64 @@ export function startDevServer(workspaceId) {
163
171
  const generation = (generations.get(workspaceId) ?? 0) + 1;
164
172
  generations.set(workspaceId, generation);
165
173
  trackedProcesses.set(workspaceId, proc);
166
- // Log stdout/stderr for debugging
174
+ // Parse complete lines separately per stream: CLI output and ANSI sequences
175
+ // can span arbitrary pipe chunks. Bound the unfinished line retained in memory.
176
+ const observeOutput = () => {
177
+ const decoder = new StringDecoder('utf8');
178
+ let pending = '';
179
+ return (data) => {
180
+ if (shuttingDown ||
181
+ trackedProcesses.get(workspaceId) !== proc ||
182
+ generations.get(workspaceId) !== generation ||
183
+ stoppingProcesses.has(workspaceId) ||
184
+ directServerStatuses.has(workspaceId))
185
+ return;
186
+ pending += decoder.write(data);
187
+ const lines = pending.split(/[\r\n]/);
188
+ pending = lines.pop().slice(-8192);
189
+ for (const line of lines) {
190
+ // Quasar's "App URL....." and Vite/Next's "Local:" are explicit
191
+ // readiness announcements; unrelated links in build output are not.
192
+ const match = stripVTControlCharacters(line).match(/^\s*(?:[»➜-]\s*)?(?:App URL[.\s]*|Local:\s*)(https?:\/\/\S+)\s*$/i);
193
+ if (!match)
194
+ continue;
195
+ let url;
196
+ try {
197
+ url = new URL(match[1]);
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ if (url.username ||
203
+ url.password ||
204
+ !['localhost', '127.0.0.1', '[::1]', '0.0.0.0', '[::]'].includes(url.hostname))
205
+ continue;
206
+ if (url.hostname === '0.0.0.0' || url.hostname === '[::]')
207
+ url.hostname = 'localhost';
208
+ const status = {
209
+ status: 'running',
210
+ instanceName,
211
+ projectName: '',
212
+ httpPort: url.port || (url.protocol === 'https:' ? '443' : '80'),
213
+ url: url.href,
214
+ containers: [],
215
+ };
216
+ directServerStatuses.set(workspaceId, status);
217
+ updateDevServerStatus(workspaceId, 'running');
218
+ emitEphemeral(workspaceId, 'devserver:status', status);
219
+ break;
220
+ }
221
+ };
222
+ };
223
+ const observeStdout = observeOutput();
224
+ const observeStderr = observeOutput();
167
225
  proc.stdout?.on('data', (data) => {
168
226
  console.log(`[dev-server:${instanceName}] ${data.toString().trim()}`);
227
+ observeStdout(data);
169
228
  });
170
229
  proc.stderr?.on('data', (data) => {
171
230
  console.error(`[dev-server:${instanceName}] ${data.toString().trim()}`);
231
+ observeStderr(data);
172
232
  });
173
233
  proc.on('exit', (code) => {
174
234
  // Shell exit does not imply that its background children have stopped.
@@ -181,6 +241,7 @@ export function startDevServer(workspaceId) {
181
241
  if (trackedProcesses.get(workspaceId) !== proc || stoppingProcesses.has(workspaceId))
182
242
  return;
183
243
  trackedProcesses.delete(workspaceId);
244
+ directServerStatuses.delete(workspaceId);
184
245
  const currentStatus = await getStatus(workspace.projectPath, workspace.workingBranch);
185
246
  if (shuttingDown || generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
186
247
  return;
@@ -200,6 +261,7 @@ export function startDevServer(workspaceId) {
200
261
  // A signal failure is also an 'error' event; it does not prove process exit.
201
262
  if (!proc.pid)
202
263
  trackedProcesses.delete(workspaceId);
264
+ directServerStatuses.delete(workspaceId);
203
265
  updateDevServerStatus(workspaceId, 'error');
204
266
  console.error(`[dev-server] Process error for workspace ${workspaceId}:`, err);
205
267
  emitEphemeral(workspaceId, 'devserver:status', {
@@ -233,6 +295,7 @@ export async function stopDevServer(workspaceId) {
233
295
  if (pending)
234
296
  return pending;
235
297
  generations.set(workspaceId, (generations.get(workspaceId) ?? 0) + 1);
298
+ directServerStatuses.delete(workspaceId);
236
299
  const stopping = stopDevServerOwned(workspaceId);
237
300
  stoppingProcesses.set(workspaceId, stopping);
238
301
  try {
@@ -333,6 +396,7 @@ async function stopTrackedProcess(workspaceId) {
333
396
  throw new Error('Dev server stop is not confirmed; retry after the process exits');
334
397
  if (trackedProcesses.get(workspaceId) === tracked)
335
398
  trackedProcesses.delete(workspaceId);
399
+ directServerStatuses.delete(workspaceId);
336
400
  }
337
401
  async function stopDevServerOwned(workspaceId) {
338
402
  const workspace = getWorkspace(workspaceId);
@@ -70,9 +70,10 @@ function liveProbe() {
70
70
  }
71
71
  /** No credential files, provider calls, generated prompts, or raw process errors in this report. */
72
72
  export async function checkEnvironment(request, probe = liveProbe()) {
73
- const [major, minor] = probe.nodeVersion.split('.').map(Number);
73
+ const [major, minor, patch] = probe.nodeVersion.split('.').map(Number);
74
+ const supportedNode = major > 26 || (major === 26 && (minor > 12 || (minor === 12 && patch >= 1)));
74
75
  const checks = [
75
- { code: 'node', status: major > 24 || (major === 24 && minor >= 15) ? 'ok' : 'error' },
76
+ { code: 'node', status: supportedNode ? 'ok' : 'error' },
76
77
  { code: 'platform', status: ['linux', 'darwin'].includes(probe.platform) ? 'ok' : 'error' },
77
78
  ];
78
79
  const commandAvailable = async (command, args) => {