diff --git a/AGENTS.md b/AGENTS.md index 7d3ec2a..8236adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,8 @@ A worker reaches another worker **only through the coordinator**: `ask_crew` (ev - **Browser profile:** `DATA_DIR/computer-browsers/` mounted at `~/.cobble-browser`, which Chrome is pointed at (`BROWSER_EXTRA_ARGS=--user-data-dir=... --password-store=basic`), so logins, extensions and site permissions survive restarts and recreates. Never use the image's `~/.config/browser`: every boot overwrites its `Default/Preferences` with session-only cookies. Seed prefs (`computer-browser-preferences.ts`) are written only when missing; stale `Singleton*` links are removed before each provision. Bumping `AppData.ComputerWorkspaceLayout` recreates every computer once (`migrateComputerLayout`, skipped while a job runs on it). Default extensions (`ComputerBrowserExtensions`, `normal_installed`: can be turned off, not removed) come from a managed policy written as root into the running container (`syncComputerBrowserPolicy`, on provision and every `sync-computers` run), not a mount, so existing computers get new ones without a recreate; Chrome installs them live. Deleting a computer keeps its directory. Outside docker (dev) containers are reached by IP (`pickReachableAddress`). - One main computer (`ensureMainComputer`), whose coordinator grant is locked (`isLockedComputerAccess`). With `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. Only super admins create/delete/retry computers. - A bot's effective computers (`grab-bot-computers`): the workspace's in a workspace; otherwise direct grants plus its workspaces' (coordinator: all); `bots.computer_id` first. Only a `running` (healthy) computer is reached (`grabComputerMcpUrl` `ready`, `computerNotReadyMessage`). -- **Folders (enforced in tools, not a Linux user):** a run works only in `bots//users/` (cwd, `HOME`), `bots/` and `workspaces/` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; `sudo` refused). +- **Folders (enforced in tools, not a Linux user):** a run works only in `bots//users/` (cwd, `HOME`), `bots/` and `workspaces/` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; the file tools' `sudo` refused; bash runs as `gem`, who has sudo, so packages can be installed, and they're lost on a restart). +- **Bash on a computer:** the sandbox ignores `sandbox_execute_bash`'s `timeout` and runs every call in one shared shell session, so a command that never ends blocked every later one on that computer, from any run, and Stop never killed it. `withCommandTimeout` runs each in its own `bash -c` under coreutils `timeout` (at most `AppData.ComputerCommandMaxSeconds`, below `McpToolTimeoutMs`), stdin from `/dev/null`. Never `exit` in a wrapper (it would kill the session shell). - **Viewing:** `/computers//` proxy (`handleComputerProxy`, strips app cookies, bridges websockets). `ComputerView` modes: browser, terminal, code, desktop (noVNC; the only one with Chrome's toolbar). **Take-over:** `ask_user_to_take_over` writes `[take-over:ID:mode]`; "Hand back" sends a chat message; tasks stop and report rather than wait. ### Schedules (routines in the UI) diff --git a/src/bots-queue/compose-envs-note.test.ts b/src/bots-queue/compose-envs-note.test.ts index df320fd..b6a4790 100644 --- a/src/bots-queue/compose-envs-note.test.ts +++ b/src/bots-queue/compose-envs-note.test.ts @@ -16,6 +16,18 @@ describe("composeEnvsNote", () => { expect(note).toContain("Don't ask the user for them"); }); + test("says where each env comes from", () => { + const note = composeEnvsNote({ + on_computer: [{ key: "DB_PASSWORD", description: "Production database", paradigm: "workspace" }], + tools_only: [{ key: "TOKEN", description: "", paradigm: "user" }], + has_computer: true, + }); + + expect(note).toContain("- DB_PASSWORD (this workspace's, every bot in it has it): Production database"); + expect(note).toContain("- TOKEN (this user's own, for you)"); + expect(note).toContain("never needs setting again per bot"); + }); + test("never mentions values", () => { const note = composeEnvsNote({ on_computer: [{ key: "K", description: "d" }], tools_only: [], has_computer: true }); diff --git a/src/bots-queue/compose-envs-note.ts b/src/bots-queue/compose-envs-note.ts index da4c7be..8d21f72 100644 --- a/src/bots-queue/compose-envs-note.ts +++ b/src/bots-queue/compose-envs-note.ts @@ -1,4 +1,6 @@ -type Env = { key: string; description: string }; +import type { EnvParadigm } from "@/src/dict/env-dict"; + +type Env = { key: string; description: string; paradigm?: EnvParadigm }; type Params = { /** @@ -16,21 +18,39 @@ type Params = { }; /** - * Added to a chat reply so the bot knows which envs it already has (keys - * and descriptions, never values). Without it a reply can't tell, asks the - * user for keys that are set (`request_secret`), or "checks" them with - * `show_secret` or a shell where they aren't loaded. + * Where an env comes from, as the bot is told: who else has it. + */ +const EnvSources: Record = { + general: "install-wide, every bot has it", + workspace: "this workspace's, every bot in it has it", + computer: "this computer's, every bot on it has it", + bot: "yours, for every user", + user: "this user's own, for you", +}; + +/** + * Added to a chat reply so the bot knows which envs it already has (keys, + * descriptions and where each comes from, never values). Without it a + * reply can't tell, asks the user for keys that are set + * (`request_secret`), "checks" them with `show_secret` or a shell where + * they aren't loaded, or tells the user a workspace's keys must be set + * again for every bot. */ export default function composeEnvsNote({ on_computer, tools_only, has_computer }: Params) { const list = (envs: Env[]) => - envs.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`).join("\n"); + envs + .map( + (env) => + `- ${env.key}${env.paradigm ? ` (${EnvSources[env.paradigm]})` : ""}${env.description ? `: ${env.description}` : ""}`, + ) + .join("\n"); if (!on_computer.length && !tools_only.length) { return `# Your envs\n\nNo envs are set for you here. If you need a secret, ask for it with request_secret.`; } return [ - `# Your envs\n\nThese are already set for you here (keys and descriptions; you never see values). Don't ask the user for them, and don't call show_secret or run printenv to check them: this list is the check.`, + `# Your envs\n\nThese are already set for you here (keys and descriptions; you never see values). Don't ask the user for them, and don't call show_secret or run printenv to check them: this list is the check. A workspace, computer or install-wide env reaches every bot there the same way, so it never needs setting again per bot.`, on_computer.length ? `On your computer, as $KEY in a task's shell and code (start_task; search_computers doesn't load them):\n${list(on_computer)}` : "", diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 7ea2d42..735ade4 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -354,6 +354,13 @@ export const AppData = { * One MCP tool call gives up after this long without progress. */ McpToolTimeoutMs: 5 * 60 * 1000, + /** + * A bash command on a computer is killed there after at most this long + * (`withCommandTimeout`), below `McpToolTimeoutMs`: the sandbox ignores + * its own `timeout` and runs every call in one shell session, so a + * command left running would hold up every later one on that computer. + */ + ComputerCommandMaxSeconds: 270, /** * MCP servers set up in Settings, under MCPs: a stdio server runs in * `DATA_DIR//` (its setup installs there, so it diff --git a/src/data/harness-prompts.ts b/src/data/harness-prompts.ts index cc736f5..d7f3a72 100644 --- a/src/data/harness-prompts.ts +++ b/src/data/harness-prompts.ts @@ -102,6 +102,7 @@ You work only in your own folders on the computer, never elsewhere in it: every dirs.shared_dir ? `\n- ${dirs.shared_dir}: shared with every bot in this workspace.` : "" } - The browser downloads to its own folder: move what you download into your folder straight away. +- A computer can be restarted or rebuilt at any time, and only ${AppData.ComputerWorkspaceDir} and the browser's logins survive it. Programs you install (sudo apt-get install -y, pip, npm -g) may be gone afterwards: check for one first (command -v) and install it again when it's missing. What you make there (downloads, screenshots, a crawl, raw evidence, logs) is kept with the computer, not in your files, and it is not searchable. diff --git a/src/harness/tools/bot/tasks/list-tasks-tool.ts b/src/harness/tools/bot/tasks/list-tasks-tool.ts index febd81e..eaea78c 100644 --- a/src/harness/tools/bot/tasks/list-tasks-tool.ts +++ b/src/harness/tools/bot/tasks/list-tasks-tool.ts @@ -64,7 +64,7 @@ export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_w if (!tasks.length) { return args.open_only - ? `You have no open tasks${args.status ? ` with that status` : ""}.` + ? `No open tasks${args.status ? ` with that status` : ""} here. Finished and cancelled ones aren't listed with open_only: list without it before saying a task is gone.` : `No tasks${args.status ? ` with that status` : ""}.`; } diff --git a/src/harness/tools/bot/tasks/update-task-tool.ts b/src/harness/tools/bot/tasks/update-task-tool.ts index 3d18638..d27f20b 100644 --- a/src/harness/tools/bot/tasks/update-task-tool.ts +++ b/src/harness/tools/bot/tasks/update-task-tool.ts @@ -90,7 +90,9 @@ export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_ updated_by_bot_id: bot_id, }); - if (args.status) said.push(`status is now ${args.status}`); + if (args.status) { + said.push(task.status == args.status ? `status was already ${args.status}` : `status is now ${args.status}`); + } } if (args.note?.trim()) { diff --git a/src/harness/tools/computer/open-computer-tools.ts b/src/harness/tools/computer/open-computer-tools.ts index 9e3de67..10d3208 100644 --- a/src/harness/tools/computer/open-computer-tools.ts +++ b/src/harness/tools/computer/open-computer-tools.ts @@ -12,6 +12,7 @@ import type { HarnessTool } from "../../types"; import computerDirsShell from "./computer-dirs-shell"; import captureSecretTool from "./capture-secret-tool"; import runComputerCommand from "./run-computer-command"; +import withCommandTimeout from "./with-command-timeout"; import withComputerDirs from "./with-computer-dirs"; import withComputerEnv from "./with-computer-env"; import writeComputerEnv from "./write-computer-env"; @@ -65,7 +66,8 @@ export type OpenComputer = { * 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. Every tool is kept in the - * run's folders (`withComputerDirs`), which are created first. Screenshots + * run's folders (`withComputerDirs`), which are created first, and every + * bash command is stopped on the computer (`withCommandTimeout`). 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. */ @@ -125,7 +127,7 @@ export default async function openComputerTools({ return { name: target.name, tools: [ - ...tools.map((tool) => withComputerDirs({ tool: withComputerEnv({ tool, ...paths }), dirs })), + ...tools.map((tool) => withComputerDirs({ tool: withComputerEnv({ tool: withCommandTimeout({ tool }), ...paths }), dirs })), ...(user_id && bot_id && onSecretRef ? [ captureSecretTool({ diff --git a/src/harness/tools/computer/with-command-timeout.test.ts b/src/harness/tools/computer/with-command-timeout.test.ts new file mode 100644 index 0000000..8643e7a --- /dev/null +++ b/src/harness/tools/computer/with-command-timeout.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { AppData } from "@/src/data/app-data"; +import type { HarnessTool } from "../../types"; +import withCommandTimeout from "./with-command-timeout"; + +const echoTool = (name: string): HarnessTool => ({ + name, + description: "Run a command.", + parameters: {}, + run: async ({ args }) => JSON.stringify(args), +}); + +const signal = new AbortController().signal; +const call = async (args: Record) => + JSON.parse(await withCommandTimeout({ tool: echoTool("sandbox_execute_bash") }).run({ args, signal })); +const max = AppData.ComputerCommandMaxSeconds; + +describe("withCommandTimeout", () => { + test("runs the command under timeout, reading no input, in its own bash", async () => { + const sent = await call({ cmd: "echo 'hi'", timeout: 20 }); + + expect(sent.cmd).toStartWith(`timeout -k 5 20 bash -c 'echo '\\''hi'\\''' < /dev/null; `); + expect(sent.cmd).toContain("[Stopped after 20s"); + expect(sent.cmd).toEndWith("(exit $_cobble_status)"); + expect(sent.cmd).not.toMatch(/(^|; )exit /); + }); + + test("keeps the timeout between 1 second and the cap, the cap without one", async () => { + expect((await call({ cmd: "ls" })).cmd).toStartWith(`timeout -k 5 ${max} `); + expect((await call({ cmd: "ls", timeout: 0 })).cmd).toStartWith(`timeout -k 5 ${max} `); + expect((await call({ cmd: "ls", timeout: 100000 })).cmd).toStartWith(`timeout -k 5 ${max} `); + expect((await call({ cmd: "ls", timeout: 2.5 })).cmd).toStartWith(`timeout -k 5 3 `); + expect(max * 1000).toBeLessThan(AppData.McpToolTimeoutMs); + }); + + test("tells the model how commands run", () => { + expect(withCommandTimeout({ tool: echoTool("sandbox_execute_bash") }).description).toContain("reads no input"); + }); + + test("leaves other tools alone", () => { + const tool = echoTool("sandbox_execute_code"); + expect(withCommandTimeout({ tool })).toBe(tool); + }); +}); diff --git a/src/harness/tools/computer/with-command-timeout.ts b/src/harness/tools/computer/with-command-timeout.ts new file mode 100644 index 0000000..f32ae5c --- /dev/null +++ b/src/harness/tools/computer/with-command-timeout.ts @@ -0,0 +1,64 @@ +import { AppData } from "@/src/data/app-data"; +import type { HarnessTool } from "../../types"; +import { shellQuote } from "./download-computer-file"; + +type Params = { + tool: HarnessTool; +}; + +/** + * Makes the computer's bash tool stop its commands on the computer. The + * sandbox ignores the tool's `timeout` and runs every call in one shared + * shell session, so a command that never ends (a network read without a + * timeout, a password prompt) used to keep running after the call gave up, + * and every later command on that computer, from any run, waited behind + * it. Each command now runs under `timeout` (its own, at most + * `ComputerCommandMaxSeconds`), reading no input, in its own bash, and + * says so when it was stopped. Other tools pass through. + */ +export default function withCommandTimeout({ tool }: Params): HarnessTool { + if (tool.name != "sandbox_execute_bash") return tool; + + const max = AppData.ComputerCommandMaxSeconds; + + return { + ...tool, + description: `${tool.description}\n\nEvery command runs in a fresh shell (cd and export don't carry over to the next call), reads no input (anything waiting for a prompt fails at once: pass -y, a password through its env, and so on) and is stopped after timeout seconds (at most ${max}, ${max} when not given). Start longer work in the background (nohup ... > log 2>&1 &) and check its log in later calls.`, + run: async ({ args, signal }) => { + if (typeof args?.cmd != "string") return tool.run({ args, signal }); + + const seconds = commandSeconds({ timeout: args.timeout }); + + return tool.run({ + args: { ...args, timeout: seconds + 10, cmd: timedCommand({ cmd: args.cmd, seconds }) }, + signal, + }); + }, + }; +} + +/** + * The command's own timeout in whole seconds, kept to 1..`ComputerCommandMaxSeconds`. + */ +function commandSeconds({ timeout }: { timeout?: unknown }) { + const max = AppData.ComputerCommandMaxSeconds; + const asked = typeof timeout == "number" ? timeout : Number(timeout); + + if (!Number.isFinite(asked) || asked <= 0) return max; + + return Math.min(max, Math.max(1, Math.ceil(asked))); +} + +/** + * `cmd` under `timeout` (TERM, then KILL 5 seconds later, to its whole + * process group), with its exit code kept. Never `exit`s: the sandbox's + * session shell would go with it. + */ +function timedCommand({ cmd, seconds }: { cmd: string; seconds: number }) { + return [ + `timeout -k 5 ${seconds} bash -c ${shellQuote({ text: cmd })} < /dev/null`, + `_cobble_status=$?`, + `if [ $_cobble_status -eq 124 ] || [ $_cobble_status -eq 137 ]; then echo "[Stopped after ${seconds}s: the command timed out. Run long work in the background and check its log.]"; fi`, + `(exit $_cobble_status)`, + ].join("; "); +}