@loicngr/kobo 2.1.26 → 2.1.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 (126) hide show
  1. package/AGENTS.md +6 -3
  2. package/CHANGELOG.md +11 -0
  3. package/CONFIGURATION.md +97 -49
  4. package/SECURITY.md +33 -6
  5. package/dist/mcp-server/kobo-tasks-server.js +36 -1021
  6. package/dist/mcp-server/managed-mcp-dispatcher.js +960 -0
  7. package/dist/mcp-server/workspace-dialogue-client.js +26 -1
  8. package/dist/server/index.js +52 -19
  9. package/dist/server/middleware/host-check-middleware.js +8 -5
  10. package/dist/server/middleware/network-auth-middleware.js +51 -29
  11. package/dist/server/middleware/security-headers-middleware.js +19 -0
  12. package/dist/server/middleware/traffic-limits-middleware.js +74 -0
  13. package/dist/server/routes/auth.js +73 -0
  14. package/dist/server/routes/internal-mcp.js +137 -0
  15. package/dist/server/routes/mcp-credentials.js +30 -0
  16. package/dist/server/routes/mcp.js +156 -76
  17. package/dist/server/routes/workspaces.js +19 -3
  18. package/dist/server/services/agent/orchestrator.js +27 -17
  19. package/dist/server/services/external-mcp-credentials-service.js +110 -0
  20. package/dist/server/services/forge/github/provider.js +15 -3
  21. package/dist/server/services/forge/types.js +8 -0
  22. package/dist/server/services/internal-mcp-capability-service.js +98 -0
  23. package/dist/server/services/mcp-connection-info-service.js +1 -1
  24. package/dist/server/services/memory-mcp-service.js +9 -7
  25. package/dist/server/services/memory-service.js +15 -0
  26. package/dist/server/services/network-access-service.js +43 -73
  27. package/dist/server/services/network-session-service.js +182 -0
  28. package/dist/server/services/settings-service.js +29 -7
  29. package/dist/server/services/terminal-websocket-service.js +5 -0
  30. package/dist/server/services/websocket-service.js +5 -0
  31. package/dist/server/utils/paths.js +2 -1
  32. package/dist/server/utils/startup-display.js +1 -2
  33. package/dist/shared/models.js +6 -0
  34. package/docker-compose.vps.yml +6 -0
  35. package/docs/deployment-vps.md +24 -12
  36. package/package.json +1 -1
  37. package/src/client/dist/pwa/assets/ActivityFeed-D58VS9o2.js +2 -0
  38. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-DOr41E2Y.js → AutoLoopFinalReviewSettings-CK3r_fVB.js} +1 -1
  39. package/src/client/dist/pwa/assets/{ChangelogPage-DOCgZMpn.js → ChangelogPage-CAAUEn5X.js} +1 -1
  40. package/src/client/dist/pwa/assets/ComparisonPanel-BCb7vB5E.js +2 -0
  41. package/src/client/dist/pwa/assets/CreatePage-DasH1Uk4.js +2 -0
  42. package/src/client/dist/pwa/assets/{DashboardPage-dBpJaQSb.js → DashboardPage-XE52EABP.js} +1 -1
  43. package/src/client/dist/pwa/assets/DiffViewer-CncKlXne.js +8 -0
  44. package/src/client/dist/pwa/assets/{DocumentsPanel-AqLx0MzB.js → DocumentsPanel-L4pbQfz1.js} +1 -1
  45. package/src/client/dist/pwa/assets/{DrawerToggleButton-BLWaFgh0.js → DrawerToggleButton-DiOJm5he.js} +1 -1
  46. package/src/client/dist/pwa/assets/GitPanel-WJlzy2VJ.js +3 -0
  47. package/src/client/dist/pwa/assets/{GitPanel-CRaLJzjc.css → GitPanel-yHvU38dp.css} +1 -1
  48. package/src/client/dist/pwa/assets/{HealthPage-DeryVLyz.js → HealthPage-hJY5MurK.js} +1 -1
  49. package/src/client/dist/pwa/assets/MainLayout-DgWElK7w.js +2 -0
  50. package/src/client/dist/pwa/assets/{MemoryPanel-B34DENtz.js → MemoryPanel-DtEvh_b7.js} +1 -1
  51. package/src/client/dist/pwa/assets/{MemoryProposalList-BpN_OU8J.js → MemoryProposalList-kgFCm9zz.js} +1 -1
  52. package/src/client/dist/pwa/assets/{QBtnDropdown-BPcK_rVs.js → QBtnDropdown-CWPIugs8.js} +1 -1
  53. package/src/client/dist/pwa/assets/{QExpansionItem-CpwQ8L_F.js → QExpansionItem-D-EGL2b3.js} +1 -1
  54. package/src/client/dist/pwa/assets/{QScrollArea-BkNSVl7s.js → QScrollArea-BMrqK-xd.js} +1 -1
  55. package/src/client/dist/pwa/assets/QSelect-DFWyDWCE.js +1 -0
  56. package/src/client/dist/pwa/assets/{QTooltip-Dp8fitHV.js → QTooltip-Bk8dLUtU.js} +1 -1
  57. package/src/client/dist/pwa/assets/{SchedulePanel-Ck8LKDCa.js → SchedulePanel-CuxmxCKi.js} +1 -1
  58. package/src/client/dist/pwa/assets/{SearchPage-BAUZg1zq.js → SearchPage-DKnQPUqY.js} +1 -1
  59. package/src/client/dist/pwa/assets/{SettingsPage-Dcq1Jeoo.css → SettingsPage-B4rmd2WX.css} +1 -1
  60. package/src/client/dist/pwa/assets/SettingsPage-CO3vIpPd.js +9 -0
  61. package/src/client/dist/pwa/assets/{SplitWorkspacePage-CH8OHmtn.js → SplitWorkspacePage-WhQyX8fn.js} +1 -1
  62. package/src/client/dist/pwa/assets/{TerminalPanel-CBmZ0IzQ.js → TerminalPanel-BY0lPq6k.js} +10 -10
  63. package/src/client/dist/pwa/assets/{TerminalPanel-Dz9EE-Ph.css → TerminalPanel-BcbwuFVR.css} +1 -1
  64. package/src/client/dist/pwa/assets/{ToolCallItem-CLtGTV6w.js → ToolCallItem-DYubi-J0.js} +3 -3
  65. package/src/client/dist/pwa/assets/{TouchPan-9SH2dx6m.js → TouchPan-DDotyYLN.js} +1 -1
  66. package/src/client/dist/pwa/assets/{TourReplayButton-Dw4sGf9B.js → TourReplayButton-kAnEMcy2.js} +1 -1
  67. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-wd_sObJa.js → WorkspaceAttentionLabels-DiYt3rP3.js} +1 -1
  68. package/src/client/dist/pwa/assets/WorkspacePage-K-1ce50d.js +4 -0
  69. package/src/client/dist/pwa/assets/{browser-CC4W8r76.js → browser-DVYLH8vl.js} +1 -1
  70. package/src/client/dist/pwa/assets/{build-path-tree-BBuyB-my.js → build-path-tree-DEuOdIIk.js} +1 -1
  71. package/src/client/dist/pwa/assets/custom-sounds-Ggw6V9iF.js +1 -0
  72. package/src/client/dist/pwa/assets/de-Bk3zQrin.js +17 -0
  73. package/src/client/dist/pwa/assets/{editor.api-BwfxvQ88.js → editor.api-BC38eXAS.js} +1 -1
  74. package/src/client/dist/pwa/assets/{efforts-B3I7w5bs.js → efforts-TGacUPcJ.js} +1 -1
  75. package/src/client/dist/pwa/assets/es-f3xuAWA9.js +17 -0
  76. package/src/client/dist/pwa/assets/{expand-template-Ci9UjnxZ.js → expand-template-DM8gq3Mo.js} +1 -1
  77. package/src/client/dist/pwa/assets/{format-CJIzi3Vf.js → format-OwxXBnDJ.js} +1 -1
  78. package/src/client/dist/pwa/assets/{formatters-DQ8cP7nO.js → formatters-YgaCZcwJ.js} +1 -1
  79. package/src/client/dist/pwa/assets/fr-B6c21SG3.js +17 -0
  80. package/src/client/dist/pwa/assets/{freemarker2-DbpVO-cL.js → freemarker2-l7R42fI2.js} +1 -1
  81. package/src/client/dist/pwa/assets/{handlebars-BHve0PpA.js → handlebars-DFmPrgjH.js} +1 -1
  82. package/src/client/dist/pwa/assets/{html-BhUkdFPz.js → html-CERIAlPM.js} +1 -1
  83. package/src/client/dist/pwa/assets/i18n-WaE4cZK9.js +1 -0
  84. package/src/client/dist/pwa/assets/index-CpWBGKdM.js +20 -0
  85. package/src/client/dist/pwa/assets/it-DUstDpC2.js +17 -0
  86. package/src/client/dist/pwa/assets/{javascript-BLAorPa3.js → javascript-BBYncplF.js} +1 -1
  87. package/src/client/dist/pwa/assets/{liquid-CJGF-h-f.js → liquid-C1HqYyYU.js} +1 -1
  88. package/src/client/dist/pwa/assets/{mdx-Cm0oNz_l.js → mdx-E74dsP22.js} +1 -1
  89. package/src/client/dist/pwa/assets/{monaco-contributions-nBg6wf05.js → monaco-contributions-DHvm-kp5.js} +1 -1
  90. package/src/client/dist/pwa/assets/{monaco.contribution-hr9s26Ud.js → monaco.contribution-DYdn9CwR.js} +2 -2
  91. package/src/client/dist/pwa/assets/network-auth-DTZUKbny.js +1 -0
  92. package/src/client/dist/pwa/assets/{notify-theme-EcbxuH_8.js → notify-theme-FuTL7HuS.js} +1 -1
  93. package/src/client/dist/pwa/assets/{permissionModes-DLWiBfdb.js → permissionModes-D-Nexnoo.js} +1 -1
  94. package/src/client/dist/pwa/assets/{project-color-vDuTaTJn.js → project-color-_snXWYUp.js} +1 -1
  95. package/src/client/dist/pwa/assets/{python-BEjbzFAQ.js → python-DNAAGBcX.js} +1 -1
  96. package/src/client/dist/pwa/assets/{razor-BJPT7Wxx.js → razor-CB3g5p_T.js} +1 -1
  97. package/src/client/dist/pwa/assets/{typescript-DPqM46Ef.js → typescript-0kpz8bqP.js} +1 -1
  98. package/src/client/dist/pwa/assets/{use-search-index-status-BwwrB410.js → use-search-index-status-D38yFOD_.js} +1 -1
  99. package/src/client/dist/pwa/assets/{use-tours-B0KtCS9w.js → use-tours-BWkHp34X.js} +2 -2
  100. package/src/client/dist/pwa/assets/{use-worktree-restore-tzd2FESU.js → use-worktree-restore-DUjZr44N.js} +1 -1
  101. package/src/client/dist/pwa/assets/{workspace-templates-CubzsZUU.js → workspace-templates-D-lPJQ4G.js} +1 -1
  102. package/src/client/dist/pwa/assets/{xml-C6SVzw5_.js → xml-BbOuqdUe.js} +1 -1
  103. package/src/client/dist/pwa/assets/{yaml-OpCqnv4g.js → yaml-gasFQtfk.js} +1 -1
  104. package/src/client/dist/pwa/index.html +1 -1
  105. package/src/client/dist/pwa/sw.js +1 -1
  106. package/src/mcp-server/README.md +13 -19
  107. package/src/mcp-server/kobo-tasks-server.ts +42 -1146
  108. package/src/mcp-server/managed-mcp-dispatcher.ts +1071 -0
  109. package/src/mcp-server/workspace-dialogue-client.ts +26 -1
  110. package/src/client/dist/pwa/assets/ActivityFeed-DfYrVNEh.js +0 -2
  111. package/src/client/dist/pwa/assets/ComparisonPanel-b5nTBjK6.js +0 -2
  112. package/src/client/dist/pwa/assets/CreatePage-B7VPHEqm.js +0 -2
  113. package/src/client/dist/pwa/assets/DiffViewer-BLQz2Qyx.js +0 -8
  114. package/src/client/dist/pwa/assets/GitPanel-Ba7ltCsM.js +0 -3
  115. package/src/client/dist/pwa/assets/MainLayout-B2XvjdWi.js +0 -2
  116. package/src/client/dist/pwa/assets/QSelect-DrLpbvcr.js +0 -1
  117. package/src/client/dist/pwa/assets/SettingsPage-Cj6kSVT5.js +0 -9
  118. package/src/client/dist/pwa/assets/WorkspacePage-D4ODPOG-.js +0 -4
  119. package/src/client/dist/pwa/assets/custom-sounds-BbPNRzAs.js +0 -1
  120. package/src/client/dist/pwa/assets/de-D9VzPDGF.js +0 -17
  121. package/src/client/dist/pwa/assets/es-3R4MJuTt.js +0 -17
  122. package/src/client/dist/pwa/assets/fr-DXwDVnJ4.js +0 -17
  123. package/src/client/dist/pwa/assets/i18n-CGLQAADf.js +0 -1
  124. package/src/client/dist/pwa/assets/index-CNcXBpzz.js +0 -20
  125. package/src/client/dist/pwa/assets/it-T9AjnG42.js +0 -17
  126. package/src/client/dist/pwa/assets/network-auth-C850jGfI.js +0 -1
