@taskforcehq/taskforce 0.3.313 → 0.3.315

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 (51) hide show
  1. package/dist/compat/workspaceSyncCompat.js +2 -0
  2. package/dist/components/features/AnnotatedAttachmentWorkspace.js +51 -11
  3. package/dist/core/PlanEntitlementService.d.ts +2 -0
  4. package/dist/core/PlanEntitlementService.js +26 -9
  5. package/dist/core/Taskforce.d.ts +5 -0
  6. package/dist/core/Taskforce.js +16 -2
  7. package/dist/core/types.d.ts +1 -0
  8. package/dist/hooks/sync/recovery.d.ts +1 -0
  9. package/dist/hooks/sync/recovery.js +1 -0
  10. package/dist/hooks/sync/transfers.d.ts +2 -0
  11. package/dist/hooks/sync/transfers.js +3 -0
  12. package/dist/hooks/useSyncOrchestrator.js +9 -1
  13. package/dist/hooks/useTaskforce.js +13 -6
  14. package/dist/hooks/useWorkspaceSyncController.js +3 -0
  15. package/dist/mcp/canonicalAssetHelpers.js +33 -19
  16. package/dist/mcp/documentAssetRegistrar.js +6 -6
  17. package/dist/mcp/runtime.js +83 -11
  18. package/dist/mcp/taskAttachmentHelpers.js +29 -7
  19. package/dist/migrations/taskSchemaMigrations.js +4 -0
  20. package/dist/server/index.js +1 -1
  21. package/dist/server/routes/admin.js +10 -0
  22. package/dist/server/routes/billing.js +3 -0
  23. package/dist/server/routes/documents.js +2 -1
  24. package/dist/server/routes.js +10 -6
  25. package/dist/storage/documentIntegrity.js +1 -6
  26. package/dist/storage/documentPurge.js +11 -12
  27. package/dist/sync/workspaceRepair.js +11 -9
  28. package/dist/sync/workspaceSyncState.d.ts +1 -0
  29. package/dist/sync/workspaceSyncState.js +1 -0
  30. package/dist/ui/.well-known/mcp-registry-auth +1 -0
  31. package/dist/ui/assets/{AgentsModule-BULqIo8e.js → AgentsModule-CNBWCIXk.js} +1 -1
  32. package/dist/ui/assets/AnnotatedAttachmentWorkspace-9qxB0e6A.js +3 -0
  33. package/dist/ui/assets/{ContextAttachmentManager-DoMe2OpY.js → ContextAttachmentManager-CRyuFYlg.js} +1 -1
  34. package/dist/ui/assets/{DocumentWorkspace-DumBWhvb.js → DocumentWorkspace-Dx7wb9NF.js} +1 -1
  35. package/dist/ui/assets/{EntityActivityTimeline-sHk8Nzbq.js → EntityActivityTimeline-Cmal2OrJ.js} +1 -1
  36. package/dist/ui/assets/{InitiativesModule-3X9UNzHh.js → InitiativesModule-wzbYPQQL.js} +1 -1
  37. package/dist/ui/assets/{PlansPage-C3A3EYaV.js → PlansPage-BAnSfbTf.js} +1 -1
  38. package/dist/ui/assets/{TaskContextUpload-C8G7pWkX.js → TaskContextUpload-DENyMyUk.js} +1 -1
  39. package/dist/ui/assets/{TaskSettings-DM1o9Wla.js → TaskSettings-DASuVwpY.js} +1 -1
  40. package/dist/ui/assets/{WorkflowsModule-CW0crzvD.js → WorkflowsModule-DJMEA_yt.js} +1 -1
  41. package/dist/ui/assets/documentReferences-BhNx80zO.js +1 -0
  42. package/dist/ui/assets/index-CWg2olz9.js +5 -0
  43. package/dist/ui/index.html +1 -1
  44. package/dist/utils/pathContainment.d.ts +7 -0
  45. package/dist/utils/pathContainment.js +53 -0
  46. package/dist/utils/pathSafety.d.ts +6 -0
  47. package/dist/utils/pathSafety.js +73 -0
  48. package/package.json +3 -1
  49. package/dist/ui/assets/AnnotatedAttachmentWorkspace-DHr4rHK9.js +0 -3
  50. package/dist/ui/assets/documentReferences-BZB3nMRb.js +0 -1
  51. package/dist/ui/assets/index-BWFQ9mGn.js +0 -5
