fix(agent-teams): tighten config access and context propagation
This commit is contained in:
parent
f422cfc7af
commit
2a57feb810
@ -138,6 +138,38 @@ describe("agent-teams messaging tools", () => {
|
|||||||
expect(resumeCalls).toHaveLength(0)
|
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 () => {
|
test("broadcast schedules teammate resumes without serial await", async () => {
|
||||||
//#given
|
//#given
|
||||||
const { manager, resumeCalls, resolveAllResumes } = createManagerWithDeferredResume()
|
const { manager, resumeCalls, resolveAllResumes } = createManagerWithDeferredResume()
|
||||||
|
|||||||
@ -28,7 +28,10 @@ function validateRecipientTeam(recipient: unknown, teamName: string): string | n
|
|||||||
}
|
}
|
||||||
|
|
||||||
const specifiedTeam = trimmed.slice(atIndex + 1).trim()
|
const specifiedTeam = trimmed.slice(atIndex + 1).trim()
|
||||||
if (!specifiedTeam || specifiedTeam === teamName) {
|
if (!specifiedTeam) {
|
||||||
|
return "recipient_team_invalid"
|
||||||
|
}
|
||||||
|
if (specifiedTeam === teamName) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +58,10 @@ export function createSendMessageTool(manager: BackgroundManager): ToolDefinitio
|
|||||||
summary: tool.schema.string().optional().describe("Short summary"),
|
summary: tool.schema.string().optional().describe("Short summary"),
|
||||||
request_id: tool.schema.string().optional().describe("Protocol request id"),
|
request_id: tool.schema.string().optional().describe("Protocol request id"),
|
||||||
approve: tool.schema.boolean().optional().describe("Approval flag"),
|
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> => {
|
execute: async (args: Record<string, unknown>, context: TeamToolContext): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -3,13 +3,38 @@ import { getTeamConfigPath } from "./paths"
|
|||||||
import { validateTeamName } from "./name-validation"
|
import { validateTeamName } from "./name-validation"
|
||||||
import { ensureInbox } from "./inbox-store"
|
import { ensureInbox } from "./inbox-store"
|
||||||
import {
|
import {
|
||||||
|
TeamConfig,
|
||||||
TeamCreateInputSchema,
|
TeamCreateInputSchema,
|
||||||
TeamDeleteInputSchema,
|
TeamDeleteInputSchema,
|
||||||
TeamReadConfigInputSchema,
|
TeamReadConfigInputSchema,
|
||||||
TeamToolContext,
|
TeamToolContext,
|
||||||
|
isTeammateMember,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import { createTeamConfig, deleteTeamData, listTeammates, readTeamConfig, readTeamConfigOrThrow } from "./team-config-store"
|
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 {
|
export function createTeamCreateTool(): ToolDefinition {
|
||||||
return tool({
|
return tool({
|
||||||
description: "Create a team workspace with config, inboxes, and task storage.",
|
description: "Create a team workspace with config, inboxes, and task storage.",
|
||||||
@ -82,13 +107,23 @@ export function createTeamReadConfigTool(): ToolDefinition {
|
|||||||
args: {
|
args: {
|
||||||
team_name: tool.schema.string().describe("Team name"),
|
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 {
|
try {
|
||||||
const input = TeamReadConfigInputSchema.parse(args)
|
const input = TeamReadConfigInputSchema.parse(args)
|
||||||
const config = readTeamConfig(input.team_name)
|
const config = readTeamConfig(input.team_name)
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return JSON.stringify({ error: "team_not_found" })
|
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)
|
return JSON.stringify(config)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" })
|
return JSON.stringify({ error: error instanceof Error ? error.message : "team_read_config_failed" })
|
||||||
|
|||||||
14
src/tools/agent-teams/teammate-parent-context.test.ts
Normal file
14
src/tools/agent-teams/teammate-parent-context.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -8,6 +8,6 @@ export function resolveTeamParentContext(context: TeamToolContext): ParentContex
|
|||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
messageID: context.messageID,
|
messageID: context.messageID,
|
||||||
agent: context.agent ?? "sisyphus",
|
agent: context.agent ?? "sisyphus",
|
||||||
abort: new AbortController().signal,
|
abort: context.abort ?? new AbortController().signal,
|
||||||
} as ToolContextWithMetadata)
|
} as ToolContextWithMetadata)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1032,6 +1032,67 @@ describe("agent-teams tools functional", () => {
|
|||||||
expect(config.leadSessionId).toBe("ses-main")
|
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 () => {
|
test("rejects unknown session claiming team-lead inbox", async () => {
|
||||||
//#given
|
//#given
|
||||||
const { manager } = createMockManager()
|
const { manager } = createMockManager()
|
||||||
|
|||||||
@ -192,6 +192,7 @@ export const TeamProcessShutdownInputSchema = z.object({
|
|||||||
export interface TeamToolContext {
|
export interface TeamToolContext {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
messageID: string
|
messageID: string
|
||||||
|
abort: AbortSignal
|
||||||
agent?: string
|
agent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user