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 <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-10-07 08:36:36 +00:00
parent 9c1137780d
commit 751967a8e1
No known key found for this signature in database
9 changed files with 347 additions and 13 deletions

View File

@ -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 });
}

View File

@ -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) {

View File

@ -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);
});
});

View File

@ -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 `<dataDir>/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(/^\/+/, "")}`);

View File

@ -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"]);
});
});

View File

@ -0,0 +1,32 @@
import type { PrismaClient } from "./client.js";
type AccountDeleteDb = Pick<PrismaClient, "spaceMember" | "bot">;
/** 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<string> },
): Promise<string[]> {
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;
}

View File

@ -1,3 +1,4 @@
export * from "./account-delete.js";
export * from "./autopilot-lines.js";
export * from "./bootstrap-user.js";
export * from "./cancel-runs.js";

View File

@ -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<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
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<Space>(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<Bot>(app, owner, "bots/create", botPayload("Owner Bot"), shared.id);
await rpc<Bot>(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<Bot>(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<T>(
app: App,
cookie: string,
procedure: string,
input: unknown = {},
spaceId?: string,
): Promise<T> {
const headers: Record<string, string> = {
"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;
}

View File

@ -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 () => {