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:
@@ -22,6 +22,7 @@ import updateWorkspaceTool from "./update-workspace-tool";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
|
||||
import startTaskTool from "./start-task-tool";
|
||||
import computerRunDirs from "@/src/utils/computer-work-dirs";
|
||||
|
||||
type Params = {
|
||||
bot: BUN_SQLITE_COBBLE_BOTS;
|
||||
@@ -109,7 +110,12 @@ export default async function grabBotChatTools({
|
||||
}),
|
||||
...(computers.length
|
||||
? [
|
||||
searchComputersTool({ bot_id, computers, workspace_id }),
|
||||
searchComputersTool({
|
||||
bot_id,
|
||||
computers,
|
||||
workspace_id,
|
||||
dirs: computerRunDirs({ bot_id, bot_kind: bot.kind, user_id, workspace_id }),
|
||||
}),
|
||||
askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||
import { shellQuote } from "./download-computer-file";
|
||||
|
||||
/**
|
||||
* The shell that puts a command in the run's folder on the computer:
|
||||
* creates its folders (the user's in the bot's, and the workspace's shared
|
||||
* one), moves into the user's and makes it `HOME`, so `~` and every
|
||||
* relative path land there. Ends with `; `, ready to prefix a command.
|
||||
*/
|
||||
export default function computerDirsShell({ dirs }: { dirs: ComputerRunDirs }) {
|
||||
const cwd = shellQuote({ text: dirs.cwd });
|
||||
const folders = [dirs.cwd, ...(dirs.shared_dir ? [dirs.shared_dir] : [])]
|
||||
.map((dir) => shellQuote({ text: dir }))
|
||||
.join(" ");
|
||||
|
||||
return `mkdir -p ${folders} && cd ${cwd} && export HOME=${cwd}; `;
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
|
||||
import recordBotFile, { grabBotFileRow } from "@/src/functions/backend/db/bot-files/record-bot-file";
|
||||
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
|
||||
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
|
||||
import {
|
||||
computerWorkspaceRelative,
|
||||
resolveComputerPath,
|
||||
type ComputerRunDirs,
|
||||
} from "@/src/utils/computer-work-dirs";
|
||||
import folderRef from "@/src/utils/folder-ref";
|
||||
import type { StorageScope } from "@/src/utils/storage-access";
|
||||
import type { HarnessTool } from "../../types";
|
||||
@@ -16,7 +21,7 @@ import walkRegularFiles from "@/src/utils/walk-regular-files";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The computer the run is on: what it made is in its home.
|
||||
* The computer the run is on: what it made is in its workspace.
|
||||
*/
|
||||
computer_id: number | string;
|
||||
/**
|
||||
@@ -26,6 +31,11 @@ type Params = {
|
||||
scopes: StorageScope[];
|
||||
bot_id: number | string;
|
||||
user_id: number | string;
|
||||
/**
|
||||
* The run's folders on the computer (`computerRunDirs`): only they are
|
||||
* imported from, a relative path from the user's.
|
||||
*/
|
||||
dirs: ComputerRunDirs;
|
||||
onAttach?: (params: { media_id: number; name: string }) => void;
|
||||
};
|
||||
|
||||
@@ -52,6 +62,7 @@ export default function importFileTool({
|
||||
scopes,
|
||||
bot_id,
|
||||
user_id,
|
||||
dirs,
|
||||
onAttach,
|
||||
}: Params): HarnessTool<Args> {
|
||||
const own = scopes[0];
|
||||
@@ -59,7 +70,7 @@ export default function importFileTool({
|
||||
|
||||
return {
|
||||
name: "import_file",
|
||||
description: `Copy a file or folder from this computer into your own files, so it is kept with the app and stays after the computer is rebuilt. Give its path on the computer, e.g. "seo/coderank/raw/crawl.json" or "seo/coderank/raw"; a folder comes in as it is, in one go. Up to ${max_mb} MB. Check with list_files and the computer's own tools what you already imported: importing the same thing twice just writes over the copy, so do it once.${
|
||||
description: `Copy a file or folder from this computer into your own files, so it is kept with the app and stays after the computer is rebuilt. Give its path on the computer, relative to your folder there (${dirs.cwd}), e.g. "seo/coderank/raw/crawl.json" or "seo/coderank/raw"; a folder comes in as it is, in one go. Up to ${max_mb} MB. Check with list_files and the computer's own tools what you already imported: importing the same thing twice just writes over the copy, so do it once.${
|
||||
own?.scope == "workspace" ? " The copies land in the workspace's shared files." : ""
|
||||
} Imports are searchable at once. Name a copy with its [file:ID] ref. Import the report you wrote before the last long steps of a run, so it is never lost to the step limit.`,
|
||||
schema: z.object({
|
||||
@@ -67,7 +78,7 @@ export default function importFileTool({
|
||||
.string()
|
||||
.min(1)
|
||||
.max(AppData.BotFilePathMaxChars)
|
||||
.describe(`The file or folder on the computer, relative to its home, e.g. "seo/coderank/raw"`),
|
||||
.describe(`The file or folder on the computer, relative to your folder there, e.g. "seo/coderank/raw"`),
|
||||
name: z
|
||||
.string()
|
||||
.max(AppData.BotFilePathMaxChars)
|
||||
@@ -78,9 +89,15 @@ export default function importFileTool({
|
||||
run: async ({ args, signal }) => {
|
||||
if (!own) return `You have no files folder to import into.`;
|
||||
|
||||
const source = normalizeSourcePath({ path: args.path });
|
||||
/**
|
||||
* Workspace-relative from here on, which is what a folder ref
|
||||
* names.
|
||||
*/
|
||||
const source = computerWorkspaceRelative({
|
||||
absolute: resolveComputerPath({ path: args.path, dirs }),
|
||||
});
|
||||
|
||||
if (!source) return `Give a path inside the computer's home, e.g. "seo/coderank/raw".`;
|
||||
if (!source) return `Give a file or folder inside your folder, e.g. "seo/coderank/raw".`;
|
||||
|
||||
const target = await grabComputerMcpUrl({ computer_id });
|
||||
|
||||
@@ -91,7 +108,7 @@ export default function importFileTool({
|
||||
const kind = await sourceKind({ base_url: target.base_url, source, signal });
|
||||
|
||||
if (kind == "missing") {
|
||||
return `Nothing at ~/${source} on this computer. Check it with search_computers or the computer's own tools before importing.`;
|
||||
return `Nothing at ${onComputer({ source })} on this computer. Check it with search_computers or the computer's own tools before importing.`;
|
||||
}
|
||||
|
||||
if (kind == "folder") {
|
||||
@@ -149,7 +166,7 @@ async function importOneFile({
|
||||
}) {
|
||||
const download = await downloadComputerFile({
|
||||
base_url: target.base_url,
|
||||
path: `${AppData.ComputerHomeDir}/${target.source}`,
|
||||
path: onComputer({ source: target.source }),
|
||||
max_bytes: AppData.BotFileImportMaxBytes,
|
||||
signal,
|
||||
});
|
||||
@@ -182,7 +199,7 @@ async function importOneFile({
|
||||
return [
|
||||
previous?.id
|
||||
? `You already had ${saved.path} (same ref, no second copy): its bytes are now this file, ${formatSize({ bytes: download.size })} byte for byte.`
|
||||
: `Copied ~/${target.source} into your files as ${saved.path} [file:${saved.id}], ${formatSize({ bytes: download.size })} byte for byte.`,
|
||||
: `Copied ${onComputer({ source: target.source })} into your files as ${saved.path} [file:${saved.id}], ${formatSize({ bytes: download.size })} byte for byte.`,
|
||||
`The original is still on the computer, in ${folderRef({
|
||||
volume: "computer",
|
||||
owner_id: target.computer_id,
|
||||
@@ -220,10 +237,10 @@ async function importFolder({
|
||||
|
||||
const saved = await unpackArchive({ archive: archive.bytes, folder: name.replace(/\/+$/, ""), scope, bot_id, user_id });
|
||||
|
||||
if (!saved.length) return `The folder ~/${target.source} is empty, so nothing was imported.`;
|
||||
if (!saved.length) return `The folder ${onComputer({ source: target.source })} is empty, so nothing was imported.`;
|
||||
|
||||
return [
|
||||
`Copied ${saved.length} file(s) from ~/${target.source} into your files under ${name}/:`,
|
||||
`Copied ${saved.length} file(s) from ${onComputer({ source: target.source })} into your files under ${name}/:`,
|
||||
...saved.slice(0, 20).map((file) => `- ${file.path} [file:${file.id}]`),
|
||||
saved.length > 20 ? `(and ${saved.length - 20} more)` : "",
|
||||
`The originals are still on the computer, in ${folderRef({
|
||||
@@ -261,7 +278,7 @@ async function sourceKind({
|
||||
}): Promise<"file" | "folder" | "missing"> {
|
||||
const result = await runComputerCommand({
|
||||
base_url,
|
||||
command: `d="$HOME"/${shellQuote({ text: source })}; if [ -d "$d" ]; then echo folder; elif [ -f "$d" ]; then echo file; else echo missing; fi`,
|
||||
command: `d=${shellQuote({ text: onComputer({ source }) })}; if [ -d "$d" ]; then echo folder; elif [ -f "$d" ]; then echo file; else echo missing; fi`,
|
||||
timeout_ms: AppData.ComputerSearchTimeoutMs,
|
||||
signal,
|
||||
});
|
||||
@@ -287,14 +304,14 @@ function importFailure({
|
||||
const limit = formatSize({ bytes: AppData.BotFileImportMaxBytes });
|
||||
|
||||
if (reason == "missing") {
|
||||
return `Nothing at ~/${source} on this computer. Check it with search_computers or the computer's own tools before importing.`;
|
||||
return `Nothing at ${onComputer({ source })} on this computer. Check it with search_computers or the computer's own tools before importing.`;
|
||||
}
|
||||
|
||||
if (reason == "too_big") {
|
||||
return `~/${source} is ${formatSize({ bytes: size })}, over the ${limit} limit, so nothing was copied. Zip it on the computer first, or import the files that matter on their own.`;
|
||||
return `${onComputer({ source })} is ${formatSize({ bytes: size })}, over the ${limit} limit, so nothing was copied. Zip it on the computer first, or import the files that matter on their own.`;
|
||||
}
|
||||
|
||||
return `The copy of ~/${source} came back with ${formatSize({ bytes: size })} of its bytes, so it was thrown away rather than saved. Try again; if it keeps happening, the file is changing while it is read.`;
|
||||
return `The copy of ${onComputer({ source })} came back with ${formatSize({ bytes: size })} of its bytes, so it was thrown away rather than saved. Try again; if it keeps happening, the file is changing while it is read.`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,7 +343,7 @@ async function readFolderArchive({
|
||||
const result = await runComputerCommand({
|
||||
base_url,
|
||||
command: [
|
||||
`d="$HOME"/${shellQuote({ text: source })}`,
|
||||
`d=${shellQuote({ text: onComputer({ source }) })}`,
|
||||
`t=$(mktemp /tmp/cobble-import-XXXXXX.tgz)`,
|
||||
`tar -czf "$t" -C "$(dirname "$d")" "$(basename "$d")" || { rm -f "$t"; echo FAILED; exit 0; }`,
|
||||
`s=$(wc -c < "$t")`,
|
||||
@@ -437,19 +454,9 @@ function archiveRoot({ dir }: { dir: string }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The path the tool was given, made relative to the computer's home and
|
||||
* refused if it leaves it.
|
||||
* A workspace-relative path as it is on the computer.
|
||||
*/
|
||||
function normalizeSourcePath({ path: requested }: { path: string }) {
|
||||
const clean = requested
|
||||
.trim()
|
||||
.replace(/^~\/?/, "")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
|
||||
if (!clean) return null;
|
||||
|
||||
if (clean.split("/").some((segment) => segment == ".." || segment == ".")) return null;
|
||||
|
||||
return clean;
|
||||
function onComputer({ source }: { source: string }) {
|
||||
return `${AppData.ComputerWorkspaceDir}/${source}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ComputerMcpExcludedTools } from "@/src/dict/computer-dict";
|
||||
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
|
||||
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
|
||||
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
|
||||
import type { ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import connectMcpServer from "../../mcp/connect-mcp-server";
|
||||
import grabMcpTools from "../../mcp/grab-mcp-tools";
|
||||
import readImage from "../../media/read-image";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import computerDirsShell from "./computer-dirs-shell";
|
||||
import runComputerCommand from "./run-computer-command";
|
||||
import withComputerDirs from "./with-computer-dirs";
|
||||
import withComputerEnv from "./with-computer-env";
|
||||
import writeComputerEnv from "./write-computer-env";
|
||||
|
||||
@@ -21,6 +26,11 @@ type Params = {
|
||||
* screenshots.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The run's folders on the computer (`computerRunDirs`): every tool is
|
||||
* kept inside them.
|
||||
*/
|
||||
dirs: ComputerRunDirs;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -43,14 +53,16 @@ export type OpenComputer = {
|
||||
* Connects to a computer (its AIO sandbox, over MCP) and returns its tools:
|
||||
* shell, files, code and browser. Its envs (`grabComputerEnvs`) are written
|
||||
* to the computer and loaded by the shell and code tools; secret values are
|
||||
* redacted from everything the tools return. Screenshots come back as
|
||||
* readings from the media reading harness. Call `close` when the run ends.
|
||||
* redacted from everything the tools return. Every tool is kept in the
|
||||
* run's folders (`withComputerDirs`), which are created first. Screenshots
|
||||
* come back as readings from the media reading harness. Call `close` when the run ends.
|
||||
* Throws when the computer can't be reached, which the queue retries.
|
||||
*/
|
||||
export default async function openComputerTools({
|
||||
computer_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
dirs,
|
||||
signal,
|
||||
}: Params): Promise<OpenComputer> {
|
||||
const target = await grabComputerMcpUrl({ computer_id });
|
||||
@@ -65,6 +77,14 @@ export default async function openComputerTools({
|
||||
|
||||
const envs = await grabComputerEnvs({ computer_id, bot_id, workspace_id });
|
||||
const paths = await writeComputerEnv({ base_url: target.base_url, envs, signal });
|
||||
|
||||
await runComputerCommand({
|
||||
base_url: target.base_url,
|
||||
command: `${computerDirsShell({ dirs })}true`,
|
||||
timeout_ms: AppData.McpConnectTimeoutMs,
|
||||
signal,
|
||||
});
|
||||
|
||||
const secrets = envs.filter((env) => env.secret);
|
||||
const redact = (text: string) => redactSecrets({ text, secrets });
|
||||
|
||||
@@ -87,7 +107,7 @@ export default async function openComputerTools({
|
||||
|
||||
return {
|
||||
name: target.name,
|
||||
tools: tools.map((tool) => withComputerEnv({ tool, ...paths })),
|
||||
tools: tools.map((tool) => withComputerDirs({ tool: withComputerEnv({ tool, ...paths }), dirs })),
|
||||
envs: envs.map((env) => ({ key: env.key, description: env.description })),
|
||||
redact,
|
||||
close: () => client.close().catch(() => {}),
|
||||
|
||||
@@ -5,8 +5,10 @@ import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-comput
|
||||
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
|
||||
import computerRef from "@/src/utils/computer-ref";
|
||||
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
|
||||
import type { ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import computerDirsShell from "./computer-dirs-shell";
|
||||
import runComputerCommand from "./run-computer-command";
|
||||
|
||||
type Params = {
|
||||
@@ -22,6 +24,11 @@ type Params = {
|
||||
* The bot's computers, the current one first (`grabBotComputers`).
|
||||
*/
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
/**
|
||||
* The run's folders (`computerRunDirs`): the command starts in the
|
||||
* user's.
|
||||
*/
|
||||
dirs: ComputerRunDirs;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
@@ -32,16 +39,18 @@ type Args = {
|
||||
* Lets a bot look for something across every computer it has: one shell
|
||||
* command runs on each at once (`runComputerCommand`), and the outputs come
|
||||
* back labelled with each computer's ref (`computerRef`), secrets redacted,
|
||||
* so the bot can tell the user exactly which computer holds what.
|
||||
* so the bot can tell the user exactly which computer holds what. It
|
||||
* starts in the run's own folder on each (`computerDirsShell`).
|
||||
*/
|
||||
export default function searchComputersTool({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
computers,
|
||||
dirs,
|
||||
}: Params): HarnessTool<Args> {
|
||||
return {
|
||||
name: "search_computers",
|
||||
description: `Run one read-only shell command (bash) on every computer you have at once, to find which one holds something: a file, a running app, a logged-in browser session, a setting. Each computer's output comes back under its ref, e.g. ${computerRef({ id: computers[0]?.id || 1 })}. For a logged-in site, look for its domain in the browser profiles' cookie databases (copy the file first, since the browser locks it) and list only host names, never cookie values. Don't change anything with it; use start_task for that. Output past ${AppData.ComputerSearchMaxOutputChars} characters per computer is cut, so filter in the command.`,
|
||||
description: `Run one read-only shell command (bash) on every computer you have at once, to find which one holds something: a file, a running app, a logged-in browser session, a setting. Each computer's output comes back under its ref, e.g. ${computerRef({ id: computers[0]?.id || 1 })}. For a logged-in site, look for its domain in the browser profiles' cookie databases (copy the file first, since the browser locks it) and list only host names, never cookie values. It starts in your folder on each computer (${dirs.cwd}, also ~). Don't change anything with it; use start_task for that. Output past ${AppData.ComputerSearchMaxOutputChars} characters per computer is cut, so filter in the command.`,
|
||||
schema: z.object({
|
||||
command: z.string().min(1).max(4000),
|
||||
}),
|
||||
@@ -60,7 +69,7 @@ export default function searchComputersTool({
|
||||
const [result, envs] = await Promise.all([
|
||||
runComputerCommand({
|
||||
base_url: target.base_url,
|
||||
command: args.command,
|
||||
command: `${computerDirsShell({ dirs })}${args.command}`,
|
||||
timeout_ms: AppData.ComputerSearchTimeoutMs,
|
||||
signal,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import computerRunDirs from "@/src/utils/computer-work-dirs";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import computerDirsShell from "./computer-dirs-shell";
|
||||
import withComputerDirs from "./with-computer-dirs";
|
||||
|
||||
const echoTool = (name: string): HarnessTool => ({
|
||||
name,
|
||||
description: "",
|
||||
parameters: {},
|
||||
run: async ({ args }) => JSON.stringify(args),
|
||||
});
|
||||
|
||||
const dirs = computerRunDirs({ bot_id: 3, user_id: 7, workspace_id: 2 });
|
||||
const cwd = "/home/gem/workspace/bots/3/users/7";
|
||||
const signal = new AbortController().signal;
|
||||
const call = async (name: string, args: Record<string, unknown>) =>
|
||||
JSON.parse(await withComputerDirs({ tool: echoTool(name), dirs }).run({ args, signal }));
|
||||
|
||||
describe("withComputerDirs", () => {
|
||||
test("bash runs in the user's folder with it as HOME", async () => {
|
||||
const sent = await call("sandbox_execute_bash", { cmd: "ls" });
|
||||
|
||||
expect(sent.cwd).toBe(cwd);
|
||||
expect(sent.cmd).toBe(`export HOME="${cwd}"; ls`);
|
||||
});
|
||||
|
||||
test("bash keeps a cwd inside the run's folders and refuses one outside", async () => {
|
||||
expect((await call("sandbox_execute_bash", { cmd: "ls", cwd: "seo" })).cwd).toBe(`${cwd}/seo`);
|
||||
await expect(call("sandbox_execute_bash", { cmd: "ls", cwd: "/etc" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("code starts in the user's folder", async () => {
|
||||
const python = await call("sandbox_execute_code", { code: "print(1)", language: "python" });
|
||||
const js = await call("sandbox_execute_code", { code: "1", language: "javascript" });
|
||||
|
||||
expect(python.code).toContain(`_cobble_os.chdir("${cwd}")`);
|
||||
expect(python.code).toEndWith("\nprint(1)");
|
||||
expect(js.code).toContain(`process.chdir("${cwd}")`);
|
||||
});
|
||||
|
||||
test("file tools resolve paths and refuse outside ones and sudo", async () => {
|
||||
expect((await call("sandbox_file_operations", { action: "read", path: "a.txt" })).path).toBe(`${cwd}/a.txt`);
|
||||
expect((await call("sandbox_str_replace_editor", { command: "view", path: "/home/gem/workspace/workspaces/2/x" })).path).toBe(
|
||||
"/home/gem/workspace/workspaces/2/x",
|
||||
);
|
||||
await expect(call("sandbox_file_operations", { action: "read", path: "/home/gem/.bashrc" })).rejects.toThrow();
|
||||
await expect(call("sandbox_file_operations", { action: "list", path: "a", sudo: true })).rejects.toThrow();
|
||||
await expect(call("sandbox_str_replace_editor", { command: "view", path: "../../../4/x" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("file URLs must be in the run's folders, web ones pass", async () => {
|
||||
expect((await call("browser_navigate", { url: "https://example.com" })).url).toBe("https://example.com");
|
||||
expect((await call("browser_navigate", { url: `file://${cwd}/a.html` })).url).toBe(`file://${cwd}/a.html`);
|
||||
await expect(call("browser_navigate", { url: "file:///etc/passwd" })).rejects.toThrow();
|
||||
await expect(call("sandbox_convert_to_markdown", { uri: "file:///home/gem/.config/x" })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("leaves other tools alone", () => {
|
||||
const tool = echoTool("browser_click");
|
||||
expect(withComputerDirs({ tool, dirs })).toBe(tool);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computerDirsShell", () => {
|
||||
test("creates the folders and moves in", () => {
|
||||
expect(computerDirsShell({ dirs })).toBe(
|
||||
`mkdir -p '${cwd}' '/home/gem/workspace/workspaces/2' && cd '${cwd}' && export HOME='${cwd}'; `,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import { resolveComputerPath, type ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
tool: HarnessTool;
|
||||
dirs: ComputerRunDirs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tools whose `path` argument names a file on the computer.
|
||||
*/
|
||||
const PathTools = ["sandbox_file_operations", "sandbox_str_replace_editor"];
|
||||
|
||||
/**
|
||||
* Tools that open a URL, which may be a `file:` one.
|
||||
*/
|
||||
const UrlTools: Record<string, string> = {
|
||||
browser_navigate: "url",
|
||||
browser_new_tab: "url",
|
||||
sandbox_convert_to_markdown: "uri",
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps a computer tool in the run's folders (`computerRunDirs`): bash
|
||||
* runs in the user's folder with it as `HOME`, code starts there, a file
|
||||
* tool's path is resolved against it and refused outside the run's folders
|
||||
* (`resolveComputerPath`, its `sudo` too), and so is a `file:` URL. The
|
||||
* model gets a refusal back as the tool's error; nothing reaches the
|
||||
* computer. Other tools pass through.
|
||||
*/
|
||||
export default function withComputerDirs({ tool, dirs }: Params): HarnessTool {
|
||||
if (tool.name == "sandbox_execute_bash") {
|
||||
return {
|
||||
...tool,
|
||||
run: async ({ args, signal }) => {
|
||||
const cwd = resolveComputerPath({ path: typeof args?.cwd == "string" ? args.cwd : "", dirs });
|
||||
|
||||
return tool.run({
|
||||
args: {
|
||||
...args,
|
||||
cwd,
|
||||
cmd: typeof args?.cmd == "string" ? `export HOME=${JSON.stringify(dirs.cwd)}; ${args.cmd}` : args?.cmd,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (tool.name == "sandbox_execute_code") {
|
||||
return {
|
||||
...tool,
|
||||
run: async ({ args, signal }) =>
|
||||
tool.run({
|
||||
args:
|
||||
typeof args?.code == "string"
|
||||
? { ...args, code: `${codePrefix({ language: args.language, cwd: dirs.cwd })}${args.code}` }
|
||||
: args,
|
||||
signal,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (PathTools.includes(tool.name)) {
|
||||
return {
|
||||
...tool,
|
||||
run: async ({ args, signal }) => {
|
||||
if (args?.sudo) throw new AppError(`sudo isn't available here: work in your own folders.`);
|
||||
|
||||
return tool.run({
|
||||
args: { ...args, path: resolveComputerPath({ path: args?.path, dirs }) },
|
||||
signal,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const url_key = UrlTools[tool.name];
|
||||
|
||||
if (url_key) {
|
||||
return {
|
||||
...tool,
|
||||
run: async ({ args, signal }) => {
|
||||
const url = typeof args?.[url_key] == "string" ? args[url_key].trim() : "";
|
||||
|
||||
if (/^file:/i.test(url)) {
|
||||
const file_path = decodeURIComponent(url.replace(/^file:(\/\/[^/]*)?/i, ""));
|
||||
|
||||
resolveComputerPath({ path: file_path.startsWith("/") ? file_path : `/${file_path}`, dirs });
|
||||
}
|
||||
|
||||
return tool.run({ args, signal });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
function codePrefix({ language, cwd }: { language?: unknown; cwd: string }) {
|
||||
const dir = JSON.stringify(cwd);
|
||||
const lang = typeof language == "string" ? language.toLowerCase() : "python";
|
||||
|
||||
if (lang.startsWith("py")) {
|
||||
return `import os as _cobble_os; _cobble_os.makedirs(${dir}, exist_ok=True); _cobble_os.chdir(${dir}); _cobble_os.environ["HOME"] = ${dir}\n`;
|
||||
}
|
||||
|
||||
if (lang.startsWith("js") || lang.startsWith("javascript") || lang.startsWith("node")) {
|
||||
return `try { require("fs").mkdirSync(${dir}, { recursive: true }); process.chdir(${dir}); process.env.HOME = ${dir}; } catch {}\n`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import checkRequiredArgs from "./check-required-args";
|
||||
import runHttpTool from "./run-http-tool";
|
||||
import runPromptTool from "./run-prompt-tool";
|
||||
import runScriptTool from "./run-script-tool";
|
||||
import type { ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||
|
||||
type Params = {
|
||||
tool: CustomToolRecord;
|
||||
@@ -20,6 +21,11 @@ type Params = {
|
||||
computer_id?: number | string | null;
|
||||
bot_id?: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The run's folders on the computer (`computerRunDirs`), where a
|
||||
* script runs. None for a try from the settings page.
|
||||
*/
|
||||
dirs?: ComputerRunDirs | null;
|
||||
/**
|
||||
* Saves how each run went on the tool. Off for a draft being tried.
|
||||
*/
|
||||
@@ -40,6 +46,7 @@ export default function buildCustomTool({
|
||||
computer_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
dirs,
|
||||
record = true,
|
||||
}: Params): HarnessTool<Record<string, unknown>> | null {
|
||||
if (tool.kind == "script" && !computer_id) return null;
|
||||
@@ -58,6 +65,7 @@ export default function buildCustomTool({
|
||||
computer_id: computer_id!,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
dirs,
|
||||
signal,
|
||||
});
|
||||
case "prompt":
|
||||
|
||||
@@ -4,7 +4,9 @@ import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-comput
|
||||
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
|
||||
import type { CustomToolScriptConfig } from "@/src/types";
|
||||
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
|
||||
import type { ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import computerDirsShell from "../computer/computer-dirs-shell";
|
||||
import runComputerCommand from "../computer/run-computer-command";
|
||||
import writeComputerEnv from "../computer/write-computer-env";
|
||||
|
||||
@@ -20,6 +22,12 @@ type Params = {
|
||||
* The workspace the run is in: its envs apply on the computer.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The run's folders on the computer (`computerRunDirs`): the script
|
||||
* runs in the user's. A try from the settings page has no bot, so it
|
||||
* runs at the workspace's root.
|
||||
*/
|
||||
dirs?: ComputerRunDirs | null;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -27,7 +35,8 @@ type Params = {
|
||||
* Runs the tool's code on a computer over the sandbox's REST API, with the
|
||||
* computer's envs set (`writeComputerEnv`) and the arguments as JSON in
|
||||
* `COBBLE_ARGS` and on stdin. Code and arguments travel base64-encoded,
|
||||
* so nothing in them needs quoting. Returns what it printed (stdout and
|
||||
* so nothing in them needs quoting. It runs in the run's own folder
|
||||
* (`computerDirsShell`). Returns what it printed (stdout and
|
||||
* stderr), secrets redacted; a non-zero exit throws with it.
|
||||
*/
|
||||
export default async function runScriptTool({
|
||||
@@ -36,6 +45,7 @@ export default async function runScriptTool({
|
||||
computer_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
dirs,
|
||||
signal,
|
||||
}: Params) {
|
||||
const target = await grabComputerMcpUrl({ computer_id });
|
||||
@@ -61,6 +71,7 @@ export default async function runScriptTool({
|
||||
`f=$(mktemp --suffix=.${language.extension})`,
|
||||
`echo ${code} | base64 -d > "$f"`,
|
||||
`[ -f ${shell_path} ] && . ${shell_path}`,
|
||||
dirs ? computerDirsShell({ dirs }).replace(/; $/, "") : `cd ${AppData.ComputerWorkspaceDir}`,
|
||||
`export COBBLE_ARGS="$(echo ${input} | base64 -d)"`,
|
||||
`printf '%s' "$COBBLE_ARGS" | ${language.interpreter} "$f" 2>&1`,
|
||||
`status=$?`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import openMcpServer from "../../mcp/open-mcp-server";
|
||||
import readImage from "../../media/read-image";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import buildCustomTool from "../custom/build-custom-tool";
|
||||
import type { ComputerRunDirs } from "@/src/utils/computer-work-dirs";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
@@ -17,6 +18,11 @@ type Params = {
|
||||
* current one. None leaves script tools out.
|
||||
*/
|
||||
computer_id?: number | string | null;
|
||||
/**
|
||||
* The run's folders on that computer (`computerRunDirs`), where its
|
||||
* scripts run.
|
||||
*/
|
||||
dirs: ComputerRunDirs;
|
||||
/**
|
||||
* Names the run's built-in tools already use: they win.
|
||||
*/
|
||||
@@ -50,6 +56,7 @@ export default async function openBotExtensions({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
dirs,
|
||||
taken = [],
|
||||
signal,
|
||||
}: Params): Promise<OpenBotExtensions> {
|
||||
@@ -74,7 +81,7 @@ export default async function openBotExtensions({
|
||||
};
|
||||
|
||||
for (const tool of tools) {
|
||||
add(buildCustomTool({ tool, envs, computer_id, bot_id, workspace_id }));
|
||||
add(buildCustomTool({ tool, envs, computer_id, bot_id, workspace_id, dirs }));
|
||||
}
|
||||
|
||||
for (const server of servers) {
|
||||
|
||||
Reference in New Issue
Block a user