From 751967a8e159d75e8802845ecf270125a37ea3c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 08:36:36 +0000 Subject: [PATCH] fix(api): scope account-delete sandbox teardown to owned spaces Only destroy team sandboxes and homes for spaces the deleting user owns with no remaining members or bots. Guard home removal under dataDir/homes. Add unit and postgres journey coverage. Co-authored-by: cynarAI --- apps/api/src/app.ts | 7 +- packages/adapters/src/child-bots.ts | 21 +- packages/adapters/src/home.test.ts | 24 ++- packages/adapters/src/home.ts | 27 +++ packages/db/src/account-delete.test.ts | 36 ++++ packages/db/src/account-delete.ts | 32 +++ packages/db/src/index.ts | 1 + .../account-delete-sandbox.postgres.test.ts | 182 ++++++++++++++++++ packages/testkit/src/journeys.test.ts | 30 ++- 9 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 packages/db/src/account-delete.test.ts create mode 100644 packages/db/src/account-delete.ts create mode 100644 packages/testkit/src/account-delete-sandbox.postgres.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8357eb7..8cfbd91 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -84,6 +84,7 @@ import { parsePositiveInteger, provisionMessagingIdentity, requireMembership, + spaceIdsForSandboxTeardownAfterAccountDelete, } from "@rakazo/db"; import type { Logger } from "@rakazo/logging"; import { @@ -390,7 +391,11 @@ export async function createApp( ), ), ); - const spaceIds = [...new Set(bots.map((bot) => bot.spaceId))]; + const candidateSpaceIds = [...new Set(bots.map((bot) => bot.spaceId))]; + const spaceIds = await spaceIdsForSandboxTeardownAfterAccountDelete(prisma, { + userId, + spaceIds: candidateSpaceIds, + }); for (const spaceId of spaceIds) { await destroySpaceSandboxes(lifecycleDeps, { spaceId }, { ...deleteContext, spaceId }); } diff --git a/packages/adapters/src/child-bots.ts b/packages/adapters/src/child-bots.ts index 753513f..0af476a 100644 --- a/packages/adapters/src/child-bots.ts +++ b/packages/adapters/src/child-bots.ts @@ -1,4 +1,3 @@ -import { rm } from "node:fs/promises"; import type { AdapterContext, AgentHomeStore, @@ -22,7 +21,7 @@ import { import { getLogger } from "@rakazo/logging"; import { toComputerRef } from "./computer-support.js"; import { checkpointAndRecordComputerWorkspace } from "./computer-workspace.js"; -import { resolveAgentHomePath } from "./home.js"; +import { removeAgentHomeDirectory } from "./home.js"; import { removePiBotSessions } from "./pi-session.js"; export function confirmSpawnedBotName(confirmName: string, botName: string) { @@ -351,10 +350,11 @@ export async function destroySpaceSandboxes( if (!computer.providerRef) continue; const ref = toComputerRef(computer); await deps.sandbox.destroy(ref, { ...context, botId: computer.homeKey }).catch(() => undefined); - await rm(resolveAgentHomePath(deps.home, computer.homeKey, deps.dataDir ?? "./data"), { - recursive: true, - force: true, - }).catch(() => undefined); + await removeAgentHomeDirectory( + deps.home, + computer.homeKey, + deps.dataDir ?? "./data", + ).catch(() => undefined); } if (computers.length) { await deps.prisma.computer.updateMany({ @@ -483,10 +483,11 @@ export async function destroyBot( }), ); if (dedicated) { - await rm(resolveAgentHomePath(deps.home, dedicated.homeKey, deps.dataDir ?? "./data"), { - recursive: true, - force: true, - }).catch(() => undefined); + await removeAgentHomeDirectory( + deps.home, + dedicated.homeKey, + deps.dataDir ?? "./data", + ).catch(() => undefined); } const artifactStore = deps.artifacts; if (artifactStore) { diff --git a/packages/adapters/src/home.test.ts b/packages/adapters/src/home.test.ts index 825287b..a327e0c 100644 --- a/packages/adapters/src/home.test.ts +++ b/packages/adapters/src/home.test.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "n import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { LocalAgentHomeStore } from "./home.js"; +import { LocalAgentHomeStore, removeAgentHomeDirectory, resolveSafeAgentHomePath } from "./home.js"; const context = { operationId: "test", @@ -164,3 +164,25 @@ describe("LocalAgentHomeStore path containment", () => { expect(await readFile(path.join(store.pathFor("bot-2"), "nested/run"))).toEqual(bytes); }); }); + +describe("resolveSafeAgentHomePath", () => { + it("rejects empty, root-like, and traversal home keys", async () => { + const root = await mkdtemp(path.join(tmpdir(), "rakazo-home-guard-")); + dirs.push(root); + const home = new LocalAgentHomeStore(root); + for (const homeKey of ["", ".", "..", "../etc", "team/../escape"]) { + expect(() => resolveSafeAgentHomePath(home, homeKey, root)).toThrow( + /Invalid agent home key|escapes/, + ); + } + }); + + it("removes only directories under homes/", async () => { + const root = await mkdtemp(path.join(tmpdir(), "rakazo-home-guard-rm-")); + dirs.push(root); + const home = new LocalAgentHomeStore(root); + const target = resolveSafeAgentHomePath(home, "bot-safe", root); + await removeAgentHomeDirectory(home, "bot-safe", root); + expect(target.startsWith(path.join(root, "homes"))).toBe(true); + }); +}); diff --git a/packages/adapters/src/home.ts b/packages/adapters/src/home.ts index 894f3a8..55352bc 100644 --- a/packages/adapters/src/home.ts +++ b/packages/adapters/src/home.ts @@ -224,6 +224,33 @@ export function resolveAgentHomePath(home: AgentHomeStore, homeKey: string, data return path.resolve(dataDir, "homes", homeKey); } +function assertValidHomeKey(homeKey: string) { + if (!homeKey || homeKey === "." || homeKey === ".." || path.basename(homeKey) !== homeKey) { + throw new Error("Invalid agent home key"); + } +} + +/** Resolved path must stay under `/homes/`; rejects traversal and empty keys. */ +export function resolveSafeAgentHomePath( + home: AgentHomeStore, + homeKey: string, + dataDir = "./data", +) { + assertValidHomeKey(homeKey); + const homesRoot = path.resolve(dataDir, "homes"); + const target = path.resolve(resolveAgentHomePath(home, homeKey, dataDir)); + assertContained(homesRoot, target); + return target; +} + +export async function removeAgentHomeDirectory( + home: AgentHomeStore, + homeKey: string, + dataDir = "./data", +) { + await rm(resolveSafeAgentHomePath(home, homeKey, dataDir), { recursive: true, force: true }); +} + function safeJoin(root: string, rel: string) { const resolvedRoot = path.resolve(root); const resolved = path.resolve(resolvedRoot, `.${path.sep}${rel.replace(/^\/+/, "")}`); diff --git a/packages/db/src/account-delete.test.ts b/packages/db/src/account-delete.test.ts new file mode 100644 index 0000000..feb1353 --- /dev/null +++ b/packages/db/src/account-delete.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PrismaClient } from "./client.js"; +import { spaceIdsForSandboxTeardownAfterAccountDelete } from "./account-delete.js"; + +describe("spaceIdsForSandboxTeardownAfterAccountDelete", () => { + it("includes solely-owned spaces and skips shared ones", async () => { + const findUnique = vi + .fn() + .mockImplementation(async ({ where }: { where: { spaceId_userId: { spaceId: string } } }) => { + const { spaceId } = where.spaceId_userId; + if (spaceId === "solo") return { role: "owner" }; + if (spaceId === "shared") return { role: "owner" }; + if (spaceId === "member-only") return { role: "member" }; + return null; + }); + const countMembers = vi.fn(async ({ where }: { where: { spaceId: string } }) => { + if (where.spaceId === "shared") return 1; + return 0; + }); + const countBots = vi.fn(async ({ where }: { where: { spaceId: string } }) => { + if (where.spaceId === "shared") return 1; + return 0; + }); + const prisma = { + spaceMember: { findUnique, count: countMembers }, + bot: { count: countBots }, + } as unknown as PrismaClient; + + const result = await spaceIdsForSandboxTeardownAfterAccountDelete(prisma, { + userId: "user-1", + spaceIds: ["solo", "shared", "member-only", "solo"], + }); + + expect(result).toEqual(["solo"]); + }); +}); diff --git a/packages/db/src/account-delete.ts b/packages/db/src/account-delete.ts new file mode 100644 index 0000000..ee07ca8 --- /dev/null +++ b/packages/db/src/account-delete.ts @@ -0,0 +1,32 @@ +import type { PrismaClient } from "./client.js"; + +type AccountDeleteDb = Pick; + +/** Spaces whose team sandboxes and homes may be torn down when this user deletes their account. */ +export async function spaceIdsForSandboxTeardownAfterAccountDelete( + prisma: AccountDeleteDb, + input: { userId: string; spaceIds: Iterable }, +): Promise { + const uniqueSpaceIds = [...new Set(input.spaceIds)]; + if (!uniqueSpaceIds.length) return []; + + const eligible: string[] = []; + for (const spaceId of uniqueSpaceIds) { + const membership = await prisma.spaceMember.findUnique({ + where: { spaceId_userId: { spaceId, userId: input.userId } }, + select: { role: true }, + }); + if (membership?.role !== "owner") continue; + + const [otherMembers, otherBots] = await Promise.all([ + prisma.spaceMember.count({ + where: { spaceId, userId: { not: input.userId } }, + }), + prisma.bot.count({ + where: { spaceId, userId: { not: input.userId } }, + }), + ]); + if (otherMembers === 0 && otherBots === 0) eligible.push(spaceId); + } + return eligible; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5e4e200..6378608 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,3 +1,4 @@ +export * from "./account-delete.js"; export * from "./autopilot-lines.js"; export * from "./bootstrap-user.js"; export * from "./cancel-runs.js"; diff --git a/packages/testkit/src/account-delete-sandbox.postgres.test.ts b/packages/testkit/src/account-delete-sandbox.postgres.test.ts new file mode 100644 index 0000000..342f0a0 --- /dev/null +++ b/packages/testkit/src/account-delete-sandbox.postgres.test.ts @@ -0,0 +1,182 @@ +import { mkdirSync, mkdtempSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposioEmulator, FakeSandboxProvider } from "@rakazo/adapters"; +import type { Bot, Space } from "@rakazo/contracts"; +import { computerHomeKey, ensureComputerRecord } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Promise }; +type AppHandles = Awaited>; + +const databaseAvailable = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const fixtureOrigin = "http://127.0.0.1:5173"; + +describe.skipIf(!databaseAvailable)("account delete and team sandbox teardown", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-account-delete-sandbox-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles.stop?.(); + }); + + async function seedTeamComputer(spaceId: string, userId: string) { + const homeKey = computerHomeKey("team", spaceId); + const computer = await ensureComputerRecord(handles.prisma, { + mode: "team", + spaceId, + userId, + kind: "fake", + }); + const homePath = path.join(dataDir, "homes", homeKey); + mkdirSync(path.join(homePath, "marker"), { recursive: true }); + const sandbox = handles.sandbox as FakeSandboxProvider; + const ref = await sandbox.provision( + { botId: homeKey, homePath }, + { + operationId: "account-delete-seed", + traceId: "account-delete-seed", + spaceId, + userId, + signal: AbortSignal.timeout(15_000), + }, + ); + await handles.prisma.computer.update({ + where: { id: computer.id }, + data: { providerRef: ref.id, state: "running" }, + }); + return { homeKey, homePath, refId: ref.id, sandbox }; + } + + async function deleteAccount(cookie: string) { + const deleted = await app.request("/api/auth/delete-user", { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + origin: fixtureOrigin, + }, + body: JSON.stringify({ password: "password12" }), + }); + expect(deleted.status).toBe(200); + } + + it("preserves shared team sandboxes when a non-owner member deletes their account", async () => { + const owner = await signup(app, `shared-owner-${stamp}@rakazo.test`, "Shared Owner"); + const member = await signup(app, `shared-member-${stamp}@rakazo.test`, "Shared Member"); + const shared = await rpc(app, owner, "spaces/create", { name: "Shared teardown" }); + const sharedRow = await handles.prisma.space.findUniqueOrThrow({ + where: { id: shared.id }, + select: { organizationId: true }, + }); + const memberActor = await rpc<{ userId: string }>(app, member, "me"); + await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } }); + await handles.prisma.member.create({ + data: { + id: `shared-org-member-${stamp}`, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + await handles.prisma.spaceMember.create({ + data: { + id: `shared-space-member-${stamp}`, + spaceId: shared.id, + organizationId: sharedRow.organizationId, + userId: memberActor.userId, + role: "member", + createdAt: new Date(), + }, + }); + + await rpc(app, owner, "bots/create", botPayload("Owner Bot"), shared.id); + await rpc(app, member, "bots/create", botPayload("Member Bot"), shared.id); + const { homePath, refId, sandbox } = await seedTeamComputer(shared.id, memberActor.userId); + + await deleteAccount(member); + + expect(existsSync(homePath)).toBe(true); + expect(sandbox.boxes.has(refId)).toBe(true); + const computer = await handles.prisma.computer.findFirst({ + where: { spaceId: shared.id, scope: "team" }, + }); + expect(computer?.providerRef).toBeTruthy(); + }); + + it("tears down team sandboxes when the sole owner deletes their account", async () => { + const cookie = await signup(app, `solo-delete-${stamp}@rakazo.test`, "Solo Delete"); + const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); + const bot = await rpc(app, cookie, "bots/create", botPayload("Temporary")); + const { homePath, refId, sandbox } = await seedTeamComputer(bot.spaceId, me.userId); + + await deleteAccount(cookie); + + expect(existsSync(homePath)).toBe(false); + expect(sandbox.boxes.has(refId)).toBe(false); + }); +}); + +function botPayload(name: string) { + return { + name, + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }; +} + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: fixtureOrigin }, + body: JSON.stringify({ email, password: "password12", name }), + }); + expect(response.status).toBeLessThan(400); + return sessionCookieHeader(response); +} + +async function rpc( + app: App, + cookie: string, + procedure: string, + input: unknown = {}, + spaceId?: string, +): Promise { + const headers: Record = { + "content-type": "application/json", + cookie, + origin: fixtureOrigin, + }; + if (spaceId) headers["x-rakazo-space-id"] = spaceId; + const response = await app.request(`/rpc/${procedure}`, { + method: "POST", + headers, + body: JSON.stringify({ json: input }), + }); + const body = (await response.json()) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || body.error) { + throw new Error(`${procedure}: ${body.error?.message ?? response.status}`); + } + return body.json as T; +} diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index da71092..d289abd 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -19,6 +19,8 @@ import { createRepos, createThreadEvents, createThreadMessage, + computerHomeKey, + ensureComputerRecord, ensureMainBot, joinMainConversation, RunHistoryWriteError, @@ -1798,6 +1800,30 @@ describeJourneys("required product journeys", () => { instructions: "", notifyOnFinish: true, }); + const homeKey = computerHomeKey("team", bot.spaceId); + const teamComputer = await ensureComputerRecord(prisma, { + mode: "team", + spaceId: bot.spaceId, + userId: me.userId, + kind: "fake", + }); + const teamHomePath = path.join(dataDir, "homes", homeKey); + mkdirSync(path.join(teamHomePath, "account-delete-marker"), { recursive: true }); + const fakeSandbox = sandbox as FakeSandboxProvider; + const teamRef = await fakeSandbox.provision( + { botId: homeKey, homePath: teamHomePath }, + { + operationId: "journey-11-seed", + traceId: "journey-11-seed", + spaceId: bot.spaceId, + userId: me.userId, + signal: AbortSignal.timeout(15_000), + }, + ); + await prisma.computer.update({ + where: { id: teamComputer.id }, + data: { providerRef: teamRef.id, state: "running" }, + }); const deleted = await app.request("/api/auth/delete-user", { method: "POST", @@ -1814,6 +1840,8 @@ describeJourneys("required product journeys", () => { expect(await prisma.organization.findUnique({ where: { id: me.spaceId } })).toBeNull(); expect(await prisma.bot.findUnique({ where: { id: bot.id } })).toBeNull(); expect((await raw(app, cookie, "me")).status).toBeGreaterThanOrEqual(400); + expect(existsSync(teamHomePath)).toBe(false); + expect(fakeSandbox.boxes.has(teamRef.id)).toBe(false); }); it("12: a bot can spawn a regular bot and must confirm the name to delete it", async () => {