diff --git a/.env.example b/.env.example index 0835a6c..76c78e6 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,8 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # Code-tool agent run 灰度开关(默认关闭)。 # 后端必须先开启 MEMIND_AGENT_CODE_RUNS_ENABLED,H5 构建开关才会生效。 # 开启后,页面编辑子聊天会传 tool_mode=code;普通聊天仍需同时开启 AUTODETECT 才会按文本自动识别代码任务。 +# Phase 1.5:memindadm 运行时策略优先于 VITE_;设为 env 可强制只读 env(紧急 override)。 +# MEMIND_CODE_RUN_POLICY_SOURCE=env # MEMIND_AGENT_CODE_RUNS_ENABLED=0 # VITE_AGENT_CODE_RUNS_ENABLED=0 # VITE_AGENT_CODE_RUNS_AUTODETECT=0 diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index c9aa089..fb77fad 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -20,6 +20,7 @@ import { createAssetGatewayConfigService } from './asset-gateway.mjs'; import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; +import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs'; import { createMindSearchConfigService } from './mindsearch-config.mjs'; import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; @@ -111,6 +112,7 @@ export async function createAdminServices(env = {}) { const mindSearchConfigService = createMindSearchConfigService(pool); await mindSearchConfigService.ensureSchema(); const skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root }); + const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env }); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); const adminSystemTestService = createAdminSystemTestService({ pool, @@ -152,6 +154,7 @@ export async function createAdminServices(env = {}) { memoryV2ConfigService, mindSearchConfigService, skillRuntimeConfigService, + agentCodeRunPolicyService, wechatScheduleLlmConfigService, adminSystemTestService, plazaPosts, diff --git a/admin-routes.mjs b/admin-routes.mjs index 2042c56..8af7791 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -32,6 +32,7 @@ function plazaRouteError(res, req, error) { * @param {object|null} deps.llmProviderService * @param {object|null} deps.memoryV2ConfigService * @param {object|null} deps.skillRuntimeConfigService + * @param {object|null} deps.agentCodeRunPolicyService * @param {object|null} deps.adminSystemTestService * @param {object|null} deps.plazaPosts * @param {object|null} deps.plazaOps @@ -48,6 +49,7 @@ export function createAdminApi({ memoryV2ConfigService, mindSearchConfigService, skillRuntimeConfigService, + agentCodeRunPolicyService, adminSystemTestService, plazaPosts, plazaOps, @@ -248,6 +250,40 @@ export function createAdminApi({ return res.json(await skillRuntimeConfigService.getPublicRuntimeConfig()); }); + adminApi.get('/agent-code-run/config', requireAdmin, async (_req, res) => { + if (!agentCodeRunPolicyService?.getAdminConfig) { + return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' }); + } + return res.json(await agentCodeRunPolicyService.getAdminConfig()); + }); + + const updateAgentCodeRunConfig = async (req, res) => { + if (!agentCodeRunPolicyService?.updateAdminConfig) { + return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' }); + } + try { + const result = await agentCodeRunPolicyService.updateAdminConfig(req.body?.config ?? req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + return res.json(result); + } catch (err) { + if (err?.code === 'CODE_RUN_POLICY_ENV_LOCKED') { + return res.status(409).json({ message: err.message }); + } + throw err; + } + }; + + adminApi.put('/agent-code-run/config', requireAdmin, updateAgentCodeRunConfig); + adminApi.patch('/agent-code-run/config', requireAdmin, updateAgentCodeRunConfig); + + adminApi.get('/agent-code-run/runtime', requireAdmin, async (_req, res) => { + if (!agentCodeRunPolicyService?.getRuntimeState) { + return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' }); + } + return res.json(await agentCodeRunPolicyService.getRuntimeState()); + }); + adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => { if (!adminSystemTestService?.runSkillValidation) { return res.status(503).json({ message: '系统测试服务未启用' }); diff --git a/admin-server.mjs b/admin-server.mjs index 059c308..55179cc 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -80,6 +80,8 @@ const CONSOLES = { imageMakeAdminConfigService: services.imageMakeAdminConfigService, memoryV2ConfigService: services.memoryV2ConfigService, mindSearchConfigService: services.mindSearchConfigService, + skillRuntimeConfigService: services.skillRuntimeConfigService, + agentCodeRunPolicyService: services.agentCodeRunPolicyService, adminSystemTestService: services.adminSystemTestService, plazaPosts: services.plazaPosts, plazaOps: services.plazaOps, diff --git a/agent-code-run-admin-config.mjs b/agent-code-run-admin-config.mjs new file mode 100644 index 0000000..e9c037e --- /dev/null +++ b/agent-code-run-admin-config.mjs @@ -0,0 +1,332 @@ +const CONFIG_TABLE = 'h5_agent_code_run_config'; +const CONFIG_SCOPE = 'global'; +const POLICY_SOURCE_ENV = 'env'; +const POLICY_SOURCE_ENV_OVERRIDE = 'env-override'; +const POLICY_SOURCE_ENV_MIGRATION = 'env-migration'; +const POLICY_SOURCE_ADMIN_DB = 'admin-db'; +const POLICY_SOURCE_DEFAULT = 'default'; + +const DEFAULT_TASK_TYPES = Object.freeze([ + 'page_data_dev', + 'h5_chat_code_task', + 'page_edit_code_task', +]); + +function defaultConfigShape() { + return { + codeRun: { + enabled: false, + clientEnabled: false, + generalAutodetect: false, + requireValidation: true, + userAllowlist: [], + taskTypeAllowlist: [...DEFAULT_TASK_TYPES], + }, + pageDataDev: { + autodetect: false, + }, + meta: { + notes: '', + }, + }; +} + +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +function normalizeBoolean(value, fallback = false) { + if (value == null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } + return value.map((item) => String(item ?? '').trim()).filter(Boolean); +} + +function cloneConfig(config = null) { + return structuredClone?.(config ?? defaultConfigShape()) + ?? JSON.parse(JSON.stringify(config ?? defaultConfigShape())); +} + +function parseJsonLike(value, fallback) { + if (value == null || value === '') return fallback; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return fallback; + } + } + if (typeof value === 'object') return value; + return fallback; +} + +function policySourceFromEnv(env = process.env) { + return String(env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env' + ? POLICY_SOURCE_ENV_OVERRIDE + : null; +} + +function buildConfigFromEnv(env = process.env) { + const config = defaultConfigShape(); + config.codeRun.enabled = envFlag(env.MEMIND_AGENT_CODE_RUNS_ENABLED); + config.codeRun.clientEnabled = envFlag(env.VITE_AGENT_CODE_RUNS_ENABLED, config.codeRun.enabled); + config.codeRun.generalAutodetect = envFlag(env.VITE_AGENT_CODE_RUNS_AUTODETECT); + config.codeRun.requireValidation = envFlag(env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION, true); + config.codeRun.userAllowlist = normalizeStringList(env.MEMIND_AGENT_CODE_RUNS_USER_IDS); + const taskTypes = normalizeStringList(env.MEMIND_AGENT_CODE_RUN_TASK_TYPES); + config.codeRun.taskTypeAllowlist = taskTypes.length ? taskTypes : [...DEFAULT_TASK_TYPES]; + config.pageDataDev.autodetect = envFlag(env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT); + return config; +} + +function envPolicyActive(config) { + return Boolean(config?.codeRun?.enabled) + || Boolean(config?.codeRun?.clientEnabled) + || Boolean(config?.pageDataDev?.autodetect) + || (config?.codeRun?.userAllowlist?.length ?? 0) > 0; +} + +function mergePatch(currentConfig, patch = {}) { + const next = cloneConfig(currentConfig); + const codeRunPatch = patch?.codeRun ?? {}; + const pageDataDevPatch = patch?.pageDataDev ?? {}; + + if ('enabled' in codeRunPatch) next.codeRun.enabled = normalizeBoolean(codeRunPatch.enabled, false); + if ('clientEnabled' in codeRunPatch) { + next.codeRun.clientEnabled = normalizeBoolean(codeRunPatch.clientEnabled, false); + } + if ('generalAutodetect' in codeRunPatch) { + next.codeRun.generalAutodetect = normalizeBoolean(codeRunPatch.generalAutodetect, false); + } + if ('requireValidation' in codeRunPatch) { + next.codeRun.requireValidation = normalizeBoolean(codeRunPatch.requireValidation, true); + } + if ('userAllowlist' in codeRunPatch) { + next.codeRun.userAllowlist = normalizeStringList(codeRunPatch.userAllowlist); + } + if ('taskTypeAllowlist' in codeRunPatch) { + const taskTypes = normalizeStringList(codeRunPatch.taskTypeAllowlist); + next.codeRun.taskTypeAllowlist = taskTypes.length ? taskTypes : [...DEFAULT_TASK_TYPES]; + } + if ('autodetect' in pageDataDevPatch) { + next.pageDataDev.autodetect = normalizeBoolean(pageDataDevPatch.autodetect, false); + } + if (patch?.meta && typeof patch.meta.notes === 'string') { + next.meta.notes = patch.meta.notes; + } + return next; +} + +function flattenPolicy(config, source) { + const taskTypeAllowlist = normalizeStringList(config.codeRun.taskTypeAllowlist); + return { + source, + enabled: Boolean(config.codeRun.enabled), + clientEnabled: Boolean(config.codeRun.clientEnabled), + generalAutodetect: Boolean(config.codeRun.generalAutodetect), + requireValidation: Boolean(config.codeRun.requireValidation), + pageDataDevAutodetect: Boolean(config.pageDataDev.autodetect), + userAllowlist: normalizeStringList(config.codeRun.userAllowlist), + taskTypeAllowlist: taskTypeAllowlist.length ? taskTypeAllowlist : [...DEFAULT_TASK_TYPES], + }; +} + +export function isUserAllowedByCodeRunPolicy(userId, policy) { + if (!policy?.enabled) return false; + const allowlist = policy.userAllowlist ?? []; + if (allowlist.length === 0) return true; + return allowlist.includes(String(userId ?? '').trim()); +} + +export function isTaskTypeAllowedByCodeRunPolicy(taskType, policy) { + const allowlist = (policy?.taskTypeAllowlist ?? []).map((item) => String(item).toLowerCase()); + if (allowlist.length === 0) return true; + const normalized = String(taskType ?? '').trim().toLowerCase(); + if (!normalized) return false; + return allowlist.includes(normalized); +} + +async function ensureConfigTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} ( + config_scope VARCHAR(32) PRIMARY KEY, + config_json JSON NOT NULL, + updated_by CHAR(36) NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +async function loadStoredState(pool) { + await ensureConfigTable(pool); + const [rows] = await pool.query( + `SELECT config_json, updated_by, updated_at + FROM ${CONFIG_TABLE} + WHERE config_scope = ? + LIMIT 1`, + [CONFIG_SCOPE], + ); + const row = rows[0]; + if (!row) return null; + const parsed = parseJsonLike(row.config_json, {}); + return { + config: mergePatch(defaultConfigShape(), parsed), + updatedAt: Number(row.updated_at ?? 0) || null, + updatedBy: row.updated_by ?? null, + }; +} + +export function createAgentCodeRunAdminConfigService(pool, { env = process.env } = {}) { + async function loadEffectiveConfig() { + const forcedEnvSource = policySourceFromEnv(env); + if (forcedEnvSource) { + const config = buildConfigFromEnv(env); + return { + config, + updatedAt: null, + updatedBy: null, + source: forcedEnvSource, + }; + } + + const stored = await loadStoredState(pool); + if (stored) { + return { + config: cloneConfig(stored.config), + updatedAt: stored.updatedAt, + updatedBy: stored.updatedBy, + source: POLICY_SOURCE_ADMIN_DB, + }; + } + + const envConfig = buildConfigFromEnv(env); + if (envPolicyActive(envConfig)) { + return { + config: envConfig, + updatedAt: null, + updatedBy: null, + source: POLICY_SOURCE_ENV_MIGRATION, + }; + } + + return { + config: defaultConfigShape(), + updatedAt: null, + updatedBy: null, + source: POLICY_SOURCE_DEFAULT, + }; + } + + return { + async getAdminConfig() { + const state = await loadEffectiveConfig(); + return { + config: state.config, + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + source: state.source, + envOverrideActive: state.source === POLICY_SOURCE_ENV_OVERRIDE, + }; + }, + + async updateAdminConfig(patch = {}, { updatedBy = null } = {}) { + if (policySourceFromEnv(env)) { + throw Object.assign(new Error('MEMIND_CODE_RUN_POLICY_SOURCE=env 时不允许通过后台修改'), { + code: 'CODE_RUN_POLICY_ENV_LOCKED', + }); + } + const stored = await loadStoredState(pool); + const base = stored?.config ?? defaultConfigShape(); + const nextConfig = mergePatch(base, patch.config ?? patch); + await ensureConfigTable(pool); + const now = Date.now(); + await pool.query( + `INSERT INTO ${CONFIG_TABLE} + (config_scope, config_json, updated_by, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + updated_at = VALUES(updated_at)`, + [CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now], + ); + return this.getAdminConfig(); + }, + + async getRuntimeState() { + const state = await loadEffectiveConfig(); + const policy = flattenPolicy(state.config, state.source); + return { + source: state.source, + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + config: state.config, + policy, + envOverrideActive: state.source === POLICY_SOURCE_ENV_OVERRIDE, + }; + }, + + async getEffectivePolicy(userId) { + const state = await loadEffectiveConfig(); + const policy = flattenPolicy(state.config, state.source); + return { + ...policy, + userAllowed: isUserAllowedByCodeRunPolicy(userId, policy), + }; + }, + + async getPublicClientPolicy(userId) { + const policy = await this.getEffectivePolicy(userId); + const enabled = policy.enabled && policy.clientEnabled && policy.userAllowed; + return { + codeRun: { + enabled, + pageDataDevAutodetect: enabled && policy.pageDataDevAutodetect, + generalAutodetect: enabled && policy.generalAutodetect, + }, + }; + }, + + async getRuntimeStatusSummary() { + const state = await loadEffectiveConfig(); + const policy = flattenPolicy(state.config, state.source); + return { + source: policy.source, + enabled: policy.enabled, + clientEnabled: policy.clientEnabled, + userAllowlist: policy.userAllowlist, + taskTypeAllowlist: policy.taskTypeAllowlist, + requireValidation: policy.requireValidation, + pageDataDevAutodetect: policy.pageDataDevAutodetect, + generalAutodetect: policy.generalAutodetect, + envOverrideActive: policy.source === POLICY_SOURCE_ENV_OVERRIDE, + updatedAt: state.updatedAt, + updatedBy: state.updatedBy, + }; + }, + }; +} + +export const agentCodeRunAdminConfigInternals = { + CONFIG_SCOPE, + CONFIG_TABLE, + DEFAULT_TASK_TYPES, + defaultConfigShape, + buildConfigFromEnv, + mergePatch, + flattenPolicy, +}; diff --git a/agent-code-run-admin-config.test.mjs b/agent-code-run-admin-config.test.mjs new file mode 100644 index 0000000..47d0fbd --- /dev/null +++ b/agent-code-run-admin-config.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + agentCodeRunAdminConfigInternals, + createAgentCodeRunAdminConfigService, + isTaskTypeAllowedByCodeRunPolicy, + isUserAllowedByCodeRunPolicy, +} from './agent-code-run-admin-config.mjs'; + +function createMemoryPool(initialRow = null) { + const state = { row: initialRow }; + return { + state, + async query(sql, params = []) { + const normalized = String(sql).replace(/\s+/g, ' ').trim(); + if (normalized.startsWith('CREATE TABLE')) return [[]]; + if (normalized.includes('SELECT config_json')) { + return [state.row ? [state.row] : []]; + } + if (normalized.includes('INSERT INTO h5_agent_code_run_config')) { + state.row = { + config_json: params[1], + updated_by: params[2], + updated_at: params[3], + }; + return [{ affectedRows: 1 }]; + } + return [[]]; + }, + }; +} + +test('default policy is fail closed', async () => { + const service = createAgentCodeRunAdminConfigService(createMemoryPool(), { env: {} }); + const policy = await service.getEffectivePolicy('user-1'); + assert.equal(policy.enabled, false); + assert.equal(policy.clientEnabled, false); + assert.equal(policy.pageDataDevAutodetect, false); + assert.equal(policy.source, 'default'); +}); + +test('env migration activates when legacy env flags are set', async () => { + const service = createAgentCodeRunAdminConfigService(createMemoryPool(), { + env: { + MEMIND_AGENT_CODE_RUNS_ENABLED: '1', + VITE_AGENT_PAGE_DATA_DEV_AUTODETECT: '1', + MEMIND_AGENT_CODE_RUNS_USER_IDS: 'user-a', + }, + }); + const policy = await service.getEffectivePolicy('user-a'); + assert.equal(policy.source, 'env-migration'); + assert.equal(policy.enabled, true); + assert.equal(policy.pageDataDevAutodetect, true); + assert.equal(policy.userAllowed, true); + assert.equal(isUserAllowedByCodeRunPolicy('user-b', policy), false); +}); + +test('admin-db config overrides env migration defaults', async () => { + const pool = createMemoryPool({ + config_json: JSON.stringify({ + codeRun: { + enabled: true, + clientEnabled: true, + pageDataDevAutodetect: false, + generalAutodetect: false, + requireValidation: true, + userAllowlist: [], + taskTypeAllowlist: ['page_data_dev'], + }, + pageDataDev: { autodetect: true }, + }), + updated_by: 'admin-1', + updated_at: 123, + }); + const service = createAgentCodeRunAdminConfigService(pool, { + env: { MEMIND_AGENT_CODE_RUNS_ENABLED: '0' }, + }); + const policy = await service.getEffectivePolicy('user-1'); + assert.equal(policy.source, 'admin-db'); + assert.equal(policy.enabled, true); + assert.equal(policy.pageDataDevAutodetect, true); + assert.equal(isTaskTypeAllowedByCodeRunPolicy('page_data_dev', policy), true); + assert.equal(isTaskTypeAllowedByCodeRunPolicy('h5_chat_code_task', policy), false); +}); + +test('updateAdminConfig persists patch to database', async () => { + const pool = createMemoryPool(); + const service = createAgentCodeRunAdminConfigService(pool, { env: {} }); + const saved = await service.updateAdminConfig( + { + config: { + codeRun: { enabled: true, clientEnabled: true }, + pageDataDev: { autodetect: true }, + }, + }, + { updatedBy: 'admin-1' }, + ); + assert.equal(saved.source, 'admin-db'); + assert.equal(saved.config.codeRun.enabled, true); + assert.equal(saved.config.pageDataDev.autodetect, true); + const clientPolicy = await service.getPublicClientPolicy('user-1'); + assert.equal(clientPolicy.codeRun.enabled, true); + assert.equal(clientPolicy.codeRun.pageDataDevAutodetect, true); +}); + +test('MEMIND_CODE_RUN_POLICY_SOURCE=env locks admin updates', async () => { + const service = createAgentCodeRunAdminConfigService(createMemoryPool(), { + env: { MEMIND_CODE_RUN_POLICY_SOURCE: 'env', MEMIND_AGENT_CODE_RUNS_ENABLED: '1' }, + }); + await assert.rejects( + () => service.updateAdminConfig({ config: { codeRun: { enabled: false } } }), + /不允许通过后台修改/, + ); +}); + +test('getPublicClientPolicy disables client flags when user not allowlisted', async () => { + const config = agentCodeRunAdminConfigInternals.defaultConfigShape(); + config.codeRun.enabled = true; + config.codeRun.clientEnabled = true; + config.codeRun.userAllowlist = ['allowed-user']; + config.pageDataDev.autodetect = true; + const pool = createMemoryPool({ + config_json: JSON.stringify(config), + updated_by: null, + updated_at: 1, + }); + const service = createAgentCodeRunAdminConfigService(pool, { env: {} }); + const denied = await service.getPublicClientPolicy('other-user'); + assert.equal(denied.codeRun.enabled, false); + const allowed = await service.getPublicClientPolicy('allowed-user'); + assert.equal(allowed.codeRun.enabled, true); + assert.equal(allowed.codeRun.pageDataDevAutodetect, true); +}); diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 9941342..80d9bfb 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -56,12 +56,32 @@ export function createPostAgentRunsHandler({ sessionAccess = null, agentRunGateway, mindSpaceAssetAgent = null, + codeRunPolicyService = 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), requireCodeRunValidation = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION), }) { const sessionStore = sessionAccess ?? createSessionAccess({ userAuth, enabled: false }); + + async function resolveCodeRunPolicy(userId) { + if (codeRunPolicyService?.getEffectivePolicy) { + return codeRunPolicyService.getEffectivePolicy(userId); + } + return { + source: 'env', + enabled: codeRunsEnabled, + clientEnabled: codeRunsEnabled, + generalAutodetect: false, + pageDataDevAutodetect: false, + requireValidation: requireCodeRunValidation, + userAllowlist: [...codeRunUserIds], + taskTypeAllowlist: [...codeRunTaskTypes], + userAllowed: + !codeRunUserIds.size || codeRunUserIds.has(String(userId ?? '').trim()), + }; + } + return async function postAgentRuns(request, response) { try { const sessionId = String(request.body?.session_id ?? '').trim() || null; @@ -87,29 +107,28 @@ export function createPostAgentRunsHandler({ }); return; } - if (toolMode === 'code' && !codeRunsEnabled) { - response.status(403).json({ message: '代码任务灰度未开启' }); - return; - } - if ( - toolMode === 'code' && - codeRunUserIds.size > 0 && - !codeRunUserIds.has(request.currentUser.id) - ) { - response.status(403).json({ message: '当前用户未开启代码任务灰度' }); - return; - } - if ( - toolMode === 'code' && - codeRunTaskTypes.size > 0 && - (!taskType || !codeRunTaskTypes.has(taskType.toLowerCase())) - ) { - response.status(403).json({ message: '当前代码任务类型未开启灰度' }); - return; - } - if (toolMode === 'code' && requireCodeRunValidation && !hasExpectedFileValidation(userMessage)) { - response.status(400).json({ message: '代码任务必须声明产物校验规则' }); - return; + if (toolMode === 'code') { + const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id); + 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 && + (!taskType || !taskTypeAllowlist.map((item) => String(item).toLowerCase()).includes(taskType.toLowerCase())) + ) { + response.status(403).json({ message: '当前代码任务类型未开启灰度' }); + return; + } + if (codeRunPolicy.requireValidation && !hasExpectedFileValidation(userMessage)) { + response.status(400).json({ message: '代码任务必须声明产物校验规则' }); + return; + } } if (sessionId) { const owns = await sessionStore.validateOwnership(request.currentUser.id, sessionId); diff --git a/docs/help-code02.md b/docs/help-code02.md index d50c057..020aa82 100644 --- a/docs/help-code02.md +++ b/docs/help-code02.md @@ -339,15 +339,16 @@ node scripts/migrate-agent-code-run-config-from-env.mjs --apply ## 9. 实施清单(Phase 1.5) -- [ ] `agent-code-run-admin-config.mjs` + 单测 -- [ ] `admin-routes.mjs`:`/agent-code-run/config`、`/runtime` -- [ ] `server.mjs`:`/auth/status` 增加 `agentCodeRun` -- [ ] `agent-run-routes.mjs`:改用 `getEffectivePolicy(userId)` -- [ ] `agentRunMode.ts`:runtime policy 优先于 VITE_ +- [x] `agent-code-run-admin-config.mjs` + 单测 +- [x] `admin-routes.mjs`:`/agent-code-run/config`、`/runtime` +- [x] `server.mjs`:`/auth/status` 增加 `agentCodeRun` +- [x] `agent-run-routes.mjs`:改用 `getEffectivePolicy(userId)` +- [x] `agentRunMode.ts`:runtime policy 优先于 VITE_ - [ ] Ops UI 页面(可先做 JSON 编辑,后做表单) - [ ] `migrate-agent-code-run-config-from-env.mjs` -- [ ] 更新 `docs/agent-run-worker-rollout-runbook.md` 与 `.env.example` -- [ ] verify:`agent-code-run-admin-config.test.mjs` + 扩展 `chat-agent-run-gate.test.mjs` +- [ ] 更新 `docs/agent-run-worker-rollout-runbook.md` +- [x] `.env.example` 增加 `MEMIND_CODE_RUN_POLICY_SOURCE` +- [x] verify:`agent-code-run-admin-config.test.mjs` + 扩展 `chat-agent-run-gate.test.mjs` --- diff --git a/server.mjs b/server.mjs index 5c16a7b..7a7bf42 100644 --- a/server.mjs +++ b/server.mjs @@ -214,6 +214,7 @@ import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; import { createEpisodicMemoryService } from './episodic-memory.mjs'; import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; +import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs'; import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createExperienceService } from './experience-service.mjs'; import { attachAsrRoutes } from './asr-proxy.mjs'; @@ -384,6 +385,7 @@ let memoryV2 = null; let episodicMemoryService = null; let memoryV2ConfigService = null; let skillRuntimeConfigService = null; +let agentCodeRunPolicyService = null; let wechatScheduleLlmConfigService = null; let mindSpace = null; let mindSpaceAssets = null; @@ -719,6 +721,7 @@ async function bootstrapUserAuth() { }); memoryV2ConfigService = createMemoryV2AdminConfigService(pool); skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root: __dirname }); + agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env }); wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); conversationMemoryService = createConversationMemoryService(pool, { llmProviderService, @@ -1024,6 +1027,16 @@ async function resolveSkillRuntimeForClient() { } } +async function resolveAgentCodeRunForClient(userId) { + if (!agentCodeRunPolicyService?.getPublicClientPolicy) return null; + try { + return await agentCodeRunPolicyService.getPublicClientPolicy(userId); + } catch (err) { + console.warn('[AgentCodeRun] public policy unavailable:', err instanceof Error ? err.message : err); + return null; + } +} + app.get('/auth/status', async (req, res) => { await userAuthReady; if (userAuth) { @@ -1033,6 +1046,7 @@ app.get('/auth/status', async (req, res) => { const row = await userAuth.getUserById(me.id); const capabilityState = await userAuth.resolveUserCapabilities(row); const skillRuntime = await resolveSkillRuntimeForClient(); + const agentCodeRun = await resolveAgentCodeRunForClient(me.id); return res.json({ authenticated: true, user: me, @@ -1041,6 +1055,7 @@ app.get('/auth/status', async (req, res) => { grantedSkills: capabilityState.grantedSkills ?? [], unrestricted: capabilityState.unrestricted, skillRuntime, + agentCodeRun, }); } catch (err) { console.error('[Auth] status failed:', err instanceof Error ? err.message : err); @@ -2228,12 +2243,21 @@ function runtimeCsvList(value) { } function runtimeCodeRunPolicyStatus() { - return { - enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), - userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), - taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), - requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION), - }; + return agentCodeRunPolicyService?.getRuntimeStatusSummary + ? agentCodeRunPolicyService.getRuntimeStatusSummary() + : Promise.resolve({ + source: 'env', + enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), + clientEnabled: runtimeEnvFlag(process.env.VITE_AGENT_CODE_RUNS_ENABLED), + userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), + taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES), + requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION), + pageDataDevAutodetect: runtimeEnvFlag(process.env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT), + generalAutodetect: runtimeEnvFlag(process.env.VITE_AGENT_CODE_RUNS_AUTODETECT), + envOverrideActive: String(process.env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env', + updatedAt: null, + updatedBy: null, + }); } api.get('/runtime/status', async (_req, res) => { @@ -2261,7 +2285,7 @@ api.get('/runtime/status', async (_req, res) => { if (toolQueue) { status.toolRuntime = { ...(status.toolRuntime ?? {}), - codeRunPolicy: runtimeCodeRunPolicyStatus(), + codeRunPolicy: await runtimeCodeRunPolicyStatus(), queue: toolQueue, }; } @@ -5081,7 +5105,13 @@ api.post('/agent/runs', async (req, res, next) => { [ tkmindProxy.requireUser, tkmindProxy.ensureChatAllowed, - createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway, mindSpaceAssetAgent }), + createPostAgentRunsHandler({ + userAuth, + sessionAccess, + agentRunGateway, + mindSpaceAssetAgent, + codeRunPolicyService: agentCodeRunPolicyService, + }), ], req, res, diff --git a/src/api/client.ts b/src/api/client.ts index e839822..de7f117 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -42,6 +42,7 @@ import type { } from '../types'; import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message'; import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode'; +import { applyAgentCodeRunClientPolicy } from '../utils/agentRunMode'; import { API, ApiError, @@ -319,6 +320,7 @@ export async function checkAuth(): Promise { return { authenticated: false }; } if (status.authenticated) resetUnauthorizedGuard(); + applyAgentCodeRunClientPolicy(status.agentCodeRun ?? null); if (status.authenticated && status.mode === 'user' && !status.capabilities) { try { const me = await getMe(); diff --git a/src/types.ts b/src/types.ts index 36ef42b..9089d88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -905,6 +905,14 @@ export type PolicyDefinition = { risk: 'low' | 'medium' | 'high'; }; +export type AgentCodeRunClientPolicy = { + codeRun?: { + enabled?: boolean; + pageDataDevAutodetect?: boolean; + generalAutodetect?: boolean; + }; +}; + export type AuthStatus = { authenticated: boolean; mode?: 'user' | 'legacy' | 'none' | 'unavailable'; @@ -913,6 +921,7 @@ export type AuthStatus = { capabilities?: CapabilityMap; grantedSkills?: string[]; unrestricted?: boolean; + agentCodeRun?: AgentCodeRunClientPolicy | null; }; export type PathGrant = { diff --git a/src/utils/agentRunMode.ts b/src/utils/agentRunMode.ts index 3ab99f6..ad3db83 100644 --- a/src/utils/agentRunMode.ts +++ b/src/utils/agentRunMode.ts @@ -1,4 +1,4 @@ -import type { MindSpaceChatContext } from '../types'; +import type { MindSpaceChatContext, AgentCodeRunClientPolicy } from '../types'; export type AgentRunCreateOptions = { toolMode?: 'chat' | 'code'; @@ -32,6 +32,33 @@ export const agentPageDataDevAutodetectEnabled = envFlag( import.meta.env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT, ); +let runtimeAgentCodeRunPolicy: AgentCodeRunClientPolicy | null = null; + +export function applyAgentCodeRunClientPolicy(policy: AgentCodeRunClientPolicy | null) { + runtimeAgentCodeRunPolicy = policy; +} + +function clientCodeRunsEnabled(): boolean { + if (runtimeAgentCodeRunPolicy?.codeRun?.enabled != null) { + return runtimeAgentCodeRunPolicy.codeRun.enabled; + } + return agentCodeRunsEnabled; +} + +function clientCodeRunsAutodetectEnabled(): boolean { + if (runtimeAgentCodeRunPolicy?.codeRun?.generalAutodetect != null) { + return runtimeAgentCodeRunPolicy.codeRun.generalAutodetect; + } + return agentCodeRunsAutodetectEnabled; +} + +function clientPageDataDevAutodetectEnabled(): boolean { + if (runtimeAgentCodeRunPolicy?.codeRun?.pageDataDevAutodetect != null) { + return runtimeAgentCodeRunPolicy.codeRun.pageDataDevAutodetect; + } + return agentPageDataDevAutodetectEnabled; +} + export const PAGE_DATA_DEV_TASK_TYPE = 'page_data_dev'; function parseUserIdSet(value: unknown): Set { @@ -46,7 +73,7 @@ function parseUserIdSet(value: unknown): Set { const agentCodeRunUserIds = parseUserIdSet(import.meta.env.VITE_AGENT_CODE_RUNS_USER_IDS); export function agentCodeRunsEnabledForUser(userId?: string | null): boolean { - if (!agentCodeRunsEnabled) return false; + if (!clientCodeRunsEnabled()) return false; if (agentCodeRunUserIds.size === 0) return true; return Boolean(userId && agentCodeRunUserIds.has(userId)); } @@ -68,7 +95,7 @@ export function isPageDataDevTaskText(text: string): boolean { } export function resolvePageDataDevTaskType(text: string): string | null { - if (!agentPageDataDevAutodetectEnabled) return null; + if (!clientPageDataDevAutodetectEnabled()) return null; return isPageDataDevTaskText(text) ? PAGE_DATA_DEV_TASK_TYPE : null; } @@ -263,8 +290,8 @@ export function resolveAgentRunOptions( { taskType = 'code_task', forceCode = false, - allowAutodetect = agentCodeRunsAutodetectEnabled, - allowPageDataDevAutodetect = agentPageDataDevAutodetectEnabled, + allowAutodetect = clientCodeRunsAutodetectEnabled(), + allowPageDataDevAutodetect = clientPageDataDevAutodetectEnabled(), userId = null, requestId = null, mindspaceContext = null,