From bede7d0d73062fe20c273915e7f59b6f092928f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 08:25:56 +0000 Subject: [PATCH] fix(api): block takeover while teaching on sibling bot When a user records a taught skill on one Team Computer bot, manual takeover on another bot in the same space cleared the teaching lease without stopping the session. Teaching input then failed silently and skills/stop could not release the replacement takeover lease. Reject computer/takeover while another bot on the same computer is recording, matching the existing Stop teaching first messaging guard. Co-authored-by: cynarAI --- apps/api/src/router.ts | 12 ++++++- apps/api/src/taught-skills.ts | 21 ++++++++++++ packages/testkit/src/authorization.test.ts | 40 ++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) 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");