From 212e163006ae120827923335e568845a886db775 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 2 Jul 2026 10:47:22 +0800 Subject: [PATCH] feat: add code run rollout policy gates --- agent-run-routes.mjs | 42 +++++++++++++ agent-run-routes.test.mjs | 123 ++++++++++++++++++++++++++++++++++++++ server.mjs | 23 +++++++ 3 files changed, 188 insertions(+) diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index f23941d..f985334 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -13,11 +13,41 @@ function parseUserIdSet(value) { ); } +function parseTaskTypeSet(value) { + return new Set( + String(value ?? '') + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean), + ); +} + +function hasExpectedFileValidation(userMessage) { + const metadata = userMessage?.metadata; + const runMetadata = metadata?.memindRun ?? metadata?.agentRun ?? {}; + const validation = runMetadata.validation ?? metadata?.toolGatewayValidation; + if (!validation || typeof validation !== 'object' || Array.isArray(validation)) return false; + const expectedFile = validation.expectedFile ?? validation.expectedPath; + if (typeof expectedFile === 'string' && expectedFile.trim()) return true; + if (expectedFile && typeof expectedFile === 'object' && !Array.isArray(expectedFile)) { + const filePath = String(expectedFile.path ?? expectedFile.file ?? expectedFile.relativePath ?? '').trim(); + if (filePath) return true; + } + if (!Array.isArray(validation.expectedFiles)) return false; + return validation.expectedFiles.some((item) => { + if (typeof item === 'string') return Boolean(item.trim()); + if (!item || typeof item !== 'object' || Array.isArray(item)) return false; + return Boolean(String(item.path ?? item.file ?? item.relativePath ?? '').trim()); + }); +} + export function createPostAgentRunsHandler({ userAuth, agentRunGateway, 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), }) { return async function postAgentRuns(request, response) { try { @@ -55,6 +85,18 @@ export function createPostAgentRunsHandler({ 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 (sessionId) { const owns = await userAuth.ownsSession(request.currentUser.id, sessionId); if (!owns) { diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index ec1af37..d1a0daf 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -179,6 +179,129 @@ test('POST /agent/runs accepts code tool mode for whitelisted user', async () => assert.equal(created[0].payload.toolMode, 'code'); }); +test('POST /agent/runs accepts allowlisted code task type with validation policy', async () => { + const created = []; + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return true; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + created.push({ userId, payload }); + return { id: 'run-code', status: 'queued' }; + }, + }, + codeRunsEnabled: true, + codeRunUserIds: new Set(['user-1']), + codeRunTaskTypes: new Set(['small_patch']), + requireCodeRunValidation: true, + }); + const userMessage = { + role: 'user', + content: [{ type: 'text', text: 'create artifact' }], + metadata: { + memindRun: { + validation: { + expectedFile: { + path: 'RESULT.md', + contains: 'ok', + }, + }, + }, + }, + }; + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-code-small-patch', + user_message: userMessage, + tool_mode: 'code', + task_type: 'small_patch', + }, + }, + res, + ); + + assert.equal(res.statusCode, 202); + assert.equal(created[0].payload.taskType, 'small_patch'); + assert.deepEqual(created[0].payload.userMessage, userMessage); +}); + +test('POST /agent/runs rejects non-allowlisted code task type', async () => { + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + throw new Error('should not check ownership after task type gate rejects code mode'); + }, + }, + agentRunGateway: { + async createRun() { + throw new Error('should not be called'); + }, + }, + codeRunsEnabled: true, + codeRunUserIds: new Set(['user-1']), + codeRunTaskTypes: new Set(['small_patch']), + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-code-repo-refactor', + user_message: { role: 'user', content: [] }, + tool_mode: 'code', + task_type: 'repo_refactor', + }, + }, + res, + ); + + assert.equal(res.statusCode, 403); + assert.deepEqual(res.body, { message: '当前代码任务类型未开启灰度' }); +}); + +test('POST /agent/runs requires validation metadata for guarded code tasks', async () => { + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + throw new Error('should not check ownership after validation gate rejects code mode'); + }, + }, + agentRunGateway: { + async createRun() { + throw new Error('should not be called'); + }, + }, + codeRunsEnabled: true, + codeRunUserIds: new Set(['user-1']), + requireCodeRunValidation: true, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-code-no-validation', + user_message: { role: 'user', content: [] }, + tool_mode: 'code', + task_type: 'small_patch', + }, + }, + res, + ); + + assert.equal(res.statusCode, 400); + assert.deepEqual(res.body, { message: '代码任务必须声明产物校验规则' }); +}); + test('POST /agent/runs rejects code tool mode for non-whitelisted user', async () => { const handler = createPostAgentRunsHandler({ userAuth: { diff --git a/server.mjs b/server.mjs index 8ada420..632c3af 100644 --- a/server.mjs +++ b/server.mjs @@ -1783,6 +1783,28 @@ api.get('/status', async (_req, res, next) => { return next(); }); +function runtimeEnvFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + +function runtimeCsvList(value) { + return String(value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +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), + }; +} + api.get('/runtime/status', async (_req, res) => { await userAuthReady; if (!tkmindProxy?.getRuntimeStatus) { @@ -1798,6 +1820,7 @@ api.get('/runtime/status', async (_req, res) => { if (toolQueue) { status.toolRuntime = { ...(status.toolRuntime ?? {}), + codeRunPolicy: runtimeCodeRunPolicyStatus(), queue: toolQueue, }; }