feat(hub): RoleEntry 完整 bundle + per-run tool 白名单 + per-role 触发权限

RoleEntry 扩成 (model, systemPrompt, tools) bundle,id 兼任 slash command 名。
ToolRegistry.subset() 支持按白名单构造 per-run 视图,模型永远看不到 role 外的工具。
trigger 解析 /<role> 命令,查 RoleEntry 组装 systemPrompt + 子集 registry 传给 runner。
新增 RoleTriggerGrant 表 + canTriggerRole gate,与 ADR-0004 canTriggerAgent 串联:
先问'能不能触发 agent',再问'能触发哪个 role'。未配置 role 放行(back-compat)。

- models.ts: RoleEntry 加 systemPrompt + tools 字段
- tools.ts: ToolRegistry.subset(names) 返回共享 handler 的子集视图
- trigger.ts: extractRole 解析 slash; 传 systemPrompt + runTools; catch 链容错 P2025
- runner.ts: RunRequest.systemPrompt 改 string | undefined (exactOptionalPropertyTypes)
- schema.prisma + migration: RoleTriggerGrant(projectId, roleId, principal, revokedAt)
- permission.ts: canTriggerRole gate (有 grant 记录即白名单模式,含 revoked)
- server.ts: draft/review 两个 role 加 systemPrompt + tools 白名单
- 测试: role-permission.test.ts (5) + trigger.test.ts (+3), 53 全绿
This commit is contained in:
2026-07-07 18:47:51 +08:00
parent 0fe6cabc68
commit afaf5bee09
11 changed files with 367 additions and 24 deletions
+71 -14
View File
@@ -21,7 +21,7 @@ import type { ToolRegistry } from "../agent/tools.js";
import type { ModelRegistry } from "../agent/models.js";
import { runAgent, type ProjectContext } from "../agent/runner.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
import { canTriggerAgent } from "../permission.js";
import { canTriggerAgent, canTriggerRole } from "../permission.js";
import { transcriptPath } from "../agent/transcript.js";
interface TriggerDeps {
@@ -132,11 +132,31 @@ export function makeTriggerHandler(deps: TriggerDeps) {
return;
}
// ADR-0017: role-as-data; model resolved by registry. "draft" is the
// fallback role id; real wiring lets the trigger carry a role hint (slash
// command, card selector). The registry degrades unknown roles gracefully.
const model = deps.models.resolve(undefined, "draft");
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
// slashes are left as literal text (extractRole returns null). Falls back
// to "draft" when no role is named — the registry degrades gracefully.
const { roleId: parsedRole, prompt: cleanPrompt } = extractRole(prompt, deps.models);
const roleId = parsedRole ?? "draft";
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
// canTriggerAgent above: that checked "can trigger an agent at all";
// this checks "can trigger *this* role". Unconfigured role ⇒ open.
if (senderOpenId !== "") {
const rolePerm = await canTriggerRole(deps.prisma, projectId, roleId, senderOpenId);
if (!rolePerm.allowed) {
deps.logger.info({ projectId, roleId, senderOpenId, reason: rolePerm.reason }, "feishu trigger: role permission denied");
await sendText(rt, chatId, `无权限使用角色 ${roleId}`);
return;
}
}
const role = deps.models.role(roleId);
const model = deps.models.resolve(undefined, roleId);
const providerId = deps.provider.id;
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
// is enforced by constructing a per-run registry subset. `tools: undefined`
// means the full registered set (unrestricted role).
const systemPrompt = role?.systemPrompt;
const runTools =
role?.tools !== undefined ? deps.tools.subset(role.tools) : deps.tools;
// ADR-0017: provider+model-bound session. Reuse if one exists for this
// (project, provider, model) triple; otherwise create.
@@ -156,7 +176,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
projectId,
provider: providerId,
model,
title: prompt.slice(0, 40) || null,
title: cleanPrompt.slice(0, 40) || null,
metadata: {},
},
select: { id: true },
@@ -169,10 +189,10 @@ export function makeTriggerHandler(deps: TriggerDeps) {
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
entrypoint: "FEISHU",
status: "ACTIVE",
prompt,
prompt: cleanPrompt,
model,
provider: providerId,
metadata: {},
metadata: { roleId },
},
select: { id: true },
});
@@ -190,7 +210,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
return;
}
await sendText(rt, chatId, `已开始处理(model: ${model})。`);
await sendText(rt, chatId, `已开始处理(role: ${roleId}, model: ${model})。`);
const projectCtx: ProjectContext = {
projectId,
@@ -199,7 +219,14 @@ export function makeTriggerHandler(deps: TriggerDeps) {
};
// Run the agent — fire-and-forget from the ws handler's perspective.
runAgent(deps.provider, deps.tools, { prompt, model, project: projectCtx, transcriptPath: transcriptPath(project.workspaceDir, session.id) })
// Per-run tools + systemPrompt come from the role bundle (ADR-0017).
runAgent(deps.provider, runTools, {
prompt: cleanPrompt,
model,
project: projectCtx,
systemPrompt,
transcriptPath: transcriptPath(project.workspaceDir, session.id),
})
.then(async (result) => {
await deps.prisma.agentRun.update({
where: { id: run.id },
@@ -224,10 +251,17 @@ export function makeTriggerHandler(deps: TriggerDeps) {
}));
})
.catch(async (e) => {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
});
// The run may have been removed (admin force-delete, or test reset)
// between creation and this callback. A P2025 from update is not
// actionable here — log and still post the status card.
try {
await deps.prisma.agentRun.update({
where: { id: run.id },
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
});
} catch (updateErr) {
deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED (already gone?)");
}
await sendCard(rt, chatId, buildRunStatusCard({
status: "处理出错",
model,
@@ -275,3 +309,26 @@ export function extractPrompt(msg: MessageReceiveEvent["message"]): string | nul
return null;
}
}
/**
* Parse a leading `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
* Returns the role id and the remaining prompt with the command stripped.
* Control commands already handled upstream (`/new`, `/resume`, `/reset`) are
* ignored here — they never reach this function.
*
* Unknown `/foo` that isn't a registered role: returns `null` role and the
* original prompt unchanged (the slash is treated as literal text). Role
* resolution still happens via the registry's fallback.
*/
export function extractRole(
prompt: string,
registry: { role(id: string): unknown },
): { roleId: string | null; prompt: string } {
const m = /^\/(\S+)\s*/.exec(prompt);
if (m === null || m[1] === undefined) return { roleId: null, prompt };
const roleId = m[1];
if (registry.role(roleId) === undefined) {
// Unknown slash command — leave the prompt as-is (no silent role switch).
return { roleId: null, prompt };
}
return { roleId, prompt: prompt.slice(m[0].length) };
}