mindspace: close authority boundaries
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user