mindspace: close authority boundaries

This commit is contained in:
john
2026-07-27 15:34:35 +08:00
parent dfab78c75a
commit e94052ff24
78 changed files with 11962 additions and 2162 deletions
+629 -2
View File
@@ -1,10 +1,18 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
mintMindSpaceMcpScopedToken,
} from '../mindspace-mcp-scoped-token.mjs';
import { createMindSpaceRpcRequestHandler } from './mindspace-rpc-server.mjs';
function createStubAdapter() {
const calls = {
writeUploadContent: [],
registerChatDocxArtifact: [],
workspaceTools: [],
};
return {
calls,
@@ -15,6 +23,175 @@ function createStubAdapter() {
return [];
},
},
chatSaveService: {
async createSharedHtml() {
return {
publicUrl: 'https://example.com/shared.html',
filename: 'shared.html',
};
},
async materializeWorkspaceHtml() {
return { changed: false };
},
async readWorkspaceHtml() {
return {
html: '<html></html>',
relativePath: 'public/page.html',
};
},
async ensurePreviewThumbnail() {
return { ready: true };
},
async renderPreviewThumbnailSvg() {
return { svg: '<svg></svg>', contentType: 'image/svg+xml; charset=utf-8' };
},
},
conversationArtifactService: {
async registerChatDocxArtifact(...args) {
calls.registerChatDocxArtifact.push(args);
return { id: 'docx-1' };
},
async registerPublicHtmlArtifacts() {
return [{ artifactId: 'html-1' }];
},
async registerPublishedLongImageArtifact() {
return { id: 'image-1' };
},
async registerWorkspaceFileArtifact() {
return { id: 'file-1' };
},
},
publicFinishService: {
async materializeSessionEvent() {
return { materialized: [], skipped: [] };
},
async syncAfterFinish() {
return {
materialized: [],
skipped: [],
publicHtmlRelativePaths: [],
};
},
async preparePageDataAfterFinish() {
return {
autoBind: {
bound: [],
skipped: [],
errors: [],
},
evaluation: {
structuralPageData: false,
relevantFiles: [],
},
};
},
async prepareWechatPageDataDelivery() {
return {
outcome: { action: 'skip' },
deliveryArtifacts: [],
};
},
async prepareWechatHtmlDelivery() {
return {
confirmedArtifacts: [],
};
},
async ensureWechatFreshPageThumbnails() {
return { ok: true };
},
},
workspacePublicationDeliveryService: {
async listRecentlyModifiedPublicHtml() {
return { relativePaths: [] };
},
async readOwnerPublicAsset() {
return { action: 'not_found' };
},
async renderLongImage() {
return {
bodyBase64: '',
servedName: 'page.long.png',
};
},
async resolveWorkspaceRequest() {
return { action: 'not_found' };
},
async validateRunDeliverables() {
return { errors: [] };
},
},
workspacePageDeliveryService: {
async syncAndDeliver() {
return { sync: {} };
},
},
workspaceToolService: {
async createDirectory() {
calls.workspaceTools.push([
'createDirectory',
...arguments,
]);
return { relativePath: 'public/assets' };
},
async editFile() {
calls.workspaceTools.push([
'editFile',
...arguments,
]);
return { relativePath: 'public/page.html' };
},
async generateLongImage() {
calls.workspaceTools.push([
'generateLongImage',
...arguments,
]);
return {
relativePath:
'public/page.long.png',
};
},
async listDirectory() {
calls.workspaceTools.push([
'listDirectory',
...arguments,
]);
return { entries: [] };
},
async publishPage() {
calls.workspaceTools.push([
'publishPage',
...arguments,
]);
return {
canonicalUrl:
'https://example.com/MindSpace/user-1/public/page.html',
};
},
async readFile() {
calls.workspaceTools.push([
'readFile',
...arguments,
]);
return { content: 'hello' };
},
async writeFile() {
calls.workspaceTools.push([
'writeFile',
...arguments,
]);
return { relativePath: 'public/page.html' };
},
async writeBinaryFile() {
calls.workspaceTools.push([
'writeBinaryFile',
...arguments,
]);
return {
relativePath:
'public/report.docx',
};
},
},
conversationPackageRegistry: {
async ensurePackage() { return { ok: true }; },
async putObjectForSession() { return { ok: true }; },
@@ -31,8 +208,12 @@ function createStubAdapter() {
async createUpload() { return { ok: true }; },
async deleteAsset() { return { ok: true }; },
async listAssets() { return []; },
async readAsset() { return { ok: true }; },
async readPublicAsset() { return { ok: true }; },
async readAssetContent() {
return { ok: true, bodyBase64: '' };
},
async readPublicAssetContent() {
return { ok: true, bodyBase64: '' };
},
async renderAssetPreview() { return { preview: true }; },
async renderAssetThumbnail() { return { thumbnail: true }; },
async syncWorkspaceAssets() { return { synced: true }; },
@@ -148,6 +329,107 @@ test('health and contract endpoints are exposed without auth', async () => {
assert.equal(health.body.ok, true);
assert.equal(contract.statusCode, 200);
assert.ok(contract.body.bindings.assetService.includes('renderAssetPreview'));
assert.ok(
contract.body.bindings.chatSaveService.includes(
'materializeWorkspaceHtml',
),
);
assert.ok(
contract.body.bindings.conversationArtifactService
.includes('registerPublicHtmlArtifacts'),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'syncAfterFinish',
),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'preparePageDataAfterFinish',
),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'prepareWechatPageDataDelivery',
),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'prepareWechatHtmlDelivery',
),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'ensureWechatFreshPageThumbnails',
),
);
assert.ok(
contract.body.bindings
.workspacePublicationDeliveryService
.includes('resolveWorkspaceRequest'),
);
assert.ok(
contract.body.bindings
.workspaceToolService
.includes('writeBinaryFile'),
);
assert.ok(
contract.body.bindings
.workspaceToolService
.includes('generateLongImage'),
);
});
test('RPC startup rejects an undersized MCP signing secret', async () => {
await assert.rejects(
createMindSpaceRpcRequestHandler({
adapter: createStubAdapter(),
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
'too-short',
},
}),
/at least 16 characters/,
);
});
test('RPC startup does not require MCP token verifier when scoped MCP is disabled', async (t) => {
const memindRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'mindspace-rpc-no-mcp-'),
);
t.after(() => {
fs.rmSync(memindRoot, {
recursive: true,
force: true,
});
});
fs.copyFileSync(
path.resolve(
'mindspace-server-adapter-contract.mjs',
),
path.join(
memindRoot,
'mindspace-server-adapter-contract.mjs',
),
);
const handler =
await createMindSpaceRpcRequestHandler({
adapter: createStubAdapter(),
env: {
MINDSPACE_MEMIND_ROOT: memindRoot,
},
});
const health = await runRequest(handler, {
method: 'GET',
path: '/health',
});
assert.equal(health.statusCode, 200);
assert.equal(
health.body.mcpScopedToolsEnabled,
false,
);
});
test('rpc invocation enforces auth token and dispatches args to adapter method', async () => {
@@ -199,6 +481,39 @@ test('rpc invocation revives JSON-serialized buffers before dispatch', async ()
assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true);
});
test('rpc revives generated artifact bodies before dispatching to MindSpace', async () => {
const adapter = createStubAdapter();
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const payload = {
userId: 'user-1',
sessionId: 'session-1',
messageId: 'message-1',
filename: 'chat.docx',
body: Buffer.from('docx payload'),
};
const response = await runRequest(handler, {
method: 'POST',
path:
'/mindspace/v1/adapter/conversationArtifactService/registerChatDocxArtifact',
body: JSON.stringify({ args: [payload] }),
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { id: 'docx-1' });
assert.equal(
Buffer.isBuffer(
adapter.calls.registerChatDocxArtifact[0][0].body,
),
true,
);
});
test('rpc maps publication_not_found to 404 with error code', async () => {
const adapter = createStubAdapter();
adapter.publicationService.resolvePublic = async () => {
@@ -247,3 +562,315 @@ test('rpc maps publication_login_required to 401 with error code', async () => {
assert.equal(response.statusCode, 401);
assert.equal(response.body.code, 'publication_login_required');
});
test('rpc maps chat save validation errors to 400 with error code', async () => {
const adapter = createStubAdapter();
adapter.chatSaveService.materializeWorkspaceHtml =
async () => {
const error = new Error('无效的页面路径');
error.code = 'invalid_page_path';
throw error;
};
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const response = await runRequest(handler, {
method: 'POST',
path:
'/mindspace/v1/adapter/chatSaveService/materializeWorkspaceHtml',
body: JSON.stringify({
args: [{
userId: 'user-1',
html: '<html>unsafe</html>',
relativePath: '../unsafe.html',
}],
}),
});
assert.equal(response.statusCode, 400);
assert.equal(response.body.code, 'invalid_page_path');
});
test('scoped MCP RPC injects token claims and dispatches only allowed workspace tools', async () => {
const adapter = createStubAdapter();
const secret =
'mindspace-mcp-test-secret-1234';
const token = mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: ['write_file'],
});
const handler =
await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
secret,
},
});
const response = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/mcp/write_file',
headers: {
authorization: `Bearer ${token}`,
},
body: JSON.stringify({
arguments: {
path: 'public/page.html',
content: '<html></html>',
},
}),
});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.deepEqual(
adapter.calls.workspaceTools[0],
[
'writeFile',
{
path: 'public/page.html',
content: '<html></html>',
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
},
],
);
});
test('scoped MCP RPC dispatches binary writes and long-image generation to MindSpace', async () => {
const adapter = createStubAdapter();
const secret =
'mindspace-mcp-test-secret-1234';
const token = mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: [
'generate_long_image',
'write_binary_file',
],
});
const handler =
await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
secret,
},
});
const headers = {
authorization: `Bearer ${token}`,
};
const binary = await runRequest(handler, {
method: 'POST',
path:
'/mindspace/v1/mcp/write_binary_file',
headers,
body: JSON.stringify({
arguments: {
path: 'public/report.docx',
bodyBase64:
Buffer.from('PK').toString(
'base64',
),
},
}),
});
const longImage = await runRequest(
handler,
{
method: 'POST',
path:
'/mindspace/v1/mcp/generate_long_image',
headers,
body: JSON.stringify({
arguments: {
html_path:
'public/page.html',
output_path:
'public/page.long.png',
},
}),
},
);
assert.equal(binary.statusCode, 200);
assert.equal(longImage.statusCode, 200);
assert.deepEqual(
adapter.calls.workspaceTools.map(
([method]) => method,
),
[
'writeBinaryFile',
'generateLongImage',
],
);
assert.equal(
adapter.calls.workspaceTools[0][1]
.packageId,
'cp_session-1',
);
});
test('scoped MCP RPC rejects tools and scope overrides outside the token', async () => {
const adapter = createStubAdapter();
const secret =
'mindspace-mcp-test-secret-1234';
const readToken =
mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: ['read_file'],
});
const writeToken =
mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: ['write_file'],
});
const handler =
await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
secret,
},
});
const forbidden = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/mcp/write_file',
headers: {
authorization: `Bearer ${readToken}`,
},
body: JSON.stringify({
arguments: {
path: 'public/page.html',
content: '<html></html>',
},
}),
});
const scopeMismatch = await runRequest(
handler,
{
method: 'POST',
path:
'/mindspace/v1/mcp/write_file',
headers: {
authorization: `Bearer ${writeToken}`,
},
body: JSON.stringify({
arguments: {
workspaceRef:
'mindspace://users/user-2/workspace',
path: 'public/page.html',
content: '<html></html>',
},
}),
},
);
assert.equal(forbidden.statusCode, 403);
assert.equal(
forbidden.body.code,
'mcp_tool_forbidden',
);
assert.equal(scopeMismatch.statusCode, 403);
assert.equal(
scopeMismatch.body.code,
'mcp_scope_mismatch',
);
assert.equal(
adapter.calls.workspaceTools.length,
0,
);
});
test('scoped MCP RPC bounds request bodies and reports invalid JSON as client errors', async () => {
const secret =
'mindspace-mcp-test-secret-1234';
const token = mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: ['write_file'],
});
const handler =
await createMindSpaceRpcRequestHandler({
adapter: createStubAdapter(),
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
secret,
MINDSPACE_MCP_MAX_BODY_BYTES:
'1024',
},
});
const headers = {
authorization: `Bearer ${token}`,
};
const tooLarge = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/mcp/write_file',
headers,
body: JSON.stringify({
arguments: {
path: 'oa/large.txt',
content: 'x'.repeat(1200),
},
}),
});
const invalidJson = await runRequest(
handler,
{
method: 'POST',
path:
'/mindspace/v1/mcp/write_file',
headers,
body: '{invalid',
},
);
assert.equal(tooLarge.statusCode, 413);
assert.equal(
tooLarge.body.code,
'request_body_too_large',
);
assert.equal(invalidJson.statusCode, 400);
assert.equal(
invalidJson.body.code,
'invalid_json_body',
);
});