@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.
- package/dist/compat/workspaceSyncCompat.js +2 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +51 -11
- package/dist/core/PlanEntitlementService.d.ts +2 -0
- package/dist/core/PlanEntitlementService.js +26 -9
- package/dist/core/Taskforce.d.ts +5 -0
- package/dist/core/Taskforce.js +16 -2
- package/dist/core/types.d.ts +1 -0
- package/dist/hooks/sync/recovery.d.ts +1 -0
- package/dist/hooks/sync/recovery.js +1 -0
- package/dist/hooks/sync/transfers.d.ts +2 -0
- package/dist/hooks/sync/transfers.js +3 -0
- package/dist/hooks/useSyncOrchestrator.js +9 -1
- package/dist/hooks/useTaskforce.js +13 -6
- package/dist/hooks/useWorkspaceSyncController.js +3 -0
- package/dist/mcp/canonicalAssetHelpers.js +33 -19
- package/dist/mcp/documentAssetRegistrar.js +6 -6
- package/dist/mcp/runtime.js +83 -11
- package/dist/mcp/taskAttachmentHelpers.js +29 -7
- package/dist/migrations/taskSchemaMigrations.js +4 -0
- package/dist/server/index.js +1 -1
- package/dist/server/routes/admin.js +10 -0
- package/dist/server/routes/billing.js +3 -0
- package/dist/server/routes/documents.js +2 -1
- package/dist/server/routes.js +10 -6
- package/dist/storage/documentIntegrity.js +1 -6
- package/dist/storage/documentPurge.js +11 -12
- package/dist/sync/workspaceRepair.js +11 -9
- package/dist/sync/workspaceSyncState.d.ts +1 -0
- package/dist/sync/workspaceSyncState.js +1 -0
- package/dist/ui/.well-known/mcp-registry-auth +1 -0
- package/dist/ui/assets/{AgentsModule-BULqIo8e.js → AgentsModule-CNBWCIXk.js} +1 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-9qxB0e6A.js +3 -0
- package/dist/ui/assets/{ContextAttachmentManager-DoMe2OpY.js → ContextAttachmentManager-CRyuFYlg.js} +1 -1
- package/dist/ui/assets/{DocumentWorkspace-DumBWhvb.js → DocumentWorkspace-Dx7wb9NF.js} +1 -1
- package/dist/ui/assets/{EntityActivityTimeline-sHk8Nzbq.js → EntityActivityTimeline-Cmal2OrJ.js} +1 -1
- package/dist/ui/assets/{InitiativesModule-3X9UNzHh.js → InitiativesModule-wzbYPQQL.js} +1 -1
- package/dist/ui/assets/{PlansPage-C3A3EYaV.js → PlansPage-BAnSfbTf.js} +1 -1
- package/dist/ui/assets/{TaskContextUpload-C8G7pWkX.js → TaskContextUpload-DENyMyUk.js} +1 -1
- package/dist/ui/assets/{TaskSettings-DM1o9Wla.js → TaskSettings-DASuVwpY.js} +1 -1
- package/dist/ui/assets/{WorkflowsModule-CW0crzvD.js → WorkflowsModule-DJMEA_yt.js} +1 -1
- package/dist/ui/assets/documentReferences-BhNx80zO.js +1 -0
- package/dist/ui/assets/index-CWg2olz9.js +5 -0
- package/dist/ui/index.html +1 -1
- package/dist/utils/pathContainment.d.ts +7 -0
- package/dist/utils/pathContainment.js +53 -0
- package/dist/utils/pathSafety.d.ts +6 -0
- package/dist/utils/pathSafety.js +73 -0
- package/package.json +3 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-DHr4rHK9.js +0 -3
- package/dist/ui/assets/documentReferences-BZB3nMRb.js +0 -1
- 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()
|
|
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()
|
|
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 =
|
|
101
|
-
if (!resolvedPath
|
|
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 =
|
|
137
|
-
if (!resolvedPath
|
|
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
|
|
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
|
|
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
|
|
201
|
-
if (!
|
|
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.'),
|
package/dist/mcp/runtime.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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 =
|
|
4824
|
-
if (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(
|
|
8
|
-
const source = (asset?.storageProvider === 'external'
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -784,7 +784,7 @@ export function createStandaloneServer(options) {
|
|
|
784
784
|
return;
|
|
785
785
|
}
|
|
786
786
|
}
|
|
787
|
-
if (
|
|
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
|
-
?
|
|
431
|
+
? resolveExistingStorageKeyFilePathInsideRoot(basePath, normalizedRelative)
|
|
431
432
|
: null;
|
|
432
433
|
const ext = normalizedRelative ? path.extname(normalizedRelative).toLowerCase() : '';
|
|
433
434
|
const resolvedDownloadNameBase = sanitizeDownloadFilename(requestedFilename
|
package/dist/server/routes.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
832
|
-
if (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
|
-
|
|
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
|
}
|