@@ -1,11 +1,12 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
+ import { normalizeRelativeStorageKey, resolveExistingStorageKeyFilePathInsideRoot, resolveStorageKeyWritePathInsideRoot } from '../utils/pathSafety.js';
4
5
  export function createCanonicalAssetHelpers(deps) {
5
6
  const normalizeAttachmentStorageKey = (pathValue, fsPathValue) => {
6
7
  const fsPathRaw = String(fsPathValue || '').trim().replace(/\\/g, '/');
7
8
  if (fsPathRaw) {
8
- return fsPathRaw.replace(/^\.taskforce\//, '').trim() || null;
9
+ return normalizeRelativeStorageKey(fsPathRaw.replace(/^\.taskforce\//, '').trim());
9
10
  }
10
11
  const pathRaw = String(pathValue || '').trim();
11
12
  if (!pathRaw)
@@ -13,10 +14,18 @@ export function createCanonicalAssetHelpers(deps) {
13
14
  if (/^https?:\/\//i.test(pathRaw))
14
15
  return pathRaw;
15
16
  if (pathRaw.startsWith('/api/taskforce/context/')) {
16
- return decodeURIComponent(pathRaw.slice('/api/taskforce/context/'.length)).trim() || null;
17
+ return normalizeRelativeStorageKey(decodeURIComponent(pathRaw.slice('/api/taskforce/context/'.length)).trim());
17
18
  }
18
19
  return null;
19
20
  };
21
+ const isSafeCanonicalAssetStorage = (asset) => {
22
+ if (asset.storageProvider === 'external')
23
+ return true;
24
+ return Boolean(normalizeRelativeStorageKey(asset.storageKey));
25
+ };
26
+ const resolveCanonicalLocalFilePath = (storageKey) => {
27
+ return resolveExistingStorageKeyFilePathInsideRoot(deps.basePath, storageKey);
28
+ };
20
29
  const buildSyntheticAttachmentId = (taskId, attachment) => {
21
30
  const stableSource = [
22
31
  taskId,
@@ -32,14 +41,14 @@ export function createCanonicalAssetHelpers(deps) {
32
41
  const assetId = String(attachment.assetId || '').trim();
33
42
  if (assetId) {
34
43
  const byId = deps.workspaceAssetStore.get(assetId, deps.activeWorkspaceId);
35
- if (byId && !byId.deletedAt)
44
+ if (byId && !byId.deletedAt && isSafeCanonicalAssetStorage(byId))
36
45
  return byId;
37
46
  }
38
47
  const storageKey = normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
39
48
  if (!storageKey || /^https?:\/\//i.test(storageKey))
40
49
  return null;
41
50
  const byStorageKey = deps.workspaceAssetStore.getByStorageKey(storageKey, deps.activeWorkspaceId);
42
- if (byStorageKey && !byStorageKey.deletedAt)
51
+ if (byStorageKey && !byStorageKey.deletedAt && isSafeCanonicalAssetStorage(byStorageKey))
43
52
  return byStorageKey;
44
53
  return null;
45
54
  };
@@ -80,6 +89,9 @@ export function createCanonicalAssetHelpers(deps) {
80
89
  throw new Error('Attachment scan pending');
81
90
  }
82
91
  if (asset.storageProvider === 'r2') {
92
+ if (!normalizeRelativeStorageKey(asset.storageKey)) {
93
+ throw new Error(`Attachment ${attachment.attachmentId} not found on ${targetLabel} ${taskId}`);
94
+ }
83
95
  const attachmentContext = describeAttachmentStorageContext(attachment, asset.storageKey);
84
96
  if (!deps.objectStorageClient) {
85
97
  throw new Error(`Attachment read failed from R2 because object storage is not configured in this MCP runtime (${attachmentContext})`);
@@ -97,11 +109,8 @@ export function createCanonicalAssetHelpers(deps) {
97
109
  }
98
110
  return object.body;
99
111
  }
100
- const resolvedPath = path.resolve(deps.basePath, asset.storageKey);
101
- if (!resolvedPath.startsWith(path.resolve(deps.basePath))) {
102
- throw new Error(`Attachment ${attachment.attachmentId} not found on ${targetLabel} ${taskId}`);
103
- }
104
- if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
112
+ const resolvedPath = resolveCanonicalLocalFilePath(asset.storageKey);
113
+ if (!resolvedPath) {
105
114
  throw new Error(`Attachment ${attachment.attachmentId} not found on ${targetLabel} ${taskId}`);
106
115
  }
107
116
  return fs.readFileSync(resolvedPath);
@@ -117,6 +126,9 @@ export function createCanonicalAssetHelpers(deps) {
117
126
  throw new Error('Document scan pending');
118
127
  }
119
128
  if (asset.storageProvider === 'r2') {
129
+ if (!normalizeRelativeStorageKey(asset.storageKey)) {
130
+ throw new Error(`Document ${asset.assetId} not found in workspace ${deps.activeWorkspaceId}.`);
131
+ }
120
132
  if (!deps.objectStorageClient) {
121
133
  throw new Error(`Document read failed from R2 because object storage is not configured in this MCP runtime (assetId=${asset.assetId}, storageKey=${asset.storageKey || 'unknown'})`);
122
134
  }
@@ -133,11 +145,8 @@ export function createCanonicalAssetHelpers(deps) {
133
145
  }
134
146
  return object.body;
135
147
  }
136
- const resolvedPath = path.resolve(deps.basePath, asset.storageKey);
137
- if (!resolvedPath.startsWith(path.resolve(deps.basePath))) {
138
- throw new Error(`Document ${asset.assetId} not found in workspace ${deps.activeWorkspaceId}.`);
139
- }
140
- if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
148
+ const resolvedPath = resolveCanonicalLocalFilePath(asset.storageKey);
149
+ if (!resolvedPath) {
141
150
  throw new Error(`Document ${asset.assetId} not found in workspace ${deps.activeWorkspaceId}.`);
142
151
  }
143
152
  return fs.readFileSync(resolvedPath);
@@ -176,14 +185,17 @@ export function createCanonicalAssetHelpers(deps) {
176
185
  const logicalFilename = resolveAttachmentLogicalFilename(attachment, asset);
177
186
  if (logicalFilename !== requestedFilename)
178
187
  continue;
179
- const relativePath = deps.toPosixPath(path.relative(deps.projectRoot, path.resolve(deps.basePath, asset.storageKey)));
188
+ const absolutePath = resolveStorageKeyWritePathInsideRoot(deps.basePath, asset.storageKey);
189
+ if (!absolutePath)
190
+ continue;
191
+ const relativePath = deps.toPosixPath(path.relative(deps.projectRoot, absolutePath));
180
192
  return {
181
193
  index,
182
194
  attachment,
183
195
  asset,
184
196
  descriptor,
185
197
  relativePath,
186
- absolutePath: path.resolve(deps.basePath, asset.storageKey),
198
+ absolutePath,
187
199
  logicalFilename,
188
200
  };
189
201
  }
@@ -197,10 +209,12 @@ export function createCanonicalAssetHelpers(deps) {
197
209
  const targetId = String(targetIdRaw || '').trim();
198
210
  if (!relativePath || !targetId)
199
211
  return null;
200
- const absolutePath = path.resolve(deps.projectRoot, relativePath);
201
- if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile())
212
+ const storageKey = normalizeRelativeStorageKey(relativePath.replace(/^\.taskforce\//, '').trim() || relativePath);
213
+ if (!storageKey)
214
+ return null;
215
+ const absolutePath = resolveExistingStorageKeyFilePathInsideRoot(deps.basePath, storageKey);
216
+ if (!absolutePath)
202
217
  return null;
203
- const storageKey = relativePath.replace(/^\.taskforce\//, '').trim() || relativePath;
204
218
  const buffer = fs.readFileSync(absolutePath);
205
219
  const stats = fs.statSync(absolutePath);
206
220
  const mimeType = deps.inferMimeTypeFromPath(relativePath);
@@ -72,13 +72,13 @@ export function registerDocumentAssetTools(registerTool, executeTool) {
72
72
  },
73
73
  }));
74
74
  register('save_workstream_attachment', (context) => ({
75
- description: context.describeTool('Save a file and attach it to a workstream. Use this for generated content or existing files from disk. Set overwrite=true to replace an existing canonical attachment with the same filename.'),
75
+ description: context.describeTool('Save a file and attach it to a workstream. Use this for generated content or existing local files from disk. In cloud MCP, use inline content instead of server-local file paths. Set overwrite=true to replace an existing canonical attachment with the same filename.'),
76
76
  inputSchema: {
77
77
  properties: {
78
78
  id: stringProperty('Workstream ID or workstream reference such as WS-123.'),
79
79
  filename: stringProperty('Destination filename for the attachment.'),
80
80
  content: stringProperty('Inline file content; use this for AI-authored markdown/text attachments.'),
81
- sourcePath: stringProperty('Path to an existing source file. Must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root.'),
81
+ sourcePath: stringProperty('Path to an existing source file for local runtimes only. Must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root.'),
82
82
  caption: stringProperty('Optional attachment caption.'),
83
83
  overwrite: booleanProperty('When true, overwrite an existing canonical workstream attachment with the same filename instead of creating a new one.'),
84
84
  },
@@ -117,13 +117,13 @@ export function registerDocumentAssetTools(registerTool, executeTool) {
117
117
  },
118
118
  }));
119
119
  register('save_initiative_attachment', (context) => ({
120
- description: context.describeTool('Save a file and attach it to an initiative. Use this for generated content or existing files from disk. Set overwrite=true to replace an existing canonical attachment with the same filename.'),
120
+ description: context.describeTool('Save a file and attach it to an initiative. Use this for generated content or existing local files from disk. In cloud MCP, use inline content instead of server-local file paths. Set overwrite=true to replace an existing canonical attachment with the same filename.'),
121
121
  inputSchema: {
122
122
  properties: {
123
123
  id: stringProperty('Initiative ID or initiative reference such as IN-123.'),
124
124
  filename: stringProperty('Destination filename for the attachment.'),
125
125
  content: stringProperty('Inline file content; use this for AI-authored markdown/text attachments.'),
126
- sourcePath: stringProperty('Path to an existing source file. Must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root.'),
126
+ sourcePath: stringProperty('Path to an existing source file for local runtimes only. Must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root.'),
127
127
  caption: stringProperty('Optional attachment caption.'),
128
128
  overwrite: booleanProperty('When true, overwrite an existing canonical initiative attachment with the same filename instead of creating a new one.'),
129
129
  },
@@ -422,13 +422,13 @@ export function registerDocumentAssetTools(registerTool, executeTool) {
422
422
  },
423
423
  }));
424
424
  register('save_task_attachment', (context) => ({
425
- description: context.describeTool('Save a file and attach it to a task. Use this for generated content, existing files from disk, or staged remote uploads for binary files in cloud MCP. Set overwrite=true to replace an existing canonical attachment with the same filename. For AI-authored markdown/text, use inline content for AI-authored markdown/text rather than writing an external temp file first.'),
425
+ description: context.describeTool('Save a file and attach it to a task. Use this for generated content, existing local files from disk, or staged remote uploads for binary files in cloud MCP. In cloud MCP, use inline content or the staged upload flow instead of server-local file paths. Set overwrite=true to replace an existing canonical attachment with the same filename. For AI-authored markdown/text, use inline content for AI-authored markdown/text rather than writing an external temp file first.'),
426
426
  inputSchema: {
427
427
  properties: {
428
428
  id: stringProperty('Task ID or task reference such as T-123.'),
429
429
  filename: stringProperty('Destination filename for the attachment.'),
430
430
  content: stringProperty('Inline file content; use this for AI-authored markdown/text attachments.'),
431
- sourcePath: stringProperty('Path to an existing source file. Must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root.'),
431
+ sourcePath: stringProperty('Path to an existing source file for local runtimes only. Must be within project root, a Taskforce-managed attachment source, or an approved agent workspace root.'),
432
432
  uploadedPath: stringProperty('Finalize a previously staged remote upload by passing the uploaded Taskforce storage path returned by save_task_attachment init mode.'),
433
433
  uploadDraftId: stringProperty('Finalize a previously staged remote upload by passing the uploadDraftId returned by save_task_attachment init mode.'),
434
434
  mimeType: stringProperty('Mime type for staged remote uploads such as image/png or image/jpeg. Provide this with sizeBytes to start a staged upload.'),
@@ -11,6 +11,7 @@ import { buildResolveAiProfileInputFromBootstrap } from './aiProfileBootstrap.js
11
11
  import { createCanonicalAssetHelpers } from './canonicalAssetHelpers.js';
12
12
  import { createDocumentHelpers } from './documentHelpers.js';
13
13
  import { createTaskAttachmentHelpers } from './taskAttachmentHelpers.js';
14
+ import { isPathWithinRoot } from '../utils/pathContainment.js';
14
15
  import { validateStructuredDocForAttach } from './structuredDocValidation.js';
15
16
  import { validateStructuredCommentForAdd } from './structuredCommentValidation.js';
16
17
  import { resolveDatabaseConfigFromEnv } from '../storage/providerConfig.js';
@@ -23,6 +24,7 @@ import { getDocumentReferenceLabel, parseDocumentReference } from '../utils/docu
23
24
  import { getImageReferenceLabel, parseImageReference } from '../utils/imageReferences.js';
24
25
  import { inferCanonicalAssetKind, isValidCanonicalDocumentAsset } from '../utils/canonicalAssetKind.js';
25
26
  import { getTaskReferenceLabel, shouldUseProvisionalTaskReferences } from '../utils/taskReferences.js';
27
+ import { resolveStorageKeyPathInsideRoot } from '../utils/pathSafety.js';
26
28
  import { parseAiProfileSurfaceType } from '../shared/aiProfileSurfaceType.js';
27
29
  import { parseAiProfileSeatScope } from '../shared/aiProfileSeatScope.js';
28
30
  import { formatInitiativeReference, formatWorkstreamReference, resolveInitiativeReference, parseWorkstreamReference, } from '../utils/planningReferences.js';
@@ -75,6 +77,7 @@ const TOOL_LOG_TRUNCATED_DEPTH_VALUE = '[truncated-depth]';
75
77
  const TOOL_LOG_MAX_STRING_LENGTH = 160;
76
78
  const TOOL_LOG_MAX_ARRAY_ITEMS = 8;
77
79
  const toolLogSensitiveWordSet = new Set(['token', 'tokens', 'password', 'passwords', 'secret', 'secrets', 'authorization']);
80
+ const toolLogSensitiveWordPairs = new Set(['api:key', 'client:secret', 'bearer:token', 'proxy:authorization', 'private:key']);
78
81
  function truncateToolLogString(value) {
79
82
  return `${value.slice(0, TOOL_LOG_MAX_STRING_LENGTH)}${value.length > TOOL_LOG_MAX_STRING_LENGTH ? '…' : ''}`;
80
83
  }
@@ -88,6 +91,10 @@ function shouldRedactToolLogKey(key) {
88
91
  return false;
89
92
  if (words.some((word) => toolLogSensitiveWordSet.has(word)))
90
93
  return true;
94
+ for (let index = 0; index < words.length - 1; index += 1) {
95
+ if (toolLogSensitiveWordPairs.has(`${words[index]}:${words[index + 1]}`))
96
+ return true;
97
+ }
91
98
  const compact = words.join('');
92
99
  return compact === 'apikey'
93
100
  || compact === 'clientsecret'
@@ -124,11 +131,29 @@ function sanitizeToolArgsForLog(args) {
124
131
  };
125
132
  return visit(args, 0);
126
133
  }
134
+ function redactRecoverableErrorDetails(details) {
135
+ const visit = (value, key) => {
136
+ if (key && shouldRedactToolLogKey(key))
137
+ return TOOL_LOG_REDACTED_VALUE;
138
+ if (value === null || value === undefined)
139
+ return value;
140
+ if (Array.isArray(value))
141
+ return value.map((item) => visit(item));
142
+ if (typeof value !== 'object')
143
+ return value;
144
+ const output = {};
145
+ for (const [childKey, childValue] of Object.entries(value)) {
146
+ output[childKey] = visit(childValue, childKey);
147
+ }
148
+ return output;
149
+ };
150
+ return visit(details);
151
+ }
127
152
  function resolveRecoverableToolError(error) {
128
153
  const message = error instanceof Error ? String(error.message || '').trim() : '';
129
154
  const code = error instanceof TaskforceRuleError ? error.code : undefined;
130
155
  const statusCode = error instanceof TaskforceRuleError ? error.statusCode : undefined;
131
- const details = error instanceof TaskforceRuleError ? error.details : undefined;
156
+ const details = error instanceof TaskforceRuleError ? redactRecoverableErrorDetails(error.details) : undefined;
132
157
  if (/^This tool requires an AI profile\./i.test(message)) {
133
158
  return {
134
159
  message,
@@ -914,7 +939,6 @@ export function createTaskforceMcpServer(options = {}) {
914
939
  throw new TaskforceRuleError(`AI profile "${profile.name}" has been retired from this workspace. Call resolve_profile to reactivate it before retrying.`, 409, 'AI_PROFILE_RETIRED', {
915
940
  workspaceId: ACTIVE_WORKSPACE_ID,
916
941
  profileId: profile.id,
917
- profileToken: profile.profileToken || null,
918
942
  archivedReason: profile.archivedReason || null,
919
943
  });
920
944
  }
@@ -1404,12 +1428,8 @@ export function createTaskforceMcpServer(options = {}) {
1404
1428
  function isHttpUrl(value) {
1405
1429
  return /^https?:\/\//i.test(value);
1406
1430
  }
1407
- function isWithinRoot(filePath, root) {
1408
- const relative = path.relative(root, filePath);
1409
- return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
1410
- }
1411
1431
  function isApprovedAttachmentSourcePath(filePath, projectRootReal) {
1412
- return getApprovedAttachmentSourceRoots(projectRootReal).some((root) => isWithinRoot(filePath, root));
1432
+ return getApprovedAttachmentSourceRoots(projectRootReal).some((root) => isPathWithinRoot(filePath, root));
1413
1433
  }
1414
1434
  function toPosixPath(filePath) {
1415
1435
  return filePath.split(path.sep).join('/');
@@ -1842,7 +1862,31 @@ export function createTaskforceMcpServer(options = {}) {
1842
1862
  },
1843
1863
  };
1844
1864
  }
1865
+ function buildPlanningWriteResponseDeferredDetails(targetType, entity, context) {
1866
+ const referenceLabel = targetType === 'initiative'
1867
+ ? (typeof entity?.referenceLabel === 'string' ? entity.referenceLabel : formatInitiativeReference(entity))
1868
+ : (typeof entity?.referenceLabel === 'string' ? entity.referenceLabel : formatWorkstreamReference(entity));
1869
+ return {
1870
+ deferred: true,
1871
+ reason: 'cloud_write_fast_ack',
1872
+ message: 'The write completed. Taskforce skipped richer response hydration on the cloud MCP write path to avoid turning committed writes into client-visible timeouts under concurrent QA load.',
1873
+ nextTool: targetType === 'initiative' ? 'get_initiative' : 'get_workstream',
1874
+ suggestedArgs: {
1875
+ id: referenceLabel || entity?.id || '',
1876
+ },
1877
+ context,
1878
+ };
1879
+ }
1845
1880
  function buildPostWriteInitiativeResourcePayload(initiative, context) {
1881
+ if (runtimeMode === 'cloud') {
1882
+ return {
1883
+ initiative: buildPlanningInitiativeSummary(initiative),
1884
+ workstreamCount: 0,
1885
+ taskCount: 0,
1886
+ workstreams: [],
1887
+ responseHydration: buildPlanningWriteResponseDeferredDetails('initiative', initiative, context),
1888
+ };
1889
+ }
1846
1890
  try {
1847
1891
  return buildInitiativeResourcePayload(initiative);
1848
1892
  }
@@ -1857,6 +1901,14 @@ export function createTaskforceMcpServer(options = {}) {
1857
1901
  }
1858
1902
  }
1859
1903
  function buildPostWriteWorkstreamResourcePayload(workstream, context) {
1904
+ if (runtimeMode === 'cloud') {
1905
+ return {
1906
+ workstream: buildPlanningWorkstreamSummary(workstream),
1907
+ taskCount: 0,
1908
+ tasks: [],
1909
+ responseHydration: buildPlanningWriteResponseDeferredDetails('workstream', workstream, context),
1910
+ };
1911
+ }
1860
1912
  try {
1861
1913
  return buildWorkstreamResourcePayload(workstream);
1862
1914
  }
@@ -2291,6 +2343,9 @@ export function createTaskforceMcpServer(options = {}) {
2291
2343
  if (hasContent === hasSourcePath) {
2292
2344
  throw new Error("Provide exactly one of: content or sourcePath");
2293
2345
  }
2346
+ if (hasSourcePath && runtimeMode === 'cloud') {
2347
+ throw new Error('sourcePath is only available in local runtimes. In cloud runtime, provide content instead.');
2348
+ }
2294
2349
  const now = new Date().toISOString();
2295
2350
  const safeCaption = typeof caption === 'string' && caption.trim().length > 0
2296
2351
  ? caption.trim()
@@ -2919,9 +2974,24 @@ export function createTaskforceMcpServer(options = {}) {
2919
2974
  case "replace_task_checklist": {
2920
2975
  const { taskId, items } = args;
2921
2976
  const { id: resolvedTaskId } = resolveTaskByIdentifierOrThrow(taskId);
2977
+ const now = new Date().toISOString();
2978
+ const checklistItems = Array.isArray(items)
2979
+ ? items.map((item, index) => ({
2980
+ id: item?.id,
2981
+ taskId: resolvedTaskId,
2982
+ title: String(item?.title ?? item?.text ?? '').trim() || `Checklist item ${index + 1}`,
2983
+ isCompleted: Boolean(item?.isCompleted ?? item?.done),
2984
+ order: Number.isFinite(Number(item?.order)) ? Number(item.order) : index,
2985
+ createdAt: String(item?.createdAt || now),
2986
+ updatedAt: item?.updatedAt ? String(item.updatedAt) : now,
2987
+ }))
2988
+ : [];
2922
2989
  const updatedTask = core.updateTask(resolvedTaskId, {
2923
- checklistItems: Array.isArray(items) ? items : [],
2990
+ checklistItems,
2924
2991
  }, ACTIVE_WORKSPACE_ID, { actorRef: getActiveMcpActorRef() });
2992
+ if (!updatedTask) {
2993
+ throw new Error(`Task ${resolvedTaskId} not found`);
2994
+ }
2925
2995
  const replaced = core.listChecklistItems(resolvedTaskId, ACTIVE_WORKSPACE_ID);
2926
2996
  recordMcpWriteAudit('replace_task_checklist', {
2927
2997
  taskId: resolvedTaskId,
@@ -2937,7 +3007,6 @@ export function createTaskforceMcpServer(options = {}) {
2937
3007
  text: JSON.stringify({
2938
3008
  taskId: resolvedTaskId,
2939
3009
  items: replaced,
2940
- ...(updatedTask ? { task: updatedTask } : {}),
2941
3010
  }, null, 2),
2942
3011
  }],
2943
3012
  };
@@ -4496,6 +4565,9 @@ export function createTaskforceMcpServer(options = {}) {
4496
4565
  const hasContent = typeof content === 'string';
4497
4566
  const hasSourcePath = typeof sourcePath === 'string' && sourcePath.trim().length > 0;
4498
4567
  const hasUploadedPath = typeof uploadedPath === 'string' && uploadedPath.trim().length > 0;
4568
+ if (hasSourcePath && runtimeMode === 'cloud') {
4569
+ throw new Error('sourcePath is only available in local runtimes. In cloud runtime, use content or the staged upload flow instead.');
4570
+ }
4499
4571
  const contentModeCount = Number(hasContent) + Number(hasSourcePath) + Number(hasUploadedPath);
4500
4572
  const now = new Date().toISOString();
4501
4573
  const safeCaption = typeof caption === 'string' && caption.trim().length > 0
@@ -4820,8 +4892,8 @@ export function createTaskforceMcpServer(options = {}) {
4820
4892
  await objectStorageClient.deleteObject(replacedStorageKey).catch(() => { });
4821
4893
  }
4822
4894
  else if (overwriteTarget.asset.storageProvider === 'local') {
4823
- const replacedAbsolutePath = path.resolve(basePath, replacedStorageKey);
4824
- if (replacedAbsolutePath.startsWith(path.resolve(basePath)) && fs.existsSync(replacedAbsolutePath)) {
4895
+ const replacedAbsolutePath = resolveStorageKeyPathInsideRoot(basePath, replacedStorageKey);
4896
+ if (replacedAbsolutePath && fs.existsSync(replacedAbsolutePath)) {
4825
4897
  fs.rmSync(replacedAbsolutePath, { force: true });
4826
4898
  }
4827
4899
  }
@@ -1,18 +1,40 @@
1
1
  export function createTaskAttachmentHelpers(deps) {
2
2
  const isCanonicalDocumentAssetRecord = (asset) => deps.isValidCanonicalDocumentAsset(asset);
3
+ const inferSpecificMimeType = (...candidates) => {
4
+ for (const candidate of candidates) {
5
+ const value = String(candidate || '').trim();
6
+ if (!value)
7
+ continue;
8
+ const inferred = deps.inferMimeTypeFromPath(value);
9
+ if (inferred && inferred !== 'application/octet-stream')
10
+ return inferred;
11
+ }
12
+ return '';
13
+ };
3
14
  const describeTaskAttachment = (taskId, attachmentInput) => {
4
15
  const attachment = deps.canonicalAssetHelpers.toTaskAttachmentRecord(attachmentInput);
5
16
  const asset = deps.canonicalAssetHelpers.getAttachmentAssetRecord(attachment);
6
17
  const storageKey = deps.canonicalAssetHelpers.normalizeAttachmentStorageKey(attachment.path, attachment.fsPath);
7
- const assetId = String(attachment.assetId || asset?.assetId || '').trim() || undefined;
8
- const source = (asset?.storageProvider === 'external' || /^https?:\/\//i.test(String(attachment.path || '').trim()))
18
+ const assetId = String(asset?.assetId || '').trim() || undefined;
19
+ const source = (asset?.storageProvider === 'external'
20
+ || /^https?:\/\//i.test(String(attachment.path || '').trim())
21
+ || (!asset && !storageKey))
9
22
  ? 'external'
10
23
  : 'canonical';
11
- const mimeType = String(asset?.mimeType
12
- || (storageKey && !/^https?:\/\//i.test(storageKey) ? deps.inferMimeTypeFromPath(storageKey) : '')
13
- || deps.inferMimeTypeFromPath(String(attachment.originalFilename || attachment.displayName || attachment.caption || attachment.path || ''))).trim() || 'application/octet-stream';
14
- const kind = asset?.kind || deps.inferAssetKind(storageKey || String(attachment.path || ''), mimeType);
15
- const isCanonicalDocument = Boolean(asset && isCanonicalDocumentAssetRecord(asset)) || (!asset && kind === 'document');
24
+ const storedMimeType = String(asset?.mimeType || '').trim();
25
+ const inferredMimeType = inferSpecificMimeType(asset?.originalFilename, asset?.storageKey, storageKey && !/^https?:\/\//i.test(storageKey) ? storageKey : '', attachment.originalFilename, attachment.displayName, attachment.path);
26
+ const mimeType = (storedMimeType && storedMimeType !== 'application/octet-stream'
27
+ ? storedMimeType
28
+ : inferredMimeType) || storedMimeType || 'application/octet-stream';
29
+ const inferredKind = deps.inferAssetKind(asset?.originalFilename || asset?.storageKey || storageKey || String(attachment.originalFilename || attachment.path || ''), mimeType);
30
+ const kind = asset?.kind && asset.kind !== 'file' ? asset.kind : inferredKind;
31
+ const isCanonicalDocument = kind === 'document' && (!asset
32
+ || isCanonicalDocumentAssetRecord({
33
+ kind,
34
+ storageKey: String(asset.storageKey || storageKey || '').trim(),
35
+ originalFilename: String(asset.originalFilename || attachment.originalFilename || '').trim(),
36
+ mimeType,
37
+ }));
16
38
  const referenceNumber = typeof attachment.referenceNumber === 'number'
17
39
  ? attachment.referenceNumber
18
40
  : (typeof asset?.referenceNumber === 'number' ? asset.referenceNumber : null);
@@ -700,6 +700,7 @@ export function ensureEntitlementsSchema(db) {
700
700
  label TEXT,
701
701
  description TEXT,
702
702
  is_custom INTEGER NOT NULL DEFAULT 0,
703
+ public_visible INTEGER,
703
704
  public_label TEXT,
704
705
  public_description TEXT,
705
706
  public_description_visible INTEGER,
@@ -773,6 +774,9 @@ export function ensureEntitlementsSchema(db) {
773
774
  if (!planFeatureCatalogOverrideColumns.some((column) => column.name === 'is_custom')) {
774
775
  db.exec(`ALTER TABLE plan_feature_catalog_overrides ADD COLUMN is_custom INTEGER NOT NULL DEFAULT 0`);
775
776
  }
777
+ if (!planFeatureCatalogOverrideColumns.some((column) => column.name === 'public_visible')) {
778
+ db.exec(`ALTER TABLE plan_feature_catalog_overrides ADD COLUMN public_visible INTEGER`);
779
+ }
776
780
  if (!planFeatureCatalogOverrideColumns.some((column) => column.name === 'public_description_visible')) {
777
781
  db.exec(`ALTER TABLE plan_feature_catalog_overrides ADD COLUMN public_description_visible INTEGER`);
778
782
  }
@@ -784,7 +784,7 @@ export function createStandaloneServer(options) {
784
784
  return;
785
785
  }
786
786
  }
787
- if (isAuthApiPath) {
787
+ if (shouldApplyAuthRateLimit) {
788
788
  const latestAuthRateSettings = resolveAuthRateLimitSettings();
789
789
  if (latestAuthRateSettings.enabled !== authRateLimitSettings.enabled
790
790
  || latestAuthRateSettings.maxRequests !== authRateLimitSettings.maxRequests
@@ -732,6 +732,9 @@ export function registerAdminRoutes(deps) {
732
732
  const publicDescription = body?.publicDescription === undefined || body?.publicDescription === null
733
733
  ? body?.publicDescription
734
734
  : String(body.publicDescription);
735
+ const publicVisible = body?.publicVisible === undefined || body?.publicVisible === null
736
+ ? body?.publicVisible
737
+ : (body.publicVisible === false ? false : true);
735
738
  const publicDescriptionVisible = body?.publicDescriptionVisible === undefined || body?.publicDescriptionVisible === null
736
739
  ? body?.publicDescriptionVisible
737
740
  : (body.publicDescriptionVisible === false ? false : true);
@@ -772,6 +775,7 @@ export function registerAdminRoutes(deps) {
772
775
  const feature = core.createCustomPlanFeatureCatalogEntry({
773
776
  label,
774
777
  description,
778
+ publicVisible,
775
779
  publicLabel,
776
780
  publicDescription,
777
781
  publicDescriptionVisible,
@@ -781,6 +785,7 @@ export function registerAdminRoutes(deps) {
781
785
  featureKey: feature.featureKey,
782
786
  label: feature.label,
783
787
  description: feature.description,
788
+ publicVisible: feature.publicVisible ?? null,
784
789
  publicLabel: feature.publicLabel ?? null,
785
790
  publicDescription: feature.publicDescription ?? null,
786
791
  publicDescriptionVisible: feature.publicDescriptionVisible ?? null,
@@ -817,6 +822,9 @@ export function registerAdminRoutes(deps) {
817
822
  const publicDescription = body?.publicDescription === undefined || body?.publicDescription === null
818
823
  ? body?.publicDescription
819
824
  : String(body.publicDescription);
825
+ const publicVisible = body?.publicVisible === undefined || body?.publicVisible === null
826
+ ? body?.publicVisible
827
+ : (body.publicVisible === false ? false : true);
820
828
  const publicDescriptionVisible = body?.publicDescriptionVisible === undefined || body?.publicDescriptionVisible === null
821
829
  ? body?.publicDescriptionVisible
822
830
  : (body.publicDescriptionVisible === false ? false : true);
@@ -853,6 +861,7 @@ export function registerAdminRoutes(deps) {
853
861
  featureKey,
854
862
  label,
855
863
  description,
864
+ publicVisible,
856
865
  publicLabel,
857
866
  publicDescription,
858
867
  publicDescriptionVisible,
@@ -862,6 +871,7 @@ export function registerAdminRoutes(deps) {
862
871
  featureKey,
863
872
  label: feature.label,
864
873
  description: feature.description,
874
+ publicVisible: feature.publicVisible ?? null,
865
875
  publicLabel: feature.publicLabel ?? null,
866
876
  publicDescription: feature.publicDescription ?? null,
867
877
  publicDescriptionVisible: feature.publicDescriptionVisible ?? null,
@@ -2669,6 +2669,9 @@ export function registerBillingRoutes(deps) {
2669
2669
  return acc;
2670
2670
  }
2671
2671
  const catalogEntry = featureCatalogByKey.get(featureKey);
2672
+ if (catalogEntry?.publicVisible === false) {
2673
+ return acc;
2674
+ }
2672
2675
  acc.push({
2673
2676
  featureKey,
2674
2677
  access,
@@ -25,6 +25,7 @@
25
25
  import * as fs from 'fs';
26
26
  import * as path from 'path';
27
27
  import { normalizeTaskDataRelativePath } from '../../storage/objectStorageClient.js';
28
+ import { resolveExistingStorageKeyFilePathInsideRoot } from '../../utils/pathSafety.js';
28
29
  import { shouldUseProvisionalTaskReferences } from '../../utils/taskReferences.js';
29
30
  import { purgeExpiredDocuments } from '../../storage/documentPurge.js';
30
31
  import { parsePlan } from '../../utils/planParser.js';
@@ -427,7 +428,7 @@ export function registerDocumentRoutes(deps) {
427
428
  return;
428
429
  }
429
430
  const filePath = normalizedRelative
430
- ? ensureWithinDir(path.join(basePath, normalizedRelative), basePath)
431
+ ? resolveExistingStorageKeyFilePathInsideRoot(basePath, normalizedRelative)
431
432
  : null;
432
433
  const ext = normalizedRelative ? path.extname(normalizedRelative).toLowerCase() : '';
433
434
  const resolvedDownloadNameBase = sanitizeDownloadFilename(requestedFilename
@@ -23,6 +23,7 @@ import { getDocumentReferenceLabel } from '../utils/documentReferences.js';
23
23
  import { getImageReferenceLabel } from '../utils/imageReferences.js';
24
24
  import { inferCanonicalAssetKind, isValidCanonicalDocumentAsset } from '../utils/canonicalAssetKind.js';
25
25
  import { isReservedWorkspaceId } from '../utils/workspaceIdentity.js';
26
+ import { normalizeRelativeStorageKey, resolveExistingStorageKeyFilePathInsideRoot } from '../utils/pathSafety.js';
26
27
  import { resolveVirusScanConfigFromEnv, scanBufferForThreats } from '../security/virusScan.js';
27
28
  import { registerSyncRoutes } from './routes/sync.js';
28
29
  import { registerBillingRoutes } from './routes/billing.js';
@@ -788,11 +789,14 @@ export function createRoutes(core, context = {}) {
788
789
  const resolvedObjectStorage = resolveEffectiveObjectStorageConfig();
789
790
  const objectStorageClient = getObjectStorageClient();
790
791
  const { basePath } = core.getPaths();
791
- const ext = path.extname(params.asset.storageKey).toLowerCase();
792
+ const storageKey = normalizeRelativeStorageKey(params.asset.storageKey);
793
+ if (!storageKey)
794
+ return { statusCode: 404 };
795
+ const ext = path.extname(storageKey).toLowerCase();
792
796
  const resolvedDownloadNameBase = sanitizeDownloadFilename(String(params.requestedFilename || '').trim()
793
797
  || params.asset.logicalName
794
798
  || params.asset.originalFilename
795
- || path.basename(params.asset.storageKey, ext)
799
+ || path.basename(storageKey, ext)
796
800
  || 'download');
797
801
  const resolvedDownloadName = ext && !resolvedDownloadNameBase.toLowerCase().endsWith(ext)
798
802
  ? `${resolvedDownloadNameBase}${ext}`
@@ -805,7 +809,7 @@ export function createRoutes(core, context = {}) {
805
809
  return { statusCode: 404 };
806
810
  }
807
811
  if (!params.forceDownload) {
808
- const object = await objectStorageClient.getObject(params.asset.storageKey);
812
+ const object = await objectStorageClient.getObject(storageKey);
809
813
  if (!object) {
810
814
  return { statusCode: 404 };
811
815
  }
@@ -821,15 +825,15 @@ export function createRoutes(core, context = {}) {
821
825
  return {
822
826
  statusCode: 302,
823
827
  headers: {
824
- Location: objectStorageClient.createSignedGetUrl(params.asset.storageKey, resolvedObjectStorage.r2.signedDownloadTtlSeconds, { responseContentDisposition: contentDisposition })
828
+ Location: objectStorageClient.createSignedGetUrl(storageKey, resolvedObjectStorage.r2.signedDownloadTtlSeconds, { responseContentDisposition: contentDisposition })
825
829
  }
826
830
  };
827
831
  };
828
832
  if (params.asset.storageProvider === 'r2' && objectStorageClient && resolvedObjectStorage.provider === 'r2' && resolvedObjectStorage.r2) {
829
833
  return readFromObjectStorage();
830
834
  }
831
- const filePath = ensureWithinDir(path.join(basePath, params.asset.storageKey), basePath);
832
- if (filePath && fs.existsSync(filePath)) {
835
+ const filePath = resolveExistingStorageKeyFilePathInsideRoot(basePath, storageKey);
836
+ if (filePath) {
833
837
  return {
834
838
  statusCode: 200,
835
839
  headers: {
@@ -2,12 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { DocumentRegistry, createDocumentHash } from './documentRegistry.js';
4
4
  import { ObjectStorageClient } from './objectStorageClient.js';
5
- function isPathInsideRoot(candidatePath, rootDir) {
6
- const resolved = path.resolve(candidatePath);
7
- const root = path.resolve(rootDir);
8
- const relative = path.relative(root, resolved);
9
- return !relative.startsWith('..') && !path.isAbsolute(relative);
10
- }
5
+ import { isPathInsideRoot } from '../utils/pathSafety.js';
11
6
  function inc(byCode, code) {
12
7
  byCode[code] = (byCode[code] || 0) + 1;
13
8
  }