Cursor Agent 0b5cd4ed43 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>
2026-09-22 07:04:29 +00:00

167 lines
6.9 KiB
TypeScript

import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { LocalAgentHomeStore } from "./home.js";
const context = {
operationId: "test",
traceId: "test",
spaceId: "workspace",
userId: "user",
signal: new AbortController().signal,
};
const dirs: string[] = [];
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-home-"));
dirs.push(root);
const store = new LocalAgentHomeStore(root);
const home = store.pathFor("bot-1");
await mkdir(home, { recursive: true });
return { root, store, home };
}
describe("LocalAgentHomeStore path containment", () => {
it("keeps revision metadata external without reserving a workspace file name", async () => {
const { root, store } = await fixture();
const source = path.join(root, "checkpoint-source");
await mkdir(source);
await writeFile(path.join(source, "result.txt"), "durable");
await writeFile(path.join(source, ".revision"), "belongs to the user");
const revision = await store.commit("bot-1", source, context);
const exported = [];
for await (const file of store.exportHome("bot-1", context)) exported.push(file.path);
expect(revision).toMatch(/^rev-/);
expect(store.describe().capabilities.revisions).toBe(false);
expect(exported.sort()).toEqual([".revision", "result.txt"]);
});
it("rejects lexical traversal and sibling-prefix paths", async () => {
const { store } = await fixture();
await expect(store.readFile("bot-1", "../../homes-other/secret", context)).rejects.toThrow(
/escapes|invalid/i,
);
});
it("rejects oversized reads before loading their contents", async () => {
const { store, home } = await fixture();
await writeFile(path.join(home, "large.txt"), "12345");
await expect(store.readFile("bot-1", "large.txt", context, { maxBytes: 4 })).rejects.toThrow(
/exceeds 4 bytes/,
);
});
it("allows symlinks whose resolved target stays inside the bot home", async () => {
const { store, home } = await fixture();
await writeFile(path.join(home, "target.txt"), "before");
await symlink("target.txt", path.join(home, "link.txt"));
expect(await store.readFile("bot-1", "link.txt", context)).toBe("before");
await store.writeFile("bot-1", "link.txt", "after", context);
expect(await readFile(path.join(home, "target.txt"), "utf8")).toBe("after");
});
it("allows directory symlinks that remain inside the bot home", async () => {
const { store, home } = await fixture();
await mkdir(path.join(home, "target-dir"));
await symlink(path.join(home, "target-dir"), path.join(home, "linked-dir"), "junction");
await store.writeFile("bot-1", "linked-dir/result.txt", "safe", context);
expect(await readFile(path.join(home, "target-dir", "result.txt"), "utf8")).toBe("safe");
expect(await store.list("bot-1", "linked-dir", context)).toEqual([
{ path: "linked-dir/result.txt", kind: "file", size: 4 },
]);
});
it("rejects reads and writes through symlinks outside the bot home", async () => {
const { root, store, home } = await fixture();
const outside = path.join(root, "outside.txt");
await writeFile(outside, "secret");
await symlink(outside, path.join(home, "escape.txt"));
await expect(store.readFile("bot-1", "escape.txt", context)).rejects.toThrow(/escapes/i);
await expect(store.writeFile("bot-1", "escape.txt", "changed", context)).rejects.toThrow(
/escapes/i,
);
expect(await readFile(outside, "utf8")).toBe("secret");
});
it("does not create directories through an external symlink", async () => {
const { root, store, home } = await fixture();
const outside = path.join(root, "outside-dir");
await mkdir(outside);
await symlink(outside, path.join(home, "escape-dir"), "junction");
await expect(
store.writeFile("bot-1", "escape-dir/new/result.txt", "changed", context),
).rejects.toThrow(/escapes/i);
await expect(readFile(path.join(outside, "new", "result.txt"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it("hides external symlinks from listings and exports", async () => {
const { root, store, home } = await fixture();
await writeFile(path.join(home, "safe.txt"), "safe");
await symlink(path.join(root, "outside"), path.join(home, "external"));
await writeFile(path.join(root, "outside"), "secret");
expect(await store.list("bot-1", "", context)).toEqual([
{ path: "safe.txt", kind: "file", size: 4 },
]);
const exported = [];
for await (const file of store.exportHome("bot-1", context)) exported.push(file.path);
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]);
await mkdir(path.join(home, "nested/empty"), { recursive: true });
await writeFile(path.join(home, "nested/run"), bytes);
await chmod(path.join(home, "nested/run"), 0o750);
await symlink("nested/run", path.join(home, "linked"));
await symlink(home, path.join(home, "nested/loop"), "junction");
const files = [];
for await (const file of store.exportHome("bot-1", context)) files.push(file);
expect(files.map((file) => file.path).sort()).toEqual(["linked", "nested/run"]);
for (const file of files) {
expect(Buffer.from(file.content)).toEqual(bytes);
if (process.platform !== "win32") expect(file.executable).toBe(true);
}
const dest = path.join(root, "checkout");
await store.checkout("bot-1", dest, context);
expect(await readFile(path.join(dest, "linked"))).toEqual(bytes);
expect((await stat(path.join(dest, "nested/empty"))).isDirectory()).toBe(true);
if (process.platform !== "win32") {
expect((await stat(path.join(dest, "nested/run"))).mode & 0o777).toBe(0o750);
}
await store.commit("bot-2", dest, context);
expect(await readFile(path.join(store.pathFor("bot-2"), "nested/run"))).toEqual(bytes);
});
});