feat: add code run rollout policy gates
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+23
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user