From 2896e762560a8a71e2badce9d3547276638fcd60 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 28 Oct 2026 04:34:39 +0100 Subject: [PATCH] Stop computer bash commands on the computer itself The sandbox ignores sandbox_execute_bash's timeout and runs every call in one shared shell session, so a command that never ended (a network read without a timeout, a prompt) kept running after the call gave up, and every later command on that computer, from any run, timed out behind it. Each command now runs in its own bash under coreutils timeout (at most ComputerCommandMaxSeconds, below McpToolTimeoutMs), reading no input, and says when it was stopped. Replies' env list says where each key comes from and that workspace, computer and install-wide envs reach every bot there, so bots stop telling users a workspace's keys must be set per bot. The computer prompt says installed programs may be gone after a restart. An empty open-only task list says closed tasks aren't shown, and update_task says when a status was already set. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- src/bots-queue/compose-envs-note.test.ts | 12 ++++ src/bots-queue/compose-envs-note.ts | 34 ++++++++-- src/data/app-data.ts | 7 ++ src/data/harness-prompts.ts | 1 + .../tools/bot/tasks/list-tasks-tool.ts | 2 +- .../tools/bot/tasks/update-task-tool.ts | 4 +- .../tools/computer/open-computer-tools.ts | 6 +- .../computer/with-command-timeout.test.ts | 44 +++++++++++++ .../tools/computer/with-command-timeout.ts | 64 +++++++++++++++++++ 10 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 src/harness/tools/computer/with-command-timeout.test.ts create mode 100644 src/harness/tools/computer/with-command-timeout.ts 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("; "); +}