d4faee3a1e
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
/**
|
|
* 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("directory listing: dirs first, sorted by name; subdirectory paths work", 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("file read: content and content-type; directories / missing files error", 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("file write: overwrites; missing parent directories are auto-created (folder uploads keep their structure)", 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("path confinement: `..` traversal and absolute paths are both rejected", 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 at the filesystem root: subdirectories still drill down (prefix-joining '//' regression)", 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("a symlink to a directory inside the Workspace: kind is dir and it can be drilled into", 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("symlink escape: reads and writes are both rejected when the link points outside the 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("writing through a last-segment symlink: O_NOFOLLOW refuses to overwrite files outside the Workspace by proxy", 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 route (batch existence check)", () => {
|
|
let t: TestApp;
|
|
let owner: ReturnType<typeof apiClient>;
|
|
let outsider: ReturnType<typeof apiClient>;
|
|
let sessionId: string;
|
|
let workspace: 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: "project" })
|
|
).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;
|
|
workspace = sess.session.workspace;
|
|
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("files/content on html: inline stays text/plain; preview=1 keeps text/html under a CSP sandbox; download keeps the real type with no CSP", async () => {
|
|
await fs.writeFile(path.join(workspace, "page.html"), "<!doctype html><script>1</script>");
|
|
const url = `/api/sessions/${sessionId}/files/content?path=page.html`;
|
|
|
|
const inline = await owner.get(url);
|
|
expect(inline.status).toBe(200);
|
|
expect(inline.headers.get("content-type")).toContain("text/plain");
|
|
expect(inline.headers.get("content-security-policy")).toBeNull();
|
|
|
|
const preview = await owner.get(`${url}&preview=1`);
|
|
expect(preview.status).toBe(200);
|
|
expect(preview.headers.get("content-type")).toContain("text/html");
|
|
const csp = preview.headers.get("content-security-policy") ?? "";
|
|
expect(csp).toContain("sandbox");
|
|
expect(csp).not.toContain("allow-same-origin");
|
|
expect(preview.headers.get("content-disposition")).toContain("inline");
|
|
|
|
// download wins over preview: attachment + real type, no CSP needed.
|
|
const download = await owner.get(`${url}&download=1&preview=1`);
|
|
expect(download.headers.get("content-type")).toContain("text/html");
|
|
expect(download.headers.get("content-disposition")).toContain("attachment");
|
|
expect(download.headers.get("content-security-policy")).toBeNull();
|
|
|
|
// Non-scriptable files are unaffected by preview.
|
|
const txt = await owner.get(`/api/sessions/${sessionId}/files/content?path=a.txt&preview=1`);
|
|
expect(txt.headers.get("content-type")).toContain("text/plain");
|
|
expect(txt.headers.get("content-security-policy")).toBeNull();
|
|
});
|
|
|
|
it("existing files return in order, deduplicated; missing / directory / out-of-bounds all count as nonexistent, always 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("invalid body → 400: non-array / non-string entries / too many / too long", 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("outsider access → 404 (no existence leak)", async () => {
|
|
const res = await outsider.post(`/api/sessions/${sessionId}/files/stat`, {
|
|
paths: ["a.txt"],
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("agent delete route", () => {
|
|
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: "project" })
|
|
).json()) as ProjectCreateResponse;
|
|
projectId = created.project.projectId;
|
|
});
|
|
afterEach(async () => {
|
|
await t.cleanup();
|
|
});
|
|
|
|
it("owner deletes an Agent: 204, the directory and list entry disappear; default_agent 409; outsiders 404", async () => {
|
|
const created = (await (
|
|
await owner.post(`/api/projects/${projectId}/agents`, { agentId: "temp_agent", name: "temp" })
|
|
).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);
|
|
});
|
|
});
|