Merge pull request #65 from cynarAI/cursor/teaching-takeover-clobber-5453

fix(api): block takeover while teaching on sibling Team Computer bot
This commit is contained in:
cursor[bot] 2026-09-22 09:00:17 +00:00 committed by GitHub
commit fbf9a6cd03
3 changed files with 72 additions and 1 deletions

View File

@ -172,7 +172,11 @@ import {
readServerUpdateStatus, readServerUpdateStatus,
UpdaterProxyError, UpdaterProxyError,
} from "./server-update.js"; } from "./server-update.js";
import { assertTeachingSendAllowed, createTaughtSkillsService } from "./taught-skills.js"; import {
assertTeachingSendAllowed,
assertTeachingTakeoverAllowed,
createTaughtSkillsService,
} from "./taught-skills.js";
import { import {
isPeerRun, isPeerRun,
loadAllMessages, loadAllMessages,
@ -1809,6 +1813,12 @@ export function createRouter(deps: RouterDeps) {
if (!bot.computer?.providerRef || bot.computer.state !== "running") { if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be 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) { if (hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id) {
await bindWaitingTakeoverToControl(deps, { await bindWaitingTakeoverToControl(deps, {
spaceId: context.actor.spaceId, spaceId: context.actor.spaceId,

View File

@ -110,6 +110,27 @@ export async function assertTeachingSendAllowed(
} }
} }
export async function assertTeachingTakeoverAllowed(
prisma: PrismaClient,
actor: Actor,
botId: string,
computerId: string,
): Promise<void> {
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( async function cancelActiveRuns(
deps: TaughtSkillsDeps, deps: TaughtSkillsDeps,
_actor: Actor, _actor: Actor,

View File

@ -694,6 +694,46 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(ownerCrossBot.content).toBe(secret); 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<Bot>(app, cookie, "bots/create", botInput("Teacher"));
const sibling = await rpc<Bot>(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 () => { 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 owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner");
const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member"); const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member");