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/<id>/ prefix and add regression coverage for the shared-Space case. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
39a6a5a525
commit
0b5cd4ed43
@ -68,6 +68,7 @@ import {
|
|||||||
resolveAutoReviewChecker,
|
resolveAutoReviewChecker,
|
||||||
resolveBotWorkspacePath,
|
resolveBotWorkspacePath,
|
||||||
sanitizeComposioError,
|
sanitizeComposioError,
|
||||||
|
teamBotWorkspaceDirectory,
|
||||||
savePushToken,
|
savePushToken,
|
||||||
scheduleComputerControlExpiry,
|
scheduleComputerControlExpiry,
|
||||||
scheduleComputerSleep,
|
scheduleComputerSleep,
|
||||||
@ -4550,6 +4551,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
userId: context.actor.userId,
|
userId: context.actor.userId,
|
||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
};
|
};
|
||||||
|
const exportOptions =
|
||||||
|
bot.computer.scope === "team"
|
||||||
|
? { prefix: teamBotWorkspaceDirectory(input.botId) }
|
||||||
|
: undefined;
|
||||||
const [memory, routines, files, history] = await Promise.all([
|
const [memory, routines, files, history] = await Promise.all([
|
||||||
deps.prisma.memoryDocument.findMany({
|
deps.prisma.memoryDocument.findMany({
|
||||||
where: { botId: input.botId, spaceId: context.actor.spaceId },
|
where: { botId: input.botId, spaceId: context.actor.spaceId },
|
||||||
@ -4559,7 +4564,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
(async () => {
|
(async () => {
|
||||||
const exported: Array<{ path: string; content: string }> = [];
|
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({
|
exported.push({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
content: new TextDecoder().decode(file.content),
|
content: new TextDecoder().decode(file.content),
|
||||||
|
|||||||
@ -256,7 +256,11 @@ export interface AgentHomeStore {
|
|||||||
checkout(botId: string, dest: string, context: AdapterContext): Promise<string>;
|
checkout(botId: string, dest: string, context: AdapterContext): Promise<string>;
|
||||||
commit(botId: string, src: string, context: AdapterContext): Promise<string>;
|
commit(botId: string, src: string, context: AdapterContext): Promise<string>;
|
||||||
restore(botId: string, revision: string, dest: string, context: AdapterContext): Promise<void>;
|
restore(botId: string, revision: string, dest: string, context: AdapterContext): Promise<void>;
|
||||||
exportHome(botId: string, context: AdapterContext): AsyncIterable<PortableFile>;
|
exportHome(
|
||||||
|
botId: string,
|
||||||
|
context: AdapterContext,
|
||||||
|
options?: { prefix?: string },
|
||||||
|
): AsyncIterable<PortableFile>;
|
||||||
readFile(
|
readFile(
|
||||||
botId: string,
|
botId: string,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@ -122,6 +122,21 @@ describe("LocalAgentHomeStore path containment", () => {
|
|||||||
expect(exported).toEqual(["safe.txt"]);
|
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 () => {
|
it("exports and copies internal links, bytes, empty directories, and file modes", async () => {
|
||||||
const { root, store, home } = await fixture();
|
const { root, store, home } = await fixture();
|
||||||
const bytes = Buffer.from([0, 255, 127, 10]);
|
const bytes = Buffer.from([0, 255, 127, 10]);
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AdapterContext, AgentHomeStore, PortableFile } from "@rakazo/adapter-kit";
|
import type { AdapterContext, AgentHomeStore, PortableFile } from "@rakazo/adapter-kit";
|
||||||
|
import { normalizeWorkspacePath } from "./computer-support.js";
|
||||||
import { fileHandlePath } from "./file-handle-path.js";
|
import { fileHandlePath } from "./file-handle-path.js";
|
||||||
|
|
||||||
export class LocalAgentHomeStore implements AgentHomeStore {
|
export class LocalAgentHomeStore implements AgentHomeStore {
|
||||||
@ -92,13 +93,25 @@ export class LocalAgentHomeStore implements AgentHomeStore {
|
|||||||
await this.checkout(botId, dest, context);
|
await this.checkout(botId, dest, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
async *exportHome(botId: string, _context: AdapterContext): AsyncIterable<PortableFile> {
|
async *exportHome(
|
||||||
|
botId: string,
|
||||||
|
_context: AdapterContext,
|
||||||
|
options?: { prefix?: string },
|
||||||
|
): AsyncIterable<PortableFile> {
|
||||||
await this.waitForBotWrite(botId);
|
await this.waitForBotWrite(botId);
|
||||||
await this.recoverInterruptedCommit(botId);
|
await this.recoverInterruptedCommit(botId);
|
||||||
const dir = this.botDir(botId);
|
const dir = this.botDir(botId);
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
const root = await realpath(dir);
|
const root = await realpath(dir);
|
||||||
|
if (!options?.prefix) {
|
||||||
yield* walkFiles(root, root);
|
yield* walkFiles(root, root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scoped = await containedExistingPath(root, normalizeWorkspacePath(options.prefix)).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
if (!scoped) return;
|
||||||
|
yield* walkFiles(root, scoped);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(
|
async readFile(
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
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 type { appContract, Space, SpaceNavigation } from "@rakazo/contracts";
|
||||||
import {
|
import {
|
||||||
claimEmptySpaceDeletionForMember,
|
claimEmptySpaceDeletionForMember,
|
||||||
|
computerHomeKey,
|
||||||
deleteEmptySpaceForMember,
|
deleteEmptySpaceForMember,
|
||||||
releaseSpaceDeletionClaim,
|
releaseSpaceDeletionClaim,
|
||||||
renewSpaceDeletionClaim,
|
renewSpaceDeletionClaim,
|
||||||
@ -468,6 +469,77 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
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<Actor>(app, owner, "me");
|
||||||
|
const memberActor = await rpc<Actor>(app, member, "me");
|
||||||
|
|
||||||
|
const shared = await rpc<Space>(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<Bot>(app, owner, "bots/create", botInput("Owner Bot"), shared.id);
|
||||||
|
const memberBot = await rpc<Bot>(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 () => {
|
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 owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
|
||||||
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user