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
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import computerFolderLabel from "./computer-folder-label";
const names = { bots: { "3": "Scout" }, users: { "7": "Ada Lovelace" }, workspaces: { "2": "Launch" } };
describe("computerFolderLabel", () => {
test("names the id folders", () => {
expect(computerFolderLabel({ path: "bots/3", names })).toBe("Scout");
expect(computerFolderLabel({ path: "bots/3/users/7", names })).toBe("Ada Lovelace");
expect(computerFolderLabel({ path: "workspaces/2", names })).toBe("Launch");
});
test("leaves everything else unnamed", () => {
expect(computerFolderLabel({ path: "bots", names })).toBe("");
expect(computerFolderLabel({ path: "bots/3/users", names })).toBe("");
expect(computerFolderLabel({ path: "bots/3/users/7/seo", names })).toBe("");
expect(computerFolderLabel({ path: "bots/9", names })).toBe("");
expect(computerFolderLabel({ path: "other/3", names })).toBe("");
});
});
+32
View File
@@ -0,0 +1,32 @@
type Params = {
/**
* A path inside a computer's workspace, e.g. `bots/3/users/7`.
*/
path: string;
names: {
bots: Record<string, string>;
users: Record<string, string>;
workspaces: Record<string, string>;
};
};
/**
* What an id folder in a computer's workspace is, for the files browser:
* `bots/3` is the bot's name, `bots/3/users/7` the user's, `workspaces/2`
* the workspace's. Empty for anything else.
*/
export default function computerFolderLabel({ path, names }: Params) {
const [top, owner, sub, sub_owner, ...rest] = path.split("/").filter(Boolean);
if (!owner || rest.length) return "";
if (top == "workspaces" && !sub) return names.workspaces[owner] || "";
if (top != "bots") return "";
if (!sub) return names.bots[owner] || "";
if (sub == "users" && sub_owner) return names.users[sub_owner] || "";
return "";
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";
import computerPathAllowed from "./computer-path-allowed";
const member = { manages_crew: false, user_id: 7, bot_ids: [3], workspace_ids: [2] };
describe("computerPathAllowed", () => {
test("crew managers see everything", () => {
expect(computerPathAllowed({ ...member, manages_crew: true, path: "bots/9/users/1" })).toBe(true);
expect(computerPathAllowed({ ...member, manages_crew: true, path: "legacy" })).toBe(true);
});
test("a member sees their bots and their own user folder", () => {
expect(computerPathAllowed({ ...member, path: "" })).toBe(true);
expect(computerPathAllowed({ ...member, path: "bots" })).toBe(true);
expect(computerPathAllowed({ ...member, path: "bots/3" })).toBe(true);
expect(computerPathAllowed({ ...member, path: "bots/3/notes.md" })).toBe(true);
expect(computerPathAllowed({ ...member, path: "bots/3/users" })).toBe(true);
expect(computerPathAllowed({ ...member, path: "bots/3/users/7/a" })).toBe(true);
expect(computerPathAllowed({ ...member, path: "bots/3/users/8" })).toBe(false);
expect(computerPathAllowed({ ...member, path: "bots/4" })).toBe(false);
expect(computerPathAllowed({ ...member, path: "bots/03" })).toBe(false);
});
test("a member sees the workspaces they reach and nothing else at the root", () => {
expect(computerPathAllowed({ ...member, path: "workspaces/2/x" })).toBe(true);
expect(computerPathAllowed({ ...member, path: "workspaces/5" })).toBe(false);
expect(computerPathAllowed({ ...member, path: ".cobble-workspace" })).toBe(false);
expect(computerPathAllowed({ ...member, path: "other" })).toBe(false);
});
});
+58
View File
@@ -0,0 +1,58 @@
type Params = {
/**
* A path inside a computer's workspace, relative to its root (the files
* browser's), e.g. `bots/3/users/1/reports`. Empty for the root.
*/
path: string;
/**
* Super admins and admins (`canManageCrew`) see the whole workspace.
*/
manages_crew: boolean;
user_id: number | string;
/**
* The bots and workspaces the user reaches (`grabUserBot`,
* `grabUserWorkspace`).
*/
bot_ids: (number | string)[];
workspace_ids: (number | string)[];
};
/**
* Whether the files browser shows a path in a computer's workspace to a
* user. Crew managers see all of it. Anyone else sees a bot's folder only
* when they reach the bot, and within it only their own user folder
* (`bots/<bot>/users/<them>`), a workspace's shared folder only when they
* reach the workspace, and nothing else at the root.
*/
export default function computerPathAllowed({
path,
manages_crew,
user_id,
bot_ids,
workspace_ids,
}: Params) {
if (manages_crew) return true;
const segments = path.split("/").filter(Boolean);
const [top, owner, sub, sub_owner] = segments;
if (!top) return true;
if (top == "bots") {
if (!owner) return true;
if (!bot_ids.some((id) => String(Number(id)) == owner)) return false;
if (sub == "users" && sub_owner) return sub_owner == String(Number(user_id));
return true;
}
if (top == "workspaces") {
if (!owner) return true;
return workspace_ids.some((id) => String(Number(id)) == owner);
}
return false;
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import computerRunDirs, { computerWorkspaceRelative, resolveComputerPath } from "./computer-work-dirs";
const W = "/home/gem/workspace";
describe("computerRunDirs", () => {
test("a worker gets its own folder, the user's inside it", () => {
const dirs = computerRunDirs({ bot_id: 3, user_id: 7 });
expect(dirs.cwd).toBe(`${W}/bots/3/users/7`);
expect(dirs.roots).toEqual([`${W}/bots/3`]);
expect(dirs.shared_dir).toBeNull();
});
test("in a workspace the shared folder joins", () => {
const dirs = computerRunDirs({ bot_id: 3, user_id: 7, workspace_id: 2 });
expect(dirs.roots).toEqual([`${W}/bots/3`, `${W}/workspaces/2`]);
});
test("the coordinator reaches the whole workspace", () => {
const dirs = computerRunDirs({ bot_id: 1, bot_kind: "coordinator", user_id: 1 });
expect(dirs.roots).toEqual([W]);
});
});
describe("resolveComputerPath", () => {
const dirs = computerRunDirs({ bot_id: 3, user_id: 7, workspace_id: 2 });
test("relative paths and ~ land in the user's folder", () => {
expect(resolveComputerPath({ path: "seo/raw", dirs })).toBe(`${W}/bots/3/users/7/seo/raw`);
expect(resolveComputerPath({ path: "~/a.txt", dirs })).toBe(`${W}/bots/3/users/7/a.txt`);
expect(resolveComputerPath({ path: "~", dirs })).toBe(`${W}/bots/3/users/7`);
expect(resolveComputerPath({ path: "", dirs })).toBe(`${W}/bots/3/users/7`);
});
test("absolute paths inside the roots pass", () => {
expect(resolveComputerPath({ path: `${W}/bots/3/notes.md`, dirs })).toBe(`${W}/bots/3/notes.md`);
expect(resolveComputerPath({ path: `${W}/workspaces/2/x/`, dirs })).toBe(`${W}/workspaces/2/x`);
});
test("anything outside is refused", () => {
for (const path of [
"/home/gem/x.txt",
"/etc/passwd",
"../../../4/a",
"../8/secret",
`${W}/bots/4`,
`${W}/bots/3/users/8/a`,
`${W}/bots/30`,
`${W}/workspaces/5`,
"~/../../../../x",
"a\0b",
]) {
expect(() => resolveComputerPath({ path, dirs })).toThrow();
}
});
test("the bot-wide folder and its users folder itself pass", () => {
expect(resolveComputerPath({ path: "..", dirs })).toBe(`${W}/bots/3/users`);
expect(resolveComputerPath({ path: "../..", dirs })).toBe(`${W}/bots/3`);
});
test("the coordinator reaches any bot's folder", () => {
const coordinator = computerRunDirs({ bot_id: 1, bot_kind: "coordinator", user_id: 1 });
expect(resolveComputerPath({ path: `${W}/bots/3/report.md`, dirs: coordinator })).toBe(`${W}/bots/3/report.md`);
expect(() => resolveComputerPath({ path: "/home/gem/.config", dirs: coordinator })).toThrow();
});
test("workspace-relative names", () => {
expect(computerWorkspaceRelative({ absolute: `${W}/bots/3/users/7/seo` })).toBe("bots/3/users/7/seo");
});
});
+151
View File
@@ -0,0 +1,151 @@
import path from "node:path";
import { AppData } from "@/src/data/app-data";
import AppError from "@/src/utils/app-error";
/**
* Where one run may work on a computer, every path absolute inside the
* computer's workspace (`AppData.ComputerWorkspaceDir`, the only directory
* mounted from the data volume).
*/
export type ComputerRunDirs = {
/**
* The computer's workspace root.
*/
workspace: string;
/**
* Where the run starts and where a relative path lands: the user's
* folder in the bot's (`bots/<bot>/users/<user>`).
*/
cwd: string;
/**
* The bot's own folder (`bots/<bot>`).
*/
bot_dir: string;
/**
* The workspace's shared folder (`workspaces/<id>`), in a workspace.
*/
shared_dir: string | null;
/**
* The folders the run may touch. The coordinator's is the whole
* workspace.
*/
roots: string[];
/**
* The user the run is for: another user's folder in a bot's is never
* the run's, even inside its roots.
*/
user_id: number;
};
type Params = {
bot_id: number | string;
/**
* `coordinator`: it reaches every bot's and every workspace's folders,
* like its files (`grabStorageScopes`).
*/
bot_kind?: string | null;
user_id: number | string;
workspace_id?: number | string | null;
};
/**
* The folders a run works in on a computer: the user's own inside the
* bot's (`bots/<bot>/users/<user>`, where it starts), the bot's own
* (`bots/<bot>`, for work that is the same for every user) and, in a
* workspace, the folder its bots share (`workspaces/<id>`).
*/
export default function computerRunDirs({
bot_id,
bot_kind,
user_id,
workspace_id,
}: Params): ComputerRunDirs {
const workspace = AppData.ComputerWorkspaceDir;
const bot_dir = path.posix.join(workspace, "bots", String(Number(bot_id)));
const cwd = path.posix.join(bot_dir, "users", String(Number(user_id)));
const shared_dir = Number(workspace_id) > 0
? path.posix.join(workspace, "workspaces", String(Number(workspace_id)))
: null;
return {
workspace,
cwd,
bot_dir,
shared_dir,
roots: bot_kind == "coordinator"
? [workspace]
: [bot_dir, ...(shared_dir ? [shared_dir] : [])],
user_id: Number(user_id),
};
}
/**
* Whether `child` is `parent` or inside it (both absolute, normalized).
*/
export function isInsideDir({ parent, child }: { parent: string; child: string }) {
return child == parent || child.startsWith(`${parent.replace(/\/+$/, "")}/`);
}
/**
* Turns a path a bot gave a computer tool into the absolute one it means,
* only when it is one of the run's: a relative path (or `~`, which the
* run's shells read as its folder) lands in `cwd`, an absolute one must be
* inside `roots`, and another user's folder in a bot's is refused. Throws
* an `AppError` the bot gets back as the tool's error.
*/
export function resolveComputerPath({
path: requested,
dirs,
}: {
path?: string | null;
dirs: ComputerRunDirs;
}) {
const raw = String(requested ?? "").trim().replace(/\\/g, "/");
if (raw.includes("\0") || raw.length > AppData.BotFilePathMaxChars * 2) {
throw new AppError(`That path isn't valid.`);
}
const home_relative = raw == "~" ? "" : raw.startsWith("~/") ? raw.slice(2) : null;
const absolute = path.posix.normalize(
home_relative != null
? path.posix.join(dirs.cwd, home_relative)
: raw.startsWith("/")
? raw
: path.posix.join(dirs.cwd, raw || "."),
).replace(/(.)\/+$/, "$1");
if (!isComputerPathAllowed({ absolute, dirs })) {
throw new AppError(
`${absolute} is outside your folders on this computer. Work in ${dirs.cwd} (this user's), ${dirs.roots.join(" or ")} for work every user shares.`,
);
}
return absolute;
}
/**
* Whether an absolute, normalized path is one the run may touch.
*/
function isComputerPathAllowed({ absolute, dirs }: { absolute: string; dirs: ComputerRunDirs }) {
if (!dirs.roots.some((root) => isInsideDir({ parent: root, child: absolute }))) return false;
const relative = path.posix.relative(dirs.workspace, absolute).split("/");
/**
* `bots/<bot>/users/<user>/...`: only the run's own user.
*/
if (relative[0] == "bots" && relative[2] == "users" && relative[3]) {
return Number(relative[3]) == dirs.user_id && String(dirs.user_id) == relative[3];
}
return true;
}
/**
* A path inside the computer's workspace as a folder ref or the files
* browser names it: relative to the workspace root, no leading slash.
*/
export function computerWorkspaceRelative({ absolute }: { absolute: string }) {
return path.posix.relative(AppData.ComputerWorkspaceDir, absolute);
}