Files
penguin-harness/packages/server/test/workspace-files.test.ts
T
Yaowei Zheng 45bfae6e94 Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
2026-07-19 14:06:53 +08:00

225 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for the Workspace files service: directory-listing order, read/write,
* path confinement (`..` traversal and symlink escape), size-limit protection,
* batch existence checks (files/stat); and the Agent delete route (default_agent
* cannot be deleted, owner-only, directory and index cleanup).
*/
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { WorkspaceFilesService } from "../src/services/workspace-files-service.js";
import type {
AgentCreateResponse,
ProjectCreateResponse,
SessionCreateResponse,
} from "../src/api/types.js";
import { apiClient, createTestApp, makeTempRoot, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js";
describe("workspace-files-service", () => {
let ws: string;
let outside: string;
const svc = new WorkspaceFilesService();
beforeEach(async () => {
ws = await makeTempRoot();
outside = await makeTempRoot();
await fs.mkdir(path.join(ws, "sub"));
await fs.writeFile(path.join(ws, "b.txt"), "hello");
await fs.writeFile(path.join(ws, "sub", "c.md"), "# md");
await fs.writeFile(path.join(outside, "secret.txt"), "secret");
});
afterEach(async () => {
await fs.rm(ws, { recursive: true, force: true });
await fs.rm(outside, { recursive: true, force: true });
});
it("列目录:dir 在前、按名称排序;子目录路径生效", async () => {
const root = await svc.list(ws, "");
expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual(["dir:sub", "file:b.txt"]);
const sub = await svc.list(ws, "sub");
expect(sub.entries.map((e) => e.name)).toEqual(["c.md"]);
});
it("读文件:内容与 content-type;目录/不存在报错", async () => {
const file = await svc.read(ws, "sub/c.md");
expect(file.data.toString()).toBe("# md");
expect(file.contentType).toContain("markdown");
await expect(svc.read(ws, "sub")).rejects.toMatchObject({ status: 400 });
await expect(svc.read(ws, "nope.txt")).rejects.toMatchObject({ status: 404 });
});
it("写文件:覆盖写入;父目录缺失时自动补建(上传文件夹保留目录结构)", async () => {
await svc.write(ws, "sub/new.txt", Buffer.from("data"));
expect(await fs.readFile(path.join(ws, "sub", "new.txt"), "utf8")).toBe("data");
await svc.write(ws, "missing/deep/x.txt", Buffer.from("d"));
expect(await fs.readFile(path.join(ws, "missing", "deep", "x.txt"), "utf8")).toBe("d");
});
it("路径限域:`..` 穿越与绝对路径均拒绝", async () => {
await expect(svc.list(ws, "../")).rejects.toMatchObject({ status: 400 });
await expect(svc.read(ws, `../${path.basename(outside)}/secret.txt`)).rejects.toMatchObject({
status: 400,
});
await expect(svc.write(ws, "../escape.txt", Buffer.from("x"))).rejects.toMatchObject({
status: 400,
});
});
it("Workspace 为文件系统根:子目录可正常下钻(前缀拼接 '//' 回归)", async () => {
const root = path.parse(ws).root;
const sub = await svc.list(root, path.relative(root, path.join(ws, "sub")));
expect(sub.entries.map((e) => e.name)).toEqual(["c.md"]);
});
it("指向 Workspace 内目录的符号链接:kind 为 dir 且可下钻", async () => {
await fs.symlink(path.join(ws, "sub"), path.join(ws, "link-sub"));
const root = await svc.list(ws, "");
expect(root.entries.map((e) => `${e.kind}:${e.name}`)).toEqual([
"dir:link-sub",
"dir:sub",
"file:b.txt",
]);
const viaLink = await svc.list(ws, "link-sub");
expect(viaLink.entries.map((e) => e.name)).toEqual(["c.md"]);
});
it("符号链接逃逸:链接指向 Workspace 外时读写均拒绝", async () => {
await fs.symlink(outside, path.join(ws, "link-out"));
await expect(svc.list(ws, "link-out")).rejects.toMatchObject({ status: 400 });
await expect(svc.read(ws, "link-out/secret.txt")).rejects.toMatchObject({ status: 400 });
// Writing outside via a directory symlink: caught by the parent-directory realpath check.
await expect(svc.write(ws, "link-out/evil.txt", Buffer.from("x"))).rejects.toMatchObject({
status: 400,
});
// Auto-creation under a missing path is equally restricted: if the nearest
// existing ancestor is a symlink pointing outside, mkdir must not be used to escape.
await expect(svc.write(ws, "link-out/new/evil.txt", Buffer.from("x"))).rejects.toMatchObject({
status: 400,
});
expect(
await fs
.stat(path.join(outside, "new"))
.then(() => true)
.catch(() => false),
).toBe(false);
});
it("末段符号链接写入:O_NOFOLLOW 拒绝借刀覆盖域外文件", async () => {
// The Agent has a symlink inside the Workspace pointing to an outside file; an upload attempts to overwrite it.
const victim = path.join(outside, "secret.txt");
await fs.symlink(victim, path.join(ws, "report.pdf"));
await expect(svc.write(ws, "report.pdf", Buffer.from("PWNED"))).rejects.toMatchObject({
status: 400,
});
// The outside file's content is unchanged.
expect(await fs.readFile(victim, "utf8")).toBe("secret");
});
});
describe("files/stat 路由(批量存在性检查)", () => {
let t: TestApp;
let owner: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>;
let sessionId: string;
beforeEach(async () => {
t = await createTestApp();
const a = await provisionUser(t.app, "owner");
const b = await provisionUser(t.app, "outsider");
owner = apiClient(t.app, a.cookie);
outsider = apiClient(t.app, b.cookie);
const created = (await (
await owner.post("/api/projects", { projectId: "owner-stat", name: "项目" })
).json()) as ProjectCreateResponse;
const projectId = created.project.projectId;
await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" },
models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6", contextWindow: 128000 }],
});
const sess = (await (
await owner.post(`/api/projects/${projectId}/agents/default_agent/sessions`, {})
).json()) as SessionCreateResponse;
sessionId = sess.session.sessionId;
await fs.mkdir(path.join(sess.session.workspace, "sub"));
await fs.writeFile(path.join(sess.session.workspace, "a.txt"), "A");
await fs.writeFile(path.join(sess.session.workspace, "sub", "b.md"), "B");
});
afterEach(async () => {
await t.cleanup();
});
it("存在的文件保序去重返回;不存在 / 目录 / 越界一律按不存在计且恒 200", async () => {
const res = await owner.post(`/api/sessions/${sessionId}/files/stat`, {
paths: ["sub/b.md", "a.txt", "sub/b.md", "nope.txt", "sub", "../escape.txt", "/etc/passwd"],
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ existing: ["sub/b.md", "a.txt"] });
const empty = await owner.post(`/api/sessions/${sessionId}/files/stat`, { paths: [] });
expect(empty.status).toBe(200);
expect(await empty.json()).toEqual({ existing: [] });
});
it("非法 body → 400:非数组 / 非字符串项 / 超量 / 超长", async () => {
const url = `/api/sessions/${sessionId}/files/stat`;
expect((await owner.post(url, { paths: "a.txt" })).status).toBe(400);
expect((await owner.post(url, { paths: [1] })).status).toBe(400);
const tooMany = Array.from({ length: 101 }, () => "a.txt");
expect((await owner.post(url, { paths: tooMany })).status).toBe(400);
expect((await owner.post(url, { paths: ["x".repeat(513)] })).status).toBe(400);
});
it("外人访问 → 404(不泄露存在性)", async () => {
const res = await outsider.post(`/api/sessions/${sessionId}/files/stat`, {
paths: ["a.txt"],
});
expect(res.status).toBe(404);
});
});
describe("agent 删除路由", () => {
let t: TestApp;
let owner: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>;
let projectId: string;
beforeEach(async () => {
t = await createTestApp();
const a = await provisionUser(t.app, "owner");
const b = await provisionUser(t.app, "outsider");
owner = apiClient(t.app, a.cookie);
outsider = apiClient(t.app, b.cookie);
const created = (await (
await owner.post("/api/projects", { projectId: "owner-ws", name: "项目" })
).json()) as ProjectCreateResponse;
projectId = created.project.projectId;
});
afterEach(async () => {
await t.cleanup();
});
it("owner 删除 Agent:204,目录与列表项消失;default_agent 409;外人 404", async () => {
const created = (await (
await owner.post(`/api/projects/${projectId}/agents`, { agentId: "temp_agent", name: "临时" })
).json()) as AgentCreateResponse;
const agentId = created.agent.agentId;
const dir = path.join(t.root, projectId, "agents", agentId);
await fs.access(dir); // directory exists after creation
const outsiderRes = await outsider.delete(`/api/projects/${projectId}/agents/${agentId}`);
expect(outsiderRes.status).toBe(404); // no access → don't leak existence
const res = await owner.delete(`/api/projects/${projectId}/agents/${agentId}`);
expect(res.status).toBe(204);
await expect(fs.access(dir)).rejects.toThrow();
const list = (await (await owner.get(`/api/projects/${projectId}/agents`)).json()) as {
agents: Array<{ agentId: string }>;
};
expect(list.agents.some((x) => x.agentId === agentId)).toBe(false);
const def = await owner.delete(`/api/projects/${projectId}/agents/default_agent`);
expect(def.status).toBe(409);
});
});