diff --git a/.env.example b/.env.example index 912b13a..ac5d283 100644 --- a/.env.example +++ b/.env.example @@ -32,9 +32,12 @@ 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 +# VITE_AGENT_PAGE_DATA_DEV_AUTODETECT=0 # Tool Gateway Queue v0(/agent/runs 后台任务队列,默认保守限流)。 # MEMIND_AGENT_RUN_QUEUE_CONCURRENCY=1 diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 9a1575b..4119660 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -21,6 +21,7 @@ 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 { createSystemDisclosurePolicyService } from './system-disclosure-policy.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'; @@ -116,6 +117,7 @@ export async function createAdminServices(env = {}) { autoRefresh: false, }); await systemDisclosurePolicyService.initialize(); + const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env }); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); const adminSystemTestService = createAdminSystemTestService({ pool, @@ -158,6 +160,7 @@ export async function createAdminServices(env = {}) { mindSearchConfigService, skillRuntimeConfigService, systemDisclosurePolicyService, + agentCodeRunPolicyService, wechatScheduleLlmConfigService, adminSystemTestService, plazaPosts, diff --git a/admin-routes.mjs b/admin-routes.mjs index debe42d..f8f3489 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -33,6 +33,7 @@ function plazaRouteError(res, req, error) { * @param {object|null} deps.memoryV2ConfigService * @param {object|null} deps.skillRuntimeConfigService * @param {object|null} deps.systemDisclosurePolicyService + * @param {object|null} deps.agentCodeRunPolicyService * @param {object|null} deps.adminSystemTestService * @param {object|null} deps.plazaPosts * @param {object|null} deps.plazaOps @@ -50,6 +51,7 @@ export function createAdminApi({ mindSearchConfigService, skillRuntimeConfigService, systemDisclosurePolicyService, + agentCodeRunPolicyService, adminSystemTestService, plazaPosts, plazaOps, @@ -286,6 +288,48 @@ 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.get('/agent-code-run/runs', requireAdmin, async (req, res) => { + if (!agentCodeRunPolicyService?.listRecentPageDataDevRuns) { + return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' }); + } + const limit = Number(req.query?.limit ?? 50); + return res.json(await agentCodeRunPolicyService.listRecentPageDataDevRuns({ limit })); + }); + 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 fc0edb2..3f5960b 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -80,7 +80,9 @@ const CONSOLES = { imageMakeAdminConfigService: services.imageMakeAdminConfigService, memoryV2ConfigService: services.memoryV2ConfigService, mindSearchConfigService: services.mindSearchConfigService, + skillRuntimeConfigService: services.skillRuntimeConfigService, systemDisclosurePolicyService: services.systemDisclosurePolicyService, + 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..6292c29 --- /dev/null +++ b/agent-code-run-admin-config.mjs @@ -0,0 +1,366 @@ +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', + 'page_data_dev_complex', + '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, + }; + }, + + async listRecentPageDataDevRuns({ limit = 50 } = {}) { + const pageDataTaskTypes = new Set(['page_data_dev', 'page_data_dev_complex']); + const capped = Math.min(Math.max(Number(limit) || 50, 1), 200); + const [rows] = await pool.query( + `SELECT id, user_id, request_id, status, error_message, created_at, updated_at, user_message_json + FROM h5_agent_runs + ORDER BY updated_at DESC + LIMIT ?`, + [capped * 4], + ); + const runs = []; + for (const row of rows) { + if (runs.length >= capped) break; + const parsed = parseJsonLike(row.user_message_json, {}); + const metadata = parsed?.metadata ?? {}; + const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; + const taskType = String(runMetadata.taskType ?? metadata.taskType ?? '').trim().toLowerCase(); + if (!pageDataTaskTypes.has(taskType)) continue; + runs.push({ + id: row.id, + userId: row.user_id, + requestId: row.request_id, + status: row.status, + taskType, + executor: String(runMetadata.executor ?? metadata.executor ?? '').trim() || null, + errorMessage: row.error_message ?? null, + createdAt: Number(row.created_at ?? 0) || null, + updatedAt: Number(row.updated_at ?? 0) || null, + }); + } + return { runs, limit: capped }; + }, + }; +} + +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..5c45007 --- /dev/null +++ b/agent-code-run-admin-config.test.mjs @@ -0,0 +1,175 @@ +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); +}); + +test('listRecentPageDataDevRuns filters page data dev task types', async () => { + const pool = { + async query(sql) { + const normalized = String(sql).replace(/\s+/g, ' ').trim(); + if (normalized.includes('FROM h5_agent_runs')) { + return [[ + { + id: 'run-1', + user_id: 'user-1', + request_id: 'req-1', + status: 'failed', + error_message: 'boom', + created_at: 1, + updated_at: 2, + user_message_json: JSON.stringify({ + metadata: { memindRun: { taskType: 'page_data_dev', executor: 'aider' } }, + }), + }, + { + id: 'run-2', + user_id: 'user-1', + request_id: 'req-2', + status: 'succeeded', + error_message: null, + created_at: 3, + updated_at: 4, + user_message_json: JSON.stringify({ + metadata: { memindRun: { taskType: 'h5_chat_code_task' } }, + }), + }, + ]]; + } + return [[]]; + }, + }; + const service = createAgentCodeRunAdminConfigService(pool, { env: {} }); + const result = await service.listRecentPageDataDevRuns({ limit: 10 }); + assert.equal(result.runs.length, 1); + assert.equal(result.runs[0].taskType, 'page_data_dev'); + assert.equal(result.runs[0].executor, 'aider'); +}); 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/chat-intent-router.mjs b/chat-intent-router.mjs index c90989b..0d8da5f 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -3,6 +3,7 @@ import { buildWebNewsSkillPrompt, extractSelectedChatSkillName, hasExplicitChatSkillPrompt, + isPageDataDevIntent, isPageDataIntent, isPageGenerationIntent, isProductCampaignIntent, @@ -930,6 +931,15 @@ export function classifyWithRules({ reason, }, { source: 'rule' }), decisionContext); } + if (includeIntentPatterns && normalized && isPageDataDevIntent(normalized)) { + return finalizeRouterClassification(normalizeClassification({ + route: CHAT_INTENT_ROUTE.AGENT, + confidence: 0.94, + reason: 'Page Data 开发修复(修 bug / 测试回归)', + agent_brief: + '这是 Page Data 开发修复任务:优先根据 verify/报错信息修 public HTML 与 policy,不要重建问卷;建表/bind 缺失时说明需 Goose 或 repair 脚本。', + }, { source: 'rule' }), decisionContext); + } if ( includeIntentPatterns && normalized && diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index afb62a3..1402e10 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -1335,3 +1335,14 @@ test('createChatIntentRouter uses agent fallback for unmatched general questions assert.equal(result.source, 'fallback'); assert.equal(llmCalls.length, 0); }); + +test('classifyWithRules routes page data dev repair before new page-data collect', () => { + const result = classifyWithRules({ + text: '修复问卷 insert 403,columns_not_allowed', + sessionId: null, + sessionMessageCount: 0, + }); + assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT); + assert.match(result.reason, /Page Data 开发修复/); + assert.equal(result.suggested_skill, undefined); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index 5cacbf0..2f031cd 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -71,9 +71,27 @@ function isInteractivePageDataIntent(text) { return INTERACTIVE_PAGE_DATA_SURFACE_PATTERN.test(text) || featureCount >= 2; } +const PAGE_DATA_DEV_INTENT_PATTERNS = [ + /columns_not_allowed/i, + /\bpage[\s-]?data[\s-]?dev\b/i, + /(?:insert|提交|写入).{0,16}(?:失败|403|报错|error)/iu, + /(?:修复|修|排查|debug|fix).{0,24}(?:问卷|page[\s-]?data|数据提交|插入|admin|后台|policy|绑定|bind)/iu, + /(?:问卷|page[\s-]?data|数据页|表单页).{0,24}(?:bug|坏了|不通|失败|有问题)/iu, + /(?:测试|verify).{0,16}(?:没过|失败|不通过)/iu, + /(?:repair|修复).{0,16}(?:bind|绑定|policy|策略)/iu, +]; + +/** Page Data 开发修复(Aider 兜底):修 bug / 测回归,不是新建问卷。 */ +export function isPageDataDevIntent(text) { + const normalized = String(text ?? '').trim(); + if (!normalized) return false; + return PAGE_DATA_DEV_INTENT_PATTERNS.some((pattern) => pattern.test(normalized)); +} + export function isPageDataIntent(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; + if (isPageDataDevIntent(normalized)) return false; return PAGE_DATA_INTENT_PATTERNS.some((pattern) => pattern.test(normalized)) || isInteractivePageDataIntent(normalized); } diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 9df4c3e..53e55a2 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -6,6 +6,7 @@ import { CHAT_SKILL_DEFINITIONS, filterChatSkills, isExcelAnalysisIntent, + isPageDataDevIntent, isPageDataIntent, isPageGenerationIntent, } from './chat-skills.mjs'; @@ -168,3 +169,11 @@ test('buildAutoChatSkillPrefix enables publish for implicit page requests', () = assert.match(prefix, /static-page-publish/); assert.match(prefix, /禁止询问是否还要发布/); }); + +test('isPageDataDevIntent matches repair phrases but not new survey creation', () => { + assert.equal(isPageDataDevIntent('修复问卷 insert 403 columns_not_allowed'), true); + assert.equal(isPageDataDevIntent('page-data-dev 修 policy 白名单'), true); + assert.equal(isPageDataDevIntent('帮我做一个报名问卷'), false); + assert.equal(isPageDataIntent('帮我做一个报名问卷'), true); + assert.equal(isPageDataIntent('修复问卷 insert 403 columns_not_allowed'), false); +}); diff --git a/docs/agent-run-worker-rollout-runbook.md b/docs/agent-run-worker-rollout-runbook.md index 0f804c4..2120d97 100644 --- a/docs/agent-run-worker-rollout-runbook.md +++ b/docs/agent-run-worker-rollout-runbook.md @@ -37,6 +37,47 @@ runtime-slo-report.ok=true runtime-slo-report.failures=[] ``` +## Admin DB Policy (Phase 1.5) + +Code-run **user/taskType/client toggles** can be managed in memindadm instead of rebuilding H5 for every change. + +| Layer | Controls | +|-------|----------| +| **memindadm** (`h5_agent_code_run_config`) | `codeRun.enabled`, `clientEnabled`, allowlists, `pageDataDev.autodetect`, `generalAutodetect`, `requireValidation` | +| **Portal env** | `MEMIND_TOOL_GATEWAY_ENABLED`, worker queue topology, emergency override via `MEMIND_CODE_RUN_POLICY_SOURCE=env` | +| **H5 runtime** | `/auth/status.agentCodeRun` → client autodetect without `VITE_*` rebuild | + +Admin API (memind_adm `:8082`): + +```text +GET/PATCH /admin-api/agent-code-run/config +GET /admin-api/agent-code-run/runtime +``` + +Ops UI: super-admin console → **Code Run** (`/ops/admin/agent-code-run`). + +One-time env → DB migration (dev/staging): + +```bash +node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run +node scripts/migrate-agent-code-run-config-from-env.mjs --updated-by +``` + +Verify effective policy after save: + +```bash +curl -fsS http://127.0.0.1:8081/auth/status -H "Cookie: h5_user=" | jq '.agentCodeRun' +curl -fsS http://127.0.0.1:8081/api/runtime/status | jq '.toolRuntime.codeRunPolicy' +``` + +Page Data dev loop (local only): + +```bash +MEMIND_TOOL_GATEWAY_ENABLED=1 npm run dev:page-data-aider-loop -- --dry-run +``` + +See also: [help-code01.md](./help-code01.md), [help-code02.md](./help-code02.md). + ## One-user Canary Profile Use only for a controlled john-user canary. diff --git a/docs/help-code01.md b/docs/help-code01.md new file mode 100644 index 0000000..5889ecc --- /dev/null +++ b/docs/help-code01.md @@ -0,0 +1,373 @@ +# Help Code01:Aider 补充 Page Data 简单系统开发方案 + +> 文档版本:2026-07-23 +> 状态:方案设计(未实施) +> 目标:用 **Aider** 在 Goose 主路径之外,补充 Page Data 简单系统在**开发/调试**阶段的修 bug 与测试回归;**不替代 Goose**,不替代 `page-data-collect` skill。 + +--- + +## 1. 背景与目标 + +### 1.1 现状 + +Memind 的 Page Data 主路径由 **Goose + `page-data-collect` skill + sandbox-fs MCP** 完成: + +```text +load_skill(page-data-collect) + → private_data_execute(建表) + → private_data_register_dataset(注册 dataset) + → write_file(survey.html + admin.html) + → private_data_bind_workspace_page(发布 + 策略) +``` + +**Aider / OpenHands** 当前仅在 `toolMode='code'` 的显式代码任务中作为外部执行器,**不参与** Page Data 创建与日常 H5 聊天。 + +### 1.2 痛点 + +简单 Page Data 系统(问卷、台账、小后台)在 Goose 搭完骨架后,开发阶段常见: + +- HTML 字段名与 dataset 列不一致 → insert 403 +- `page-data-client.js` 用法错误 +- policy 白名单未更新(bind 失败或漏列) +- admin 页读不到数据 +- 改完不确定是否回归通过 + +重新走聊天让 Goose 修,成本高、周期长。 + +### 1.3 目标 + +引入 **Aider 作为 L2 开发兜底**: + +| 阶段 | 执行者 | +|------|--------| +| **创建** | Goose + `page-data-collect`(不变) | +| **测试** | 现有 verify / scenario 脚本(不变) | +| **修 bug** | Aider(新增,仅 dev/staging) | +| **改表 / 重新 bind** | Goose 或运维脚本(Aider 不主责) | + +--- + +## 2. 设计原则 + +1. **不替代 Goose**:Page Data 首次建表、注册、bind 仍走 MCP 主路径。 +2. **测试驱动**:Aider 修复必须以 verify 脚本失败输出为依据,禁止无验收的盲改。 +3. **cwd 隔离**:用户 workspace 与 Memind 平台源码分开,不可混跑。 +4. **dev 优先**:生产环境默认不自动触发 Aider Page Data 修复;需白名单与 code run 灰度。 +5. **简单系统用 Aider**:复杂多文件改造才考虑 OpenHands,本方案默认 `executor=aider`。 + +--- + +## 3. 三层架构 + +```mermaid +flowchart LR + A[Goose page-data-collect] --> B[产物: HTML + PG表 + policy] + B --> C[npm verify / 场景脚本] + C -->|失败| D[Aider code run] + D --> E[改 workspace 内文件] + E --> C + C -->|通过| F[交付] +``` + +### 3.1 L0 — 创建(Goose,不变) + +- Skill:`skills/page-data-collect/SKILL.md` +- MCP:`mindspace-sandbox-mcp.mjs`(`private_data_*` + `write_file` / `edit_file`) +- 守卫:`mindspace-page-data-finish-guard.mjs`、交付 sync + +### 3.2 L1 — 测试(平台脚本,不变) + +| 命令 | 用途 | +|------|------| +| `npm run verify:page-data` | Page Data 平台单测与集成测 | +| `npm run verify:children-hobby-diet-survey` | 问卷链路产物验证(~5s) | +| `npm run test:scenario:john4-diet` | 全链路 E2E(含 Agent 聊天,~3–10min) | +| `npm run verify:page-data-delivery` | 绑定/delivery dry-run | +| `npm run repair:page-data-bindings` | 修复 workspace bind | + +参考:`.claude/skills/memind-page-data-survey-verify/SKILL.md` + +### 3.3 L2 — 修 bug(Aider,本方案新增) + +- 入口:`toolMode='code'`,`executor='aider'`,建议 `taskType='page_data_dev'` +- cwd:用户 workspace(`resolveWorkingDir(userId)`)或 Memind 仓库(平台开发) +- 输入:verify 失败输出 + 目标文件路径 + Page Data 约束摘要 +- 输出:修改后的 HTML / policy JSON;可选 receipt +- 验收:重跑 L1 verify 直至通过 + +--- + +## 4. Aider 能力边界 + +### 4.1 ✅ 适合修复 + +| 问题类型 | 可改路径 | +|----------|----------| +| HTML 字段与 dataset 列不一致 | `public/*-survey.html`、`public/*-admin.html` | +| `page-data-client.js` API 误用 | 同上 | +| policy insert/read 白名单错误 | `.mindspace/page-data-policies/{pageId}.json` | +| CSP / 脚本路径 / 同源约束 | `public/assets/`、`public/*.html` | +| 提交后 UI 无反馈 | HTML 内 JS | + +### 4.2 ⚠️ 需配合脚本或 Goose + +| 问题 | 推荐做法 | +|------|----------| +| PG 表缺列 | Goose 重新 `private_data_execute`,或运维 SQL | +| dataset 未注册 | `scripts/ensure-page-data-datasets.mjs` | +| bind 丢失 | `npm run repair:page-data-bindings` | +| 策略索引不同步 | `page-data-policy-index` 相关 repair | + +### 4.3 ❌ 不交给 Aider + +| 问题 | 原因 | +|------|------| +| 首次建表 + register + bind | 无 `private_data_*` MCP | +| 替代 Goose 做页面生成 | 缺 skill、finish sync、交付守卫 | +| 生产环境无人值守自动改码 | 风险高,需严格灰度 | + +--- + +## 5. 两种开发场景 + +### 5.1 场景 A:用户 workspace 里的 Page Data 小系统 + +**典型产物:** + +```text +MindSpace/{userId}/ + public/my-survey.html + public/my-admin.html + .mindspace/page-data-policies/{pageId}.json +``` + +**Aider 配置:** + +| 项 | 值 | +|----|-----| +| cwd | `MindSpace/{userId}/` | +| executor | `aider` | +| taskType | `page_data_dev`(建议新增)或 `page_edit_code_task` | +| validation | `expectedFiles`: 目标 `public/*.html` | + +**测试裁判:** 问卷专用 verify 或自定义 `scripts/verify-*.mjs` + +### 5.2 场景 B:Memind 平台 Page Data 模块开发 + +**典型修改:** + +- `page-data-service.mjs`、`page-data-public-service.mjs` +- `mindspace-page-data-finish-guard.mjs` +- `public/assets/page-data-client.js` + +**Aider 配置:** + +| 项 | 值 | +|----|-----| +| cwd | `/Users/john/Project/Memind`(或 CI checkout 路径) | +| executor | `aider` | +| taskType | `h5_chat_code_task` 或专用 `page_data_platform_dev` | +| validation | receipt + 单测通过 | + +**测试裁判:** `npm run verify:page-data` + +> **禁止**在同一次 Aider run 中混用场景 A 与 B 的 cwd。 + +--- + +## 6. 开发闭环操作手册 + +### 6.1 前置:开启 code run + +> **Phase 1(当前):** env + VITE,适合本机 dev。 +> **Phase 1.5(目标):** memindadm 配置 + `/auth/status` 下发,见 [help-code02.md](./help-code02.md)。 + +```bash +# .env 示例(开发机,Phase 1) +MEMIND_AGENT_CODE_RUNS_ENABLED=1 +MEMIND_TOOL_GATEWAY_ENABLED=1 +MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR=aider + +VITE_AGENT_CODE_RUNS_ENABLED=1 +VITE_AGENT_PAGE_DATA_DEV_AUTODETECT=1 +VITE_AGENT_CODE_RUNS_USER_IDS= # 或留空表示全用户(仅 dev) +# 可选:MEMIND_AGENT_CODE_RUN_TASK_TYPES=page_data_dev,h5_chat_code_task,... +``` + +还需: + +- DB `h5_capability_grants`:目标用户 `aider=true` +- `h5_llm_executor_bindings`:aider 有 enabled 的 provider/model +- Portal 或 external worker 之一启用 Tool Gateway(本地 dev 可 Portal 直开) + +### 6.2 标准循环 + +```bash +# 1. 确认 Portal +curl -sf http://127.0.0.1:8081/auth/status + +# 2. Goose 已建好 Page Data 系统(聊天或 scenario) + +# 3. 跑验证 +npm run verify:children-hobby-diet-survey +# 或 +npm run verify:page-data + +# 4. 若失败 → 触发 Aider(H5 / agent-run / 独立脚本) +# instruction 必须包含完整 verify 失败输出 + +# 5. Aider 改完 → 回到步骤 3,直至全绿 +``` + +### 6.3 H5 触发话术示例 + +- 「问卷 submit 403,列 q4_diet_meals 不允许,请根据 verify 结果修 HTML 和 policy」 +- 「Page Data admin 页读不到数据,测试脚本报 policy read 未开启」 +- 「修 page-data 简单系统的 bug,不要改表,只改 public HTML」 + +### 6.4 Aider instruction 模板 + +```text +[Page Data 开发修复任务] + +工作目录: MindSpace/{userId}/ +taskType: page_data_dev + +验证失败输出(必须原样附上): + + +目标文件(仅可改这些): +- public/{survey}.html +- public/{admin}.html +- .mindspace/page-data-policies/{pageId}.json + +约束: +- 必须使用 /assets/page-data-client.js 公开 API +- 禁止 localStorage / sessionStorage / IndexedDB +- 禁止自建 Express 或独立后端 +- 列名必须与 dataset register 的 insert/read 白名单一致 +- 不要修改 Memind 平台源码 + +验收: +- 修改后应能通过: npm run verify:children-hobby-diet-survey +- 或: node scripts/verify-.mjs +``` + +--- + +## 7. 与现有 Memind 架构的集成点 + +### 7.1 现有组件(复用,不重写) + +| 组件 | 路径 | 角色 | +|------|------|------| +| Agent Run 队列 | `agent-run-gateway.mjs` | code run 调度 | +| Tool Gateway | `tool-gateway.mjs` | spawn Aider | +| Launch Plan | `llm-providers.mjs` | `aider --message ... --yes-always` | +| 前端 code 检测 | `src/utils/agentRunMode.ts` | `toolMode:'code'` | +| 能力门禁 | `capabilities.mjs` | code mode 才注入 aider | +| Page Data 路由 | `page-data-routes.mjs` | 运行时 API(Aider 不直接调,HTML 调) | + +### 7.2 建议新增(实施阶段) + +| 项 | 说明 | 优先级 | +|----|------|--------| +| `taskType: page_data_dev` | router / autodetect 识别「修 page data bug」 | P1 | +| chat-intent-router 规则 | 「修问卷/测试没过/page data bug」→ code run | P1 | +| `buildAgentRunTaskValidation` 扩展 | page_data_dev 的 expectedFiles + receipt | P2 | +| 独立 dev 脚本 | 仓库外或 `scripts/page-data-aider-dev-loop.mjs`:verify → aider → verify | P2 | +| 文档索引 | 在 `docs/regression-guards/` 或 AGENTS.md 加链接 | P3 | + +### 7.3 不建议的做法 + +- 用 Aider/OpenHands **替代** Goose 做 Page Data 创建 +- 用 `page-data-collect` skill 充当通用 code executor +- 生产默认全开 `MEMIND_AGENT_CODE_RUNS_ENABLED` 给所有用户 +- 无 verify 输出让 Aider 盲改 workspace + +--- + +## 8. 与 OpenHands / Cursor 的关系 + +| 引擎 | 本方案中的角色 | +|------|----------------| +| **Goose** | L0 创建(主路径,不变) | +| **Aider** | L2 简单 Page Data dev 修 bug(**本方案核心**) | +| **OpenHands** | 可选 L3:Aider 失败且涉及多文件/复杂 repo 时再 escalation | +| **Cursor SDK** | 非必需;仅 Memind 平台源码开发或 IDE 级工具时可选用 | + +OpenHands 任务类型保持现有 `repo_refactor,multi_file,complex_repo`;**不必**为 Page Data 简单 HTML bug 默认走 OpenHands。 + +--- + +## 9. 风险与运维 + +| 风险 | 缓解 | +|------|------| +| Aider 误改非目标文件 | validation `expectedFiles` + cwd 沙箱 + prompt 白名单 | +| 表结构问题被误当 HTML bug | verify 区分 `columns_not_allowed` vs `table_not_found`;后者回 Goose | +| code run 孤儿化导致 H5 loading | verify 脚本检查 orphan running;`--no-runs` 可选 | +| 生产误触发 | 生产 `MEMIND_AGENT_CODE_RUNS_ENABLED=0` 或严格 user allowlist | +| PG 连接失败 | fail closed(skill 已有);Aider 不得「先写 HTML 等 PG 恢复」 | + +--- + +## 10. 实施路线图 + +### Phase 0 — 文档与手动流程(当前) + +- [x] 本方案文档 `docs/help-code01.md` +- [ ] 开发者在本地手动:verify 失败 → 复制输出 → H5/code run 触发 Aider → 再 verify + +### Phase 1 — 最小 Memind 改动 + +- [x] 新增 `page_data_dev` taskType +- [x] `chat-intent-router` 增加 Page Data 修 bug 意图 +- [x] `agentRunMode.ts` 可选 autodetect 模式(dev only,`VITE_AGENT_PAGE_DATA_DEV_AUTODETECT`) + +### Phase 1.5 — memindadm 运行时策略(推荐下一步) + +> 详细设计见 **[help-code02.md](./help-code02.md)**。 + +- [x] `h5_agent_code_run_config` 表 + `agent-code-run-admin-config.mjs` +- [x] memindadm API:`/admin-api/agent-code-run/config`、`/runtime`(UI 表单待做) +- [x] `/auth/status` 下发 `agentCodeRun`,H5 不再依赖 `VITE_*` rebuild +- [x] `agent-run-routes.mjs` 从 DB 读策略(env 仅紧急 override) +- [x] Worker 拓扑(`MEMIND_TOOL_GATEWAY_ENABLED`)继续留 env +- [x] `scripts/migrate-agent-code-run-config-from-env.mjs` + +**为何需要 Phase 1.5:** Phase 1 的 env/VITE 适合 dev;要解决「用户经常失败」的运营灰度,必须在 memindadm 按用户/任务类型动态开关,且 H5 需运行时生效。 + +### Phase 2 — 自动化 dev loop + +- [x] `scripts/page-data-aider-dev-loop.mjs`:读 verify 输出 → 调 Tool Gateway/Aider → 重跑 verify +- [x] `npm run dev:page-data-aider-loop` 快捷入口 +- [ ] 接入 CI optional job(仅 staging) +- [x] `npm run ci:page-data-dev-loop-smoke`(dry-run 烟测,可挂 CI optional job) + +### Phase 3 — 可选 escalation + +- [x] Aider 连续 N 次 verify 失败 → 升级 OpenHands(`taskType=page_data_dev_complex`,`--escalate-after`) +- [x] 运维面板展示 page_data_dev run 历史(`/admin-api/agent-code-run/runs` + Ops UI) + +--- + +## 11. 相关文档与命令速查 + +| 资源 | 路径 / 命令 | +|------|-------------| +| **adm 运行时策略(Phase 1.5)** | `docs/help-code02.md` | +| Page Data API 用法 | `docs/page-data-api-usage.md` | +| page-data-collect skill | `skills/page-data-collect/SKILL.md` | +| 问卷 verify skill | `.claude/skills/memind-page-data-survey-verify/SKILL.md` | +| Tool Gateway 架构 | `docs/architecture/memind-2-streaming-agent-runtime-plan.md` | +| Agent run worker | `docs/agent-run-worker-rollout-runbook.md` | +| `npm run verify:page-data` | Page Data 平台测试 | +| `npm run verify:children-hobby-diet-survey` | 问卷产物验证 | +| `npm run repair:page-data-bindings` | 修复 bind | + +--- + +## 12. 一句话总结 + +**Goose 负责把 Page Data 简单系统「建起来」;现有 verify 脚本负责「验对不对」;Aider 负责开发阶段「根据测试结果改 HTML/policy 修 bug」——三者串联,不替代 Goose 主路径。** diff --git a/docs/help-code02.md b/docs/help-code02.md new file mode 100644 index 0000000..59e1d38 --- /dev/null +++ b/docs/help-code02.md @@ -0,0 +1,382 @@ +# Help Code02:Code Run / Page Data Dev 的 memindadm 运行时策略 + +> 文档版本:2026-07-23 +> 状态:方案设计(未实施) +> 前置:[help-code01.md](./help-code01.md)(Aider 补充 Page Data 开发兜底) +> 关联:[memindadm-goose-gateway-design.md](./memindadm-goose-gateway-design.md) + +--- + +## 1. 为什么要做 Phase 1.5 + +Phase 1 用 **env + VITE_ 构建期变量** 控制 code run 与 `page_data_dev` autodetect。这在 dev/staging 可用,但对「用户经常页面失败、插入失败」的运营场景不够: + +| 问题 | env/VITE 的局限 | +|------|----------------| +| 改开关要 rebuild H5 | `VITE_*` 打进 bundle,memindadm 点了不生效 | +| 改开关要 SSH 改 `.env` | 103 Portal / worker 两套 env,易不一致 | +| 无法按用户即时灰度 | 白名单改 env 无审计、无 UI | +| 与现有 adm 能力分裂 | aider 能力已在 `h5_capability_grants`,策略却在 env | + +**目标:** 把 **「谁、哪种 task、是否 page_data_dev autodetect」** 迁入 memindadm;**部署拓扑**(worker 是否 spawn Aider)继续留 env。 + +--- + +## 2. 配置分层(硬边界) + +```mermaid +flowchart TB + subgraph adm ["memindadm 运行时策略(DB)"] + P1[codeRun.enabled] + P2[userAllowlist / roleAllowlist] + P3[taskTypeAllowlist 含 page_data_dev] + P4[pageDataDev.autodetect] + P5[requireValidation] + P6[generalCodeAutodetect] + end + + subgraph env ["部署 env(不改或只读 override)"] + E1[MEMIND_TOOL_GATEWAY_ENABLED] + E2[AIDER_BIN / OPENHANDS_BIN] + E3[worker 并发 / 超时 / guard] + E4[MEMIND_AGENT_RUN_* 紧急 override] + end + + subgraph cap ["已有用户能力(DB)"] + C1[h5_capability_grants: aider/openhands] + C2[h5_llm_executor_bindings] + end + + adm --> Portal[agent-run-routes 校验] + adm --> Auth["/auth/status → H5"] + cap --> Portal + env --> Worker[agent-run-worker] + Portal --> Worker +``` + +### 2.1 进 memindadm 的项 + +| 原 env / VITE | adm 字段 | 说明 | +|---------------|----------|------| +| `MEMIND_AGENT_CODE_RUNS_ENABLED` | `codeRun.enabled` | 后端是否接受 `tool_mode=code` | +| `MEMIND_AGENT_CODE_RUNS_USER_IDS` | `codeRun.userAllowlist` | 空 = 不限制(enabled 时) | +| `MEMIND_AGENT_CODE_RUN_TASK_TYPES` | `codeRun.taskTypeAllowlist` | 含 `page_data_dev`、`h5_chat_code_task` | +| `MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION` | `codeRun.requireValidation` | 是否强制 receipt | +| `VITE_AGENT_CODE_RUNS_ENABLED` | `codeRun.clientEnabled` | H5 是否展示/走 code 路径 | +| `VITE_AGENT_CODE_RUNS_AUTODETECT` | `codeRun.generalAutodetect` | 通用代码语义 autodetect | +| `VITE_AGENT_PAGE_DATA_DEV_AUTODETECT` | `pageDataDev.autodetect` | Page Data 修 bug 专用 autodetect | + +### 2.2 继续留 env 的项 + +| env | 原因 | +|-----|------| +| `MEMIND_TOOL_GATEWAY_ENABLED` | worker 进程级;Portal 与 worker 故意不同值 | +| `MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR` | 机器上装的是 aider 还是 openhands | +| `MEMIND_AGENT_RUN_QUEUE_CONCURRENCY` 等 | SLO / guard / LaunchAgent | +| `MEMIND_AGENT_CODE_RUNS_*`(可选) | **紧急 override**:`MEMIND_CODE_RUN_POLICY_SOURCE=env` 时强制 env 优先 | + +--- + +## 3. 数据模型 + +### 3.1 表:`h5_agent_code_run_config` + +与 `h5_skill_runtime_config`、`h5_image_make_admin_config` 同模式:`config_scope='global'` 单行 JSON。 + +```sql +CREATE TABLE IF NOT EXISTS h5_agent_code_run_config ( + 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; +``` + +### 3.2 默认 JSON Schema + +```json +{ + "codeRun": { + "enabled": false, + "clientEnabled": false, + "generalAutodetect": false, + "requireValidation": true, + "userAllowlist": [], + "taskTypeAllowlist": [ + "page_data_dev", + "h5_chat_code_task", + "page_edit_code_task" + ] + }, + "pageDataDev": { + "autodetect": false + }, + "meta": { + "notes": "" + } +} +``` + +### 3.3 生效优先级(与 Memory V2 adm 一致) + +参考 `memory-v2-admin-config.mjs` 的 `FIELD_SPECS + env override` 模式: + +```text +1. 若 MEMIND_CODE_RUN_POLICY_SOURCE=env → 仅读 env(紧急回滚) +2. 否则读 h5_agent_code_run_config(admin-db) +3. 若表为空且 MEMIND_AGENT_CODE_RUNS_ENABLED=1 → source=env-migration(兼容旧部署) +4. 否则 default(全 false,fail closed) +``` + +### 3.4 用户级能力(不重复造表) + +以下 **仍用现有表**,adm「Code Run 策略」页只读展示 + 链到用户能力编辑: + +- `h5_capability_grants`:`aider` / `openhands` +- `h5_llm_executor_bindings`:Aider 模型绑定 +- `h5_user_policies`:`code_delegate_executor`、`code_task_routing` + +**完整放行条件(与 today 相同,只是策略来源改为 DB):** + +```text +codeRun.enabled (adm) + AND user ∈ allowlist(若配置) + AND taskType ∈ taskTypeAllowlist + AND capabilities.aider(用户 grant) + AND executor binding 可用 + AND(若 requireValidation)message 含 validation metadata + AND toolGateway.enabled(env,worker 侧) +``` + +--- + +## 4. 服务模块:`agent-code-run-admin-config.mjs` + +建议新建,API 对齐 `skill-runtime-admin-config.mjs`: + +| 方法 | 用途 | +|------|------| +| `getAdminConfig()` | memindadm 编辑页 | +| `updateAdminConfig(patch, { updatedBy })` | 保存 + 审计 | +| `getRuntimeState()` | adm 运行时预览 | +| `getEffectivePolicy({ userId })` | Portal 校验用 | +| `getPublicClientPolicy({ userId })` | `/auth/status` 下发 H5 | + +### 4.1 `getEffectivePolicy` 返回示例 + +```json +{ + "source": "admin-db", + "updatedAt": 1753276800000, + "enabled": true, + "userAllowed": true, + "taskTypes": ["page_data_dev", "h5_chat_code_task", "page_edit_code_task"], + "requireValidation": true, + "pageDataDevAutodetect": true, + "generalAutodetect": false +} +``` + +### 4.2 `getPublicClientPolicy` 返回示例(按用户过滤后) + +```json +{ + "codeRun": { + "enabled": true, + "pageDataDevAutodetect": true, + "generalAutodetect": false + } +} +``` + +未登录或用户不在 allowlist 时:`codeRun.enabled=false`(或不返回该块)。 + +--- + +## 5. HTTP API + +### 5.1 memindadm(`admin-routes.mjs`) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/admin/agent-code-run/config` | 读配置 + source + updatedBy | +| PUT/PATCH | `/api/admin/agent-code-run/config` | 更新 | +| GET | `/api/admin/agent-code-run/runtime` | 有效策略 + env 覆盖状态 + worker 只读摘要 | + +Worker 摘要可代理现有 `/api/runtime/status` 的 `toolRuntime.codeRunPolicy` 与 `queue`(**只读**,不在 adm 改 worker env)。 + +### 5.2 Portal 用户面 + +**扩展 `GET /auth/status`**(已登录用户): + +```json +{ + "authenticated": true, + "user": { "...": "..." }, + "capabilities": { "aider": true, "...": "..." }, + "skillRuntime": { "...": "..." }, + "agentCodeRun": { + "enabled": true, + "pageDataDevAutodetect": true, + "generalAutodetect": false + } +} +``` + +**扩展 `POST /api/agent/runs` 校验:** 从 `getEffectivePolicy(userId)` 读,不再只读 `process.env.MEMIND_AGENT_CODE_RUNS_*`。 + +### 5.3 只读运维 + +`GET /api/runtime/status` 增加: + +```json +{ + "toolRuntime": { + "codeRunPolicy": { + "source": "admin-db", + "enabled": true, + "userAllowlist": [], + "taskTypeAllowlist": ["page_data_dev", "..."], + "requireValidation": true, + "pageDataDevAutodetect": true, + "envOverrideActive": false + } + } +} +``` + +--- + +## 6. 前端改造(去掉 VITE_ 硬依赖) + +### 6.1 `src/utils/agentRunMode.ts` + +```typescript +// 优先级:runtime policy(/auth/status)> VITE_(dev fallback) + +let runtimePolicy: AgentCodeRunClientPolicy | null = null; + +export function applyAgentCodeRunClientPolicy(policy: AgentCodeRunClientPolicy | null) { + runtimePolicy = policy; +} + +function clientCodeRunsEnabled(): boolean { + if (runtimePolicy?.codeRun?.enabled != null) return runtimePolicy.codeRun.enabled; + return agentCodeRunsEnabled; // VITE fallback +} + +function clientPageDataDevAutodetect(): boolean { + if (runtimePolicy?.codeRun?.pageDataDevAutodetect != null) { + return runtimePolicy.codeRun.pageDataDevAutodetect; + } + return agentPageDataDevAutodetectEnabled; +} +``` + +### 6.2 注入时机 + +在现有 `/auth/status` 加载处(如 `src/api/client.ts` 或 auth hook): + +```typescript +const status = await fetchAuthStatus(); +applyAgentCodeRunClientPolicy(status.agentCodeRun ?? null); +``` + +**效果:** memindadm 打开 `pageDataDev.autodetect` 后,用户刷新页面即生效,**无需 rebuild H5**。 + +--- + +## 7. memindadm UI(Ops 后台) + +建议菜单位置:**系统 / Agent 运行时 → Code Run & Page Data Dev** + +| 控件 | 类型 | 说明 | +|------|------|------| +| Code Run 总开关 | toggle | `codeRun.enabled` | +| H5 客户端启用 | toggle | `codeRun.clientEnabled` | +| Page Data Dev Autodetect | toggle | `pageDataDev.autodetect` | +| 通用 Code Autodetect | toggle | `codeRun.generalAutodetect`(默认关) | +| 强制 Validation | toggle | `codeRun.requireValidation` | +| 用户白名单 | multi-select UUID | 空 = 全部(enabled 时) | +| Task Types | checkbox list | 至少含 `page_data_dev` | +| 当前 Worker 状态 | read-only | 来自 `/runtime/status` | +| Env Override 警告 | banner | `MEMIND_CODE_RUN_POLICY_SOURCE=env` 时显示 | + +**保存时:** 写 `updated_by` + `updated_at`;可选写 admin audit log(与 image-make 一致)。 + +--- + +## 8. 迁移与兼容 + +### 8.1 首次上线 + +1. 建表 `h5_agent_code_run_config`,默认全 `false` +2. 部署 `agent-code-run-admin-config.mjs` + admin API +3. **迁移脚本**(可选):若 env 已开启,import 到 DB 并提示改 `POLICY_SOURCE=admin` + +```bash +node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run +node scripts/migrate-agent-code-run-config-from-env.mjs --apply +``` + +### 8.2 回滚 + +| 场景 | 操作 | +|------|------| +| adm 配错了 | memindadm 关 `codeRun.enabled` | +| 紧急全站关闭 | `MEMIND_CODE_RUN_POLICY_SOURCE=env` + unset `MEMIND_AGENT_CODE_RUNS_ENABLED` | +| worker 异常 | LaunchAgent 停 worker(现有 runbook),与 adm 无关 | + +### 8.3 与 help-code01 Phase 的关系 + +| Phase | 内容 | 配置来源 | +|-------|------|----------| +| Phase 1 ✅ | `page_data_dev` 意图 + autodetect 逻辑 | env/VITE | +| **Phase 1.5** | adm 运行时策略 + `/auth/status` | **DB + adm UI** | +| Phase 2 | verify → Aider dev loop 脚本 | 脚本 + adm 开关 | + +--- + +## 9. 实施清单(Phase 1.5) + +- [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_ +- [x] Ops UI 页面(表单 + JSON 高级编辑:`/ops/admin/agent-code-run`) +- [x] `migrate-agent-code-run-config-from-env.mjs` +- [x] 更新 `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` + +--- + +## 10. 安全与审计 + +1. **仅 admin 可写** — 复用 `requireAdmin` +2. **默认 fail closed** — 新环境 adm 配置为空 = 全关 +3. **双闸门保留** — adm 开 + 用户 `aider` grant + worker gateway env +4. **审计字段** — `updated_by`、`updated_at`;重要变更写 admin audit +5. **生产建议** — 先 `userAllowlist` 小范围开 `page_data_dev`,再扩 `generalAutodetect` + +--- + +## 11. 相关文件索引 + +| 类型 | 路径 | +|------|------| +| 方案总览 | `docs/help-code01.md` | +| Goose 网关规划 | `docs/memindadm-goose-gateway-design.md` | +| 可复用 adm 模式 | `skill-runtime-admin-config.mjs` | +| env↔adm 映射先例 | `memory-v2-admin-config.mjs` | +| 后端 code run 门禁 | `agent-run-routes.mjs` | +| 前端 autodetect | `src/utils/agentRunMode.ts` | +| 运行时只读 | `server.mjs` → `runtimeCodeRunPolicyStatus()` | +| Worker runbook | `docs/agent-run-worker-rollout-runbook.md` | + +--- + +## 12. 一句话总结 + +**memindadm 管「策略」(谁、哪种 task、是否 page_data_dev);env 管「部署」(worker 是否 spawn Aider);H5 从 `/auth/status` 读运行时策略,不再依赖 rebuild VITE_。** diff --git a/mindspace-chat-save.test.mjs b/mindspace-chat-save.test.mjs index d35c292..09f1706 100644 --- a/mindspace-chat-save.test.mjs +++ b/mindspace-chat-save.test.mjs @@ -117,6 +117,24 @@ test('buildChatSavePreviewUrls use local api routes', () => { ); }); +test('chat page previews keep generated scripts sandboxed and proxy analytics through Portal', async () => { + const [pageSavePreview, chatSharePreview, viteConfig] = await Promise.all([ + fs.readFile(new URL('./src/components/PageSavePreviewPanel.tsx', import.meta.url), 'utf8'), + fs.readFile(new URL('./src/components/ChatSharePreviewModal.tsx', import.meta.url), 'utf8'), + fs.readFile(new URL('./vite.config.ts', import.meta.url), 'utf8'), + ]); + + assert.match(pageSavePreview, /sandbox="allow-scripts allow-downloads"/); + assert.match(chatSharePreview, /sandbox="allow-scripts allow-popups allow-downloads"/); + for (const source of [pageSavePreview, chatSharePreview]) { + assert.doesNotMatch( + source, + /sandbox="[^"]*(?:allow-scripts[^"]*allow-same-origin|allow-same-origin[^"]*allow-scripts)[^"]*"/, + ); + } + assert.match(viteConfig, /['"]\/analytics['"]\s*:\s*portalTarget/); +}); + test('findPublishHtml resolves nested public html by basename', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-')); const publishDir = path.join(root, 'MindSpace', USER_ID, 'public'); diff --git a/ops/src/App.tsx b/ops/src/App.tsx index 43138e3..4909db8 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -11,6 +11,7 @@ import { ReviewPage } from './pages/ReviewPage'; import { SummaryPage } from './pages/admin/SummaryPage'; import { UsersPage } from './pages/admin/UsersPage'; import { BillingPage } from './pages/admin/BillingPage'; +import { AgentCodeRunPage } from './pages/admin/AgentCodeRunPage'; import { WechatPage } from './pages/admin/WechatPage'; import { SystemPolicyPage } from './pages/admin/SystemPolicyPage'; @@ -44,6 +45,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index 68c7c16..303d379 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -358,3 +358,81 @@ export async function resumeWechatDigest(id: string) { method: 'POST', }); } + +// ─── Agent Code Run ─────────────────────────────────────────────────────────── + +export type AgentCodeRunConfigShape = { + codeRun: { + enabled: boolean; + clientEnabled: boolean; + generalAutodetect: boolean; + requireValidation: boolean; + userAllowlist: string[]; + taskTypeAllowlist: string[]; + }; + pageDataDev: { + autodetect: boolean; + }; + meta: { + notes: string; + }; +}; + +export type AgentCodeRunAdminConfig = { + config: AgentCodeRunConfigShape; + updatedAt: number | null; + updatedBy: string | null; + source: string; + envOverrideActive: boolean; +}; + +export type AgentCodeRunRuntimeState = { + source: string; + updatedAt: number | null; + updatedBy: string | null; + config: AgentCodeRunConfigShape; + policy: { + source: string; + enabled: boolean; + clientEnabled: boolean; + generalAutodetect: boolean; + requireValidation: boolean; + pageDataDevAutodetect: boolean; + userAllowlist: string[]; + taskTypeAllowlist: string[]; + }; + envOverrideActive: boolean; +}; + +export async function fetchAgentCodeRunConfig() { + return adminFetch('/admin-api/agent-code-run/config'); +} + +export async function fetchAgentCodeRunRuntime() { + return adminFetch('/admin-api/agent-code-run/runtime'); +} + +export async function patchAgentCodeRunConfig(body: { config: AgentCodeRunConfigShape }) { + return adminFetch('/admin-api/agent-code-run/config', { + method: 'PATCH', + body: JSON.stringify(body), + }); +} + +export type AgentCodeRunHistoryRow = { + id: string; + userId: string; + requestId: string; + status: string; + taskType: string; + executor: string | null; + errorMessage: string | null; + createdAt: number | null; + updatedAt: number | null; +}; + +export async function fetchAgentCodeRunHistory(limit = 50) { + return adminFetch<{ runs: AgentCodeRunHistoryRow[]; limit: number }>( + `/admin-api/agent-code-run/runs?limit=${limit}`, + ); +} diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 31fe7d4..593061e 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -5,6 +5,7 @@ const links = [ { to: '/admin/users', label: '用户管理' }, { to: '/admin/billing', label: '账单记录' }, { to: '/admin/wechat', label: '服务号管理' }, + { to: '/admin/agent-code-run', label: 'Code Run' }, { to: '/admin/policy', label: '策略中心' }, ]; diff --git a/ops/src/pages/admin/AgentCodeRunPage.tsx b/ops/src/pages/admin/AgentCodeRunPage.tsx new file mode 100644 index 0000000..824336e --- /dev/null +++ b/ops/src/pages/admin/AgentCodeRunPage.tsx @@ -0,0 +1,396 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + fetchAgentCodeRunConfig, + fetchAgentCodeRunHistory, + fetchAgentCodeRunRuntime, + patchAgentCodeRunConfig, + type AgentCodeRunAdminConfig, + type AgentCodeRunHistoryRow, + type AgentCodeRunRuntimeState, +} from '../../api/admin'; + +type FormState = { + enabled: boolean; + clientEnabled: boolean; + generalAutodetect: boolean; + requireValidation: boolean; + pageDataDevAutodetect: boolean; + userAllowlist: string; + taskTypeAllowlist: string; + notes: string; +}; + +function listToText(values: string[] | undefined) { + return (values ?? []).join(', '); +} + +function textToList(value: string) { + return value + .split(/[,\n]/) + .map((item) => item.trim()) + .filter(Boolean); +} + +function configToForm(config: AgentCodeRunAdminConfig['config']): FormState { + return { + 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: listToText(config.codeRun.userAllowlist), + taskTypeAllowlist: listToText(config.codeRun.taskTypeAllowlist), + notes: config.meta?.notes ?? '', + }; +} + +function formToPatch(form: FormState) { + return { + config: { + codeRun: { + enabled: form.enabled, + clientEnabled: form.clientEnabled, + generalAutodetect: form.generalAutodetect, + requireValidation: form.requireValidation, + userAllowlist: textToList(form.userAllowlist), + taskTypeAllowlist: textToList(form.taskTypeAllowlist), + }, + pageDataDev: { + autodetect: form.pageDataDevAutodetect, + }, + meta: { + notes: form.notes, + }, + }, + }; +} + +function formatTime(value: number | null | undefined) { + if (!value) return '—'; + return new Date(value).toLocaleString('zh-CN', { hour12: false }); +} + +function ToggleRow({ + label, + hint, + checked, + onChange, + disabled, +}: { + label: string; + hint?: string; + checked: boolean; + onChange: (next: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function AgentCodeRunPage() { + const [configState, setConfigState] = useState(null); + const [runtimeState, setRuntimeState] = useState(null); + const [runHistory, setRunHistory] = useState([]); + const [form, setForm] = useState(null); + const [jsonDraft, setJsonDraft] = useState(''); + const [useJson, setUseJson] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const locked = Boolean(configState?.envOverrideActive); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [config, runtime, history] = await Promise.all([ + fetchAgentCodeRunConfig(), + fetchAgentCodeRunRuntime(), + fetchAgentCodeRunHistory(30), + ]); + setConfigState(config); + setRuntimeState(runtime); + setRunHistory(history.runs ?? []); + setForm(configToForm(config.config)); + setJsonDraft(JSON.stringify(config.config, null, 2)); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const effectivePolicy = runtimeState?.policy; + + const previewJson = useMemo(() => { + if (!form) return ''; + return JSON.stringify(formToPatch(form).config, null, 2); + }, [form]); + + async function handleSave() { + if (!form) return; + setSaving(true); + setMessage(null); + setError(null); + try { + let patch: ReturnType; + if (useJson) { + const parsed = JSON.parse(jsonDraft) as AgentCodeRunAdminConfig['config']; + patch = { config: parsed }; + } else { + patch = formToPatch(form); + } + const saved = await patchAgentCodeRunConfig(patch); + setConfigState(saved); + setForm(configToForm(saved.config)); + setJsonDraft(JSON.stringify(saved.config, null, 2)); + setMessage('配置已保存'); + const runtime = await fetchAgentCodeRunRuntime(); + setRuntimeState(runtime); + const history = await fetchAgentCodeRunHistory(30); + setRunHistory(history.runs ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setSaving(false); + } + } + + if (loading) return

