forked from EduCraft/curriculum-project-hub
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:
+71
-14
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user