diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 60c6989..4d3b3b1 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -172,7 +172,11 @@ import { readServerUpdateStatus, UpdaterProxyError, } from "./server-update.js"; -import { assertTeachingSendAllowed, createTaughtSkillsService } from "./taught-skills.js"; +import { + assertTeachingSendAllowed, + assertTeachingTakeoverAllowed, + createTaughtSkillsService, +} from "./taught-skills.js"; import { isPeerRun, loadAllMessages, @@ -1809,6 +1813,12 @@ export function createRouter(deps: RouterDeps) { if (!bot.computer?.providerRef || bot.computer.state !== "running") { throw new ORPCError("BAD_REQUEST", { message: "computer must be running" }); } + await assertTeachingTakeoverAllowed( + deps.prisma, + context.actor, + input.botId, + bot.computer.id, + ); if (hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id) { await bindWaitingTakeoverToControl(deps, { spaceId: context.actor.spaceId, diff --git a/apps/api/src/taught-skills.ts b/apps/api/src/taught-skills.ts index 62e2971..de9f925 100644 --- a/apps/api/src/taught-skills.ts +++ b/apps/api/src/taught-skills.ts @@ -110,6 +110,27 @@ export async function assertTeachingSendAllowed( } } +export async function assertTeachingTakeoverAllowed( + prisma: PrismaClient, + actor: Actor, + botId: string, + computerId: string, +): Promise { + const teachingElsewhere = await prisma.taughtSkill.findFirst({ + where: { + spaceId: actor.spaceId, + userId: actor.userId, + status: "recording", + botId: { not: botId }, + bot: { computerId }, + }, + select: { id: true }, + }); + if (teachingElsewhere) { + throw new ORPCError("CONFLICT", { message: "Stop teaching first" }); + } +} + async function cancelActiveRuns( deps: TaughtSkillsDeps, _actor: Actor, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 3ccc160..0cae8c8 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -694,6 +694,46 @@ describeWithDatabase("API authorization and resource isolation", () => { expect(ownerCrossBot.content).toBe(secret); }); + it("blocks Team Computer takeover on another bot while teaching is recording", async () => { + const cookie = await signup(app, `teach-takeover-${stamp}@rakazo.test`, "Teach Takeover"); + const teacher = await rpc(app, cookie, "bots/create", botInput("Teacher")); + const sibling = await rpc(app, cookie, "bots/create", botInput("Sibling")); + const [teacherRow, siblingRow] = await Promise.all([ + handles.prisma.bot.findUniqueOrThrow({ where: { id: teacher.id } }), + handles.prisma.bot.findUniqueOrThrow({ where: { id: sibling.id } }), + ]); + expect(teacherRow.computerId).toBe(siblingRow.computerId); + + await rpc(app, cookie, "computer/boot", { botId: teacher.id }); + const skill = await rpc<{ id: string; status: string }>(app, cookie, "skills/start", { + botId: teacher.id, + goal: "Demonstrate export", + }); + expect(skill.status).toBe("recording"); + + const blockedTakeover = await raw(app, cookie, "computer/takeover", { botId: sibling.id }); + expect(blockedTakeover.status).toBe(409); + expect(await blockedTakeover.text()).toMatch(/Stop teaching first/i); + + await rpc(app, cookie, "computer/input", { + botId: teacher.id, + kind: "key", + payload: { key: "x" }, + }); + + const stopped = await rpc<{ status: string }>(app, cookie, "skills/stop", { + skillId: skill.id, + }); + expect(stopped.status).toBe("draft"); + + const computer = await handles.prisma.computer.findUniqueOrThrow({ + where: { id: teacherRow.computerId! }, + select: { controlHolder: true, controlBotId: true }, + }); + expect(computer.controlHolder).toBe("bot"); + expect(computer.controlBotId).toBeNull(); + }); + it("scopes Team Computer export to the exporting member's bot workspace in a shared Space", async () => { const owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner"); const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member");