加载中…

; + if (error && !form) return

{error}

; + if (!form || !configState) return

配置不可用

; + + return ( +
+
+

Agent Code Run & Page Data Dev

+

+ 控制 code run 灰度、H5 客户端开关与 Page Data 开发修复 autodetect。Worker 拓扑仍由 env 管理。 +

+
+

+ 当前来源:{configState.source} + {configState.envOverrideActive ? ( + · MEMIND_CODE_RUN_POLICY_SOURCE=env 已锁定后台修改 + ) : null} +

+

更新时间:{formatTime(configState.updatedAt)}

+ {effectivePolicy ? ( +

+ 有效策略:server={effectivePolicy.enabled ? '开' : '关'} · client= + {effectivePolicy.clientEnabled ? '开' : '关'} · pageDataDev= + {effectivePolicy.pageDataDevAutodetect ? '开' : '关'} +

+ ) : null} +
+
+ + {locked ? ( +
+ 当前环境启用了 MEMIND_CODE_RUN_POLICY_SOURCE=env,后台无法写入 DB。请改 env 或去掉该变量后再保存。 +
+ ) : null} + +
+

开关

+ setForm((current) => (current ? { ...current, enabled } : current))} + /> + setForm((current) => (current ? { ...current, clientEnabled } : current))} + /> + + setForm((current) => (current ? { ...current, pageDataDevAutodetect } : current)) + } + /> + + setForm((current) => (current ? { ...current, generalAutodetect } : current)) + } + /> + + setForm((current) => (current ? { ...current, requireValidation } : current)) + } + /> +
+ +
+

