diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 871a92f..b4b1328 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -75,7 +75,6 @@ import { scriptedCatalogEntry, serializeModelSecret, takeoverLeaseMs, - teamBotWorkspaceDirectory, toComputerRef, touchRunningComputer, verifyMcpInstall, @@ -98,6 +97,7 @@ import { } from "@rakazo/core"; import type { PrismaClient, ThreadEvents } from "@rakazo/db"; import { + appendEventInTransaction, BotSectionNameConflictError, CannotDeleteDefaultSpaceError, CannotDeleteLastSpaceError, @@ -108,6 +108,7 @@ import { createProjectGoalRepos, createRepos, createSpaceForMember, + createThreadMessageInTransaction, deleteEmptySpaceForMember, deleteUnreferencedCredentialSecret, findDefaultModelCredential, @@ -119,6 +120,7 @@ import { IsolationError, isProjectGoalStatus, issueMessagingLinkCode, + lockOwnedGroup, newestModelCredentialOrder, newestVoiceCredentialOrder, Prisma, @@ -132,6 +134,7 @@ import { SpaceNotFoundError, selectSpaceModelPreference, selectSpaceVoicePreference, + touchGroupUpdatedAt, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-secrets.js"; @@ -178,7 +181,6 @@ import { shouldForwardPeerThreadEvent, } from "./thread-message-pages.js"; import { - followUpGroupThreadMessage, reactToThreadMessage, resolveThreadTarget, sendThreadMessage, @@ -1542,8 +1544,79 @@ export function createRouter(deps: RouterDeps) { } return { ok: true as const }; } - const committed = await followUpGroupThreadMessage(deps, context.actor, target, { - text: input.text, + const committed = await deps.prisma.$transaction(async (tx) => { + await lockOwnedGroup(tx, context.actor, target.groupId); + const group = await tx.chatGroup.findFirst({ + where: { + id: target.groupId, + archivedAt: null, + thread: { id: target.threadId }, + }, + include: { members: { orderBy: { createdAt: "asc" } } }, + }); + const botId = group?.members[0]?.botId; + if (!botId) throw new IsolationError(); + const blocks = [{ kind: "text" as const, text: input.text }]; + const message = await createThreadMessageInTransaction(tx, { + threadId: target.threadId, + role: "user", + blocks, + }); + const active = await tx.run.findFirst({ + where: { + threadId: target.threadId, + botId, + status: { in: [...ACTIVE_RUN_STATUSES] }, + }, + select: { id: true }, + }); + let run: { id: string } | null = null; + if (!active) { + const task = await tx.task.create({ + data: { + spaceId: context.actor.spaceId, + botId, + threadId: target.threadId, + userId: context.actor.userId, + prompt: input.text, + status: "queued", + }, + }); + run = await tx.run.create({ + data: { + spaceId: context.actor.spaceId, + botId, + threadId: target.threadId, + taskId: task.id, + userId: context.actor.userId, + status: "queued", + trigger: "follow_up", + sourceMessageId: message.id, + }, + select: { id: true }, + }); + await tx.message.update({ where: { id: message.id }, data: { runId: run.id } }); + } else { + await tx.steeringMessage.create({ + data: { + messageId: message.id, + botId, + userId: context.actor.userId, + runId: active.id, + }, + }); + await tx.message.update({ where: { id: message.id }, data: { runId: active.id } }); + } + const event = await appendEventInTransaction(tx, { + spaceId: context.actor.spaceId, + threadId: target.threadId, + botId, + type: "thread.message.created", + runId: run?.id ?? active?.id, + payload: { messageId: message.id, role: "user", blocks }, + }); + await touchGroupUpdatedAt(tx, target.groupId); + return { runId: run?.id, eventSeq: event.seq }; }); await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => { getLogger().error("group follow-up realtime notification", error); @@ -1823,6 +1896,13 @@ export function createRouter(deps: RouterDeps) { }; } if (hasActiveComputerControl(bot.computer) && bot.computer.controlBotId !== bot.id) { + const previousBot = await deps.prisma.bot.findFirst({ + where: { id: bot.computer.controlBotId!, spaceId: context.actor.spaceId }, + select: { userId: true }, + }); + if (previousBot && previousBot.userId !== context.actor.userId) { + throw new ORPCError("CONFLICT", { message: "Another member holds control" }); + } const previousBotId = bot.computer.controlBotId!; await deps.sandbox.setScreenControl?.( toComputerRef(bot.computer), @@ -2426,11 +2506,7 @@ export function createRouter(deps: RouterDeps) { }), remove: authed.routines.remove.handler(async ({ context, input }) => { const existing = await deps.prisma.routine.findFirst({ - where: { - id: input.routineId, - spaceId: context.actor.spaceId, - userId: context.actor.userId, - }, + where: { id: input.routineId, spaceId: context.actor.spaceId }, }); if (!existing) throw new IsolationError(); await deps.prisma.routine.delete({ where: { id: existing.id } }); @@ -4477,10 +4553,6 @@ export function createRouter(deps: RouterDeps) { userId: context.actor.userId, signal: new AbortController().signal, }; - const exportOptions = - bot.computer.scope === "team" - ? { prefix: teamBotWorkspaceDirectory(input.botId) } - : undefined; const [memory, routines, files, history] = await Promise.all([ deps.prisma.memoryDocument.findMany({ where: { botId: input.botId, spaceId: context.actor.spaceId }, @@ -4490,7 +4562,7 @@ export function createRouter(deps: RouterDeps) { }), (async () => { const exported: Array<{ path: string; content: string }> = []; - for await (const file of deps.home.exportHome(homeKey, exportContext, exportOptions)) { + for await (const file of deps.home.exportHome(homeKey, exportContext)) { exported.push({ path: file.path, content: new TextDecoder().decode(file.content), diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 1ed4038..3fd6019 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator } from "@rakazo/adapters"; -import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@rakazo/contracts"; +import type { appContract, Space, SpaceNavigation } from "@rakazo/contracts"; import { claimEmptySpaceDeletionForMember, deleteEmptySpaceForMember, @@ -468,6 +468,78 @@ describeWithDatabase("API authorization and resource isolation", () => { expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull(); }); + it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => { + const owner = await signup(app, `takeover-owner-${stamp}@rakazo.test`, "Takeover Owner"); + const member = await signup(app, `takeover-member-${stamp}@rakazo.test`, "Takeover Member"); + const ownerActor = await rpc(app, owner, "me"); + const memberActor = await rpc(app, member, "me"); + const sharedSpaceId = ownerActor.spaceId; + const sharedRow = await handles.prisma.space.findUniqueOrThrow({ + where: { id: sharedSpaceId }, + select: { organizationId: true }, + }); + + await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); + await handles.prisma.member.create({ + data: { + id: `takeover-org-member-${stamp}`, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + await handles.prisma.spaceMember.create({ + data: { + id: `takeover-space-member-${stamp}`, + spaceId: sharedSpaceId, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + + const ownerBot = await rpc( + app, + owner, + "bots/create", + botInput("Owner Bot"), + sharedSpaceId, + ); + const memberBot = await rpc( + app, + member, + "bots/create", + botInput("Member Bot"), + sharedSpaceId, + ); + const ownerBotRow = await handles.prisma.bot.findUniqueOrThrow({ where: { id: ownerBot.id } }); + const memberBotRow = await handles.prisma.bot.findUniqueOrThrow({ + where: { id: memberBot.id }, + }); + expect(ownerBotRow.computerId).toBe(memberBotRow.computerId); + + await rpc(app, owner, "computer/boot", { botId: ownerBot.id }, sharedSpaceId); + await rpc(app, owner, "computer/takeover", { botId: ownerBot.id }, sharedSpaceId); + + const memberTakeover = await raw( + app, + member, + "computer/takeover", + { botId: memberBot.id }, + sharedSpaceId, + ); + expect(memberTakeover.status).toBe(409); + expect(await memberTakeover.text()).toMatch(/Another member holds control/i); + + const computer = await handles.prisma.computer.findUniqueOrThrow({ + where: { id: ownerBotRow.computerId! }, + select: { controlBotId: true }, + }); + expect(computer.controlBotId).toBe(ownerBot.id); + }); + it("keeps approval rules private to each user in a shared Space", async () => { const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner"); const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member"); @@ -509,78 +581,6 @@ describeWithDatabase("API authorization and resource isolation", () => { ).not.toBeNull(); }); - it("redacts memory provider details in aiConsent/status for non-owner members", async () => { - const owner = await signup(app, `ai-consent-owner-${stamp}@rakazo.test`, "Consent Owner"); - const ownerActor = await rpc(app, owner, "me"); - const shared = await rpc(app, owner, "spaces/create", { name: "Consent shared" }); - const sharedRow = await handles.prisma.space.findUniqueOrThrow({ - where: { id: shared.id }, - select: { organizationId: true }, - }); - const secret = await handles.prisma.secret.create({ - data: { - userId: ownerActor.userId, - spaceId: shared.id, - kind: "memory-provider", - ciphertext: "encrypted-test-key", - }, - }); - await handles.prisma.spaceMemoryConfig.create({ - data: { - spaceId: shared.id, - userId: ownerActor.userId, - provider: "supermemory", - settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" }, - secretId: secret.id, - defaultMemoryScope: "isolated", - }, - }); - - const member = await signup(app, `ai-consent-member-${stamp}@rakazo.test`, "Consent Member"); - const memberActor = await rpc(app, member, "me"); - await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); - await handles.prisma.member.create({ - data: { - id: `ai-consent-org-member-${stamp}`, - organizationId: sharedRow.organizationId, - userId: memberActor.userId, - role: "member", - createdAt: new Date(), - }, - }); - await handles.prisma.spaceMember.create({ - data: { - id: `ai-consent-space-member-${stamp}`, - spaceId: shared.id, - organizationId: sharedRow.organizationId, - userId: memberActor.userId, - role: "member", - createdAt: new Date(), - }, - }); - - const ownerStatus = await rpc( - app, - owner, - "aiConsent/status", - { uses: ["memory"] }, - shared.id, - ); - const memberStatus = await rpc( - app, - member, - "aiConsent/status", - { uses: ["memory"] }, - shared.id, - ); - const ownerMemory = ownerStatus.recipients.find((recipient) => recipient.use === "memory"); - const memberMemory = memberStatus.recipients.find((recipient) => recipient.use === "memory"); - expect(ownerMemory?.name).toContain("127.0.0.1:8123"); - expect(memberMemory?.name).toBe("Space memory"); - expect(JSON.stringify(memberStatus.recipients)).not.toContain("127.0.0.1"); - expect(JSON.stringify(memberStatus.recipients)).not.toMatch(/supermemory/i); - }); - it("keeps space data and computers behind the selected space boundary", async () => { const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner"); const original = await rpc(app, cookie, "me");