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:
2026-10-19 10:26:44 +01:00
co-authored by Claude Opus 5.5
parent 0ce61cdb49
commit f24c0d7e82
91 changed files with 2692 additions and 331 deletions
@@ -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;
}
+10 -9
View File
@@ -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;
+140
View File
@@ -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);
}
}
@@ -1,6 +1,7 @@
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-computer";
import userAuth from "@/src/functions/backend/auth/user-auth";
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
import parseComputerProxyPath from "@/src/utils/parse-computer-proxy-path";
type Params = {
@@ -10,8 +11,8 @@ type Params = {
/**
* Where a `/computers/<id>/...` request goes: the computer's own address
* for that path, only for the computer's owner. An error response when
* the user isn't logged in, the computer isn't theirs, or it has no
* container.
* the user isn't logged in, the computer isn't theirs, it has no
* container, or it isn't ready.
*/
export default async function computerProxyTarget({ req }: Params) {
const url = new URL(req.url);
@@ -36,6 +37,10 @@ export default async function computerProxyTarget({ req }: Params) {
return { error: new Response("This computer has no container.", { status: 502 }) };
}
if (!address.ready) {
return { error: new Response(computerNotReadyMessage(address), { status: 503 }) };
}
return {
session,
url,