fix(api): scope Team Computer read/list to member bot homes in shared Spaces
Team mode stores one disk per Space. computer/readFile and computer/files only verified the caller owns the entry bot; an explicit bots/<otherBotId>/… path let one member read another member's workspace. Wire existing path guards into the API router and executor file/shell tools, and add a shared-Space authorization regression test. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
015ea35259
commit
ff93b2c481
@ -32,6 +32,7 @@ import {
|
||||
acquireComputerExecutionLease,
|
||||
applyTeachingDesktopInput,
|
||||
archiveBot,
|
||||
assertTeamWorkspacePathAllowed,
|
||||
buildMcpCredentialBlob,
|
||||
buildModelConnectPlaintext,
|
||||
ComputerBusyError,
|
||||
@ -47,6 +48,7 @@ import {
|
||||
displayBotWorkspacePath,
|
||||
enqueueTakeoverContinuation,
|
||||
expireComputerControl,
|
||||
filterTeamWorkspaceEntries,
|
||||
hasActiveComputerControl,
|
||||
isAutoReviewCheckerConfigured,
|
||||
isComputerScreenUnavailable,
|
||||
@ -118,6 +120,7 @@ import {
|
||||
IsolationError,
|
||||
isProjectGoalStatus,
|
||||
issueMessagingLinkCode,
|
||||
listSpaceUserBotIds,
|
||||
newestModelCredentialOrder,
|
||||
newestVoiceCredentialOrder,
|
||||
Prisma,
|
||||
@ -2063,6 +2066,20 @@ export function createRouter(deps: RouterDeps) {
|
||||
const computerMode = parseComputerMode(computer.scope);
|
||||
const ctx = computerContext(context.actor, bot.id, "files");
|
||||
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"]>>;
|
||||
if (computer.state === "running" && computer.providerRef) {
|
||||
await deps.prisma.computer.updateMany({
|
||||
@ -2074,7 +2091,10 @@ export function createRouter(deps: RouterDeps) {
|
||||
} else {
|
||||
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,
|
||||
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path),
|
||||
}));
|
||||
@ -2085,6 +2105,21 @@ export function createRouter(deps: RouterDeps) {
|
||||
const computerMode = parseComputerMode(bot.computer.scope);
|
||||
const ctx = computerContext(context.actor, bot.id, "read");
|
||||
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;
|
||||
if (bot.computer.state === "running" && bot.computer.providerRef) {
|
||||
await deps.prisma.computer.updateMany({
|
||||
|
||||
@ -92,6 +92,7 @@ import {
|
||||
findModelCredential,
|
||||
InvalidSpaceNameError,
|
||||
isTooManyDatabaseConnections,
|
||||
listSpaceUserBotIds,
|
||||
loadRunHistoryMessages,
|
||||
type McpServer,
|
||||
type Prisma,
|
||||
@ -227,7 +228,10 @@ import {
|
||||
} from "./computer-lifecycle.js";
|
||||
import { withComputerScreenAvailability } from "./computer-screens.js";
|
||||
import {
|
||||
assertTeamShellCommandAllowed,
|
||||
assertTeamWorkspacePathAllowed,
|
||||
displayBotWorkspacePath,
|
||||
filterTeamWorkspaceEntries,
|
||||
resolveBotWorkspaceCwd,
|
||||
resolveBotWorkspacePath,
|
||||
teamBotWorkspaceDirectory,
|
||||
@ -1265,6 +1269,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
if (!bot.computer) throw new Error("Bot has no computer");
|
||||
const storedComputer = bot.computer;
|
||||
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");
|
||||
screenRelease = { computer, context };
|
||||
scheduleComputerSleep(deps.jobs, storedComputer.id);
|
||||
@ -1281,6 +1294,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
context,
|
||||
computer,
|
||||
computerMode,
|
||||
teamBotIds: teamBotIds ?? undefined,
|
||||
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
||||
},
|
||||
)
|
||||
@ -2091,12 +2105,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
const requestedPath = String(args.path ?? "");
|
||||
const entries = await deps.sandbox.listFiles(
|
||||
computer,
|
||||
resolveBotWorkspacePath(computerMode, bot.id, requestedPath),
|
||||
resolveStoredWorkspacePath(requestedPath),
|
||||
context,
|
||||
);
|
||||
const visibleEntries = teamBotIds
|
||||
? filterTeamWorkspaceEntries(entries, teamBotIds)
|
||||
: entries;
|
||||
return {
|
||||
path: requestedPath,
|
||||
entries: entries.map((entry) => ({
|
||||
entries: visibleEntries.map((entry) => ({
|
||||
...entry,
|
||||
path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path),
|
||||
})),
|
||||
@ -2104,7 +2121,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
}
|
||||
if (name === "read_file") {
|
||||
const filePath = String(args.path ?? "");
|
||||
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath);
|
||||
const storedPath = resolveStoredWorkspacePath(filePath);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
||||
@ -2148,7 +2165,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
await deps.sandbox.writeFile(
|
||||
computer,
|
||||
{
|
||||
path: resolveBotWorkspacePath(computerMode, bot.id, filePath),
|
||||
path: resolveStoredWorkspacePath(filePath),
|
||||
content: new TextEncoder().encode(content),
|
||||
},
|
||||
context,
|
||||
@ -2173,7 +2190,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
if (!rows && dataPath) {
|
||||
const bytes = await deps.sandbox.readFile(
|
||||
computer,
|
||||
resolveBotWorkspacePath(computerMode, bot.id, dataPath),
|
||||
resolveStoredWorkspacePath(dataPath),
|
||||
context,
|
||||
{ maxBytes: ATTACHMENT_MAX_BYTES },
|
||||
);
|
||||
@ -2195,7 +2212,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
workspaceCheckpoint.markDirty();
|
||||
await deps.sandbox.writeFile(
|
||||
computer,
|
||||
{ path: resolveBotWorkspacePath(computerMode, bot.id, outPath), content: png },
|
||||
{ path: resolveStoredWorkspacePath(outPath), content: png },
|
||||
context,
|
||||
);
|
||||
let attached = false;
|
||||
@ -2249,7 +2266,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
if (!deps.artifacts) {
|
||||
return finish({ error: "artifact storage unavailable", path: filePath });
|
||||
}
|
||||
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath);
|
||||
const storedPath = resolveStoredWorkspacePath(filePath);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
|
||||
@ -2298,6 +2315,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
bot.id,
|
||||
args.cwd ? String(args.cwd) : undefined,
|
||||
);
|
||||
if (teamBotIds) {
|
||||
if (cwd) assertTeamWorkspacePathAllowed(cwd, teamBotIds);
|
||||
assertTeamShellCommandAllowed(command, teamBotIds);
|
||||
}
|
||||
workspaceCheckpoint.markDirty();
|
||||
const result = await runSandboxCommand(
|
||||
deps.sandbox,
|
||||
@ -2335,7 +2356,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
kind: "open",
|
||||
path: /^https?:\/\//i.test(requestedPath)
|
||||
? requestedPath
|
||||
: resolveBotWorkspacePath(computerMode, bot.id, requestedPath),
|
||||
: resolveStoredWorkspacePath(requestedPath),
|
||||
},
|
||||
],
|
||||
observe: true,
|
||||
@ -3538,6 +3559,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
context,
|
||||
computer,
|
||||
computerMode,
|
||||
teamBotIds: teamBotIds ?? undefined,
|
||||
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
||||
},
|
||||
)
|
||||
@ -3906,7 +3928,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
await deps.sandbox.writeFile(
|
||||
computer,
|
||||
{
|
||||
path: resolveBotWorkspacePath(computerMode, bot.id, file.path),
|
||||
path: resolveStoredWorkspacePath(file.path),
|
||||
content: new TextEncoder().encode(file.content),
|
||||
},
|
||||
context,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters";
|
||||
@ -595,6 +595,105 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
||||
).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("scopes Team Computer export to the exporting member's bot workspace in a shared Space", async () => {
|
||||
const owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner");
|
||||
const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member");
|
||||
@ -1872,8 +1971,14 @@ async function expectDenied(
|
||||
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
|
||||
}
|
||||
|
||||
async function expectForbidden(app: App, cookie: string, procedure: string, body: unknown) {
|
||||
const response = await raw(app, cookie, procedure, body);
|
||||
async function expectForbidden(
|
||||
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(await response.text(), procedure).toMatch(/forbidden/i);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user