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
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "RoleTriggerGrant" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"principal" TEXT NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "RoleTriggerGrant_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RoleTriggerGrant_projectId_roleId_revokedAt_idx" ON "RoleTriggerGrant"("projectId", "roleId", "revokedAt");
-- CreateIndex
CREATE INDEX "RoleTriggerGrant_principal_revokedAt_idx" ON "RoleTriggerGrant"("principal", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "RoleTriggerGrant_projectId_roleId_principal_revokedAt_key" ON "RoleTriggerGrant"("projectId", "roleId", "principal", "revokedAt");
-- AddForeignKey
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RoleTriggerGrant" ADD CONSTRAINT "RoleTriggerGrant_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+30
View File
@@ -40,6 +40,7 @@ model User {
heldLocks ProjectAgentLock[] @relation("lockHolder") heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator") feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
permissionGrants PermissionGrant[] @relation("grantCreator") permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor") auditEntries AuditEntry[] @relation("auditActor")
} }
@@ -81,6 +82,7 @@ model Project {
agentLock ProjectAgentLock? agentLock ProjectAgentLock?
permissionGrants PermissionGrant[] @relation("projectGrants") permissionGrants PermissionGrant[] @relation("projectGrants")
permissionSettings PermissionSettings[] @relation("projectSettings") permissionSettings PermissionSettings[] @relation("projectSettings")
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
@@index([archivedAt]) @@index([archivedAt])
} }
@@ -260,6 +262,34 @@ model PermissionSettings {
@@index([resourceType, resourceId]) @@index([resourceType, resourceId])
} }
/// Per-role agent trigger grant. Orthogonal to ADR-0004's PermissionGrant:
/// ADR-0004 decides "can trigger an agent at all" (triggerAgent capability,
/// edit+ role); this table decides "can trigger *which* agent role" (e.g.
/// /review vs /draft). Two gates in series — both must pass.
///
/// `roleId` is an opaque string matching RoleEntry.id (ADR-0017: roles are
/// data, not a code enum). `principal` mirrors ADR-0004's opaque principal
/// (sub-typology OPEN). A project-scoped row grants the role on that project;
/// the compound unique covers "one active grant per (project, principal, role)".
/// Revocation via `revokedAt`, same pattern as PermissionGrant.
model RoleTriggerGrant {
id String @id @default(cuid())
projectId String
roleId String
principal String
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
project Project @relation("projectRoleGrants", fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("roleGrantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([projectId, roleId, principal, revokedAt])
@@index([projectId, roleId, revokedAt])
@@index([principal, revokedAt])
}
// --- Audit (ADR Audit, content OPEN) ------------------------------------- // --- Audit (ADR Audit, content OPEN) -------------------------------------
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type, /// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
+23 -5
View File
@@ -9,11 +9,17 @@
*/ */
/** /**
* A named role preset that maps to a default model. Roles are **data**, not a * A named role preset — the full per-run agent bundle. Roles are **data**, not
* code enum: admin/teachers define them (ADR-0017: role-based routing is * a code enum: admin/teachers define them (ADR-0017: role-based routing is
* product config, not a spec invariant). `roleId` is an opaque string here — * product config, not a spec invariant). `roleId` is an opaque string and
* the registry holds the role set, so new roles are added by configuration, * doubles as the slash-command name (`/draft ...`) — the registry holds the
* not by editing this file. * role set, so new roles are added by configuration, not by editing code.
*
* A role bundles everything that distinguishes one agent persona from another:
* model, system prompt, and the tool surface (files / cph / feishu / skills /
* mcps). Per-run tool whitelisting is enforced by {@link ToolRegistry.subset};
* the model never sees tools outside its role's whitelist, even if it tries to
* call them — security does not rely on the system prompt.
*/ */
export interface RoleEntry { export interface RoleEntry {
readonly id: string; readonly id: string;
@@ -21,6 +27,18 @@ export interface RoleEntry {
readonly label: string; readonly label: string;
/** Default model id for runs under this role, if a routing rule is set. */ /** Default model id for runs under this role, if a routing rule is set. */
readonly defaultModel: string | undefined; readonly defaultModel: string | undefined;
/**
* System prompt seeding the agent's persona/instructions. Prepended to the
* run's messages only at session start (resume reads it from the transcript,
* see runner.ts). `undefined` ⇒ no system prompt (bare run).
*/
readonly systemPrompt: string | undefined;
/**
* Tool names this role may use (whitelist). `undefined` ⇒ the full registered
* set (back-compat / unrestricted roles). An empty array ⇒ no tools at all.
* Names not present in the registry are silently dropped at run setup.
*/
readonly tools: readonly string[] | undefined;
} }
/** A model the admin has enabled for use by the Hub. */ /** A model the admin has enabled for use by the Hub. */
+1 -1
View File
@@ -27,7 +27,7 @@ export interface RunRequest {
/** Resolved model id (already chosen by the caller per ADR-0017). */ /** Resolved model id (already chosen by the caller per ADR-0017). */
readonly model: string; readonly model: string;
readonly project: ProjectContext; readonly project: ProjectContext;
readonly systemPrompt?: string; readonly systemPrompt: string | undefined;
/** Iteration cap before the run is returned as `length`. Default 25. */ /** Iteration cap before the run is returned as `length`. Default 25. */
readonly maxIterations?: number; readonly maxIterations?: number;
/** Path to the session transcript JSONL. If set, prior messages are loaded /** Path to the session transcript JSONL. If set, prior messages are loaded
+25
View File
@@ -60,6 +60,31 @@ export class ToolRegistry {
} }
return tool.execute(args, ctx); return tool.execute(args, ctx);
} }
/**
* Return a per-run view restricted to `names`. Names not registered are
* silently dropped (a role's whitelist may name tools that a particular Hub
* instance doesn't register). The returned registry shares the handler
* references — no copy of the closures.
*
* Per-role tool whitelisting (ADR-0017: role-based routing is product
* config). The runner receives a subset registry so the model literally
* never sees tools outside its role's whitelist: the tool list sent to the
* provider is `subset.specs()`, and `execute` on a name not in the subset
* returns an error.
*/
subset(names: readonly string[]): ToolRegistry {
const allowed = new Set(names);
const view = new ToolRegistry();
for (const [name, tool] of this.byName) {
if (allowed.has(name)) {
// Bypass the duplicate check: we're constructing from an already-valid
// registry, not re-registering.
view.byName.set(name, tool);
}
}
return view;
}
} }
/** /**
+71 -14
View File
@@ -21,7 +21,7 @@ import type { ToolRegistry } from "../agent/tools.js";
import type { ModelRegistry } from "../agent/models.js"; import type { ModelRegistry } from "../agent/models.js";
import { runAgent, type ProjectContext } from "../agent/runner.js"; import { runAgent, type ProjectContext } from "../agent/runner.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.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"; import { transcriptPath } from "../agent/transcript.js";
interface TriggerDeps { interface TriggerDeps {
@@ -132,11 +132,31 @@ export function makeTriggerHandler(deps: TriggerDeps) {
return; return;
} }
// ADR-0017: role-as-data; model resolved by registry. "draft" is the // ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
// fallback role id; real wiring lets the trigger carry a role hint (slash // slashes are left as literal text (extractRole returns null). Falls back
// command, card selector). The registry degrades unknown roles gracefully. // to "draft" when no role is named — the registry degrades gracefully.
const model = deps.models.resolve(undefined, "draft"); 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; 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 // ADR-0017: provider+model-bound session. Reuse if one exists for this
// (project, provider, model) triple; otherwise create. // (project, provider, model) triple; otherwise create.
@@ -156,7 +176,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
projectId, projectId,
provider: providerId, provider: providerId,
model, model,
title: prompt.slice(0, 40) || null, title: cleanPrompt.slice(0, 40) || null,
metadata: {}, metadata: {},
}, },
select: { id: true }, select: { id: true },
@@ -169,10 +189,10 @@ export function makeTriggerHandler(deps: TriggerDeps) {
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology) requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
entrypoint: "FEISHU", entrypoint: "FEISHU",
status: "ACTIVE", status: "ACTIVE",
prompt, prompt: cleanPrompt,
model, model,
provider: providerId, provider: providerId,
metadata: {}, metadata: { roleId },
}, },
select: { id: true }, select: { id: true },
}); });
@@ -190,7 +210,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
return; return;
} }
await sendText(rt, chatId, `已开始处理(model: ${model})。`); await sendText(rt, chatId, `已开始处理(role: ${roleId}, model: ${model})。`);
const projectCtx: ProjectContext = { const projectCtx: ProjectContext = {
projectId, projectId,
@@ -199,7 +219,14 @@ export function makeTriggerHandler(deps: TriggerDeps) {
}; };
// Run the agent — fire-and-forget from the ws handler's perspective. // 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) => { .then(async (result) => {
await deps.prisma.agentRun.update({ await deps.prisma.agentRun.update({
where: { id: run.id }, where: { id: run.id },
@@ -224,10 +251,17 @@ export function makeTriggerHandler(deps: TriggerDeps) {
})); }));
}) })
.catch(async (e) => { .catch(async (e) => {
await deps.prisma.agentRun.update({ // The run may have been removed (admin force-delete, or test reset)
where: { id: run.id }, // between creation and this callback. A P2025 from update is not
data: { status: "FAILED", error: String(e), finishedAt: new Date() }, // 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({ await sendCard(rt, chatId, buildRunStatusCard({
status: "处理出错", status: "处理出错",
model, model,
@@ -275,3 +309,26 @@ export function extractPrompt(msg: MessageReceiveEvent["message"]): string | nul
return null; 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) };
}
+51
View File
@@ -68,6 +68,57 @@ export async function canTriggerAgent(
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` }; return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
} }
} }
/**
* Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to
* {@link canTriggerAgent}: that decides "can trigger an agent at all"
* (ADR-0004 triggerAgent capability); this decides "can trigger *which* role".
* Two gates in series — both must pass.
*
* Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)`
* (regardless of principal), the role is unconfigured on this project →
* **allow** (back-compat: a project that hasn't configured per-role grants
* doesn't get locked down). Once any grant exists for that role on the
* project, only principals with an active grant may trigger it. "Grants exist
* but this principal has none" → deny (admin explicitly restricted the role).
*
* `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the
* same opaque string ADR-0004 uses (sender open_id here; principal
* sub-typology OPEN).
*/
export async function canTriggerRole(
prisma: PrismaClient,
projectId: string,
roleId: string,
principal: string,
): Promise<PermissionResult> {
try {
// Any grant record (active OR revoked) for this (project, role)? If none,
// the role is unconfigured → allow (back-compat: no per-role restriction).
// A revoked grant still counts as "configured" — revoking one person must
// not silently reopen the role to everyone.
const anyGrant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId },
select: { id: true },
});
if (anyGrant === null) {
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
}
// Grants exist — this principal must hold one.
const grant = await prisma.roleTriggerGrant.findFirst({
where: { projectId, roleId, principal, revokedAt: null },
select: { id: true },
});
if (grant !== null) {
return { allowed: true, reason: `granted role ${roleId}` };
}
return {
allowed: false,
reason: `no active ${roleId} grant for ${principal} on project ${projectId}`,
};
} catch (e) {
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
}
}
/// Whether a role grants edit capability (edit or manage). Exported for tests. /// Whether a role grants edit capability (edit or manage). Exported for tests.
export function roleGrantsEdit(role: PermissionRole): boolean { export function roleGrantsEdit(role: PermissionRole): boolean {
+16 -2
View File
@@ -52,8 +52,22 @@ async function main(): Promise<void> {
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true }, { id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
], ],
[ [
{ id: "draft", label: "草稿", defaultModel: "z-ai/glm-4.6" }, {
{ id: "review", label: "审校", defaultModel: "anthropic/claude-3.5-sonnet" }, id: "draft",
label: "草稿",
defaultModel: "z-ai/glm-4.6",
systemPrompt: undefined,
// Drafting needs full workspace + cph access.
tools: ["read_file", "write_file", "list_files", "cph_check", "cph_build", "feishu_read_context"],
},
{
id: "review",
label: "审校",
defaultModel: "anthropic/claude-3.5-sonnet",
systemPrompt: undefined,
// Review is read-only on artifacts; no write_file, no build.
tools: ["read_file", "list_files", "cph_check", "feishu_read_context"],
},
], ],
); );
+1
View File
@@ -22,6 +22,7 @@ export async function resetDb(): Promise<void> {
"AuditEntry", "AuditEntry",
"PermissionSettings", "PermissionSettings",
"PermissionGrant", "PermissionGrant",
"RoleTriggerGrant",
"ProjectAgentLock", "ProjectAgentLock",
"AgentRun", "AgentRun",
"AgentSession", "AgentSession",
@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterAll } from "vitest";
import { prisma, resetDb } from "./helpers.js";
import { canTriggerRole } from "../../src/permission.js";
describe("canTriggerRole (integration, per-role gate)", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
const project = await prisma.project.create({
data: { id: "p-role-1", name: "T", workspaceDir: "/tmp/x" },
});
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
expect(r.allowed).toBe(true);
});
it("allows when principal holds an active grant for the role", async () => {
const project = await prisma.project.create({
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" },
});
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(true);
});
it("denies when grants exist for the role but principal has none", async () => {
const project = await prisma.project.create({
data: { id: "p-role-3", name: "T", workspaceDir: "/tmp/x" },
});
// Someone else has the role; ou_b does not.
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a" },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_b");
expect(r.allowed).toBe(false);
});
it("denies when the principal's grant was revoked", async () => {
const project = await prisma.project.create({
data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" },
});
await prisma.roleTriggerGrant.create({
data: { projectId: project.id, roleId: "review", principal: "ou_a", revokedAt: new Date() },
});
const r = await canTriggerRole(prisma, project.id, "review", "ou_a");
expect(r.allowed).toBe(false);
});
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => {
const p1 = await prisma.project.create({ data: { id: "p-role-5a", name: "T", workspaceDir: "/tmp/x" } });
const p2 = await prisma.project.create({ data: { id: "p-role-5b", name: "T", workspaceDir: "/tmp/x" } });
await prisma.roleTriggerGrant.create({
data: { projectId: p1.id, roleId: "review", principal: "ou_a" },
});
// p2 has the role configured for someone else; ou_a has no grant there.
await prisma.roleTriggerGrant.create({
data: { projectId: p2.id, roleId: "review", principal: "ou_other" },
});
const onP1 = await canTriggerRole(prisma, p1.id, "review", "ou_a");
expect(onP1.allowed).toBe(true);
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
expect(onP2.allowed).toBe(false);
});
});
+51 -2
View File
@@ -33,7 +33,10 @@ describe("trigger full lifecycle (integration)", () => {
tools = new ToolRegistry(); tools = new ToolRegistry();
models = new InMemoryModelRegistry( models = new InMemoryModelRegistry(
[{ id: "mock-model", label: "Mock", toolCapable: true }], [{ id: "mock-model", label: "Mock", toolCapable: true }],
[{ id: "draft", label: "草稿", defaultModel: "mock-model" }], [
{ id: "draft", label: "草稿", defaultModel: "mock-model", systemPrompt: undefined, tools: undefined },
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
],
); );
rt = mockFeishuRuntime(); rt = mockFeishuRuntime();
}); });
@@ -57,7 +60,7 @@ describe("trigger full lifecycle (integration)", () => {
// A status card was sent. // A status card was sent.
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1); expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
expect(rt.sentTexts).toContain("已开始处理(model: mock-model)。"); expect(rt.sentTexts).toContain("已开始处理(role: draft, model: mock-model)。");
}); });
it("rejects a sender without edit grant (ADR-0004)", async () => { it("rejects a sender without edit grant (ADR-0004)", async () => {
@@ -198,6 +201,52 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.prompt).toBe("/unknown"); expect(runs[0]?.prompt).toBe("/unknown");
}); });
}); });
it("denies /review when sender has no role grant (per-role gate)", async () => {
await seedProject("proj-10", "chat-10");
// Someone else holds review; ou_test_user does not.
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
});
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
expect(rt.sentTexts).toContain("无权限使用角色 review。");
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("allows /review when sender holds the role grant", async () => {
await seedProject("proj-11", "chat-11");
await prisma.roleTriggerGrant.create({
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
});
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.status).toBe("COMPLETED");
expect(runs[0]?.metadata).toMatchObject({ roleId: "review" });
});
});
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
await seedProject("proj-12", "chat-12");
const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger });
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
await vi.waitFor(async () => {
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(1);
expect(runs[0]?.prompt).toBe("写第三单元");
expect(runs[0]?.metadata).toMatchObject({ roleId: "draft" });
});
});
}); });
afterAll(async () => { afterAll(async () => {