Run computers with resources and health checks, add settings for bots, workspaces, computers and users
- Computers get CPUs and memory (default 1 CPU, 2 GB), set at creation and editable; new resources stop, remove and recreate the container, its home dir kept. Every computer container has a docker health check and stays "creating" until it passes; only healthy computers are reached, by hostname, else by their IP on the network (dev outside docker). - Docker is required: the app creates the cobble network on startup and exits without docker. - Production container: deploy/run-app-container.sh mounts .env, the data dir and the docker socket; the database (DB_DIR), media, computers' homes and bun-sqlite's live schema now live on the data volume. - Settings pages for bots, workspaces, computers and users, sharing list components; bots can be deleted; users are created with a password and confirmation, edited and deleted through RBAC-gated APIs. - User types trimmed to super_admin, admin, member, read_only and revoked; admins manage members. UserAvatar shows profile photos from the user row. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -60,8 +60,8 @@ describe("checkUserAccess", () => {
|
||||
test("includes match with OR semantics", () => {
|
||||
expect(
|
||||
checkUserAccess({
|
||||
user_types: makeUserTypes("president"),
|
||||
includes: ["admin", "president"],
|
||||
user_types: makeUserTypes("member"),
|
||||
includes: ["admin", "member"],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserBot from "./grab-user-bot";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes one of the user's worker bots with its conversations, tasks,
|
||||
* jobs, models and workspace places. A job it's running stops: it loses
|
||||
* its row. The coordinator can't be deleted.
|
||||
*/
|
||||
export default async function deleteBot({ user_id, bot_id }: Params) {
|
||||
const bot = await grabUserBot({ bot_id, user_id });
|
||||
|
||||
if (!bot?.id) {
|
||||
throw new AppError(`Bot not found.`);
|
||||
}
|
||||
|
||||
if (bot.kind == "coordinator") {
|
||||
throw new AppError(`The coordinator can't be deleted.`);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't delete the bot: ${res.error}`);
|
||||
}
|
||||
|
||||
if (bot.image_media_id) {
|
||||
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
@@ -17,6 +17,15 @@ type Params = {
|
||||
* `main` only for the owner's one main computer (`ensureMainComputer`).
|
||||
*/
|
||||
kind?: ComputerKind;
|
||||
/**
|
||||
* CPU cores its container may use. Defaults to `AppData.ComputerDefaultCpus`.
|
||||
*/
|
||||
cpus?: number;
|
||||
/**
|
||||
* Its container's memory limit in MB. Defaults to
|
||||
* `AppData.ComputerDefaultMemoryMb`.
|
||||
*/
|
||||
memory_mb?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,7 +33,8 @@ type Params = {
|
||||
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
|
||||
* in the background (`provisionComputer`). Returns the computer at once,
|
||||
* still `creating`, and the setup to await when the caller must (a CLI
|
||||
* script). The main computer of a server that reaches it at
|
||||
* script). Its resources (`cpus`, `memory_mb`) are saved on the computer
|
||||
* and applied to its container whenever it is set up. The main computer of a server that reaches it at
|
||||
* `MAIN_COMPUTER_URL` (a dev server) gets no container of its own.
|
||||
*/
|
||||
export default async function createComputer({
|
||||
@@ -32,6 +42,8 @@ export default async function createComputer({
|
||||
name,
|
||||
description,
|
||||
kind = "sandbox",
|
||||
cpus = AppData.ComputerDefaultCpus,
|
||||
memory_mb = AppData.ComputerDefaultMemoryMb,
|
||||
}: Params) {
|
||||
const external = kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
|
||||
|
||||
@@ -70,6 +82,8 @@ export default async function createComputer({
|
||||
name,
|
||||
description: description || undefined,
|
||||
kind,
|
||||
cpus,
|
||||
memory_mb,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -3,30 +3,39 @@ import type {
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||
} from "@/db/types/db";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ComputerMcpPath } from "@/src/dict/computer-dict";
|
||||
import { ComputerMcpPath, type ContainerStatus } from "@/src/dict/computer-dict";
|
||||
import grabEnv from "../envs/grab-env";
|
||||
import pickReachableAddress from "./pick-reachable-address";
|
||||
import refreshComputerContainer from "./refresh-computer-container";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
};
|
||||
|
||||
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "id" | "name" | "kind"> &
|
||||
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "hostname" | "port" | "metadata">;
|
||||
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "kind"> &
|
||||
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "hostname" | "ip_address" | "port">;
|
||||
|
||||
export type ComputerAddress =
|
||||
| { ready: true; url: string; base_url: string; name: string }
|
||||
| { ready: false; status: ContainerStatus; name: string };
|
||||
|
||||
/**
|
||||
* Where a computer's MCP server answers. In order: the container's
|
||||
* `metadata.mcp_url`, `MAIN_COMPUTER_URL` for the main computer (an app
|
||||
* setting, `grabEnv`: for a server outside docker, e.g.
|
||||
* `http://localhost:8170`), else its docker hostname and port. `base_url`
|
||||
* is the same address without `/mcp`, for the sandbox's REST API. Null
|
||||
* when the computer has no container.
|
||||
* Where a computer's MCP server answers: `MAIN_COMPUTER_URL` for the
|
||||
* main computer (an app setting, `grabEnv`: for a server outside docker,
|
||||
* e.g. `http://localhost:8170`), else its docker hostname and port, or
|
||||
* its IP address on the network when the hostname can't be reached (a
|
||||
* dev server outside docker, `pickReachableAddress`). `base_url` is the
|
||||
* same address without `/mcp`, for the sandbox's REST API. Only a
|
||||
* computer whose health check passed (`running`) is reached; any other
|
||||
* state is read again from docker first, since it may have come back
|
||||
* since the last sync, and returned as `ready: false`. Null when the
|
||||
* computer has no container.
|
||||
*/
|
||||
export default async function grabComputerMcpUrl({ computer_id }: Params) {
|
||||
export default async function grabComputerMcpUrl({ computer_id }: Params): Promise<ComputerAddress | null> {
|
||||
const row = (
|
||||
await BunSQLite.sql<ComputerRow>({
|
||||
sql: `SELECT co.id, co.name, co.kind, c.hostname, c.port, c.metadata
|
||||
sql: `SELECT co.name, co.kind, c.id, c.docker_id, c.status, c.hostname, c.ip_address, c.port
|
||||
FROM computers co JOIN containers c ON c.id = co.container_id
|
||||
WHERE co.id = ? LIMIT 1`,
|
||||
values: [computer_id],
|
||||
@@ -35,23 +44,37 @@ export default async function grabComputerMcpUrl({ computer_id }: Params) {
|
||||
|
||||
if (!row?.hostname) return null;
|
||||
|
||||
const metadata = parseJobJson({ text: row.metadata }) || {};
|
||||
|
||||
if (typeof metadata.mcp_url == "string" && metadata.mcp_url) {
|
||||
return {
|
||||
url: metadata.mcp_url,
|
||||
base_url: metadata.mcp_url.replace(/\/mcp\/?$/, ""),
|
||||
name: row.name || "",
|
||||
};
|
||||
}
|
||||
const name = row.name || "";
|
||||
|
||||
const main_override = row.kind == "main"
|
||||
? (await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()
|
||||
: undefined;
|
||||
|
||||
const base = main_override
|
||||
? main_override.replace(/\/+$/, "")
|
||||
: `http://${row.hostname}:${row.port || AppData.ComputerPort}`;
|
||||
if (main_override) {
|
||||
const base_url = main_override.replace(/\/+$/, "");
|
||||
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
|
||||
}
|
||||
|
||||
return { url: `${base}${ComputerMcpPath}`, base_url: base, name: row.name || "" };
|
||||
let status = (row.status || "unknown") as ContainerStatus;
|
||||
let ip_address: string | null | undefined = row.ip_address;
|
||||
|
||||
if (status != "running" && status != "creating" && status != "failed") {
|
||||
const refreshed = await refreshComputerContainer({ computer_id, container: row });
|
||||
|
||||
if (refreshed) ({ status, ip_address } = refreshed);
|
||||
}
|
||||
|
||||
if (status != "running") return { ready: false, status, name };
|
||||
|
||||
const port = row.port || AppData.ComputerPort;
|
||||
|
||||
const base_url = await pickReachableAddress({
|
||||
key: `computer:${computer_id}`,
|
||||
candidates: [
|
||||
`http://${row.hostname}:${port}`,
|
||||
...(ip_address ? [`http://${ip_address}:${port}`] : []),
|
||||
],
|
||||
});
|
||||
|
||||
return { ready: true, url: `${base_url}${ComputerMcpPath}`, base_url, name };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import pickReachableAddress from "./pick-reachable-address";
|
||||
|
||||
const server = Bun.serve({ port: 0, fetch: () => new Response("", { status: 404 }) });
|
||||
const reachable = `http://127.0.0.1:${server.port}`;
|
||||
const unreachable = "http://cobble-computer-test.invalid:8080";
|
||||
|
||||
afterAll(() => server.stop(true));
|
||||
|
||||
describe("pickReachableAddress", () => {
|
||||
test("falls back to the next address when the first can't be reached", async () => {
|
||||
expect(await pickReachableAddress({ key: "a", candidates: [unreachable, reachable] })).toBe(reachable);
|
||||
});
|
||||
|
||||
test("keeps the first address when it answers, whatever the status", async () => {
|
||||
expect(await pickReachableAddress({ key: "b", candidates: [reachable, unreachable] })).toBe(reachable);
|
||||
});
|
||||
|
||||
test("returns the first address when none answers", async () => {
|
||||
expect(await pickReachableAddress({ key: "c", candidates: [unreachable, "http://127.0.0.1:1"] })).toBe(unreachable);
|
||||
});
|
||||
|
||||
test("returns a lone address without trying it", async () => {
|
||||
expect(await pickReachableAddress({ key: "d", candidates: [unreachable] })).toBe(unreachable);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Caches the pick, e.g. `computer:<id>`.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Base URLs in the order to try, e.g. the hostname, then the IP address.
|
||||
*/
|
||||
candidates: [string, ...string[]];
|
||||
};
|
||||
|
||||
const picked = new Map<string, { base_url: string; at: number }>();
|
||||
|
||||
/**
|
||||
* The first of `candidates` that answers over HTTP (any status counts),
|
||||
* each given `AppData.ComputerReachTimeoutMs`. The pick is reused for
|
||||
* `AppData.ComputerAddressCacheMs` while it is still a candidate. When
|
||||
* none answers, the first, so the caller's own error names it.
|
||||
*/
|
||||
export default async function pickReachableAddress({ key, candidates }: Params) {
|
||||
if (candidates.length < 2) return candidates[0];
|
||||
|
||||
const cached = picked.get(key);
|
||||
|
||||
if (
|
||||
cached &&
|
||||
Date.now() - cached.at < AppData.ComputerAddressCacheMs &&
|
||||
candidates.includes(cached.base_url)
|
||||
) {
|
||||
return cached.base_url;
|
||||
}
|
||||
|
||||
for (const base_url of candidates) {
|
||||
const reached = await fetch(base_url, {
|
||||
method: "HEAD",
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(AppData.ComputerReachTimeoutMs),
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (reached) {
|
||||
picked.set(key, { base_url, at: Date.now() });
|
||||
return base_url;
|
||||
}
|
||||
}
|
||||
|
||||
picked.delete(key);
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import createComputerContainer from "@/src/functions/backend/docker/create-computer-container";
|
||||
@@ -17,15 +17,18 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Sets up a computer's container: prepares its home directory, pulls the
|
||||
* sandbox image and starts the container, then records it (`containers`:
|
||||
* docker id, state, and where to reach it outside docker). A failure is
|
||||
* sandbox image and starts the container with the computer's resources
|
||||
* (`cpus`, `memory_mb`, else the `AppData` defaults), then records it (`containers`:
|
||||
* docker id, state, and its IP address on the network). A failure is
|
||||
* recorded as `failed` with its reason, which the computer lists show. The
|
||||
* owner's tabs hear about it either way. Never throws.
|
||||
*/
|
||||
export default async function provisionComputer({ computer_id, fresh }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
|
||||
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
|
||||
await BunSQLite.sql<
|
||||
BUN_SQLITE_COBBLE_CONTAINERS & Pick<BUN_SQLITE_COBBLE_COMPUTERS, "cpus" | "memory_mb">
|
||||
>({
|
||||
sql: `SELECT co.*, c.cpus, c.memory_mb FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
|
||||
values: [computer_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
@@ -41,26 +44,28 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
|
||||
});
|
||||
|
||||
const data_dir = await prepareComputerDataDir({ computer_id, fresh });
|
||||
const created = await createComputerContainer({ computer_id, name: row.name, data_dir });
|
||||
const created = await createComputerContainer({
|
||||
computer_id,
|
||||
name: row.name,
|
||||
data_dir,
|
||||
cpus: Number(row.cpus) || AppData.ComputerDefaultCpus,
|
||||
memory_mb: Number(row.memory_mb) || AppData.ComputerDefaultMemoryMb,
|
||||
});
|
||||
|
||||
const { error: _error, mcp_url: _mcp_url, ...kept } = metadata;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, port = ?,
|
||||
status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
|
||||
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, ip_address = ?,
|
||||
port = ?, status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [
|
||||
created.docker_id,
|
||||
AppData.ComputerImage,
|
||||
created.network,
|
||||
row.name,
|
||||
created.ip_address,
|
||||
AppData.ComputerPort,
|
||||
created.status,
|
||||
JSON.stringify({
|
||||
...kept,
|
||||
data_dir,
|
||||
host_dir: created.host_dir,
|
||||
...(created.mcp_url ? { mcp_url: created.mcp_url } : {}),
|
||||
}),
|
||||
JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }),
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
row.id,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import type { ContainerStatus } from "@/src/dict/computer-dict";
|
||||
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
|
||||
import pushComputerUpdate from "./push-computer-update";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
container: Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status" | "ip_address">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a computer container's state from docker and records it
|
||||
* (`status`, `ip_address`, which can change when it restarts,
|
||||
* `last_seen_at`), telling the owner when its state changed. A container
|
||||
* docker no longer has is `dead`. Returns the state recorded, or null
|
||||
* when docker couldn't be asked.
|
||||
*/
|
||||
export default async function refreshComputerContainer({ computer_id, container }: Params) {
|
||||
if (!container.id || !container.docker_id) return null;
|
||||
|
||||
const state = await inspectDockerContainer({ container: container.docker_id }).catch(() => undefined);
|
||||
|
||||
if (state === undefined) return null;
|
||||
|
||||
const status: ContainerStatus = state?.status || "dead";
|
||||
const ip_address = state?.ip_address || container.ip_address || null;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE containers SET status = ?, ip_address = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [status, ip_address, Date.now(), Date.now(), container.id],
|
||||
});
|
||||
|
||||
const changed = status != container.status;
|
||||
|
||||
if (changed) await pushComputerUpdate({ computer_id }).catch(() => {});
|
||||
|
||||
return { status, ip_address, changed };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import _ from "lodash";
|
||||
import grabEnv from "../envs/grab-env";
|
||||
import grabUserComputers from "./grab-user-computers";
|
||||
import grabUserComputer from "./grab-user-computer";
|
||||
import provisionComputer from "./provision-computer";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
computer_id: number | string;
|
||||
/**
|
||||
* Only the fields given are changed.
|
||||
*/
|
||||
data: Pick<BUN_SQLITE_COBBLE_COMPUTERS, "name" | "description" | "cpus" | "memory_mb">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates one of the user's computers. When its container's settings
|
||||
* change (`cpus`, `memory_mb`), the container is stopped, removed and
|
||||
* run again with them in the background (`provisionComputer`); its home
|
||||
* directory is bind-mounted, so its files stay. A name or description
|
||||
* alone leaves the container running. The main computer of a server that
|
||||
* reaches it at `MAIN_COMPUTER_URL` has no container to recreate.
|
||||
*/
|
||||
export default async function updateComputer({ user_id, computer_id, data }: Params) {
|
||||
const computer = await grabUserComputer({ computer_id, user_id });
|
||||
|
||||
if (!computer?.id) {
|
||||
throw new AppError(`Computer not found.`);
|
||||
}
|
||||
|
||||
const recreate =
|
||||
(data.cpus !== undefined && Number(data.cpus) != Number(computer.cpus)) ||
|
||||
(data.memory_mb !== undefined && Number(data.memory_mb) != Number(computer.memory_mb));
|
||||
|
||||
if (recreate && (await grabUserComputers({ computer_id: computer.id }))[0]?.status == "creating") {
|
||||
throw new AppError(`${computer.name || "The computer"} is still being set up. Try again once it's ready.`);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.update<
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "computers",
|
||||
targetId: computer.id,
|
||||
/**
|
||||
* `BunSQLite.update` writes every key it gets, so a field left out
|
||||
* here would be set to null.
|
||||
*/
|
||||
data: _.omitBy(
|
||||
{
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
cpus: data.cpus,
|
||||
memory_mb: data.memory_mb,
|
||||
},
|
||||
_.isUndefined,
|
||||
),
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't update the computer: ${res.msg}`);
|
||||
}
|
||||
|
||||
const external =
|
||||
computer.kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
|
||||
|
||||
const recreating = recreate && !external && Boolean(computer.container_id);
|
||||
|
||||
const provisioning = recreating
|
||||
? provisionComputer({ computer_id: Number(computer.id) })
|
||||
: Promise.resolve();
|
||||
|
||||
return { recreating, provisioning };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||
import removeDockerContainer from "@/src/functions/backend/docker/remove-docker-container";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
||||
import grabUsers from "./grab-users";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Who is deleting: never themselves.
|
||||
*/
|
||||
actor_user_id: number | string;
|
||||
/**
|
||||
* Whoever may revoke a user may delete them (`canRevokeUser`).
|
||||
*/
|
||||
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a user with everything that's theirs (bots, workspaces,
|
||||
* conversations, computers, user types, password, media and its files):
|
||||
* their computers' containers are removed first, their home directories
|
||||
* stay on disk. Their audit log entries stay too. The super admin can't be
|
||||
* deleted, and nobody deletes themselves.
|
||||
*/
|
||||
export default async function deleteUser({ actor_user_id, actor_user_types, user_id }: Params) {
|
||||
if (String(actor_user_id) == String(user_id)) {
|
||||
throw new AppError(`You can't delete yourself.`);
|
||||
}
|
||||
|
||||
const user = (await grabUsers({ user_id })).singleRes;
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`User not found.`);
|
||||
}
|
||||
|
||||
if (parseUserTypes({ user_types: user.user_types }).includes("super_admin")) {
|
||||
throw new AppError(`The super admin can't be deleted.`);
|
||||
}
|
||||
|
||||
const target_user_types = parseUserTypes({ user_types: user.user_types }).map((user_type) => ({ user_type }));
|
||||
|
||||
if (!canRevokeUser({ user_types: actor_user_types, target_user_types })) {
|
||||
throw new AppError(`You can't delete ${user.first_name || "this user"}.`);
|
||||
}
|
||||
|
||||
const containers =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
|
||||
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.user_id = ?`,
|
||||
values: [user.id],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
for (const container of containers) {
|
||||
if (container.docker_id || container.name) {
|
||||
await removeDockerContainer({ container: container.docker_id || container.name! });
|
||||
}
|
||||
}
|
||||
|
||||
const media_ids =
|
||||
(
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `SELECT id FROM media WHERE user_id = ?`,
|
||||
values: [user.id],
|
||||
})
|
||||
).payload?.map((media) => media.id) || [];
|
||||
|
||||
if (media_ids.length) {
|
||||
await deleteMedia({ ids: media_ids, user_id: user.id });
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql({ sql: `DELETE FROM users WHERE id = ?`, values: [user.id] });
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't delete the user: ${res.error}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* These point at the user without a foreign key, so nothing cascades.
|
||||
*/
|
||||
for (const table of ["user_types", "user_passwords", "sso_login_codes"]) {
|
||||
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
|
||||
}
|
||||
|
||||
if (containers.length) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM containers WHERE id IN (${containers.map(() => "?").join(", ")})`,
|
||||
values: containers.map((container) => container.id!),
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -29,21 +29,22 @@ export default async function grabUsers(
|
||||
sql += `SELECT\n`;
|
||||
sql += ` u.*,\n`;
|
||||
sql += ` ut_agg.user_types,\n`;
|
||||
sql += ` m.id AS profile_media_id\n`;
|
||||
/**
|
||||
* Their profile photo only: other primary media (a bot's picture)
|
||||
* must not stand in for it.
|
||||
*/
|
||||
sql += ` (\n`;
|
||||
sql += ` SELECT m.id FROM media m\n`;
|
||||
sql += ` JOIN media_paradigms mp ON mp.media_id = m.id AND mp.media_paradigm = 'user-profile-image'\n`;
|
||||
sql += ` WHERE m.user_id = u.id AND m.is_primary = 1\n`;
|
||||
sql += ` ORDER BY m.id DESC LIMIT 1\n`;
|
||||
sql += ` ) AS profile_media_id\n`;
|
||||
sql += `FROM users u\n`;
|
||||
sql += `LEFT JOIN (\n`;
|
||||
sql += ` SELECT user_id, GROUP_CONCAT(DISTINCT user_type) AS user_types\n`;
|
||||
sql += ` FROM user_types\n`;
|
||||
sql += ` GROUP BY user_id\n`;
|
||||
sql += `) ut_agg ON u.id = ut_agg.user_id\n`;
|
||||
sql += `LEFT JOIN media m ON (\n`;
|
||||
sql += ` u.id = m.user_id\n`;
|
||||
sql += ` AND m.is_primary = 1\n`;
|
||||
sql += `)\n`;
|
||||
sql += `LEFT JOIN media_paradigms mp ON (\n`;
|
||||
sql += ` m.id = mp.media_id\n`;
|
||||
sql += ` AND mp.media_paradigm = 'user-profile-image'\n`;
|
||||
sql += `)\n`;
|
||||
sql += `WHERE COALESCE(u.archived, 0) <> 1\n`;
|
||||
|
||||
const target_user_id = params?.user_id || query?.user_id;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_USERS,
|
||||
BUN_SQLITE_COBBLE_USER_TYPES,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { UneditableUserTypes, UserTypes } from "@/src/dict/user-types-dict";
|
||||
import setUserPassword from "@/src/functions/backend/auth/set-user-password";
|
||||
import type { SaveUserRequest } from "@/src/schemas/user-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import userTypeAssignment from "@/src/utils/rbac/user-type-assignment";
|
||||
import validatePassword from "@/src/utils/validate-password";
|
||||
import grabUsers from "./grab-users";
|
||||
|
||||
type Params = SaveUserRequest & {
|
||||
/**
|
||||
* The user types of whoever is saving: what they may grant or remove
|
||||
* is the assignment matrix's call (`userTypeAssignment`).
|
||||
*/
|
||||
actor_user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a user (a password is required: login is password only) or
|
||||
* updates one, then sets their user types to `user_types`: each one
|
||||
* granted or removed must pass the assignment matrix, and uneditable ones
|
||||
* (`super_admin`) stay as they are. The super admin's types are never
|
||||
* changed here, and they can't be revoked. A user needs at least one type to sign
|
||||
* in. Email and username stay unique. Returns the user's id and whether
|
||||
* they're new.
|
||||
*/
|
||||
export default async function saveUser({
|
||||
actor_user_types,
|
||||
user_id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
user_types,
|
||||
}: Params) {
|
||||
email = email.trim().toLowerCase();
|
||||
username = username.trim().toLowerCase();
|
||||
|
||||
if (!AppData["UsernameRegex"].test(username)) {
|
||||
throw new AppError(`Usernames must be 3-32 characters: lowercase letters, numbers, dots, dashes or underscores`);
|
||||
}
|
||||
|
||||
if (password || !user_id) {
|
||||
const password_error = validatePassword({ password });
|
||||
if (password_error) throw new AppError(password_error);
|
||||
}
|
||||
|
||||
const existing = user_id ? (await grabUsers({ user_id })).singleRes : null;
|
||||
|
||||
if (user_id && !existing?.id) {
|
||||
throw new AppError(`User not found.`);
|
||||
}
|
||||
|
||||
const current = parseUserTypes({ user_types: existing?.user_types });
|
||||
const is_super_admin = current.includes("super_admin");
|
||||
|
||||
if (is_super_admin && user_types.includes("revoked")) {
|
||||
throw new AppError(`The super admin can't be revoked.`);
|
||||
}
|
||||
|
||||
const kept = is_super_admin ? current : current.filter((type) => UneditableUserTypes.includes(type));
|
||||
const wanted = is_super_admin
|
||||
? []
|
||||
: [...new Set(user_types)].filter((type) => !UneditableUserTypes.includes(type));
|
||||
const added = wanted.filter((type) => !current.includes(type));
|
||||
const removed = current.filter((type) => !kept.includes(type) && !wanted.includes(type));
|
||||
|
||||
if (!kept.length && !wanted.length) {
|
||||
throw new AppError(`Give them at least one user type, or they can't sign in.`);
|
||||
}
|
||||
|
||||
for (const [types, action] of [[added, "assign"], [removed, "delete"]] as const) {
|
||||
for (const target_user_type of types) {
|
||||
if (!userTypeAssignment({ actor_user_types, target_user_type, target_user_types: current, action })) {
|
||||
const title = UserTypes.find((type) => type.value == target_user_type)?.title || target_user_type;
|
||||
throw new AppError(`You can't ${action == "assign" ? "grant" : "remove"} ${title}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const taken = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_USERS>({
|
||||
sql: `SELECT id, email, username FROM users WHERE (email = ? OR username = ?) AND id != ? LIMIT 1`,
|
||||
values: [email, username, user_id || 0],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (taken) {
|
||||
throw new AppError(taken.email == email ? `That email is already in use.` : `That username is taken.`);
|
||||
}
|
||||
|
||||
let target_id = Number(user_id || 0);
|
||||
|
||||
if (existing?.id) {
|
||||
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_USERS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "users",
|
||||
targetId: existing.id,
|
||||
data: { first_name, last_name, email, username },
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't update the user: ${res.msg}`);
|
||||
} else {
|
||||
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_USERS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "users",
|
||||
data: [{ first_name, last_name, email, username }],
|
||||
});
|
||||
|
||||
target_id = Number(res.singleRes?.id || res.postInsertReturn?.insertId || 0);
|
||||
|
||||
if (!res.success || !target_id) throw new Error(`Couldn't create the user: ${res.msg}`);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
await setUserPassword({ user_id: target_id, password });
|
||||
}
|
||||
|
||||
if (removed.length) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM user_types WHERE user_id = ? AND user_type IN (${removed.map(() => "?").join(", ")})`,
|
||||
values: [target_id, ...removed],
|
||||
});
|
||||
}
|
||||
|
||||
if (added.length) {
|
||||
await BunSQLite.insert<BUN_SQLITE_COBBLE_USER_TYPES, (typeof BunSQLiteTables)[number]>({
|
||||
table: "user_types",
|
||||
data: added.map((user_type) => ({ user_id: target_id, user_type })),
|
||||
});
|
||||
}
|
||||
|
||||
return { user_id: target_id, created: !existing?.id };
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ComputerMcpPath } from "@/src/dict/computer-dict";
|
||||
import dockerRequest from "./docker-request";
|
||||
import ensureDockerNetwork from "./ensure-docker-network";
|
||||
import grabAppContainer from "./grab-app-container";
|
||||
import grabFreePort from "./grab-free-port";
|
||||
import inspectDockerContainer from "./inspect-docker-container";
|
||||
|
||||
import pullDockerImage from "./pull-docker-image";
|
||||
import removeDockerContainer from "./remove-docker-container";
|
||||
import resolveHostPath from "./resolve-host-path";
|
||||
import waitForHealthyContainer from "./wait-for-healthy-container";
|
||||
|
||||
/**
|
||||
* Docker takes health check durations in nanoseconds.
|
||||
*/
|
||||
const seconds = (value: number) => value * 1e9;
|
||||
|
||||
type Params = {
|
||||
computer_id: number;
|
||||
@@ -19,22 +22,36 @@ type Params = {
|
||||
* The computer's home directory as this app sees it.
|
||||
*/
|
||||
data_dir: string;
|
||||
/**
|
||||
* CPU cores the container may use.
|
||||
*/
|
||||
cpus: number;
|
||||
/**
|
||||
* The container's memory limit in MB.
|
||||
*/
|
||||
memory_mb: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and starts an AIO sandbox container for a computer
|
||||
* (`AppData.ComputerImage`) on the computers' network, with its home
|
||||
* directory bind-mounted from `data_dir` and restarted with docker. A
|
||||
* container left by an earlier try is replaced. When the app runs outside
|
||||
* docker, the sandbox is also published on a localhost port, returned as
|
||||
* `mcp_url`'s host.
|
||||
* Creates and starts (the API's `docker run`) an AIO sandbox container
|
||||
* for a computer (`AppData.ComputerImage`), attached to the computers'
|
||||
* network (created first when missing), with its home directory
|
||||
* bind-mounted from `data_dir`, limited to `cpus` and `memory_mb`, with a
|
||||
* health check (`AppData.ComputerHealthcheck`), and restarted with
|
||||
* docker. A container left by an earlier try is stopped and replaced.
|
||||
* Resolves once the health check passes, with its IP address on the network, which a dev
|
||||
* server outside docker reaches it at, since it can't resolve the
|
||||
* hostname.
|
||||
*/
|
||||
export default async function createComputerContainer({ computer_id, name, data_dir }: Params) {
|
||||
export default async function createComputerContainer({
|
||||
computer_id,
|
||||
name,
|
||||
data_dir,
|
||||
cpus,
|
||||
memory_mb,
|
||||
}: Params) {
|
||||
const network = await ensureDockerNetwork();
|
||||
const in_docker = Boolean(await grabAppContainer());
|
||||
const host_dir = await resolveHostPath({ local_path: data_dir });
|
||||
const port_key = `${AppData.ComputerPort}/tcp`;
|
||||
const host_port = in_docker ? undefined : grabFreePort();
|
||||
|
||||
await pullDockerImage({ image: AppData.ComputerImage });
|
||||
await removeDockerContainer({ container: name });
|
||||
@@ -49,18 +66,25 @@ export default async function createComputerContainer({ computer_id, name, data_
|
||||
Labels: {
|
||||
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
|
||||
},
|
||||
ExposedPorts: { [port_key]: {} },
|
||||
ExposedPorts: { [`${AppData.ComputerPort}/tcp`]: {} },
|
||||
Healthcheck: {
|
||||
Test: [
|
||||
"CMD-SHELL",
|
||||
`curl -fs -o /dev/null http://localhost:${AppData.ComputerPort}/v1/sandbox && ([ "$DISABLE_BROWSER" = "true" ] || nc -z localhost "$BROWSER_REMOTE_DEBUGGING_PORT") || exit 1`,
|
||||
],
|
||||
Interval: seconds(AppData.ComputerHealthcheck.IntervalSeconds),
|
||||
Timeout: seconds(AppData.ComputerHealthcheck.TimeoutSeconds),
|
||||
StartPeriod: seconds(AppData.ComputerHealthcheck.StartPeriodSeconds),
|
||||
Retries: AppData.ComputerHealthcheck.Retries,
|
||||
},
|
||||
HostConfig: {
|
||||
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
|
||||
NetworkMode: network,
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
SecurityOpt: ["seccomp=unconfined"],
|
||||
ShmSize: AppData.ComputerShmBytes,
|
||||
Memory: AppData.ComputerMemoryBytes,
|
||||
NanoCpus: AppData.ComputerCpus * 1e9,
|
||||
PortBindings: host_port
|
||||
? { [port_key]: [{ HostIp: "127.0.0.1", HostPort: String(host_port) }] }
|
||||
: undefined,
|
||||
Memory: memory_mb * 1024 * 1024,
|
||||
NanoCpus: Math.round(cpus * 1e9),
|
||||
},
|
||||
NetworkingConfig: {
|
||||
EndpointsConfig: { [network]: { Aliases: [name] } },
|
||||
@@ -81,13 +105,13 @@ export default async function createComputerContainer({ computer_id, name, data_
|
||||
throw new Error(`Couldn't start the container: ${started.message}`);
|
||||
}
|
||||
|
||||
const state = await inspectDockerContainer({ container: created.data.Id });
|
||||
const state = await waitForHealthyContainer({ container: created.data.Id });
|
||||
|
||||
return {
|
||||
docker_id: created.data.Id,
|
||||
network,
|
||||
status: state?.status || "unknown",
|
||||
status: state.status,
|
||||
host_dir,
|
||||
mcp_url: host_port ? `http://127.0.0.1:${host_port}${ComputerMcpPath}` : undefined,
|
||||
ip_address: state.ip_address,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* A port nothing on this host listens on right now, for publishing a
|
||||
* computer when the app runs outside docker and can't reach it by name.
|
||||
*/
|
||||
export default function grabFreePort() {
|
||||
const listener = Bun.listen({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
socket: { data() {} },
|
||||
});
|
||||
const port = listener.port;
|
||||
|
||||
listener.stop(true);
|
||||
|
||||
return port;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ContainerStatus } from "@/src/dict/computer-dict";
|
||||
import dockerRequest from "./docker-request";
|
||||
|
||||
@@ -10,11 +11,19 @@ type Params = {
|
||||
|
||||
export type DockerContainerState = {
|
||||
id: string;
|
||||
/**
|
||||
* `State.Status`, with `running` narrowed by the health check
|
||||
* (`starting`, `unhealthy`). A container without one is `running`.
|
||||
*/
|
||||
status: ContainerStatus;
|
||||
/**
|
||||
* Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`.
|
||||
*/
|
||||
ports: Record<string, string>;
|
||||
/**
|
||||
* Its IP address on `AppData.DockerNetworkName`, when it's on it and running.
|
||||
*/
|
||||
ip_address: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -25,8 +34,14 @@ export default async function inspectDockerContainer({
|
||||
}: Params): Promise<DockerContainerState | null> {
|
||||
const res = await dockerRequest<{
|
||||
Id: string;
|
||||
State?: { Status?: ContainerStatus };
|
||||
NetworkSettings?: { Ports?: Record<string, { HostPort?: string }[] | null> };
|
||||
State?: {
|
||||
Status?: ContainerStatus;
|
||||
Health?: { Status?: "starting" | "healthy" | "unhealthy" | "none" };
|
||||
};
|
||||
NetworkSettings?: {
|
||||
Ports?: Record<string, { HostPort?: string }[] | null>;
|
||||
Networks?: Record<string, { IPAddress?: string }>;
|
||||
};
|
||||
}>({ path: `/containers/${encodeURIComponent(container)}/json` });
|
||||
|
||||
if (res.status == 404) return null;
|
||||
@@ -35,13 +50,20 @@ export default async function inspectDockerContainer({
|
||||
throw new Error(`Couldn't inspect ${container}: ${res.message}`);
|
||||
}
|
||||
|
||||
const status = res.data.State?.Status || "unknown";
|
||||
const health = res.data.State?.Health?.Status;
|
||||
|
||||
return {
|
||||
id: res.data.Id,
|
||||
status: res.data.State?.Status || "unknown",
|
||||
status:
|
||||
status == "running" && (health == "starting" || health == "unhealthy")
|
||||
? health
|
||||
: status,
|
||||
ports: Object.fromEntries(
|
||||
Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) =>
|
||||
bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [],
|
||||
),
|
||||
),
|
||||
ip_address: res.data.NetworkSettings?.Networks?.[AppData.DockerNetworkName]?.IPAddress || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import dockerRequest from "./docker-request";
|
||||
|
||||
type Params = {
|
||||
@@ -8,10 +9,17 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops and removes a container. Its bind-mounted files stay on the host.
|
||||
* A container docker doesn't have is already gone.
|
||||
* Stops a container (killed after `AppData.ComputerStopTimeoutSeconds`),
|
||||
* then removes it. Its bind-mounted files stay on the host. A container
|
||||
* docker doesn't have is already gone.
|
||||
*/
|
||||
export default async function removeDockerContainer({ container }: Params) {
|
||||
await dockerRequest({
|
||||
method: "POST",
|
||||
path: `/containers/${encodeURIComponent(container)}/stop`,
|
||||
query: { t: AppData.ComputerStopTimeoutSeconds },
|
||||
});
|
||||
|
||||
const res = await dockerRequest({
|
||||
method: "DELETE",
|
||||
path: `/containers/${encodeURIComponent(container)}`,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import inspectDockerContainer from "./inspect-docker-container";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The container's id or name.
|
||||
*/
|
||||
container: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits until a container's health check passes (`running`), checking
|
||||
* every `AppData.ComputerHealthPollMs` for at most
|
||||
* `AppData.ComputerHealthyTimeoutMs`, and returns its state. Throws when
|
||||
* it turns unhealthy, stops, or takes too long.
|
||||
*/
|
||||
export default async function waitForHealthyContainer({ container }: Params) {
|
||||
const deadline = Date.now() + AppData.ComputerHealthyTimeoutMs;
|
||||
|
||||
while (true) {
|
||||
const state = await inspectDockerContainer({ container });
|
||||
|
||||
if (state?.status == "running") return state;
|
||||
|
||||
if (!state || !["starting", "created", "restarting"].includes(state.status)) {
|
||||
throw new Error(`The container is ${state?.status || "gone"} instead of passing its health check.`);
|
||||
}
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`The container didn't pass its health check within ${Math.round(AppData.ComputerHealthyTimeoutMs / 60000)} minutes.`,
|
||||
);
|
||||
}
|
||||
|
||||
await Bun.sleep(AppData.ComputerHealthPollMs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user