fix(agent-teams): tighten config access and context propagation

This commit is contained in:
Nguyen Khac Trung Kien
2026-02-08 13:49:59 +07:00
committed by YeonGyu-Kim
parent f422cfc7af
commit 2a57feb810
7 changed files with 153 additions and 4 deletions

View File

@@ -138,6 +138,38 @@ describe("agent-teams messaging tools", () => {
expect(resumeCalls).toHaveLength(0)
})
test("send_message rejects recipient with empty team suffix", async () => {
//#given
const { manager, resumeCalls } = createManagerWithImmediateResume()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext()
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
await executeJsonTool(
tools,
"spawn_teammate",
{ team_name: "core", name: "worker_1", prompt: "Handle release prep", category: "quick" },
leadContext,
)
//#when
const invalidRecipient = await executeJsonTool(
tools,
"send_message",
{
team_name: "core",
type: "message",
recipient: "worker_1@",
summary: "sync",
content: "Please update status.",
},
leadContext,
) as { error?: string }
//#then
expect(invalidRecipient.error).toBe("recipient_team_invalid")
expect(resumeCalls).toHaveLength(0)
})
test("broadcast schedules teammate resumes without serial await", async () => {
//#given
const { manager, resumeCalls, resolveAllResumes } = createManagerWithDeferredResume()

View File

@@ -28,7 +28,10 @@ function validateRecipientTeam(recipient: unknown, teamName: string): string | n
}
const specifiedTeam = trimmed.slice(atIndex + 1).trim()
if (!specifiedTeam || specifiedTeam === teamName) {
if (!specifiedTeam) {
return "recipient_team_invalid"
}
if (specifiedTeam === teamName) {
return null
}
@@ -55,7 +58,10 @@ export function createSendMessageTool(manager: BackgroundManager): ToolDefinitio
summary: tool.schema.string().optional().describe("Short summary"),
request_id: tool.schema.string().optional().describe("Protocol request id"),
approve: tool.schema.boolean().optional().describe("Approval flag"),
sender: tool.schema.string().optional().describe("Sender name (default: team-lead)"),
sender: tool.schema
.string()
.optional()
.describe("Sender name inferred from calling session; explicit value must match resolved sender."),
},
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {

View File

@@ -3,13 +3,38 @@ import { getTeamConfigPath } from "./paths"
import { validateTeamName } from "./name-validation"
import { ensureInbox } from "./inbox-store"
import {
TeamConfig,
TeamCreateInputSchema,
TeamDeleteInputSchema,
TeamReadConfigInputSchema,
TeamToolContext,
isTeammateMember,
} from "./types"
import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store"
function resolveReaderFromContext(config: TeamConfig, context: TeamToolContext): "team-lead" | string | null {
if (context.sessionID === config.leadSessionId) {
return "team-lead"
}
const matchedMember = config.members.find((member) => isTeammateMember(member) && member.sessionID === context.sessionID)
return matchedMember?.name ?? null
}
function toPublicTeamConfig(config: TeamConfig): {
team_name: string
description: string
lead_agent_id: string
teammates: Array<{ name: string }>
} {
return {
team_name: config.name,
description: config.description,
lead_agent_id: config.leadAgentId,
teammates: listTeammates(config).map((member) => ({ name: member.name })),
}
}
export function createTeamCreateTool(): ToolDefinition {
return tool({
description: "Create a team workspace with config, inboxes, and task storage.",
@@ -82,13 +107,23 @@ export function createTeamReadConfigTool(): ToolDefinition {
args: {
team_name: tool.schema.string().describe("Team name"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
try {
const input = TeamReadConfigInputSchema.parse(args)
const config = readTeamConfig(input.team_name)
if (!config) {
return JSON.stringify({ error: "team_not_found" })
}
const actor = resolveReaderFromContext(config, context)
if (!actor) {
return JSON.stringify({ error: "unauthorized_reader_session" })
}
if (actor !== "team-lead") {
return JSON.stringify(toPublicTeamConfig(config))
}
return JSON.stringify(config)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" })

View File

@@ -0,0 +1,14 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
describe("agent-teams teammate parent context", () => {
test("forwards incoming abort signal to parent context resolver", () => {
//#given
const sourceUrl = new URL("./teammate-parent-context.ts", import.meta.url)
const source = readFileSync(sourceUrl, "utf-8")
//#then
expect(source.includes("abort: context.abort ?? new AbortController().signal")).toBe(true)
})
})

View File

@@ -8,6 +8,6 @@ export function resolveTeamParentContext(context: TeamToolContext): ParentContex
sessionID: context.sessionID,
messageID: context.messageID,
agent: context.agent ?? "sisyphus",
abort: new AbortController().signal,
abort: context.abort ?? new AbortController().signal,
} as ToolContextWithMetadata)
}

View File

@@ -1032,6 +1032,67 @@ describe("agent-teams tools functional", () => {
expect(config.leadSessionId).toBe("ses-main")
})
test("read_config rejects sessions outside the team", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext("ses-main")
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
//#when
const result = await executeJsonTool(
tools,
"read_config",
{ team_name: "core" },
createContext("ses-unknown"),
) as { error?: string }
//#then
expect(result.error).toBe("unauthorized_reader_session")
})
test("read_config returns sanitized config for teammate sessions", async () => {
//#given
const { manager } = createMockManager()
const tools = createAgentTeamsTools(manager)
const leadContext = createContext("ses-main")
await executeJsonTool(tools, "team_create", { team_name: "core" }, leadContext)
await executeJsonTool(
tools,
"spawn_teammate",
{
team_name: "core",
name: "worker_1",
prompt: "Handle release prep",
category: "quick",
},
leadContext,
)
//#when
const teammateView = await executeJsonTool(
tools,
"read_config",
{ team_name: "core" },
createContext("ses-worker-1"),
) as {
team_name?: string
description?: string
lead_agent_id?: string
teammates?: Array<{ name: string }>
leadSessionId?: string
members?: unknown
}
//#then
expect(teammateView.team_name).toBe("core")
expect(teammateView.description).toBe("")
expect(teammateView.lead_agent_id).toBe("team-lead@core")
expect(teammateView.teammates).toEqual(expect.arrayContaining([expect.objectContaining({ name: "worker_1" })]))
expect("leadSessionId" in teammateView).toBe(false)
expect("members" in teammateView).toBe(false)
})
test("rejects unknown session claiming team-lead inbox", async () => {
//#given
const { manager } = createMockManager()

View File

@@ -192,6 +192,7 @@ export const TeamProcessShutdownInputSchema = z.object({
export interface TeamToolContext {
sessionID: string
messageID: string
abort: AbortSignal
agent?: string
}