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
73 lines
2.6 KiB
TypeScript
73 lines
2.6 KiB
TypeScript
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { ComputerFileEntry } from "@rakazo/adapter-kit";
|
|
import { normalizeWorkspacePath } from "./computer-support.js";
|
|
import { isAllowedDesktopPath } from "./desktop-sandbox-paths.js";
|
|
import {
|
|
isProjectWorkspacePath,
|
|
projectWorkspaceRelativePath,
|
|
resolveProjectHostPath,
|
|
} from "./project-workspace-path.js";
|
|
|
|
export async function listProjectWorkspaceFiles(
|
|
workspaceRoot: string,
|
|
storedPath: string,
|
|
): Promise<ComputerFileEntry[]> {
|
|
if (!isProjectWorkspacePath(storedPath)) {
|
|
throw new Error("Path is not under the project workspace");
|
|
}
|
|
const root = await realpath(workspaceRoot);
|
|
const hostDirectory = await containedHostPath(
|
|
root,
|
|
resolveProjectHostPath(workspaceRoot, storedPath),
|
|
);
|
|
const relativeDirectory = projectWorkspaceRelativePath(storedPath);
|
|
const entries = await readdir(hostDirectory, { withFileTypes: true });
|
|
const listed = await Promise.all(
|
|
entries.map(async (entry) => {
|
|
// A symlink that leaves the linked folder, or leads nowhere, is not part of the project.
|
|
const info = await containedHostPath(root, path.join(hostDirectory, entry.name))
|
|
.then((target) => stat(target))
|
|
.catch(() => null);
|
|
if (!info) return [];
|
|
const childStored = normalizeWorkspacePath(
|
|
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`,
|
|
);
|
|
return [
|
|
{
|
|
path: childStored,
|
|
kind: info.isDirectory() ? ("dir" as const) : ("file" as const),
|
|
size: info.size,
|
|
...(info.isFile() && info.mode & 0o100 ? { executable: true } : {}),
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
return listed.flat();
|
|
}
|
|
|
|
export async function readProjectWorkspaceFile(
|
|
workspaceRoot: string,
|
|
storedPath: string,
|
|
options?: { maxBytes?: number },
|
|
): Promise<Uint8Array> {
|
|
const hostPath = await containedHostPath(
|
|
await realpath(workspaceRoot),
|
|
resolveProjectHostPath(workspaceRoot, storedPath),
|
|
);
|
|
const info = await stat(hostPath);
|
|
if (options?.maxBytes !== undefined && info.size > options.maxBytes) {
|
|
throw new Error(`computer file exceeds ${options.maxBytes} bytes`);
|
|
}
|
|
return new Uint8Array(await readFile(hostPath));
|
|
}
|
|
|
|
/** Follows symlinks, but only to places inside the linked folder. */
|
|
async function containedHostPath(root: string, candidate: string): Promise<string> {
|
|
const target = await realpath(candidate);
|
|
if (!isAllowedDesktopPath(target, [root])) {
|
|
throw new Error("Path escapes the project workspace");
|
|
}
|
|
return target;
|
|
}
|