From 0b5cd4ed431b5c544cbc7f1c00d9fbc4a7ab771a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 05:15:55 +0000 Subject: [PATCH] fix(api): scope Team Computer export to exporting bot workspace Team mode shares one disk per Space. export/bot walked the entire team home, so a member exporting their own bot received every other member's workspace files too. Scope exportHome with the exporting bot's bots// prefix and add regression coverage for the shared-Space case. Co-authored-by: cynarAI --- apps/api/src/router.ts | 7 +- packages/adapter-kit/src/interfaces.ts | 6 +- packages/adapters/src/home.test.ts | 15 +++++ packages/adapters/src/home.ts | 17 ++++- packages/testkit/src/authorization.test.ts | 74 +++++++++++++++++++++- 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 66dc260..cef4467 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -68,6 +68,7 @@ import { resolveAutoReviewChecker, resolveBotWorkspacePath, sanitizeComposioError, + teamBotWorkspaceDirectory, savePushToken, scheduleComputerControlExpiry, scheduleComputerSleep, @@ -4550,6 +4551,10 @@ 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 }, @@ -4559,7 +4564,7 @@ export function createRouter(deps: RouterDeps) { }), (async () => { const exported: Array<{ path: string; content: string }> = []; - for await (const file of deps.home.exportHome(homeKey, exportContext)) { + for await (const file of deps.home.exportHome(homeKey, exportContext, exportOptions)) { exported.push({ path: file.path, content: new TextDecoder().decode(file.content), diff --git a/packages/adapter-kit/src/interfaces.ts b/packages/adapter-kit/src/interfaces.ts index 31e2abb..53185e7 100644 --- a/packages/adapter-kit/src/interfaces.ts +++ b/packages/adapter-kit/src/interfaces.ts @@ -256,7 +256,11 @@ export interface AgentHomeStore { checkout(botId: string, dest: string, context: AdapterContext): Promise; commit(botId: string, src: string, context: AdapterContext): Promise; restore(botId: string, revision: string, dest: string, context: AdapterContext): Promise; - exportHome(botId: string, context: AdapterContext): AsyncIterable; + exportHome( + botId: string, + context: AdapterContext, + options?: { prefix?: string }, + ): AsyncIterable; readFile( botId: string, path: string, diff --git a/packages/adapters/src/home.test.ts b/packages/adapters/src/home.test.ts index 45563dc..825287b 100644 --- a/packages/adapters/src/home.test.ts +++ b/packages/adapters/src/home.test.ts @@ -122,6 +122,21 @@ describe("LocalAgentHomeStore path containment", () => { expect(exported).toEqual(["safe.txt"]); }); + it("scopes team exports to one bot workspace under a shared home key", async () => { + const { store } = await fixture(); + const teamHome = "team-space-1"; + await store.writeFile(teamHome, "bots/bot-a/secret.txt", "owner-secret", context); + await store.writeFile(teamHome, "bots/bot-b/other.txt", "member-safe", context); + await store.writeFile(teamHome, "shared/note.txt", "shared-note", context); + + const exported = []; + for await (const file of store.exportHome(teamHome, context, { prefix: "bots/bot-b" })) { + exported.push({ path: file.path, content: new TextDecoder().decode(file.content) }); + } + + expect(exported).toEqual([{ path: "other.txt", content: "member-safe" }]); + }); + it("exports and copies internal links, bytes, empty directories, and file modes", async () => { const { root, store, home } = await fixture(); const bytes = Buffer.from([0, 255, 127, 10]); diff --git a/packages/adapters/src/home.ts b/packages/adapters/src/home.ts index 9a9c1e7..894f3a8 100644 --- a/packages/adapters/src/home.ts +++ b/packages/adapters/src/home.ts @@ -13,6 +13,7 @@ import { } from "node:fs/promises"; import path from "node:path"; import type { AdapterContext, AgentHomeStore, PortableFile } from "@rakazo/adapter-kit"; +import { normalizeWorkspacePath } from "./computer-support.js"; import { fileHandlePath } from "./file-handle-path.js"; export class LocalAgentHomeStore implements AgentHomeStore { @@ -92,13 +93,25 @@ export class LocalAgentHomeStore implements AgentHomeStore { await this.checkout(botId, dest, context); } - async *exportHome(botId: string, _context: AdapterContext): AsyncIterable { + async *exportHome( + botId: string, + _context: AdapterContext, + options?: { prefix?: string }, + ): AsyncIterable { await this.waitForBotWrite(botId); await this.recoverInterruptedCommit(botId); const dir = this.botDir(botId); await mkdir(dir, { recursive: true }); const root = await realpath(dir); - yield* walkFiles(root, root); + if (!options?.prefix) { + yield* walkFiles(root, root); + return; + } + const scoped = await containedExistingPath(root, normalizeWorkspacePath(options.prefix)).catch( + () => null, + ); + if (!scoped) return; + yield* walkFiles(root, scoped); } async readFile( diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 10686c9..d317636 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -1,10 +1,11 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { ComposioEmulator } from "@rakazo/adapters"; +import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters"; import type { appContract, Space, SpaceNavigation } from "@rakazo/contracts"; import { claimEmptySpaceDeletionForMember, + computerHomeKey, deleteEmptySpaceForMember, releaseSpaceDeletionClaim, renewSpaceDeletionClaim, @@ -468,6 +469,77 @@ describeWithDatabase("API authorization and resource isolation", () => { expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.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"); + const ownerActor = await rpc(app, owner, "me"); + const memberActor = await rpc(app, member, "me"); + + const shared = await rpc(app, owner, "spaces/create", { name: "Shared export" }); + const sharedRow = await handles.prisma.space.findUniqueOrThrow({ + where: { id: shared.id }, + select: { organizationId: true }, + }); + + await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); + await handles.prisma.member.create({ + data: { + id: `export-member-org-${stamp}`, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + await handles.prisma.spaceMember.create({ + data: { + id: `export-member-space-${stamp}`, + spaceId: shared.id, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + + const ownerBot = await rpc(app, owner, "bots/create", botInput("Owner Bot"), shared.id); + const memberBot = await rpc(app, member, "bots/create", botInput("Member Bot"), shared.id); + const home = new LocalAgentHomeStore(dataDir); + const homeKey = computerHomeKey("team", shared.id); + const homeContext = { + operationId: "test", + traceId: "test", + spaceId: shared.id, + userId: ownerActor.userId, + signal: new AbortController().signal, + }; + await home.writeFile( + homeKey, + `${teamBotWorkspaceDirectory(ownerBot.id)}/PRIVATE.md`, + "owner-workspace-secret", + homeContext, + ); + await home.writeFile( + homeKey, + `${teamBotWorkspaceDirectory(memberBot.id)}/notes.txt`, + "member-notes", + homeContext, + ); + + const manifest = await rpc<{ files: Array<{ path: string; content: string }> }>( + app, + member, + "export/bot", + { botId: memberBot.id }, + shared.id, + ); + + expect(manifest.files.some((file) => file.content.includes("owner-workspace-secret"))).toBe( + false, + ); + expect(manifest.files.some((file) => file.content.includes("member-notes"))).toBe(true); + }); + 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");