@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.
- package/AGENTS.md +6 -3
- package/CHANGELOG.md +11 -0
- package/CONFIGURATION.md +97 -49
- package/SECURITY.md +33 -6
- package/dist/mcp-server/kobo-tasks-server.js +36 -1021
- package/dist/mcp-server/managed-mcp-dispatcher.js +960 -0
- package/dist/mcp-server/workspace-dialogue-client.js +26 -1
- package/dist/server/index.js +52 -19
- package/dist/server/middleware/host-check-middleware.js +8 -5
- package/dist/server/middleware/network-auth-middleware.js +51 -29
- package/dist/server/middleware/security-headers-middleware.js +19 -0
- package/dist/server/middleware/traffic-limits-middleware.js +74 -0
- package/dist/server/routes/auth.js +73 -0
- package/dist/server/routes/internal-mcp.js +137 -0
- package/dist/server/routes/mcp-credentials.js +30 -0
- package/dist/server/routes/mcp.js +156 -76
- package/dist/server/routes/workspaces.js +19 -3
- package/dist/server/services/agent/orchestrator.js +27 -17
- package/dist/server/services/external-mcp-credentials-service.js +110 -0
- package/dist/server/services/forge/github/provider.js +15 -3
- package/dist/server/services/forge/types.js +8 -0
- package/dist/server/services/internal-mcp-capability-service.js +98 -0
- package/dist/server/services/mcp-connection-info-service.js +1 -1
- package/dist/server/services/memory-mcp-service.js +9 -7
- package/dist/server/services/memory-service.js +15 -0
- package/dist/server/services/network-access-service.js +43 -73
- package/dist/server/services/network-session-service.js +182 -0
- package/dist/server/services/settings-service.js +29 -7
- package/dist/server/services/terminal-websocket-service.js +5 -0
- package/dist/server/services/websocket-service.js +5 -0
- package/dist/server/utils/paths.js +2 -1
- package/dist/server/utils/startup-display.js +1 -2
- package/dist/shared/models.js +6 -0
- package/docker-compose.vps.yml +6 -0
- package/docs/deployment-vps.md +24 -12
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-D58VS9o2.js +2 -0
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-DOr41E2Y.js → AutoLoopFinalReviewSettings-CK3r_fVB.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-DOCgZMpn.js → ChangelogPage-CAAUEn5X.js} +1 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-BCb7vB5E.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-DasH1Uk4.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-dBpJaQSb.js → DashboardPage-XE52EABP.js} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-CncKlXne.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-AqLx0MzB.js → DocumentsPanel-L4pbQfz1.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-BLWaFgh0.js → DrawerToggleButton-DiOJm5he.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-WJlzy2VJ.js +3 -0
- package/src/client/dist/pwa/assets/{GitPanel-CRaLJzjc.css → GitPanel-yHvU38dp.css} +1 -1
- package/src/client/dist/pwa/assets/{HealthPage-DeryVLyz.js → HealthPage-hJY5MurK.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-DgWElK7w.js +2 -0
- package/src/client/dist/pwa/assets/{MemoryPanel-B34DENtz.js → MemoryPanel-DtEvh_b7.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-BpN_OU8J.js → MemoryProposalList-kgFCm9zz.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-BPcK_rVs.js → QBtnDropdown-CWPIugs8.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-CpwQ8L_F.js → QExpansionItem-D-EGL2b3.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-BkNSVl7s.js → QScrollArea-BMrqK-xd.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-DFWyDWCE.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-Dp8fitHV.js → QTooltip-Bk8dLUtU.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-Ck8LKDCa.js → SchedulePanel-CuxmxCKi.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-BAUZg1zq.js → SearchPage-DKnQPUqY.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-Dcq1Jeoo.css → SettingsPage-B4rmd2WX.css} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-CO3vIpPd.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-CH8OHmtn.js → SplitWorkspacePage-WhQyX8fn.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-CBmZ0IzQ.js → TerminalPanel-BY0lPq6k.js} +10 -10
- package/src/client/dist/pwa/assets/{TerminalPanel-Dz9EE-Ph.css → TerminalPanel-BcbwuFVR.css} +1 -1
- package/src/client/dist/pwa/assets/{ToolCallItem-CLtGTV6w.js → ToolCallItem-DYubi-J0.js} +3 -3
- package/src/client/dist/pwa/assets/{TouchPan-9SH2dx6m.js → TouchPan-DDotyYLN.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-Dw4sGf9B.js → TourReplayButton-kAnEMcy2.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-wd_sObJa.js → WorkspaceAttentionLabels-DiYt3rP3.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-K-1ce50d.js +4 -0
- package/src/client/dist/pwa/assets/{browser-CC4W8r76.js → browser-DVYLH8vl.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-BBuyB-my.js → build-path-tree-DEuOdIIk.js} +1 -1
- package/src/client/dist/pwa/assets/custom-sounds-Ggw6V9iF.js +1 -0
- package/src/client/dist/pwa/assets/de-Bk3zQrin.js +17 -0
- package/src/client/dist/pwa/assets/{editor.api-BwfxvQ88.js → editor.api-BC38eXAS.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-B3I7w5bs.js → efforts-TGacUPcJ.js} +1 -1
- package/src/client/dist/pwa/assets/es-f3xuAWA9.js +17 -0
- package/src/client/dist/pwa/assets/{expand-template-Ci9UjnxZ.js → expand-template-DM8gq3Mo.js} +1 -1
- package/src/client/dist/pwa/assets/{format-CJIzi3Vf.js → format-OwxXBnDJ.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-DQ8cP7nO.js → formatters-YgaCZcwJ.js} +1 -1
- package/src/client/dist/pwa/assets/fr-B6c21SG3.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-DbpVO-cL.js → freemarker2-l7R42fI2.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-BHve0PpA.js → handlebars-DFmPrgjH.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BhUkdFPz.js → html-CERIAlPM.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-WaE4cZK9.js +1 -0
- package/src/client/dist/pwa/assets/index-CpWBGKdM.js +20 -0
- package/src/client/dist/pwa/assets/it-DUstDpC2.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-BLAorPa3.js → javascript-BBYncplF.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-CJGF-h-f.js → liquid-C1HqYyYU.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-Cm0oNz_l.js → mdx-E74dsP22.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-nBg6wf05.js → monaco-contributions-DHvm-kp5.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-hr9s26Ud.js → monaco.contribution-DYdn9CwR.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-DTZUKbny.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-EcbxuH_8.js → notify-theme-FuTL7HuS.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-DLWiBfdb.js → permissionModes-D-Nexnoo.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-vDuTaTJn.js → project-color-_snXWYUp.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BEjbzFAQ.js → python-DNAAGBcX.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BJPT7Wxx.js → razor-CB3g5p_T.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-DPqM46Ef.js → typescript-0kpz8bqP.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-BwwrB410.js → use-search-index-status-D38yFOD_.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-B0KtCS9w.js → use-tours-BWkHp34X.js} +2 -2
- package/src/client/dist/pwa/assets/{use-worktree-restore-tzd2FESU.js → use-worktree-restore-DUjZr44N.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-CubzsZUU.js → workspace-templates-D-lPJQ4G.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-C6SVzw5_.js → xml-BbOuqdUe.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-OpCqnv4g.js → yaml-gasFQtfk.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +13 -19
- package/src/mcp-server/kobo-tasks-server.ts +42 -1146
- package/src/mcp-server/managed-mcp-dispatcher.ts +1071 -0
- package/src/mcp-server/workspace-dialogue-client.ts +26 -1
- package/src/client/dist/pwa/assets/ActivityFeed-DfYrVNEh.js +0 -2
- package/src/client/dist/pwa/assets/ComparisonPanel-b5nTBjK6.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-B7VPHEqm.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-BLQz2Qyx.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-Ba7ltCsM.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-B2XvjdWi.js +0 -2
- package/src/client/dist/pwa/assets/QSelect-DrLpbvcr.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Cj6kSVT5.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-D4ODPOG-.js +0 -4
- package/src/client/dist/pwa/assets/custom-sounds-BbPNRzAs.js +0 -1
- package/src/client/dist/pwa/assets/de-D9VzPDGF.js +0 -17
- package/src/client/dist/pwa/assets/es-3R4MJuTt.js +0 -17
- package/src/client/dist/pwa/assets/fr-DXwDVnJ4.js +0 -17
- package/src/client/dist/pwa/assets/i18n-CGLQAADf.js +0 -1
- package/src/client/dist/pwa/assets/index-CNcXBpzz.js +0 -20
- package/src/client/dist/pwa/assets/it-T9AjnG42.js +0 -17
- 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 ? {
|
|
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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
357
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
31
|
-
expectedToken:
|
|
32
|
-
providedToken: c.req.header('X-Kobo-Token')
|
|
33
|
-
trustLoopback: !
|
|
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;
|