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>
167 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|