mindspace: close authority boundaries
This commit is contained in:
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
hasRecentOwnPublicHtmlReference,
|
||||
hasPublicHtmlWriteRequestInSessionEvent,
|
||||
isStubPublicHtmlContent,
|
||||
collectOwnPublicHtmlArtifactRefs,
|
||||
collectOwnPublicHtmlRelativePaths,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
syncPublicDocxDownloads,
|
||||
syncPublicHtmlAfterFinish,
|
||||
} from './mindspace-public-finish-sync.mjs';
|
||||
import { createMindSpacePublicFinishService } from './mindspace-public-finish-service.mjs';
|
||||
|
||||
const CURRENT_USER = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' };
|
||||
|
||||
@@ -77,6 +79,43 @@ test('detects a public html tool request even when the message has no text', ()
|
||||
assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true);
|
||||
});
|
||||
|
||||
test('stream event prefilter detects public HTML writes without filesystem access', () => {
|
||||
assert.equal(
|
||||
hasPublicHtmlWriteRequestInSessionEvent({
|
||||
type: 'Message',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'toolRequest',
|
||||
toolCall: {
|
||||
value: {
|
||||
name: 'edit_file',
|
||||
arguments: {
|
||||
path: 'public/page.html',
|
||||
old_str: 'before',
|
||||
new_str: 'after',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasPublicHtmlWriteRequestInSessionEvent({
|
||||
type: 'Message',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('detects public html references beyond the old eight message window', () => {
|
||||
const messages = [
|
||||
{
|
||||
@@ -746,3 +785,615 @@ test('materializePublicHtmlWritesFromSessionEvent writes public html from messag
|
||||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createPublicFinishService(overrides = {}) {
|
||||
const calls = [];
|
||||
const service = createMindSpacePublicFinishService({
|
||||
pool: { query: async () => [[]] },
|
||||
h5Root: '/srv/h5',
|
||||
storageRoot: '/srv/storage',
|
||||
env: {
|
||||
H5_PUBLIC_BASE_URL: 'https://example.com',
|
||||
},
|
||||
conversationArtifactService: {
|
||||
async registerPublicHtmlArtifacts(input) {
|
||||
calls.push({ kind: 'artifacts', input });
|
||||
return [];
|
||||
},
|
||||
},
|
||||
resolveMindSpaceUserPublishDirFn() {
|
||||
return '/srv/h5/MindSpace/user-1';
|
||||
},
|
||||
buildMindSpacePublicUrlForUserFn({
|
||||
user,
|
||||
relativePath,
|
||||
}) {
|
||||
return `https://example.com/MindSpace/${user.id}/${relativePath}`;
|
||||
},
|
||||
evaluateH5HtmlFinishGuardFn(input) {
|
||||
calls.push({
|
||||
kind: 'delivery-evaluation',
|
||||
input,
|
||||
});
|
||||
return {
|
||||
needsRepair: false,
|
||||
missingHtml: [],
|
||||
missingAssets: [],
|
||||
browserStorageViolations: [],
|
||||
htmlGenerationNeedsRetry: false,
|
||||
linkExists() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
async preparePageDataAfterFinishFn(
|
||||
input,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'page-data-preparation',
|
||||
input,
|
||||
});
|
||||
return {
|
||||
autoBind: {
|
||||
bound: [],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
evaluation: {
|
||||
structuralPageData: false,
|
||||
relevantFiles: [
|
||||
{
|
||||
relativePath:
|
||||
'public/page.html',
|
||||
absolutePath:
|
||||
'/srv/h5/MindSpace/user-1/public/page.html',
|
||||
content: '<html></html>',
|
||||
},
|
||||
],
|
||||
htmlIssues: [],
|
||||
unboundFiles: [],
|
||||
needsRepair: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
return { calls, service };
|
||||
}
|
||||
|
||||
test('public finish service resolves stream writes and canonical URLs inside MindSpace', async () => {
|
||||
const calls = [];
|
||||
const setup = createPublicFinishService({
|
||||
materializeSessionEventFn(event, options) {
|
||||
calls.push({ kind: 'materialize', event, options });
|
||||
return {
|
||||
materialized: ['public/page.html'],
|
||||
skipped: [],
|
||||
};
|
||||
},
|
||||
collectArtifactRefsFn(input) {
|
||||
calls.push({ kind: 'collect', input });
|
||||
return [
|
||||
{
|
||||
relativePath: 'public/page.html',
|
||||
messageId: 'message-1',
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
const event = {
|
||||
type: 'Message',
|
||||
message: {
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
await setup.service.materializeSessionEvent({
|
||||
userId: 'user-1',
|
||||
event,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
calls[0].options.publishDir,
|
||||
'/srv/h5/MindSpace/user-1',
|
||||
);
|
||||
assert.deepEqual(result.publicHtmlRelativePaths, [
|
||||
'public/page.html',
|
||||
]);
|
||||
assert.equal(
|
||||
result.publicHtmlArtifacts[0].canonicalUrl,
|
||||
'https://example.com/MindSpace/user-1/public/page.html',
|
||||
);
|
||||
});
|
||||
|
||||
test('public finish service owns WeChat HTML preparation, sync, and package registration', async () => {
|
||||
const calls = [];
|
||||
const setup = createPublicFinishService({
|
||||
prepareWechatHtmlDeliveryAtWorkspaceFn(
|
||||
input,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'prepare-html',
|
||||
input,
|
||||
});
|
||||
return {
|
||||
materialization: {
|
||||
materialized: [
|
||||
'public/page.html',
|
||||
],
|
||||
skipped: [],
|
||||
},
|
||||
publishedArtifacts: [
|
||||
{
|
||||
relativePath:
|
||||
'public/page.html',
|
||||
url:
|
||||
input.buildCanonicalUrl(
|
||||
'public/page.html',
|
||||
),
|
||||
exists: true,
|
||||
isStub: false,
|
||||
},
|
||||
],
|
||||
expectedArtifacts: [],
|
||||
recentArtifacts: [],
|
||||
confirmedArtifacts: [
|
||||
{
|
||||
relativePath:
|
||||
'public/page.html',
|
||||
url:
|
||||
input.buildCanonicalUrl(
|
||||
'public/page.html',
|
||||
),
|
||||
exists: true,
|
||||
isStub: false,
|
||||
},
|
||||
],
|
||||
verifiedArtifacts: [],
|
||||
validReplyUrls: [],
|
||||
hasValidReplyLink: false,
|
||||
};
|
||||
},
|
||||
async syncAfterFinishFn(input) {
|
||||
calls.push({
|
||||
kind: 'finish-sync',
|
||||
input,
|
||||
});
|
||||
return { synced: true };
|
||||
},
|
||||
async syncWorkspaceAssets(
|
||||
userId,
|
||||
options,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'asset-sync',
|
||||
userId,
|
||||
options,
|
||||
});
|
||||
},
|
||||
conversationArtifactService: {
|
||||
async registerPublicHtmlArtifacts(
|
||||
input,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'artifact-register',
|
||||
input,
|
||||
});
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result =
|
||||
await setup.service
|
||||
.prepareWechatHtmlDelivery({
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
reply: {
|
||||
text: 'done',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
},
|
||||
],
|
||||
},
|
||||
intent: {
|
||||
agentText: 'build page',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
calls[0].input.publishDir,
|
||||
'/srv/h5/MindSpace/user-1',
|
||||
);
|
||||
assert.equal(
|
||||
result.confirmedArtifacts[0].url,
|
||||
'https://example.com/MindSpace/user-1/public/page.html',
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(
|
||||
result.confirmedArtifacts[0],
|
||||
'localPath',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.ok(
|
||||
calls.some(
|
||||
(call) =>
|
||||
call.kind === 'asset-sync' &&
|
||||
call.options.onlyRelativePaths
|
||||
.includes('public/page.html'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
calls.some(
|
||||
(call) =>
|
||||
call.kind ===
|
||||
'artifact-register' &&
|
||||
call.input.sessionId ===
|
||||
'session-1',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('public finish service owns WeChat fresh thumbnail rendering and metadata sync', async () => {
|
||||
const calls = [];
|
||||
const setup = createPublicFinishService({
|
||||
async ensureWechatFreshPageThumbnailsAtWorkspaceFn(
|
||||
input,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'fresh-thumbnail',
|
||||
input,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
matchRelativePaths: [
|
||||
'public/page.html',
|
||||
],
|
||||
thumbnailRelativePaths: [
|
||||
'public/page.thumbnail.svg',
|
||||
],
|
||||
};
|
||||
},
|
||||
async syncWorkspaceAssets(
|
||||
userId,
|
||||
options,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'asset-sync',
|
||||
userId,
|
||||
options,
|
||||
});
|
||||
},
|
||||
conversationArtifactService: {
|
||||
async registerPublicHtmlArtifacts(
|
||||
input,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'artifact-register',
|
||||
input,
|
||||
});
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result =
|
||||
await setup.service
|
||||
.ensureWechatFreshPageThumbnails({
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
artifacts: [
|
||||
{
|
||||
relativePath:
|
||||
'public/page.html',
|
||||
},
|
||||
],
|
||||
images: [
|
||||
{
|
||||
jobId: 'job-1',
|
||||
},
|
||||
],
|
||||
repairEnabled: true,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(
|
||||
calls.find(
|
||||
(call) =>
|
||||
call.kind === 'asset-sync',
|
||||
).options.onlyRelativePaths,
|
||||
[
|
||||
'public/page.html',
|
||||
'public/page.thumbnail.svg',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
calls.find(
|
||||
(call) =>
|
||||
call.kind ===
|
||||
'artifact-register',
|
||||
).input.relativePaths,
|
||||
['public/page.html'],
|
||||
);
|
||||
});
|
||||
|
||||
test('public finish service owns WeChat Page Data delivery preparation', async () => {
|
||||
const calls = [];
|
||||
let outcomeCount = 0;
|
||||
const setup = createPublicFinishService({
|
||||
createPageServiceFn(pool, options) {
|
||||
calls.push({
|
||||
kind: 'page-service',
|
||||
pool,
|
||||
options,
|
||||
});
|
||||
return {
|
||||
async findPageByRelativePath() {
|
||||
return { id: 'page-1' };
|
||||
},
|
||||
};
|
||||
},
|
||||
async resolvePageDataCollectOutcomeAsyncFn(
|
||||
input,
|
||||
) {
|
||||
calls.push({ kind: 'outcome', input });
|
||||
outcomeCount += 1;
|
||||
return outcomeCount === 1
|
||||
? { action: 'retry' }
|
||||
: {
|
||||
action: 'send',
|
||||
evaluation: {
|
||||
relevantFiles: [
|
||||
{
|
||||
relativePath:
|
||||
'public/page.html',
|
||||
absolutePath:
|
||||
'/srv/h5/MindSpace/user-1/public/page.html',
|
||||
content: '<html></html>',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
async maybeAutoBindPageDataHtmlPagesFn(
|
||||
input,
|
||||
) {
|
||||
calls.push({ kind: 'auto-bind', input });
|
||||
return {
|
||||
bound: [
|
||||
{
|
||||
relativePath: 'public/page.html',
|
||||
workspaceUrl:
|
||||
'https://example.com/MindSpace/user-1/public/page.html',
|
||||
},
|
||||
],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
};
|
||||
},
|
||||
buildPageDataDeliveryArtifactsFromBindResultFn(
|
||||
autoBind,
|
||||
publishDir,
|
||||
options,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'artifacts',
|
||||
autoBind,
|
||||
publishDir,
|
||||
options,
|
||||
});
|
||||
return [
|
||||
{
|
||||
relativePath: 'public/page.html',
|
||||
localPath:
|
||||
'/srv/h5/MindSpace/user-1/public/page.html',
|
||||
url: 'https://example.com/MindSpace/user-1/public/page.html',
|
||||
},
|
||||
];
|
||||
},
|
||||
async ensurePageDataDeliveryReadyFn(input) {
|
||||
calls.push({
|
||||
kind: 'delivery-check',
|
||||
input,
|
||||
});
|
||||
return { ok: true, failures: [] };
|
||||
},
|
||||
rewritePageDataDeliveryLinksFn(
|
||||
text,
|
||||
artifacts,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'rewrite',
|
||||
text,
|
||||
artifacts,
|
||||
});
|
||||
return '已发布:workspace-url';
|
||||
},
|
||||
});
|
||||
|
||||
const result =
|
||||
await setup.service
|
||||
.prepareWechatPageDataDelivery({
|
||||
userId: 'user-1',
|
||||
reply: {
|
||||
text: '已发布:legacy-url',
|
||||
messages: [],
|
||||
},
|
||||
intent: {
|
||||
agentText: 'build survey',
|
||||
},
|
||||
publicBaseUrl:
|
||||
'https://example.com',
|
||||
requestStartedAt: 123,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
calls
|
||||
.map((call) => call.kind),
|
||||
[
|
||||
'outcome',
|
||||
'page-service',
|
||||
'auto-bind',
|
||||
'outcome',
|
||||
'artifacts',
|
||||
'delivery-check',
|
||||
'rewrite',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
calls[0].input.publishDir,
|
||||
'/srv/h5/MindSpace/user-1',
|
||||
);
|
||||
assert.equal(
|
||||
calls[2].input.storageRoot,
|
||||
'/srv/storage',
|
||||
);
|
||||
assert.equal(result.outcome.action, 'send');
|
||||
assert.equal(result.deliveryCheck.ok, true);
|
||||
assert.equal(
|
||||
result.rewrittenText,
|
||||
'已发布:workspace-url',
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(
|
||||
result.deliveryArtifacts[0],
|
||||
'localPath',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(
|
||||
result.outcome.evaluation
|
||||
.relevantFiles[0],
|
||||
'absolutePath',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('public finish service keeps workspace sync and artifact registration inside MindSpace', async () => {
|
||||
const calls = [];
|
||||
const setup = createPublicFinishService({
|
||||
async syncWorkspaceAssets(userId, options) {
|
||||
calls.push({
|
||||
kind: 'workspace-assets',
|
||||
userId,
|
||||
options,
|
||||
});
|
||||
},
|
||||
async syncAfterFinishFn(input) {
|
||||
calls.push({ kind: 'finish', input });
|
||||
await input.syncWorkspaceAssets('user-1', {
|
||||
categoryCode: 'public',
|
||||
});
|
||||
await input.registerPublicHtmlArtifacts(
|
||||
'user-1',
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
relativePaths: ['public/page.html'],
|
||||
artifactRefs: [
|
||||
{
|
||||
relativePath: 'public/page.html',
|
||||
messageId: 'message-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
return {
|
||||
materialized: ['public/page.html'],
|
||||
skipped: [],
|
||||
publicHtmlRelativePaths: [
|
||||
'public/page.html',
|
||||
],
|
||||
publicHtmlArtifactRefs: [
|
||||
{
|
||||
relativePath: 'public/page.html',
|
||||
messageId: 'message-1',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await setup.service.syncAfterFinish({
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
assert.equal(calls[0].kind, 'finish');
|
||||
assert.equal(
|
||||
calls[0].input.publishDir,
|
||||
'/srv/h5/MindSpace/user-1',
|
||||
);
|
||||
assert.equal(calls[0].input.storageRoot, '/srv/storage');
|
||||
assert.equal(calls[1].kind, 'workspace-assets');
|
||||
assert.equal(setup.calls[0].kind, 'artifacts');
|
||||
assert.equal(
|
||||
setup.calls[0].input.userId,
|
||||
'user-1',
|
||||
);
|
||||
assert.equal(
|
||||
result.publicHtmlArtifacts[0].canonicalUrl,
|
||||
'https://example.com/MindSpace/user-1/public/page.html',
|
||||
);
|
||||
assert.equal(
|
||||
result.deliveryEvaluation.needsRepair,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(
|
||||
result.deliveryEvaluation,
|
||||
'linkExists',
|
||||
),
|
||||
false,
|
||||
);
|
||||
const pageDataPreparation =
|
||||
await setup.service.preparePageDataAfterFinish({
|
||||
userId: 'user-1',
|
||||
messages: [],
|
||||
userText: 'build page',
|
||||
});
|
||||
assert.equal(
|
||||
pageDataPreparation.evaluation
|
||||
.relevantFiles[0].relativePath,
|
||||
'public/page.html',
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(
|
||||
pageDataPreparation.evaluation
|
||||
.relevantFiles[0],
|
||||
'absolutePath',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
Object.hasOwn(
|
||||
pageDataPreparation.evaluation
|
||||
.relevantFiles[0],
|
||||
'content',
|
||||
),
|
||||
false,
|
||||
);
|
||||
const preparationCall = setup.calls.find(
|
||||
(call) =>
|
||||
call.kind === 'page-data-preparation',
|
||||
);
|
||||
assert.equal(
|
||||
preparationCall.input.publishDir,
|
||||
'/srv/h5/MindSpace/user-1',
|
||||
);
|
||||
assert.equal(
|
||||
preparationCall.input.storageRoot,
|
||||
'/srv/storage',
|
||||
);
|
||||
assert.equal(
|
||||
preparationCall.input.userText,
|
||||
'build page',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user