When a user records a taught skill on one Team Computer bot, manual takeover on another bot in the same space cleared the teaching lease without stopping the session. Teaching input then failed silently and skills/stop could not release the replacement takeover lease. Reject computer/takeover while another bot on the same computer is recording, matching the existing Stop teaching first messaging guard. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2048 lines
75 KiB
TypeScript
2048 lines
75 KiB
TypeScript
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";
|
|
import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@rakazo/contracts";
|
|
import {
|
|
claimEmptySpaceDeletionForMember,
|
|
computerHomeKey,
|
|
deleteEmptySpaceForMember,
|
|
releaseSpaceDeletionClaim,
|
|
renewSpaceDeletionClaim,
|
|
} from "@rakazo/db";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import type { createApp } from "../../../apps/api/src/app.ts";
|
|
import { sessionCookieHeader } from "./index.js";
|
|
|
|
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
|
|
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
|
type RpcPath<T, Prefix extends string = ""> = T extends { "~orpc": unknown }
|
|
? Prefix
|
|
: T extends object
|
|
? {
|
|
[Key in keyof T & string]: RpcPath<T[Key], Prefix extends "" ? Key : `${Prefix}/${Key}`>;
|
|
}[keyof T & string]
|
|
: never;
|
|
type ProtectedRpcPath = Exclude<RpcPath<typeof appContract>, "health">;
|
|
|
|
process.env.WAKEUP_DRIVER = "memory";
|
|
process.env.SANDBOX_PROVIDER = "fake";
|
|
process.env.AGENT_RUNTIME = "scripted";
|
|
|
|
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
|
|
const describeWithDatabase = hasDb ? describe : describe.skip;
|
|
|
|
describeWithDatabase("API authorization and resource isolation", () => {
|
|
let handles: AppHandles;
|
|
let app: App;
|
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-authz-"));
|
|
|
|
beforeAll(async () => {
|
|
const { createApp } = await import("../../../apps/api/src/app.ts");
|
|
handles = await createApp({
|
|
databaseUrl: process.env.DATABASE_URL!,
|
|
dataDir,
|
|
sandboxProvider: "fake",
|
|
agentRuntime: "scripted",
|
|
wakeupDriver: "memory",
|
|
signupsEnabled: "true",
|
|
composio: new ComposioEmulator(),
|
|
});
|
|
app = handles.app;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await handles?.stop();
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("rejects unauthenticated calls to every protected RPC family", async () => {
|
|
const calls = exhaustiveProtectedCalls([
|
|
["me"],
|
|
["deployment/get"],
|
|
["deployment/update", { signupsEnabled: true }],
|
|
["updater/status"],
|
|
["updater/check", {}],
|
|
["updater/apply", {}],
|
|
["models/list"],
|
|
["models/credentials"],
|
|
["models/connect", { provider: "test", apiKey: "not-a-real-key" }],
|
|
["models/beginOAuth", { provider: "openai-codex" }],
|
|
["models/submitOAuthCode", { loginId: "missing-login", code: "fake-code" }],
|
|
["models/completeOAuth", { loginId: "missing-login" }],
|
|
["models/finishOAuth", { loginId: "missing-login" }],
|
|
["models/cancelOAuth", { loginId: "missing-login" }],
|
|
["models/setDefault", { provider: "test", modelId: "test/model" }],
|
|
["spaces/list"],
|
|
["spaces/create", { name: "Nope" }],
|
|
["spaces/remove", { spaceId: "missing-space" }],
|
|
["bots/list"],
|
|
["bots/listArchived"],
|
|
["bots/get", { botId: "missing-bot" }],
|
|
["bots/create", botInput("Unauthenticated")],
|
|
["bots/duplicate", { botId: "missing-bot" }],
|
|
["bots/update", { botId: "missing-bot", name: "Nope" }],
|
|
["bots/archive", { botId: "missing-bot" }],
|
|
["bots/restore", { botId: "missing-bot" }],
|
|
["bots/remove", { botId: "missing-bot" }],
|
|
["groups/create", { name: "Nope", botIds: ["missing-a", "missing-b"] }],
|
|
["groups/list"],
|
|
["groups/get", { groupId: "missing-group" }],
|
|
["groups/update", { groupId: "missing-group", name: "Nope" }],
|
|
["groups/remove", { groupId: "missing-group" }],
|
|
["botSections/list"],
|
|
["botSections/create", { botId: "missing-bot", name: "Planning" }],
|
|
["botSections/update", { sectionId: "missing-section", name: "Planning" }],
|
|
["threads/get", { botId: "missing-bot" }],
|
|
["threads/get", { groupId: "missing-group" }],
|
|
["threads/messages", { botId: "missing-bot", before: 1 }],
|
|
["threads/subscribe", { botId: "missing-bot", cursor: -1 }],
|
|
["threads/send", { botId: "missing-bot", text: "Nope" }],
|
|
["threads/send", { groupId: "missing-group", text: "Nope" }],
|
|
["threads/stop", { botId: "missing-bot" }],
|
|
["threads/clear", { botId: "missing-bot" }],
|
|
["threads/followUp", { botId: "missing-bot", text: "Nope" }],
|
|
[
|
|
"threads/answer",
|
|
{
|
|
botId: "missing-bot",
|
|
runId: "missing-run",
|
|
messageId: "missing-message",
|
|
answer: "Nope",
|
|
},
|
|
],
|
|
["threads/markRead", { botId: "missing-bot" }],
|
|
["threads/markUnread", { botId: "missing-bot" }],
|
|
["computer/status", { botId: "missing-bot" }],
|
|
["computer/boot", { botId: "missing-bot" }],
|
|
["computer/stop", { botId: "missing-bot" }],
|
|
["computer/takeover", { botId: "missing-bot" }],
|
|
["computer/release", { botId: "missing-bot" }],
|
|
["computer/input", { botId: "missing-bot", kind: "key", payload: { key: "A" } }],
|
|
["computer/files", { botId: "missing-bot", path: "/" }],
|
|
["computer/readFile", { botId: "missing-bot", path: "MEMORY.md" }],
|
|
["computer/screenUrl", { botId: "missing-bot" }],
|
|
["computer/heartbeat", { botId: "missing-bot" }],
|
|
["memory/list", {}],
|
|
["memory/update", { documentId: "missing-memory", content: "Nope" }],
|
|
["memory/exportMarkdown", {}],
|
|
["memory/providerConfig"],
|
|
[
|
|
"memory/connectProvider",
|
|
{ provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" },
|
|
],
|
|
["memory/setDefaultScope", { defaultMemoryScope: "shared" }],
|
|
["memory/disconnectProvider"],
|
|
["routines/list", { botId: "missing-bot" }],
|
|
["routines/create", routineInput("missing-bot")],
|
|
["routines/update", { routineId: "missing-routine", name: "Nope" }],
|
|
["routines/remove", { routineId: "missing-routine" }],
|
|
["routines/testRun", { routineId: "missing-routine" }],
|
|
["scratchpad/list", { botId: "missing-bot" }],
|
|
["scratchpad/create", { botId: "missing-bot", title: "Nope" }],
|
|
["scratchpad/update", { itemId: "missing-item", title: "Nope" }],
|
|
["scratchpad/remove", { itemId: "missing-item" }],
|
|
["skills/list", { botId: "missing-bot" }],
|
|
["skills/get", { skillId: "missing-skill" }],
|
|
["skills/start", { botId: "missing-bot", goal: "Demonstrate export" }],
|
|
[
|
|
"skills/appendEvent",
|
|
{
|
|
skillId: "missing-skill",
|
|
event: { at: new Date().toISOString(), kind: "key", key: "a" },
|
|
},
|
|
],
|
|
["skills/snapshot", { skillId: "missing-skill" }],
|
|
["skills/stop", { skillId: "missing-skill" }],
|
|
["skills/updateDraft", { skillId: "missing-skill", playbook: skillPlaybookInput() }],
|
|
["skills/save", { skillId: "missing-skill" }],
|
|
["skills/testRun", { skillId: "missing-skill" }],
|
|
["skills/remove", { skillId: "missing-skill" }],
|
|
["agentSkills/list"],
|
|
["agentSkills/get", { skillId: "missing-skill" }],
|
|
["agentSkills/create", { name: "Unauthenticated", description: "Nope", body: "Steps" }],
|
|
["agentSkills/update", { skillId: "missing-skill", description: "Nope" }],
|
|
["agentSkills/remove", { skillId: "missing-skill" }],
|
|
["capabilities/list"],
|
|
["capabilities/install", capabilityInput("Unauthenticated")],
|
|
["capabilities/remove", { id: "missing-capability" }],
|
|
["connections/catalog", {}],
|
|
["connections/list"],
|
|
["connections/begin", connectionInput("Unauthenticated")],
|
|
["connections/complete", { connectionId: "missing-connection" }],
|
|
["connections/revoke", { connectionId: "missing-connection" }],
|
|
["approvalRules/list"],
|
|
[
|
|
"approvalRules/set",
|
|
{ effect: "require_approval", matchKind: "category", matchValue: "email" },
|
|
],
|
|
["approvalRules/remove", { id: "missing-rule" }],
|
|
["artifacts/list", { botId: "missing-bot" }],
|
|
["usage/list"],
|
|
["usage/summary"],
|
|
["export/bot", { botId: "missing-bot" }],
|
|
["notifications/registerPush", { token: "ExponentPushToken[not-real]" }],
|
|
["search/query", { q: "anything" }],
|
|
["voice/catalog"],
|
|
["voice/status"],
|
|
["voice/credentials"],
|
|
["voice/connect", { provider: "elevenlabs", apiKey: "not-a-real-key" }],
|
|
["voice/disconnect", { provider: "elevenlabs" }],
|
|
["voice/setVoice", { voiceId: "missing-voice" }],
|
|
["voice/voices", {}],
|
|
["voice/prepare", { text: "Nope" }],
|
|
]);
|
|
|
|
const results = await Promise.all(
|
|
calls.map(async ([procedure, input]) => ({
|
|
procedure,
|
|
response: await raw(app, "", procedure, input),
|
|
})),
|
|
);
|
|
|
|
for (const { procedure, response } of results) {
|
|
expect(response.status, procedure).toBe(401);
|
|
}
|
|
});
|
|
|
|
it("prevents one user from reading or mutating another user's resources", async () => {
|
|
const owner = await signup(app, `owner-authz-${stamp}@rakazo.test`, "Authorization Owner");
|
|
const intruder = await signup(
|
|
app,
|
|
`intruder-authz-${stamp}@rakazo.test`,
|
|
"Authorization Intruder",
|
|
);
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const intruderActor = await rpc<Actor>(app, intruder, "me");
|
|
expect(ownerActor.spaceId).not.toBe(intruderActor.spaceId);
|
|
|
|
const ownerBot = await rpc<Bot>(app, owner, "bots/create", botInput("Owner Bot"));
|
|
const intruderBot = await rpc<Bot>(app, intruder, "bots/create", botInput("Intruder Bot"));
|
|
const ownerRoutine = await rpc<{ id: string }>(
|
|
app,
|
|
owner,
|
|
"routines/create",
|
|
routineInput(ownerBot.id),
|
|
);
|
|
const ownerScratchpad = await rpc<{ id: string }>(app, owner, "scratchpad/create", {
|
|
botId: ownerBot.id,
|
|
title: "Owner open work",
|
|
notes: "private",
|
|
});
|
|
const ownerSkill = await handles.prisma.taughtSkill.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
botId: ownerBot.id,
|
|
userId: ownerActor.userId,
|
|
name: "Owner Skill",
|
|
goal: "Owner-only skill",
|
|
status: "saved",
|
|
playbook: skillPlaybookInput(),
|
|
recording: { events: [], snapshots: [] },
|
|
},
|
|
});
|
|
const ownerAgentSkill = await rpc<{ id: string }>(app, owner, "agentSkills/create", {
|
|
name: "Owner Recipe",
|
|
description: "Owner-only shared skill",
|
|
body: "1. Do the private thing.",
|
|
});
|
|
const ownerCapability = await rpc<{ id: string }>(
|
|
app,
|
|
owner,
|
|
"capabilities/install",
|
|
capabilityInput("Owner Capability"),
|
|
);
|
|
const ownerConnection = await rpc<{ connectionId: string }>(
|
|
app,
|
|
owner,
|
|
"connections/begin",
|
|
connectionInput("Owner Connection"),
|
|
);
|
|
const ownerMemory = await handles.prisma.memoryDocument.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
userId: ownerActor.userId,
|
|
botId: ownerBot.id,
|
|
scope: "bot",
|
|
path: "PRIVATE.md",
|
|
content: "owner-only-memory",
|
|
},
|
|
});
|
|
const ownerArtifact = await handles.prisma.artifact.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
userId: ownerActor.userId,
|
|
botId: ownerBot.id,
|
|
name: "owner-secret.txt",
|
|
mimeType: "text/plain",
|
|
size: 17,
|
|
hash: "sha256:not-a-real-hash",
|
|
storageKey: "test/owner-secret.txt",
|
|
},
|
|
});
|
|
const ownerThread = await handles.prisma.thread.findUniqueOrThrow({
|
|
where: { botId: ownerBot.id },
|
|
});
|
|
const ownerTask = await handles.prisma.task.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
userId: ownerActor.userId,
|
|
botId: ownerBot.id,
|
|
threadId: ownerThread.id,
|
|
prompt: "owner prompt",
|
|
status: "waiting_input",
|
|
},
|
|
});
|
|
const ownerRun = await handles.prisma.run.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
userId: ownerActor.userId,
|
|
botId: ownerBot.id,
|
|
threadId: ownerThread.id,
|
|
taskId: ownerTask.id,
|
|
status: "waiting_input",
|
|
trigger: "user",
|
|
},
|
|
});
|
|
|
|
const botIdCalls: Array<[string, unknown]> = [
|
|
["bots/get", { botId: ownerBot.id }],
|
|
["bots/duplicate", { botId: ownerBot.id }],
|
|
["bots/update", { botId: ownerBot.id, name: "Stolen Bot" }],
|
|
["bots/archive", { botId: ownerBot.id }],
|
|
["bots/restore", { botId: ownerBot.id }],
|
|
["threads/get", { botId: ownerBot.id }],
|
|
["threads/messages", { botId: ownerBot.id, before: 1 }],
|
|
["threads/subscribe", { botId: ownerBot.id, cursor: -1 }],
|
|
["threads/send", { botId: ownerBot.id, text: "intruder message" }],
|
|
["threads/send", { botId: ownerBot.id, artifactIds: [ownerArtifact.id] }],
|
|
["threads/stop", { botId: ownerBot.id }],
|
|
["threads/clear", { botId: ownerBot.id }],
|
|
["threads/followUp", { botId: ownerBot.id, text: "intruder follow-up" }],
|
|
[
|
|
"threads/answer",
|
|
{
|
|
botId: ownerBot.id,
|
|
runId: ownerRun.id,
|
|
messageId: "missing-message",
|
|
answer: "intruder answer",
|
|
},
|
|
],
|
|
["threads/markRead", { botId: ownerBot.id }],
|
|
["threads/markUnread", { botId: ownerBot.id }],
|
|
["computer/status", { botId: ownerBot.id }],
|
|
["computer/boot", { botId: ownerBot.id }],
|
|
["computer/stop", { botId: ownerBot.id }],
|
|
["computer/takeover", { botId: ownerBot.id }],
|
|
["computer/release", { botId: ownerBot.id }],
|
|
["computer/input", { botId: ownerBot.id, kind: "key", payload: { key: "A" } }],
|
|
["computer/files", { botId: ownerBot.id, path: "/" }],
|
|
["computer/readFile", { botId: ownerBot.id, path: "PRIVATE.md" }],
|
|
["computer/screenUrl", { botId: ownerBot.id }],
|
|
["computer/heartbeat", { botId: ownerBot.id }],
|
|
["routines/list", { botId: ownerBot.id }],
|
|
["routines/create", routineInput(ownerBot.id)],
|
|
["scratchpad/list", { botId: ownerBot.id }],
|
|
["scratchpad/create", { botId: ownerBot.id, title: "Stolen item" }],
|
|
["skills/list", { botId: ownerBot.id }],
|
|
["skills/start", { botId: ownerBot.id, goal: "Intruder demo" }],
|
|
["artifacts/list", { botId: ownerBot.id }],
|
|
[
|
|
"artifacts/create",
|
|
{
|
|
botId: ownerBot.id,
|
|
name: "intruder.txt",
|
|
mimeType: "text/plain",
|
|
contentBase64: Buffer.from("nope").toString("base64"),
|
|
},
|
|
],
|
|
["artifacts/get", { botId: ownerBot.id, artifactId: ownerArtifact.id }],
|
|
["export/bot", { botId: ownerBot.id }],
|
|
["voice/prepare", { text: "stolen speech", botId: ownerBot.id }],
|
|
];
|
|
await Promise.all(
|
|
botIdCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
|
);
|
|
|
|
const ownerBot2 = await rpc<Bot>(app, owner, "bots/create", botInput("Owner Bot Two"));
|
|
const ownerGroup = await rpc<{ id: string }>(app, owner, "groups/create", {
|
|
name: "Owner Group",
|
|
botIds: [ownerBot.id, ownerBot2.id],
|
|
});
|
|
const groupCalls: Array<[string, unknown]> = [
|
|
["groups/get", { groupId: ownerGroup.id }],
|
|
["groups/update", { groupId: ownerGroup.id, name: "Stolen Group" }],
|
|
["groups/remove", { groupId: ownerGroup.id }],
|
|
["threads/get", { groupId: ownerGroup.id }],
|
|
["threads/messages", { groupId: ownerGroup.id, before: 1 }],
|
|
["threads/subscribe", { groupId: ownerGroup.id, cursor: -1 }],
|
|
["threads/send", { groupId: ownerGroup.id, text: "intruder group message" }],
|
|
["threads/stop", { groupId: ownerGroup.id }],
|
|
["threads/followUp", { groupId: ownerGroup.id, text: "intruder follow-up" }],
|
|
["threads/markRead", { groupId: ownerGroup.id }],
|
|
["threads/markUnread", { groupId: ownerGroup.id }],
|
|
];
|
|
await Promise.all(
|
|
groupCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
|
);
|
|
|
|
// A caller cannot pair their own bot with another workspace's run ID.
|
|
await expectDenied(app, intruder, "threads/answer", {
|
|
botId: intruderBot.id,
|
|
runId: ownerRun.id,
|
|
messageId: "missing-message",
|
|
answer: "mixed-resource attack",
|
|
});
|
|
|
|
const resourceIdCalls = [
|
|
["routines/update", { routineId: ownerRoutine.id, name: "Stolen Routine" }],
|
|
["routines/remove", { routineId: ownerRoutine.id }],
|
|
["routines/testRun", { routineId: ownerRoutine.id }],
|
|
["scratchpad/update", { itemId: ownerScratchpad.id, title: "Stolen item" }],
|
|
["scratchpad/remove", { itemId: ownerScratchpad.id }],
|
|
["skills/get", { skillId: ownerSkill.id }],
|
|
[
|
|
"skills/appendEvent",
|
|
{ skillId: ownerSkill.id, event: { at: new Date().toISOString(), kind: "key", key: "x" } },
|
|
],
|
|
["skills/snapshot", { skillId: ownerSkill.id }],
|
|
["skills/stop", { skillId: ownerSkill.id }],
|
|
["skills/updateDraft", { skillId: ownerSkill.id, playbook: skillPlaybookInput() }],
|
|
["skills/save", { skillId: ownerSkill.id }],
|
|
["skills/testRun", { skillId: ownerSkill.id }],
|
|
["skills/remove", { skillId: ownerSkill.id }],
|
|
["agentSkills/get", { skillId: ownerAgentSkill.id }],
|
|
["agentSkills/update", { skillId: ownerAgentSkill.id, description: "Stolen" }],
|
|
["agentSkills/remove", { skillId: ownerAgentSkill.id }],
|
|
["memory/update", { documentId: ownerMemory.id, content: "stolen" }],
|
|
["connections/complete", { connectionId: ownerConnection.connectionId }],
|
|
] satisfies Array<[string, unknown]>;
|
|
await Promise.all(
|
|
resourceIdCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
|
);
|
|
|
|
expect(await rpc<unknown[]>(app, intruder, "memory/list", { botId: ownerBot.id })).toEqual([]);
|
|
expect(await rpc<string>(app, intruder, "memory/exportMarkdown", { botId: ownerBot.id })).toBe(
|
|
"",
|
|
);
|
|
expect(await rpc<Array<{ id: string }>>(app, intruder, "capabilities/list")).not.toContainEqual(
|
|
expect.objectContaining({ id: ownerCapability.id }),
|
|
);
|
|
expect(await rpc<Array<{ id: string }>>(app, intruder, "agentSkills/list")).not.toContainEqual(
|
|
expect.objectContaining({ id: ownerAgentSkill.id }),
|
|
);
|
|
expect(await rpc<Array<{ id: string }>>(app, intruder, "connections/list")).not.toContainEqual(
|
|
expect.objectContaining({ id: ownerConnection.connectionId }),
|
|
);
|
|
expect(
|
|
await rpc<{ hits: unknown[] }>(app, intruder, "search/query", { q: ownerBot.name }),
|
|
).toEqual({ hits: [] });
|
|
|
|
// These endpoints are deliberately idempotent for unknown IDs. Success must not mutate
|
|
// a row in a different workspace or disclose whether it exists.
|
|
await rpc(app, intruder, "capabilities/remove", { id: ownerCapability.id });
|
|
await rpc(app, intruder, "connections/revoke", {
|
|
connectionId: ownerConnection.connectionId,
|
|
});
|
|
expect(
|
|
await handles.prisma.capabilityInstall.findUnique({ where: { id: ownerCapability.id } }),
|
|
).not.toBeNull();
|
|
expect(
|
|
await handles.prisma.connection.findUniqueOrThrow({
|
|
where: { id: ownerConnection.connectionId },
|
|
}),
|
|
).toMatchObject({ status: "connected", userId: ownerActor.userId });
|
|
|
|
const ownerBotAfter = await handles.prisma.bot.findUniqueOrThrow({
|
|
where: { id: ownerBot.id },
|
|
});
|
|
expect(ownerBotAfter.name).toBe("Owner Bot");
|
|
expect(
|
|
await handles.prisma.routine.findUniqueOrThrow({ where: { id: ownerRoutine.id } }),
|
|
).toMatchObject({ name: "Owner Routine" });
|
|
expect(
|
|
await handles.prisma.scratchpadItem.findUniqueOrThrow({ where: { id: ownerScratchpad.id } }),
|
|
).toMatchObject({ title: "Owner open work", notes: "private" });
|
|
expect(
|
|
await handles.prisma.memoryDocument.findUniqueOrThrow({ where: { id: ownerMemory.id } }),
|
|
).toMatchObject({ content: "owner-only-memory" });
|
|
|
|
// Destructive bot removal is checked last so an authorization regression is unmistakable.
|
|
await expectDenied(app, intruder, "bots/remove", {
|
|
botId: ownerBot.id,
|
|
deleteMemories: true,
|
|
});
|
|
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
|
});
|
|
|
|
it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => {
|
|
const owner = await signup(app, `takeover-owner-${stamp}@rakazo.test`, "Takeover Owner");
|
|
const member = await signup(app, `takeover-member-${stamp}@rakazo.test`, "Takeover Member");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
const sharedSpaceId = ownerActor.spaceId;
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: sharedSpaceId },
|
|
select: { organizationId: true },
|
|
});
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `takeover-org-member-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `takeover-space-member-${stamp}`,
|
|
spaceId: sharedSpaceId,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerBot = await rpc<Bot>(
|
|
app,
|
|
owner,
|
|
"bots/create",
|
|
botInput("Owner Bot"),
|
|
sharedSpaceId,
|
|
);
|
|
const memberBot = await rpc<Bot>(
|
|
app,
|
|
member,
|
|
"bots/create",
|
|
botInput("Member Bot"),
|
|
sharedSpaceId,
|
|
);
|
|
const ownerBotRow = await handles.prisma.bot.findUniqueOrThrow({ where: { id: ownerBot.id } });
|
|
const memberBotRow = await handles.prisma.bot.findUniqueOrThrow({
|
|
where: { id: memberBot.id },
|
|
});
|
|
expect(ownerBotRow.computerId).toBe(memberBotRow.computerId);
|
|
|
|
await rpc(app, owner, "computer/boot", { botId: ownerBot.id }, sharedSpaceId);
|
|
await rpc(app, owner, "computer/takeover", { botId: ownerBot.id }, sharedSpaceId);
|
|
|
|
const memberTakeover = await raw(
|
|
app,
|
|
member,
|
|
"computer/takeover",
|
|
{ botId: memberBot.id },
|
|
sharedSpaceId,
|
|
);
|
|
expect(memberTakeover.status).toBe(409);
|
|
expect(await memberTakeover.text()).toMatch(/Another member holds control/i);
|
|
|
|
const computer = await handles.prisma.computer.findUniqueOrThrow({
|
|
where: { id: ownerBotRow.computerId! },
|
|
select: { controlBotId: true },
|
|
});
|
|
expect(computer.controlBotId).toBe(ownerBot.id);
|
|
});
|
|
|
|
it("prevents one shared-space member from deleting another member's routine", async () => {
|
|
const owner = await signup(app, `routine-owner-${stamp}@rakazo.test`, "Routine Owner");
|
|
const member = await signup(app, `routine-member-${stamp}@rakazo.test`, "Routine Member");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared routines" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `routine-member-org-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `routine-member-space-${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 Bot"), shared.id);
|
|
const ownerRoutine = await rpc<{ id: string }>(
|
|
app,
|
|
owner,
|
|
"routines/create",
|
|
routineInput(ownerBot.id),
|
|
shared.id,
|
|
);
|
|
|
|
await expectDenied(app, member, "routines/remove", { routineId: ownerRoutine.id }, shared.id);
|
|
expect(
|
|
await handles.prisma.routine.findUnique({ where: { id: ownerRoutine.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("blocks Team Computer takeover on another bot while teaching is recording", async () => {
|
|
const cookie = await signup(app, `teach-takeover-${stamp}@rakazo.test`, "Teach Takeover");
|
|
const teacher = await rpc<Bot>(app, cookie, "bots/create", botInput("Teacher"));
|
|
const sibling = await rpc<Bot>(app, cookie, "bots/create", botInput("Sibling"));
|
|
const [teacherRow, siblingRow] = await Promise.all([
|
|
handles.prisma.bot.findUniqueOrThrow({ where: { id: teacher.id } }),
|
|
handles.prisma.bot.findUniqueOrThrow({ where: { id: sibling.id } }),
|
|
]);
|
|
expect(teacherRow.computerId).toBe(siblingRow.computerId);
|
|
|
|
await rpc(app, cookie, "computer/boot", { botId: teacher.id });
|
|
const skill = await rpc<{ id: string; status: string }>(app, cookie, "skills/start", {
|
|
botId: teacher.id,
|
|
goal: "Demonstrate export",
|
|
});
|
|
expect(skill.status).toBe("recording");
|
|
|
|
const blockedTakeover = await raw(app, cookie, "computer/takeover", { botId: sibling.id });
|
|
expect(blockedTakeover.status).toBe(409);
|
|
expect(await blockedTakeover.text()).toMatch(/Stop teaching first/i);
|
|
|
|
await rpc(app, cookie, "computer/input", {
|
|
botId: teacher.id,
|
|
kind: "key",
|
|
payload: { key: "x" },
|
|
});
|
|
|
|
const stopped = await rpc<{ status: string }>(app, cookie, "skills/stop", {
|
|
skillId: skill.id,
|
|
});
|
|
expect(stopped.status).toBe("draft");
|
|
|
|
const computer = await handles.prisma.computer.findUniqueOrThrow({
|
|
where: { id: teacherRow.computerId! },
|
|
select: { controlHolder: true, controlBotId: true },
|
|
});
|
|
expect(computer.controlHolder).toBe("bot");
|
|
expect(computer.controlBotId).toBeNull();
|
|
});
|
|
|
|
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");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared export" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `export-member-org-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `export-member-space-${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 Bot"), shared.id);
|
|
const memberBot = await rpc<Bot>(app, member, "bots/create", botInput("Member Bot"), shared.id);
|
|
const home = new LocalAgentHomeStore(dataDir);
|
|
const homeKey = computerHomeKey("team", shared.id);
|
|
const homeContext = {
|
|
operationId: "test",
|
|
traceId: "test",
|
|
spaceId: shared.id,
|
|
userId: ownerActor.userId,
|
|
signal: new AbortController().signal,
|
|
};
|
|
await home.writeFile(
|
|
homeKey,
|
|
`${teamBotWorkspaceDirectory(ownerBot.id)}/PRIVATE.md`,
|
|
"owner-workspace-secret",
|
|
homeContext,
|
|
);
|
|
await home.writeFile(
|
|
homeKey,
|
|
`${teamBotWorkspaceDirectory(memberBot.id)}/notes.txt`,
|
|
"member-notes",
|
|
homeContext,
|
|
);
|
|
|
|
const manifest = await rpc<{ files: Array<{ path: string; content: string }> }>(
|
|
app,
|
|
member,
|
|
"export/bot",
|
|
{ botId: memberBot.id },
|
|
shared.id,
|
|
);
|
|
|
|
expect(manifest.files.some((file) => file.content.includes("owner-workspace-secret"))).toBe(
|
|
false,
|
|
);
|
|
expect(manifest.files.some((file) => file.content.includes("member-notes"))).toBe(true);
|
|
});
|
|
|
|
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 member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `approval-member-${stamp}`,
|
|
organizationId: ownerActor.spaceId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
const ownerRule = await rpc<{ id: string }>(app, owner, "approvalRules/set", {
|
|
effect: "always_allow",
|
|
matchKind: "tool",
|
|
matchValue: "destination.write",
|
|
});
|
|
expect(await rpc<unknown[]>(app, member, "approvalRules/list")).toEqual([]);
|
|
|
|
const memberRule = await rpc<{ id: string }>(app, member, "approvalRules/set", {
|
|
effect: "require_approval",
|
|
matchKind: "category",
|
|
matchValue: "email",
|
|
});
|
|
expect(await rpc<Array<{ id: string }>>(app, owner, "approvalRules/list")).toEqual([
|
|
expect.objectContaining({ id: ownerRule.id }),
|
|
]);
|
|
expect(await rpc<Array<{ id: string }>>(app, member, "approvalRules/list")).toEqual([
|
|
expect.objectContaining({ id: memberRule.id }),
|
|
]);
|
|
|
|
await rpc(app, member, "approvalRules/remove", { id: ownerRule.id });
|
|
expect(
|
|
await handles.prisma.actionApprovalRule.findUnique({ where: { id: ownerRule.id } }),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("redacts memory provider details in aiConsent/status for non-owner members", async () => {
|
|
const owner = await signup(app, `ai-consent-owner-${stamp}@rakazo.test`, "Consent Owner");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Consent shared" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
const secret = await handles.prisma.secret.create({
|
|
data: {
|
|
userId: ownerActor.userId,
|
|
spaceId: shared.id,
|
|
kind: "memory-provider",
|
|
ciphertext: "encrypted-test-key",
|
|
},
|
|
});
|
|
await handles.prisma.spaceMemoryConfig.create({
|
|
data: {
|
|
spaceId: shared.id,
|
|
userId: ownerActor.userId,
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
secretId: secret.id,
|
|
defaultMemoryScope: "isolated",
|
|
},
|
|
});
|
|
|
|
const member = await signup(app, `ai-consent-member-${stamp}@rakazo.test`, "Consent Member");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `ai-consent-org-member-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `ai-consent-space-member-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerStatus = await rpc<AiConsentStatus>(
|
|
app,
|
|
owner,
|
|
"aiConsent/status",
|
|
{ uses: ["memory"] },
|
|
shared.id,
|
|
);
|
|
const memberStatus = await rpc<AiConsentStatus>(
|
|
app,
|
|
member,
|
|
"aiConsent/status",
|
|
{ uses: ["memory"] },
|
|
shared.id,
|
|
);
|
|
const ownerMemory = ownerStatus.recipients.find((recipient) => recipient.use === "memory");
|
|
const memberMemory = memberStatus.recipients.find((recipient) => recipient.use === "memory");
|
|
expect(ownerMemory?.name).toContain("127.0.0.1:8123");
|
|
expect(memberMemory?.name).toBe("Space memory");
|
|
expect(JSON.stringify(memberStatus.recipients)).not.toContain("127.0.0.1");
|
|
expect(JSON.stringify(memberStatus.recipients)).not.toMatch(/supermemory/i);
|
|
});
|
|
|
|
it("keeps space data and computers behind the selected space boundary", async () => {
|
|
const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner");
|
|
const original = await rpc<Actor>(app, cookie, "me");
|
|
const originalBot = await rpc<Bot>(app, cookie, "bots/create", botInput("Open source"));
|
|
const support = await rpc<Space>(app, cookie, "spaces/create", {
|
|
name: "Customer support",
|
|
});
|
|
const storedSpaces = await handles.prisma.space.findMany({
|
|
where: { id: { in: [original.spaceId, support.id] } },
|
|
select: { id: true, organizationId: true },
|
|
});
|
|
expect(new Set(storedSpaces.map((space) => space.organizationId))).toEqual(
|
|
new Set([original.spaceId]),
|
|
);
|
|
const otherOrganizationId = `spaces-other-org-${stamp}`;
|
|
const otherWorkspaceId = `spaces-other-workspace-${stamp}`;
|
|
await handles.prisma.$transaction([
|
|
handles.prisma.organization.create({
|
|
data: {
|
|
id: otherOrganizationId,
|
|
name: "Other company",
|
|
slug: otherOrganizationId,
|
|
createdAt: new Date(),
|
|
},
|
|
}),
|
|
handles.prisma.member.create({
|
|
data: {
|
|
id: `spaces-other-member-${stamp}`,
|
|
organizationId: otherOrganizationId,
|
|
userId: original.userId,
|
|
role: "owner",
|
|
createdAt: new Date(),
|
|
},
|
|
}),
|
|
handles.prisma.space.create({
|
|
data: {
|
|
id: otherWorkspaceId,
|
|
organizationId: otherOrganizationId,
|
|
name: "Other company space",
|
|
},
|
|
}),
|
|
handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `spaces-other-space-member-${stamp}`,
|
|
spaceId: otherWorkspaceId,
|
|
organizationId: otherOrganizationId,
|
|
userId: original.userId,
|
|
createdAt: new Date(),
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const supportMe = await rpc<Actor>(app, cookie, "me", {}, support.id);
|
|
expect(supportMe.spaceId).toBe(support.id);
|
|
const supportBot = await rpc<Bot>(app, cookie, "bots/create", botInput("Support"), support.id);
|
|
|
|
expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list")).toEqual([
|
|
expect.objectContaining({ id: originalBot.id }),
|
|
]);
|
|
expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list", {}, support.id)).toEqual([
|
|
expect.objectContaining({ id: supportBot.id }),
|
|
]);
|
|
await expectDenied(app, cookie, "bots/get", { botId: supportBot.id });
|
|
await expectDenied(app, cookie, "bots/get", { botId: originalBot.id }, support.id);
|
|
|
|
const navigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list");
|
|
expect(navigation.current).toEqual(
|
|
expect.objectContaining({
|
|
id: original.spaceId,
|
|
bots: [expect.objectContaining({ id: originalBot.id })],
|
|
}),
|
|
);
|
|
expect(navigation.spaces).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: original.spaceId,
|
|
bots: [expect.objectContaining({ id: originalBot.id, notifyOnFinish: false })],
|
|
}),
|
|
expect.objectContaining({
|
|
id: support.id,
|
|
name: "Customer support",
|
|
bots: [expect.objectContaining({ id: supportBot.id, notifyOnFinish: false })],
|
|
}),
|
|
]),
|
|
);
|
|
expect(navigation.spaces).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ id: otherWorkspaceId })]),
|
|
);
|
|
const supportNavigation = await rpc<SpaceNavigation>(
|
|
app,
|
|
cookie,
|
|
"spaces/list",
|
|
{},
|
|
support.id,
|
|
);
|
|
expect(supportNavigation.current).toEqual(
|
|
expect.objectContaining({
|
|
id: support.id,
|
|
bots: [expect.objectContaining({ id: supportBot.id })],
|
|
}),
|
|
);
|
|
const otherOrganizationNavigation = await rpc<SpaceNavigation>(
|
|
app,
|
|
cookie,
|
|
"spaces/list",
|
|
{},
|
|
otherWorkspaceId,
|
|
);
|
|
expect(otherOrganizationNavigation.spaces.map((space) => space.id)).toEqual([otherWorkspaceId]);
|
|
|
|
const storedBots = await handles.prisma.bot.findMany({
|
|
where: { id: { in: [originalBot.id, supportBot.id] } },
|
|
select: { id: true, spaceId: true, computerId: true },
|
|
});
|
|
const storedOriginal = storedBots.find((bot) => bot.id === originalBot.id);
|
|
const storedSupport = storedBots.find((bot) => bot.id === supportBot.id);
|
|
expect(storedOriginal?.spaceId).toBe(original.spaceId);
|
|
expect(storedSupport?.spaceId).toBe(support.id);
|
|
expect(storedOriginal?.computerId).not.toBe(storedSupport?.computerId);
|
|
|
|
const intruder = await signup(app, `spaces-intruder-${stamp}@rakazo.test`, "Intruder");
|
|
await expectDenied(app, intruder, "bots/list", {}, support.id);
|
|
});
|
|
|
|
it("restricts memory provider configuration to Space owners", async () => {
|
|
const owner = await signup(app, `memory-provider-owner-${stamp}@rakazo.test`, "Memory Owner");
|
|
const member = await signup(
|
|
app,
|
|
`memory-provider-member-${stamp}@rakazo.test`,
|
|
"Memory Member",
|
|
);
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared memory config" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `memory-provider-member-org-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `memory-provider-member-space-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerActor = await rpc<Actor>(app, owner, "me", {}, shared.id);
|
|
const secret = await handles.prisma.secret.create({
|
|
data: {
|
|
userId: ownerActor.userId,
|
|
spaceId: shared.id,
|
|
kind: "memory-provider",
|
|
ciphertext: "encrypted-memory-provider-key",
|
|
},
|
|
});
|
|
await handles.prisma.spaceMemoryConfig.create({
|
|
data: {
|
|
spaceId: shared.id,
|
|
userId: ownerActor.userId,
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
secretId: secret.id,
|
|
defaultMemoryScope: "isolated",
|
|
},
|
|
});
|
|
|
|
await expectForbidden(app, member, "memory/providerConfig", {}, shared.id);
|
|
await expectForbidden(
|
|
app,
|
|
member,
|
|
"memory/connectProvider",
|
|
{
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
credentials: { apiKey: "not-a-real-key" },
|
|
defaultMemoryScope: "isolated",
|
|
},
|
|
shared.id,
|
|
);
|
|
await expectForbidden(
|
|
app,
|
|
member,
|
|
"memory/setDefaultScope",
|
|
{ defaultMemoryScope: "shared" },
|
|
shared.id,
|
|
);
|
|
await expectForbidden(app, member, "memory/disconnectProvider", {}, shared.id);
|
|
|
|
expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toEqual({
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
defaultMemoryScope: "isolated",
|
|
updatedAt: expect.any(String),
|
|
});
|
|
});
|
|
|
|
it("enforces the space limit across concurrent creation requests", async () => {
|
|
const cookie = await signup(app, `space-limit-${stamp}@rakazo.test`, "Space Limit");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const currentSpace = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: actor.spaceId },
|
|
select: { organizationId: true },
|
|
});
|
|
const extraSpaces = Array.from({ length: 30 }, (_, index) => ({
|
|
id: `limit-space-${stamp}-${index}`,
|
|
organizationId: currentSpace.organizationId,
|
|
name: `Limit space ${index}`,
|
|
createdAt: new Date(),
|
|
}));
|
|
await handles.prisma.space.createMany({ data: extraSpaces });
|
|
await handles.prisma.spaceMember.createMany({
|
|
data: extraSpaces.map((space, index) => ({
|
|
id: `limit-member-${stamp}-${index}`,
|
|
spaceId: space.id,
|
|
organizationId: space.organizationId,
|
|
userId: actor.userId,
|
|
createdAt: space.createdAt,
|
|
})),
|
|
});
|
|
|
|
const results = await Promise.allSettled([
|
|
rpc<Space>(app, cookie, "spaces/create", { name: "Concurrent A" }),
|
|
rpc<Space>(app, cookie, "spaces/create", { name: "Concurrent B" }),
|
|
]);
|
|
|
|
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
|
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
|
|
await expect(
|
|
handles.prisma.spaceMember.count({
|
|
where: { userId: actor.userId, organizationId: currentSpace.organizationId },
|
|
}),
|
|
).resolves.toBe(32);
|
|
});
|
|
|
|
it("reuses provider credentials and copies their selections into a new Space", async () => {
|
|
const cookie = await signup(app, `space-provider-copy-${stamp}@rakazo.test`, "Provider Copy");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const model = await rpc<ModelCredential>(app, cookie, "models/connect", {
|
|
provider: "copy-provider",
|
|
apiKey: "fake-copy-model-key",
|
|
label: "Copy provider",
|
|
modelId: "copy/model",
|
|
});
|
|
const voice = await rpc<{ id: string; voiceId: string }>(app, cookie, "voice/connect", {
|
|
provider: "scripted",
|
|
apiKey: "fake-copy-voice-key",
|
|
});
|
|
|
|
const created = await rpc<Space>(app, cookie, "spaces/create", { name: "Copied defaults" });
|
|
const [modelPreference, voicePreference] = await Promise.all([
|
|
handles.prisma.spaceModelPreference.findUnique({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: created.id,
|
|
userId: actor.userId,
|
|
credentialId: model.id,
|
|
},
|
|
},
|
|
}),
|
|
handles.prisma.spaceVoicePreference.findUnique({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: created.id,
|
|
userId: actor.userId,
|
|
credentialId: voice.id,
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
expect(modelPreference).toMatchObject({ modelId: "copy/model", isDefault: true });
|
|
expect(voicePreference).toMatchObject({ voiceId: voice.voiceId, isDefault: true });
|
|
await rpc(
|
|
app,
|
|
cookie,
|
|
"voice/setVoice",
|
|
{ provider: "scripted", voiceId: "space-specific-voice" },
|
|
created.id,
|
|
);
|
|
await expect(
|
|
handles.prisma.spaceVoicePreference.findUniqueOrThrow({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
credentialId: voice.id,
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({ voiceId: voice.voiceId, isDefault: true });
|
|
await expect(
|
|
handles.prisma.spaceVoicePreference.findUniqueOrThrow({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: created.id,
|
|
userId: actor.userId,
|
|
credentialId: voice.id,
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({ voiceId: "space-specific-voice", isDefault: true });
|
|
await expect(
|
|
handles.prisma.userModelCredential.count({ where: { userId: actor.userId } }),
|
|
).resolves.toBe(1);
|
|
await expect(
|
|
handles.prisma.userVoiceCredential.count({ where: { userId: actor.userId } }),
|
|
).resolves.toBe(1);
|
|
});
|
|
|
|
it("shares model credentials while keeping defaults private to each space", async () => {
|
|
const cookie = await signup(app, `model-defaults-${stamp}@rakazo.test`, "Model Defaults");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const support = await rpc<Space>(app, cookie, "spaces/create", { name: "Support models" });
|
|
const expectSpaceModelDefault = async (spaceId: string, provider: string, modelId: string) => {
|
|
const preference = await handles.prisma.spaceModelPreference.findFirst({
|
|
where: { userId: actor.userId, spaceId, isDefault: true },
|
|
include: { credential: true },
|
|
});
|
|
expect(preference).toMatchObject({ modelId, credential: { provider } });
|
|
};
|
|
|
|
const connectedA = await rpc<ModelCredential>(app, cookie, "models/connect", {
|
|
provider: "provider-a",
|
|
apiKey: "fake-provider-a-key",
|
|
label: "Provider A",
|
|
modelId: "a/one",
|
|
});
|
|
expect(connectedA.isDefault).toBe(true);
|
|
const providerABeforeRotation = await handles.prisma.userModelCredential.findUniqueOrThrow({
|
|
where: { id: connectedA.id },
|
|
});
|
|
|
|
const rotatedA = await rpc<ModelCredential>(app, cookie, "models/connect", {
|
|
provider: "provider-a",
|
|
apiKey: "fake-provider-a-replacement-key",
|
|
label: "Provider A rotated",
|
|
modelId: "a/rotated",
|
|
});
|
|
const providerAAfterRotation = await handles.prisma.userModelCredential.findUniqueOrThrow({
|
|
where: { id: connectedA.id },
|
|
});
|
|
expect(rotatedA.id).toBe(connectedA.id);
|
|
expect(providerAAfterRotation.secretId).not.toBe(providerABeforeRotation.secretId);
|
|
expect(
|
|
await handles.prisma.secret.findUnique({ where: { id: providerABeforeRotation.secretId } }),
|
|
).toBeNull();
|
|
await expect(
|
|
handles.prisma.secret.findUniqueOrThrow({ where: { id: providerAAfterRotation.secretId } }),
|
|
).resolves.toMatchObject({ userId: actor.userId, spaceId: null, kind: "model" });
|
|
expect(
|
|
await handles.prisma.userModelCredential.count({
|
|
where: { userId: actor.userId, provider: "provider-a" },
|
|
}),
|
|
).toBe(1);
|
|
|
|
const supportCredentials = await rpc<ModelCredential[]>(
|
|
app,
|
|
cookie,
|
|
"models/credentials",
|
|
{},
|
|
support.id,
|
|
);
|
|
expect(supportCredentials).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: connectedA.id, provider: "provider-a", isDefault: false }),
|
|
]),
|
|
);
|
|
await rpc(
|
|
app,
|
|
cookie,
|
|
"models/setDefault",
|
|
{ provider: "provider-a", modelId: "a/support" },
|
|
support.id,
|
|
);
|
|
await expectSpaceModelDefault(support.id, "provider-a", "a/support");
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-a", "a/rotated");
|
|
|
|
const connectedB = await rpc<ModelCredential>(app, cookie, "models/connect", {
|
|
provider: "provider-b",
|
|
apiKey: "fake-provider-b-key",
|
|
label: "Provider B",
|
|
modelId: "b/one",
|
|
});
|
|
expect(connectedB.isDefault).toBe(true);
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-b", "b/one");
|
|
|
|
await rpc(app, cookie, "models/setDefault", { provider: "provider-a", modelId: "a/two" });
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-a", "a/two");
|
|
|
|
await rpc(app, cookie, "models/setDefault", { provider: "provider-b", modelId: "b/two" });
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-b", "b/two");
|
|
|
|
await rpc(app, cookie, "models/setDefault", { provider: "provider-a", modelId: "a/three" });
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-a", "a/three");
|
|
await expectSpaceModelDefault(support.id, "provider-a", "a/support");
|
|
const listed = await rpc<ModelCredential[]>(app, cookie, "models/credentials");
|
|
expect(JSON.stringify(listed)).not.toContain("fake-provider-a-key");
|
|
expect(JSON.stringify(listed)).not.toContain("fake-provider-b-key");
|
|
|
|
const missing = await raw(app, cookie, "models/setDefault", {
|
|
provider: "missing-provider",
|
|
modelId: "missing/model",
|
|
});
|
|
expect(missing.status).toBeGreaterThanOrEqual(400);
|
|
expect(await missing.text()).toMatch(/credential/i);
|
|
});
|
|
|
|
it("deletes only empty, non-default spaces", async () => {
|
|
const cookie = await signup(app, `space-delete-${stamp}@rakazo.test`, "Space Delete");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const empty = await rpc<Space>(app, cookie, "spaces/create", { name: "Temporary" });
|
|
const busy = await rpc<Space>(app, cookie, "spaces/create", { name: "Busy" });
|
|
const busyBot = await rpc<Bot>(app, cookie, "bots/create", botInput("Busy bot"), busy.id);
|
|
|
|
await expect(raw(app, cookie, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
|
|
status: 400,
|
|
});
|
|
await expect(
|
|
handles.prisma.space.findUnique({ where: { id: busy.id } }),
|
|
).resolves.not.toBeNull();
|
|
await rpc(app, cookie, "bots/archive", { botId: busyBot.id }, busy.id);
|
|
expect(
|
|
(await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, busy.id)).spaces.find(
|
|
(space) => space.id === busy.id,
|
|
),
|
|
).toMatchObject({ hasContent: true, canDelete: false });
|
|
await expect(raw(app, cookie, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
|
|
status: 400,
|
|
});
|
|
await expect(
|
|
raw(app, cookie, "spaces/remove", { spaceId: actor.spaceId }),
|
|
).resolves.toMatchObject({ status: 400 });
|
|
await expect(
|
|
raw(app, cookie, "spaces/remove", { spaceId: "missing-space" }),
|
|
).resolves.toMatchObject({ status: 404 });
|
|
|
|
const removed = await rpc<{ ok: true; activeSpaceId: string }>(app, cookie, "spaces/remove", {
|
|
spaceId: empty.id,
|
|
});
|
|
expect(removed).toEqual({ ok: true, activeSpaceId: actor.spaceId });
|
|
await expect(handles.prisma.space.findUnique({ where: { id: empty.id } })).resolves.toBeNull();
|
|
const navigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list");
|
|
expect(navigation.spaces.map((space) => space.id)).not.toContain(empty.id);
|
|
|
|
const intruder = await signup(app, `space-delete-intruder-${stamp}@rakazo.test`, "Intruder");
|
|
await expect(raw(app, intruder, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
|
|
status: 404,
|
|
});
|
|
|
|
// Shared-space members must not delete; only the SpaceMember owner may.
|
|
const shared = await rpc<Space>(app, cookie, "spaces/create", { name: "Shared empty" });
|
|
expect(shared.canDelete).toBe(true);
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
const memberCookie = await signup(
|
|
app,
|
|
`space-delete-member-${stamp}@rakazo.test`,
|
|
"Space Member",
|
|
);
|
|
const memberActor = await rpc<Actor>(app, memberCookie, "me");
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `space-delete-org-member-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `space-delete-space-member-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await expect(
|
|
raw(app, memberCookie, "spaces/remove", { spaceId: shared.id }, shared.id),
|
|
).resolves.toMatchObject({ status: 403 });
|
|
await expect(
|
|
handles.prisma.space.findUnique({ where: { id: shared.id } }),
|
|
).resolves.not.toBeNull();
|
|
|
|
// Another member's bot still counts as content for onboarding emptiness.
|
|
const ownerBot = await rpc<Bot>(
|
|
app,
|
|
cookie,
|
|
"bots/create",
|
|
botInput("Owner shared bot"),
|
|
shared.id,
|
|
);
|
|
const ownerNavigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, shared.id);
|
|
expect(ownerNavigation.spaces).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: shared.id, hasContent: true, canDelete: false }),
|
|
]),
|
|
);
|
|
const memberNavigation = await rpc<SpaceNavigation>(
|
|
app,
|
|
memberCookie,
|
|
"spaces/list",
|
|
{},
|
|
shared.id,
|
|
);
|
|
expect(memberNavigation.spaces).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: shared.id, hasContent: true, canDelete: false }),
|
|
]),
|
|
);
|
|
|
|
await rpc(app, cookie, "bots/remove", { botId: ownerBot.id, deleteMemories: true }, shared.id);
|
|
expect(
|
|
(await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, shared.id)).spaces.find(
|
|
(space) => space.id === shared.id,
|
|
)?.canDelete,
|
|
).toBe(true);
|
|
const ownerRemoved = await rpc<{ ok: true; activeSpaceId: string }>(
|
|
app,
|
|
cookie,
|
|
"spaces/remove",
|
|
{
|
|
spaceId: shared.id,
|
|
},
|
|
);
|
|
expect(ownerRemoved.ok).toBe(true);
|
|
await expect(handles.prisma.space.findUnique({ where: { id: shared.id } })).resolves.toBeNull();
|
|
});
|
|
|
|
it("blocks bot creation after empty space deletion is claimed", async () => {
|
|
const cookie = await signup(app, `space-race-${stamp}@rakazo.test`, "Space Race");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const space = await rpc<Space>(app, cookie, "spaces/create", { name: "Concurrent" });
|
|
await handles.prisma.computer.create({
|
|
data: {
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
scopeKey: `space-race-scope-${stamp}`,
|
|
homeKey: `space-race-home-${stamp}`,
|
|
kind: "fake",
|
|
providerRef: `space-race-provider-${stamp}`,
|
|
},
|
|
});
|
|
const deleteInput = {
|
|
currentSpaceId: space.id,
|
|
userId: actor.userId,
|
|
spaceId: space.id,
|
|
};
|
|
|
|
const firstClaim = await claimEmptySpaceDeletionForMember(handles.prisma, deleteInput);
|
|
expect(firstClaim.recovered).toBe(false);
|
|
expect(firstClaim.computers).toEqual([
|
|
expect.objectContaining({ providerRef: `space-race-provider-${stamp}` }),
|
|
]);
|
|
await expect(
|
|
renewSpaceDeletionClaim(handles.prisma, { ...deleteInput, claimId: firstClaim.claimId }),
|
|
).resolves.toBe(true);
|
|
const claimedNavigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, space.id);
|
|
expect(claimedNavigation.spaces.find((item) => item.id === space.id)?.canDelete).toBe(false);
|
|
const blockedCreate = await raw(app, cookie, "bots/create", botInput("Racing bot"), space.id);
|
|
expect(blockedCreate.ok).toBe(false);
|
|
expect(blockedCreate.status).toBe(409);
|
|
expect(await handles.prisma.bot.count({ where: { spaceId: space.id } })).toBe(0);
|
|
expect(
|
|
await handles.prisma.computer.findFirst({
|
|
where: { spaceId: space.id },
|
|
select: { providerRef: true },
|
|
}),
|
|
).toEqual({ providerRef: `space-race-provider-${stamp}` });
|
|
|
|
await handles.prisma.space.update({
|
|
where: { id: space.id },
|
|
data: { deletingAt: new Date(Date.now() - 6 * 60_000) },
|
|
});
|
|
const replacementClaim = await claimEmptySpaceDeletionForMember(handles.prisma, deleteInput);
|
|
expect(replacementClaim.recovered).toBe(true);
|
|
await expect(
|
|
deleteEmptySpaceForMember(handles.prisma, { ...deleteInput, claimId: firstClaim.claimId }),
|
|
).rejects.toThrow("Space deletion is already in progress");
|
|
await releaseSpaceDeletionClaim(handles.prisma, {
|
|
...deleteInput,
|
|
claimId: firstClaim.claimId,
|
|
});
|
|
expect(
|
|
await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletionClaimId: true },
|
|
}),
|
|
).toEqual({ deletionClaimId: replacementClaim.claimId });
|
|
await releaseSpaceDeletionClaim(handles.prisma, {
|
|
...deleteInput,
|
|
claimId: replacementClaim.claimId,
|
|
});
|
|
const created = await raw(app, cookie, "bots/create", botInput("After retry"), space.id);
|
|
expect(created.ok).toBe(true);
|
|
});
|
|
|
|
it("keeps a space claimed after ambiguous sandbox teardown failure", async () => {
|
|
const cookie = await signup(app, `space-teardown-${stamp}@rakazo.test`, "Teardown");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const space = await rpc<Space>(app, cookie, "spaces/create", { name: "Teardown" });
|
|
await handles.prisma.computer.create({
|
|
data: {
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
scopeKey: `space-teardown-scope-${stamp}`,
|
|
homeKey: `space-teardown-home-${stamp}`,
|
|
kind: "fake",
|
|
providerRef: `space-teardown-provider-${stamp}`,
|
|
},
|
|
});
|
|
const destroy = vi
|
|
.spyOn(handles.sandbox, "destroy")
|
|
.mockRejectedValueOnce(new Error("ambiguous provider failure"));
|
|
|
|
try {
|
|
const removed = await raw(app, cookie, "spaces/remove", { spaceId: space.id }, space.id);
|
|
expect(removed.status).toBe(500);
|
|
const lifecycle = await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletingAt: true, deletionClaimId: true },
|
|
});
|
|
expect(lifecycle?.deletingAt).toBeInstanceOf(Date);
|
|
expect(lifecycle?.deletionClaimId).toBeTruthy();
|
|
expect(
|
|
await handles.prisma.computer.findFirst({
|
|
where: { spaceId: space.id },
|
|
select: { providerRef: true },
|
|
}),
|
|
).toEqual({ providerRef: `space-teardown-provider-${stamp}` });
|
|
const blockedCreate = await raw(
|
|
app,
|
|
cookie,
|
|
"bots/create",
|
|
botInput("Blocked after teardown"),
|
|
space.id,
|
|
);
|
|
expect(blockedCreate.ok).toBe(false);
|
|
} finally {
|
|
destroy.mockRestore();
|
|
const lifecycle = await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletionClaimId: true },
|
|
});
|
|
if (lifecycle?.deletionClaimId) {
|
|
await releaseSpaceDeletionClaim(handles.prisma, {
|
|
currentSpaceId: space.id,
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
claimId: lifecycle.deletionClaimId,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it("times out a hung sandbox teardown without unblocking the space", async () => {
|
|
const cookie = await signup(app, `space-deadline-${stamp}@rakazo.test`, "Deadline");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const space = await rpc<Space>(app, cookie, "spaces/create", { name: "Deadline" });
|
|
await handles.prisma.computer.create({
|
|
data: {
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
scopeKey: `space-deadline-scope-${stamp}`,
|
|
homeKey: `space-deadline-home-${stamp}`,
|
|
kind: "fake",
|
|
providerRef: `space-deadline-provider-${stamp}`,
|
|
},
|
|
});
|
|
process.env.SPACE_TEARDOWN_TIMEOUT_MS = "50";
|
|
const destroy = vi
|
|
.spyOn(handles.sandbox, "destroy")
|
|
.mockImplementation(() => new Promise(() => undefined));
|
|
|
|
try {
|
|
const removed = await raw(app, cookie, "spaces/remove", { spaceId: space.id }, space.id);
|
|
expect(removed.status).toBe(500);
|
|
const lifecycle = await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletingAt: true, deletionClaimId: true },
|
|
});
|
|
expect(lifecycle?.deletingAt).toBeInstanceOf(Date);
|
|
expect(lifecycle?.deletionClaimId).toBeTruthy();
|
|
const blockedCreate = await raw(
|
|
app,
|
|
cookie,
|
|
"bots/create",
|
|
botInput("Blocked during teardown"),
|
|
space.id,
|
|
);
|
|
expect(blockedCreate.status).toBe(409);
|
|
} finally {
|
|
destroy.mockRestore();
|
|
delete process.env.SPACE_TEARDOWN_TIMEOUT_MS;
|
|
const lifecycle = await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletionClaimId: true },
|
|
});
|
|
if (lifecycle?.deletionClaimId) {
|
|
await releaseSpaceDeletionClaim(handles.prisma, {
|
|
currentSpaceId: space.id,
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
claimId: lifecycle.deletionClaimId,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it("validates custom thinking against the saved connection capability", async () => {
|
|
const cookie = await signup(app, `custom-thinking-${stamp}@rakazo.test`, "Custom Thinking");
|
|
const bot = await rpc<Bot>(app, cookie, "bots/create", botInput("Thinking Bot"));
|
|
const connection = {
|
|
provider: "openai-compatible",
|
|
modelId: "arbitrary-model",
|
|
baseUrl: "http://localhost:8000/v1",
|
|
};
|
|
await rpc(app, cookie, "models/connect", {
|
|
...connection,
|
|
apiKey: "fake-saved-key",
|
|
reasoning: false,
|
|
});
|
|
await rpc(app, cookie, "models/connect", { ...connection, reasoning: true });
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
expect(
|
|
await handles.executor.resolveModel({
|
|
userId: actor.userId,
|
|
spaceId: actor.spaceId,
|
|
botId: bot.id,
|
|
}),
|
|
).toMatchObject({ apiKey: "fake-saved-key", reasoning: true });
|
|
const update = {
|
|
botId: bot.id,
|
|
modelProvider: connection.provider,
|
|
modelId: connection.modelId,
|
|
};
|
|
expect(
|
|
await rpc(app, cookie, "bots/update", { ...update, thinkingLevel: "low" }),
|
|
).toMatchObject({ thinkingLevel: "low" });
|
|
expect(
|
|
(await raw(app, cookie, "bots/update", { ...update, thinkingLevel: "xhigh" })).status,
|
|
).toBe(400);
|
|
await rpc(app, cookie, "models/connect", { ...connection, reasoning: false });
|
|
expect(
|
|
(await raw(app, cookie, "bots/update", { ...update, thinkingLevel: "low" })).status,
|
|
).toBe(400);
|
|
expect(
|
|
await rpc(app, cookie, "bots/update", { ...update, thinkingLevel: "off" }),
|
|
).toMatchObject({ thinkingLevel: "off" });
|
|
});
|
|
|
|
it("validates per-bot model overrides against connected providers and catalog", async () => {
|
|
const cookie = await signup(app, `bot-model-${stamp}@rakazo.test`, "Bot Model");
|
|
const bot = await rpc<
|
|
Bot & {
|
|
modelProvider: string | null;
|
|
modelId: string | null;
|
|
thinkingLevel: string | null;
|
|
}
|
|
>(app, cookie, "bots/create", botInput("Model Bot"));
|
|
await rpc(app, cookie, "models/connect", {
|
|
provider: "xai",
|
|
apiKey: "fake-xai-key-not-real",
|
|
label: "xAI",
|
|
modelId: "grok-4.6",
|
|
});
|
|
|
|
const updated = await rpc<
|
|
Bot & {
|
|
modelProvider: string | null;
|
|
modelId: string | null;
|
|
thinkingLevel: string | null;
|
|
}
|
|
>(app, cookie, "bots/update", {
|
|
botId: bot.id,
|
|
modelProvider: "xai",
|
|
modelId: "grok-4.6",
|
|
thinkingLevel: "high",
|
|
});
|
|
expect(updated).toMatchObject({
|
|
modelProvider: "xai",
|
|
modelId: "grok-4.6",
|
|
thinkingLevel: "high",
|
|
});
|
|
|
|
const unknown = await raw(app, cookie, "bots/update", {
|
|
botId: bot.id,
|
|
modelProvider: "xai",
|
|
modelId: "not-a-real-grok",
|
|
});
|
|
expect(unknown.status).toBeGreaterThanOrEqual(400);
|
|
expect(await unknown.text()).toMatch(/unknown model/i);
|
|
|
|
const disconnected = await raw(app, cookie, "bots/update", {
|
|
botId: bot.id,
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-opus-4-6",
|
|
});
|
|
expect(disconnected.status).toBeGreaterThanOrEqual(400);
|
|
expect(await disconnected.text()).toMatch(/connect/i);
|
|
|
|
const partialClear = await raw(app, cookie, "bots/update", {
|
|
botId: bot.id,
|
|
modelId: null,
|
|
});
|
|
expect(partialClear.status).toBeGreaterThanOrEqual(400);
|
|
expect(await partialClear.text()).toMatch(/both be set or both cleared/i);
|
|
});
|
|
|
|
it("chooses the newest duplicate provider credential when selecting a default", async () => {
|
|
const cookie = await signup(app, `model-duplicates-${stamp}@rakazo.test`, "Model Duplicates");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const olderSecret = await handles.prisma.secret.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
spaceId: null,
|
|
kind: "model",
|
|
ciphertext: "encrypted-older-key",
|
|
},
|
|
});
|
|
const newerSecret = await handles.prisma.secret.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
spaceId: null,
|
|
kind: "model",
|
|
ciphertext: "encrypted-newer-key",
|
|
},
|
|
});
|
|
const older = await handles.prisma.userModelCredential.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
provider: "duplicate-provider",
|
|
label: "Older",
|
|
secretId: olderSecret.id,
|
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
|
|
},
|
|
});
|
|
const newer = await handles.prisma.userModelCredential.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
provider: "duplicate-provider",
|
|
label: "Newer",
|
|
secretId: newerSecret.id,
|
|
createdAt: new Date("2026-02-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-02-02T00:00:00.000Z"),
|
|
},
|
|
});
|
|
await handles.prisma.spaceModelPreference.create({
|
|
data: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
credentialId: older.id,
|
|
modelId: "older/model",
|
|
isDefault: true,
|
|
},
|
|
});
|
|
|
|
await rpc(app, cookie, "models/setDefault", {
|
|
provider: "duplicate-provider",
|
|
modelId: "newer/selected",
|
|
});
|
|
|
|
const preferences = await handles.prisma.spaceModelPreference.findMany({
|
|
where: { userId: actor.userId, spaceId: actor.spaceId },
|
|
});
|
|
expect(preferences.filter((row) => row.isDefault).map((row) => row.credentialId)).toEqual([
|
|
newer.id,
|
|
]);
|
|
expect(preferences.find((row) => row.credentialId === newer.id)).toMatchObject({
|
|
isDefault: true,
|
|
modelId: "newer/selected",
|
|
});
|
|
expect(preferences.find((row) => row.credentialId === older.id)).toMatchObject({
|
|
isDefault: false,
|
|
modelId: "older/model",
|
|
});
|
|
const listed = await rpc<ModelCredential[]>(app, cookie, "models/credentials");
|
|
expect(
|
|
listed.filter((row) => row.provider === "duplicate-provider").map((row) => row.id),
|
|
).toEqual([newer.id, older.id]);
|
|
});
|
|
|
|
it("restricts deployment settings to the deployment owner", async () => {
|
|
const owner = await signup(app, `deployment-owner-${stamp}@rakazo.test`, "Deployment Owner");
|
|
const other = await signup(app, `deployment-other-${stamp}@rakazo.test`, "Deployment Other");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const otherActor = await rpc<Actor>(app, other, "me");
|
|
// This test changes a live allowlist; the operator has already proved
|
|
// ownership of the mailbox. Endpoint verification has offline auth tests.
|
|
await handles.prisma.user.update({
|
|
where: { id: ownerActor.userId },
|
|
data: { emailVerified: true },
|
|
});
|
|
await handles.prisma.deploymentSettings.update({
|
|
where: { id: "default" },
|
|
data: {
|
|
ownerUserId: ownerActor.userId,
|
|
signupsEnabled: true,
|
|
signupAllowlist: "",
|
|
},
|
|
});
|
|
|
|
expect(otherActor.userId).not.toBe(ownerActor.userId);
|
|
|
|
await rpc(app, owner, "deployment/get");
|
|
await expectDenied(app, other, "deployment/get", {});
|
|
await expectDenied(app, other, "deployment/update", {
|
|
signupsEnabled: false,
|
|
signupAllowlist: ["attacker@example.test"],
|
|
});
|
|
await expectForbidden(app, other, "updater/status", {});
|
|
await expectForbidden(app, other, "updater/check", {});
|
|
await expectForbidden(app, other, "updater/apply", {});
|
|
expect(
|
|
await handles.prisma.deploymentSettings.findUniqueOrThrow({ where: { id: "default" } }),
|
|
).toMatchObject({ signupsEnabled: true, signupAllowlist: "" });
|
|
|
|
try {
|
|
await rpc(app, owner, "deployment/update", { signupsEnabled: false });
|
|
const closedSignup = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
email: `closed-${stamp}@rakazo.test`,
|
|
password: "password123",
|
|
name: "Closed Signup",
|
|
}),
|
|
});
|
|
expect(closedSignup.status).toBe(400);
|
|
expect(await closedSignup.text()).toContain("Registration is closed");
|
|
|
|
const approvedEmail = `approved-${stamp}@example.test`;
|
|
await rpc(app, owner, "deployment/update", {
|
|
signupsEnabled: true,
|
|
signupAllowlist: [approvedEmail],
|
|
});
|
|
const disallowedSignup = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
email: `not-approved-${stamp}@rakazo.test`,
|
|
password: "password123",
|
|
name: "Disallowed Signup",
|
|
}),
|
|
});
|
|
expect(disallowedSignup.status).toBe(400);
|
|
expect(await disallowedSignup.text()).toContain("Email is not allowed to register");
|
|
const unverifiedSignup = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
email: approvedEmail,
|
|
password: "password123",
|
|
name: "Approved Signup",
|
|
}),
|
|
});
|
|
expect(unverifiedSignup.status).toBe(400);
|
|
expect(await unverifiedSignup.text()).toContain("Registration requires email delivery");
|
|
} finally {
|
|
await rpc(app, owner, "deployment/update", {
|
|
signupsEnabled: true,
|
|
signupAllowlist: [],
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
function botInput(name: string) {
|
|
return {
|
|
name,
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: false,
|
|
};
|
|
}
|
|
|
|
function routineInput(botId: string) {
|
|
return {
|
|
botId,
|
|
name: "Owner Routine",
|
|
prompt: "owner-only prompt",
|
|
crons: ["0 9 * * 1"],
|
|
timezone: "UTC",
|
|
notify: false,
|
|
active: false,
|
|
};
|
|
}
|
|
|
|
function skillPlaybookInput() {
|
|
return {
|
|
whenToUse: "When needed",
|
|
inputs: ["example"],
|
|
steps: ["Do the thing"],
|
|
howToCheck: "Verify result",
|
|
whatToReturn: "Summary",
|
|
approvalBoundaries: "Ask first",
|
|
failureHandling: "Stop and ask",
|
|
};
|
|
}
|
|
|
|
function capabilityInput(name: string) {
|
|
return { kind: "skill", name, source: "test://authorization", config: {} };
|
|
}
|
|
|
|
function connectionInput(displayName: string) {
|
|
return { provider: "test-provider", displayName };
|
|
}
|
|
|
|
async function signup(app: App, email: string, name: string) {
|
|
const response = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
|
|
body: JSON.stringify({ email, password: "password12", name }),
|
|
});
|
|
if (response.status >= 400) {
|
|
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
|
|
}
|
|
return sessionCookieHeader(response);
|
|
}
|
|
|
|
async function raw(
|
|
app: App,
|
|
cookie: string,
|
|
procedure: string,
|
|
body: unknown = {},
|
|
spaceId?: string,
|
|
) {
|
|
return app.request(`/rpc/${procedure}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
|
|
origin: "http://127.0.0.1:5173",
|
|
},
|
|
body: JSON.stringify({ json: body ?? {} }),
|
|
});
|
|
}
|
|
|
|
async function rpc<T>(
|
|
app: App,
|
|
cookie: string,
|
|
procedure: string,
|
|
body: unknown = {},
|
|
spaceId?: string,
|
|
): Promise<T> {
|
|
const response = await raw(app, cookie, procedure, body, spaceId);
|
|
const text = await response.text();
|
|
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
|
if (response.status >= 400 || payload.error) {
|
|
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
|
|
}
|
|
return payload.json as T;
|
|
}
|
|
|
|
async function expectDenied(
|
|
app: App,
|
|
cookie: string,
|
|
procedure: string,
|
|
body: unknown,
|
|
spaceId?: string,
|
|
) {
|
|
const response = await raw(app, cookie, procedure, body, spaceId);
|
|
if (procedure === "threads/subscribe" && response.status === 200) {
|
|
// Streaming transports commit the HTTP 200 before advancing the async iterator. The
|
|
// ownership error is therefore encoded in the iterator response instead of the status.
|
|
expect(await response.text(), procedure).toMatch(/error|forbidden|not.found|unauthorized/i);
|
|
return;
|
|
}
|
|
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
interface Actor {
|
|
userId: string;
|
|
spaceId: string;
|
|
}
|
|
|
|
interface ModelCredential {
|
|
id: string;
|
|
provider: string;
|
|
label: string;
|
|
hasKey: boolean;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
interface Bot {
|
|
id: string;
|
|
}
|
|
|
|
function exhaustiveProtectedCalls<
|
|
const Calls extends ReadonlyArray<readonly [ProtectedRpcPath, unknown?]>,
|
|
>(calls: Calls & (ProtectedRpcPath extends Calls[number][0] ? unknown : never)): Calls {
|
|
return calls;
|
|
}
|