371900bae5
Memind CI / Test, build, and release guards (pull_request) Failing after 18s
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>
911 lines
22 KiB
JavaScript
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,
|
|
};
|