Files
memind/mindspace-workspace-tool-service.mjs
john 371900bae5
Memind CI / Test, build, and release guards (pull_request) Failing after 18s
feat(mindspace): add page quota grace write and local delivery guardrails.
Centralize page HTML quota checks with grace-write semantics across page
services and workspace tools, keep localhost MindSpace links clickable in
chat display, and expand Page Data/static-page skill plus local dev docs
for quota, delivery URLs, and native Aider/OpenHands tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:44:26 +08:00

911 lines
22 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import {
assertNoProhibitedBrowserStorage,
} from './mindspace-browser-storage-policy.mjs';
import {
buildMindSpacePublicUrlForUser,
resolveMindSpaceUserPublishDir,
} from './mindspace-runtime-config.mjs';
import {
renderLongImage,
} from './mindspace-long-image.mjs';
import {
UPLOAD_ZONE_CODES,
} from './user-space.mjs';
import {
assertPageWriteQuotaForUser,
isMindSpacePageWriteRelativePath,
} from './mindspace-space-quota.mjs';
function workspaceToolError(message, code) {
return Object.assign(new Error(message), { code });
}
function requireServiceMethod(
service,
methodName,
) {
if (
!service ||
typeof service[methodName] !== 'function'
) {
throw new Error(
`createMindSpaceWorkspaceToolService requires conversationArtifactService.${methodName}`,
);
}
}
function requiredString(value, field) {
const normalized = String(value ?? '').trim();
if (!normalized) {
throw workspaceToolError(
`${field} is required`,
'invalid_workspace_tool_input',
);
}
return normalized;
}
export function parseMindSpaceWorkspaceRef(
workspaceRef,
) {
const normalized = requiredString(
workspaceRef,
'workspaceRef',
);
let parsed;
try {
parsed = new URL(normalized);
} catch {
throw workspaceToolError(
'Invalid MindSpace workspaceRef',
'invalid_workspace_ref',
);
}
let segments;
try {
segments = parsed.pathname
.split('/')
.filter(Boolean)
.map((segment) =>
decodeURIComponent(segment),
);
} catch {
throw workspaceToolError(
'Invalid MindSpace workspaceRef encoding',
'invalid_workspace_ref',
);
}
if (
parsed.protocol !== 'mindspace:' ||
parsed.hostname !== 'users' ||
parsed.username ||
parsed.password ||
parsed.port ||
parsed.search ||
parsed.hash ||
segments.length !== 2 ||
segments[1] !== 'workspace' ||
segments[0] === '.' ||
segments[0] === '..' ||
/[\\/]/.test(segments[0])
) {
throw workspaceToolError(
'Invalid MindSpace workspaceRef',
'invalid_workspace_ref',
);
}
return {
workspaceRef: normalized,
userId: requiredString(
segments[0],
'workspaceRef.userId',
),
};
}
export function normalizeMindSpaceWorkspacePath(
value,
{ allowRoot = false } = {},
) {
const raw = String(value ?? '')
.normalize('NFKC')
.trim()
.replace(/\\/g, '/');
if (
(allowRoot && (!raw || raw === '.')) ||
(allowRoot && raw === './')
) {
return '';
}
if (
!raw ||
raw.startsWith('/') ||
/^[a-z]:\//i.test(raw) ||
raw.includes('\0')
) {
throw workspaceToolError(
'Workspace path must be relative',
'invalid_workspace_path',
);
}
const segments = raw
.split('/')
.filter(Boolean);
if (
segments.some(
(segment) =>
segment === '.' ||
segment === '..',
) ||
segments[0] === '.mindspace'
) {
throw workspaceToolError(
'Workspace path is outside the allowed logical workspace',
'invalid_workspace_path',
);
}
return segments.join('/');
}
function pathInsideRoot(root, target) {
return (
target === root ||
target.startsWith(`${root}${path.sep}`)
);
}
async function ensureNoSymlinkTraversal(
root,
relativePath,
{ includeLeaf = true } = {},
) {
const rootReal = await fs.realpath(root);
const segments = relativePath
? relativePath.split('/')
: [];
const limit = includeLeaf
? segments.length
: Math.max(0, segments.length - 1);
let current = rootReal;
for (let index = 0; index < limit; index += 1) {
current = path.join(
current,
segments[index],
);
let stat;
try {
stat = await fs.lstat(current);
} catch (error) {
if (error?.code === 'ENOENT') break;
throw error;
}
if (stat.isSymbolicLink()) {
throw workspaceToolError(
'Workspace symlink traversal is forbidden',
'invalid_workspace_path',
);
}
}
return rootReal;
}
async function resolveExistingWorkspacePath(
root,
relativePath,
) {
const rootReal =
await ensureNoSymlinkTraversal(
root,
relativePath,
);
const target = path.join(
rootReal,
...relativePath.split('/'),
);
let targetReal;
try {
targetReal = await fs.realpath(target);
} catch (error) {
if (error?.code === 'ENOENT') {
throw workspaceToolError(
`Workspace entry not found: ${relativePath}`,
'workspace_entry_not_found',
);
}
throw error;
}
if (!pathInsideRoot(rootReal, targetReal)) {
throw workspaceToolError(
'Workspace path escaped its logical root',
'invalid_workspace_path',
);
}
return targetReal;
}
async function resolveWritableWorkspacePath(
root,
relativePath,
) {
const rootReal =
await ensureNoSymlinkTraversal(
root,
relativePath,
{ includeLeaf: false },
);
const target = path.join(
rootReal,
...relativePath.split('/'),
);
await fs.mkdir(path.dirname(target), {
recursive: true,
});
const parentReal = await fs.realpath(
path.dirname(target),
);
if (!pathInsideRoot(rootReal, parentReal)) {
throw workspaceToolError(
'Workspace path escaped its logical root',
'invalid_workspace_path',
);
}
try {
const stat = await fs.lstat(target);
if (stat.isSymbolicLink()) {
throw workspaceToolError(
'Workspace symlink writes are forbidden',
'invalid_workspace_path',
);
}
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
return target;
}
function assertPackageScope(
sessionId,
packageId,
) {
const expected = `cp_${sessionId}`;
if (packageId !== expected) {
throw workspaceToolError(
'packageId does not match the scoped session',
'invalid_workspace_package_scope',
);
}
}
function mimeSafeByteLength(content) {
return Buffer.byteLength(
String(content ?? ''),
'utf8',
);
}
function decodeWorkspaceBinary(
value,
{ maxBytes },
) {
const encoded = String(value ?? '').trim();
if (
!encoded ||
encoded.length % 4 !== 0 ||
!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)
) {
throw workspaceToolError(
'Workspace binary body must be valid base64',
'invalid_workspace_binary',
);
}
const body = Buffer.from(encoded, 'base64');
if (
body.length === 0 ||
body.length > maxBytes ||
body.toString('base64') !== encoded
) {
throw workspaceToolError(
body.length > maxBytes
? 'Workspace binary file exceeds the write limit'
: 'Workspace binary body must be valid base64',
body.length > maxBytes
? 'workspace_file_too_large'
: 'invalid_workspace_binary',
);
}
return body;
}
export function createMindSpaceWorkspaceToolService({
h5Root,
env = process.env,
syncWorkspaceAssets = null,
conversationArtifactService,
workspacePageDeliveryService = null,
resolveMindSpaceUserPublishDirFn =
resolveMindSpaceUserPublishDir,
buildMindSpacePublicUrlForUserFn =
buildMindSpacePublicUrlForUser,
renderLongImageFn = renderLongImage,
maxReadBytes = 5 * 1024 * 1024,
maxWriteBytes = 10 * 1024 * 1024,
maxBinaryWriteBytes = 8 * 1024 * 1024,
maxGeneratedBinaryBytes =
40 * 1024 * 1024,
assertPageWriteQuotaForUserFn =
assertPageWriteQuotaForUser,
getQuotaPool = null,
} = {}) {
if (!h5Root) {
throw new Error(
'createMindSpaceWorkspaceToolService requires h5Root',
);
}
requireServiceMethod(
conversationArtifactService,
'registerPublicHtmlArtifacts',
);
requireServiceMethod(
conversationArtifactService,
'registerWorkspaceFileArtifact',
);
if (
!workspacePageDeliveryService ||
typeof workspacePageDeliveryService
.syncAndDeliver !== 'function'
) {
throw new Error(
'createMindSpaceWorkspaceToolService requires workspacePageDeliveryService.syncAndDeliver',
);
}
const resolveScope = (input = {}) => {
const {
workspaceRef,
sessionId,
packageId,
} = input;
const parsed =
parseMindSpaceWorkspaceRef(workspaceRef);
const suppliedUserId = String(
input.userId ?? '',
).trim();
if (
suppliedUserId &&
suppliedUserId !== parsed.userId
) {
throw workspaceToolError(
'userId does not match workspaceRef',
'invalid_workspace_user_scope',
);
}
const normalizedSessionId =
requiredString(sessionId, 'sessionId');
const normalizedPackageId =
requiredString(packageId, 'packageId');
assertPackageScope(
normalizedSessionId,
normalizedPackageId,
);
const workspaceRoot =
resolveMindSpaceUserPublishDirFn(
h5Root,
{ id: parsed.userId },
);
return {
...parsed,
sessionId: normalizedSessionId,
packageId: normalizedPackageId,
workspaceRoot,
};
};
const ensurePageWriteAllowed = async ({
userId,
relativePath,
requiredBytes,
}) => {
if (
!getQuotaPool ||
typeof assertPageWriteQuotaForUserFn !== 'function' ||
!isMindSpacePageWriteRelativePath(relativePath)
) {
return;
}
const pool = getQuotaPool();
if (!pool) return;
await assertPageWriteQuotaForUserFn(pool, userId, requiredBytes);
};
const registerWrite = async ({
scope,
relativePath,
messageId = null,
body = null,
}) => {
let publicHtmlArtifacts = [];
let workspaceFileArtifact = null;
if (
relativePath.startsWith('public/') &&
relativePath.toLowerCase().endsWith('.html') &&
typeof conversationArtifactService
?.registerPublicHtmlArtifacts ===
'function'
) {
publicHtmlArtifacts =
await conversationArtifactService
.registerPublicHtmlArtifacts({
userId: scope.userId,
sessionId: scope.sessionId,
relativePaths: [relativePath],
artifactRefs: [
{
relativePath,
messageId:
String(messageId ?? '').trim() ||
null,
},
],
});
} else if (
body != null &&
typeof conversationArtifactService
?.registerWorkspaceFileArtifact ===
'function'
) {
workspaceFileArtifact =
await conversationArtifactService
.registerWorkspaceFileArtifact({
userId: scope.userId,
sessionId: scope.sessionId,
messageId:
String(messageId ?? '').trim() ||
null,
relativePath,
body,
});
}
const categoryCode =
relativePath.split('/')[0];
let assetSync = null;
if (
typeof syncWorkspaceAssets === 'function' &&
UPLOAD_ZONE_CODES.includes(categoryCode)
) {
assetSync = await syncWorkspaceAssets(
scope.userId,
{
categoryCode,
onlyRelativePaths: [relativePath],
},
);
}
return {
assetSync,
publicHtmlArtifacts,
workspaceFileArtifact,
};
};
return {
async readFile(input = {}) {
const scope = resolveScope(input);
const relativePath =
normalizeMindSpaceWorkspacePath(
input.path,
);
const target =
await resolveExistingWorkspacePath(
scope.workspaceRoot,
relativePath,
);
const stat = await fs.stat(target);
if (!stat.isFile()) {
throw workspaceToolError(
'Workspace path is not a file',
'invalid_workspace_path',
);
}
if (stat.size > maxReadBytes) {
throw workspaceToolError(
'Workspace file exceeds the read limit',
'workspace_file_too_large',
);
}
return {
workspaceRef: scope.workspaceRef,
packageId: scope.packageId,
relativePath,
content: await fs.readFile(
target,
'utf8',
),
sizeBytes: stat.size,
};
},
async writeFile(input = {}) {
const scope = resolveScope(input);
const relativePath =
normalizeMindSpaceWorkspacePath(
input.path,
);
const content = String(
input.content ?? '',
);
const sizeBytes =
mimeSafeByteLength(content);
if (sizeBytes > maxWriteBytes) {
throw workspaceToolError(
'Workspace file exceeds the write limit',
'workspace_file_too_large',
);
}
assertNoProhibitedBrowserStorage(
content,
{ relativePath },
);
await ensurePageWriteAllowed({
userId: scope.userId,
relativePath,
requiredBytes: sizeBytes,
});
const target =
await resolveWritableWorkspacePath(
scope.workspaceRoot,
relativePath,
);
await fs.writeFile(target, content, 'utf8');
const registration = await registerWrite({
scope,
relativePath,
messageId: input.messageId,
body: content,
});
return {
workspaceRef: scope.workspaceRef,
packageId: scope.packageId,
relativePath,
sizeBytes,
registration,
};
},
async writeBinaryFile(input = {}) {
const scope = resolveScope(input);
const relativePath =
normalizeMindSpaceWorkspacePath(
input.path,
);
const body = decodeWorkspaceBinary(
input.bodyBase64,
{ maxBytes: maxBinaryWriteBytes },
);
const target =
await resolveWritableWorkspacePath(
scope.workspaceRoot,
relativePath,
);
await fs.writeFile(target, body);
const registration = await registerWrite({
scope,
relativePath,
messageId: input.messageId,
body,
});
return {
workspaceRef: scope.workspaceRef,
packageId: scope.packageId,
relativePath,
sizeBytes: body.length,
registration,
};
},
async editFile(input = {}) {
const scope = resolveScope(input);
const relativePath =
normalizeMindSpaceWorkspacePath(
input.path,
);
const target =
await resolveExistingWorkspacePath(
scope.workspaceRoot,
relativePath,
);
const original = await fs.readFile(
target,
'utf8',
);
const oldString = String(
input.oldString ?? input.old_str ?? '',
);
if (
oldString &&
!original.includes(oldString)
) {
throw workspaceToolError(
'edit_file: old_str was not found',
'workspace_edit_not_found',
);
}
const newString = String(
input.newString ?? input.new_str ?? '',
);
const updated = oldString
? original.replace(oldString, newString)
: newString;
const sizeBytes =
mimeSafeByteLength(updated);
if (sizeBytes > maxWriteBytes) {
throw workspaceToolError(
'Workspace file exceeds the write limit',
'workspace_file_too_large',
);
}
assertNoProhibitedBrowserStorage(
updated,
{ relativePath },
);
const originalSizeBytes = mimeSafeByteLength(original);
await ensurePageWriteAllowed({
userId: scope.userId,
relativePath,
requiredBytes: Math.max(0, sizeBytes - originalSizeBytes),
});
await fs.writeFile(target, updated, 'utf8');
const registration = await registerWrite({
scope,
relativePath,
messageId: input.messageId,
body: updated,
});
return {
workspaceRef: scope.workspaceRef,
packageId: scope.packageId,
relativePath,
sizeBytes,
registration,
};
},
async listDirectory(input = {}) {
const scope = resolveScope(input);
const relativePath =
normalizeMindSpaceWorkspacePath(
input.path,
{ allowRoot: true },
);
const target = relativePath
? await resolveExistingWorkspacePath(
scope.workspaceRoot,
relativePath,
)
: await fs.realpath(
scope.workspaceRoot,
);
const entries = await fs.readdir(target, {
withFileTypes: true,
});
return {
workspaceRef: scope.workspaceRef,
packageId: scope.packageId,
relativePath,
entries: entries
.filter(
(entry) =>
entry.name !== '.mindspace',
)
.map((entry) => ({
name: entry.name,
type: entry.isDirectory()
? 'directory'
: entry.isFile()
? 'file'
: 'other',
}))
.sort((left, right) =>
left.name.localeCompare(right.name),
),
};
},
async createDirectory(input = {}) {
const scope = resolveScope(input);
const relativePath =
normalizeMindSpaceWorkspacePath(
input.path,
);
const target =
await resolveWritableWorkspacePath(
scope.workspaceRoot,
`${relativePath}/.mindspace-create-placeholder`,
);
await fs.mkdir(path.dirname(target), {
recursive: true,
});
return {
workspaceRef: scope.workspaceRef,
packageId: scope.packageId,
relativePath,
};
},
async generateLongImage(input = {}) {
const scope = resolveScope(input);
const htmlRelativePath =
normalizeMindSpaceWorkspacePath(
input.htmlPath ??
input.html_path ??
input.path,
);
if (
!htmlRelativePath
.toLowerCase()
.endsWith('.html')
) {
throw workspaceToolError(
'generate_long_image requires an HTML source',
'invalid_workspace_page_path',
);
}
const outputRelativePath =
normalizeMindSpaceWorkspacePath(
input.outputPath ??
input.output_path ??
htmlRelativePath.replace(
/\.html$/i,
'.long.png',
),
);
if (
!outputRelativePath
.toLowerCase()
.endsWith('.png')
) {
throw workspaceToolError(
'generate_long_image output must be PNG',
'invalid_workspace_binary',
);
}
const htmlTarget =
await resolveExistingWorkspacePath(
scope.workspaceRoot,
htmlRelativePath,
);
const htmlStat = await fs.stat(htmlTarget);
if (!htmlStat.isFile()) {
throw workspaceToolError(
'Long-image source is not a file',
'invalid_workspace_path',
);
}
const outputTarget =
await resolveWritableWorkspacePath(
scope.workspaceRoot,
outputRelativePath,
);
const sourceCanonicalUrl =
buildMindSpacePublicUrlForUserFn({
h5Root,
env,
user: {
id: scope.userId,
},
relativePath:
htmlRelativePath,
});
await renderLongImageFn({
url: sourceCanonicalUrl,
outputPath: outputTarget,
});
const outputStat =
await fs.stat(outputTarget);
if (
!outputStat.isFile() ||
outputStat.size <= 0
) {
throw workspaceToolError(
'Long-image renderer did not produce a file',
'workspace_entry_not_found',
);
}
if (
outputStat.size >
maxGeneratedBinaryBytes
) {
throw workspaceToolError(
'Generated long image exceeds the write limit',
'workspace_file_too_large',
);
}
const registration = await registerWrite({
scope,
relativePath: outputRelativePath,
messageId: input.messageId,
body: await fs.readFile(outputTarget),
});
return {
workspaceRef: scope.workspaceRef,
packageId: scope.packageId,
relativePath: outputRelativePath,
sizeBytes: outputStat.size,
sourceCanonicalUrl,
canonicalUrl:
outputRelativePath.startsWith(
'public/',
)
? buildMindSpacePublicUrlForUserFn({
h5Root,
env,
user: {
id: scope.userId,
},
relativePath:
outputRelativePath,
})
: null,
registration,
};
},
async publishPage(input = {}) {
const scope = resolveScope(input);
const relativePath =
normalizeMindSpaceWorkspacePath(
input.path ?? input.relativePath,
);
if (
!relativePath.startsWith('public/') ||
!relativePath.toLowerCase().endsWith('.html')
) {
throw workspaceToolError(
'publish_page requires public/*.html',
'invalid_workspace_page_path',
);
}
await resolveExistingWorkspacePath(
scope.workspaceRoot,
relativePath,
);
const registration = await registerWrite({
scope,
relativePath,
messageId: input.messageId,
});
const delivery =
await workspacePageDeliveryService
.syncAndDeliver(scope.userId, {
pageDataRelativePaths: [
relativePath,
],
});
return {
workspaceRef: scope.workspaceRef,
packageId: scope.packageId,
relativePath,
canonicalUrl:
buildMindSpacePublicUrlForUserFn({
h5Root,
env,
user: { id: scope.userId },
relativePath,
}),
registration,
delivery,
};
},
};
}
export const mindSpaceWorkspaceToolServiceInternals = {
assertPackageScope,
decodeWorkspaceBinary,
ensureNoSymlinkTraversal,
requireServiceMethod,
resolveExistingWorkspacePath,
resolveWritableWorkspacePath,
};