fix(api): scope Team Computer bot homes per member in shared Spaces
Team mode shares one disk per Space. RPC and executor paths under bots/<otherBotId>/ were reachable with any member-owned entry bot, leaking another member's workspace files while same-user cross-bot access stays intentional. Add path guards, filter bot listings, regression tests. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
4defccae95
commit
8ae36763a7
@ -32,6 +32,7 @@ import {
|
|||||||
acquireComputerExecutionLease,
|
acquireComputerExecutionLease,
|
||||||
applyTeachingDesktopInput,
|
applyTeachingDesktopInput,
|
||||||
archiveBot,
|
archiveBot,
|
||||||
|
assertTeamWorkspacePathAllowed,
|
||||||
buildMcpCredentialBlob,
|
buildMcpCredentialBlob,
|
||||||
buildModelConnectPlaintext,
|
buildModelConnectPlaintext,
|
||||||
ComputerBusyError,
|
ComputerBusyError,
|
||||||
@ -47,6 +48,7 @@ import {
|
|||||||
displayBotWorkspacePath,
|
displayBotWorkspacePath,
|
||||||
enqueueTakeoverContinuation,
|
enqueueTakeoverContinuation,
|
||||||
expireComputerControl,
|
expireComputerControl,
|
||||||
|
filterTeamWorkspaceEntries,
|
||||||
hasActiveComputerControl,
|
hasActiveComputerControl,
|
||||||
isAutoReviewCheckerConfigured,
|
isAutoReviewCheckerConfigured,
|
||||||
isComputerScreenUnavailable,
|
isComputerScreenUnavailable,
|
||||||
@ -121,6 +123,7 @@ import {
|
|||||||
IsolationError,
|
IsolationError,
|
||||||
isProjectGoalStatus,
|
isProjectGoalStatus,
|
||||||
issueMessagingLinkCode,
|
issueMessagingLinkCode,
|
||||||
|
listSpaceUserBotIds,
|
||||||
lockOwnedGroup,
|
lockOwnedGroup,
|
||||||
newestModelCredentialOrder,
|
newestModelCredentialOrder,
|
||||||
newestVoiceCredentialOrder,
|
newestVoiceCredentialOrder,
|
||||||
@ -2140,6 +2143,20 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
const computerMode = parseComputerMode(computer.scope);
|
const computerMode = parseComputerMode(computer.scope);
|
||||||
const ctx = computerContext(context.actor, bot.id, "files");
|
const ctx = computerContext(context.actor, bot.id, "files");
|
||||||
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
|
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
|
||||||
|
const teamBotIds =
|
||||||
|
computerMode === "team"
|
||||||
|
? await listSpaceUserBotIds(deps.prisma, context.actor.spaceId, context.actor.userId)
|
||||||
|
: null;
|
||||||
|
if (teamBotIds) {
|
||||||
|
try {
|
||||||
|
assertTeamWorkspacePathAllowed(storedPath, teamBotIds);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("escapes")) {
|
||||||
|
throw new ORPCError("FORBIDDEN", { message: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
let entries: Awaited<ReturnType<SandboxProvider["listFiles"]>>;
|
let entries: Awaited<ReturnType<SandboxProvider["listFiles"]>>;
|
||||||
if (computer.state === "running" && computer.providerRef) {
|
if (computer.state === "running" && computer.providerRef) {
|
||||||
await deps.prisma.computer.updateMany({
|
await deps.prisma.computer.updateMany({
|
||||||
@ -2151,7 +2168,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
} else {
|
} else {
|
||||||
entries = await deps.home.list(computer.homeKey, storedPath, ctx);
|
entries = await deps.home.list(computer.homeKey, storedPath, ctx);
|
||||||
}
|
}
|
||||||
return entries.map((entry) => ({
|
const visibleEntries = teamBotIds
|
||||||
|
? filterTeamWorkspaceEntries(entries, teamBotIds)
|
||||||
|
: entries;
|
||||||
|
return visibleEntries.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path),
|
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path),
|
||||||
}));
|
}));
|
||||||
@ -2162,6 +2182,21 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
const computerMode = parseComputerMode(bot.computer.scope);
|
const computerMode = parseComputerMode(bot.computer.scope);
|
||||||
const ctx = computerContext(context.actor, bot.id, "read");
|
const ctx = computerContext(context.actor, bot.id, "read");
|
||||||
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
|
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
|
||||||
|
if (computerMode === "team") {
|
||||||
|
const teamBotIds = await listSpaceUserBotIds(
|
||||||
|
deps.prisma,
|
||||||
|
context.actor.spaceId,
|
||||||
|
context.actor.userId,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
assertTeamWorkspacePathAllowed(storedPath, teamBotIds);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("escapes")) {
|
||||||
|
throw new ORPCError("FORBIDDEN", { message: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
let content: string;
|
let content: string;
|
||||||
if (bot.computer.state === "running" && bot.computer.providerRef) {
|
if (bot.computer.state === "running" && bot.computer.providerRef) {
|
||||||
await deps.prisma.computer.updateMany({
|
await deps.prisma.computer.updateMany({
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
assertTeamShellCommandAllowed,
|
||||||
|
assertTeamWorkspacePathAllowed,
|
||||||
displayBotWorkspacePath,
|
displayBotWorkspacePath,
|
||||||
|
filterTeamWorkspaceEntries,
|
||||||
resolveBotWorkspaceCwd,
|
resolveBotWorkspaceCwd,
|
||||||
resolveBotWorkspacePath,
|
resolveBotWorkspacePath,
|
||||||
teamBotWorkspaceDirectory,
|
teamBotWorkspaceDirectory,
|
||||||
|
teamWorkspaceTargetBotId,
|
||||||
} from "./computer-support.js";
|
} from "./computer-support.js";
|
||||||
|
|
||||||
describe("Team Computer bot folders", () => {
|
describe("Team Computer bot folders", () => {
|
||||||
@ -43,6 +47,34 @@ describe("Team Computer bot folders", () => {
|
|||||||
).toBe("bots/bot-1/notes/result.txt");
|
).toBe("bots/bot-1/notes/result.txt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scopes Team bot homes to the caller's bots in shared Spaces", () => {
|
||||||
|
const allowed = new Set(["bot-1", "bot-2"]);
|
||||||
|
expect(teamWorkspaceTargetBotId("bots/bot-2/notes/result.txt")).toBe("bot-2");
|
||||||
|
expect(teamWorkspaceTargetBotId("shared/brief.md")).toBeNull();
|
||||||
|
expect(() =>
|
||||||
|
assertTeamWorkspacePathAllowed("bots/bot-2/notes/result.txt", allowed),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() => assertTeamWorkspacePathAllowed("bots/bot-3/secret.txt", allowed)).toThrow(
|
||||||
|
/escapes the bot computer workspace/,
|
||||||
|
);
|
||||||
|
expect(() => assertTeamShellCommandAllowed("cat bots/bot-3/secret.txt", allowed)).toThrow(
|
||||||
|
/escapes the bot computer workspace/,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
filterTeamWorkspaceEntries(
|
||||||
|
[
|
||||||
|
{ path: "bots/bot-1", name: "bot-1" },
|
||||||
|
{ path: "bots/bot-3", name: "bot-3" },
|
||||||
|
{ path: "shared", name: "shared" },
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ path: "bots/bot-1", name: "bot-1" },
|
||||||
|
{ path: "shared", name: "shared" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves Private Computer paths unchanged", () => {
|
it("leaves Private Computer paths unchanged", () => {
|
||||||
expect(resolveBotWorkspacePath("dedicated", "bot-1", "notes/result.txt")).toBe(
|
expect(resolveBotWorkspacePath("dedicated", "bot-1", "notes/result.txt")).toBe(
|
||||||
"notes/result.txt",
|
"notes/result.txt",
|
||||||
|
|||||||
@ -80,6 +80,49 @@ export function teamBotWorkspaceDirectory(botId: string): string {
|
|||||||
return path.posix.join("bots", normalizeWorkspacePath(botId));
|
return path.posix.join("bots", normalizeWorkspacePath(botId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEAM_WORKSPACE_ESCAPE = "Path escapes the bot computer workspace";
|
||||||
|
|
||||||
|
/** Bot home segment under `bots/<botId>/…`, or null for shared/root paths. */
|
||||||
|
export function teamWorkspaceTargetBotId(storedPath: string): string | null {
|
||||||
|
const normalized = normalizeWorkspacePath(storedPath);
|
||||||
|
if (normalized === "bots" || !normalized.startsWith("bots/")) return null;
|
||||||
|
const remainder = normalized.slice("bots/".length);
|
||||||
|
if (!remainder) return null;
|
||||||
|
const [botId] = remainder.split("/");
|
||||||
|
return botId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTeamWorkspacePathAllowed(
|
||||||
|
storedPath: string,
|
||||||
|
allowedBotIds: ReadonlySet<string>,
|
||||||
|
): void {
|
||||||
|
const targetBotId = teamWorkspaceTargetBotId(storedPath);
|
||||||
|
if (!targetBotId || allowedBotIds.has(targetBotId)) return;
|
||||||
|
throw new Error(TEAM_WORKSPACE_ESCAPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertTeamShellCommandAllowed(
|
||||||
|
command: string,
|
||||||
|
allowedBotIds: ReadonlySet<string>,
|
||||||
|
): void {
|
||||||
|
for (const match of command.matchAll(/\bbots\/([A-Za-z0-9_-]+)/g)) {
|
||||||
|
const botId = match[1];
|
||||||
|
if (botId && !allowedBotIds.has(botId)) {
|
||||||
|
throw new Error(TEAM_WORKSPACE_ESCAPE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterTeamWorkspaceEntries<T extends { path: string }>(
|
||||||
|
entries: readonly T[],
|
||||||
|
allowedBotIds: ReadonlySet<string>,
|
||||||
|
): T[] {
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
const targetBotId = teamWorkspaceTargetBotId(entry.path);
|
||||||
|
return !targetBotId || allowedBotIds.has(targetBotId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveBotWorkspacePath(
|
export function resolveBotWorkspacePath(
|
||||||
scope: ComputerMode,
|
scope: ComputerMode,
|
||||||
botId: string,
|
botId: string,
|
||||||
|
|||||||
@ -92,6 +92,7 @@ import {
|
|||||||
findModelCredential,
|
findModelCredential,
|
||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
isTooManyDatabaseConnections,
|
isTooManyDatabaseConnections,
|
||||||
|
listSpaceUserBotIds,
|
||||||
loadRunHistoryMessages,
|
loadRunHistoryMessages,
|
||||||
type McpServer,
|
type McpServer,
|
||||||
type Prisma,
|
type Prisma,
|
||||||
@ -227,7 +228,10 @@ import {
|
|||||||
} from "./computer-lifecycle.js";
|
} from "./computer-lifecycle.js";
|
||||||
import { withComputerScreenAvailability } from "./computer-screens.js";
|
import { withComputerScreenAvailability } from "./computer-screens.js";
|
||||||
import {
|
import {
|
||||||
|
assertTeamShellCommandAllowed,
|
||||||
|
assertTeamWorkspacePathAllowed,
|
||||||
displayBotWorkspacePath,
|
displayBotWorkspacePath,
|
||||||
|
filterTeamWorkspaceEntries,
|
||||||
resolveBotWorkspaceCwd,
|
resolveBotWorkspaceCwd,
|
||||||
resolveBotWorkspacePath,
|
resolveBotWorkspacePath,
|
||||||
teamBotWorkspaceDirectory,
|
teamBotWorkspaceDirectory,
|
||||||
@ -1268,6 +1272,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
if (!bot.computer) throw new Error("Bot has no computer");
|
if (!bot.computer) throw new Error("Bot has no computer");
|
||||||
const storedComputer = bot.computer;
|
const storedComputer = bot.computer;
|
||||||
const computerMode = parseComputerMode(storedComputer.scope);
|
const computerMode = parseComputerMode(storedComputer.scope);
|
||||||
|
const teamBotIds =
|
||||||
|
computerMode === "team"
|
||||||
|
? await listSpaceUserBotIds(deps.prisma, run.spaceId, run.userId)
|
||||||
|
: null;
|
||||||
|
const resolveStoredWorkspacePath = (requestedPath: string) => {
|
||||||
|
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, requestedPath);
|
||||||
|
if (teamBotIds) assertTeamWorkspacePathAllowed(storedPath, teamBotIds);
|
||||||
|
return storedPath;
|
||||||
|
};
|
||||||
const computer = await provisionComputer(deps, storedComputer.id, context, "bot");
|
const computer = await provisionComputer(deps, storedComputer.id, context, "bot");
|
||||||
screenRelease = { computer, context };
|
screenRelease = { computer, context };
|
||||||
scheduleComputerSleep(deps.jobs, storedComputer.id);
|
scheduleComputerSleep(deps.jobs, storedComputer.id);
|
||||||
@ -1284,6 +1297,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
context,
|
context,
|
||||||
computer,
|
computer,
|
||||||
computerMode,
|
computerMode,
|
||||||
|
teamBotIds: teamBotIds ?? undefined,
|
||||||
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -2094,12 +2108,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
const requestedPath = String(args.path ?? "");
|
const requestedPath = String(args.path ?? "");
|
||||||
const entries = await deps.sandbox.listFiles(
|
const entries = await deps.sandbox.listFiles(
|
||||||
computer,
|
computer,
|
||||||
resolveBotWorkspacePath(computerMode, bot.id, requestedPath),
|
resolveStoredWorkspacePath(requestedPath),
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
|
const visibleEntries = teamBotIds
|
||||||
|
? filterTeamWorkspaceEntries(entries, teamBotIds)
|
||||||
|
: entries;
|
||||||
return {
|
return {
|
||||||
path: requestedPath,
|
path: requestedPath,
|
||||||
entries: entries.map((entry) => ({
|
entries: visibleEntries.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path),
|
path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path),
|
||||||
})),
|
})),
|
||||||
@ -2107,7 +2124,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
}
|
}
|
||||||
if (name === "read_file") {
|
if (name === "read_file") {
|
||||||
const filePath = String(args.path ?? "");
|
const filePath = String(args.path ?? "");
|
||||||
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath);
|
const storedPath = resolveStoredWorkspacePath(filePath);
|
||||||
let bytes: Uint8Array;
|
let bytes: Uint8Array;
|
||||||
try {
|
try {
|
||||||
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
||||||
@ -2151,7 +2168,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
await deps.sandbox.writeFile(
|
await deps.sandbox.writeFile(
|
||||||
computer,
|
computer,
|
||||||
{
|
{
|
||||||
path: resolveBotWorkspacePath(computerMode, bot.id, filePath),
|
path: resolveStoredWorkspacePath(filePath),
|
||||||
content: new TextEncoder().encode(content),
|
content: new TextEncoder().encode(content),
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
@ -2176,7 +2193,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
if (!rows && dataPath) {
|
if (!rows && dataPath) {
|
||||||
const bytes = await deps.sandbox.readFile(
|
const bytes = await deps.sandbox.readFile(
|
||||||
computer,
|
computer,
|
||||||
resolveBotWorkspacePath(computerMode, bot.id, dataPath),
|
resolveStoredWorkspacePath(dataPath),
|
||||||
context,
|
context,
|
||||||
{ maxBytes: ATTACHMENT_MAX_BYTES },
|
{ maxBytes: ATTACHMENT_MAX_BYTES },
|
||||||
);
|
);
|
||||||
@ -2198,7 +2215,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
workspaceCheckpoint.markDirty();
|
workspaceCheckpoint.markDirty();
|
||||||
await deps.sandbox.writeFile(
|
await deps.sandbox.writeFile(
|
||||||
computer,
|
computer,
|
||||||
{ path: resolveBotWorkspacePath(computerMode, bot.id, outPath), content: png },
|
{ path: resolveStoredWorkspacePath(outPath), content: png },
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
let attached = false;
|
let attached = false;
|
||||||
@ -2252,7 +2269,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
if (!deps.artifacts) {
|
if (!deps.artifacts) {
|
||||||
return finish({ error: "artifact storage unavailable", path: filePath });
|
return finish({ error: "artifact storage unavailable", path: filePath });
|
||||||
}
|
}
|
||||||
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath);
|
const storedPath = resolveStoredWorkspacePath(filePath);
|
||||||
let bytes: Uint8Array;
|
let bytes: Uint8Array;
|
||||||
try {
|
try {
|
||||||
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
||||||
@ -2301,6 +2318,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
bot.id,
|
bot.id,
|
||||||
args.cwd ? String(args.cwd) : undefined,
|
args.cwd ? String(args.cwd) : undefined,
|
||||||
);
|
);
|
||||||
|
if (teamBotIds) {
|
||||||
|
if (cwd) assertTeamWorkspacePathAllowed(cwd, teamBotIds);
|
||||||
|
assertTeamShellCommandAllowed(command, teamBotIds);
|
||||||
|
}
|
||||||
workspaceCheckpoint.markDirty();
|
workspaceCheckpoint.markDirty();
|
||||||
const result = await runSandboxCommand(
|
const result = await runSandboxCommand(
|
||||||
deps.sandbox,
|
deps.sandbox,
|
||||||
@ -2338,7 +2359,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
kind: "open",
|
kind: "open",
|
||||||
path: /^https?:\/\//i.test(requestedPath)
|
path: /^https?:\/\//i.test(requestedPath)
|
||||||
? requestedPath
|
? requestedPath
|
||||||
: resolveBotWorkspacePath(computerMode, bot.id, requestedPath),
|
: resolveStoredWorkspacePath(requestedPath),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
observe: true,
|
observe: true,
|
||||||
@ -3541,6 +3562,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
context,
|
context,
|
||||||
computer,
|
computer,
|
||||||
computerMode,
|
computerMode,
|
||||||
|
teamBotIds: teamBotIds ?? undefined,
|
||||||
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -3909,7 +3931,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
await deps.sandbox.writeFile(
|
await deps.sandbox.writeFile(
|
||||||
computer,
|
computer,
|
||||||
{
|
{
|
||||||
path: resolveBotWorkspacePath(computerMode, bot.id, file.path),
|
path: resolveStoredWorkspacePath(file.path),
|
||||||
content: new TextEncoder().encode(file.content),
|
content: new TextEncoder().encode(file.content),
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
validateAttachmentMimeType,
|
validateAttachmentMimeType,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import type { PrismaClient } from "@rakazo/db";
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
import { resolveBotWorkspacePath } from "./computer-support.js";
|
import { assertTeamWorkspacePathAllowed, resolveBotWorkspacePath } from "./computer-support.js";
|
||||||
|
|
||||||
export type MaterializedThreadFile = {
|
export type MaterializedThreadFile = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -101,6 +101,7 @@ export async function materializeCurrentTurnFiles(
|
|||||||
context: AdapterContext & { botId: string };
|
context: AdapterContext & { botId: string };
|
||||||
computer: ComputerRef;
|
computer: ComputerRef;
|
||||||
computerMode: ComputerMode;
|
computerMode: ComputerMode;
|
||||||
|
teamBotIds?: ReadonlySet<string>;
|
||||||
markWorkspaceDirty?: () => void;
|
markWorkspaceDirty?: () => void;
|
||||||
},
|
},
|
||||||
): Promise<MaterializedThreadFile[]> {
|
): Promise<MaterializedThreadFile[]> {
|
||||||
@ -127,11 +128,17 @@ export async function materializeCurrentTurnFiles(
|
|||||||
throw new Error(`Attached file exceeds the 10 MiB limit: ${row.name}`);
|
throw new Error(`Attached file exceeds the 10 MiB limit: ${row.name}`);
|
||||||
}
|
}
|
||||||
const relativePath = `attachments/${row.id}${attachmentExtensionForMimeType(row.mimeType)}`;
|
const relativePath = `attachments/${row.id}${attachmentExtensionForMimeType(row.mimeType)}`;
|
||||||
|
const storedPath = resolveBotWorkspacePath(
|
||||||
|
input.computerMode,
|
||||||
|
input.context.botId,
|
||||||
|
relativePath,
|
||||||
|
);
|
||||||
|
if (input.teamBotIds) assertTeamWorkspacePathAllowed(storedPath, input.teamBotIds);
|
||||||
input.markWorkspaceDirty?.();
|
input.markWorkspaceDirty?.();
|
||||||
await deps.sandbox.writeFile(
|
await deps.sandbox.writeFile(
|
||||||
input.computer,
|
input.computer,
|
||||||
{
|
{
|
||||||
path: resolveBotWorkspacePath(input.computerMode, input.context.botId, relativePath),
|
path: storedPath,
|
||||||
content: bytes,
|
content: bytes,
|
||||||
},
|
},
|
||||||
input.context,
|
input.context,
|
||||||
|
|||||||
@ -21,8 +21,21 @@ export function computerHomeKey(mode: ComputerMode, spaceId: string, botId?: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComputerDb = Pick<PrismaClient, "computer">;
|
type ComputerDb = Pick<PrismaClient, "computer">;
|
||||||
|
type BotDb = Pick<PrismaClient, "bot">;
|
||||||
type ExecutionLeaseDb = Pick<PrismaClient, "computerExecutionLease">;
|
type ExecutionLeaseDb = Pick<PrismaClient, "computerExecutionLease">;
|
||||||
|
|
||||||
|
export async function listSpaceUserBotIds(
|
||||||
|
prisma: BotDb,
|
||||||
|
spaceId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const rows = await prisma.bot.findMany({
|
||||||
|
where: { spaceId, userId, archivedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return new Set(rows.map((row) => row.id));
|
||||||
|
}
|
||||||
|
|
||||||
/** Expire leases as fencing tombstones so the next acquire increments fence. */
|
/** Expire leases as fencing tombstones so the next acquire increments fence. */
|
||||||
export async function expireComputerExecutionLeases(
|
export async function expireComputerExecutionLeases(
|
||||||
prisma: ExecutionLeaseDb,
|
prisma: ExecutionLeaseDb,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { mkdtempSync, rmSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, 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 } from "@rakazo/adapters";
|
import { ComposioEmulator } from "@rakazo/adapters";
|
||||||
@ -468,6 +468,105 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps Team Computer bot homes private to each member in a shared Space", async () => {
|
||||||
|
const owner = await signup(app, `team-home-owner-${stamp}@rakazo.test`, "Team Home Owner");
|
||||||
|
const member = await signup(app, `team-home-member-${stamp}@rakazo.test`, "Team Home Member");
|
||||||
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared Team Computer" });
|
||||||
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
||||||
|
where: { id: shared.id },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
const memberActor = await rpc<Actor>(app, member, "me");
|
||||||
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
||||||
|
await handles.prisma.member.create({
|
||||||
|
data: {
|
||||||
|
id: `team-home-org-member-${stamp}`,
|
||||||
|
organizationId: sharedRow.organizationId,
|
||||||
|
userId: memberActor.userId,
|
||||||
|
role: "member",
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await handles.prisma.spaceMember.create({
|
||||||
|
data: {
|
||||||
|
id: `team-home-space-member-${stamp}`,
|
||||||
|
spaceId: shared.id,
|
||||||
|
organizationId: sharedRow.organizationId,
|
||||||
|
userId: memberActor.userId,
|
||||||
|
role: "member",
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerBot = await rpc<Bot>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"bots/create",
|
||||||
|
botInput("Owner Team Bot"),
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
const ownerBotTwo = await rpc<Bot>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"bots/create",
|
||||||
|
botInput("Owner Team Bot Two"),
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
const memberBot = await rpc<Bot>(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"bots/create",
|
||||||
|
botInput("Member Team Bot"),
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
const secret = "owner-only-workspace-secret";
|
||||||
|
const ownerNotesDir = path.join(
|
||||||
|
dataDir,
|
||||||
|
"homes",
|
||||||
|
`team-${shared.id}`,
|
||||||
|
"bots",
|
||||||
|
ownerBot.id,
|
||||||
|
"notes",
|
||||||
|
);
|
||||||
|
mkdirSync(ownerNotesDir, { recursive: true });
|
||||||
|
writeFileSync(path.join(ownerNotesDir, "secret.txt"), secret);
|
||||||
|
|
||||||
|
await expectForbidden(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"computer/readFile",
|
||||||
|
{ botId: memberBot.id, path: `bots/${ownerBot.id}/notes/secret.txt` },
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
const memberNotesDir = path.join(
|
||||||
|
dataDir,
|
||||||
|
"homes",
|
||||||
|
`team-${shared.id}`,
|
||||||
|
"bots",
|
||||||
|
memberBot.id,
|
||||||
|
"notes",
|
||||||
|
);
|
||||||
|
mkdirSync(memberNotesDir, { recursive: true });
|
||||||
|
writeFileSync(path.join(memberNotesDir, "mine.txt"), "member-only");
|
||||||
|
const botListing = await rpc<Array<{ path: string }>>(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"computer/files",
|
||||||
|
{ botId: memberBot.id, path: "bots" },
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
expect(botListing.map((entry) => entry.path)).toEqual([`bots/${memberBot.id}`]);
|
||||||
|
|
||||||
|
const ownerCrossBot = await rpc<{ content: string }>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"computer/readFile",
|
||||||
|
{ botId: ownerBotTwo.id, path: `bots/${ownerBot.id}/notes/secret.txt` },
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
expect(ownerCrossBot.content).toBe(secret);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps approval rules private to each user in a shared Space", async () => {
|
it("keeps approval rules private to each user in a shared Space", async () => {
|
||||||
const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
|
const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
|
||||||
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
||||||
@ -1516,8 +1615,14 @@ async function expectDenied(
|
|||||||
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
|
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectForbidden(app: App, cookie: string, procedure: string, body: unknown) {
|
async function expectForbidden(
|
||||||
const response = await raw(app, cookie, procedure, body);
|
app: App,
|
||||||
|
cookie: string,
|
||||||
|
procedure: string,
|
||||||
|
body: unknown,
|
||||||
|
spaceId?: string,
|
||||||
|
) {
|
||||||
|
const response = await raw(app, cookie, procedure, body, spaceId);
|
||||||
expect(response.status, procedure).toBe(403);
|
expect(response.status, procedure).toBe(403);
|
||||||
expect(await response.text(), procedure).toMatch(/forbidden/i);
|
expect(await response.text(), procedure).toMatch(/forbidden/i);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user