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:
parent
9c1137780d
commit
751967a8e1
@ -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 });
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(/^\/+/, "")}`);
|
||||
|
||||
36
packages/db/src/account-delete.test.ts
Normal file
36
packages/db/src/account-delete.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
32
packages/db/src/account-delete.ts
Normal file
32
packages/db/src/account-delete.ts
Normal 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;
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./account-delete.js";
|
||||
export * from "./autopilot-lines.js";
|
||||
export * from "./bootstrap-user.js";
|
||||
export * from "./cancel-runs.js";
|
||||
|
||||
182
packages/testkit/src/account-delete-sandbox.postgres.test.ts
Normal file
182
packages/testkit/src/account-delete-sandbox.postgres.test.ts
Normal 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;
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user