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,
|
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,
|
||||||
@ -118,6 +120,7 @@ import {
|
|||||||
IsolationError,
|
IsolationError,
|
||||||
isProjectGoalStatus,
|
isProjectGoalStatus,
|
||||||
issueMessagingLinkCode,
|
issueMessagingLinkCode,
|
||||||
|
listSpaceUserBotIds,
|
||||||
newestModelCredentialOrder,
|
newestModelCredentialOrder,
|
||||||
newestVoiceCredentialOrder,
|
newestVoiceCredentialOrder,
|
||||||
Prisma,
|
Prisma,
|
||||||
@ -2063,6 +2066,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({
|
||||||
@ -2074,7 +2091,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),
|
||||||
}));
|
}));
|
||||||
@ -2085,6 +2105,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({
|
||||||
|
|||||||
@ -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,
|
||||||
@ -1265,6 +1269,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);
|
||||||
@ -1281,6 +1294,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
context,
|
context,
|
||||||
computer,
|
computer,
|
||||||
computerMode,
|
computerMode,
|
||||||
|
teamBotIds: teamBotIds ?? undefined,
|
||||||
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -2091,12 +2105,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),
|
||||||
})),
|
})),
|
||||||
@ -2104,7 +2121,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, {
|
||||||
@ -2148,7 +2165,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,
|
||||||
@ -2173,7 +2190,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 },
|
||||||
);
|
);
|
||||||
@ -2195,7 +2212,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;
|
||||||
@ -2249,7 +2266,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, {
|
||||||
@ -2298,6 +2315,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,
|
||||||
@ -2335,7 +2356,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,
|
||||||
@ -3538,6 +3559,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
context,
|
context,
|
||||||
computer,
|
computer,
|
||||||
computerMode,
|
computerMode,
|
||||||
|
teamBotIds: teamBotIds ?? undefined,
|
||||||
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
markWorkspaceDirty: workspaceCheckpoint.markDirty,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -3906,7 +3928,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,
|
||||||
|
|||||||
@ -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, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters";
|
import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters";
|
||||||
@ -595,6 +595,105 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
).not.toBeNull();
|
).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 () => {
|
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 owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner");
|
||||||
const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member");
|
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);
|
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