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

382 lines
12 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import {
access,
mkdir,
open,
readdir,
realpath,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import type { AdapterContext, AgentHomeStore, PortableFile } from "@rakazo/adapter-kit";
import { normalizeWorkspacePath } from "./computer-support.js";
import { fileHandlePath } from "./file-handle-path.js";
export class LocalAgentHomeStore implements AgentHomeStore {
private readonly botWrites = new Map<string, Promise<void>>();
constructor(private readonly root: string) {}
describe() {
return {
id: "local-fs",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { revisions: false },
};
}
private botDir(botId: string) {
if (!botId || botId === "." || botId === ".." || path.basename(botId) !== botId) {
throw new Error("Invalid bot id");
}
return path.join(this.root, "homes", botId);
}
pathFor(botId: string) {
return path.resolve(this.botDir(botId));
}
async checkout(botId: string, dest: string, _context: AdapterContext): Promise<string> {
await this.waitForBotWrite(botId);
await this.recoverInterruptedCommit(botId);
await mkdir(dest, { recursive: true });
const src = this.botDir(botId);
await mkdir(src, { recursive: true });
await copyDir(src, dest);
return "working";
}
async commit(botId: string, src: string, _context: AdapterContext): Promise<string> {
return this.withBotWrite(botId, async () => {
await this.recoverInterruptedCommit(botId);
const dest = this.botDir(botId);
const parent = path.dirname(dest);
const staging = path.join(parent, `.${botId}.staging-${randomUUID()}`);
const previous = `${dest}.previous`;
await mkdir(parent, { recursive: true });
await mkdir(staging, { recursive: true });
try {
await copyDir(src, staging);
await rm(previous, { recursive: true, force: true });
if (await pathExists(dest)) await rename(dest, previous);
try {
await rename(staging, dest);
} catch (error) {
if (!(await pathExists(dest)) && (await pathExists(previous))) {
await rename(previous, dest).catch(() => undefined);
}
throw error;
}
await rm(previous, { recursive: true, force: true });
return this.writeRevision(botId);
} finally {
await rm(staging, { recursive: true, force: true });
}
});
}
async revise(botId: string): Promise<string> {
return this.withBotWrite(botId, async () => this.writeRevision(botId));
}
async restore(
botId: string,
_revision: string,
dest: string,
context: AdapterContext,
): Promise<void> {
await this.checkout(botId, dest, context);
}
async *exportHome(
botId: string,
_context: AdapterContext,
options?: { prefix?: string },
): AsyncIterable<PortableFile> {
await this.waitForBotWrite(botId);
await this.recoverInterruptedCommit(botId);
const dir = this.botDir(botId);
await mkdir(dir, { recursive: true });
const root = await realpath(dir);
if (!options?.prefix) {
yield* walkFiles(root, root);
return;
}
const scoped = await containedExistingPath(root, normalizeWorkspacePath(options.prefix)).catch(
() => null,
);
if (!scoped) return;
yield* walkFiles(root, scoped);
}
async readFile(
botId: string,
filePath: string,
_context: AdapterContext,
options?: { maxBytes?: number },
): Promise<string> {
await this.waitForBotWrite(botId);
await this.recoverInterruptedCommit(botId);
const full = await containedExistingPath(this.botDir(botId), filePath);
const handle = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
if (options?.maxBytes !== undefined) {
const info = await handle.stat();
if (info.size > options.maxBytes) {
throw new Error(`agent home file exceeds ${options.maxBytes} bytes`);
}
}
return await handle.readFile("utf8");
} finally {
await handle.close();
}
}
async writeFile(
botId: string,
filePath: string,
content: string,
_context: AdapterContext,
): Promise<void> {
await this.withBotWrite(botId, async () => {
await this.recoverInterruptedCommit(botId);
const full = await containedWritePath(this.botDir(botId), filePath);
const handle = await open(
full,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
0o666,
);
try {
await handle.writeFile(content, "utf8");
} finally {
await handle.close();
}
});
}
async list(botId: string, dirPath: string, _context: AdapterContext) {
await this.waitForBotWrite(botId);
await this.recoverInterruptedCommit(botId);
const root = this.botDir(botId);
const candidate = safeJoin(root, dirPath);
const full = await ensureContainedDirectory(root, candidate);
const entries = await readdir(full, { withFileTypes: true });
const listed = await Promise.all(
entries.map(async (entry) => {
const child = await containedTarget(root, path.join(full, entry.name)).catch(() => null);
if (!child) return null;
const info = await stat(child);
return {
path: path.posix.join(dirPath.replace(/\\/g, "/"), entry.name),
kind: info.isDirectory() ? ("dir" as const) : ("file" as const),
size: info.size,
};
}),
);
return listed.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
}
private async writeRevision(botId: string) {
const revision = `rev-${Date.now()}-${randomUUID().slice(0, 8)}`;
const directory = path.join(this.root, "home-revisions");
await mkdir(directory, { recursive: true });
await writeFile(path.join(directory, `${botId}.txt`), revision, "utf8");
return revision;
}
private async recoverInterruptedCommit(botId: string) {
const dest = this.botDir(botId);
const previous = `${dest}.previous`;
if (!(await pathExists(dest)) && (await pathExists(previous))) await rename(previous, dest);
}
private async withBotWrite<T>(botId: string, work: () => Promise<T>): Promise<T> {
const previous = this.botWrites.get(botId) ?? Promise.resolve();
let release: () => void = () => undefined;
const current = new Promise<void>((resolve) => {
release = resolve;
});
// Keep the chain alive even when a prior write rejects, so later writers are not stuck
// behind a permanently rejected predecessor.
const queued = previous.catch(() => undefined).then(() => current);
this.botWrites.set(botId, queued);
await previous.catch(() => undefined);
try {
return await work();
} finally {
release();
if (this.botWrites.get(botId) === queued) this.botWrites.delete(botId);
}
}
private async waitForBotWrite(botId: string) {
await (this.botWrites.get(botId) ?? Promise.resolve());
}
}
export function resolveAgentHomePath(home: AgentHomeStore, homeKey: string, dataDir = "./data") {
if (home instanceof LocalAgentHomeStore) return home.pathFor(homeKey);
return path.resolve(dataDir, "homes", homeKey);
}
function safeJoin(root: string, rel: string) {
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(resolvedRoot, `.${path.sep}${rel.replace(/^\/+/, "")}`);
assertContained(resolvedRoot, resolved);
return resolved;
}
async function containedExistingPath(root: string, rel: string) {
await mkdir(root, { recursive: true });
const candidate = safeJoin(root, rel);
return containedTarget(root, candidate);
}
async function containedWritePath(root: string, rel: string) {
await mkdir(root, { recursive: true });
const candidate = safeJoin(root, rel);
const resolvedParent = await ensureContainedDirectory(root, path.dirname(candidate));
try {
return await containedTarget(root, path.join(resolvedParent, path.basename(candidate)));
} catch (error) {
if (isMissing(error)) return path.join(resolvedParent, path.basename(candidate));
throw error;
}
}
async function ensureContainedDirectory(root: string, candidate: string) {
await mkdir(root, { recursive: true });
const lexicalRoot = path.resolve(root);
const lexicalCandidate = path.resolve(candidate);
assertContained(lexicalRoot, lexicalCandidate);
const resolvedRoot = await realpath(root);
const relative = path.relative(lexicalRoot, lexicalCandidate);
let current = resolvedRoot;
for (const segment of relative.split(path.sep).filter(Boolean)) {
const next = path.join(current, segment);
try {
current = await realpath(next);
} catch (error) {
if (!isMissing(error)) throw error;
await mkdir(next);
current = await realpath(next);
}
assertContained(resolvedRoot, current);
if (!(await stat(current)).isDirectory()) throw new Error("Path component is not a directory");
}
return current;
}
async function containedTarget(root: string, candidate: string) {
const [resolvedRoot, resolvedTarget] = await Promise.all([realpath(root), realpath(candidate)]);
assertContained(resolvedRoot, resolvedTarget);
return resolvedTarget;
}
function assertContained(root: string, candidate: string) {
const relative = path.relative(root, candidate);
if (
relative === "" ||
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
)
return;
throw new Error("Path escapes the bot home");
}
function isMissing(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
async function pathExists(target: string) {
try {
await access(target);
return true;
} catch {
return false;
}
}
async function traversalTarget(root: string, candidate: string) {
const resolved = await realpath(candidate);
assertContained(root, resolved);
return resolved;
}
async function readTraversalFile(root: string, full: string) {
const handle = await open(
full,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
);
try {
const info = await handle.stat();
if (!info.isFile()) throw new Error("Home entry is not a regular file");
// O_NOFOLLOW only protects the final component. A parent can be swapped
// during open and restored before any pathname recheck. Check the actual
// opened object against the root captured once for the entire traversal.
assertContained(root, await fileHandlePath(handle.fd));
return { content: await handle.readFile(), mode: info.mode };
} finally {
await handle.close();
}
}
async function copyDir(
src: string,
dest: string,
sourceRoot?: string,
visited = new Set<string>(),
) {
const root = sourceRoot ?? (await realpath(src));
const current = await traversalTarget(root, src).catch(() => null);
if (!current || visited.has(current)) return;
await mkdir(dest, { recursive: true });
visited.add(current);
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const from = await traversalTarget(root, path.join(current, entry.name)).catch(() => null);
if (!from) continue;
const to = path.join(dest, entry.name);
const info = await stat(from);
if (info.isDirectory()) await copyDir(from, to, root, visited);
else if (info.isFile()) {
const file = await readTraversalFile(root, from);
await writeFile(to, file.content, { mode: file.mode & 0o777 });
}
}
}
async function* walkFiles(
root: string,
current: string,
outputPath = "",
visited = new Set<string>(),
): AsyncGenerator<PortableFile> {
const resolvedCurrent = await traversalTarget(root, current).catch(() => null);
if (!resolvedCurrent || visited.has(resolvedCurrent)) return;
visited.add(resolvedCurrent);
const entries = await readdir(resolvedCurrent, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const full = await traversalTarget(root, path.join(resolvedCurrent, entry.name)).catch(
() => null,
);
if (!full) continue;
const info = await stat(full);
const portablePath = path.posix.join(outputPath, entry.name);
if (info.isDirectory()) {
yield* walkFiles(root, full, portablePath, visited);
} else if (info.isFile()) {
const { content, mode } = await readTraversalFile(root, full);
yield {
path: portablePath,
content: new Uint8Array(content),
executable: Boolean(mode & 0o100),
};
}
}
}