cynarAI 97787a0272
Production readiness phase 1: journey audit, fixes 1–4, E2E (#175)
* Production readiness phase 1: journeys doc, onboarding persistence, teardown, chat UX

- Add docs/PRODUCTION-READINESS.md with journey matrix and defect backlog
- Persist onboarding draft in session storage across reload
- Localize onboarding discovery main-bot role; German Chief of Staff string
- Destroy team sandboxes and home folders when deleting an account
- Keep composer/transcript visible during queued runs; extend send watchdog
- Add Playwright specs: reload persistence, DE intake, queued progress, console guard

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix CI: sync Lingui catalogs, onboarding name draft, E2E stability

- Localize Chief of Staff in discovery shell; sync all locale PO files
- Propagate Oo name to onboarding draft while typing
- Adjust DE intake, queued-run, and console-error E2E specs

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format chat-queued-progress E2E spec

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* 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>

* chore: apply biome formatting

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 11:25:39 +02:00

189 lines
7.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, removeAgentHomeDirectory, resolveSafeAgentHomePath } 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);
});
});
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);
});
});