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 <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 08:25:56 +00:00
parent 291f23e917
commit bede7d0d73
3 changed files with 72 additions and 1 deletions

View File

@ -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,

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(
deps: TaughtSkillsDeps,
_actor: Actor,

View File

@ -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<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 () => {
const owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner");
const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member");