@@ -45,8 +45,9 @@ export async function callWorkspaceDialogueTool(backendUrl, name, args, token, c
45
45
  const transport = new StreamableHTTPClientTransport(new URL(`${backendUrl.replace(/\/$/, '')}/api/mcp`), {
46
46
  requestInit: {
47
47
  signal,
48
+ redirect: 'error',
48
49
  headers: {
49
- ...(token ? { 'X-Kobo-Token': token } : {}),
50
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
50
51
  'X-Kobo-Client-Name': encodeURIComponent(normalizeClientName(clientName)),
51
52
  'X-Kobo-Client-Name-Encoding': 'uri',
52
53
  'X-Kobo-Client-Transport': 'stdio',
@@ -61,3 +62,27 @@ export async function callWorkspaceDialogueTool(backendUrl, name, args, token, c
61
62
  await client.close();
62
63
  }
63
64
  }
65
+ /** Discovery uses the same principal and live grants as execution. */
66
+ export async function listWorkspaceDialogueTools(backendUrl, token, clientName) {
67
+ const client = new Client({ name: 'kobo-stdio-bridge', version: '1.0.0' });
68
+ const signal = AbortSignal.timeout(30_000);
69
+ const transport = new StreamableHTTPClientTransport(new URL(`${backendUrl.replace(/\/$/, '')}/api/mcp`), {
70
+ requestInit: {
71
+ signal,
72
+ redirect: 'error',
73
+ headers: {
74
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
75
+ 'X-Kobo-Client-Name': encodeURIComponent(normalizeClientName(clientName)),
76
+ 'X-Kobo-Client-Name-Encoding': 'uri',
77
+ 'X-Kobo-Client-Transport': 'stdio',
78
+ },
79
+ },
80
+ });
81
+ try {
82
+ await client.connect(transport, { signal });
83
+ return await client.listTools({}, { signal, timeout: 30_000 });
84
+ }
85
+ finally {
86
+ await client.close();
87
+ }
88
+ }
@@ -7,8 +7,11 @@ import { WebSocketServer } from 'ws';
7
7
  import { closeDb, getDb } from './db/index.js';
8
8
  import { getPendingMigrations, runMigrations } from './db/migrations.js';
9
9
  import { hostCheckMiddleware } from './middleware/host-check-middleware.js';
10
- import { networkAuthMiddleware } from './middleware/network-auth-middleware.js';
10
+ import { networkAuthMiddleware, operatorDispatchAuthMiddleware } from './middleware/network-auth-middleware.js';
11
+ import { securityHeadersMiddleware } from './middleware/security-headers-middleware.js';
12
+ import { requestBodyLimitsMiddleware, trafficLimitsMiddleware } from './middleware/traffic-limits-middleware.js';
11
13
  import activityRouter from './routes/activity.js';
14
+ import authRouter from './routes/auth.js';
12
15
  import changelogRouter from './routes/changelog.js';
13
16
  import devServerRouter from './routes/dev-server.js';
14
17
  import documentsRouter from './routes/documents.js';
@@ -20,8 +23,10 @@ import gitRouter from './routes/git.js';
20
23
  import healthRouter from './routes/health.js';
21
24
  import imagesRouter from './routes/images.js';
22
25
  import integrationsRouter from './routes/integrations.js';
26
+ import internalMcpRouter from './routes/internal-mcp.js';
23
27
  import maintenanceRouter from './routes/maintenance.js';
24
28
  import mcpRouter from './routes/mcp.js';
29
+ import mcpCredentialsRouter from './routes/mcp-credentials.js';
25
30
  import memoryRouter from './routes/memory.js';
26
31
  import { migrationRouter } from './routes/migration.js';
27
32
  import notionRouter from './routes/notion.js';
@@ -47,7 +52,8 @@ import { beginDevServerShutdown, startDevServer, stopAllDevServers, stopDevServe
47
52
  import { onKoboShutdown } from './services/lifecycle-hook-service.js';
48
53
  import { reconcileMessageRequests } from './services/mcp-message-request-service.js';
49
54
  import { reconcileMemoryContextsOnStartup } from './services/memory-context-service.js';
50
- import { authorizeWsUpgrade, generateToken, getLanHostnames, getLanUrls, isAllowedOrigin, isAllowedRequestHost, resolveBindHost, resolveDevClientOrigin, resolveNetworkAccessEnvOverrides, resolveProxyHostname, } from './services/network-access-service.js';
55
+ import { generateToken, getLanHostnames, getLanUrls, isAllowedOrigin, isAllowedRequestHost, resolveBindHost, resolveDevClientOrigin, resolveNetworkAccessEnvOverrides, resolveProxyHostname, resolveProxyOrigin, } from './services/network-access-service.js';
56
+ import { consumeWebSocketTicket, isNetworkTransportAllowed, registerNetworkSocket, } from './services/network-session-service.js';
51
57
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
52
58
  import { reconcileOrphanedProgressBars } from './services/progress-bar-service.js';
53
59
  import * as quotaBackoffService from './services/quota-backoff-service.js';
@@ -156,14 +162,21 @@ const app = new Hono();
156
162
  // that re-resolves its own domain to 127.0.0.1 cannot read our responses as
157
163
  // same-origin. Mounted on every path, ahead of the token gate: the SPA shell
158
164
  // is served outside /api/*, and rebinding targets the browser, not the socket.
165
+ app.use('*', securityHeadersMiddleware());
159
166
  app.use('*', hostCheckMiddleware);
160
167
  // Gate non-loopback requests behind the network-access token (loopback exempt).
168
+ app.use('/api/*', trafficLimitsMiddleware());
161
169
  app.use('/api/*', networkAuthMiddleware);
170
+ app.use('/api/*', requestBodyLimitsMiddleware());
171
+ app.use('/api/*', operatorDispatchAuthMiddleware);
162
172
  // Health check (root / is handled by the SPA catch-all below)
163
173
  app.get('/api/health', (c) => c.json({ status: 'ok', version: getPackageVersion() }));
164
174
  // Mount route sub-routers
165
175
  app.route('/api/workspaces', workspacesRouter);
166
176
  app.route('/api/workspace-messages', workspaceMessagesRouter);
177
+ app.route('/api/auth', authRouter);
178
+ app.route('/api/internal-mcp', internalMcpRouter);
179
+ app.route('/api/mcp-credentials', mcpCredentialsRouter);
167
180
  app.route('/api/mcp', mcpRouter);
168
181
  app.route('/api/memory', memoryRouter);
169
182
  app.route('/api/progress-bars', progressBarsRouter);
@@ -265,7 +278,6 @@ const server = serve({
265
278
  devClientOrigin: resolveDevClientOrigin(),
266
279
  networkEnabled: settings.networkAccessEnabled,
267
280
  lanUrls: settings.networkAccessEnabled ? getLanUrls(info.port) : [],
268
- token: settings.networkAccessToken,
269
281
  changelog: readStartupNotes(getChangelogPath()),
270
282
  color: Boolean(process.stdout.isTTY && process.env.TERM !== 'dumb' && !('NO_COLOR' in process.env)),
271
283
  }));
@@ -288,8 +300,8 @@ server.on('error', (err) => {
288
300
  void gracefulShutdown('server error', 1);
289
301
  });
290
302
  // Create WebSocketServer attached to the HTTP server
291
- const wss = new WebSocketServer({ noServer: true });
292
- const terminalWss = new WebSocketServer({ noServer: true });
303
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
304
+ const terminalWss = new WebSocketServer({ noServer: true, maxPayload: 128 * 1024, perMessageDeflate: false });
293
305
  // Wire WebSocket connections to websocket-service.handleConnection()
294
306
  wss.on('connection', (ws) => {
295
307
  handleConnection(ws);
@@ -353,8 +365,32 @@ server.on('upgrade', (request, socket, head) => {
353
365
  socket.destroy();
354
366
  return;
355
367
  }
356
- const { pathname } = new URL(request.url ?? '/', `http://localhost:${PORT}`);
357
- const wsGlobal = getGlobalSettings();
368
+ let upgradeUrl;
369
+ try {
370
+ const raw = request.url ?? '/';
371
+ if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\'))
372
+ throw new Error('Invalid upgrade path');
373
+ upgradeUrl = new URL(raw, `http://localhost:${PORT}`);
374
+ }
375
+ catch {
376
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
377
+ socket.destroy();
378
+ return;
379
+ }
380
+ const { pathname } = upgradeUrl;
381
+ let wsGlobal;
382
+ try {
383
+ wsGlobal = getGlobalSettings();
384
+ }
385
+ catch {
386
+ socket.destroy();
387
+ return;
388
+ }
389
+ if (!isNetworkTransportAllowed(request.socket.remoteAddress, wsGlobal)) {
390
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
391
+ socket.destroy();
392
+ return;
393
+ }
358
394
  // WebSockets are exempt from the same-origin policy and send no preflight,
359
395
  // so without these two any page the user visits could open /ws/terminal/<id>
360
396
  // and drive a real shell here. Checked before the token gate: on loopback the
@@ -369,7 +405,7 @@ server.on('upgrade', (request, socket, head) => {
369
405
  behindProxy: wsGlobal.networkAccessBehindProxy,
370
406
  proxyHostname: resolveProxyHostname(),
371
407
  })) {
372
- console.warn(`[host-check] WS 403 (forbidden host '${wsHost ?? 'unknown'}') ${pathname}`);
408
+ console.warn('[host-check] WS 403 (forbidden host)');
373
409
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
374
410
  socket.destroy();
375
411
  return;
@@ -380,27 +416,23 @@ server.on('upgrade', (request, socket, head) => {
380
416
  behindProxy: wsGlobal.networkAccessBehindProxy,
381
417
  proxyHostname: resolveProxyHostname(),
382
418
  devOrigin: resolveDevClientOrigin(),
419
+ proxyOrigin: resolveProxyOrigin(),
383
420
  })) {
384
- console.warn(`[origin-check] WS 403 (forbidden origin '${request.headers.origin ?? 'unknown'}') ${pathname}`);
421
+ console.warn('[origin-check] WS 403 (forbidden origin)');
385
422
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
386
423
  socket.destroy();
387
424
  return;
388
425
  }
389
- if (!authorizeWsUpgrade({
390
- address: request.socket.remoteAddress,
391
- rawUrl: request.url,
392
- enabled: wsGlobal.networkAccessEnabled,
393
- expectedToken: wsGlobal.networkAccessToken,
394
- trustLoopback: !wsGlobal.networkAccessBehindProxy,
395
- })) {
396
- console.warn(`[network-auth] WS 401 (missing/invalid token) from ${request.socket.remoteAddress ?? 'unknown'} ${pathname}`);
426
+ const ticket = consumeWebSocketTicket(upgradeUrl.searchParams.get('ticket'), pathname, wsGlobal);
427
+ if (!ticket) {
397
428
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
398
429
  socket.destroy();
399
430
  return;
400
431
  }
401
432
  if (pathname === '/ws') {
402
433
  wss.handleUpgrade(request, socket, head, (ws) => {
403
- wss.emit('connection', ws, request);
434
+ if (registerNetworkSocket(ws, request.socket.remoteAddress ?? 'unknown', wsGlobal, getGlobalSettings, ticket.session))
435
+ wss.emit('connection', ws, request);
404
436
  });
405
437
  }
406
438
  else if (pathname.startsWith('/ws/terminal/')) {
@@ -410,7 +442,8 @@ server.on('upgrade', (request, socket, head) => {
410
442
  return;
411
443
  }
412
444
  terminalWss.handleUpgrade(request, socket, head, (ws) => {
413
- terminalWss.emit('connection', ws, workspaceId);
445
+ if (registerNetworkSocket(ws, request.socket.remoteAddress ?? 'unknown', wsGlobal, getGlobalSettings, ticket.session))
446
+ terminalWss.emit('connection', ws, workspaceId);
414
447
  });
415
448
  }
416
449
  else {
@@ -1,4 +1,4 @@
1
- import { getLanHostnames, isAllowedOrigin, isAllowedRequestHost, isLocalRequestHost, resolveDevClientOrigin, resolveProxyHostname, } from '../services/network-access-service.js';
1
+ import { getLanHostnames, isAllowedOrigin, isAllowedRequestHost, isLocalRequestHost, resolveDevClientOrigin, resolveProxyHostname, resolveProxyOrigin, } from '../services/network-access-service.js';
2
2
  import { getGlobalSettings } from '../services/settings-service.js';
3
3
  /**
4
4
  * Rejects requests addressed to a hostname this server is not legitimately
@@ -24,11 +24,12 @@ export const hostCheckMiddleware = async (c, next) => {
24
24
  return next();
25
25
  let host;
26
26
  try {
27
- host = new URL(c.req.url).host;
27
+ host = c.req.header('host') ?? new URL(c.req.url).host;
28
28
  }
29
29
  catch {
30
30
  host = undefined;
31
31
  }
32
+ const requestProtocol = new URL(c.req.url).protocol;
32
33
  const origin = c.req.header('origin');
33
34
  // Fast path. This middleware runs on every request, static assets included,
34
35
  // and the calls below are a synchronous settings read plus an interface
@@ -39,7 +40,7 @@ export const hostCheckMiddleware = async (c, next) => {
39
40
  return next();
40
41
  const devOrigin = resolveDevClientOrigin();
41
42
  // A page we served ourselves, talking to us: still no settings needed.
42
- if (isLocalRequestHost(host) && isAllowedOrigin({ origin, requestHost: host, devOrigin })) {
43
+ if (isLocalRequestHost(host) && isAllowedOrigin({ origin, requestHost: host, devOrigin, requestProtocol })) {
43
44
  return next();
44
45
  }
45
46
  let global;
@@ -63,7 +64,7 @@ export const hostCheckMiddleware = async (c, next) => {
63
64
  proxyHostname: resolveProxyHostname(),
64
65
  })) {
65
66
  // Surface it: "the page just stopped loading" is otherwise undebuggable.
66
- console.warn(`[host-check] 403 (forbidden host '${host ?? 'unknown'}') ${c.req.method} ${c.req.path}`);
67
+ console.warn('[host-check] 403 (forbidden host)');
67
68
  return c.json({ error: 'forbidden host' }, 403);
68
69
  }
69
70
  if (!isAllowedOrigin({
@@ -72,8 +73,10 @@ export const hostCheckMiddleware = async (c, next) => {
72
73
  behindProxy: global.networkAccessBehindProxy,
73
74
  proxyHostname: resolveProxyHostname(),
74
75
  devOrigin,
76
+ requestProtocol,
77
+ proxyOrigin: resolveProxyOrigin(),
75
78
  })) {
76
- console.warn(`[origin-check] 403 (forbidden origin '${origin}') ${c.req.method} ${c.req.path}`);
79
+ console.warn('[origin-check] 403 (forbidden origin)');
77
80
  return c.json({ error: 'forbidden origin' }, 403);
78
81
  }
79
82
  return next();
@@ -1,42 +1,64 @@
1
1
  import { getConnInfo } from '@hono/node-server/conninfo';
2
- import { evaluateNetworkAccess } from '../services/network-access-service.js';
2
+ import { getCookie } from 'hono/cookie';
3
+ import { evaluateNetworkAccess, isLoopbackAddress } from '../services/network-access-service.js';
4
+ import { browserCookieName, getBrowserSession } from '../services/network-session-service.js';
3
5
  import { getGlobalSettings } from '../services/settings-service.js';
4
- /**
5
- * Gates non-loopback requests behind the network-access token.
6
- *
7
- * Loopback requests pass without a token (the host machine's own usage is
8
- * frictionless) — unless `networkAccessBehindProxy` is enabled, in which case
9
- * loopback is treated like any other address (see network-access-service.ts).
10
- * The client IP comes only from the OS socket via getConnInfo, never from
11
- * X-Forwarded-For, so a remote client cannot spoof a loopback address.
12
- *
13
- * `/api/health` is always exempt, regardless of `networkAccessBehindProxy`:
14
- * it exposes only `{ status, version }` (nothing sensitive), and Docker's own
15
- * HEALTHCHECK / a Compose healthcheck / an orchestrator's liveness probe has
16
- * no way to supply the runtime-generated token — without this exemption, a
17
- * "behind a reverse proxy" deployment's own container never reports healthy.
18
- */
6
+ /** Socket addresses are authoritative; forwarded headers never establish trust. */
19
7
  export const networkAuthMiddleware = async (c, next) => {
20
8
  if (c.req.path === '/api/health')
21
9
  return next();
22
10
  const address = getConnInfo(c).remote.address;
23
- const global = getGlobalSettings();
24
- // MCP clients commonly send the shared token as an Authorization header.
25
- const bearerToken = /^\/api\/mcp\/?$/.test(c.req.path)
26
- ? c.req.header('Authorization')?.match(/^Bearer\s+(.+)$/i)?.[1]
27
- : undefined;
11
+ const policy = getGlobalSettings();
12
+ const loopback = isLoopbackAddress(address);
13
+ // These two transports authenticate themselves, with credentials that cannot
14
+ // authorize any other HTTP route. Internal capabilities never cross the network.
15
+ if (/^\/api\/internal-mcp(?:\/|$)/.test(c.req.path)) {
16
+ if (c.req.method !== 'POST' || !loopback)
17
+ return c.json({ error: 'unauthorized' }, 403);
18
+ return next();
19
+ }
20
+ if (c.req.path === '/api/mcp' || c.req.path === '/api/mcp/') {
21
+ if (!policy.networkAccessEnabled && (!loopback || policy.networkAccessBehindProxy))
22
+ return c.json({ error: 'unauthorized' }, 403);
23
+ return next();
24
+ }
25
+ // Supplying an MCP credential never becomes operator authority, even where
26
+ // unauthenticated local operator requests are normally trusted.
27
+ if (/^Bearer\s+kobo_mcp_/i.test(c.req.header('Authorization') ?? '') ||
28
+ c.req.header('X-Kobo-Token')?.startsWith('kobo_mcp_')) {
29
+ return c.json({ error: 'Operator credential required' }, 401);
30
+ }
31
+ const session = getBrowserSession(getCookie(c, browserCookieName(policy)), policy);
28
32
  const decision = evaluateNetworkAccess({
29
33
  address,
30
- enabled: global.networkAccessEnabled,
31
- expectedToken: global.networkAccessToken,
32
- providedToken: c.req.header('X-Kobo-Token') ?? bearerToken,
33
- trustLoopback: !global.networkAccessBehindProxy,
34
+ enabled: policy.networkAccessEnabled,
35
+ expectedToken: policy.networkAccessToken,
36
+ providedToken: session ? policy.networkAccessToken : c.req.header('X-Kobo-Token'),
37
+ trustLoopback: !policy.networkAccessBehindProxy,
34
38
  });
35
39
  if (decision.allow)
36
40
  return next();
37
- // Surface denied requests so "my device can't connect" is debuggable.
38
- // Never log the token itself, only the reason and the remote address.
39
- const reason = decision.status === 403 ? 'network access disabled' : 'missing/invalid token';
40
- console.warn(`[network-auth] HTTP ${decision.status} (${reason}) from ${address ?? 'unknown'} ${c.req.method} ${c.req.path}`);
41
41
  return c.json({ error: 'unauthorized' }, decision.status);
42
42
  };
43
+ /** Mount after the initial auth gate and body limits, before operator routes.
44
+ * Acceptance happens only after the complete bounded request has arrived.
45
+ * MCP transports implement their own equivalent live-credential checks.
46
+ */
47
+ export const operatorDispatchAuthMiddleware = async (c, next) => {
48
+ if (/^\/api\/(?:internal-)?mcp(?:\/|$)/.test(c.req.path))
49
+ return next();
50
+ if (c.req.raw.body) {
51
+ const original = c.req.raw;
52
+ let body;
53
+ try {
54
+ body = await original.arrayBuffer();
55
+ }
56
+ catch {
57
+ return c.json({ error: 'Invalid request body' }, 400);
58
+ }
59
+ // Preserve raw consumers (multipart uploads and workspace creation), as
60
+ // well as Hono's json/formData helpers. The initial limits ran beforehand.
61
+ c.req.raw = new Request(original, { body });
62
+ }
63
+ return networkAuthMiddleware(c, next);
64
+ };
@@ -0,0 +1,19 @@
1
+ /** Browser protections that also apply to denied requests and SPA responses. */
2
+ export function securityHeadersMiddleware() {
3
+ return async (c, next) => {
4
+ c.header('Content-Security-Policy', "frame-ancestors 'self'");
5
+ c.header('X-Frame-Options', 'SAMEORIGIN');
6
+ c.header('X-Content-Type-Options', 'nosniff');
7
+ c.header('Referrer-Policy', 'no-referrer');
8
+ if (c.req.path === '/api' || c.req.path.startsWith('/api/')) {
9
+ c.header('Cache-Control', 'no-store');
10
+ c.header('Pragma', 'no-cache');
11
+ }
12
+ // HSTS belongs at the TLS terminator. Forwarded headers are client input
13
+ // unless a proxy is explicitly trusted, and local HTTP must remain usable.
14
+ await next();
15
+ if (c.req.path === '/api' || c.req.path.startsWith('/api/')) {
16
+ c.header('Cache-Control', 'no-store');
17
+ }
18
+ };
19
+ }
@@ -0,0 +1,74 @@
1
+ import { getConnInfo } from '@hono/node-server/conninfo';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ /** Bounded process-local limits, keyed by OS peers, never attacker-controlled forwarded IPs. */
4
+ export function trafficLimitsMiddleware() {
5
+ const peers = new Map();
6
+ let activeMcp = 0;
7
+ let activeLargeMcp = 0;
8
+ return async (c, next) => {
9
+ if (c.req.path === '/api/health')
10
+ return next();
11
+ const now = Date.now();
12
+ const address = getConnInfo(c).remote.address ?? 'unknown';
13
+ let peer = peers.get(address);
14
+ if (!peer || peer.reset <= now) {
15
+ for (const [key, value] of peers)
16
+ if (value.reset <= now)
17
+ peers.delete(key);
18
+ if (peers.size >= 4096 && !peers.has(address))
19
+ return c.json({ error: 'Too many clients' }, 429);
20
+ peer = { reset: now + 60_000, requests: 0, logins: 0 };
21
+ peers.set(address, peer);
22
+ }
23
+ if (++peer.requests > 3000 ||
24
+ (c.req.path === '/api/auth/session' && c.req.method === 'POST' && ++peer.logins > 20)) {
25
+ c.header('Retry-After', String(Math.max(1, Math.ceil((peer.reset - now) / 1000))));
26
+ return c.json({ error: 'Too many requests' }, 429);
27
+ }
28
+ const mcp = /^\/api\/(?:internal-)?mcp\/?$/.test(c.req.path);
29
+ // Node rejects mismatched Content-Length framing. Unknown/chunked lengths
30
+ // reserve a large-body slot until completion without reading any bytes.
31
+ const length = c.req.header('Content-Length');
32
+ const largeMcp = mcp && c.req.raw.body !== null && (length === undefined || !/^\d+$/.test(length) || Number(length) > 1024 * 1024);
33
+ if (mcp && (activeMcp >= 32 || (largeMcp && activeLargeMcp >= 4)))
34
+ return c.json({ error: 'Too many active MCP requests' }, 429);
35
+ if (mcp)
36
+ activeMcp++;
37
+ if (largeMcp)
38
+ activeLargeMcp++;
39
+ // Do not buffer bodies here: authentication must finish first.
40
+ try {
41
+ return await next();
42
+ }
43
+ finally {
44
+ if (mcp)
45
+ activeMcp--;
46
+ if (largeMcp)
47
+ activeLargeMcp--;
48
+ }
49
+ };
50
+ }
51
+ /** Mount after operator authentication. MCP routes authenticate and bound their own bodies. */
52
+ export function requestBodyLimitsMiddleware() {
53
+ const small = bodyLimit({ maxSize: 1024 * 1024 });
54
+ const large = bodyLimit({ maxSize: 72 * 1024 * 1024 });
55
+ let activeUploads = 0;
56
+ return async (c, next) => {
57
+ if (/^\/api\/(?:internal-)?mcp\/?$/.test(c.req.path))
58
+ return next();
59
+ const attachments = c.req.path === '/api/workspaces' ||
60
+ /^\/api\/workspaces\/[^/]+\/(images|attachments|messages)(?:\/|$)/.test(c.req.path) ||
61
+ c.req.path.startsWith('/api/voice/');
62
+ if (!attachments)
63
+ return small(c, next);
64
+ if (activeUploads >= 4)
65
+ return c.json({ error: 'Too many active uploads' }, 429);
66
+ activeUploads++;
67
+ try {
68
+ return await large(c, next);
69
+ }
70
+ finally {
71
+ activeUploads--;
72
+ }
73
+ };
74
+ }
@@ -0,0 +1,73 @@
1
+ import { getConnInfo } from '@hono/node-server/conninfo';
2
+ import { Hono } from 'hono';
3
+ import { bodyLimit } from 'hono/body-limit';
4
+ import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
5
+ import { evaluateNetworkAccess, tokenMatches } from '../services/network-access-service.js';
6
+ import { browserCookieName, createBrowserSession, createWebSocketTicket, getBrowserSession, revokeBrowserSession, secureBrowserCookie, } from '../services/network-session-service.js';
7
+ import { getGlobalSettings } from '../services/settings-service.js';
8
+ const router = new Hono();
9
+ router.use('*', bodyLimit({ maxSize: 1024 }));
10
+ router.post('/session', (c) => {
11
+ const policy = getGlobalSettings();
12
+ // Even a local unauthenticated caller cannot mint an authenticated session.
13
+ if (!policy.networkAccessToken || !tokenMatches(c.req.header('X-Kobo-Token') ?? '', policy.networkAccessToken))
14
+ return c.json({ error: 'unauthorized' }, 401);
15
+ try {
16
+ const session = createBrowserSession(policy);
17
+ setCookie(c, browserCookieName(policy), session.token, {
18
+ httpOnly: true,
19
+ secure: secureBrowserCookie(policy),
20
+ sameSite: 'Strict',
21
+ path: '/',
22
+ maxAge: 12 * 60 * 60,
23
+ });
24
+ return c.json({ ok: true });
25
+ }
26
+ catch {
27
+ return c.json({ error: 'Session capacity reached' }, 429);
28
+ }
29
+ });
30
+ router.delete('/session', (c) => {
31
+ const policy = getGlobalSettings();
32
+ const token = getCookie(c, browserCookieName(policy));
33
+ if (token)
34
+ revokeBrowserSession(token);
35
+ deleteCookie(c, browserCookieName(policy), { path: '/', secure: secureBrowserCookie(policy) });
36
+ return c.json({ ok: true });
37
+ });
38
+ router.post('/ws-ticket', async (c) => {
39
+ let body;
40
+ try {
41
+ body = await c.req.json();
42
+ }
43
+ catch {
44
+ return c.json({ error: 'Invalid JSON' }, 400);
45
+ }
46
+ if (!body ||
47
+ typeof body !== 'object' ||
48
+ !('path' in body) ||
49
+ typeof body.path !== 'string' ||
50
+ !/^\/ws(?:\/terminal\/[A-Za-z0-9_-]{1,200})?$/.test(body.path))
51
+ return c.json({ error: 'Invalid WebSocket path' }, 400);
52
+ const policy = getGlobalSettings();
53
+ const cookie = getCookie(c, browserCookieName(policy));
54
+ const session = getBrowserSession(cookie, policy);
55
+ // Stale cookies must not override a valid operator header or trusted local access.
56
+ // Remote callers still need a current session or token in the policy check below.
57
+ const decision = evaluateNetworkAccess({
58
+ address: getConnInfo(c).remote.address,
59
+ enabled: policy.networkAccessEnabled,
60
+ expectedToken: policy.networkAccessToken,
61
+ providedToken: session ? policy.networkAccessToken : c.req.header('X-Kobo-Token'),
62
+ trustLoopback: !policy.networkAccessBehindProxy,
63
+ });
64
+ if (!decision.allow)
65
+ return c.json({ error: 'unauthorized' }, decision.status);
66
+ try {
67
+ return c.json({ ticket: createWebSocketTicket(body.path, policy, session) });
68
+ }
69
+ catch {
70
+ return c.json({ error: 'Ticket capacity reached' }, 429);
71
+ }
72
+ });
73
+ export default router;
@@ -0,0 +1,137 @@
1
+ import { Hono } from 'hono';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import { createManagedMcpDispatcher } from '../../mcp-server/managed-mcp-dispatcher.js';
4
+ import { MAX_MCP_ATTACHMENT_REQUEST_BYTES } from '../../shared/mcp-attachments.js';
5
+ import { normalizeClientName } from '../../shared/workspace-message-types.js';
6
+ import { getDb } from '../db/index.js';
7
+ import { authorizeInternalMcpTool, resolveInternalMcpCapability } from '../services/internal-mcp-capability-service.js';
8
+ import { getSettingsPath } from '../utils/paths.js';
9
+ const app = new Hono();
10
+ app.use('*', async (c, next) => {
11
+ try {
12
+ resolveInternalMcpCapability(c.req.header('X-Kobo-Mcp-Session'));
13
+ }
14
+ catch {
15
+ return c.json({ isError: true, content: [{ type: 'text', text: 'Invalid or revoked managed MCP capability' }] }, 403);
16
+ }
17
+ return next();
18
+ });
19
+ app.use('*', bodyLimit({ maxSize: MAX_MCP_ATTACHMENT_REQUEST_BYTES }));
20
+ app.post('/', async (c) => {
21
+ let input;
22
+ try {
23
+ input = await c.req.json();
24
+ }
25
+ catch {
26
+ return c.json({ error: 'Invalid JSON' }, 400);
27
+ }
28
+ if (!input || typeof input !== 'object' || Array.isArray(input))
29
+ return c.json({ error: 'Expected an object' }, 400);
30
+ const token = c.req.header('X-Kobo-Mcp-Session');
31
+ try {
32
+ const capability = resolveInternalMcpCapability(token);
33
+ const args = input.arguments ?? {};
34
+ if (!args || typeof args !== 'object' || Array.isArray(args))
35
+ return c.json({ error: 'Expected arguments object' }, 400);
36
+ const name = typeof input.name === 'string' ? input.name : '';
37
+ if (input.method !== 'list' && input.method !== 'call')
38
+ return c.json({ error: 'Unknown operation' }, 400);
39
+ if (input.method === 'call')
40
+ authorizeInternalMcpTool(token, name, args);
41
+ // Only attachment tools receive the larger envelope budget.
42
+ if (name !== 'create_workspace' &&
43
+ name !== 'send_workspace_message' &&
44
+ new TextEncoder().encode(JSON.stringify(input)).byteLength > 1024 * 1024)
45
+ return c.json({ error: 'Request too large' }, 413);
46
+ const check = () => {
47
+ if (input.method === 'call')
48
+ authorizeInternalMcpTool(token, name, args);
49
+ else
50
+ resolveInternalMcpCapability(token);
51
+ };
52
+ const dispatch = async (pathname, init) => {
53
+ // All paths originate from the fixed managed-tool dispatcher. Reject path
54
+ // traversal before URL normalization, including caller-supplied IDs.
55
+ const requestPath = pathname.split('?')[0];
56
+ if (requestPath.includes('..') || /[%\\#]/.test(requestPath))
57
+ throw new Error('Invalid managed request path');
58
+ let routes;
59
+ let prefix;
60
+ if (pathname.startsWith('/api/workspaces/')) {
61
+ routes = (await import('./workspaces.js')).default;
62
+ prefix = '/api/workspaces';
63
+ }
64
+ else if (pathname.startsWith('/api/dev-server/')) {
65
+ routes = (await import('./dev-server.js')).default;
66
+ prefix = '/api/dev-server';
67
+ }
68
+ else if (pathname.startsWith('/api/memory/agent/')) {
69
+ routes = (await import('./memory.js')).default;
70
+ prefix = '/api/memory';
71
+ }
72
+ else if (pathname.startsWith('/api/progress-bars/agent/')) {
73
+ routes = (await import('./progress-bars.js')).default;
74
+ prefix = '/api/progress-bars';
75
+ }
76
+ else if (pathname.startsWith('/api/search?')) {
77
+ routes = (await import('./search.js')).default;
78
+ prefix = '/api/search';
79
+ }
80
+ else
81
+ throw new Error('Unsupported managed request');
82
+ check();
83
+ const relative = pathname.slice(prefix.length);
84
+ return routes.request(`http://kobo.internal${relative.startsWith('?') ? '/' : ''}${relative}`, init);
85
+ };
86
+ const backendRequest = async (method, pathname, body) => {
87
+ const response = await dispatch(pathname, {
88
+ method,
89
+ headers: { 'Content-Type': 'application/json' },
90
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
91
+ });
92
+ if (!response.ok)
93
+ throw new Error(`Managed backend returned ${response.status}`);
94
+ const text = await response.text();
95
+ return text ? JSON.parse(text) : null;
96
+ };
97
+ const dispatcher = createManagedMcpDispatcher({
98
+ workspaceId: capability.workspaceId,
99
+ db: getDb(),
100
+ settingsPath: getSettingsPath(),
101
+ backendUrl: 'http://kobo.internal',
102
+ env: capability.env,
103
+ backendRequest,
104
+ fetcher: async (url, init) => dispatch(new URL(String(url)).pathname, init ?? {}),
105
+ dialogueTool: async (_url, tool, arguments_) => {
106
+ const { executeExternalMcpTool } = await import('./mcp.js');
107
+ check();
108
+ return executeExternalMcpTool(tool, arguments_, {
109
+ kind: 'mcp',
110
+ clientName: normalizeClientName(typeof input.clientName === 'string' ? input.clientName : 'kobo-managed-agent'),
111
+ transport: 'stdio',
112
+ });
113
+ },
114
+ });
115
+ if (input.method === 'list')
116
+ return c.json({
117
+ tools: dispatcher.availableTools().filter((tool) => {
118
+ try {
119
+ authorizeInternalMcpTool(token, tool.name, {});
120
+ return true;
121
+ }
122
+ catch {
123
+ return false;
124
+ }
125
+ }),
126
+ });
127
+ check();
128
+ return c.json(await dispatcher.callTool(name, args));
129
+ }
130
+ catch (error) {
131
+ return c.json({
132
+ isError: true,
133
+ content: [{ type: 'text', text: error instanceof Error ? error.message : 'Managed MCP request rejected' }],
134
+ }, 403);
135
+ }
136
+ });
137
+ export default app;