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'; 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, } = {}) { 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 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 }, ); 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 }, ); 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, };