feat(hooks): add team tool gating hook with tests
This commit is contained in:
@@ -0,0 +1,276 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||||
|
import { TeamModeConfigSchema } from "../../config/schema/team-mode"
|
||||||
|
import {
|
||||||
|
clearTeamSessionRegistry,
|
||||||
|
registerTeamSession,
|
||||||
|
} from "../../features/team-mode/team-session-registry"
|
||||||
|
import type { RuntimeState } from "../../features/team-mode/types"
|
||||||
|
import { saveRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||||
|
import { createTeamToolGating } from "./hook"
|
||||||
|
|
||||||
|
function createConfig(overrides?: Partial<TeamModeConfig>, baseDir = "/tmp/team-mode"): TeamModeConfig {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
tmux_visualization: false,
|
||||||
|
max_parallel_members: 4,
|
||||||
|
max_members: 8,
|
||||||
|
max_messages_per_run: 10_000,
|
||||||
|
max_wall_clock_minutes: 120,
|
||||||
|
max_member_turns: 500,
|
||||||
|
base_dir: baseDir,
|
||||||
|
message_payload_max_bytes: 32_768,
|
||||||
|
recipient_unread_max_bytes: 262_144,
|
||||||
|
mailbox_poll_interval_ms: 3_000,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntimeState(): RuntimeState {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
teamRunId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
teamName: "team-alpha",
|
||||||
|
specSource: "project",
|
||||||
|
createdAt: 1,
|
||||||
|
status: "active",
|
||||||
|
leadSessionId: "lead-session",
|
||||||
|
members: [
|
||||||
|
{ name: "m1", sessionId: "member-session-1", agentType: "general-purpose", status: "running", pendingInjectedMessageIds: [] },
|
||||||
|
{ name: "m2", sessionId: "member-session-2", agentType: "general-purpose", status: "running", pendingInjectedMessageIds: [] },
|
||||||
|
],
|
||||||
|
shutdownRequests: [],
|
||||||
|
bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10_000, maxWallClockMinutes: 120, maxMemberTurns: 500 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTeams(baseDir: string, ...runtimeStates: RuntimeState[]): Promise<void> {
|
||||||
|
const config = TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true })
|
||||||
|
await Promise.all(runtimeStates.map(async (runtimeState) => {
|
||||||
|
await mkdir(path.join(baseDir, "runtime", runtimeState.teamRunId), { recursive: true })
|
||||||
|
await saveRuntimeState(runtimeState, config)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHook(tool: string, sessionID: string, args: Record<string, unknown>, config?: Partial<TeamModeConfig>, baseDir = "/tmp/team-mode"): Promise<void> {
|
||||||
|
const hook = createTeamToolGating({ directory: baseDir } as PluginInput, createConfig(config, baseDir))
|
||||||
|
await hook["tool.execute.before"]?.({ tool, sessionID, callID: "call-1" }, { args })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createTeamToolGating", () => {
|
||||||
|
const temporaryDirectories: string[] = []
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
temporaryDirectories.length = 0
|
||||||
|
clearTeamSessionRegistry()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
clearTeamSessionRegistry()
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows a fresh session to call team_create", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_create", "fresh-session", {}, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows team_list from a fresh session", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_list", "fresh-session", {}, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects team_create when the caller is already a team member", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_create", "member-session-1", {}, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).rejects.toThrow("team_create denied: session is already a participant of team 11111111-1111-4111-8111-111111111111")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows the target member to self-approve shutdown", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_approve_shutdown", "member-session-1", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows the lead to force-approve shutdown", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_approve_shutdown", "lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects a non-target member from approving shutdown", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_approve_shutdown", "member-session-2", { teamRunId: "11111111-1111-4111-8111-111111111111", memberName: "m1" }, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).rejects.toThrow("team_approve_shutdown: caller must be target member or team lead")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows delegate-task for team members without a run-wide budget", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("delegate-task", "member-session-1", {}, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows team_delete for the lead of the target team", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_delete", "lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("no-ops for unrelated tools without querying team state", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("write", "fresh-session", {}, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows team_send_message during the spawn race when runtime state lacks the member's sessionId but the registry already has it", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
const staleRuntimeState: RuntimeState = {
|
||||||
|
...createRuntimeState(),
|
||||||
|
members: [
|
||||||
|
{ name: "m1", agentType: "general-purpose", status: "pending", pendingInjectedMessageIds: [] },
|
||||||
|
{ name: "m2", agentType: "general-purpose", status: "pending", pendingInjectedMessageIds: [] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
await seedTeams(baseDir, staleRuntimeState)
|
||||||
|
registerTeamSession("just-spawned-session", {
|
||||||
|
teamRunId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
memberName: "m1",
|
||||||
|
role: "member",
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_send_message", "just-spawned-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows team_send_message from a lead whose session is tracked only in the registry", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
const staleRuntimeState: RuntimeState = {
|
||||||
|
...createRuntimeState(),
|
||||||
|
leadSessionId: undefined,
|
||||||
|
members: [
|
||||||
|
{ name: "lead", agentType: "leader", status: "pending", pendingInjectedMessageIds: [] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
await seedTeams(baseDir, staleRuntimeState)
|
||||||
|
registerTeamSession("caller-lead-session", {
|
||||||
|
teamRunId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
memberName: "lead",
|
||||||
|
role: "lead",
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_send_message", "caller-lead-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects team_send_message when the session is not in the registry and not in runtime state", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
await seedTeams(baseDir, createRuntimeState())
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_send_message", "unknown-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).rejects.toThrow("team-mode tool team_send_message denied: not a participant of team 11111111-1111-4111-8111-111111111111")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects team_send_message when the registry only has the caller for a different team than the requested teamRunId", async () => {
|
||||||
|
// given
|
||||||
|
const baseDir = await mkdtemp(path.join(tmpdir(), "team-tool-gating-"))
|
||||||
|
temporaryDirectories.push(baseDir)
|
||||||
|
const emptyState: RuntimeState = { ...createRuntimeState(), members: [] }
|
||||||
|
await seedTeams(baseDir, emptyState)
|
||||||
|
registerTeamSession("cross-team-session", {
|
||||||
|
teamRunId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
memberName: "other-team-member",
|
||||||
|
role: "member",
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = runHook("team_send_message", "cross-team-session", { teamRunId: "11111111-1111-4111-8111-111111111111" }, undefined, baseDir)
|
||||||
|
|
||||||
|
// then
|
||||||
|
await expect(result).rejects.toThrow("denied: not a participant of team 11111111-1111-4111-8111-111111111111")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
import type { TeamModeConfig } from "../../config/schema/team-mode"
|
||||||
|
import { lookupTeamSession } from "../../features/team-mode/team-session-registry"
|
||||||
|
import type { RuntimeState } from "../../features/team-mode/types"
|
||||||
|
import {
|
||||||
|
listActiveTeams,
|
||||||
|
loadRuntimeState,
|
||||||
|
} from "../../features/team-mode/team-state-store"
|
||||||
|
|
||||||
|
const ACTIVE_RUNTIME_STATUSES = new Set<RuntimeState["status"]>(["creating", "active", "shutdown_requested"])
|
||||||
|
const UNIVERSAL_TOOL_NAMES = new Set([
|
||||||
|
"team_send_message",
|
||||||
|
"team_task_create",
|
||||||
|
"team_task_list",
|
||||||
|
"team_task_update",
|
||||||
|
"team_task_get",
|
||||||
|
"team_status",
|
||||||
|
])
|
||||||
|
|
||||||
|
type TeamParticipant =
|
||||||
|
| { role: "neither" }
|
||||||
|
| { role: "lead"; teamRunId: string }
|
||||||
|
| { role: "member"; teamRunId: string; memberName: string }
|
||||||
|
|
||||||
|
function getStringArg(args: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = args[key]
|
||||||
|
return typeof value === "string" ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParticipantFromRegistry(sessionID: string): TeamParticipant | undefined {
|
||||||
|
const entry = lookupTeamSession(sessionID)
|
||||||
|
if (!entry) return undefined
|
||||||
|
if (entry.role === "lead") {
|
||||||
|
return { role: "lead", teamRunId: entry.teamRunId }
|
||||||
|
}
|
||||||
|
return { role: "member", teamRunId: entry.teamRunId, memberName: entry.memberName }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveParticipant(sessionID: string, config: TeamModeConfig): Promise<TeamParticipant> {
|
||||||
|
const fromRegistry = resolveParticipantFromRegistry(sessionID)
|
||||||
|
if (fromRegistry) {
|
||||||
|
return fromRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTeams = await listActiveTeams(config)
|
||||||
|
|
||||||
|
for (const activeTeam of activeTeams) {
|
||||||
|
const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config)
|
||||||
|
if (!ACTIVE_RUNTIME_STATUSES.has(runtimeState.status)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeState.leadSessionId === sessionID) {
|
||||||
|
return { role: "lead", teamRunId: runtimeState.teamRunId }
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedMember = runtimeState.members.find((member) => member.sessionId === sessionID)
|
||||||
|
if (matchedMember) {
|
||||||
|
return {
|
||||||
|
role: "member",
|
||||||
|
teamRunId: runtimeState.teamRunId,
|
||||||
|
memberName: matchedMember.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { role: "neither" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLeadOfTargetTeam(participant: TeamParticipant, teamRunId: string | undefined): boolean {
|
||||||
|
return participant.role === "lead" && participant.teamRunId === teamRunId
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTargetMember(participant: TeamParticipant, teamRunId: string | undefined, memberName: string | undefined): boolean {
|
||||||
|
return participant.role === "member"
|
||||||
|
&& participant.teamRunId === teamRunId
|
||||||
|
&& participant.memberName === memberName
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTeamToolGating(_ctx: PluginInput, config: TeamModeConfig | undefined): Hooks {
|
||||||
|
return {
|
||||||
|
"tool.execute.before": async (
|
||||||
|
input: { tool: string; sessionID: string; callID: string },
|
||||||
|
output: { args: Record<string, unknown> },
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!config?.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolName = input.tool
|
||||||
|
if (!toolName.startsWith("team_") && toolName !== "delegate-task") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = await resolveParticipant(input.sessionID, config)
|
||||||
|
|
||||||
|
if (toolName === "delegate-task") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "team_create") {
|
||||||
|
if (participant.role !== "neither") {
|
||||||
|
throw new Error(`team_create denied: session is already a participant of team ${participant.teamRunId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamRunId = getStringArg(output.args, "teamRunId")
|
||||||
|
const memberName = getStringArg(output.args, "memberName")
|
||||||
|
|
||||||
|
if (toolName === "team_delete" || toolName === "team_shutdown_request") {
|
||||||
|
if (!isLeadOfTargetTeam(participant, teamRunId)) {
|
||||||
|
throw new Error(`${toolName} is lead-only`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "team_approve_shutdown" || toolName === "team_reject_shutdown") {
|
||||||
|
if (!isLeadOfTargetTeam(participant, teamRunId) && !isTargetMember(participant, teamRunId, memberName)) {
|
||||||
|
throw new Error(`${toolName}: caller must be target member or team lead`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === "team_list") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UNIVERSAL_TOOL_NAMES.has(toolName)) {
|
||||||
|
if (
|
||||||
|
(participant.role === "lead" || participant.role === "member")
|
||||||
|
&& participant.teamRunId === teamRunId
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
teamRunId === undefined
|
||||||
|
? `team-mode tool ${toolName} requires teamRunId argument`
|
||||||
|
: `team-mode tool ${toolName} denied: not a participant of team ${teamRunId}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { createTeamToolGating } from "./hook"
|
||||||
Reference in New Issue
Block a user