Keep every bot's computer work in its own workspace folder

Computers now mount only /home/gem/workspace from DATA_DIR/computers/<id>,
so only the workspace persists and goes into the data archive. An older
computer's whole home is moved aside to <id>-home-<ts> (kept, never
archived) and its container recreated once by sync-computers.

Each run works in bots/<bot>/users/<user> (also its cwd and HOME), with
bots/<bot> for work every user shares and workspaces/<id> shared by a
workspace's bots; the coordinator reaches the whole workspace. Computer
tools are gated by withComputerDirs: bash and code start there, file tool
paths and file: URLs outside the run's folders are refused, and so is sudo.
search_computers, import_file and script tools start there too.

The files browser shows members only the bots' folders they reach, their
own user folder in each and their workspaces', with names on the id folders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 08:24:49 +01:00
co-authored by Claude Opus 5.5
parent fefc295d15
commit 86becd50bc
41 changed files with 1012 additions and 105 deletions
@@ -37,6 +37,13 @@ export default async function writeDataBackupArchive(): Promise<CreatedDataBacku
// after it and an extract drops a single folder.
"--transform",
`s,^\\.$,${archive_root},;s,^\\./,${archive_root}/,`,
// A computer's old whole home, moved aside when only its
// workspace became the mount (`prepareComputerDataDir`),
// is kept on disk but never archived: only workspaces are.
// `*` stops at `/`, so a file inside a workspace whose name
// happens to hold the suffix is still archived.
"--no-wildcards-match-slash",
`--exclude=./${AppData.ComputerDataDir}/*${AppData.ComputerOldHomeSuffix}*`,
"-C",
DATA_DIR,
".",
@@ -0,0 +1,36 @@
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import logEvent from "@/src/functions/backend/logger";
import provisionComputer from "./provision-computer";
type Params = {
computer_id: number;
/**
* Its container's `metadata`.
*/
metadata?: string | null;
};
/**
* Recreates a computer whose container still mounts its whole home (set up
* before only `AppData.ComputerWorkspaceDir` was mounted), so its bots work
* in the workspace alone. `provisionComputer` moves the old home aside
* (`prepareComputerDataDir`) and records the layout, so this runs once per
* computer. Returns whether it did.
*/
export default async function migrateComputerLayout({ computer_id, metadata }: Params) {
const layout = parseJobJson({ text: metadata || "" })?.layout;
if (layout == AppData.ComputerWorkspaceLayout) return false;
await provisionComputer({ computer_id });
await logEvent({
title: `Computer #${computer_id} now mounts only its workspace`,
category: "system",
plain_text: `Recreated computer #${computer_id} so only ${AppData.ComputerWorkspaceDir} is kept; its old home was moved aside and kept on disk.`,
metadata: { computer_id },
});
return true;
}
@@ -1,4 +1,5 @@
import fs from "fs/promises";
import path from "path";
import { AppData } from "@/src/data/app-data";
import grabComputerDataDir from "./grab-computer-data-dir";
@@ -13,28 +14,45 @@ type Params = {
};
/**
* Creates a computer's home directory, owned by the sandbox's user so it
* can write there, and returns its path. Docker would otherwise create a
* missing mount source owned by root.
* Creates a computer's workspace directory (mounted at
* `AppData.ComputerWorkspaceDir`), with its `bots` and `workspaces`
* folders, owned by the sandbox's user so it can write there, and returns
* its path. Docker would otherwise create a missing mount source owned by
* root.
*
* A directory without the layout marker (`AppData.ComputerWorkspaceMarker`)
* is a computer's whole home from before only the workspace was mounted:
* it is moved aside to `<id>-home-<timestamp>` and kept on disk (left out
* of data backups), so the computer starts with a clean workspace.
*/
export default async function prepareComputerDataDir({ computer_id, fresh }: Params) {
const dir = grabComputerDataDir({ computer_id });
const marker = path.join(dir, AppData.ComputerWorkspaceMarker);
const existing = await fs.readdir(dir).catch(() => null);
if (fresh) {
const existing = await fs.readdir(dir).catch(() => null);
if (existing?.length) {
if (existing?.length) {
if (fresh) {
await fs.rename(dir, `${dir}-${Date.now()}`);
} else if (!(await fs.stat(marker).catch(() => null))) {
await fs.rename(dir, `${dir}${AppData.ComputerOldHomeSuffix}${Date.now()}`);
}
}
await fs.mkdir(dir, { recursive: true });
for (const folder of [dir, path.join(dir, "bots"), path.join(dir, "workspaces")]) {
await fs.mkdir(folder, { recursive: true });
await chownToSandbox({ target: folder });
}
/**
* Only root can hand a directory to another user; a dev server
* running as that user already owns it.
*/
await fs.chown(dir, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
await fs.writeFile(marker, "", { flag: "a" });
await chownToSandbox({ target: marker });
return dir;
}
/**
* Only root can hand a file to another user; a dev server running as that
* user already owns it.
*/
async function chownToSandbox({ target }: { target: string }) {
await fs.chown(target, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
}
@@ -10,18 +10,20 @@ import pushComputerUpdate from "./push-computer-update";
type Params = {
computer_id: number;
/**
* A brand new computer: start from an empty home directory.
* A brand new computer: start from an empty workspace.
*/
fresh?: boolean;
};
/**
* Sets up a computer's container: prepares its home directory, pulls the
* Sets up a computer's container: prepares its workspace directory, pulls the
* 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.
* owner's tabs hear about it either way. The metadata's `layout` says the
* container mounts only the workspace (`syncComputers` recreates one that
* doesn't). Never throws.
*/
export default async function provisionComputer({ computer_id, fresh }: Params) {
const row = (
@@ -65,7 +67,12 @@ export default async function provisionComputer({ computer_id, fresh }: Params)
created.ip_address,
AppData.ComputerPort,
created.status,
JSON.stringify({ ...kept, data_dir, host_dir: created.host_dir }),
JSON.stringify({
...kept,
data_dir,
host_dir: created.host_dir,
layout: AppData.ComputerWorkspaceLayout,
}),
Date.now(),
Date.now(),
row.id,
@@ -1,16 +1,16 @@
import { describe, expect, test } from "bun:test";
import resolveComputerHomeDir from "./resolve-computer-home-dir";
import resolveComputerWorkspaceDir from "./resolve-computer-workspace-dir";
const data_dir = "/data";
describe("resolveComputerHomeDir", () => {
describe("resolveComputerWorkspaceDir", () => {
test("uses the directory the app bind-mounted", () => {
/**
* The truth for a container the app provisioned: on a bind mount
* that can be anywhere on the docker host, not under DATA_DIR.
*/
expect(
resolveComputerHomeDir({
resolveComputerWorkspaceDir({
computer_id: 2,
kind: "sandbox",
metadata: JSON.stringify({ data_dir: "/srv/homes/2", host_dir: "/host/2" }),
@@ -22,7 +22,7 @@ describe("resolveComputerHomeDir", () => {
test("falls back to MAIN_COMPUTER_DATA_DIR for the main computer", () => {
expect(
resolveComputerHomeDir({
resolveComputerWorkspaceDir({
computer_id: 1,
kind: "main",
metadata: null,
@@ -34,7 +34,7 @@ describe("resolveComputerHomeDir", () => {
test("leaves a sandbox alone whatever the main setting says", () => {
expect(
resolveComputerHomeDir({
resolveComputerWorkspaceDir({
computer_id: 3,
kind: "sandbox",
metadata: null,
@@ -45,26 +45,26 @@ describe("resolveComputerHomeDir", () => {
});
test("uses the conventional folder when nothing is recorded", () => {
expect(resolveComputerHomeDir({ computer_id: 4, kind: "main", data_dir })).toBe(
expect(resolveComputerWorkspaceDir({ computer_id: 4, kind: "main", data_dir })).toBe(
"/data/computers/4",
);
});
test("ignores an empty or unreadable record", () => {
expect(
resolveComputerHomeDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }),
resolveComputerWorkspaceDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }),
).toBe("/data/computers/5");
expect(resolveComputerHomeDir({ computer_id: 5, metadata: "not json", data_dir })).toBe(
expect(resolveComputerWorkspaceDir({ computer_id: 5, metadata: "not json", data_dir })).toBe(
"/data/computers/5",
);
expect(resolveComputerHomeDir({ computer_id: 5, metadata: null, data_dir })).toBe(
expect(resolveComputerWorkspaceDir({ computer_id: 5, metadata: null, data_dir })).toBe(
"/data/computers/5",
);
});
test("normalizes a recorded relative path", () => {
expect(
resolveComputerHomeDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }),
resolveComputerWorkspaceDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }),
).toBe("/srv/b");
});
});
@@ -27,8 +27,9 @@ type Params = {
};
/**
* Where a computer's home (`AppData.ComputerHomeDir` in its container)
* actually is on this app's disk, for the files browser and for anything
* Where a computer's workspace (`AppData.ComputerWorkspaceDir` in its
* container, the only directory mounted from the data volume) actually is
* on this app's disk, for the files browser and for anything
* else that has to read a computer's files.
*
* A container the app provisioned recorded the directory it bind-mounted
@@ -36,15 +37,16 @@ type Params = {
* that path can be anywhere the docker host allows. A computer the app
* didn't provision has no record. That is the install's main computer on a
* server reached at `MAIN_COMPUTER_URL` (a dev server outside docker),
* whose sandbox keeps its home wherever its compose file says, so
* `MAIN_COMPUTER_DATA_DIR` names that directory for this app. Without
* whose sandbox keeps its workspace wherever its compose file says, so
* `MAIN_COMPUTER_DATA_DIR` names that directory (the host side of
* `AppData.ComputerWorkspaceDir`) for this app. Without
* either, the conventional place every app-provisioned computer uses:
* `DATA_DIR/<AppData.ComputerDataDir>/<id>`.
*
* Kept apart from the docker and database calls so the precedence is
* testable on its own.
*/
export default function resolveComputerHomeDir({
export default function resolveComputerWorkspaceDir({
computer_id,
kind,
metadata,
@@ -19,7 +19,7 @@ type Params = {
*/
name: string;
/**
* The computer's home directory as this app sees it.
* The computer's workspace directory as this app sees it.
*/
data_dir: string;
/**
@@ -36,7 +36,7 @@ type Params = {
* 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
* bind-mounted from `data_dir` (only its workspace, `AppData.ComputerWorkspaceDir`: the rest of its home lives and dies with the container), 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
@@ -78,7 +78,7 @@ export default async function createComputerContainer({
Retries: AppData.ComputerHealthcheck.Retries,
},
HostConfig: {
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
Binds: [`${host_dir}:${AppData.ComputerWorkspaceDir}`],
NetworkMode: network,
RestartPolicy: { Name: "unless-stopped" },
SecurityOpt: ["seccomp=unconfined"],
@@ -0,0 +1,87 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import reachableBotsSql from "@/src/utils/crew-access/reachable-bots-sql";
import workspacesSql from "@/src/utils/crew-access/workspaces-sql";
import type { CrewRole } from "@/src/utils/rbac/crew-role";
type Params = {
role: CrewRole;
user_id: number | string;
};
export type ComputerFolderAccess = {
/**
* What `computerPathAllowed` needs about the user.
*/
manages_crew: boolean;
user_id: number;
bot_ids: number[];
workspace_ids: number[];
/**
* Names for the workspace's id folders (`bots/<id>`, `users/<id>`,
* `workspaces/<id>`), the ones the user may see.
*/
names: {
bots: Record<string, string>;
users: Record<string, string>;
workspaces: Record<string, string>;
};
};
/**
* Which of a computer's workspace folders a user may open in the files
* browser, and what they are called: the bots and workspaces they reach,
* by the same crew SQL as the rest of the app. Crew managers see every
* one, and every user's folder in them.
*/
export default async function grabComputerFolderAccess({
role,
user_id,
}: Params): Promise<ComputerFolderAccess> {
const manages_crew = role != "member";
const bot_access = reachableBotsSql({ role, user_id });
const workspace_access = workspacesSql({ role, user_id });
const [bots, workspaces, users] = await Promise.all([
rows<{ id: number; name?: string | null }>({
sql: `SELECT b.id, b.name FROM bots b WHERE ${bot_access.sql}`,
values: bot_access.values,
}),
rows<{ id: number; name?: string | null }>({
sql: `SELECT w.id, w.name FROM workspaces w WHERE ${workspace_access.sql}`,
values: workspace_access.values,
}),
rows<{ id: number; first_name?: string | null; last_name?: string | null; username?: string | null }>({
sql: `SELECT id, first_name, last_name, username FROM users${manages_crew ? "" : " WHERE id = ?"}`,
values: manages_crew ? [] : [Number(user_id)],
}),
]);
return {
manages_crew,
user_id: Number(user_id),
bot_ids: bots.map((bot) => Number(bot.id)),
workspace_ids: workspaces.map((workspace) => Number(workspace.id)),
names: {
bots: Object.fromEntries(bots.map((bot) => [String(bot.id), bot.name || `Bot ${bot.id}`])),
workspaces: Object.fromEntries(
workspaces.map((workspace) => [String(workspace.id), workspace.name || `Workspace ${workspace.id}`]),
),
users: Object.fromEntries(
users.map((user) => [
String(user.id),
[user.first_name, user.last_name].filter(Boolean).join(" ") || user.username || `User ${user.id}`,
]),
),
},
};
}
async function rows<T extends { [k: string]: any }>({
sql,
values,
}: {
sql: string;
values: (string | number)[];
}) {
return (await BunSQLite.sql<T>({ sql, values })).payload || [];
}
@@ -2,6 +2,7 @@ import { statSync } from "node:fs";
import path from "node:path";
import { AppData } from "@/src/data/app-data";
import { mimeTypeForFile } from "../db/bot-files/media-type-for-file";
import { canSeePath } from "./list-storage-entries";
import resolveStoragePath from "./resolve-storage-path";
import type { ResolvedStorageSpec } from "./resolve-storage-spec";
@@ -31,7 +32,8 @@ export type StorageFile = {
/**
* One file for the browser to open: checked to be inside the volume (the
* same rules a bot's own path gets), to exist, and to be a regular file
* rather than a folder. Null when it isn't one, so a caller can answer
* rather than a folder, and one the user may see in a computer's workspace
* (`canSeePath`). Null when it isn't one, so a caller can answer
* "not found" without leaking what is there.
*/
export default function grabStorageFile({ resolved, path: requested }: Params): StorageFile | null {
@@ -39,6 +41,8 @@ export default function grabStorageFile({ resolved, path: requested }: Params):
if (!resolved_path.name) return null;
if (!canSeePath({ resolved, path: resolved_path.relative })) return null;
let stats;
try {
@@ -4,6 +4,8 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import grabMediaURL from "../media/grab-media-url";
import computerFolderLabel from "@/src/utils/computer-folder-label";
import computerPathAllowed from "@/src/utils/computer-path-allowed";
import { isInsideRoot } from "./resolve-storage-path";
import type { ResolvedStorageSpec } from "./resolve-storage-spec";
import {
@@ -52,13 +54,20 @@ export type StorageEntry = {
* browser's own streaming route.
*/
url?: string;
/**
* What an id folder in a computer's workspace is: the bot's, user's or
* workspace's name.
*/
label?: string;
};
/**
* One directory on the data volume, for the files browser: its entries
* (folders first), a page of them, and a `bot_files` row attached to any
* that has one. Entries a symlink leads out of the volume with are left
* out, so nothing outside the folder is ever listed.
* out, so nothing outside the folder is ever listed. In a computer's
* workspace only what the user may see is listed (`computerPathAllowed`),
* and a folder they may not see reads as missing.
*/
export default async function listStorageEntries({
resolved,
@@ -74,7 +83,9 @@ export default async function listStorageEntries({
* all, which a stale folder link lands on (the page says so, instead of
* claiming the folder is empty).
*/
if (!existsSync(absolute)) return { entries: [], total: 0, offset, missing: true };
if (!existsSync(absolute) || !canSeePath({ resolved, path: relative })) {
return { entries: [], total: 0, offset, missing: true };
}
const rows = readdirSync(absolute, { withFileTypes: true });
const indexed = await botFileRows({ resolved, paths: rows.map((row) => joinPath({ relative, name: row.name })) });
@@ -86,6 +97,8 @@ export default async function listStorageEntries({
const entry_path = joinPath({ relative, name: row.name });
if (!canSeePath({ resolved, path: entry_path })) continue;
if (needle && !entry_path.toLowerCase().includes(needle)) continue;
const entry_absolute = path.join(absolute, row.name);
@@ -114,6 +127,9 @@ export default async function listStorageEntries({
size: 0,
modified_at: Math.round(stats.mtimeMs),
url: storageSpecHref({ spec: resolved.spec, path: entry_path }),
label: resolved.computer
? computerFolderLabel({ path: entry_path, names: resolved.computer.names }) || undefined
: undefined,
});
continue;
}
@@ -197,6 +213,14 @@ async function botFileRows({
return rows;
}
/**
* Whether the user may see a path in the folder: anything in it, except
* inside a computer's workspace (`computerPathAllowed`).
*/
export function canSeePath({ resolved, path }: { resolved: ResolvedStorageSpec; path: string }) {
return !resolved.computer || computerPathAllowed({ ...resolved.computer, path });
}
function joinPath({ relative, name }: { relative: string; name: string }) {
return relative ? `${relative}/${name}` : name;
}
@@ -63,7 +63,7 @@ describe("resolveStoragePath", () => {
test("refuses a path that is too deep", () => {
expect(() =>
resolveStoragePath({ root, path: Array.from({ length: 12 }, (_, i) => `f${i}`).join("/") }),
resolveStoragePath({ root, path: Array.from({ length: 20 }, (_, i) => `f${i}`).join("/") }),
).toThrow();
});
});
@@ -78,9 +78,9 @@ export default function resolveStoragePath({
}
}
if (segments.length > AppData.BotFilePathMaxDepth) {
if (segments.length > AppData.StorageBrowserMaxDepth) {
throw new AppError(
`That path is too deep: at most ${AppData.BotFilePathMaxDepth} folders.`,
`That path is too deep: at most ${AppData.StorageBrowserMaxDepth} folders.`,
);
}
@@ -8,9 +8,10 @@ import grabDirNames from "@/src/utils/grab-dir-names";
import type { CrewRole } from "@/src/utils/rbac/crew-role";
import { storageVolumeLabel, type StorageSpec } from "@/src/utils/storage-volumes";
import grabCrewRole from "../db/access/grab-crew-role";
import grabComputerFolderAccess from "./grab-computer-folder-access";
import grabUserBot from "../db/bots/grab-user-bot";
import grabUserComputer from "../db/computers/grab-user-computer";
import resolveComputerHomeDir from "../db/computers/resolve-computer-home-dir";
import resolveComputerWorkspaceDir from "../db/computers/resolve-computer-workspace-dir";
import grabEnv from "../db/envs/grab-env";
import grabUserWorkspace from "../db/workspaces/grab-user-workspace";
@@ -29,7 +30,9 @@ type Params = {
* crew uses (`grabUserBot`, `grabUserWorkspace`, `grabUserComputer`, so a
* member sees only their own grants); their own media folder, and only
* their own whatever their role; an MCP server's install folder for crew
* managers; the database for the super admin alone.
* managers; the database for the super admin alone. Inside a computer's
* workspace a member sees only their bots' folders, their own user folder
* in each, and their workspaces' (`computerPathAllowed`).
*/
export default async function resolveStorageSpec({ user_id, spec }: Params) {
const role = await grabCrewRole({ user_id });
@@ -41,6 +44,11 @@ export default async function resolveStorageSpec({ user_id, spec }: Params) {
spec,
root,
label: await storageLabel({ spec, role, user_id }),
/**
* A computer's workspace: which of its folders the user may see,
* and their names (`computerPathAllowed`, `computerFolderLabel`).
*/
computer: spec.volume == "computers" ? await grabComputerFolderAccess({ role, user_id }) : null,
};
}
@@ -76,7 +84,7 @@ async function storageRoot({
if (!computer) return null;
return resolveComputerHomeDir({
return resolveComputerWorkspaceDir({
computer_id: spec.owner_id,
kind: computer.kind,
metadata: await computerContainerMetadata({ container_id: computer.container_id }),