Add bots files handling logic

This commit is contained in:
2026-10-21 17:56:09 +01:00
parent b0af4db1bf
commit d17a4a7bdd
97 changed files with 5843 additions and 41 deletions
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import fileRef, { fileRefId } from "./file-ref";
import linkComputerRefs from "./link-computer-refs";
describe("fileRef", () => {
test("writes the ref the chat links", () => {
expect(fileRef({ id: 12 })).toBe("[file:12]");
});
test("reads its id back", () => {
expect(fileRefId({ href: "#file-12" })).toBe(12);
expect(fileRefId({ href: "#file-0" })).toBeNull();
expect(fileRefId({ href: "#bot-3" })).toBeNull();
expect(fileRefId({ href: undefined })).toBeNull();
});
});
describe("linkComputerRefs", () => {
test("turns a file ref into a link", () => {
expect(linkComputerRefs({ text: "Saved [file:12] there." })).toBe(
"Saved [file 12](#file-12) there.",
);
});
test("leaves an existing markdown link alone", () => {
expect(linkComputerRefs({ text: "[file:12](#file-12)" })).toBe("[file:12](#file-12)");
});
test("still links the other refs", () => {
const text = linkComputerRefs({ text: "[bot:3] [workspace:2] [computer:5]" });
expect(text).toContain("(#bot-3)");
expect(text).toContain("(#workspace-2)");
expect(text).toContain("(#computer-5)");
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* How a bot names one of its files in a message: `[file:12]`. The chat
* turns it into a link to the file (`linkComputerRefs`), which a chip
* renders with the file's name.
*/
export default function fileRef({ id }: { id: number | string }) {
return `[file:${id}]`;
}
/**
* The link target a file ref becomes, e.g. `#file-12`.
*/
export const FileRefHrefPrefix = "#file-";
/**
* The id in a file ref's link target, or null.
*/
export function fileRefId({ href }: { href?: string | null }) {
if (!href?.startsWith(FileRefHrefPrefix)) return null;
const id = Number(href.slice(FileRefHrefPrefix.length));
return Number.isFinite(id) && id > 0 ? id : null;
}
+12 -4
View File
@@ -24,11 +24,18 @@ export const BotRefHrefPrefix = "#bot-";
*/
export const WorkspaceRefHrefPrefix = "#workspace-";
/**
* The link target a file ref (`fileRef`) becomes, e.g. `#file-12`. The
* chat renders these as a chip that opens the file.
*/
export const FileRefHrefPrefix = "#file-";
/**
* Turns every computer ref (`[computer:3]`) and take-over ref
* (`[take-over:3:browser]`), bot ref (`[bot:3]`) and workspace ref
* (`[workspace:2]`) in a message into a markdown link, so it
* survives markdown rendering as a link the chat can swap for a button.
* (`[take-over:3:browser]`), bot ref (`[bot:3]`), workspace ref
* (`[workspace:2]`) and file ref (`[file:12]`) in a message into a
* markdown link, so it survives markdown rendering as a link the chat can
* swap for a button or chip.
*/
export default function linkComputerRefs({ text }: { text: string }) {
return text
@@ -44,5 +51,6 @@ export default function linkComputerRefs({ text }: { text: string }) {
.replace(
/\[workspace:(\d+)\](?!\()/g,
(_, id: string) => `[workspace ${id}](${WorkspaceRefHrefPrefix}${id})`,
);
)
.replace(/\[file:(\d+)\](?!\()/g, (_, id: string) => `[file ${id}](${FileRefHrefPrefix}${id})`);
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import resolveBotPath from "./resolve-bot-path";
function makeRoot() {
return mkdtempSync(path.join(tmpdir(), "cobble-botfiles-"));
}
describe("resolveBotPath", () => {
test("keeps a normal path inside the root", () => {
const root = makeRoot();
const resolved = resolveBotPath({ root, path: "reports/q3.md" });
expect(resolved.relative).toBe("reports/q3.md");
expect(resolved.folder).toBe("reports");
expect(resolved.name).toBe("q3.md");
expect(resolved.absolute.startsWith(root)).toBe(true);
});
test("normalizes backslashes and extra slashes", () => {
const resolved = resolveBotPath({ root: makeRoot(), path: "notes//today.md/" });
expect(resolved.relative).toBe("notes/today.md");
});
test("refuses an absolute path", () => {
expect(() => resolveBotPath({ root: makeRoot(), path: "/etc/passwd" })).toThrow();
expect(() => resolveBotPath({ root: makeRoot(), path: "C:/windows" })).toThrow();
});
test("refuses going up out of the root", () => {
expect(() => resolveBotPath({ root: makeRoot(), path: "../secret.txt" })).toThrow();
expect(() => resolveBotPath({ root: makeRoot(), path: "a/../../secret.txt" })).toThrow();
});
test("refuses a NUL byte", () => {
expect(() => resolveBotPath({ root: makeRoot(), path: "a\0b.txt" })).toThrow();
});
test("refuses a symlink that leads out of the root", () => {
const root = makeRoot();
const outside = makeRoot();
writeFileSync(path.join(outside, "secret.txt"), "no");
mkdirSync(path.join(root, "linked"));
symlinkSync(outside, path.join(root, "linked", "escape"));
expect(() =>
resolveBotPath({ root, path: "linked/escape/secret.txt" }),
).toThrow();
});
test("allows a symlink that stays inside", () => {
const root = makeRoot();
mkdirSync(path.join(root, "real"));
writeFileSync(path.join(root, "real", "note.md"), "hi");
symlinkSync(path.join(root, "real"), path.join(root, "alias"));
expect(resolveBotPath({ root, path: "alias/note.md" }).relative).toBe("alias/note.md");
});
test("refuses a name too long", () => {
expect(() =>
resolveBotPath({ root: makeRoot(), path: `${"a".repeat(200)}.md` }),
).toThrow();
});
test("refuses a path too deep", () => {
const deep = Array.from({ length: 12 }, (_, i) => `d${i}`).join("/");
expect(() => resolveBotPath({ root: makeRoot(), path: `${deep}/file.md` })).toThrow();
});
test("refuses a name with characters a disk won't take", () => {
expect(() => resolveBotPath({ root: makeRoot(), path: "a<b.md" })).toThrow();
});
});
+147
View File
@@ -0,0 +1,147 @@
import { existsSync, realpathSync } from "node:fs";
import path from "node:path";
import { AppData } from "@/src/data/app-data";
import AppError from "@/src/utils/app-error";
type Params = {
/**
* The directory the file must stay inside (`DATA_DIR/bots/<id>` or
* `DATA_DIR/workspaces/<id>`), already absolute.
*/
root: string;
/**
* What the bot asked for: a path inside that directory, e.g.
* `reports/q3.md`. Never absolute.
*/
path?: string | null;
};
export type ResolvedBotPath = {
/**
* The normalized path inside the root, forward slashes, no leading
* slash: what `bot_files.path` holds.
*/
relative: string;
/**
* The absolute path on disk, inside `root`.
*/
absolute: string;
/**
* The folder part of `relative`, empty at the top.
*/
folder: string;
/**
* The last segment.
*/
name: string;
};
/**
* Turns a bot's path into one safely inside its root: rejects absolute
* paths, `..`, NUL bytes and anything too long or too deep, and, once the
* path exists, anything a symlink points outside the root. Throws an
* `AppError` with a message safe to show the user (the bot gets it back
* as the tool's error).
*/
export default function resolveBotPath({ root, path: requested }: Params): ResolvedBotPath {
const raw = (requested || "").trim().replace(/\\/g, "/");
if (raw.includes("\0")) {
throw new AppError(`That path isn't valid.`);
}
if (raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) {
throw new AppError(
`Use a path inside your files folder, not an absolute one (e.g. "reports/q3.md").`,
);
}
const segments = raw.split("/").map((segment) => segment.trim()).filter(Boolean);
for (const segment of segments) {
if (segment == "." || segment == "..") {
throw new AppError(`That path isn't valid: it must stay inside your files folder.`);
}
}
if (segments.length > AppData.BotFilePathMaxDepth) {
throw new AppError(
`That path is too deep: at most ${AppData.BotFilePathMaxDepth} folders.`,
);
}
const name = segments.at(-1);
if (!name) {
throw new AppError(`Give a file name, e.g. "notes.md".`);
}
if (name.length > AppData.BotFileNameMaxChars) {
throw new AppError(
`That file name is too long: at most ${AppData.BotFileNameMaxChars} characters.`,
);
}
if (/[<>:"|?*]/.test(name)) {
throw new AppError(`A file name can't contain < > : " | ? or *.`);
}
const relative = segments.join("/");
if (relative.length > AppData.BotFilePathMaxChars) {
throw new AppError(`That path is too long.`);
}
const absolute = path.join(root, ...segments);
const inside = path.resolve(absolute);
if (inside != path.resolve(root) && !inside.startsWith(`${path.resolve(root)}${path.sep}`)) {
throw new AppError(`That path isn't valid: it must stay inside your files folder.`);
}
/**
* A symlink inside the root must not lead out of it: check the deepest
* part that exists.
*/
guardRealPath({ root, absolute: inside });
return {
relative,
absolute: inside,
folder: segments.slice(0, -1).join("/"),
name,
};
}
/**
* Refuses a path whose existing part resolves outside the root through a
* symlink (or any other reason).
*/
function guardRealPath({ root, absolute }: { root: string; absolute: string }) {
const real_root = existsSync(root) ? realpathSync(root) : path.resolve(root);
let probe = absolute;
while (!existsSync(probe)) {
const parent = path.dirname(probe);
if (parent == probe) break;
probe = parent;
}
const real_probe = realpathSync(probe);
if (real_probe != real_root && !real_probe.startsWith(`${real_root}${path.sep}`)) {
throw new AppError(`That path isn't valid: it must stay inside your files folder.`);
}
}
/**
* Whether a path is inside a root, before it exists (used for folders).
*/
export function isInsideRoot({ root, absolute }: { root: string; absolute: string }) {
const resolved_root = path.resolve(root);
const resolved = path.resolve(absolute);
return resolved == resolved_root || resolved.startsWith(`${resolved_root}${path.sep}`);
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test";
import grabStorageScopes from "./storage-access";
describe("grabStorageScopes", () => {
test("a worker in its own chat reaches only its folder", () => {
expect(grabStorageScopes({ bot_id: 4, bot_kind: "worker" })).toEqual([
{ scope: "bot", owner_id: 4 },
]);
});
test("a worker in a workspace also reaches the workspace's folder", () => {
expect(
grabStorageScopes({ bot_id: 4, bot_kind: "worker", workspace_id: 9 }),
).toEqual([
{ scope: "bot", owner_id: 4 },
{ scope: "workspace", owner_id: 9 },
]);
});
test("the coordinator reaches every workspace, its own folder first", () => {
expect(
grabStorageScopes({
bot_id: 1,
bot_kind: "coordinator",
all_workspace_ids: [9, 3],
}),
).toEqual([
{ scope: "bot", owner_id: 1 },
{ scope: "workspace", owner_id: 9 },
{ scope: "workspace", owner_id: 3 },
]);
});
test("the coordinator in a workspace still reaches them all", () => {
const scopes = grabStorageScopes({
bot_id: 1,
bot_kind: "coordinator",
workspace_id: 9,
all_workspace_ids: [9],
});
expect(scopes).toEqual([
{ scope: "bot", owner_id: 1 },
{ scope: "workspace", owner_id: 9 },
]);
});
test("ignores invalid workspace ids", () => {
expect(
grabStorageScopes({
bot_id: 1,
bot_kind: "coordinator",
all_workspace_ids: [0, 2],
}),
).toEqual([
{ scope: "bot", owner_id: 1 },
{ scope: "workspace", owner_id: 2 },
]);
});
});
+80
View File
@@ -0,0 +1,80 @@
import type { BotFileScope } from "@/src/dict/bot-file-dict";
/**
* One storage directory a run may read and write: a bot's own
* (`DATA_DIR/bots/<owner_id>`) or a workspace's shared one
* (`DATA_DIR/workspaces/<owner_id>`).
*/
export type StorageScope = {
scope: BotFileScope;
owner_id: number;
};
type Params = {
bot_id: number;
/**
* `coordinator`: it reaches every bot's and every workspace's files.
*/
bot_kind?: string | null;
/**
* The workspace the run is in, if any. A worker then reaches that
* workspace's shared files too.
*/
workspace_id?: number | string | null;
/**
* Every workspace, for the coordinator (it isn't a member of one, it
* reaches them all). Filled by the caller that already read them.
*/
all_workspace_ids?: number[];
};
/**
* The directories a run may touch, itself first. A worker: its own, plus
* its workspace's shared one while it works there. The coordinator: every
* bot's and every workspace's, its own first.
*
* Enforced by every file tool, never left to the prompt: a bot that names
* a directory it doesn't reach is told so.
*/
export default function grabStorageScopes({
bot_id,
bot_kind,
workspace_id,
all_workspace_ids = [],
}: Params): StorageScope[] {
const own: StorageScope = { scope: "bot", owner_id: Number(bot_id) };
if (bot_kind == "coordinator") {
return [
own,
...all_workspace_ids
.filter((id) => Number(id) > 0)
.map((id): StorageScope => ({ scope: "workspace", owner_id: Number(id) })),
];
}
return workspace_id ? [own, { scope: "workspace", owner_id: Number(workspace_id) }] : [own];
}
/**
* Whether a scope is one of the run's.
*/
export function canReachScope({
scopes,
scope,
owner_id,
}: {
scopes: StorageScope[];
scope: BotFileScope;
owner_id: number | string;
}) {
return scopes.some((s) => s.scope == scope && s.owner_id == Number(owner_id));
}
/**
* A scope's label for tools and messages: "your files" for a bot's own,
* the workspace's name for a shared one.
*/
export function storageScopeKey({ scope, owner_id }: StorageScope) {
return `${scope}:${owner_id}`;
}