spaces.create linked any workspacePath to the new project, and computer.files and computer.readFile then listed and read files under that folder for anyone in the space. On a deployment with signups, any member could link / or the API's own checkout and read whatever the API process can read, such as its .env file. Linking also ran git in the chosen folder and stored its remote URL. A linked folder lives on the host, and the host is the deployment owner's machine, the rule the onboarding scan already follows: - spaces.create refuses a folder from anyone but the deployment owner, before the space is created or the folder is touched. - Project files are listed and read only for the deployment owner, so a space a member linked before this change stays closed too. Space navigation no longer shows a linked folder's path and git remote to other members. - A linked folder is stored as its real path, and listing and reading follow symlinks only while they stay inside it. Symlinks that leave the folder or lead nowhere are left out of a listing instead of failing it. - The web New project dialog offers the folder field only to the deployment owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
61 lines
2.6 KiB
TypeScript
61 lines
2.6 KiB
TypeScript
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { listProjectWorkspaceFiles, readProjectWorkspaceFile } from "./project-workspace-files.js";
|
|
|
|
describe("project workspace files", () => {
|
|
const roots: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const root of roots.splice(0)) {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function linkedFolderBesideSecret() {
|
|
const base = mkdtempSync(path.join(tmpdir(), "project-workspace-files-"));
|
|
roots.push(base);
|
|
const project = path.join(base, "project");
|
|
const outside = path.join(base, "outside");
|
|
mkdirSync(path.join(project, "docs"), { recursive: true });
|
|
mkdirSync(outside);
|
|
writeFileSync(path.join(project, "docs", "readme.md"), "inside");
|
|
writeFileSync(path.join(outside, "secret.txt"), "outside");
|
|
return { project, outside };
|
|
}
|
|
|
|
it("lists and reads files inside the linked folder, including symlinks that stay inside", async () => {
|
|
const { project } = linkedFolderBesideSecret();
|
|
symlinkSync(path.join(project, "docs", "readme.md"), path.join(project, "readme-link.md"));
|
|
|
|
const listed = await listProjectWorkspaceFiles(project, "project");
|
|
expect(listed.map((entry) => entry.path).sort()).toEqual([
|
|
"project/docs",
|
|
"project/readme-link.md",
|
|
]);
|
|
expect(await listProjectWorkspaceFiles(project, "project/docs")).toEqual([
|
|
{ path: "project/docs/readme.md", kind: "file", size: 6 },
|
|
]);
|
|
const read = await readProjectWorkspaceFile(project, "project/readme-link.md");
|
|
expect(new TextDecoder().decode(read)).toBe("inside");
|
|
});
|
|
|
|
it("keeps symlinks from leaving the linked folder", async () => {
|
|
const { project, outside } = linkedFolderBesideSecret();
|
|
symlinkSync(outside, path.join(project, "escape"));
|
|
symlinkSync(path.join(outside, "secret.txt"), path.join(project, "secret-link.txt"));
|
|
symlinkSync(path.join(project, "missing"), path.join(project, "dangling"));
|
|
|
|
const listed = await listProjectWorkspaceFiles(project, "project");
|
|
expect(listed.map((entry) => entry.path)).toEqual(["project/docs"]);
|
|
await expect(listProjectWorkspaceFiles(project, "project/escape")).rejects.toThrow(/escapes/i);
|
|
await expect(readProjectWorkspaceFile(project, "project/escape/secret.txt")).rejects.toThrow(
|
|
/escapes/i,
|
|
);
|
|
await expect(readProjectWorkspaceFile(project, "project/secret-link.txt")).rejects.toThrow(
|
|
/escapes/i,
|
|
);
|
|
});
|
|
});
|