白名单

+
+ + + +`; + +const ORDER_ADMIN_HTML = ` + + + + + 订单管理后台 + + + +
+

客户订单管理后台

查看订单、按商品统计、导出 CSV

+
+

请输入管理口令

+ + + +
+ +
+ + + +`; + +const FORM_POLICY = { + accessMode: 'public', + datasets: { + customer_orders: { + insert: true, + read: false, + columns: { + insert: ['customer_name', 'phone', 'product_name', 'quantity', 'address', 'remark', 'status'], + }, + }, + }, +}; + +const ADMIN_POLICY = { + accessMode: 'password', + datasets: { + customer_orders: { + insert: false, + read: true, + columns: { + read: ['id', 'customer_name', 'phone', 'product_name', 'quantity', 'address', 'remark', 'status', 'created_at'], + }, + }, + }, +}; + +async function main() { + const publicDir = path.join(WORKSPACE_ROOT, 'public'); + await fs.mkdir(publicDir, { recursive: true }); + await fs.writeFile(path.join(publicDir, 'customer-order-form.html'), ORDER_FORM_HTML, 'utf8'); + await fs.writeFile(path.join(publicDir, 'customer-order-admin.html'), ORDER_ADMIN_HTML, 'utf8'); + + const dataSpace = createUserDataSpaceService({ workspaceRoot: WORKSPACE_ROOT, userId: USER_ID }); + await dataSpace.executeSql(`CREATE TABLE IF NOT EXISTS customer_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_name TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + product_name TEXT NOT NULL DEFAULT '', + quantity TEXT NOT NULL DEFAULT '1', + address TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '待处理', + created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours')) + );`); + await dataSpace.upsertDataset({ + name: DATASET, + table: DATASET, + description: '客户下单系统订单表', + actions: ['read', 'insert'], + columns: { + insert: FORM_POLICY.datasets.customer_orders.columns.insert, + read: ADMIN_POLICY.datasets.customer_orders.columns.read, + }, + }); + + const pool = createDbPool(); + const storageRoot = resolveMindSpaceStorageRoot(root); + const pages = [ + { relativePath: 'public/customer-order-form.html', accessMode: 'public', password: null, policy: FORM_POLICY }, + { relativePath: 'public/customer-order-admin.html', accessMode: 'password', password: ADMIN_PASSWORD, policy: ADMIN_POLICY }, + ]; + + console.log('==> 客户下单系统 Page Data 演示\n'); + for (const page of pages) { + const result = await bindWorkspaceHtmlForPageData({ + pool, + h5Root: root, + storageRoot, + userId: USER_ID, + workspaceRoot: WORKSPACE_ROOT, + relativePath: page.relativePath, + accessMode: page.accessMode, + password: page.password, + pageDataPolicy: page.policy, + }); + console.log(`✓ ${page.relativePath}`); + console.log(` pageId: ${result.pageId}`); + console.log(` URL: ${result.workspaceUrl}\n`); + } + + console.log('后台口令:', ADMIN_PASSWORD); + await pool.end(); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.stack ?? err.message : err); + process.exit(1); +}); diff --git a/scripts/verify-customer-order-system.mjs b/scripts/verify-customer-order-system.mjs new file mode 100644 index 0000000..d5108f1 --- /dev/null +++ b/scripts/verify-customer-order-system.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * 验证 john4 客户下单系统 Page Data 链路(insert + admin read)。 + */ +import { loadH5Environment } from './load-env.mjs'; +import { + createReporter, + loginViaApi, + resolvePortalBase, +} from './scenario-test-lib.mjs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PUBLISH_ROOT_DIR } from '../user-publish.mjs'; +import fs from 'node:fs/promises'; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const USER_ID = process.env.CUSTOMER_ORDER_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa'; +const DATASET = 'customer_orders'; +const FORM_PAGE = 'customer-order-form.html'; +const ADMIN_PAGE = 'customer-order-admin.html'; + +loadH5Environment(import.meta.dirname); + +async function findPageIdFromPolicy(publishKey) { + const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies'); + const entries = await fs.readdir(policyDir); + for (const name of entries.filter((item) => item.endsWith('.json'))) { + const raw = await fs.readFile(path.join(policyDir, name), 'utf8'); + const policy = JSON.parse(raw); + if (policy?.datasets?.[DATASET]?.insert) { + return { pageId: policy.pageId, fileName: name }; + } + } + return null; +} + +async function main() { + const port = Number(process.env.H5_PORT ?? 8081); + const baseUrl = resolvePortalBase(port); + const reporter = createReporter(); + + console.log('==> 客户下单系统 Page Data 验证'); + console.log(` Portal: ${baseUrl}`); + console.log(` 用户: ${USER_ID}\n`); + + const auth = await loginViaApi(baseUrl, { username: 'john4', password: '888888' }, reporter); + const publishKey = auth.user?.id ?? USER_ID; + + const formUrl = `${baseUrl}/MindSpace/${publishKey}/public/${FORM_PAGE}`; + const adminUrl = `${baseUrl}/MindSpace/${publishKey}/public/${ADMIN_PAGE}`; + + for (const [label, url] of [['下单页', formUrl], ['后台页', adminUrl]]) { + const res = await fetch(url, { headers: { Cookie: auth.cookie } }); + const html = await res.text(); + if (res.status !== 200) { + reporter.fail(`${label} HTTP`, `${res.status}`); + } else { + reporter.pass(`${label} HTTP 200`, url); + } + if (!html.includes('page-data-client.js')) { + reporter.fail(`${label} 脚本`, '缺少 page-data-client.js'); + } else { + reporter.pass(`${label} Page Data 客户端`, '已引用'); + } + } + + const formMeta = await findPageIdFromPolicy(publishKey); + if (!formMeta?.pageId) { + reporter.fail('Page Data policy', '未找到 customer_orders insert policy'); + process.exit(reporter.summary()); + } + reporter.pass('Page Data policy', `${formMeta.fileName} pageId=${formMeta.pageId}`); + + const insertPayload = { + customer_name: '测试客户', + phone: '13800138000', + product_name: '演示商品A', + quantity: '2', + address: '上海市浦东新区测试路 1 号', + remark: '自动化验证', + status: '待处理', + }; + + const insertRes = await fetch( + `${baseUrl}/api/public/pages/${formMeta.pageId}/data/${DATASET}/rows`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: auth.cookie }, + body: JSON.stringify(insertPayload), + }, + ); + const insertBody = await insertRes.json().catch(() => ({})); + if (!insertRes.ok) { + reporter.fail('下单 insert', `${insertRes.status} ${JSON.stringify(insertBody)}`); + } else { + reporter.pass('下单 insert', `row id=${insertBody?.data?.id ?? insertBody?.id ?? 'ok'}`); + } + + const adminMeta = await (async () => { + const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies'); + const entries = await fs.readdir(policyDir); + for (const name of entries.filter((item) => item.endsWith('.json'))) { + const policy = JSON.parse(await fs.readFile(path.join(policyDir, name), 'utf8')); + if (policy?.datasets?.[DATASET]?.read) return policy.pageId; + } + return null; + })(); + + if (!adminMeta) { + reporter.fail('后台 policy', '未找到 read policy'); + } else { + const tokenRes = await fetch(`${baseUrl}/api/public/pages/${adminMeta}/data-auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: auth.cookie }, + body: JSON.stringify({ password: '88888888' }), + }); + const tokenBody = await tokenRes.json().catch(() => ({})); + const token = tokenBody?.data?.token ?? tokenBody?.token; + if (!tokenRes.ok || !token) { + reporter.fail('后台口令认证', `${tokenRes.status}`); + } else { + const listRes = await fetch( + `${baseUrl}/api/public/pages/${adminMeta}/data/${DATASET}?limit=20`, + { headers: { 'x-page-data-token': token, Cookie: auth.cookie } }, + ); + const listBody = await listRes.json().catch(() => ({})); + const rows = listBody?.data?.rows ?? listBody?.rows ?? []; + const hit = rows.some((row) => row.customer_name === insertPayload.customer_name && row.product_name === insertPayload.product_name); + if (!listRes.ok || !hit) { + reporter.fail('后台读订单', `rows=${rows.length}, 未找到测试订单`); + } else { + reporter.pass('后台读订单', `共 ${rows.length} 条,含测试订单`); + } + } + } + + const sqlitePath = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'private-data.sqlite'); + try { + await fs.stat(sqlitePath); + reporter.pass('用户数据空间', 'private-data.sqlite 存在'); + } catch { + reporter.pass('用户数据空间', 'PostgreSQL 模式(无本地 sqlite 文件)'); + } + + console.log('\n--- 访问入口 ---'); + console.log(`下单页: ${formUrl}`); + console.log(`后台页: ${adminUrl} (口令 88888888)`); + + process.exit(reporter.summary()); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.stack ?? err.message : err); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs index 83b32d2..0f249ea 100644 --- a/server.mjs +++ b/server.mjs @@ -216,6 +216,7 @@ import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; import { createEpisodicMemoryService } from './episodic-memory.mjs'; import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; import { createSystemDisclosurePolicyService } from './system-disclosure-policy.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'; @@ -388,6 +389,7 @@ let episodicMemoryService = null; let memoryV2ConfigService = null; let skillRuntimeConfigService = null; let systemDisclosurePolicyService = null; +let agentCodeRunPolicyService = null; let wechatScheduleLlmConfigService = null; let mindSpace = null; let mindSpaceAssets = null; @@ -725,6 +727,7 @@ async function bootstrapUserAuth() { skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root: __dirname }); systemDisclosurePolicyService = createSystemDisclosurePolicyService(pool); await systemDisclosurePolicyService.initialize(); + agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env }); wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); conversationMemoryService = createConversationMemoryService(pool, { llmProviderService, @@ -1033,6 +1036,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) { @@ -1042,6 +1055,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, @@ -1050,6 +1064,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); @@ -2238,12 +2253,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) => { @@ -2271,7 +2295,7 @@ api.get('/runtime/status', async (_req, res) => { if (toolQueue) { status.toolRuntime = { ...(status.toolRuntime ?? {}), - codeRunPolicy: runtimeCodeRunPolicyStatus(), + codeRunPolicy: await runtimeCodeRunPolicyStatus(), queue: toolQueue, }; } @@ -5109,7 +5133,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/components/ChatSharePreviewModal.tsx b/src/components/ChatSharePreviewModal.tsx index 4362bfc..82d5b62 100644 --- a/src/components/ChatSharePreviewModal.tsx +++ b/src/components/ChatSharePreviewModal.tsx @@ -139,7 +139,7 @@ export function ChatSharePreviewModal({
-