fix(mindspace): fail closed on invalid page data delivery
This commit is contained in:
@@ -817,12 +817,19 @@ export function createAgentRunGateway({
|
|||||||
let deliverables = null;
|
let deliverables = null;
|
||||||
if (requiresPageDeliverable || typeof validateRunDeliverables === 'function') {
|
if (requiresPageDeliverable || typeof validateRunDeliverables === 'function') {
|
||||||
const latest = await getRunById(runId);
|
const latest = await getRunById(runId);
|
||||||
|
// `[]` is a strict allowlist meaning no workspace page may be examined.
|
||||||
|
// Static page runs commonly have no Page Data artifact list, so use
|
||||||
|
// `null` to discover the current run's newly synced workspace output.
|
||||||
|
const workspaceRelativePaths = Array.isArray(deliveryResult?.pageDataRelativePaths)
|
||||||
|
&& deliveryResult.pageDataRelativePaths.length > 0
|
||||||
|
? deliveryResult.pageDataRelativePaths
|
||||||
|
: null;
|
||||||
deliverables = await prepareAndDetectSessionDeliverables({
|
deliverables = await prepareAndDetectSessionDeliverables({
|
||||||
pool,
|
pool,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
sessionId,
|
sessionId,
|
||||||
runStartedAtMs: latest?.started_at ?? row.started_at ?? null,
|
runStartedAtMs: latest?.started_at ?? row.started_at ?? null,
|
||||||
workspaceRelativePaths: deliveryResult?.pageDataRelativePaths ?? [],
|
workspaceRelativePaths,
|
||||||
});
|
});
|
||||||
if (requiresPageDeliverable && deliverables.pageCount < 1) {
|
if (requiresPageDeliverable && deliverables.pageCount < 1) {
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
|
|||||||
@@ -619,6 +619,47 @@ test('agent run succeeds when Finish is missing but session pages were already c
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('static page run succeeds when no Page Data artifact path is recorded', async () => {
|
||||||
|
const pool = createFakePool({
|
||||||
|
workspaceDeliverables: {
|
||||||
|
'user-1': [{
|
||||||
|
page_id: 'page-static-workspace',
|
||||||
|
title: '都市时尚',
|
||||||
|
publication_id: 'pub-static-workspace',
|
||||||
|
publication_status: 'online',
|
||||||
|
public_url: 'http://127.0.0.1:5173/MindSpace/user-1/public/urban-fashion.html',
|
||||||
|
workspace_relative_path: 'public/urban-fashion.html',
|
||||||
|
updated_at: Date.now(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gateway = createAgentRunGateway({
|
||||||
|
pool,
|
||||||
|
userAuth: {},
|
||||||
|
tkmindProxy: {
|
||||||
|
async startSessionForUser() {
|
||||||
|
return { id: 'session-static-workspace' };
|
||||||
|
},
|
||||||
|
async submitSessionReplyAndAwaitFinishForUser() {
|
||||||
|
return { ok: true, finishEvent: { type: 'Finish' } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// This mirrors a static-page-publish run: no Page Data artifact list.
|
||||||
|
syncUserPagesOnSuccess: async () => ({ pageDataBind: { errors: [] }, pageDataRelativePaths: [] }),
|
||||||
|
retryDelaysMs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = await gateway.createRun('user-1', {
|
||||||
|
requestId: 'req-static-workspace-page',
|
||||||
|
userMessage: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '生成一个都市时尚展示页面' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
|
||||||
|
});
|
||||||
|
|
||||||
test('agent run uses direct chat service for eligible chat messages', async () => {
|
test('agent run uses direct chat service for eligible chat messages', async () => {
|
||||||
const pool = createFakePool();
|
const pool = createFakePool();
|
||||||
const directRuns = [];
|
const directRuns = [];
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ export function shouldPromoteSessionIdToStreaming(chatState) {
|
|||||||
return chatState !== 'idle';
|
return chatState !== 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only transport uncertainty may continue a run after submit fails. A
|
||||||
|
* deterministic gateway error already has a terminal outcome and must return
|
||||||
|
* the chat UI to an error/idle state instead of leaving a Stop button active.
|
||||||
|
*
|
||||||
|
* @param {number | undefined | null} status
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function shouldKeepStreamingAfterRunError(status) {
|
||||||
|
return status === 0 || status === 409 || Number(status) >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent-run POST tracks portal request_id; Goose SSE may emit a different chat_request_id.
|
* Agent-run POST tracks portal request_id; Goose SSE may emit a different chat_request_id.
|
||||||
* Adopt the upstream id so Message/Finish events are not dropped in the UI.
|
* Adopt the upstream id so Message/Finish events are not dropped in the UI.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
reconcileSessionEventRequestContext,
|
reconcileSessionEventRequestContext,
|
||||||
resolvePostAgentRunChatState,
|
resolvePostAgentRunChatState,
|
||||||
|
shouldKeepStreamingAfterRunError,
|
||||||
shouldPromoteSessionIdToStreaming,
|
shouldPromoteSessionIdToStreaming,
|
||||||
} from './chat-agent-run-gate.mjs';
|
} from './chat-agent-run-gate.mjs';
|
||||||
|
|
||||||
@@ -47,6 +48,14 @@ test('completed run wins when the direct-chat snapshot is not ready', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('only ambiguous transport failures keep the chat streaming', () => {
|
||||||
|
assert.equal(shouldKeepStreamingAfterRunError(0), true);
|
||||||
|
assert.equal(shouldKeepStreamingAfterRunError(409), true);
|
||||||
|
assert.equal(shouldKeepStreamingAfterRunError(503), true);
|
||||||
|
assert.equal(shouldKeepStreamingAfterRunError(400), false);
|
||||||
|
assert.equal(shouldKeepStreamingAfterRunError(undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => {
|
test('shouldPromoteSessionIdToStreaming skips re-streaming after Finish', () => {
|
||||||
assert.equal(shouldPromoteSessionIdToStreaming('idle'), false);
|
assert.equal(shouldPromoteSessionIdToStreaming('idle'), false);
|
||||||
assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true);
|
assert.equal(shouldPromoteSessionIdToStreaming('waiting'), true);
|
||||||
|
|||||||
+38
-4
@@ -138,8 +138,11 @@ import {
|
|||||||
} from './mindspace-public-finish-sync.mjs';
|
} from './mindspace-public-finish-sync.mjs';
|
||||||
import { getPageDeliveryContract, markPageDeliveryContractReady, preparePageDeliveryContract } from './mindspace-delivery-contract.mjs';
|
import { getPageDeliveryContract, markPageDeliveryContractReady, preparePageDeliveryContract } from './mindspace-delivery-contract.mjs';
|
||||||
import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs';
|
import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs';
|
||||||
import { maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs';
|
import { evaluatePageDataHtmlContent, maybeRepairPageDataAfterFinish } from './mindspace-page-data-finish-guard.mjs';
|
||||||
import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs';
|
import { ensurePageDataHtmlPagesBound } from './page-data-workspace-ensure.mjs';
|
||||||
|
import { detectPageDataDatasetUsageFromHtml } from './page-data-html-detect.mjs';
|
||||||
|
import { readPageAccessPolicy } from './page-data-policy-store.mjs';
|
||||||
|
import { policyAllowsAction } from './page-access-policy.mjs';
|
||||||
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
|
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
|
||||||
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
|
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
|
||||||
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
|
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
|
||||||
@@ -736,6 +739,30 @@ async function bootstrapUserAuth() {
|
|||||||
isSessionExternallyBusy: ({ sessionId }) => Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) > 0,
|
isSessionExternallyBusy: ({ sessionId }) => Number(sessionPageDeliveryLocks.get(sessionId) ?? 0) > 0,
|
||||||
validateRunDeliverables: async ({ userId, deliverables }) => {
|
validateRunDeliverables: async ({ userId, deliverables }) => {
|
||||||
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
|
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
|
||||||
|
const pageDataErrors = [];
|
||||||
|
for (const page of deliverables?.pages ?? []) {
|
||||||
|
const relativePath = normalizeWorkspaceRelativePath(page.workspaceRelativePath);
|
||||||
|
if (!relativePath?.startsWith('public/')) continue;
|
||||||
|
const filePath = path.resolve(publishDir, relativePath);
|
||||||
|
if (!filePath.startsWith(`${path.resolve(publishDir)}${path.sep}`) || !fs.existsSync(filePath)) continue;
|
||||||
|
const html = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const evaluation = evaluatePageDataHtmlContent(html, { relativePath });
|
||||||
|
if (!evaluation.usesPageDataApi) continue;
|
||||||
|
for (const issue of evaluation.issues) {
|
||||||
|
pageDataErrors.push({ code: issue, message: `${relativePath} Page Data HTML 不可交付:${issue}` });
|
||||||
|
}
|
||||||
|
const policy = page.pageId ? readPageAccessPolicy(publishDir, page.pageId) : null;
|
||||||
|
for (const [dataset, actions] of detectPageDataDatasetUsageFromHtml(html)) {
|
||||||
|
for (const action of ['read', 'insert']) {
|
||||||
|
if (actions?.[action] && !policyAllowsAction(policy, dataset, action)) {
|
||||||
|
pageDataErrors.push({
|
||||||
|
code: 'page_data_policy_action_missing',
|
||||||
|
message: `${relativePath} 的 ${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const violations = scanWorkspaceFilesForProhibitedBrowserStorage({
|
const violations = scanWorkspaceFilesForProhibitedBrowserStorage({
|
||||||
publishDir,
|
publishDir,
|
||||||
relativePaths: (deliverables?.pages ?? [])
|
relativePaths: (deliverables?.pages ?? [])
|
||||||
@@ -743,10 +770,10 @@ async function bootstrapUserAuth() {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
errors: violations.map((violation) => ({
|
errors: [...pageDataErrors, ...violations.map((violation) => ({
|
||||||
code: 'browser_storage_forbidden',
|
code: 'browser_storage_forbidden',
|
||||||
message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`,
|
message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`,
|
||||||
})),
|
}))],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
|
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
|
||||||
@@ -3286,9 +3313,16 @@ async function syncUserGeneratedPages(userId, { sessionId = null, sinceMs = null
|
|||||||
// Agent-run/Finish delivery must stay scoped to the current conversation.
|
// Agent-run/Finish delivery must stay scoped to the current conversation.
|
||||||
// A stale Page Data page elsewhere in the user's workspace must not turn a
|
// A stale Page Data page elsewhere in the user's workspace must not turn a
|
||||||
// successfully completed current task into a failed run.
|
// successfully completed current task into a failed run.
|
||||||
const pageDataRelativePaths = sessionId
|
const discoveredRelativePaths = sessionId
|
||||||
? await listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs })
|
? await listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs })
|
||||||
: null;
|
: null;
|
||||||
|
// An empty artifact package does not prove that this run wrote no page: a
|
||||||
|
// normal static-page-publish flow may only leave a public HTML workspace
|
||||||
|
// file. `null` means "discover current workspace output"; `[]` would tell
|
||||||
|
// the sync service to inspect nothing and make a completed page run fail.
|
||||||
|
const pageDataRelativePaths = discoveredRelativePaths?.length
|
||||||
|
? discoveredRelativePaths
|
||||||
|
: null;
|
||||||
if (workspacePageDeliver?.syncAndDeliver) {
|
if (workspacePageDeliver?.syncAndDeliver) {
|
||||||
return await workspacePageDeliver.syncAndDeliver(userId, { pageDataRelativePaths });
|
return await workspacePageDeliver.syncAndDeliver(userId, { pageDataRelativePaths });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
|
|||||||
import {
|
import {
|
||||||
reconcileSessionEventRequestContext,
|
reconcileSessionEventRequestContext,
|
||||||
resolvePostAgentRunChatState,
|
resolvePostAgentRunChatState,
|
||||||
|
shouldKeepStreamingAfterRunError,
|
||||||
shouldPromoteSessionIdToStreaming,
|
shouldPromoteSessionIdToStreaming,
|
||||||
} from '../../chat-agent-run-gate.mjs';
|
} from '../../chat-agent-run-gate.mjs';
|
||||||
import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs';
|
import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs';
|
||||||
@@ -233,11 +234,6 @@ function mergeMessagePages(older: Message[], current: Message[]): Message[] {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAmbiguousReplySubmitError(err: unknown) {
|
|
||||||
if (!(err instanceof ApiError)) return true;
|
|
||||||
return err.status === 0 || err.status === 409 || err.status >= 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_LIST_RETRY_ATTEMPTS = 3;
|
const SESSION_LIST_RETRY_ATTEMPTS = 3;
|
||||||
const SESSION_LIST_RETRY_DELAY_MS = 450;
|
const SESSION_LIST_RETRY_DELAY_MS = 450;
|
||||||
|
|
||||||
@@ -1619,7 +1615,11 @@ export function useTKMindChat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (activeSessionId && isAmbiguousReplySubmitError(err)) {
|
if (
|
||||||
|
activeSessionId &&
|
||||||
|
err instanceof ApiError &&
|
||||||
|
shouldKeepStreamingAfterRunError(err.status)
|
||||||
|
) {
|
||||||
subscribeToSession(activeSessionId);
|
subscribeToSession(activeSessionId);
|
||||||
setChatState('streaming');
|
setChatState('streaming');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user