@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
|
@@ -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:
|
|
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(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
67
|
+
function parseOrigin(value) {
|
|
68
|
+
if (!/^https?:\/\/[^/?#\\\s]+$/i.test(value))
|
|
69
|
+
return null;
|
|
68
70
|
try {
|
|
69
|
-
const
|
|
70
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
122
|
-
|
|
116
|
+
if (!hostname)
|
|
117
|
+
return false;
|
|
123
118
|
if (!params.proxyHostname)
|
|
124
|
-
return
|
|
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 =
|
|
177
|
-
if (origin
|
|
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
|
|
182
|
-
|
|
175
|
+
return false;
|
|
176
|
+
const expected = params.proxyOrigin ?? parseOrigin(`https://${params.proxyHostname}`)?.origin;
|
|
177
|
+
return origin === expected;
|
|
183
178
|
}
|
|
184
|
-
//
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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());
|