From 7f8d692d16d7cba20ab52fa03c9df051571fe3a1 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 10 Jul 2026 20:05:52 +0800 Subject: [PATCH] fix(agent): recover stale runs and improve new-user OA delivery Fix DEV logout cookie clearing, materialize selected MindSpace OA assets before agent runs, and recover zombie runs from synced workspace pages. Add client run wait timeout, harness retry limits, page-edit asset forwarding, and logout/john2 scenario tests. Co-authored-by: Cursor --- agent-run-deliverable-check.mjs | 157 +++++++++++++++--- agent-run-deliverable-check.test.mjs | 110 ++++++++++++ agent-run-gateway.mjs | 127 +++++++++++--- agent-run-gateway.test.mjs | 122 +++++++++++++- agent-run-routes.mjs | 31 ++++ agent-run-routes.test.mjs | 43 +++++ chat-skills.mjs | 5 +- mindspace-asset-agent.mjs | 144 +++++++++++++++- mindspace-asset-agent.test.mjs | 85 ++++++++++ mindspace-assets.mjs | 30 +++- mindspace-chat-context.mjs | 8 +- mindspace-chat-context.test.mjs | 2 + mindspace-local-runtime-services.mjs | 2 + mindspace-local-runtime-services.test.mjs | 1 + mindspace-local-server-adapter.mjs | 2 + mindspace-local-server-adapter.test.mjs | 3 + mindspace-scan.mjs | 66 ++++++-- mindspace-scan.test.mjs | 37 +++++ .../mindspace-service-bootstrap.mjs | 1 + mindspace-workspace-sync.mjs | 30 +++- mindspace-workspace-sync.test.mjs | 125 +++++++++++++- scenarios/dev-logout.json | 19 +++ scenarios/john2-suzhou-page.json | 26 +++ scripts/run-scenario-test.mjs | 21 ++- scripts/scenario-test-lib.mjs | 50 +++++- server.mjs | 50 +++++- skills/web/SKILL.md | 3 +- src/api/client.ts | 6 +- src/hooks/usePageEditSubChat.ts | 32 ++-- src/hooks/useTKMindChat.ts | 62 +++++-- src/utils/agentRunMode.ts | 1 + user-auth.mjs | 9 + user-auth.test.mjs | 21 ++- user-publish.mjs | 12 ++ 34 files changed, 1318 insertions(+), 125 deletions(-) create mode 100644 scenarios/dev-logout.json create mode 100644 scenarios/john2-suzhou-page.json diff --git a/agent-run-deliverable-check.mjs b/agent-run-deliverable-check.mjs index f3e9624..dfe5685 100644 --- a/agent-run-deliverable-check.mjs +++ b/agent-run-deliverable-check.mjs @@ -2,8 +2,12 @@ export const RECOVERABLE_FINISH_ERROR_CODES = new Set([ 'AGENT_RUN_TIMEOUT', 'SESSION_REPLY_TIMEOUT', 'SESSION_REPLY_INCOMPLETE', + 'AGENT_RUN_STALE_RECOVERY', ]); +export const SESSION_FINISHED_STALE_GRACE_MS = 90 * 1000; +export const WORKSPACE_DELIVERABLE_LOOKBACK_MS = 60 * 1000; + export function isRecoverableFinishError(error) { const code = String(error?.code ?? '').trim(); return RECOVERABLE_FINISH_ERROR_CODES.has(code); @@ -15,6 +19,38 @@ export function hasRecoverableSessionDeliverables(summary) { return pageCount > 0 || publicationCount > 0; } +function mapDeliverableRows(rows) { + const pages = (rows ?? []).map((row) => ({ + pageId: String(row.page_id ?? '').trim(), + title: String(row.title ?? '').trim(), + publicationId: row.publication_id ? String(row.publication_id) : null, + publicationStatus: row.publication_status ? String(row.publication_status) : null, + publicUrl: row.public_url ? String(row.public_url) : null, + })).filter((row) => row.pageId); + const publicationCount = pages.filter((row) => row.publicationId).length; + return { + pageCount: pages.length, + publicationCount, + pages, + }; +} + +export function mergeDeliverableSummaries(...summaries) { + const pagesById = new Map(); + for (const summary of summaries) { + for (const page of summary?.pages ?? []) { + if (!page?.pageId) continue; + pagesById.set(page.pageId, page); + } + } + const pages = [...pagesById.values()]; + return { + pageCount: pages.length, + publicationCount: pages.filter((row) => row.publicationId).length, + pages, + }; +} + export async function detectSessionDeliverables(pool, userId, sessionId) { if (!pool?.query || !userId || !sessionId) { return { pageCount: 0, publicationCount: 0, pages: [] }; @@ -36,18 +72,92 @@ export async function detectSessionDeliverables(pool, userId, sessionId) { ORDER BY p.created_at ASC`, [userId, sessionId], ); - const pages = (rows ?? []).map((row) => ({ - pageId: String(row.page_id ?? '').trim(), - title: String(row.title ?? '').trim(), - publicationId: row.publication_id ? String(row.publication_id) : null, - publicationStatus: row.publication_status ? String(row.publication_status) : null, - publicUrl: row.public_url ? String(row.public_url) : null, - })).filter((row) => row.pageId); - const publicationCount = pages.filter((row) => row.publicationId).length; + return mapDeliverableRows(rows); +} + +export async function detectWorkspaceSyncedDeliverables(pool, userId, { sinceMs = null } = {}) { + if (!pool?.query || !userId) { + return { pageCount: 0, publicationCount: 0, pages: [] }; + } + const sinceClause = sinceMs != null ? 'AND p.updated_at >= ?' : ''; + const params = sinceMs != null ? [userId, sinceMs] : [userId]; + const [rows] = await pool.query( + `SELECT p.id AS page_id, + p.title, + pr.id AS publication_id, + pr.status AS publication_status, + pr.public_url + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + LEFT JOIN h5_publish_records pr + ON pr.page_id = p.id + AND pr.user_id = p.user_id + AND pr.status = 'online' + WHERE p.user_id = ? + AND p.status <> 'deleted' + ${sinceClause} + AND ( + p.workspace_relative_path LIKE 'public/%.html' + OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) LIKE 'public/%.html' + ) + AND ( + JSON_EXTRACT(pv.source_snapshot_json, '$.auto_synced') = true + OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.content_mode')) = 'static_html' + ) + ORDER BY p.updated_at DESC`, + params, + ); + return mapDeliverableRows(rows); +} + +export async function prepareAndDetectSessionDeliverables({ + pool, + userId, + sessionId, + runStartedAtMs = null, + prepareDeliverables = null, +} = {}) { + if (typeof prepareDeliverables === 'function') { + await prepareDeliverables({ userId, sessionId }).catch(() => {}); + } + const sinceMs = runStartedAtMs == null + ? null + : Math.max(0, Number(runStartedAtMs) - WORKSPACE_DELIVERABLE_LOOKBACK_MS); + const bySession = await detectSessionDeliverables(pool, userId, sessionId); + const byWorkspace = await detectWorkspaceSyncedDeliverables(pool, userId, { sinceMs }); + return mergeDeliverableSummaries(bySession, byWorkspace); +} + +export async function tryRecoverRunFromDeliverables({ + pool, + userId, + sessionId, + error = null, + requireRecoverableError = false, + prepareDeliverables = null, + runStartedAtMs = null, +} = {}) { + if (requireRecoverableError && !isRecoverableFinishError(error)) { + return null; + } + const deliverables = await prepareAndDetectSessionDeliverables({ + pool, + userId, + sessionId, + runStartedAtMs, + prepareDeliverables, + }); + if (!hasRecoverableSessionDeliverables(deliverables)) { + return null; + } return { - pageCount: pages.length, - publicationCount, - pages, + deliverables, + originalError: error + ? { + code: error?.code ?? null, + message: error instanceof Error ? error.message : String(error ?? ''), + } + : null, }; } @@ -56,19 +166,16 @@ export async function tryRecoverRunFromSessionDeliverables({ userId, sessionId, error, + prepareDeliverables = null, + runStartedAtMs = null, } = {}) { - if (!isRecoverableFinishError(error) || !sessionId) { - return null; - } - const deliverables = await detectSessionDeliverables(pool, userId, sessionId); - if (!hasRecoverableSessionDeliverables(deliverables)) { - return null; - } - return { - deliverables, - originalError: { - code: error?.code ?? null, - message: error instanceof Error ? error.message : String(error ?? ''), - }, - }; + return tryRecoverRunFromDeliverables({ + pool, + userId, + sessionId, + error, + requireRecoverableError: true, + prepareDeliverables, + runStartedAtMs, + }); } diff --git a/agent-run-deliverable-check.test.mjs b/agent-run-deliverable-check.test.mjs index ca81c5d..8d3c03d 100644 --- a/agent-run-deliverable-check.test.mjs +++ b/agent-run-deliverable-check.test.mjs @@ -2,8 +2,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { detectSessionDeliverables, + detectWorkspaceSyncedDeliverables, hasRecoverableSessionDeliverables, isRecoverableFinishError, + mergeDeliverableSummaries, + prepareAndDetectSessionDeliverables, + tryRecoverRunFromDeliverables, tryRecoverRunFromSessionDeliverables, } from './agent-run-deliverable-check.mjs'; @@ -75,3 +79,109 @@ test('hasRecoverableSessionDeliverables accepts pages even without online public assert.equal(hasRecoverableSessionDeliverables({ pageCount: 1, publicationCount: 0 }), true); assert.equal(hasRecoverableSessionDeliverables({ pageCount: 0, publicationCount: 0 }), false); }); + +test('detectWorkspaceSyncedDeliverables filters auto-synced public html pages', async () => { + const pool = { + async query(sql, params) { + assert.match(sql, /auto_synced/); + assert.deepEqual(params, ['user-1', 1000]); + return [[{ + page_id: 'page-html', + title: '攻略', + publication_id: null, + publication_status: null, + public_url: null, + }]]; + }, + }; + const summary = await detectWorkspaceSyncedDeliverables(pool, 'user-1', { sinceMs: 1000 }); + assert.equal(summary.pageCount, 1); +}); + +test('prepareAndDetectSessionDeliverables merges session and workspace pages after prepare hook', async () => { + const prepareCalls = []; + const pool = { + async query(sql, params) { + if (sql.includes('source_session_id')) { + return [[{ + page_id: 'page-session', + title: 'Session Page', + publication_id: null, + publication_status: null, + public_url: null, + }]]; + } + if (sql.includes('auto_synced')) { + return [[{ + page_id: 'page-workspace', + title: 'Workspace Page', + publication_id: 'pub-workspace', + publication_status: 'online', + public_url: '/u/john/pages/page-workspace', + }]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const summary = await prepareAndDetectSessionDeliverables({ + pool, + userId: 'user-1', + sessionId: 'session-1', + runStartedAtMs: 2000, + prepareDeliverables: async ({ userId, sessionId }) => { + prepareCalls.push({ userId, sessionId }); + }, + }); + assert.equal(prepareCalls.length, 1); + assert.equal(summary.pageCount, 2); + assert.equal(summary.publicationCount, 1); +}); + +test('tryRecoverRunFromDeliverables succeeds for stale recovery without recoverable finish error', async () => { + const pool = { + async query(sql) { + if (sql.includes('source_session_id')) return [[]]; + if (sql.includes('auto_synced')) { + return [[{ + page_id: 'page-1', + title: 'Survey', + publication_id: 'pub-1', + public_url: '/u/j/x', + }]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const recovered = await tryRecoverRunFromDeliverables({ + pool, + userId: 'user-1', + sessionId: 'session-1', + error: Object.assign(new Error('stale'), { code: 'AGENT_RUN_STALE_RECOVERY' }), + requireRecoverableError: false, + }); + assert.ok(recovered); + assert.equal(recovered.deliverables.pageCount, 1); +}); + +test('mergeDeliverableSummaries deduplicates pages by id', () => { + const merged = mergeDeliverableSummaries( + { + pageCount: 1, + publicationCount: 0, + pages: [{ pageId: 'page-1', title: 'A', publicationId: null, publicationStatus: null, publicUrl: null }], + }, + { + pageCount: 1, + publicationCount: 1, + pages: [{ + pageId: 'page-1', + title: 'A', + publicationId: 'pub-1', + publicationStatus: 'online', + publicUrl: '/u/j/x', + }], + }, + ); + assert.equal(merged.pageCount, 1); + assert.equal(merged.publicationCount, 1); +}); diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index c4e7fd3..929c724 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -11,7 +11,10 @@ import { persistSessionTranscriptMessages, } from './conversation-transcript-persist.mjs'; import { ensureGooseUserMessageMetadata } from './goose-message.mjs'; -import { tryRecoverRunFromSessionDeliverables } from './agent-run-deliverable-check.mjs'; +import { + SESSION_FINISHED_STALE_GRACE_MS, + tryRecoverRunFromDeliverables, +} from './agent-run-deliverable-check.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; const TERMINAL_STATUSES = new Set(['succeeded', 'failed']); @@ -391,12 +394,38 @@ export function createAgentRunGateway({ return projectRun(await getRunById(runId)); } - async function recoverRunFromDeliverables({ runId, userId, sessionId, err }) { - const recovered = await tryRecoverRunFromSessionDeliverables({ + async function prepareRunDeliverables({ userId, sessionId, runId, runStartedAtMs = null }) { + if (typeof syncUserPagesOnSuccess !== 'function') return; + await syncUserPagesOnSuccess({ + userId, + sessionId, + runId, + runStartedAtMs, + }).catch((err) => { + console.warn( + '[AgentRun] prepare deliverables sync failed:', + err instanceof Error ? err.message : err, + ); + }); + } + + async function recoverRunFromDeliverables({ + runId, + userId, + sessionId, + err, + runStartedAtMs = null, + requireRecoverableError = true, + }) { + const recovered = await tryRecoverRunFromDeliverables({ pool, userId, sessionId, error: err, + requireRecoverableError, + runStartedAtMs, + prepareDeliverables: ({ userId: uid, sessionId: sid }) => + prepareRunDeliverables({ userId: uid, sessionId: sid, runId, runStartedAtMs }), }); if (!recovered) return false; await appendEvent(runId, 'run_recovered_from_deliverables', { @@ -780,6 +809,7 @@ export function createAgentRunGateway({ userId: row.user_id, sessionId: recoverySessionId, err, + runStartedAtMs: latest?.started_at ?? row.started_at ?? null, })) { await finalizeSuccessfulRun(runId, row, recoverySessionId); return; @@ -913,18 +943,27 @@ export function createAgentRunGateway({ limit = maxConcurrentRuns, dryRun = true, reason = 'stale_running_timeout', + sessionFinishedGraceMs = SESSION_FINISHED_STALE_GRACE_MS, } = {}) { const normalizedStaleMs = positiveInteger(staleMs, runTimeoutMs); const normalizedLimit = positiveInteger(limit, maxConcurrentRuns); - const cutoff = nowMs() - normalizedStaleMs; + const normalizedSessionFinishedGraceMs = positiveInteger( + sessionFinishedGraceMs, + SESSION_FINISHED_STALE_GRACE_MS, + ); + const startedCutoff = nowMs() - normalizedStaleMs; + const sessionFinishedCutoff = nowMs() - normalizedSessionFinishedGraceMs; const [rows] = await pool.query( `SELECT r.id, + r.user_id, + r.agent_session_id, r.request_id, r.started_at, r.updated_at, r.attempts, - h.latest_heartbeat_at + h.latest_heartbeat_at, + sf.session_finished_at FROM h5_agent_runs r LEFT JOIN ( SELECT run_id, MAX(created_at) AS latest_heartbeat_at @@ -932,18 +971,31 @@ export function createAgentRunGateway({ WHERE event_type = 'worker_heartbeat' GROUP BY run_id ) h ON h.run_id = r.id + LEFT JOIN ( + SELECT run_id, MAX(created_at) AS session_finished_at + FROM h5_agent_run_events + WHERE event_type = 'session_finished' + GROUP BY run_id + ) sf ON sf.run_id = r.id WHERE r.status = 'running' AND r.started_at IS NOT NULL - AND COALESCE(h.latest_heartbeat_at, r.started_at) <= ? - ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC + AND ( + r.started_at <= ? + OR ( + sf.session_finished_at IS NOT NULL + AND sf.session_finished_at <= ? + ) + ) + ORDER BY COALESCE(sf.session_finished_at, r.started_at) ASC LIMIT ?`, - [cutoff, normalizedLimit], + [startedCutoff, sessionFinishedCutoff, normalizedLimit], ); const recovered = []; for (const row of rows) { const ageMs = Math.max(0, nowMs() - Number(row.started_at ?? 0)); const heartbeatAt = row.latest_heartbeat_at == null ? null : Number(row.latest_heartbeat_at); const heartbeatAgeMs = Math.max(0, nowMs() - Number(row.latest_heartbeat_at ?? row.started_at ?? 0)); + const sessionFinishedAt = row.session_finished_at == null ? null : Number(row.session_finished_at); const item = { id: row.id, requestId: row.request_id, @@ -952,13 +1004,40 @@ export function createAgentRunGateway({ attempts: Number(row.attempts ?? 0), heartbeatAt, heartbeatAgeMs, + sessionFinishedAt, ageMs, reason, }; if (!dryRun) { - const message = heartbeatAt == null - ? `agent run recovered from stale running state after ${ageMs}ms without heartbeat` - : `agent run recovered from stale running state after heartbeat was stale for ${heartbeatAgeMs}ms`; + const staleError = Object.assign( + new Error(`agent run stale after ${ageMs}ms`), + { code: 'AGENT_RUN_STALE_RECOVERY' }, + ); + const deliverableRecovered = await recoverRunFromDeliverables({ + runId: row.id, + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + err: staleError, + runStartedAtMs: row.started_at ?? null, + requireRecoverableError: false, + }); + if (deliverableRecovered) { + await markRun(row.id, 'succeeded', { + agent_session_id: row.agent_session_id ?? null, + completed_at: nowMs(), + error_message: null, + }); + item.status = 'succeeded'; + item.recoveredAs = 'deliverables'; + recovered.push(item); + continue; + } + + const message = sessionFinishedAt != null + ? `agent run recovered from stale running state after session finished ${Math.max(0, nowMs() - sessionFinishedAt)}ms ago` + : heartbeatAt == null + ? `agent run recovered from stale running state after ${ageMs}ms without heartbeat` + : `agent run recovered from stale running state after running for ${ageMs}ms`; const completedAt = nowMs(); const [update] = await pool.query( `UPDATE h5_agent_runs @@ -966,33 +1045,41 @@ export function createAgentRunGateway({ WHERE id = ? AND status = 'running' AND started_at IS NOT NULL - AND COALESCE( - (SELECT MAX(created_at) - FROM h5_agent_run_events - WHERE run_id = h5_agent_runs.id AND event_type = 'worker_heartbeat'), - started_at - ) <= ?`, - [message, completedAt, completedAt, row.id, cutoff], + AND ( + started_at <= ? + OR EXISTS ( + SELECT 1 + FROM h5_agent_run_events sf + WHERE sf.run_id = h5_agent_runs.id + AND sf.event_type = 'session_finished' + AND sf.created_at <= ? + ) + )`, + [message, completedAt, completedAt, row.id, startedCutoff, sessionFinishedCutoff], ); if (Number(update?.affectedRows ?? 0) === 0) continue; await appendEvent(row.id, 'stale_recovered', { reason, staleMs: normalizedStaleMs, + sessionFinishedGraceMs: normalizedSessionFinishedGraceMs, ageMs, heartbeatAt, heartbeatAgeMs, + sessionFinishedAt, status: 'failed', error: message, }); + item.status = 'failed'; } recovered.push(item); } return { dryRun, staleMs: normalizedStaleMs, - cutoff, + startedCutoff, + sessionFinishedCutoff, considered: rows.length, - recovered: dryRun ? 0 : recovered.length, + recovered: dryRun ? 0 : recovered.filter((item) => item.status).length, runs: recovered, }; } diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 2137544..dc4872b 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -6,10 +6,26 @@ import path from 'node:path'; import test from 'node:test'; import { createAgentRunGateway } from './agent-run-gateway.mjs'; -function createFakePool({ sessionDeliverables = {} } = {}) { +function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } = {}) { const runs = new Map(); const events = []; + const sessionFinishedAt = (runId) => { + const timestamps = events + .filter((event) => event.runId === runId && event.eventType === 'session_finished') + .map((event) => Number(event.createdAt ?? 0)); + return timestamps.length ? Math.max(...timestamps) : null; + }; + + const isStaleRunningRow = (row, startedCutoff, sessionFinishedCutoff) => { + if (row.status !== 'running' || row.started_at == null) return false; + const finishedAt = sessionFinishedAt(row.id); + return ( + Number(row.started_at) <= Number(startedCutoff) + || (finishedAt != null && Number(finishedAt) <= Number(sessionFinishedCutoff)) + ); + }; + const latestHeartbeatAt = (runId) => { const timestamps = events .filter((event) => event.runId === runId && event.eventType === 'worker_heartbeat') @@ -69,6 +85,28 @@ function createFakePool({ sessionDeliverables = {} } = {}) { .length, }]]; } + if (sql.includes('SELECT') && sql.includes('session_finished_at') && sql.includes('r.started_at <= ?')) { + const [startedCutoff, sessionFinishedCutoff, limit = 1] = params; + return [[...runs.values()] + .filter((row) => isStaleRunningRow(row, startedCutoff, sessionFinishedCutoff)) + .sort((a, b) => { + const aKey = Number(sessionFinishedAt(a.id) ?? a.started_at ?? 0); + const bKey = Number(sessionFinishedAt(b.id) ?? b.started_at ?? 0); + return aKey - bKey; + }) + .slice(0, Number(limit)) + .map((row) => ({ + id: row.id, + user_id: row.user_id, + agent_session_id: row.agent_session_id, + request_id: row.request_id, + started_at: row.started_at, + updated_at: row.updated_at, + attempts: row.attempts, + latest_heartbeat_at: latestHeartbeatAt(row.id), + session_finished_at: sessionFinishedAt(row.id), + }))]; + } if (sql.includes('SELECT') && sql.includes('latest_heartbeat_at') && sql.includes('COALESCE(h.latest_heartbeat_at, r.started_at) <= ?')) { const [cutoff, limit = 1] = params; return [[...runs.values()] @@ -172,6 +210,20 @@ function createFakePool({ sessionDeliverables = {} } = {}) { }); return [{ affectedRows: 1 }]; } + if (sql.includes("WHERE id = ?") && sql.includes("status = 'running'") && sql.includes('session_finished')) { + const [errorMessage, updatedAt, completedAt, id, startedCutoff, sessionFinishedCutoff] = params; + const row = runs.get(id); + if (!row || !isStaleRunningRow(row, startedCutoff, sessionFinishedCutoff)) { + return [{ affectedRows: 0 }]; + } + Object.assign(row, { + status: 'failed', + error_message: errorMessage, + updated_at: updatedAt, + completed_at: completedAt, + }); + return [{ affectedRows: 1 }]; + } if (sql.includes("WHERE id = ?") && sql.includes("status = 'running'") && sql.includes('worker_heartbeat')) { const [errorMessage, updatedAt, completedAt, id, cutoff] = params; const row = runs.get(id); @@ -195,6 +247,15 @@ function createFakePool({ sessionDeliverables = {} } = {}) { const [userId, sessionId] = params; return [sessionDeliverables[`${userId}:${sessionId}`] ?? []]; } + if (sql.includes('FROM h5_page_records p') && sql.includes('auto_synced')) { + const userId = params[0]; + const sinceMs = params.length > 1 ? Number(params[1]) : null; + let rows = workspaceDeliverables[userId] ?? []; + if (sinceMs != null) { + rows = rows.filter((row) => Number(row.updated_at ?? 0) >= sinceMs); + } + return [rows]; + } if (sql.includes('UPDATE h5_agent_runs SET')) { const id = params.at(-1); const row = runs.get(id); @@ -1420,7 +1481,7 @@ test('stale running recovery marks old running rows failed with an event', async ); }); -test('stale running recovery ignores old running rows with fresh heartbeat', async () => { +test('stale running recovery still considers old runs even with fresh heartbeat', async () => { const pool = createFakePool(); const gateway = createAgentRunGateway({ pool, @@ -1450,9 +1511,60 @@ test('stale running recovery ignores old running rows with fresh heartbeat', asy const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false }); - assert.equal(result.considered, 0); - assert.equal(result.recovered, 0); - assert.equal(pool.runs.get(run.id).status, 'running'); + assert.equal(result.considered, 1); + assert.equal(result.recovered, 1); + assert.equal(pool.runs.get(run.id).status, 'failed'); +}); + +test('stale running recovery succeeds when workspace pages exist after sync', async () => { + const startedAt = Date.now() - 5000; + const pool = createFakePool({ + workspaceDeliverables: { + 'user-1': [{ + page_id: 'page-synced', + title: '苏州攻略', + publication_id: 'pub-synced', + publication_status: 'online', + public_url: 'http://127.0.0.1:5173/u/john/pages/page-synced', + updated_at: startedAt + 1000, + }], + }, + }); + const syncCalls = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: {}, + autoDispatch: false, + runTimeoutMs: 1000, + syncUserPagesOnSuccess: async ({ userId, sessionId, runId }) => { + syncCalls.push({ userId, sessionId, runId }); + }, + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-stale-deliverable', + sessionId: 'session-stale-deliverable', + userMessage: { role: 'user', content: [] }, + }); + Object.assign(pool.runs.get(run.id), { + status: 'running', + attempts: 1, + agent_session_id: 'session-stale-deliverable', + started_at: startedAt, + updated_at: startedAt, + }); + + const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false }); + + assert.equal(result.considered, 1); + assert.equal(result.recovered, 1); + assert.equal(pool.runs.get(run.id).status, 'succeeded'); + assert.equal(syncCalls.length, 1); + assert.equal( + pool.events.some((event) => event.runId === run.id && event.eventType === 'run_recovered_from_deliverables'), + true, + ); }); test('queue status reports running heartbeat age and missing heartbeat count', async () => { diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index fadb4a1..9941342 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -55,6 +55,7 @@ export function createPostAgentRunsHandler({ userAuth, sessionAccess = null, agentRunGateway, + mindSpaceAssetAgent = null, codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), codeRunUserIds = parseUserIdSet(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), codeRunTaskTypes = parseTaskTypeSet(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), @@ -117,6 +118,36 @@ export function createPostAgentRunsHandler({ return; } } + const selectedAssetIds = [ + ...new Set( + (Array.isArray(request.body?.selected_asset_ids) + ? request.body.selected_asset_ids + : Array.isArray(request.body?.selectedAssetIds) + ? request.body.selectedAssetIds + : [] + ) + .map((item) => String(item ?? '').trim()) + .filter(Boolean), + ), + ]; + if ( + selectedAssetIds.length > 0 && + mindSpaceAssetAgent?.materializeAssetsForSession + ) { + try { + await mindSpaceAssetAgent.materializeAssetsForSession({ + sessionId, + userId: request.currentUser.id, + assetIds: selectedAssetIds, + }); + } catch (err) { + response.status(400).json({ + message: err instanceof Error ? err.message : '勾选资料落盘失败', + code: err?.code ?? 'asset_materialize_failed', + }); + return; + } + } const run = await agentRunGateway.createRun(request.currentUser.id, { sessionId, requestId, diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index 7368e52..4affb9c 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -746,3 +746,46 @@ test('GET /agent/runs/:runId/events republishes changed run state before termina assert.match(output, /"status":"succeeded"/); assert.deepEqual(dispatched, ['run-2']); }); + +test('POST /agent/runs materializes selected assets before creating run', async () => { + const materialized = []; + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return true; + }, + }, + mindSpaceAssetAgent: { + async materializeAssetsForSession(payload) { + materialized.push(payload); + return { materialized: [{ relativePath: 'oa/sales.xlsx' }] }; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + return { id: 'run-1', status: 'queued', userId, payload }; + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + session_id: 'session-1', + request_id: 'req-1', + user_message: { role: 'user', content: [{ type: 'text', text: 'analyze' }] }, + selected_asset_ids: ['asset-9'], + }, + }, + res, + ); + + assert.equal(res.statusCode, 202); + assert.deepEqual(materialized, [{ + sessionId: 'session-1', + userId: 'user-1', + assetIds: ['asset-9'], + }]); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index 73a159e..edae315 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -174,8 +174,9 @@ export function buildChatSkillPrompt(promptKey, skillName) { return '请深入分析以下内容,给出结构化解读:背景、关键发现、风险或机会、以及建议下一步:'; case 'generate-page': return ( - `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。` + - '默认只生成 HTML:必须先 load_skill,再用 write_file/edit_file 写入 public/页面.html;完成后直接返回 Markdown 可点击公网链接 `[页面标题](URL)`。' + + `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成 HTML 页面,并给出可公网访问的完整链接。` + + '默认只生成 HTML:必须先 load_skill,再用 write_file/edit_file 写入 public/页面.html;允许图表、动画等动态效果,但脚本必须适合沙箱发布,优先使用平台已支持的本地/可信库,禁止 javascript:、onxxx 内联事件、未知第三方脚本和跨用户私有资源。' + + '完成前必须验证文件已写入、页面安全检查/发布状态可用、链接返回 200 且正文包含关键数据;验证失败时不要宣称“已完成”,应修复后再回复 Markdown 可点击公网链接 `[页面标题](URL)`。' + '禁止只给本地路径(如 hello/index.html),禁止询问是否还要发布,除非写文件工具实际失败。' + '只有用户明确要求 Word / docx 下载时,才先 `load_skill` → `docx-generate`,生成 `public/*.docx` 并确认文件已存在,再写 HTML 并用同目录相对路径链接该文档。' + '只有用户明确要求长图下载时,才先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成 `public/*.long.png` 并确认文件已存在,再给长图预览链接和 `?download=long-image` 下载链接。不要在没有明确需求时强制生成伴生文件。' diff --git a/mindspace-asset-agent.mjs b/mindspace-asset-agent.mjs index 3599b89..35b362c 100644 --- a/mindspace-asset-agent.mjs +++ b/mindspace-asset-agent.mjs @@ -1,3 +1,74 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mjs'; +import { ensureUserZoneDirs } from './user-space.mjs'; + +function sanitizeBasename(value) { + const normalized = String(value ?? '').replace(/\\/g, '/').trim(); + const basename = normalized.split('/').pop() ?? ''; + return basename.replace(/[^\w.\-()\u4e00-\u9fff]+/g, '_') || 'file'; +} + +async function materializeAssetToWorkspace({ + assetService, + resolveWorkspaceRoot, + userId, + assetId, +}) { + const { asset, path: sourcePath } = await assetService.readAsset(userId, assetId); + const workspaceRoot = await resolveWorkspaceRoot(userId); + ensureUserZoneDirs(workspaceRoot); + const filename = sanitizeBasename(asset.filename ?? asset.displayName ?? asset.id); + const relativePath = + resolveAssetWorkspaceRelativePath({ + categoryCode: asset.categoryCode ?? 'oa', + originalFilename: filename, + }) ?? `oa/${filename}`; + const destPath = path.join(workspaceRoot, ...relativePath.split('/')); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(sourcePath, destPath); + const stat = await fs.stat(destPath); + return { + assetId, + relativePath, + sizeBytes: stat.size, + displayName: asset.displayName ?? filename, + mimeType: asset.mimeType ?? null, + }; +} + +export function buildSelectedAssetReadInstructions(context) { + const selected = context.selectedAssets ?? []; + if (selected.length !== 1) return []; + + const asset = selected[0]; + const h5ApiBase = String(context.h5ApiBase ?? '').replace(/\/$/, ''); + const sessionId = String(context.agentSessionId ?? '').trim(); + if (!h5ApiBase) return []; + + const sessionToken = sessionId || ''; + const sessionHint = sessionId + ? '' + : '- session_id 未写入上下文时,使用当前 Agent 会话 ID 替换 ``。\n'; + + return [ + '【勾选资料读取(硬性)】', + `- 用户已勾选「${asset.displayName}」(id: ${asset.id})`, + '- **禁止** curl / fetch 浏览器登录接口 `GET /api/mindspace/v1/assets/:id/download`(goosed 无 cookie,会 401)', + '- **优先**用 `list_dir oa` / `read_file` / `cat oa/文件名` 读取工作区已落盘副本', + sessionHint, + '- 若工作区尚无该文件,**必须**用 shell 调用 Agent 代理下载并落盘到 `oa/`:', + '```bash', + `curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_asset_download' \\`, + " -H 'Content-Type: application/json' \\", + ` -d '{"session_id":"${sessionToken}","asset_id":"${asset.id}"}'`, + '```', + '- 成功后响应 JSON 含 `relativePath`(如 `oa/report.xlsx`),后续分析/引用只用该相对路径', + '- **禁止**访问其它用户目录、MindSpace 根目录或 `./MindSpace//` 等猜测路径', + '', + ].filter(Boolean); +} + export function buildSelectedAssetDeleteInstructions(context) { const selected = context.selectedAssets ?? []; if (selected.length !== 1) return []; @@ -29,7 +100,24 @@ export function buildSelectedAssetDeleteInstructions(context) { ].filter(Boolean); } -export function createAssetAgentService({ assetService, resolveUserIdForAgentSession }) { +export function createAssetAgentService({ + assetService, + resolveUserIdForAgentSession, + resolveWorkspaceRoot, +}) { + if (typeof resolveWorkspaceRoot !== 'function') { + throw new Error('resolveWorkspaceRoot is required for asset agent service'); + } + + async function resolveOwnedUserId(sessionId, explicitUserId = null) { + if (explicitUserId) return explicitUserId; + const userId = await resolveUserIdForAgentSession(sessionId); + if (!userId) { + throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' }); + } + return userId; + } + return { async applyAgentDelete(input = {}) { const sessionId = String(input?.sessionId ?? input?.session_id ?? '').trim(); @@ -42,11 +130,7 @@ export function createAssetAgentService({ assetService, resolveUserIdForAgentSes throw Object.assign(new Error('删除前须已在对话中获得用户确认'), { code: 'confirmation_required' }); } - const userId = await resolveUserIdForAgentSession(sessionId); - if (!userId) { - throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' }); - } - + const userId = await resolveOwnedUserId(sessionId); const result = await assetService.deleteAsset(userId, assetId); return { assetId, @@ -54,5 +138,53 @@ export function createAssetAgentService({ assetService, resolveUserIdForAgentSes ...result, }; }, + + async applyAgentDownload(input = {}) { + const sessionId = String(input?.sessionId ?? input?.session_id ?? '').trim(); + const assetId = String(input?.assetId ?? input?.asset_id ?? '').trim(); + if (!sessionId || !assetId) { + throw Object.assign(new Error('缺少 session_id 或 asset_id'), { code: 'invalid_request' }); + } + + const userId = await resolveOwnedUserId(sessionId); + const materialized = await materializeAssetToWorkspace({ + assetService, + resolveWorkspaceRoot, + userId, + assetId, + }); + return { + userId, + ...materialized, + }; + }, + + async materializeAssetsForSession({ + sessionId = null, + userId = null, + assetIds = [], + } = {}) { + const normalizedIds = [...new Set( + (Array.isArray(assetIds) ? assetIds : []) + .map((item) => String(item ?? '').trim()) + .filter(Boolean), + )]; + if (normalizedIds.length === 0) { + return { materialized: [] }; + } + const resolvedUserId = await resolveOwnedUserId(sessionId, userId); + const materialized = []; + for (const assetId of normalizedIds) { + materialized.push( + await materializeAssetToWorkspace({ + assetService, + resolveWorkspaceRoot, + userId: resolvedUserId, + assetId, + }), + ); + } + return { materialized }; + }, }; } diff --git a/mindspace-asset-agent.test.mjs b/mindspace-asset-agent.test.mjs index b90d554..2fadf54 100644 --- a/mindspace-asset-agent.test.mjs +++ b/mindspace-asset-agent.test.mjs @@ -1,10 +1,33 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { buildSelectedAssetDeleteInstructions, + buildSelectedAssetReadInstructions, createAssetAgentService, } from './mindspace-asset-agent.mjs'; +test('buildSelectedAssetReadInstructions uses agent download proxy', () => { + const lines = buildSelectedAssetReadInstructions({ + selectedAssets: [ + { + id: 'asset-9', + displayName: 'sales.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + ], + h5ApiBase: 'http://127.0.0.1:8081', + agentSessionId: 'session-1', + }); + const text = lines.join('\n'); + assert.match(text, /mindspace_asset_download/); + assert.match(text, /禁止.*assets\/:id\/download/); + assert.match(text, /asset-9/); + assert.match(text, /session-1/); +}); + test('buildSelectedAssetDeleteInstructions includes agent delete curl', () => { const lines = buildSelectedAssetDeleteInstructions({ selectedAssets: [ @@ -32,6 +55,7 @@ test('applyAgentDelete requires confirmation and valid session', async () => { }, resolveUserIdForAgentSession: async (sessionId) => sessionId === 'session-1' ? 'user-1' : null, + resolveWorkspaceRoot: async () => '/tmp/workspace', }); await assert.rejects( @@ -47,3 +71,64 @@ test('applyAgentDelete requires confirmation and valid session', async () => { assert.equal(result.deleted, true); assert.equal(result.userId, 'user-1'); }); + +test('applyAgentDownload materializes asset into workspace oa/', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'asset-agent-')); + const sourcePath = path.join(workspaceRoot, 'source.xlsx'); + await fs.writeFile(sourcePath, 'excel-bytes'); + + const service = createAssetAgentService({ + assetService: { + readAsset: async () => ({ + asset: { + id: 'asset-9', + categoryCode: 'oa', + filename: 'sales.xlsx', + displayName: 'sales.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + path: sourcePath, + }), + }, + resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => workspaceRoot, + }); + + const result = await service.applyAgentDownload({ + session_id: 'session-1', + asset_id: 'asset-9', + }); + assert.equal(result.relativePath, 'oa/sales.xlsx'); + const copied = await fs.readFile(path.join(workspaceRoot, 'oa/sales.xlsx'), 'utf8'); + assert.equal(copied, 'excel-bytes'); +}); + +test('materializeAssetsForSession accepts explicit userId', async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'asset-agent-')); + const sourcePath = path.join(workspaceRoot, 'source.csv'); + await fs.writeFile(sourcePath, 'a,b\n1,2'); + + const service = createAssetAgentService({ + assetService: { + readAsset: async (_userId, assetId) => ({ + asset: { + id: assetId, + categoryCode: 'oa', + filename: 'data.csv', + displayName: 'data.csv', + mimeType: 'text/csv', + }, + path: sourcePath, + }), + }, + resolveUserIdForAgentSession: async () => null, + resolveWorkspaceRoot: async () => workspaceRoot, + }); + + const result = await service.materializeAssetsForSession({ + userId: 'user-1', + assetIds: ['asset-a'], + }); + assert.equal(result.materialized.length, 1); + assert.equal(result.materialized[0].relativePath, 'oa/data.csv'); +}); diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index e7635e4..68e3c68 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -61,6 +61,26 @@ function asNumber(value) { return Number(value ?? 0); } +function scanOptionsForCategoryFile(categoryCode, filename, mimeType) { + const normalizedName = String(filename ?? '').toLowerCase(); + if ( + categoryCode === 'public' && + mimeType === 'text/html' && + normalizedName.endsWith('.html') + ) { + return { htmlActiveContentPolicy: 'sandbox_warn' }; + } + return {}; +} + +function assetStatusForScan(scan) { + return scan.scanStatus === 'blocked' ? 'quarantined' : 'ready'; +} + +function versionStatusForScan(scan) { + return scan.scanStatus === 'blocked' ? 'blocked' : scan.scanStatus; +} + function normalizeFilename(filename) { const normalized = String(filename ?? '').normalize('NFKC').trim(); if ( @@ -636,9 +656,10 @@ export function createAssetService(pool, options = {}) { const scan = runBasicFileScan(storedBuffer, { filename: storedUploadFilename, mimeType: storedMimeType, + ...scanOptionsForCategoryFile(upload.category_code, storedUploadFilename, storedMimeType), }); - const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; - const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const assetStatus = assetStatusForScan(scan); + const versionScanStatus = versionStatusForScan(scan); const isImage = storedMimeType.startsWith('image/'); const shouldPublishImage = isImage && scan.scanStatus === 'passed'; let finalStorageKey = upload.temporary_storage_key; @@ -1188,9 +1209,10 @@ export function createAssetService(pool, options = {}) { const scan = runBasicFileScan(storedBuffer, { filename: storedFilenameInput, mimeType: storedMimeType, + ...scanOptionsForCategoryFile(category.category_code, storedFilenameInput, storedMimeType), }); - const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; - const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const assetStatus = assetStatusForScan(scan); + const versionScanStatus = versionStatusForScan(scan); const shouldPublishImage = isImage && scan.scanStatus === 'passed'; let finalStorageKey = path.posix.join('users', userId, 'assets', assetId, 'versions', versionId); let workspaceRelativePath = null; diff --git a/mindspace-chat-context.mjs b/mindspace-chat-context.mjs index 54a3819..4d6aa69 100644 --- a/mindspace-chat-context.mjs +++ b/mindspace-chat-context.mjs @@ -1,5 +1,8 @@ import { buildMindSpacePageSaveInstructions } from './mindspace-page-patch.mjs'; -import { buildSelectedAssetDeleteInstructions } from './mindspace-asset-agent.mjs'; +import { + buildSelectedAssetDeleteInstructions, + buildSelectedAssetReadInstructions, +} from './mindspace-asset-agent.mjs'; const VIEW_LABELS = { home: '空间首页', @@ -107,7 +110,7 @@ function buildSelectedAssetInstructions(context) { lines.push( `- 用户在界面勾选了 1 份${kind}:「${asset.displayName}」(id: ${asset.id},${asset.mimeType})`, '- 用户说「这个 / 这份 / 这篇 / 这份报告 / 这个数据 / 帮我分析 / 帮我删除」等指代词时,**必须**指这份勾选资料,不得猜测其它文件。', - `- 读取/下载:GET /api/mindspace/v1/assets/${asset.id}/download`, + '- 读取/分析:优先 `list_dir oa` / `read_file oa/文件名`;若工作区尚无副本,见下方「勾选资料读取」用 Agent 代理下载落盘。', '- 删除:须先与用户确认,确认后由你直接执行删除(见下方「勾选资料删除」);你对该用户空间有完整读写删改权限,**禁止**推给用户自行去界面删除。', ); if (context.h5ApiBase) { @@ -363,6 +366,7 @@ export function buildContextPrefix(context) { } lines.push(...buildSelectedAssetInstructions(context)); + lines.push(...buildSelectedAssetReadInstructions(context)); lines.push(...buildSelectedAssetDeleteInstructions(context)); if (context.homeCategories?.length) { diff --git a/mindspace-chat-context.test.mjs b/mindspace-chat-context.test.mjs index ff4c9d3..23c8155 100644 --- a/mindspace-chat-context.test.mjs +++ b/mindspace-chat-context.test.mjs @@ -220,6 +220,8 @@ test('buildContextPrefix binds deictic references to a single checked asset', () assert.match(prefix, /【界面勾选资料(硬性)】/); assert.match(prefix, /sales\.xlsx(id: asset-9/); + assert.match(prefix, /【勾选资料读取(硬性)】/); + assert.match(prefix, /mindspace_asset_download/); assert.match(prefix, /【勾选资料删除(硬性)】/); assert.match(prefix, /mindspace_asset_delete/); assert.match(prefix, /完整读写删改权限/); diff --git a/mindspace-local-runtime-services.mjs b/mindspace-local-runtime-services.mjs index 6e97efc..5af8f24 100644 --- a/mindspace-local-runtime-services.mjs +++ b/mindspace-local-runtime-services.mjs @@ -17,6 +17,7 @@ export function createMindSpaceLocalRuntimeServices({ maxFileBytes, publicPageLimit, resolveUserIdForAgentSession, + resolveWorkspaceRoot, conversationPackageBackfill, conversationPackagePublicHtmlHydrator, logger = console, @@ -83,6 +84,7 @@ export function createMindSpaceLocalRuntimeServices({ const assetAgentService = createAssetAgentService({ assetService, resolveUserIdForAgentSession, + resolveWorkspaceRoot, }); return { diff --git a/mindspace-local-runtime-services.test.mjs b/mindspace-local-runtime-services.test.mjs index b807dfb..19b09a0 100644 --- a/mindspace-local-runtime-services.test.mjs +++ b/mindspace-local-runtime-services.test.mjs @@ -18,6 +18,7 @@ test('createMindSpaceLocalRuntimeServices assembles local runtime-backed service maxFileBytes: 8192, publicPageLimit: 8, resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => '/tmp/workspace', conversationPackageBackfill: () => [], conversationPackagePublicHtmlHydrator: () => [], logger: { info() {}, warn() {}, error() {} }, diff --git a/mindspace-local-server-adapter.mjs b/mindspace-local-server-adapter.mjs index 34cd2e8..2d2b96b 100644 --- a/mindspace-local-server-adapter.mjs +++ b/mindspace-local-server-adapter.mjs @@ -9,6 +9,7 @@ export function createMindSpaceLocalServerAdapter({ maxFileBytes, publicPageLimit, resolveUserIdForAgentSession, + resolveWorkspaceRoot = null, resolveSessionSnapshot, registerPublicHtmlArtifactsForConversation, logger = console, @@ -23,6 +24,7 @@ export function createMindSpaceLocalServerAdapter({ maxFileBytes, publicPageLimit, resolveUserIdForAgentSession, + resolveWorkspaceRoot, conversationPackageBackfill: ({ user, sessionId, storageRoot, registry }) => backfillConversationPackageArtifacts({ pool, diff --git a/mindspace-local-server-adapter.test.mjs b/mindspace-local-server-adapter.test.mjs index 1be1ed7..4598ca6 100644 --- a/mindspace-local-server-adapter.test.mjs +++ b/mindspace-local-server-adapter.test.mjs @@ -48,6 +48,7 @@ test('createMindSpaceLocalServerAdapter wires backfill and public html hydrator maxFileBytes: 8192, publicPageLimit: 8, resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => path.join(h5Root, 'MindSpace', 'user-1'), resolveSessionSnapshot: async (sessionId) => snapshots.get(sessionId) ?? null, registerPublicHtmlArtifactsForConversation: async (payload) => { registered.push(payload); @@ -88,6 +89,7 @@ test('createMindSpaceLocalServerAdapter starts publication cleanup and agent wor maxFileBytes: 8192, publicPageLimit: 8, resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => path.join(h5Root, 'MindSpace', 'user-1'), logger: { log: (...args) => loggerCalls.push(['log', ...args]), warn: (...args) => loggerCalls.push(['warn', ...args]), @@ -161,6 +163,7 @@ test('createMindSpaceLocalServerAdapter startBackgroundJobs aggregates startup s maxFileBytes: 8192, publicPageLimit: 8, resolveUserIdForAgentSession: async () => 'user-1', + resolveWorkspaceRoot: async () => path.join(h5Root, 'MindSpace', 'user-1'), logger: { log() {}, warn() {}, error() {} }, setIntervalFn: (fn, ms) => { const timer = { fn, ms, unref() {} }; diff --git a/mindspace-scan.mjs b/mindspace-scan.mjs index cda6d7f..ec069c1 100644 --- a/mindspace-scan.mjs +++ b/mindspace-scan.mjs @@ -1,10 +1,16 @@ -const SCRIPT_PATTERNS = [ - /]*)>([\s\S]*?)<\/script>/gi; +const SCRIPT_SRC_PATTERN = /\bsrc\s*=\s*(['"])([^'"]+)\1/i; +const TRUSTED_SCRIPT_SRC_PATTERNS = [ + /^\/assets\/chart\.umd\.min\.js(?:[?#].*)?$/i, + /^https:\/\/cdn\.jsdelivr\.net\/npm\/chart\.js(?:@[^/]+)?\/dist\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i, + /^https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/Chart\.js\/[^/]+\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i, + /^https:\/\/unpkg\.com\/chart\.js(?:@[^/]+)?\/dist\/chart\.(?:umd\.)?min\.js(?:[?#].*)?$/i, ]; const MAX_ZIP_COMPRESSION_RATIO = 100; @@ -19,17 +25,53 @@ function isZipContainer(mimeType) { ); } -function scanTextContent(buffer) { +function isTrustedScriptSrc(src) { + const normalized = String(src ?? '').trim().replace(/^\/\//, 'https://'); + return TRUSTED_SCRIPT_SRC_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +function scanScriptTags(sample, { htmlActiveContentPolicy }) { + const scripts = [...sample.matchAll(SCRIPT_TAG_PATTERN)]; + if (!scripts.length) return null; + if (htmlActiveContentPolicy !== 'sandbox_warn') { + return { + scanStatus: 'blocked', + riskLevel: 'high', + findings: ['text_active_content'], + }; + } + const untrusted = scripts.some((match) => { + const attrs = match[1] ?? ''; + const src = attrs.match(SCRIPT_SRC_PATTERN)?.[2] ?? null; + return src ? !isTrustedScriptSrc(src) : false; + }); + if (untrusted) { + return { + scanStatus: 'blocked', + riskLevel: 'high', + findings: ['text_active_content'], + }; + } + return { + scanStatus: 'warned', + riskLevel: 'medium', + findings: ['trusted_html_active_content'], + }; +} + +function scanTextContent(buffer, options = {}) { const sample = buffer.subarray(0, Math.min(buffer.length, 256 * 1024)).toString('utf8'); - for (const pattern of SCRIPT_PATTERNS) { + for (const { type, pattern } of BLOCKED_ACTIVE_PATTERNS) { if (pattern.test(sample)) { return { scanStatus: 'blocked', riskLevel: 'high', - findings: ['text_active_content'], + findings: [type], }; } } + const scriptFinding = scanScriptTags(sample, options); + if (scriptFinding) return scriptFinding; return null; } @@ -72,7 +114,7 @@ function scanZipStructure(buffer) { return null; } -export function runBasicFileScan(buffer, { filename, mimeType }) { +export function runBasicFileScan(buffer, { filename, mimeType, htmlActiveContentPolicy = 'block' }) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { return { scanStatus: 'blocked', @@ -82,7 +124,7 @@ export function runBasicFileScan(buffer, { filename, mimeType }) { } if (mimeType.startsWith('text/') || mimeType === 'application/pdf') { - const textFinding = scanTextContent(buffer); + const textFinding = scanTextContent(buffer, { filename, mimeType, htmlActiveContentPolicy }); if (textFinding) return textFinding; } diff --git a/mindspace-scan.test.mjs b/mindspace-scan.test.mjs index c8f7d6d..a7f4d96 100644 --- a/mindspace-scan.test.mjs +++ b/mindspace-scan.test.mjs @@ -19,6 +19,43 @@ test('runBasicFileScan blocks inline script payloads', () => { assert.equal(result.riskLevel, 'high'); }); +test('runBasicFileScan warns for sandboxed html with inline script and trusted Chart.js', () => { + const result = runBasicFileScan( + Buffer.from(` + + `), + { + filename: 'dashboard.html', + mimeType: 'text/html', + htmlActiveContentPolicy: 'sandbox_warn', + }, + ); + assert.equal(result.scanStatus, 'warned'); + assert.equal(result.riskLevel, 'medium'); + assert.deepEqual(result.findings, ['trusted_html_active_content']); +}); + +test('runBasicFileScan still blocks unsafe html active content in sandbox mode', () => { + const javascriptUrl = runBasicFileScan(Buffer.from('go'), { + filename: 'dashboard.html', + mimeType: 'text/html', + htmlActiveContentPolicy: 'sandbox_warn', + }); + assert.equal(javascriptUrl.scanStatus, 'blocked'); + assert.deepEqual(javascriptUrl.findings, ['javascript_url']); + + const unknownScript = runBasicFileScan( + Buffer.from(''), + { + filename: 'dashboard.html', + mimeType: 'text/html', + htmlActiveContentPolicy: 'sandbox_warn', + }, + ); + assert.equal(unknownScript.scanStatus, 'blocked'); + assert.deepEqual(unknownScript.findings, ['text_active_content']); +}); + test('runBasicFileScan blocks suspicious zip compression ratio', () => { const buffer = Buffer.alloc(64); buffer.write('PK', 0); diff --git a/mindspace-service/mindspace-service-bootstrap.mjs b/mindspace-service/mindspace-service-bootstrap.mjs index 8f946e8..a481812 100644 --- a/mindspace-service/mindspace-service-bootstrap.mjs +++ b/mindspace-service/mindspace-service-bootstrap.mjs @@ -170,6 +170,7 @@ export async function bootstrapMindSpaceService({ maxFileBytes: runtime.maxFileBytes, publicPageLimit: runtime.publicPageLimit, resolveUserIdForAgentSession: (sessionId) => resolveUserIdForAgentSession(pool, sessionId), + resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId), resolveSessionSnapshot: (sessionId) => sessionSnapshotService.get(sessionId), registerPublicHtmlArtifactsForConversation: (payload) => registerPublicHtmlArtifactsForConversationPackage({ diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs index 3b022f8..1a47a54 100644 --- a/mindspace-workspace-sync.mjs +++ b/mindspace-workspace-sync.mjs @@ -53,6 +53,26 @@ function workspaceAssetDownloadUrl(assetId) { return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`; } +function scanOptionsForWorkspaceFile(categoryCode, filename, mimeType) { + const normalizedName = String(filename ?? '').toLowerCase(); + if ( + categoryCode === 'public' && + mimeType === 'text/html' && + normalizedName.endsWith('.html') + ) { + return { htmlActiveContentPolicy: 'sandbox_warn' }; + } + return {}; +} + +function assetStatusForScan(scan) { + return scan.scanStatus === 'blocked' ? 'quarantined' : 'ready'; +} + +function versionStatusForScan(scan) { + return scan.scanStatus === 'blocked' ? 'blocked' : scan.scanStatus; +} + export function normalizeWorkspaceRelativePath(relativePath) { const normalized = String(relativePath ?? '') .normalize('NFKC') @@ -180,9 +200,10 @@ export function createWorkspaceAssetSync({ const scan = runBasicFileScan(buffer, { filename: file.filename, mimeType: detectedMimeType, + ...scanOptionsForWorkspaceFile(category.category_code, file.filename, detectedMimeType), }); - const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; - const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const assetStatus = assetStatusForScan(scan); + const versionScanStatus = versionStatusForScan(scan); const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const assetId = idFactory(); const versionId = idFactory(); @@ -295,9 +316,10 @@ export function createWorkspaceAssetSync({ const scan = runBasicFileScan(buffer, { filename: file.filename, mimeType: detectedMimeType, + ...scanOptionsForWorkspaceFile(category.category_code, file.filename, detectedMimeType), }); - const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; - const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const assetStatus = assetStatusForScan(scan); + const versionScanStatus = versionStatusForScan(scan); const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const [versionRows] = await conn.query( `SELECT COALESCE(MAX(version_no), 0) AS max_version diff --git a/mindspace-workspace-sync.test.mjs b/mindspace-workspace-sync.test.mjs index 260a0c3..65f340f 100644 --- a/mindspace-workspace-sync.test.mjs +++ b/mindspace-workspace-sync.test.mjs @@ -112,10 +112,12 @@ test('syncUserWorkspace imports new workspace files into asset library', async ( user_id: params[1], category_id: params[3], original_filename: params[6], - checksum: params[10], - size_bytes: params[9], - current_version_id: params[8], - status: params[13], + checksum: params[11], + size_bytes: params[10], + current_version_id: params[9], + risk_level: params[12], + visibility: params[13], + status: params[14], source_type: 'workspace', }); return [[]]; @@ -203,6 +205,111 @@ test('syncUserWorkspace imports new workspace files into asset library', async ( ]); }); +test('syncUserWorkspace imports sandboxable public html as warned ready asset', async () => { + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-public-html-')); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const workspace = resolveUserWorkspaceRoot(h5Root, { id: 'user-1', username: 'john' }); + await fs.mkdir(path.join(workspace, 'public'), { recursive: true }); + await fs.writeFile( + path.join(workspace, 'public', 'dashboard.html'), + ` + + `, + ); + + const state = { + users: [{ id: 'user-1', username: 'john' }], + categories: [ + { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, + ], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 5 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + assets: [], + versions: [], + }; + + let nextId = 0; + const pool = { + async query(sql, params = []) { + if (sql.includes('SELECT username FROM h5_users')) { + return [[{ username: 'john' }]]; + } + if (sql.includes('FROM h5_space_categories c') && sql.includes('category_code = ?')) { + const category = state.categories.find( + (item) => item.user_id === params[0] && item.category_code === params[1], + ); + return [category ? [category] : []]; + } + if (sql.includes('FROM h5_assets')) return [[]]; + return [[]]; + }, + async getConnection() { + return { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + async query(sql, params = []) { + if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { + return [[state.spaces[0]]]; + } + if (sql.includes('INSERT INTO h5_assets')) { + state.assets.push({ + id: params[0], + original_filename: params[6], + workspace_relative_path: params[8], + size_bytes: params[10], + checksum: params[11], + risk_level: params[12], + visibility: params[13], + status: params[14], + }); + return [[]]; + } + if (sql.includes('INSERT INTO h5_asset_versions')) { + state.versions.push({ + id: params[0], + asset_id: params[1], + storage_key: params[2], + scan_status: params[7], + }); + return [[]]; + } + if (sql.includes('used_bytes = used_bytes +')) { + state.spaces[0].used_bytes += params[0]; + return [[]]; + } + return pool.query(sql, params); + }, + }; + }, + }; + + const sync = createWorkspaceAssetSync({ + pool, + storageRoot, + h5Root, + maxFileBytes: 1024 * 1024, + idFactory: () => `id-${++nextId}`, + }); + + const result = await sync.syncUserWorkspace('user-1', { categoryCode: 'public' }); + assert.equal(result.imported, 1); + assert.equal(state.assets[0].status, 'ready'); + assert.equal(state.assets[0].risk_level, 'medium'); + assert.equal(state.assets[0].visibility, 'public_candidate'); + assert.equal(state.versions[0].scan_status, 'warned'); + assert.match(state.versions[0].storage_key, /^workspace:\/\/user-1\/public\/dashboard\.html$/); +}); + test('syncUserWorkspace imports nested workspace files into asset library', async () => { const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-sync-nested-')); const storageRoot = path.join(h5Root, 'data', 'mindspace'); @@ -270,10 +377,12 @@ test('syncUserWorkspace imports nested workspace files into asset library', asyn user_id: params[1], category_id: params[3], original_filename: params[6], - checksum: params[10], - size_bytes: params[9], - current_version_id: params[8], - status: params[13], + checksum: params[11], + size_bytes: params[10], + current_version_id: params[9], + risk_level: params[12], + visibility: params[13], + status: params[14], source_type: 'workspace', }); return [[]]; diff --git a/scenarios/dev-logout.json b/scenarios/dev-logout.json new file mode 100644 index 0000000..61c5cad --- /dev/null +++ b/scenarios/dev-logout.json @@ -0,0 +1,19 @@ +{ + "id": "dev-logout", + "name": "DEV 登出清 Cookie", + "description": "登录 → POST /auth/logout → 验证 /auth/status 与 /api/me 拒绝旧 Cookie", + "account": { + "username": "john", + "password": "888888" + }, + "steps": [ + { + "action": "login", + "label": "登录 john 账户" + }, + { + "action": "logout", + "label": "登出并验证 Cookie 已清除" + } + ] +} diff --git a/scenarios/john2-suzhou-page.json b/scenarios/john2-suzhou-page.json new file mode 100644 index 0000000..2278986 --- /dev/null +++ b/scenarios/john2-suzhou-page.json @@ -0,0 +1,26 @@ +{ + "id": "john2-suzhou-page", + "name": "john2 苏州攻略新页", + "description": "新用户 john2 → 强制生成全新苏州 1 日攻略 HTML → 验证 run 成功与页面可访问", + "account": { + "username": "john2", + "password": "888888" + }, + "steps": [ + { + "action": "chat", + "label": "john2 登录并请求全新苏州页面", + "message": "请帮我做一个全新的苏州一日游攻略页面,保存为 public/suzhou-e2e-test.html,不要修改或复用已有页面,做完直接给我链接。", + "expect": { + "assistantMinChars": 20, + "timeoutMs": 600000, + "replyKeywords": ["苏州"], + "page": { + "keywords": ["苏州"], + "requirePublicLink": true, + "requireHttp200": true + } + } + } + ] +} diff --git a/scripts/run-scenario-test.mjs b/scripts/run-scenario-test.mjs index 3a792c1..bb9be25 100644 --- a/scripts/run-scenario-test.mjs +++ b/scripts/run-scenario-test.mjs @@ -14,6 +14,7 @@ import { listScenarios, loadScenario, loginViaApi, + logoutViaApi, resolvePortalBase, snapshotPublicHtml, verifyPageAccess, @@ -95,9 +96,22 @@ async function runScenario(scenario, port) { const label = step.label ?? step.action; console.log(`\n--- ${label} ---`); - if (step.action === 'login') { - auth = await loginViaApi(baseUrl, account, reporter); - publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username; + if (step.action === 'login' || (step.action === 'chat' && !auth && step.login !== false)) { + if (!auth) { + auth = await loginViaApi(baseUrl, account, reporter); + publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username; + } + if (step.action === 'login') { + continue; + } + } + + if (step.action === 'logout') { + if (!auth) { + throw new Error('logout 步骤前必须先 login'); + } + await logoutViaApi(baseUrl, auth.cookie, reporter); + auth = null; continue; } @@ -114,6 +128,7 @@ async function runScenario(scenario, port) { message: step.message, sessionId, selectedChatSkill: step.selectedChatSkill ?? null, + selectedAssetIds: step.selectedAssetIds ?? null, }); reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`); diff --git a/scripts/scenario-test-lib.mjs b/scripts/scenario-test-lib.mjs index 0c15450..ca67b19 100644 --- a/scripts/scenario-test-lib.mjs +++ b/scripts/scenario-test-lib.mjs @@ -75,6 +75,46 @@ export async function loginViaApi(baseUrl, { username, password }, reporter) { }; } +export async function logoutViaApi(baseUrl, cookie, reporter) { + const response = await fetch(`${baseUrl}/auth/logout`, { + method: 'POST', + headers: { Cookie: cookie }, + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(`登出失败 ${response.status}: ${JSON.stringify(body)}`); + } + + const setCookie = response.headers.getSetCookie?.() ?? []; + const cleared = setCookie.some((line) => /Max-Age=0/i.test(line) || /Expires=Thu, 01 Jan 1970/i.test(line)); + if (!cleared) { + reporter.fail('登出 Cookie', '响应未包含清 Cookie 的 Set-Cookie'); + } else { + reporter.pass('登出 Cookie', 'Set-Cookie 已清除会话'); + } + + const statusResponse = await fetch(`${baseUrl}/auth/status`, { + headers: { Cookie: cookie }, + }); + const statusBody = await statusResponse.json().catch(() => ({})); + if (statusBody?.authenticated) { + reporter.fail('登出状态', `/auth/status 仍为 authenticated`); + } else { + reporter.pass('登出状态', '未登录'); + } + + const protectedResponse = await fetch(`${baseUrl}/api/me`, { + headers: { Cookie: cookie }, + }); + if (protectedResponse.status === 401 || protectedResponse.status === 403) { + reporter.pass('登出后 API', `${protectedResponse.status} 已拒绝旧 Cookie`); + } else { + reporter.fail('登出后 API', `/api/me 返回 ${protectedResponse.status},旧 Cookie 仍有效`); + } + + return body; +} + function buildUserMessage(text, { selectedChatSkill = null } = {}) { const metadata = { userVisible: true, displayText: text }; if (selectedChatSkill) { @@ -88,13 +128,21 @@ function buildUserMessage(text, { selectedChatSkill = null } = {}) { }; } -export async function createAgentRun(baseUrl, cookie, { message, sessionId = null, selectedChatSkill = null }) { +export async function createAgentRun(baseUrl, cookie, { + message, + sessionId = null, + selectedChatSkill = null, + selectedAssetIds = null, +}) { const requestId = crypto.randomUUID(); const body = { request_id: requestId, user_message: buildUserMessage(message, { selectedChatSkill }), }; if (sessionId) body.session_id = sessionId; + if (Array.isArray(selectedAssetIds) && selectedAssetIds.length > 0) { + body.selected_asset_ids = selectedAssetIds; + } const response = await fetch(`${baseUrl}/api/agent/runs`, { method: 'POST', diff --git a/server.mjs b/server.mjs index 276f45f..364cea7 100644 --- a/server.mjs +++ b/server.mjs @@ -25,6 +25,7 @@ import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from '. import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs'; import { isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs'; import { + clearUserLogoutCookies, clearUserSessionCookie, createUserAuth, resolveCookieDomainForRequest, @@ -195,6 +196,7 @@ import { createPageDataService } from './page-data-service.mjs'; import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs'; import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs'; import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs'; +import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs'; import { isNativeH5ApiPath } from './policies.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -424,6 +426,7 @@ async function bootstrapUserAuth() { maxFileBytes: mindSpaceServerRuntime.maxFileBytes, publicPageLimit: mindSpaceServerRuntime.publicPageLimit, resolveUserIdForAgentSession, + resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId), resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId), registerPublicHtmlArtifactsForConversation, syncWorkspaceAssetsEnabled: WORKSPACE_MAINTENANCE_ENABLED, @@ -793,10 +796,13 @@ function setUserLoginCookies(res, req, token) { } function clearUserLoginCookies(res, req) { - res.set( - 'Set-Cookie', - clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)), - ); + const secure = isSecureRequest(req); + const domain = resolveCookieDomainForRequest(req); + const cookies = clearUserLogoutCookies(secure, domain); + if (legacyAuth) { + cookies.push(clearSessionCookie(secure)); + } + res.set('Set-Cookie', cookies); } async function attachUserSession(req, _res, next) { @@ -1245,14 +1251,14 @@ app.get('/auth/me', async (req, res) => { app.post('/auth/logout', async (req, res) => { await userAuthReady; - const secure = isSecureRequest(req); if (userAuth) { await userAuth.revoke(userToken(req)); - clearUserLoginCookies(res, req); } if (legacyAuth) { legacyAuth.revoke(legacySessionToken(req)); - res.set('Set-Cookie', clearSessionCookie(secure)); + } + if (userAuth || legacyAuth) { + clearUserLoginCookies(res, req); } res.status(204).end(); }); @@ -1857,6 +1863,7 @@ app.use('/wiki-api', wikiApi); const api = express.Router(); api.use(jsonUnlessMultipart); +attachShenmeiOpinionFormRoutes(api, { rootDir: __dirname }); api.use(async (req, res, next) => { await userAuthReady; @@ -1864,6 +1871,7 @@ api.use(async (req, res, next) => { if (req.path.startsWith('/internal/agent/')) return next(); if (req.path === '/agent/mindspace_page_patch') return next(); if (req.path === '/agent/mindspace_asset_delete') return next(); + if (req.path === '/agent/mindspace_asset_download') return next(); if (req.path === '/config/blocked-words') return next(); if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { return next(); @@ -4036,6 +4044,32 @@ api.post('/agent/mindspace_asset_delete', async (req, res) => { } }); +api.post('/agent/mindspace_asset_download', async (req, res) => { + if (!mindSpaceAssetAgent) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await mindSpaceAssetAgent.applyAgentDownload(req.body ?? {}); + await mindSpaceAudit?.write({ + userId: result.userId ?? null, + action: 'asset.download', + objectType: 'asset', + objectId: result.assetId, + ip: req.ip, + metadata: { via: 'agent', relativePath: result.relativePath ?? null }, + }); + return sendData(res, req, result); + } catch (error) { + if (error?.code === 'forbidden') { + return sendError(res, req, 403, error.code, error.message); + } + if (error?.code === 'invalid_request' || error?.code === 'asset_not_found') { + return sendError(res, req, 400, error.code, error.message); + } + return mindSpaceError(res, req, error); + } +}); + api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { @@ -4684,7 +4718,7 @@ api.post('/agent/runs', async (req, res, next) => { [ tkmindProxy.requireUser, tkmindProxy.ensureChatAllowed, - createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway }), + createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway, mindSpaceAssetAgent }), ], req, res, diff --git a/skills/web/SKILL.md b/skills/web/SKILL.md index 057c3c6..375b537 100644 --- a/skills/web/SKILL.md +++ b/skills/web/SKILL.md @@ -30,5 +30,6 @@ description: 网页抓取与搜索技能:访问网页、查阅文档、搜索 ## 国内网络环境(建议) - 生产环境访问不了 `google.com`,优先用本技能的 `web_search`(DuckDuckGo)/`fetch_url`,避免直接拿 `computercontroller__web_scrape` 抓 Google 页面来回重试 -- `web_search` 连续几次没有可用结果时,改用 `fetch_url` 直接访问 `https://cn.bing.com/search?q=...` 或 `https://www.so.com/s?q=...` 这类国内可达的搜索入口 +- **`web_search` 最多 2 次**(可换关键词);若仍无可用结果,**再 1 次**用 `fetch_url` 访问 `https://cn.bing.com/search?q=...` 或 `https://www.so.com/s?q=...` +- **3 轮搜索后仍无结果**:停止搜索,用内置知识直接生成页面/回答,并说明未获取实时搜索结果 - 百度/知乎/大众点评等站点有反爬拦截,遇到跳转或空结果就换个搜索源,不必在同一个来源上反复硬抓 diff --git a/src/api/client.ts b/src/api/client.ts index b6e723a..b8ec14d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2534,8 +2534,7 @@ export async function testLlmProviderKey( } export async function logout(): Promise { - if (import.meta.env.DEV) return; - await fetch('/auth/logout', { method: 'POST' }); + await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin' }); } export async function resumeSession( @@ -2658,6 +2657,9 @@ export async function createAgentRun( ...(options.toolMode ? { tool_mode: options.toolMode } : {}), ...(options.taskType ? { task_type: options.taskType } : {}), ...(options.forceDeepReasoning ? { force_deep_reasoning: true } : {}), + ...(options.selectedAssetIds?.length + ? { selected_asset_ids: options.selectedAssetIds } + : {}), }), }, { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, diff --git a/src/hooks/usePageEditSubChat.ts b/src/hooks/usePageEditSubChat.ts index 69afde6..3064ed6 100644 --- a/src/hooks/usePageEditSubChat.ts +++ b/src/hooks/usePageEditSubChat.ts @@ -344,21 +344,31 @@ export function usePageEditSubChat({ setPendingTool(null); try { + const runOptions = resolveAgentRunOptions(trimmed, { + taskType: 'page_edit_code_task', + forceCode: true, + userId: user?.id ?? null, + requestId, + mindspaceContext: context, + pageEdit: { + pageId, + pageTitle, + }, + }); const createdRun = await createAgentRun( currentSession.id, requestId, userMessage, - resolveAgentRunOptions(trimmed, { - taskType: 'page_edit_code_task', - forceCode: true, - userId: user?.id ?? null, - requestId, - mindspaceContext: context, - pageEdit: { - pageId, - pageTitle, - }, - }), + { + ...runOptions, + ...(context.selectedAssets?.length + ? { + selectedAssetIds: context.selectedAssets + .map((asset) => asset.id) + .filter(Boolean), + } + : {}), + }, ); const finishedRun = createdRun.status === 'succeeded' ? createdRun : await waitForAgentRun(createdRun.id); diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 7adda6a..a1785ae 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -8,6 +8,7 @@ import { confirmTool, createAgentRun, deleteChatSession, + getAgentRun, getMindSpace, getMe, listNotifications, @@ -108,6 +109,7 @@ async function waitForAgentRun(runId: string): Promise { } const DIRECT_CHAT_SESSION_POLL_MS = 600; +const AGENT_RUN_WAIT_TIMEOUT_MS = 16 * 60 * 1000; async function waitForAgentRunWithDirectChatPreview( runId: string, @@ -119,7 +121,22 @@ async function waitForAgentRunWithDirectChatPreview( ): Promise { return await new Promise((resolve, reject) => { let pollTimer: number | null = null; + let waitTimer: number | null = null; let pollingSessionId: string | null = null; + let settled = false; + let unsubscribe = () => {}; + + const settle = (action: () => void) => { + if (settled) return; + settled = true; + stopPoll(); + if (waitTimer != null) { + window.clearTimeout(waitTimer); + waitTimer = null; + } + unsubscribe(); + action(); + }; const stopPoll = () => { if (pollTimer != null) { @@ -150,7 +167,7 @@ async function waitForAgentRunWithDirectChatPreview( }, DIRECT_CHAT_SESSION_POLL_MS); }; - const unsubscribe = subscribeAgentRunEvents( + unsubscribe = subscribeAgentRunEvents( runId, (run) => { if (run.sessionId) { @@ -160,23 +177,41 @@ async function waitForAgentRunWithDirectChatPreview( } } if (run.status === 'succeeded') { - stopPoll(); - unsubscribe(); - resolve(run); + settle(() => resolve(run)); return; } if (run.status === 'failed') { - stopPoll(); - unsubscribe(); - reject(new Error(run.error || '后台任务失败,请稍后重试')); + settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试'))); } }, (error) => { - stopPoll(); - unsubscribe(); - reject(error); + settle(() => reject(error)); }, ); + + waitTimer = window.setTimeout(() => { + void getAgentRun(runId) + .then((run) => { + if (run.status === 'succeeded') { + settle(() => resolve(run)); + return; + } + if (run.status === 'failed') { + settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试'))); + return; + } + settle(() => + reject( + new Error('后台任务耗时较长,已停止等待;页面若已生成可在「我的页面」查看'), + ), + ); + }) + .catch((error) => { + settle(() => + reject(error instanceof Error ? error : new Error(String(error))), + ); + }); + }, AGENT_RUN_WAIT_TIMEOUT_MS); }); } @@ -1347,6 +1382,13 @@ export function useTKMindChat( { ...runOptions, ...(options?.forceDeepReasoning ? { forceDeepReasoning: true } : {}), + ...(options?.mindspaceContext?.selectedAssets?.length + ? { + selectedAssetIds: options.mindspaceContext.selectedAssets + .map((asset) => asset.id) + .filter(Boolean), + } + : {}), }, ); const runSessionId = createdRun.sessionId ?? activeSessionId; diff --git a/src/utils/agentRunMode.ts b/src/utils/agentRunMode.ts index 182266d..6061474 100644 --- a/src/utils/agentRunMode.ts +++ b/src/utils/agentRunMode.ts @@ -6,6 +6,7 @@ export type AgentRunCreateOptions = { forceDeepReasoning?: boolean; validation?: AgentRunValidation | null; validationInstruction?: string | null; + selectedAssetIds?: string[]; }; export type AgentRunValidationFile = { diff --git a/user-auth.mjs b/user-auth.mjs index f54b4d7..8b7e3b8 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -2973,6 +2973,15 @@ export function clearUserSessionCookie(secure, domain = resolveCookieDomain()) { return buildUserSessionCookie('', secure, { domain, maxAge: 0 }); } +/** Mirror userLoginCookies: clear shared-domain and legacy host-only session cookies. */ +export function clearUserLogoutCookies(secure, domain = resolveCookieDomain()) { + const cookies = [clearUserSessionCookie(secure, domain)]; + if (domain) { + cookies.push(clearUserSessionCookie(secure, null)); + } + return cookies; +} + /** Set shared-domain session and drop legacy host-only cookie from before H5_COOKIE_DOMAIN. */ export function userLoginCookies(token, secure, domain = resolveCookieDomain()) { const cookies = [userSessionCookie(token, secure, domain)]; diff --git a/user-auth.test.mjs b/user-auth.test.mjs index 6a7db89..fb1edb1 100644 --- a/user-auth.test.mjs +++ b/user-auth.test.mjs @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2'; -import { createUserAuth } from './user-auth.mjs'; +import { createUserAuth, clearUserLogoutCookies, userLoginCookies } from './user-auth.mjs'; function hashPasswordPbkdf2(password, salt) { return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex'); @@ -728,3 +728,22 @@ test('upsertWechatAgentRoute uses atomic insert-on-duplicate semantics', async ( assert.match(calls[0][0], /ON DUPLICATE KEY UPDATE/); assert.equal(calls.some(([sql]) => /UPDATE h5_wechat_agent_routes/.test(sql)), false); }); + +test('clearUserLogoutCookies clears shared-domain and host-only cookies', () => { + const cookies = clearUserLogoutCookies(false, '.tkmind.cn'); + assert.equal(cookies.length, 2); + assert.match(cookies[0], /tkmind_user_session=/); + assert.match(cookies[0], /Domain=\.tkmind\.cn/); + assert.match(cookies[0], /Max-Age=0/); + assert.match(cookies[1], /Max-Age=0/); + assert.doesNotMatch(cookies[1], /Domain=/); +}); + +test('userLoginCookies and clearUserLogoutCookies are symmetric for shared domain', () => { + const login = userLoginCookies('token-abc', true, '.tkmind.cn'); + const logout = clearUserLogoutCookies(true, '.tkmind.cn'); + assert.equal(login.length, 2); + assert.equal(logout.length, 2); + assert.match(login[1], /Max-Age=0/); + assert.match(logout[1], /Max-Age=0/); +}); diff --git a/user-publish.mjs b/user-publish.mjs index a700d9c..31ae257 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -358,6 +358,14 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools '- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段;按本会话给出的公网前缀模板拼接真实地址', '- 按需下载附件:Word 用 `generate_docx` 写入 `public/*.docx`,相对链接文件必须已在 `public/` 落盘,basename 与 HTML 一致', '- 按需长图附件:用 `generate_long_image` 写入 `public/*.long.png`,不要把 `.thumbnail.svg` 当成长图', + '', + '## sandbox-fs / MCP 稳定性', + '- `private_data_bind_workspace_page` 等 sandbox-fs 工具若返回 **Transport closed**,最多再试 **1 次**', + '- 连续失败后:若 `public/*.html` 已 write_file 落盘,改走 Portal/H5 API 或 shell 发布兜底;**禁止**反复 kill MCP 或 curl 401 的浏览器下载接口', + '', + '## 网页搜索上限', + '- `web_search` 最多 **2 次**(可换关键词);再 **1 次** `fetch_url` 访问 Bing/360 搜索页', + '- 3 轮仍无可用结果:停止搜索,用内置知识直接生成页面,并告知用户未获取实时搜索结果', ); return lines.join('\n'); } @@ -372,6 +380,8 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish `- 当前用户 **${addressName}** 的 Agent 工作区(唯一文件根目录):\`${publishDir}\``, '- 用户上传落在分区子目录:`oa/`、`private/`、`public/`(均在上述工作区内)', `- 公网 HTML 前缀(公开区页面):\`${buildPublicUrl(publicBaseUrl, slug, `${PUBLIC_ZONE_DIR}/`)}\``, + '- **勾选 OA 资料**:优先读工作区 `oa/文件名`;**禁止** curl 浏览器登录接口 `/api/mindspace/v1/assets/:id/download`(Agent 无 cookie 会 401)', + '- 工作区无副本时:POST `/api/agent/mindspace_asset_download`(body: session_id + asset_id)落盘后再 `read_file`', '- **查找文件**:在工作区内对应分区搜索(如 `oa/2025-12-06T13-34_export.csv`),不要搜其它用户目录或公网 URL', '- **禁止**:访问 `assets/` 内部路径、其它用户目录、主机绝对路径;禁止用公网 URL 列目录或读 CSV', '- **路径规则**:只用相对路径;禁止 `../`;工作区外的路径会被系统拒绝(OS 层强制,非软约束)', @@ -388,6 +398,8 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish '- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`,按模板拼真实地址', '- 按需下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致;Word 必须用 sandbox-fs `generate_docx` 生成,不要用 `computercontroller` / shell 作为交付依据', '- 按需长图链接:生成 `report.long.png` 后给 `[长图预览](.../report.long.png)`,下载用 `[下载长图](.../report.html?download=long-image)`', + '- **sandbox-fs Transport closed**:`private_data_bind_workspace_page` 等 MCP 工具若返回 Transport closed,**最多重试 1 次**;仍失败则改走 Portal API / shell 发布兜底,**禁止**无限 kill/restart MCP', + '- **网页搜索**:`web_search` 最多 2 次;再试 1 次 Bing/360 `fetch_url`;仍无结果则用内置知识直接生成页面并说明未获取实时搜索结果', `- 发布技能:\`${PUBLISH_SKILL_NAME}\`(生成页面前应 load_skill)`, ].join('\n'); }