diff --git a/.env.example b/.env.example index 9d38c5d..2af5fc3 100644 --- a/.env.example +++ b/.env.example @@ -525,11 +525,11 @@ MEMIND_RUNTIME_PROFILE=local # MEMIND_CURSOR_HELP_AGENT_BIN=/Users/john/.local/bin/agent # MEMIND_CURSOR_HELP_AUTO_GOOSE_REMEDIATE=1 -# Track C: Goose 编排 + Cursor 作为 code executor(MindSpace 落盘,推荐 PoC) -# MEMIND_CURSOR_EXECUTOR_ENABLED=1 +# Track C: TKMind 智趣(Cursor)作为 code executor — 仅对白名单用户生效(memindadm「智趣体验通道」) +# MEMIND_CURSOR_EXECUTOR_ENABLED=1 # 服务端 Cursor CLI 基础设施开关 # MEMIND_AIDER_SKILL_USE_CURSOR=1 -# 默认将页面生成、问卷(Page Data)、Excel 分析路由到 Cursor 执行 -# MEMIND_CURSOR_PAGE_TASKS_DEFAULT=1 +# 已废弃全员路由:页面/问卷/Excel 是否走 Cursor 由 memindadm 白名单控制,不再靠 MEMIND_CURSOR_PAGE_TASKS_DEFAULT +# MEMIND_CURSOR_PAGE_TASKS_DEFAULT=0 # 已废弃:不再把普通 Agent 编排全量切到 Cursor;简单对话走 DeepSeek direct_chat # MEMIND_CURSOR_AGENT_TASKS_DEFAULT=0 # MEMIND_CURSOR_DEEPSEEK_FALLBACK=1 diff --git a/admin-routes.mjs b/admin-routes.mjs index 7bca4b9..ba5ad0a 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -955,6 +955,23 @@ export function createAdminApi({ adminApi.put('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig); adminApi.patch('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig); + adminApi.get('/cursor-executor-channel/config', requireAdmin, async (_req, res) => { + if (!wechatCursorExecutorPolicyService?.getAdminConfig) { + return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' }); + } + return res.json(await wechatCursorExecutorPolicyService.getAdminConfig()); + }); + + adminApi.get('/cursor-executor-channel/runtime', requireAdmin, async (_req, res) => { + if (!wechatCursorExecutorPolicyService?.getRuntimeState) { + return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' }); + } + return res.json(await wechatCursorExecutorPolicyService.getRuntimeState()); + }); + + adminApi.put('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig); + adminApi.patch('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig); + adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); res.json({ catalog: llmProviderService.catalog }); diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 0a21705..770cc20 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -25,6 +25,10 @@ import { wrapRunStreamPayload, writeSseErrorAndEnd } from './sse-event-taxonomy. import { resolveGoalBindingForAgentRun } from './goal-run-resolve.mjs'; import { enforcePageGenerationCursorRuntime } from './cursor-page-routing.mjs'; import { resolvePreferredCodeExecutor } from './cursor-agent-launch.mjs'; +import { + CURSOR_EXECUTOR_CHANNEL, + resolveCursorChannelEligible, +} from './wechat-cursor-executor-policy.mjs'; function envFlag(value) { return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()); @@ -188,6 +192,7 @@ export function createPostAgentRunsHandler({ agentRunGateway, mindSpaceAssetAgent = null, codeRunPolicyService = null, + cursorExecutorPolicyService = null, goalRunService = null, chatIntentRouter = null, templateCatalogService = null, @@ -259,12 +264,30 @@ export function createPostAgentRunsHandler({ let requiredExecutor = selectedSkillRuntime.requiredExecutor ?? null; let requiredReviewExecutor = selectedSkillRuntime.requiredReviewExecutor ?? null; + let cursorChannelEligible = false; + if (cursorExecutorPolicyService?.getEffectivePolicy) { + try { + const cursorPolicy = await cursorExecutorPolicyService.getEffectivePolicy( + request.currentUser.id, + request.currentUser, + ); + cursorChannelEligible = resolveCursorChannelEligible({ + user: request.currentUser, + channel: CURSOR_EXECUTOR_CHANNEL.H5, + policy: cursorPolicy, + }); + } catch { + cursorChannelEligible = false; + } + } + if (!requiredExecutor && !requiredReviewExecutor) { const pageCursorRuntime = enforcePageGenerationCursorRuntime(userMessage, { rawToolMode, taskType, forceDeepReasoning, env: process.env, + channelEligible: cursorChannelEligible, }); userMessage = pageCursorRuntime.userMessage; rawToolMode = pageCursorRuntime.rawToolMode; @@ -307,29 +330,36 @@ export function createPostAgentRunsHandler({ const policyTaskType = requiredReviewExecutor ? 'h5_chat_code_task' : taskType; - if (!codeRunPolicy.enabled) { - response.status(403).json({ message: '代码任务灰度未开启' }); - return; - } - if (!codeRunPolicy.userAllowed) { - response.status(403).json({ message: '当前用户未开启代码任务灰度' }); - return; - } - const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? []; - if ( - taskTypeAllowlist.length > 0 && - ( - !policyTaskType || - !taskTypeAllowlist - .map((item) => String(item).toLowerCase()) - .includes(policyTaskType.toLowerCase()) - ) - ) { - response.status(403).json({ message: '当前代码任务类型未开启灰度' }); - return; + const isCursorChannelRun = cursorChannelEligible && ( + requiredExecutor === 'cursor' + || userMessage?.metadata?.memindRun?.pageCursorDefault === true + ); + if (!isCursorChannelRun) { + if (!codeRunPolicy.enabled) { + response.status(403).json({ message: '代码任务灰度未开启' }); + return; + } + if (!codeRunPolicy.userAllowed) { + response.status(403).json({ message: '当前用户未开启代码任务灰度' }); + return; + } + const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? []; + if ( + taskTypeAllowlist.length > 0 && + ( + !policyTaskType || + !taskTypeAllowlist + .map((item) => String(item).toLowerCase()) + .includes(policyTaskType.toLowerCase()) + ) + ) { + response.status(403).json({ message: '当前代码任务类型未开启灰度' }); + return; + } } if ( - codeRunPolicy.requireValidation + !isCursorChannelRun + && codeRunPolicy.requireValidation && !hasExpectedFileValidation(userMessage) && !userMessage?.metadata?.memindRun?.pageCursorDefault && !userMessage?.metadata?.memindRun?.cursorAgentDefault diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index d982315..86fb611 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -1091,3 +1091,128 @@ test('POST /agent/runs materializes selected assets before creating run', async assetIds: ['asset-9'], }]); }); + +test('POST /agent/runs keeps non-allowlisted H5 users on existing chat flow for page tasks', async () => { + const created = []; + const prevCursorEnabled = process.env.MEMIND_CURSOR_EXECUTOR_ENABLED; + const prevUseCursor = process.env.MEMIND_AIDER_SKILL_USE_CURSOR; + process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = '1'; + process.env.MEMIND_AIDER_SKILL_USE_CURSOR = '1'; + try { + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return true; + }, + }, + cursorExecutorPolicyService: { + async getEffectivePolicy(userId) { + return { + enabled: true, + userAllowlist: ['other-user'], + channelAllowlist: ['h5', 'wechat_mp'], + intentAllowlist: ['page.generate'], + userAllowed: userId === 'other-user', + }; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + created.push({ userId, payload }); + return { id: 'run-chat-1', status: 'queued' }; + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1', username: 'john' }, + body: { + session_id: 'session-1', + request_id: 'req-page-1', + user_message: { + role: 'user', + content: [{ type: 'text', text: '帮我写首诗,并做成页面' }], + metadata: { displayText: '帮我写首诗,并做成页面' }, + }, + }, + }, + res, + ); + + assert.equal(res.statusCode, 202); + assert.equal(created[0].payload.toolMode, 'chat'); + assert.equal(created[0].payload.taskType, null); + assert.notEqual(created[0].payload.userMessage?.metadata?.memindRun?.executor, 'cursor'); + assert.notEqual(created[0].payload.userMessage?.metadata?.memindRun?.pageCursorDefault, true); + } finally { + if (prevCursorEnabled == null) delete process.env.MEMIND_CURSOR_EXECUTOR_ENABLED; + else process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = prevCursorEnabled; + if (prevUseCursor == null) delete process.env.MEMIND_AIDER_SKILL_USE_CURSOR; + else process.env.MEMIND_AIDER_SKILL_USE_CURSOR = prevUseCursor; + } +}); + +test('POST /agent/runs routes allowlisted H5 users into cursor channel for page tasks', async () => { + const created = []; + const prevCursorEnabled = process.env.MEMIND_CURSOR_EXECUTOR_ENABLED; + const prevUseCursor = process.env.MEMIND_AIDER_SKILL_USE_CURSOR; + process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = '1'; + process.env.MEMIND_AIDER_SKILL_USE_CURSOR = '1'; + try { + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return true; + }, + }, + cursorExecutorPolicyService: { + async getEffectivePolicy(userId) { + return { + enabled: true, + userAllowlist: ['user-1'], + channelAllowlist: ['h5'], + intentAllowlist: ['page.generate'], + userAllowed: userId === 'user-1', + }; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + created.push({ userId, payload }); + return { id: 'run-cursor-1', status: 'queued' }; + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1', username: 'john' }, + body: { + session_id: 'session-1', + request_id: 'req-page-2', + user_message: { + role: 'user', + content: [{ type: 'text', text: '帮我写首诗,并做成页面' }], + metadata: { displayText: '帮我写首诗,并做成页面' }, + }, + }, + }, + res, + ); + + assert.equal(res.statusCode, 202); + assert.equal(created[0].payload.toolMode, 'code'); + assert.equal(created[0].payload.taskType, 'h5_chat_code_task'); + assert.equal(created[0].payload.userMessage?.metadata?.memindRun?.executor, 'cursor'); + assert.equal(created[0].payload.userMessage?.metadata?.memindRun?.pageCursorDefault, true); + assert.equal(created[0].payload.userMessage?.metadata?.memindRun?.cursorChannel, true); + } finally { + if (prevCursorEnabled == null) delete process.env.MEMIND_CURSOR_EXECUTOR_ENABLED; + else process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = prevCursorEnabled; + if (prevUseCursor == null) delete process.env.MEMIND_AIDER_SKILL_USE_CURSOR; + else process.env.MEMIND_AIDER_SKILL_USE_CURSOR = prevUseCursor; + } +}); diff --git a/cursor-agent-launch.mjs b/cursor-agent-launch.mjs index 679c278..04d9e1c 100644 --- a/cursor-agent-launch.mjs +++ b/cursor-agent-launch.mjs @@ -27,8 +27,9 @@ export function resolvePreferredCodeExecutor(env = process.env) { } export function cursorPageTasksDefaultEnabled(env = process.env) { + // 已废弃:页面任务是否走 Cursor 由 memindadm「智趣体验通道」白名单控制,不再靠此 env 全员开启。 if (!cursorExecutorEnabled(env)) return false; - return envFlag(env.MEMIND_CURSOR_PAGE_TASKS_DEFAULT, true); + return envFlag(env.MEMIND_CURSOR_PAGE_TASKS_DEFAULT, false); } /** @deprecated 普通 Agent 编排不再默认走 Cursor;仅页面/问卷/Excel 等代码任务使用 enforcePageGenerationCursorRuntime */ diff --git a/cursor-page-routing.mjs b/cursor-page-routing.mjs index aaf8152..f64088b 100644 --- a/cursor-page-routing.mjs +++ b/cursor-page-routing.mjs @@ -11,6 +11,7 @@ import { } from './chat-skills.mjs'; import { cursorAgentTasksDefaultEnabled, + cursorExecutorEnabled, cursorPageTasksDefaultEnabled, resolvePreferredCodeExecutor, } from './cursor-agent-launch.mjs'; @@ -61,8 +62,10 @@ export function resolveCursorTaskKind(taskText, skill = '') { export function isPageCursorDefaultCandidate(userMessage, { rawToolMode = 'chat', env = process.env, + channelEligible = false, } = {}) { - if (!cursorPageTasksDefaultEnabled(env)) return false; + if (!cursorExecutorEnabled(env)) return false; + if (!channelEligible) return false; if (String(rawToolMode ?? '').trim().toLowerCase() === 'code') return false; const skill = selectedChatSkill(userMessage); @@ -178,8 +181,9 @@ export function enforcePageGenerationCursorRuntime(userMessage, { taskType = null, forceDeepReasoning = false, env = process.env, + channelEligible = false, } = {}) { - if (!isPageCursorDefaultCandidate(userMessage, { rawToolMode, env })) { + if (!isPageCursorDefaultCandidate(userMessage, { rawToolMode, env, channelEligible })) { return { userMessage, rawToolMode, @@ -216,6 +220,8 @@ export function enforcePageGenerationCursorRuntime(userMessage, { executor: codeExecutor, pageCursorDefault: true, cursorTaskKind: taskKind, + cursorChannel: true, + channel: 'h5', suggestedDelivery: resolveCursorSuggestedDelivery(taskKind), selectedChatSkill: resolveCursorTaskSkill(taskKind, runMetadata.selectedChatSkill), }; diff --git a/cursor-page-routing.test.mjs b/cursor-page-routing.test.mjs index f2fa44b..301148d 100644 --- a/cursor-page-routing.test.mjs +++ b/cursor-page-routing.test.mjs @@ -18,7 +18,7 @@ test('page cursor default matches poem page requests', () => { content: [{ type: 'text', text: '帮我写首诗,并做成页面' }], metadata: { displayText: '帮我写首诗,并做成页面', userVisible: true }, }; - assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true); + assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), true); assert.equal(resolveCursorTaskKind('帮我写首诗,并做成页面'), 'page_generation'); }); @@ -28,7 +28,7 @@ test('page cursor default matches survey page-data requests', () => { content: [{ type: 'text', text: '帮我做一个调查问卷,结果存 PG' }], metadata: { displayText: '帮我做一个调查问卷,结果存 PG' }, }; - assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true); + assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), true); assert.equal(resolveCursorTaskKind('帮我做一个调查问卷,结果存 PG'), 'page_data'); }); @@ -38,7 +38,7 @@ test('page cursor default matches excel analysis requests', () => { content: [{ type: 'text', text: '帮我分析这个 Excel 表格的数据趋势' }], metadata: { displayText: '帮我分析这个 Excel 表格的数据趋势' }, }; - assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true); + assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), true); assert.equal(resolveCursorTaskKind('帮我分析这个 Excel 表格的数据趋势'), 'excel_analysis'); }); @@ -48,7 +48,7 @@ test('page cursor default skips generic empty page requests', () => { content: [{ type: 'text', text: '帮我做一个页面吧' }], metadata: { displayText: '帮我做一个页面吧' }, }; - assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), false); + assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), false); }); test('page cursor default skips page-data dev repair requests', () => { @@ -57,7 +57,7 @@ test('page cursor default skips page-data dev repair requests', () => { content: [{ type: 'text', text: '问卷页面提交失败,帮我排查 Page Data 绑定' }], metadata: { displayText: '问卷页面提交失败,帮我排查 Page Data 绑定' }, }; - assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), false); + assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: true }), false); }); test('enforcePageGenerationCursorRuntime switches page generation to code cursor executor', () => { @@ -66,7 +66,7 @@ test('enforcePageGenerationCursorRuntime switches page generation to code cursor content: [{ type: 'text', text: '帮我写首诗,并做成页面' }], metadata: { displayText: '帮我写首诗,并做成页面' }, }; - const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv }); + const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv, channelEligible: true }); assert.equal(enforced.rawToolMode, 'code'); assert.equal(enforced.taskType, 'h5_chat_code_task'); assert.equal(enforced.requiredExecutor, 'cursor'); @@ -86,7 +86,7 @@ test('enforcePageGenerationCursorRuntime switches survey tasks to cursor page-da content: [{ type: 'text', text: '帮我做一个调查问卷,结果存 PG' }], metadata: { displayText: '帮我做一个调查问卷,结果存 PG' }, }; - const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv }); + const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv, channelEligible: true }); assert.equal(enforced.rawToolMode, 'code'); assert.equal(enforced.requiredExecutor, 'cursor'); assert.equal(enforced.userMessage.metadata.memindRun.cursorTaskKind, 'page_data'); @@ -102,7 +102,7 @@ test('enforcePageGenerationCursorRuntime switches excel analysis to cursor flow' content: [{ type: 'text', text: '帮我分析这个 Excel 表格的数据趋势' }], metadata: { displayText: '帮我分析这个 Excel 表格的数据趋势' }, }; - const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv }); + const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv, channelEligible: true }); assert.equal(enforced.rawToolMode, 'code'); assert.equal(enforced.requiredExecutor, 'cursor'); assert.equal(enforced.userMessage.metadata.memindRun.cursorTaskKind, 'excel_analysis'); @@ -110,6 +110,15 @@ test('enforcePageGenerationCursorRuntime switches excel analysis to cursor flow' assert.match(enforced.userMessage.content[0].text, /Excel analysis task via TKMind 智趣 executor/); }); +test('page cursor default is disabled without channelEligible even when executor env is on', () => { + const userMessage = { + role: 'user', + content: [{ type: 'text', text: '帮我写首诗,并做成页面' }], + metadata: { displayText: '帮我写首诗,并做成页面' }, + }; + assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv, channelEligible: false }), false); +}); + test('page cursor default is disabled without env flag', () => { const userMessage = { role: 'user', diff --git a/server.mjs b/server.mjs index 274d21c..c608b95 100644 --- a/server.mjs +++ b/server.mjs @@ -1407,6 +1407,7 @@ attachPortalAgentRuntimeRoutes(api, { getSessionAccess: () => sessionAccess, getMindSpaceAssetAgent: () => mindSpaceAssetAgent, getCodeRunPolicyService: () => agentCodeRunPolicyService, + getCursorExecutorPolicyService: () => wechatCursorExecutorPolicyService, getUserToken: userToken, getDeepSearchInternalSecret: () => DEEP_SEARCH_INTERNAL_SECRET, diff --git a/server/portal-agent-runtime-routes.mjs b/server/portal-agent-runtime-routes.mjs index 0efbb54..1cfdc50 100644 --- a/server/portal-agent-runtime-routes.mjs +++ b/server/portal-agent-runtime-routes.mjs @@ -42,6 +42,7 @@ export function attachPortalAgentRuntimeRoutes( getSessionAccess = () => null, getMindSpaceAssetAgent = () => null, getCodeRunPolicyService = () => null, + getCursorExecutorPolicyService = () => null, getUserToken = () => null, getDeepSearchInternalSecret = () => null, bearerToken = () => null, @@ -133,6 +134,7 @@ export function attachPortalAgentRuntimeRoutes( agentRunGateway, mindSpaceAssetAgent: getMindSpaceAssetAgent(), codeRunPolicyService: getCodeRunPolicyService(), + cursorExecutorPolicyService: getCursorExecutorPolicyService(), goalRunService: getGoalRunService(), chatIntentRouter: getChatIntentRouter(), templateCatalogService: getTemplateCatalog(), diff --git a/wechat-cursor-agent-run.mjs b/wechat-cursor-agent-run.mjs index ef2e84a..4a9cd96 100644 --- a/wechat-cursor-agent-run.mjs +++ b/wechat-cursor-agent-run.mjs @@ -65,6 +65,7 @@ export async function executeWechatCursorAgentRun({ rawToolMode: 'code', taskType: 'wechat_page_generate', env: process.env, + channelEligible: true, }); userMessage = cursorRuntime.userMessage; diff --git a/wechat-cursor-executor-admin-config.mjs b/wechat-cursor-executor-admin-config.mjs index 41089f3..742586d 100644 --- a/wechat-cursor-executor-admin-config.mjs +++ b/wechat-cursor-executor-admin-config.mjs @@ -4,11 +4,18 @@ const POLICY_SOURCE_DEFAULT = 'default'; const POLICY_SOURCE_ADMIN_DB = 'admin-db'; const DEFAULT_INTENT_ALLOWLIST = Object.freeze(['page.generate']); +const DEFAULT_CHANNEL_ALLOWLIST = Object.freeze(['h5', 'wechat_mp']); + +export const CURSOR_EXECUTOR_CHANNEL = Object.freeze({ + H5: 'h5', + WECHAT_MP: 'wechat_mp', +}); function defaultConfigShape() { return { enabled: false, userAllowlist: [], + channelAllowlist: [...DEFAULT_CHANNEL_ALLOWLIST], intentAllowlist: [...DEFAULT_INTENT_ALLOWLIST], fallbackToDeepseek: true, meta: { @@ -58,6 +65,10 @@ function mergePatch(currentConfig, patch = {}) { const next = cloneConfig(currentConfig); if ('enabled' in patch) next.enabled = normalizeBoolean(patch.enabled, false); if ('userAllowlist' in patch) next.userAllowlist = normalizeStringList(patch.userAllowlist); + if ('channelAllowlist' in patch) { + const channels = normalizeStringList(patch.channelAllowlist); + next.channelAllowlist = channels.length ? channels : [...DEFAULT_CHANNEL_ALLOWLIST]; + } if ('intentAllowlist' in patch) { const intents = normalizeStringList(patch.intentAllowlist); next.intentAllowlist = intents.length ? intents : [...DEFAULT_INTENT_ALLOWLIST]; @@ -73,15 +84,26 @@ function mergePatch(currentConfig, patch = {}) { function flattenPolicy(config, source) { const intentAllowlist = normalizeStringList(config.intentAllowlist); + const channelAllowlist = normalizeStringList(config.channelAllowlist); return { source, enabled: Boolean(config.enabled), userAllowlist: normalizeStringList(config.userAllowlist), + channelAllowlist: channelAllowlist.length ? channelAllowlist : [...DEFAULT_CHANNEL_ALLOWLIST], intentAllowlist: intentAllowlist.length ? intentAllowlist : [...DEFAULT_INTENT_ALLOWLIST], fallbackToDeepseek: config.fallbackToDeepseek !== false, }; } +export function isChannelAllowedByCursorPolicy(channel, policy) { + const allowlist = normalizeStringList(policy?.channelAllowlist ?? DEFAULT_CHANNEL_ALLOWLIST) + .map((item) => item.toLowerCase()); + if (allowlist.length === 0) return false; + const normalized = String(channel ?? '').trim().toLowerCase(); + if (!normalized) return false; + return allowlist.includes(normalized); +} + export function isUserAllowedByWechatCursorPolicy(user, policy) { if (!policy?.enabled) return false; const allowlist = normalizeStringList(policy.userAllowlist).map((item) => item.toLowerCase()); diff --git a/wechat-cursor-executor-policy.mjs b/wechat-cursor-executor-policy.mjs index d44f224..e37c99d 100644 --- a/wechat-cursor-executor-policy.mjs +++ b/wechat-cursor-executor-policy.mjs @@ -1,17 +1,41 @@ import { + CURSOR_EXECUTOR_CHANNEL, + isChannelAllowedByCursorPolicy, isIntentAllowedByWechatCursorPolicy, isUserAllowedByWechatCursorPolicy, } from './wechat-cursor-executor-admin-config.mjs'; +export { CURSOR_EXECUTOR_CHANNEL }; + +export function resolveCursorChannelEligible({ + user = null, + userId = null, + channel = '', + intentKind = '', + policy = null, +} = {}) { + if (!policy?.enabled) return false; + const subject = user ?? { userId }; + if (!isUserAllowedByWechatCursorPolicy(subject, policy)) return false; + if (!isChannelAllowedByCursorPolicy(channel, policy)) return false; + const normalizedChannel = String(channel ?? '').trim().toLowerCase(); + if (normalizedChannel === CURSOR_EXECUTOR_CHANNEL.WECHAT_MP) { + if (!isIntentAllowedByWechatCursorPolicy(intentKind, policy)) return false; + } + return true; +} + export function resolveWechatCursorExecutorEligible({ user = null, userId = null, intentKind = '', policy = null, } = {}) { - if (!policy?.enabled) return false; - const subject = user ?? { userId }; - if (!isUserAllowedByWechatCursorPolicy(subject, policy)) return false; - if (!isIntentAllowedByWechatCursorPolicy(intentKind, policy)) return false; - return true; + return resolveCursorChannelEligible({ + user, + userId, + channel: CURSOR_EXECUTOR_CHANNEL.WECHAT_MP, + intentKind, + policy, + }); } diff --git a/wechat-cursor-executor-policy.test.mjs b/wechat-cursor-executor-policy.test.mjs index 5227dc6..18bb5ee 100644 --- a/wechat-cursor-executor-policy.test.mjs +++ b/wechat-cursor-executor-policy.test.mjs @@ -2,10 +2,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createWechatCursorExecutorAdminConfigService, + isChannelAllowedByCursorPolicy, isUserAllowedByWechatCursorPolicy, isIntentAllowedByWechatCursorPolicy, } from './wechat-cursor-executor-admin-config.mjs'; -import { resolveWechatCursorExecutorEligible } from './wechat-cursor-executor-policy.mjs'; +import { + CURSOR_EXECUTOR_CHANNEL, + resolveCursorChannelEligible, + resolveWechatCursorExecutorEligible, +} from './wechat-cursor-executor-policy.mjs'; function createMemoryPool() { const rows = new Map(); @@ -89,3 +94,57 @@ test('isIntentAllowedByWechatCursorPolicy respects allowlist', () => { assert.equal(isIntentAllowedByWechatCursorPolicy('page.generate', policy), true); assert.equal(isIntentAllowedByWechatCursorPolicy('chat.general', policy), false); }); + +test('H5 channel does not require intent allowlist', async () => { + const pool = createMemoryPool(); + const service = createWechatCursorExecutorAdminConfigService(pool); + await service.updateAdminConfig({ + enabled: true, + userAllowlist: ['john-uuid'], + channelAllowlist: ['h5', 'wechat_mp'], + }, { updatedBy: 'admin-1' }); + const policy = await service.getEffectivePolicy('john-uuid', { userId: 'john-uuid' }); + assert.equal( + resolveCursorChannelEligible({ + user: { userId: 'john-uuid' }, + channel: CURSOR_EXECUTOR_CHANNEL.H5, + policy, + }), + true, + ); +}); + +test('H5 channel respects channelAllowlist', async () => { + const pool = createMemoryPool(); + const service = createWechatCursorExecutorAdminConfigService(pool); + await service.updateAdminConfig({ + enabled: true, + userAllowlist: ['john-uuid'], + channelAllowlist: ['wechat_mp'], + }, { updatedBy: 'admin-1' }); + const policy = await service.getEffectivePolicy('john-uuid', { userId: 'john-uuid' }); + assert.equal( + resolveCursorChannelEligible({ + user: { userId: 'john-uuid' }, + channel: CURSOR_EXECUTOR_CHANNEL.H5, + policy, + }), + false, + ); + assert.equal( + resolveCursorChannelEligible({ + user: { userId: 'john-uuid' }, + channel: CURSOR_EXECUTOR_CHANNEL.WECHAT_MP, + intentKind: 'page.generate', + policy, + }), + true, + ); +}); + +test('isChannelAllowedByCursorPolicy defaults to both channels', () => { + const policy = { channelAllowlist: ['h5', 'wechat_mp'] }; + assert.equal(isChannelAllowedByCursorPolicy('h5', policy), true); + assert.equal(isChannelAllowedByCursorPolicy('wechat_mp', policy), true); + assert.equal(isChannelAllowedByCursorPolicy('other', policy), false); +});