Add bots files handling logic
This commit is contained in:
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user