@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
@@ -0,0 +1,98 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { isProgressBarTool } from '../../shared/progress-bar-tools.js';
3
+ import { WORKSPACE_CREATION_TOOLS } from '../../shared/workspace-creation-tools.js';
4
+ import { EXTERNAL_MCP_TOOLS } from '../../shared/workspace-dialogue-tools.js';
5
+ import { WORKSPACE_GROUP_MESSAGE_TOOLS } from '../../shared/workspace-group-message-tools.js';
6
+ import { WORKSPACE_LIFECYCLE_TOOLS } from '../../shared/workspace-lifecycle-tools.js';
7
+ const capabilities = new Map();
8
+ const readTools = new Set([
9
+ 'list_tasks',
10
+ 'get_settings',
11
+ 'get_dev_server_status',
12
+ 'get_workspace_info',
13
+ 'cron_list',
14
+ 'get_dev_server_logs',
15
+ 'list_workspace_images',
16
+ 'get_ticket',
17
+ 'get_notion_ticket',
18
+ 'get_git_info',
19
+ 'list_documents',
20
+ 'read_document',
21
+ 'get_session_usage',
22
+ 'read_workspace_events_csv',
23
+ 'search_codebase',
24
+ 'list_workspaces',
25
+ 'list_memory_scopes',
26
+ 'list_memories',
27
+ 'read_memory',
28
+ 'search_memories',
29
+ 'list_memory_operations',
30
+ ...EXTERNAL_MCP_TOOLS.filter((t) => t.annotations?.readOnlyHint).map((t) => t.name),
31
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS.filter((t) => t.annotations?.readOnlyHint).map((t) => t.name),
32
+ ...WORKSPACE_CREATION_TOOLS.filter((t) => t.annotations?.readOnlyHint).map((t) => t.name),
33
+ ]);
34
+ const writeTools = new Set([
35
+ 'mark_task_done',
36
+ 'mark_auto_loop_ready',
37
+ 'create_task',
38
+ 'update_task',
39
+ 'delete_task',
40
+ 'set_auto_loop',
41
+ 'set_workspace_agent_description',
42
+ 'set_workspace_name',
43
+ 'cron_create',
44
+ 'cron_delete',
45
+ 'start_dev_server',
46
+ 'stop_dev_server',
47
+ 'set_workspace_status',
48
+ 'log_thought',
49
+ 'schedule_wakeup',
50
+ 'cancel_wakeup',
51
+ 'stop_workspace',
52
+ 'remember',
53
+ ...EXTERNAL_MCP_TOOLS.map((t) => t.name),
54
+ ...WORKSPACE_CREATION_TOOLS.map((t) => t.name),
55
+ ...WORKSPACE_LIFECYCLE_TOOLS.map((t) => t.name),
56
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS.map((t) => t.name),
57
+ ]);
58
+ const externalTools = new Set([
59
+ ...EXTERNAL_MCP_TOOLS.map((t) => t.name),
60
+ ...WORKSPACE_CREATION_TOOLS.map((t) => t.name),
61
+ ...WORKSPACE_LIFECYCLE_TOOLS.map((t) => t.name),
62
+ ...WORKSPACE_GROUP_MESSAGE_TOOLS.map((t) => t.name),
63
+ 'stop_workspace',
64
+ ]);
65
+ export function createInternalMcpCapability(input) {
66
+ const capability = { ...input, env: { ...input.env }, token: randomBytes(32).toString('base64url') };
67
+ capabilities.set(capability.token, capability);
68
+ return capability;
69
+ }
70
+ export function revokeInternalMcpCapability(token) {
71
+ capabilities.delete(token);
72
+ }
73
+ export function resolveInternalMcpCapability(token) {
74
+ const capability = token ? capabilities.get(token) : undefined;
75
+ if (!capability)
76
+ throw new Error('Invalid or revoked managed MCP capability');
77
+ return capability;
78
+ }
79
+ export function authorizeInternalMcpTool(token, name, args) {
80
+ const capability = resolveInternalMcpCapability(token);
81
+ const report = (name === 'submit_final_review' &&
82
+ capability.env.KOBO_FINAL_REVIEW_TOKEN &&
83
+ capability.env.KOBO_FINAL_REVIEW_SESSION_ID === capability.sessionId) ||
84
+ (name === 'submit_session_handoff' && capability.env.KOBO_HANDOFF_TOKEN && capability.env.KOBO_HANDOFF_ID);
85
+ const progress = isProgressBarTool(name) && capability.env.KOBO_PROGRESS_SESSION_TOKEN;
86
+ if (!report && !progress && !readTools.has(name) && !writeTools.has(name))
87
+ throw new Error(`Unknown or unavailable tool: ${name}`);
88
+ if (capability.readOnly && !readTools.has(name) && !report && !progress)
89
+ throw new Error('This managed MCP launch is read-only');
90
+ if (!externalTools.has(name) && args.workspace_id !== undefined && args.workspace_id !== capability.workspaceId)
91
+ throw new Error('Tool is bound to its launch workspace');
92
+ if ((name === 'send_workspace_message' && args.workspace_id === capability.workspaceId) ||
93
+ (name === 'send_workspace_group_message' &&
94
+ Array.isArray(args.workspace_ids) &&
95
+ args.workspace_ids.includes(capability.workspaceId)))
96
+ throw new Error('Use the normal conversation in your own workspace');
97
+ return capability;
98
+ }
@@ -29,7 +29,7 @@ export function buildMcpConnectionInfo(input) {
29
29
  networkEnabled: input.networkEnabled,
30
30
  behindProxy: input.behindProxy,
31
31
  proxyHostname: input.proxyHostname,
32
- localRequiresToken: input.behindProxy,
32
+ localRequiresToken: true,
33
33
  stdio: args
34
34
  ? {
35
35
  command: input.command,
@@ -2,7 +2,7 @@ import { deriveMemoryActor } from '../../shared/memory.js';
2
2
  import { isMemoryToolName, validateMemoryToolArguments } from '../../shared/memory-tools.js';
3
3
  import { prepareMemoryControlReply, prepareMemoryToolEnvelope, sliceMemoryBodyFragment, } from '../utils/memory-token-budget.js';
4
4
  import { createExternalMemoryBudgetContext, reserveMemoryBudget, reserveMemoryBudgetDenial, wasMemoryFragmentDelivered, } from './memory-budget-service.js';
5
- import { listMemories, listMemoryOperations, listMemoryScopes, listWorkspaceMemoryOperations, readMemory, remember, searchMemories, } from './memory-service.js';
5
+ import { listMemories, listMemoryOperations, listMemoryScopes, listWorkspaceMemoryOperations, listWorkspaceMemoryScopes, readMemory, remember, searchMemories, } from './memory-service.js';
6
6
  function externalActor(source) {
7
7
  if (source.kind !== 'mcp')
8
8
  throw new TypeError('External memory tools require MCP client context');
@@ -176,7 +176,7 @@ function chargeExternalOutput(input) {
176
176
  return addBudgetReceipt(sized.data, input.contextId, reservation.estimatedTokens, reservation.remainingTokens);
177
177
  }
178
178
  /** Execute the external memory surface; caller identity is always derived from MCP transport. */
179
- export function executeMemoryMcpTool(name, input, source) {
179
+ export function executeMemoryMcpTool(name, input, source, allowedWorkspaceIds) {
180
180
  if (!isMemoryToolName(name))
181
181
  throw new TypeError(`Unknown memory tool '${name}'`);
182
182
  const actor = externalActor(source);
@@ -212,11 +212,13 @@ export function executeMemoryMcpTool(name, input, source) {
212
212
  try {
213
213
  switch (name) {
214
214
  case 'list_memory_scopes':
215
- data = boundedScopes(listMemoryScopes({
216
- ...(args.workspace_id ? { workspaceId: args.workspace_id } : {}),
217
- ...(cursor ? { cursor } : {}),
218
- ...(limit ? { limit } : {}),
219
- }));
215
+ data = boundedScopes(allowedWorkspaceIds
216
+ ? listWorkspaceMemoryScopes(args.workspace_id ? allowedWorkspaceIds.filter((id) => id === args.workspace_id) : allowedWorkspaceIds, { ...(cursor ? { cursor } : {}), ...(limit ? { limit } : {}) })
217
+ : listMemoryScopes({
218
+ ...(args.workspace_id ? { workspaceId: args.workspace_id } : {}),
219
+ ...(cursor ? { cursor } : {}),
220
+ ...(limit ? { limit } : {}),
221
+ }));
220
222
  break;
221
223
  case 'list_memories': {
222
224
  const page = listMemories({ ...selection, actor, ...(cursor ? { cursor } : {}), ...(limit ? { limit } : {}) });
@@ -210,6 +210,21 @@ export function resolveMemoryScope(input) {
210
210
  }
211
211
  return materializeScope(input);
212
212
  }
213
+ /** Resolve only the workspace-owned scope, without creating or exposing ancestors. */
214
+ export function resolveWorkspaceMemoryScope(workspaceId) {
215
+ return materializeScope({ level: 'workspace', workspaceId });
216
+ }
217
+ /** Catalogue for a caller whose authority is explicitly restricted to these workspaces. */
218
+ export function listWorkspaceMemoryScopes(workspaceIds, options = {}) {
219
+ const limit = requireBoundedPage(options.limit);
220
+ const offset = requireCursor(options.cursor);
221
+ const exists = getDb().prepare('SELECT id FROM workspaces WHERE id = ?');
222
+ const existing = [...new Set(workspaceIds)].sort().filter((id) => exists.get(id));
223
+ return {
224
+ items: existing.slice(offset, offset + limit).map(resolveWorkspaceMemoryScope),
225
+ ...(offset + limit < existing.length ? { nextCursor: String(offset + limit) } : {}),
226
+ };
227
+ }
213
228
  /** Lists persisted scopes; a workspace view always includes the exact applicable ancestors. */
214
229
  export function listMemoryScopes(options = {}) {
215
230
  const limit = requireBoundedPage(options.limit);
@@ -64,18 +64,29 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '::ffff:127.0.
64
64
  * scheme (`http://127.0.0.1:3000`); adding a scheme when none is present runs
65
65
  * both through the same URL rules, including the bracketed IPv6 form.
66
66
  */
67
- function headerHostname(value) {
67
+ function parseOrigin(value) {
68
+ if (!/^https?:\/\/[^/?#\\\s]+$/i.test(value))
69
+ return null;
68
70
  try {
69
- const hostname = new URL(value.includes('://') ? value : `http://${value}`).hostname;
70
- if (!hostname)
71
- return null;
72
- // The URL parser keeps IPv6 hostnames bracketed; compare on the bare address.
73
- return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
71
+ const url = new URL(value);
72
+ return url.hostname && !url.username && !url.password ? url : null;
74
73
  }
75
74
  catch {
76
75
  return null;
77
76
  }
78
77
  }
78
+ function parseHost(value) {
79
+ if (!value || /[/?#@\\\s]/.test(value))
80
+ return null;
81
+ return parseOrigin(`http://${value}`);
82
+ }
83
+ function headerHostname(value) {
84
+ const url = value.includes('://') ? parseOrigin(value) : parseHost(value);
85
+ const hostname = url?.hostname;
86
+ if (!hostname)
87
+ return null;
88
+ return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
89
+ }
79
90
  /**
80
91
  * True when a raw `Host`/`Origin` value names the machine itself. Always
81
92
  * allowed, whatever the settings say, which lets a caller settle the common
@@ -83,7 +94,7 @@ function headerHostname(value) {
83
94
  * both synchronous, and this runs on every request.
84
95
  */
85
96
  export function isLocalRequestHost(value) {
86
- if (!value)
97
+ if (!value || !parseHost(value))
87
98
  return false;
88
99
  const hostname = headerHostname(value);
89
100
  return hostname !== null && LOCAL_HOSTNAMES.has(hostname);
@@ -95,52 +106,35 @@ function isAllowedHostname(hostname, enabled, lanHostnames) {
95
106
  return true;
96
107
  return enabled && lanHostnames.includes(hostname);
97
108
  }
98
- /**
99
- * Guards against DNS rebinding: a page on `evil.com` whose domain re-resolves
100
- * to 127.0.0.1 reaches this server with `Host: evil.com`, and the browser then
101
- * treats every response as same-origin. Only the names by which the machine is
102
- * legitimately reachable are accepted — its own loopback names, plus its LAN
103
- * addresses once network access is enabled.
104
- *
105
- * Behind a reverse proxy the Host is whatever domain the operator chose. When
106
- * they declare it via `KOBO_NETWORK_ACCESS_PROXY_HOST` it is enforced like any
107
- * other name; without it we cannot tell their domain from an attacker's, so
108
- * anything is accepted. That mode also turns off the loopback exemption, so
109
- * every `/api/*` request has to carry the token.
110
- *
111
- * Residual risk when the hostname is left undeclared: the SPA shell is served
112
- * outside `/api/*` and therefore behind no token, so a rebinding page can be
113
- * handed the real interface and ask the user to paste their token into it.
114
- * Declaring the hostname closes that; otherwise, authenticate at the proxy.
115
- */
109
+ /** Reject malformed/rebinding hosts. Proxy mode requires a declared public host;
110
+ * loopback remains reachable for authenticated internal clients and health checks. */
116
111
  export function isAllowedRequestHost(params) {
117
112
  if (!params.host)
118
113
  return false;
119
- const hostname = headerHostname(params.host);
114
+ const hostname = parseHost(params.host) ? headerHostname(params.host) : null;
120
115
  if (params.behindProxy) {
121
- // No declared hostname means we cannot tell the operator's domain from an
122
- // attacker's, so anything goes — the historical behaviour.
116
+ if (!hostname)
117
+ return false;
123
118
  if (!params.proxyHostname)
124
- return true;
119
+ return LOCAL_HOSTNAMES.has(hostname);
125
120
  return hostname === params.proxyHostname || (hostname !== null && LOCAL_HOSTNAMES.has(hostname));
126
121
  }
127
122
  return isAllowedHostname(hostname, params.enabled, params.lanHostnames);
128
123
  }
129
- /**
130
- * Hostname declared via `KOBO_NETWORK_ACCESS_PROXY_HOST`, or null when unset.
131
- *
132
- * Behind a reverse proxy we otherwise have to accept any Host, which leaves the
133
- * door open to a rebinding page being served the real interface and asking the
134
- * user to paste their token into it. Declaring the domain the proxy serves
135
- * closes that, and costs one environment variable in the compose file where
136
- * the other network-access settings already live.
137
- */
124
+ /** Validated hostname declared by the operator; invalid/missing values fail closed. */
138
125
  export function resolveProxyHostname(env = process.env) {
139
126
  const raw = env.KOBO_NETWORK_ACCESS_PROXY_HOST?.trim();
140
127
  if (!raw)
141
128
  return null;
142
129
  return headerHostname(raw);
143
130
  }
131
+ /** Exact public origin. A bare configured hostname implies HTTPS. */
132
+ export function resolveProxyOrigin(env = process.env) {
133
+ const raw = env.KOBO_NETWORK_ACCESS_PROXY_HOST?.trim();
134
+ if (!raw)
135
+ return null;
136
+ return parseOrigin(raw.includes('://') ? raw : `https://${raw}`)?.origin ?? null;
137
+ }
144
138
  /**
145
139
  * Names the site a request was driven from. Guards two things.
146
140
  *
@@ -173,29 +167,22 @@ export function resolveProxyHostname(env = process.env) {
173
167
  export function isAllowedOrigin(params) {
174
168
  if (params.origin === undefined)
175
169
  return true;
176
- const origin = authority(params.origin);
177
- if (origin === null)
170
+ const origin = parseOrigin(params.origin)?.origin;
171
+ if (!origin)
178
172
  return false;
179
173
  if (params.behindProxy) {
180
174
  if (!params.proxyHostname)
181
- return true;
182
- return headerHostname(params.origin) === params.proxyHostname;
175
+ return false;
176
+ const expected = params.proxyOrigin ?? parseOrigin(`https://${params.proxyHostname}`)?.origin;
177
+ return origin === expected;
183
178
  }
184
- // `quasar dev` proxies to the backend with changeOrigin, so it rewrites the
185
- // Host to ours while the browser stays on the Quasar port. The two cannot
186
- // match there, hence the explicit declaration.
187
- if (params.devOrigin && origin === authority(params.devOrigin))
179
+ // The explicit Quasar development origin can differ from the backend port.
180
+ if (params.devOrigin && origin === parseOrigin(params.devOrigin)?.origin)
188
181
  return true;
189
- return params.requestHost !== undefined && origin === authority(params.requestHost);
190
- }
191
- /** Host and port of a raw `Host`/`Origin` value, or null when unparseable. */
192
- function authority(value) {
193
- try {
194
- return new URL(value.includes('://') ? value : `http://${value}`).host || null;
195
- }
196
- catch {
197
- return null;
198
- }
182
+ if (!params.requestHost || !parseHost(params.requestHost))
183
+ return false;
184
+ const protocol = params.requestProtocol ?? 'http:';
185
+ return origin === parseOrigin(`${protocol}//${params.requestHost}`)?.origin;
199
186
  }
200
187
  /**
201
188
  * Origin the development client is served from, declared by the `dev` script
@@ -231,23 +218,6 @@ export function evaluateNetworkAccess(params) {
231
218
  return { allow: true, status: 200 };
232
219
  return { allow: false, status: 401 };
233
220
  }
234
- /** WS upgrade authorization: parses `?token=` from the raw URL. */
235
- export function authorizeWsUpgrade(params) {
236
- let providedToken;
237
- try {
238
- providedToken = new URL(params.rawUrl ?? '/', 'http://localhost').searchParams.get('token') ?? undefined;
239
- }
240
- catch {
241
- providedToken = undefined;
242
- }
243
- return evaluateNetworkAccess({
244
- address: params.address,
245
- enabled: params.enabled,
246
- expectedToken: params.expectedToken,
247
- providedToken,
248
- trustLoopback: params.trustLoopback,
249
- }).allow;
250
- }
251
221
  /**
252
222
  * Reads KOBO_NETWORK_ACCESS_ENABLED / KOBO_NETWORK_ACCESS_BEHIND_PROXY and
253
223
  * returns only the fields an env var actually specifies — an unset env var
@@ -0,0 +1,182 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { isLoopbackAddress, resolveProxyOrigin } from './network-access-service.js';
3
+ const sessions = new Map();
4
+ const tickets = new Map();
5
+ const sockets = new Map();
6
+ const revokedSockets = new WeakSet();
7
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
8
+ const fingerprint = (p) => hash(JSON.stringify([p.networkAccessEnabled, !!p.networkAccessBehindProxy, p.networkAccessToken]));
9
+ const lifetime = 12 * 60 * 60 * 1000;
10
+ export function isNetworkTransportAllowed(address, policy) {
11
+ return policy.networkAccessEnabled || (!policy.networkAccessBehindProxy && isLoopbackAddress(address));
12
+ }
13
+ function prune() {
14
+ const now = Date.now();
15
+ for (const [id, session] of sessions)
16
+ if (session.expiresAt <= now)
17
+ sessions.delete(id);
18
+ for (const [id, ticket] of tickets)
19
+ if (ticket.expiresAt <= now)
20
+ tickets.delete(id);
21
+ }
22
+ export function createBrowserSession(policy) {
23
+ prune();
24
+ if (sessions.size >= 256)
25
+ throw new Error('Session capacity reached');
26
+ const session = {
27
+ token: randomBytes(32).toString('base64url'),
28
+ expiresAt: Date.now() + lifetime,
29
+ policy: fingerprint(policy),
30
+ };
31
+ const { token, ...record } = session;
32
+ sessions.set(hash(token), record);
33
+ return session;
34
+ }
35
+ export function getBrowserSession(token, policy) {
36
+ if (!token || token.length > 128)
37
+ return undefined;
38
+ const record = sessions.get(hash(token));
39
+ if (!record || record.expiresAt <= Date.now() || record.policy !== fingerprint(policy))
40
+ return undefined;
41
+ return { ...record, token };
42
+ }
43
+ function sessionValid(session, policy) {
44
+ return !session || !!getBrowserSession(session.token, policy);
45
+ }
46
+ export function createWebSocketTicket(path, policy, session) {
47
+ if (!/^\/ws(?:\/terminal\/[A-Za-z0-9_-]{1,200})?$/.test(path))
48
+ throw new Error('Invalid WebSocket path');
49
+ prune();
50
+ if (tickets.size >= 1024)
51
+ throw new Error('Ticket capacity reached');
52
+ if (!sessionValid(session, policy))
53
+ throw new Error('Session expired');
54
+ const token = randomBytes(32).toString('base64url');
55
+ tickets.set(hash(token), { path, policy: fingerprint(policy), expiresAt: Date.now() + 60_000, session });
56
+ return token;
57
+ }
58
+ export function consumeWebSocketTicket(token, path, policy) {
59
+ if (!token || token.length > 128)
60
+ return undefined;
61
+ const id = hash(token);
62
+ const ticket = tickets.get(id);
63
+ tickets.delete(id);
64
+ if (!ticket ||
65
+ ticket.path !== path ||
66
+ ticket.expiresAt <= Date.now() ||
67
+ ticket.policy !== fingerprint(policy) ||
68
+ !sessionValid(ticket.session, policy))
69
+ return undefined;
70
+ return ticket;
71
+ }
72
+ function terminate(ws) {
73
+ const grant = sockets.get(ws);
74
+ if (grant) {
75
+ grant.revoked = true;
76
+ clearTimeout(grant.timer);
77
+ sockets.delete(ws);
78
+ }
79
+ if (!revokedSockets.has(ws)) {
80
+ revokedSockets.add(ws);
81
+ ws.terminate();
82
+ }
83
+ }
84
+ export function registerNetworkSocket(ws, address, policy, current, session) {
85
+ let currentPolicy;
86
+ try {
87
+ currentPolicy = current();
88
+ }
89
+ catch {
90
+ terminate(ws);
91
+ return false;
92
+ }
93
+ if (!isNetworkTransportAllowed(address, currentPolicy) ||
94
+ sockets.size >= 256 ||
95
+ [...sockets.values()].filter((g) => g.address === address).length >= 64 ||
96
+ !sessionValid(session, currentPolicy) ||
97
+ fingerprint(policy) !== fingerprint(currentPolicy)) {
98
+ terminate(ws);
99
+ return false;
100
+ }
101
+ const expiresAt = session?.expiresAt ?? Date.now() + lifetime;
102
+ const timer = setTimeout(() => terminate(ws), Math.max(0, expiresAt - Date.now()));
103
+ timer.unref?.();
104
+ sockets.set(ws, {
105
+ address,
106
+ policy: fingerprint(policy),
107
+ current,
108
+ session,
109
+ expiresAt,
110
+ revoked: false,
111
+ timer,
112
+ count: 0,
113
+ window: Date.now(),
114
+ });
115
+ ws.once('close', () => {
116
+ clearTimeout(timer);
117
+ sockets.delete(ws);
118
+ revokedSockets.add(ws);
119
+ });
120
+ return true;
121
+ }
122
+ export function isNetworkSocketAuthorized(ws) {
123
+ if (revokedSockets.has(ws))
124
+ return false;
125
+ const grant = sockets.get(ws);
126
+ if (!grant)
127
+ return true; // Unregistered service-level sockets do not participate in network authentication.
128
+ let policy;
129
+ try {
130
+ policy = grant.current();
131
+ }
132
+ catch {
133
+ terminate(ws);
134
+ return false;
135
+ }
136
+ if (grant.revoked ||
137
+ grant.expiresAt <= Date.now() ||
138
+ !isNetworkTransportAllowed(grant.address, policy) ||
139
+ grant.policy !== fingerprint(policy) ||
140
+ !sessionValid(grant.session, policy)) {
141
+ terminate(ws);
142
+ return false;
143
+ }
144
+ return true;
145
+ }
146
+ export function acceptNetworkSocketMessage(ws) {
147
+ if (!isNetworkSocketAuthorized(ws))
148
+ return false;
149
+ const grant = sockets.get(ws);
150
+ if (!grant)
151
+ return true;
152
+ if (Date.now() - grant.window >= 1000) {
153
+ grant.window = Date.now();
154
+ grant.count = 0;
155
+ }
156
+ if (++grant.count > 240) {
157
+ terminate(ws);
158
+ return false;
159
+ }
160
+ return true;
161
+ }
162
+ export function revokeBrowserSession(token) {
163
+ sessions.delete(hash(token));
164
+ for (const [id, ticket] of tickets)
165
+ if (ticket.session?.token === token)
166
+ tickets.delete(id);
167
+ for (const [ws, grant] of sockets)
168
+ if (grant.session?.token === token)
169
+ terminate(ws);
170
+ }
171
+ export function revokeNetworkSessions() {
172
+ sessions.clear();
173
+ tickets.clear();
174
+ for (const ws of sockets.keys())
175
+ terminate(ws);
176
+ }
177
+ export function browserCookieName(policy) {
178
+ return secureBrowserCookie(policy) ? '__Host-kobo-session' : 'kobo-session';
179
+ }
180
+ export function secureBrowserCookie(policy) {
181
+ return !!policy.networkAccessBehindProxy && !resolveProxyOrigin()?.startsWith('http:');
182
+ }
@@ -12,6 +12,7 @@ import { getSettingsPath } from '../utils/paths.js';
12
12
  import { InvalidWorktreesPathError, resolveGlobalWorktreesRoot, sanitizeWorktreesPath, validateWorktreesPath, } from '../utils/worktree-paths.js';
13
13
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT } from './initial-prompt-template-service.js';
14
14
  import { LEGACY_AGNOSTIC_AUTO_LOOP_REVIEW_GATE, LEGACY_AGNOSTIC_REVIEW_TEMPLATE, LEGACY_CI_FIX_PROMPT_TEMPLATE, LEGACY_FINALIZATION_PROMPT, LEGACY_NOTION_INITIAL_PROMPT, LEGACY_PR_PROMPT_TEMPLATE, LEGACY_SENTRY_INITIAL_PROMPT, } from './legacy-public-prompts.js';
15
+ import { revokeNetworkSessions } from './network-session-service.js';
15
16
  import { DEFAULT_REVIEW_PROMPT_TEMPLATE } from './review-template-service.js';
16
17
  import { DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT, DEFAULT_MODEL_BY_ENGINE, FRESH_MODEL_BY_ENGINE, } from './settings-defaults.js';
17
18
  import { AGNOSTIC_PROMPTS } from './skill-suite-prompts.js';
@@ -1293,6 +1294,7 @@ function readSettings() {
1293
1294
  }
1294
1295
  function readSettingsFromDisk() {
1295
1296
  ensureSettingsPathInitialized();
1297
+ protectSettingsFiles();
1296
1298
  if (!fs.existsSync(settingsFilePath)) {
1297
1299
  const defaults = defaultSettings();
1298
1300
  writeSettings(defaults);
@@ -1334,6 +1336,22 @@ function readSettingsFromDisk() {
1334
1336
  }
1335
1337
  /** Number of `settings.json.backup-*` files kept; older ones are rotated out. */
1336
1338
  const SETTINGS_BACKUP_KEEP = 5;
1339
+ /** Repair legacy permissions before reading secrets, without following file symlinks. */
1340
+ function protectSettingsFiles() {
1341
+ const dir = path.dirname(settingsFilePath);
1342
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1343
+ fs.chmodSync(dir, 0o700);
1344
+ const basename = path.basename(settingsFilePath);
1345
+ for (const name of fs.readdirSync(dir)) {
1346
+ if (name !== basename && name !== `${basename}.tmp` && !name.startsWith(`${basename}.backup-`))
1347
+ continue;
1348
+ const file = path.join(dir, name);
1349
+ const stat = fs.lstatSync(file);
1350
+ if (!stat.isFile() || stat.nlink !== 1)
1351
+ throw new Error('[settings] Refusing unsafe settings file');
1352
+ fs.chmodSync(file, 0o600);
1353
+ }
1354
+ }
1337
1355
  /**
1338
1356
  * Keep only the `SETTINGS_BACKUP_KEEP` most recent `settings.json.backup-*`
1339
1357
  * files in `dir`, deleting the rest. Best-effort — never throws.
@@ -1368,7 +1386,7 @@ function createSettingsBackupIfPresent() {
1368
1386
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1369
1387
  settingsBackupSequence += 1;
1370
1388
  const backupPath = path.join(dir, `settings.json.backup-${stamp}-${settingsBackupSequence}`);
1371
- fs.copyFileSync(settingsFilePath, backupPath);
1389
+ fs.writeFileSync(backupPath, fs.readFileSync(settingsFilePath), { mode: 0o600, flag: 'wx' });
1372
1390
  pruneSettingsBackups(dir);
1373
1391
  }
1374
1392
  function writeSettings(settings, options) {
@@ -1376,14 +1394,11 @@ function writeSettings(settings, options) {
1376
1394
  invalidateSettingsCache();
1377
1395
  ensureSettingsPathInitialized();
1378
1396
  const tmpPath = `${settingsFilePath}.tmp`;
1379
- const dir = path.dirname(settingsFilePath);
1380
- if (!fs.existsSync(dir)) {
1381
- fs.mkdirSync(dir, { recursive: true });
1382
- }
1397
+ protectSettingsFiles();
1383
1398
  if (options?.backup) {
1384
1399
  createSettingsBackupIfPresent();
1385
1400
  }
1386
- fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), 'utf-8');
1401
+ fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), { encoding: 'utf-8', mode: 0o600 });
1387
1402
  fs.renameSync(tmpPath, settingsFilePath);
1388
1403
  }
1389
1404
  /** Read and return the full settings object, creating defaults if missing. */
@@ -1823,7 +1838,9 @@ export function updateGlobalSettings(input) {
1823
1838
  * (which would leave the server bound wide but unauthenticatable after a restart).
1824
1839
  */
1825
1840
  export function updateNetworkAccessSettings(patch) {
1826
- const settings = readSettings();
1841
+ const current = readSettings();
1842
+ // Keep cached policy intact until persistence succeeds.
1843
+ const settings = { ...current, global: { ...current.global } };
1827
1844
  if (typeof patch.networkAccessEnabled === 'boolean') {
1828
1845
  settings.global.networkAccessEnabled = patch.networkAccessEnabled;
1829
1846
  }
@@ -1834,6 +1851,11 @@ export function updateNetworkAccessSettings(patch) {
1834
1851
  settings.global.networkAccessBehindProxy = patch.networkAccessBehindProxy;
1835
1852
  }
1836
1853
  writeSettings(settings, { backup: true });
1854
+ if (current.global.networkAccessEnabled !== settings.global.networkAccessEnabled ||
1855
+ current.global.networkAccessToken !== settings.global.networkAccessToken ||
1856
+ current.global.networkAccessBehindProxy !== settings.global.networkAccessBehindProxy) {
1857
+ revokeNetworkSessions();
1858
+ }
1837
1859
  return settings.global;
1838
1860
  }
1839
1861
  function ensureGlobalWorktreesRootExists(worktreesPath) {
@@ -1,4 +1,5 @@
1
1
  import { assertWorkspaceLifecycleAvailable, isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
2
+ import { acceptNetworkSocketMessage, isNetworkSocketAuthorized } from './network-session-service.js';
2
3
  import { createTerminal, getTerminal } from './terminal-service.js';
3
4
  import { getWorkspace } from './workspace-service.js';
4
5
  const MAX_BUFFERED_BYTES = 1024 * 1024;
@@ -31,6 +32,8 @@ export function handleTerminalConnection(ws, workspaceId, isShuttingDown = () =>
31
32
  ws.terminate();
32
33
  }
33
34
  function send(data) {
35
+ if (!isNetworkSocketAuthorized(ws))
36
+ return false;
34
37
  if (disposed || ws.readyState !== 1)
35
38
  return false;
36
39
  if (ws.bufferedAmount + Buffer.byteLength(data) > MAX_BUFFERED_BYTES) {
@@ -72,6 +75,8 @@ export function handleTerminalConnection(ws, workspaceId, isShuttingDown = () =>
72
75
  ws.on('close', dispose);
73
76
  ws.on('error', disconnect);
74
77
  ws.on('message', (data, isBinary) => {
78
+ if (!acceptNetworkSocketMessage(ws))
79
+ return;
75
80
  if (disposed || ws.readyState !== 1 || isShuttingDown())
76
81
  return;
77
82
  if (isBinary) {
@@ -1,6 +1,7 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
3
  import { recordActivity } from './activity-service.js';
4
+ import { acceptNetworkSocketMessage, isNetworkSocketAuthorized } from './network-session-service.js';
4
5
  import { getAllPersistedSnapshots } from './usage/db.js';
5
6
  // ── State ──────────────────────────────────────────────────────────────────────
6
7
  /** Maps each WS client to the set of workspaceIds they are subscribed to */
@@ -11,6 +12,8 @@ const clients = new Map();
11
12
  const MAX_BUFFERED_BYTES = 1024 * 1024;
12
13
  /** A dropped frame must end the connection so its replay cursor cannot skip it. */
13
14
  function sendFrame(ws, message) {
15
+ if (!isNetworkSocketAuthorized(ws))
16
+ return;
14
17
  if (ws.readyState !== 1)
15
18
  return;
16
19
  if (ws.bufferedAmount > MAX_BUFFERED_BYTES) {
@@ -92,6 +95,8 @@ export function handleConnection(ws) {
92
95
  console.error('[ws] usage hydration failed:', err);
93
96
  }
94
97
  ws.on('message', (data) => {
98
+ if (!acceptNetworkSocketMessage(ws))
99
+ return;
95
100
  let msg;
96
101
  try {
97
102
  const parsed = JSON.parse(data.toString());