fix(api): keep host folders to the deployment owner (#112)
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
This commit is contained in:
parent
2b967df569
commit
3c3961f242
@ -569,6 +569,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
spaceNavigationDto(deps, context.actor, repos, groupRepos),
|
||||||
),
|
),
|
||||||
create: authed.spaces.create.handler(async ({ context, input }) => {
|
create: authed.spaces.create.handler(async ({ context, input }) => {
|
||||||
|
// A linked folder lives on the host, and the host is the deployment owner's machine.
|
||||||
|
if (input.workspacePath && !context.actor.isDeploymentOwner) {
|
||||||
|
throw new ORPCError("FORBIDDEN");
|
||||||
|
}
|
||||||
let space: { id: string; name: string };
|
let space: { id: string; name: string };
|
||||||
try {
|
try {
|
||||||
space = await createSpaceForMember(deps.prisma, {
|
space = await createSpaceForMember(deps.prisma, {
|
||||||
@ -2230,10 +2234,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProjectWorkspacePath(storedPath)) {
|
if (isProjectWorkspacePath(storedPath)) {
|
||||||
const workspaceRoot = await requireProjectWorkspaceRoot(
|
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
|
||||||
deps.prisma,
|
|
||||||
context.actor.spaceId,
|
|
||||||
);
|
|
||||||
const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath);
|
const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath);
|
||||||
return entries.map((entry) => ({
|
return entries.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@ -2281,10 +2282,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProjectWorkspacePath(storedPath)) {
|
if (isProjectWorkspacePath(storedPath)) {
|
||||||
const workspaceRoot = await requireProjectWorkspaceRoot(
|
const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor);
|
||||||
deps.prisma,
|
|
||||||
context.actor.spaceId,
|
|
||||||
);
|
|
||||||
const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, {
|
const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, {
|
||||||
maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES,
|
maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES,
|
||||||
});
|
});
|
||||||
@ -4894,10 +4892,13 @@ function mapUpdaterError(error: unknown): never {
|
|||||||
|
|
||||||
async function requireProjectWorkspaceRoot(
|
async function requireProjectWorkspaceRoot(
|
||||||
prisma: Pick<PrismaClient, "space">,
|
prisma: Pick<PrismaClient, "space">,
|
||||||
spaceId: string,
|
actor: Actor,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// Only the deployment owner may link a host folder, so only they may browse one. This also
|
||||||
|
// closes folders that other members linked before linking was limited to the owner.
|
||||||
|
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
|
||||||
const space = await prisma.space.findUnique({
|
const space = await prisma.space.findUnique({
|
||||||
where: { id: spaceId },
|
where: { id: actor.spaceId },
|
||||||
select: { workspacePath: true },
|
select: { workspacePath: true },
|
||||||
});
|
});
|
||||||
if (!space?.workspacePath) {
|
if (!space?.workspacePath) {
|
||||||
@ -5009,7 +5010,8 @@ async function spaceNavigationDto(
|
|||||||
memberships.length > 1 &&
|
memberships.length > 1 &&
|
||||||
!spacesWithContent.has(membership.spaceId) &&
|
!spacesWithContent.has(membership.spaceId) &&
|
||||||
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
|
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
|
||||||
workspace: projectWorkspaceDto(membership.space),
|
// A linked folder is on the owner's host; other members see neither its path nor remote.
|
||||||
|
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
|
||||||
bots: spaceBots.map((bot) => ({
|
bots: spaceBots.map((bot) => ({
|
||||||
id: bot.id,
|
id: bot.id,
|
||||||
spaceId: bot.spaceId,
|
spaceId: bot.spaceId,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
captureScreenshot,
|
captureScreenshot,
|
||||||
completeOnboarding,
|
completeOnboarding,
|
||||||
openNewSpace,
|
openNewSpace,
|
||||||
|
routePatchRpc,
|
||||||
rpc,
|
rpc,
|
||||||
signup,
|
signup,
|
||||||
unterhaltungenOnly,
|
unterhaltungenOnly,
|
||||||
@ -45,6 +46,30 @@ test("Projekte + opens new project dialog instead of group form", async ({ page
|
|||||||
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/);
|
await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const owner of [true, false]) {
|
||||||
|
const role = owner ? "owner" : "member";
|
||||||
|
const title = owner
|
||||||
|
? "new project dialog offers a host folder to the deployment owner"
|
||||||
|
: "new project dialog offers no host folder to a member";
|
||||||
|
test(title, async ({ page }, testInfo) => {
|
||||||
|
await signup(page, `proj-folder-${role}-${Date.now()}@rakazo.test`, "password12", "Folder");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
// Whoever signs up first owns the deployment, which depends on spec order.
|
||||||
|
await routePatchRpc(page, "bootstrap", (json) => ({
|
||||||
|
...json,
|
||||||
|
me: { ...(json.me as Record<string, unknown>), isDeploymentOwner: owner },
|
||||||
|
}));
|
||||||
|
await page.goto("/app");
|
||||||
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
|
await page.locator(".sidebar-projekte").getByTestId("create-project-trigger").click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "New project" });
|
||||||
|
await expect(dialog.getByLabel("Name")).toBeVisible();
|
||||||
|
await expect(dialog.getByLabel("Project folder")).toHaveCount(owner ? 1 : 0);
|
||||||
|
await captureScreenshot(page, testInfo, `new-project-${role}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("spaces stay invisible by default and chat creation requires approval", async ({
|
test("spaces stay invisible by default and chat creation requires approval", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
@ -5482,6 +5482,7 @@ export function ShellPage() {
|
|||||||
{newSpaceOpen ? (
|
{newSpaceOpen ? (
|
||||||
<NewSpaceDialog
|
<NewSpaceDialog
|
||||||
variant={newSpaceDialogVariant}
|
variant={newSpaceDialogVariant}
|
||||||
|
canLinkFolder={bootstrapMe?.isDeploymentOwner === true}
|
||||||
onCancel={() => setNewSpaceOpen(false)}
|
onCancel={() => setNewSpaceOpen(false)}
|
||||||
onConfirm={async ({ name, workspacePath }) => {
|
onConfirm={async ({ name, workspacePath }) => {
|
||||||
const space = await rpc.spaces.create({
|
const space = await rpc.spaces.create({
|
||||||
|
|||||||
@ -38,10 +38,13 @@ export type NewSpaceConfirmInput = {
|
|||||||
|
|
||||||
export function NewSpaceDialog({
|
export function NewSpaceDialog({
|
||||||
variant = "space",
|
variant = "space",
|
||||||
|
canLinkFolder,
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: {
|
}: {
|
||||||
variant?: "space" | "project";
|
variant?: "space" | "project";
|
||||||
|
/** Folders live on the host, which only the deployment owner may link. */
|
||||||
|
canLinkFolder: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: (input: NewSpaceConfirmInput) => Promise<void>;
|
onConfirm: (input: NewSpaceConfirmInput) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
@ -53,6 +56,7 @@ export function NewSpaceDialog({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const isProject = variant === "project";
|
const isProject = variant === "project";
|
||||||
|
const showFolder = isProject && canLinkFolder;
|
||||||
const desktop = desktopBridge();
|
const desktop = desktopBridge();
|
||||||
const trimmedWorkspacePath = workspacePath.trim();
|
const trimmedWorkspacePath = workspacePath.trim();
|
||||||
|
|
||||||
@ -63,7 +67,7 @@ export function NewSpaceDialog({
|
|||||||
setError(null);
|
setError(null);
|
||||||
void onConfirm({
|
void onConfirm({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
...(isProject && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}),
|
...(showFolder && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}),
|
||||||
}).catch((reason: unknown) => {
|
}).catch((reason: unknown) => {
|
||||||
setError(
|
setError(
|
||||||
reason instanceof Error
|
reason instanceof Error
|
||||||
@ -104,7 +108,7 @@ export function NewSpaceDialog({
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{isProject ? (
|
{showFolder ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor={folderId} className="block text-ui-body text-foreground/75">
|
<label htmlFor={folderId} className="block text-ui-body text-foreground/75">
|
||||||
<Trans>Project folder</Trans>
|
<Trans>Project folder</Trans>
|
||||||
|
|||||||
60
packages/adapters/src/project-workspace-files.test.ts
Normal file
60
packages/adapters/src/project-workspace-files.test.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { readdir, readFile, stat } from "node:fs/promises";
|
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import type { ComputerFileEntry } from "@rakazo/adapter-kit";
|
import type { ComputerFileEntry } from "@rakazo/adapter-kit";
|
||||||
import { normalizeWorkspacePath } from "./computer-support.js";
|
import { normalizeWorkspacePath } from "./computer-support.js";
|
||||||
|
import { isAllowedDesktopPath } from "./desktop-sandbox-paths.js";
|
||||||
import {
|
import {
|
||||||
isProjectWorkspacePath,
|
isProjectWorkspacePath,
|
||||||
projectWorkspaceRelativePath,
|
projectWorkspaceRelativePath,
|
||||||
@ -14,28 +16,34 @@ export async function listProjectWorkspaceFiles(
|
|||||||
if (!isProjectWorkspacePath(storedPath)) {
|
if (!isProjectWorkspacePath(storedPath)) {
|
||||||
throw new Error("Path is not under the project workspace");
|
throw new Error("Path is not under the project workspace");
|
||||||
}
|
}
|
||||||
const hostDirectory = resolveProjectHostPath(workspaceRoot, storedPath);
|
const root = await realpath(workspaceRoot);
|
||||||
|
const hostDirectory = await containedHostPath(
|
||||||
|
root,
|
||||||
|
resolveProjectHostPath(workspaceRoot, storedPath),
|
||||||
|
);
|
||||||
const relativeDirectory = projectWorkspaceRelativePath(storedPath);
|
const relativeDirectory = projectWorkspaceRelativePath(storedPath);
|
||||||
const entries = await readdir(hostDirectory, { withFileTypes: true });
|
const entries = await readdir(hostDirectory, { withFileTypes: true });
|
||||||
const listed = await Promise.all(
|
const listed = await Promise.all(
|
||||||
entries.map(async (entry) => {
|
entries.map(async (entry) => {
|
||||||
const childHost = resolveProjectHostPath(
|
// A symlink that leaves the linked folder, or leads nowhere, is not part of the project.
|
||||||
workspaceRoot,
|
const info = await containedHostPath(root, path.join(hostDirectory, entry.name))
|
||||||
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`,
|
.then((target) => stat(target))
|
||||||
);
|
.catch(() => null);
|
||||||
const info = await stat(childHost);
|
if (!info) return [];
|
||||||
const childStored = normalizeWorkspacePath(
|
const childStored = normalizeWorkspacePath(
|
||||||
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`,
|
relativeDirectory ? `project/${relativeDirectory}/${entry.name}` : `project/${entry.name}`,
|
||||||
);
|
);
|
||||||
return {
|
return [
|
||||||
path: childStored,
|
{
|
||||||
kind: info.isDirectory() ? ("dir" as const) : ("file" as const),
|
path: childStored,
|
||||||
size: info.size,
|
kind: info.isDirectory() ? ("dir" as const) : ("file" as const),
|
||||||
...(info.isFile() && info.mode & 0o100 ? { executable: true } : {}),
|
size: info.size,
|
||||||
};
|
...(info.isFile() && info.mode & 0o100 ? { executable: true } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return listed;
|
return listed.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readProjectWorkspaceFile(
|
export async function readProjectWorkspaceFile(
|
||||||
@ -43,10 +51,22 @@ export async function readProjectWorkspaceFile(
|
|||||||
storedPath: string,
|
storedPath: string,
|
||||||
options?: { maxBytes?: number },
|
options?: { maxBytes?: number },
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const hostPath = resolveProjectHostPath(workspaceRoot, storedPath);
|
const hostPath = await containedHostPath(
|
||||||
|
await realpath(workspaceRoot),
|
||||||
|
resolveProjectHostPath(workspaceRoot, storedPath),
|
||||||
|
);
|
||||||
const info = await stat(hostPath);
|
const info = await stat(hostPath);
|
||||||
if (options?.maxBytes !== undefined && info.size > options.maxBytes) {
|
if (options?.maxBytes !== undefined && info.size > options.maxBytes) {
|
||||||
throw new Error(`computer file exceeds ${options.maxBytes} bytes`);
|
throw new Error(`computer file exceeds ${options.maxBytes} bytes`);
|
||||||
}
|
}
|
||||||
return new Uint8Array(await readFile(hostPath));
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { PrismaClient } from "./client.js";
|
import type { PrismaClient } from "./client.js";
|
||||||
import {
|
import {
|
||||||
assertProjectWorkspaceDirectory,
|
|
||||||
InvalidProjectWorkspacePathError,
|
InvalidProjectWorkspacePathError,
|
||||||
normalizeProjectWorkspacePath,
|
normalizeProjectWorkspacePath,
|
||||||
|
resolveProjectWorkspaceDirectory,
|
||||||
wireProjectWorkspace,
|
wireProjectWorkspace,
|
||||||
} from "./project-workspace.js";
|
} from "./project-workspace.js";
|
||||||
|
|
||||||
@ -43,11 +43,21 @@ describe("project workspace wiring", () => {
|
|||||||
|
|
||||||
it("rejects missing folders", async () => {
|
it("rejects missing folders", async () => {
|
||||||
const missing = path.join(tmpdir(), `missing-${Date.now()}`);
|
const missing = path.join(tmpdir(), `missing-${Date.now()}`);
|
||||||
await expect(assertProjectWorkspaceDirectory(missing)).rejects.toBeInstanceOf(
|
await expect(resolveProjectWorkspaceDirectory(missing)).rejects.toBeInstanceOf(
|
||||||
InvalidProjectWorkspacePathError,
|
InvalidProjectWorkspacePathError,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves a symlinked folder to the real folder behind it", async () => {
|
||||||
|
const root = tempDir("project-workspace-symlink-");
|
||||||
|
const real = path.join(root, "real");
|
||||||
|
mkdirSync(real);
|
||||||
|
symlinkSync(real, path.join(root, "link"));
|
||||||
|
expect(await resolveProjectWorkspaceDirectory(path.join(root, "link"))).toBe(
|
||||||
|
realpathSync(real),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("persists workspace and git metadata on the space", async () => {
|
it("persists workspace and git metadata on the space", async () => {
|
||||||
const root = tempDir("project-workspace-wire-");
|
const root = tempDir("project-workspace-wire-");
|
||||||
mkdirSync(path.join(root, ".git"));
|
mkdirSync(path.join(root, ".git"));
|
||||||
@ -64,7 +74,7 @@ describe("project workspace wiring", () => {
|
|||||||
spaceId: "space-1",
|
spaceId: "space-1",
|
||||||
workspacePath: root,
|
workspacePath: root,
|
||||||
});
|
});
|
||||||
expect(wired.path).toBe(path.resolve(root));
|
expect(wired.path).toBe(realpathSync(root));
|
||||||
expect(wired.git).toEqual({
|
expect(wired.git).toEqual({
|
||||||
remoteUrl: "https://github.com/example/demo.git",
|
remoteUrl: "https://github.com/example/demo.git",
|
||||||
defaultBranch: "main",
|
defaultBranch: "main",
|
||||||
@ -73,7 +83,7 @@ describe("project workspace wiring", () => {
|
|||||||
dirtyPaths: [],
|
dirtyPaths: [],
|
||||||
});
|
});
|
||||||
expect(updates[0]).toMatchObject({
|
expect(updates[0]).toMatchObject({
|
||||||
workspacePath: path.resolve(root),
|
workspacePath: realpathSync(root),
|
||||||
gitRemoteUrl: "https://github.com/example/demo.git",
|
gitRemoteUrl: "https://github.com/example/demo.git",
|
||||||
gitDefaultBranch: "main",
|
gitDefaultBranch: "main",
|
||||||
gitHeadCommit: "abc123",
|
gitHeadCommit: "abc123",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { access, stat } from "node:fs/promises";
|
import { realpath, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readGitCheckout } from "@rakazo/core/git-checkout";
|
import { readGitCheckout } from "@rakazo/core/git-checkout";
|
||||||
import type { PrismaClient } from "./client.js";
|
import type { PrismaClient } from "./client.js";
|
||||||
@ -22,11 +22,13 @@ export function normalizeProjectWorkspacePath(input: string): string {
|
|||||||
return path.resolve(trimmed);
|
return path.resolve(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertProjectWorkspaceDirectory(workspacePath: string): Promise<void> {
|
/** The real folder behind a path, so a symlink cannot later point the link somewhere else. */
|
||||||
const resolved = normalizeProjectWorkspacePath(workspacePath);
|
export async function resolveProjectWorkspaceDirectory(workspacePath: string): Promise<string> {
|
||||||
|
const normalized = normalizeProjectWorkspacePath(workspacePath);
|
||||||
|
let resolved: string;
|
||||||
let info: Awaited<ReturnType<typeof stat>>;
|
let info: Awaited<ReturnType<typeof stat>>;
|
||||||
try {
|
try {
|
||||||
await access(resolved);
|
resolved = await realpath(normalized);
|
||||||
info = await stat(resolved);
|
info = await stat(resolved);
|
||||||
} catch {
|
} catch {
|
||||||
throw new InvalidProjectWorkspacePathError("That folder does not exist or is not readable.");
|
throw new InvalidProjectWorkspacePathError("That folder does not exist or is not readable.");
|
||||||
@ -34,6 +36,7 @@ export async function assertProjectWorkspaceDirectory(workspacePath: string): Pr
|
|||||||
if (!info.isDirectory()) {
|
if (!info.isDirectory()) {
|
||||||
throw new InvalidProjectWorkspacePathError("That path is not a folder.");
|
throw new InvalidProjectWorkspacePathError("That path is not a folder.");
|
||||||
}
|
}
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WiredProjectWorkspace {
|
export interface WiredProjectWorkspace {
|
||||||
@ -52,8 +55,7 @@ export async function wireProjectWorkspace(
|
|||||||
prisma: Pick<PrismaClient, "space">,
|
prisma: Pick<PrismaClient, "space">,
|
||||||
input: { spaceId: string; workspacePath: string },
|
input: { spaceId: string; workspacePath: string },
|
||||||
): Promise<WiredProjectWorkspace> {
|
): Promise<WiredProjectWorkspace> {
|
||||||
const resolved = normalizeProjectWorkspacePath(input.workspacePath);
|
const resolved = await resolveProjectWorkspaceDirectory(input.workspacePath);
|
||||||
await assertProjectWorkspaceDirectory(resolved);
|
|
||||||
const git = await readGitCheckout(resolved);
|
const git = await readGitCheckout(resolved);
|
||||||
|
|
||||||
await prisma.space.update({
|
await prisma.space.update({
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters";
|
import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters";
|
||||||
@ -2185,6 +2185,100 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets only the deployment owner link a host folder and read its files", async () => {
|
||||||
|
const owner = await signup(app, `folder-owner-${stamp}@rakazo.test`, "Folder Owner");
|
||||||
|
const member = await signup(app, `folder-member-${stamp}@rakazo.test`, "Folder Member");
|
||||||
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
||||||
|
const settings = await handles.prisma.deploymentSettings.findUniqueOrThrow({
|
||||||
|
where: { id: "default" },
|
||||||
|
select: { ownerUserId: true },
|
||||||
|
});
|
||||||
|
await handles.prisma.deploymentSettings.update({
|
||||||
|
where: { id: "default" },
|
||||||
|
data: { ownerUserId: ownerActor.userId },
|
||||||
|
});
|
||||||
|
// Stands in for any folder the API process can read, such as its own checkout.
|
||||||
|
const hostFolder = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-authz-host-")));
|
||||||
|
writeFileSync(path.join(hostFolder, ".env"), "HOST_SECRET=not-a-real-secret\n");
|
||||||
|
try {
|
||||||
|
await expectForbidden(app, member, "spaces/create", {
|
||||||
|
name: "Member folder",
|
||||||
|
workspacePath: hostFolder,
|
||||||
|
});
|
||||||
|
const memberNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list");
|
||||||
|
expect(memberNavigation.spaces.map((space) => space.name)).not.toContain("Member folder");
|
||||||
|
|
||||||
|
// A space linked before only the owner could link stays closed to the member.
|
||||||
|
const legacy = await rpc<Space>(app, member, "spaces/create", { name: "Legacy folder" });
|
||||||
|
await handles.prisma.space.update({
|
||||||
|
where: { id: legacy.id },
|
||||||
|
data: { workspacePath: hostFolder },
|
||||||
|
});
|
||||||
|
const memberBot = await rpc<Bot>(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"bots/create",
|
||||||
|
botInput("Member Folder Reader"),
|
||||||
|
legacy.id,
|
||||||
|
);
|
||||||
|
await expectForbidden(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"computer/files",
|
||||||
|
{ botId: memberBot.id, path: "project" },
|
||||||
|
legacy.id,
|
||||||
|
);
|
||||||
|
await expectForbidden(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"computer/readFile",
|
||||||
|
{ botId: memberBot.id, path: "project/.env" },
|
||||||
|
legacy.id,
|
||||||
|
);
|
||||||
|
const legacyNavigation = await rpc<SpaceNavigation>(app, member, "spaces/list");
|
||||||
|
expect(legacyNavigation.spaces.find((space) => space.id === legacy.id)?.workspace).toBeNull();
|
||||||
|
|
||||||
|
const linked = await rpc<Space>(app, owner, "spaces/create", {
|
||||||
|
name: "Owner folder",
|
||||||
|
workspacePath: hostFolder,
|
||||||
|
});
|
||||||
|
expect(linked.workspace?.path).toBe(hostFolder);
|
||||||
|
const ownerNavigation = await rpc<SpaceNavigation>(app, owner, "spaces/list");
|
||||||
|
expect(ownerNavigation.spaces.find((space) => space.id === linked.id)?.workspace?.path).toBe(
|
||||||
|
hostFolder,
|
||||||
|
);
|
||||||
|
const ownerBot = await rpc<Bot>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"bots/create",
|
||||||
|
botInput("Owner Folder Reader"),
|
||||||
|
linked.id,
|
||||||
|
);
|
||||||
|
const listing = await rpc<Array<{ path: string }>>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"computer/files",
|
||||||
|
{ botId: ownerBot.id, path: "project" },
|
||||||
|
linked.id,
|
||||||
|
);
|
||||||
|
expect(listing.map((entry) => entry.path)).toContain("project/.env");
|
||||||
|
const file = await rpc<{ content: string }>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"computer/readFile",
|
||||||
|
{ botId: ownerBot.id, path: "project/.env" },
|
||||||
|
linked.id,
|
||||||
|
);
|
||||||
|
expect(file.content).toContain("HOST_SECRET");
|
||||||
|
} finally {
|
||||||
|
await handles.prisma.deploymentSettings.update({
|
||||||
|
where: { id: "default" },
|
||||||
|
data: { ownerUserId: settings.ownerUserId },
|
||||||
|
});
|
||||||
|
rmSync(hostFolder, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function botInput(name: string) {
|
function botInput(name: string) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user