From 02a2c41d8f4180a869fb51d40693d72e84cc844b Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 1 Jun 2026 10:50:13 +1000 Subject: [PATCH 1/3] fix: enforce channel membership for realtime agent mode --- .../[id]/talk/realtime/session/route.test.ts | 136 ++++++++++++++++-- .../[id]/talk/realtime/session/route.ts | 55 ++++++- 2 files changed, 180 insertions(+), 11 deletions(-) diff --git a/src/app/api/runtimes/[id]/talk/realtime/session/route.test.ts b/src/app/api/runtimes/[id]/talk/realtime/session/route.test.ts index 3dbdd07..f6f2e7c 100644 --- a/src/app/api/runtimes/[id]/talk/realtime/session/route.test.ts +++ b/src/app/api/runtimes/[id]/talk/realtime/session/route.test.ts @@ -5,33 +5,84 @@ type RuntimeRow = { ownerUserId: string | null; }; -type Field = { key: keyof RuntimeRow }; -type Predicate = (row: RuntimeRow) => boolean; +type AgentRow = { + id: string; + callsign: string; +}; -const { mockRuntimeRows, mockGetGatewayClientForRuntime } = vi.hoisted(() => ({ +type ChannelMemberRow = { + id: string; + channelId: string; + memberType: "user" | "agent"; + agentId: string | null; + role: string; + agentParticipationMode: string | null; +}; + +type DbRow = RuntimeRow | AgentRow | ChannelMemberRow; +type Field = { table: string; key: string }; +type Predicate = (row: DbRow) => boolean; + +const { mockRuntimeRows, mockAgentRows, mockChannelMemberRows, mockGetGatewayClientForRuntime } = vi.hoisted(() => ({ mockRuntimeRows: [] as RuntimeRow[], + mockAgentRows: [] as AgentRow[], + mockChannelMemberRows: [] as ChannelMemberRow[], mockGetGatewayClientForRuntime: vi.fn(), })); vi.mock("@/db/schema", () => ({ companyRuntimes: { - id: { key: "id" }, - ownerUserId: { key: "ownerUserId" }, + __table: "companyRuntimes", + id: { table: "companyRuntimes", key: "id" }, + ownerUserId: { table: "companyRuntimes", key: "ownerUserId" }, + }, + agents: { + __table: "agents", + id: { table: "agents", key: "id" }, + callsign: { table: "agents", key: "callsign" }, + }, + channelMembers: { + __table: "channelMembers", + id: { table: "channelMembers", key: "id" }, + channelId: { table: "channelMembers", key: "channelId" }, + memberType: { table: "channelMembers", key: "memberType" }, + agentId: { table: "channelMembers", key: "agentId" }, + role: { table: "channelMembers", key: "role" }, + agentParticipationMode: { table: "channelMembers", key: "agentParticipationMode" }, }, })); vi.mock("drizzle-orm", () => ({ - eq: (field: Field, value: unknown): Predicate => (row) => row[field.key] === value, + eq: (field: Field, value: unknown): Predicate => (row) => (row as Record)[field.key] === value, and: (...predicates: Array): Predicate => (row) => predicates.every((predicate) => predicate?.(row) ?? true), })); +function rowsForTable(table: { __table: string }) { + if (table.__table === "companyRuntimes") return mockRuntimeRows; + if (table.__table === "agents") return mockAgentRows; + if (table.__table === "channelMembers") return mockChannelMemberRows; + return []; +} + +function projectRows(rows: DbRow[], selection: Record) { + return rows.map((row) => + Object.fromEntries( + Object.entries(selection).map(([key, field]) => [ + key, + (row as Record)[field.key], + ]), + ), + ); +} + vi.mock("@/db", () => ({ db: { - select: () => ({ - from: () => ({ + select: (selection: Record) => ({ + from: (table: { __table: string }) => ({ where: (predicate: Predicate) => ({ - limit: (count: number) => Promise.resolve(mockRuntimeRows.filter(predicate).slice(0, count)), + limit: (count: number) => + Promise.resolve(projectRows(rowsForTable(table).filter(predicate).slice(0, count), selection)), }), }), }), @@ -41,7 +92,7 @@ vi.mock("@/db", () => ({ vi.mock("@/lib/agent-access", () => ({ getAgentAccessContext: () => ({ userId: "user_1", activeCompanyId: null, memberships: [] }), - buildRuntimeReadWhere: () => (row: RuntimeRow) => row.ownerUserId === "user_1", + buildRuntimeReadWhere: () => (row: DbRow) => (row as RuntimeRow).ownerUserId === "user_1", })); vi.mock("@/lib/gateway-chat-pool", () => ({ @@ -54,6 +105,8 @@ describe("POST /api/runtimes/[id]/talk/realtime/session", () => { beforeEach(() => { vi.clearAllMocks(); mockRuntimeRows.length = 0; + mockAgentRows.length = 0; + mockChannelMemberRows.length = 0; }); it("proxies realtime talk session requests through an accessible runtime", async () => { @@ -124,6 +177,69 @@ describe("POST /api/runtimes/[id]/talk/realtime/session", () => { })); }); + it("rejects channel-scoped realtime sessions for agents outside the channel", async () => { + const realtimeTalkSession = vi.fn(); + mockRuntimeRows.push({ id: "rt_1", ownerUserId: "user_1" }); + mockAgentRows.push({ id: "agent_1", callsign: "neo" }); + mockGetGatewayClientForRuntime.mockResolvedValue({ realtimeTalkSession }); + + const response = await POST( + new Request("http://localhost/api/runtimes/rt_1/talk/realtime/session", { + method: "POST", + body: JSON.stringify({ + agentId: "main", + channelAgentId: "neo", + channelId: "channel_crew", + }), + }), + { params: Promise.resolve({ id: "rt_1" }) }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Agent is not a member of this channel.", + }); + expect(mockGetGatewayClientForRuntime).not.toHaveBeenCalled(); + expect(realtimeTalkSession).not.toHaveBeenCalled(); + }); + + it("allows channel-scoped realtime sessions for eligible channel agents", async () => { + const realtimeTalkSession = vi.fn().mockResolvedValue({ + transport: "gateway-relay", + relaySessionId: "relay_1", + }); + mockRuntimeRows.push({ id: "rt_1", ownerUserId: "user_1" }); + mockAgentRows.push({ id: "agent_1", callsign: "neo" }); + mockChannelMemberRows.push({ + id: "member_1", + channelId: "channel_crew", + memberType: "agent", + agentId: "agent_1", + role: "member", + agentParticipationMode: "mention_only", + }); + mockGetGatewayClientForRuntime.mockResolvedValue({ realtimeTalkSession }); + + const response = await POST( + new Request("http://localhost/api/runtimes/rt_1/talk/realtime/session", { + method: "POST", + body: JSON.stringify({ + sessionKey: "main", + agentId: "main", + channelAgentId: "neo", + channelId: "channel_crew", + }), + }), + { params: Promise.resolve({ id: "rt_1" }) }, + ); + + expect(response.status).toBe(200); + expect(realtimeTalkSession).toHaveBeenCalledWith(expect.objectContaining({ + agentId: "main", + sessionKey: "main", + })); + }); + it("does not call the gateway for unreadable runtimes", async () => { mockRuntimeRows.push({ id: "rt_1", ownerUserId: "user_2" }); diff --git a/src/app/api/runtimes/[id]/talk/realtime/session/route.ts b/src/app/api/runtimes/[id]/talk/realtime/session/route.ts index 7ca72e1..d565b8b 100644 --- a/src/app/api/runtimes/[id]/talk/realtime/session/route.ts +++ b/src/app/api/runtimes/[id]/talk/realtime/session/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { and, eq } from "drizzle-orm"; import { db, withRetry } from "@/db"; -import { companyRuntimes } from "@/db/schema"; +import { agents, channelMembers, companyRuntimes } from "@/db/schema"; import { buildRuntimeReadWhere, getAgentAccessContext } from "@/lib/agent-access"; import { getGatewayClientForRuntime } from "@/lib/gateway-chat-pool"; @@ -9,6 +9,8 @@ export const dynamic = "force-dynamic"; const REALTIME_SLOW_SPEECH_SILENCE_MS = 2000; const REALTIME_SLOW_SPEECH_PREFIX_PADDING_MS = 500; +const CHANNEL_AGENT_SPEAKING_ROLES = new Set(["owner", "admin", "member", "contributor"]); +const CHANNEL_AGENT_SPEAKING_MODES = new Set(["mention_only", "proactive", "on_call"]); export async function POST( request: Request, @@ -32,6 +34,16 @@ export async function POST( if (!runtime) return NextResponse.json({ error: "Runtime not found" }, { status: 404 }); const body = await request.json().catch(() => ({})); + const channelId = readOptionalString(body.channelId); + const channelAgentId = readOptionalString(body.channelAgentId) ?? readOptionalString(body.agentId); + if (channelId) { + const violation = await resolveRealtimeChannelAgentViolation({ + channelId, + agentCallsign: channelAgentId, + }); + if (violation) return NextResponse.json({ error: violation }, { status: 403 }); + } + const client = await getGatewayClientForRuntime(runtime.id); const session = await client.realtimeTalkSession({ sessionKey: readOptionalString(body.sessionKey), @@ -51,6 +63,47 @@ export async function POST( } } +async function resolveRealtimeChannelAgentViolation(params: { + channelId: string; + agentCallsign?: string; +}) { + const callsign = params.agentCallsign?.trim(); + if (!callsign) return "Channel agent mention is required."; + + const [agent] = await withRetry(() => + db! + .select({ id: agents.id }) + .from(agents) + .where(eq(agents.callsign, callsign)) + .limit(1) + ); + if (!agent) return "Agent is not a member of this channel."; + + const [member] = await withRetry(() => + db! + .select({ + role: channelMembers.role, + agentParticipationMode: channelMembers.agentParticipationMode, + }) + .from(channelMembers) + .where(and( + eq(channelMembers.channelId, params.channelId), + eq(channelMembers.memberType, "agent"), + eq(channelMembers.agentId, agent.id), + )) + .limit(1) + ); + + if (!member) return "Agent is not a member of this channel."; + if (!CHANNEL_AGENT_SPEAKING_ROLES.has(member.role)) { + return "Agent cannot post in this channel."; + } + if (!CHANNEL_AGENT_SPEAKING_MODES.has(member.agentParticipationMode ?? "mention_only")) { + return "Agent is not configured to respond in this channel."; + } + return null; +} + function readOptionalString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } From 11d6451465c0a974c6bbbfd5cd166b1f070b1a53 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Mon, 1 Jun 2026 10:50:31 +1000 Subject: [PATCH 2/3] fix: block channel agent mode for nonmembers --- src/app/chat/page.tsx | 51 +++++++++++++++++++++++++---- src/components/chat/voice-agent.tsx | 11 +++++-- src/lib/realtime-voice-client.ts | 4 +++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 7b72aef..35af0ba 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -438,6 +438,7 @@ function ChatComposer({ onEnterAgentMode, showAgentMode = true, agentButtonTitle = "Enter agent mode (hands-free)", + agentModeDisabled = false, addMenuLabel = "Add to Chat", isDragOver = false, onDragOver, @@ -460,6 +461,7 @@ function ChatComposer({ onEnterAgentMode: () => void; showAgentMode?: boolean; agentButtonTitle?: string; + agentModeDisabled?: boolean; addMenuLabel?: string; isDragOver?: boolean; onDragOver?: (event: React.DragEvent) => void; @@ -638,8 +640,9 @@ function ChatComposer({ ) : showAgentMode ? (