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 <[email protected]>
This commit is contained in:
2026-10-28 04:34:39 +01:00
co-authored by Claude Opus 5.5
parent e51f2aa9a4
commit 2896e76256
10 changed files with 165 additions and 12 deletions
+12
View File
@@ -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 });
+27 -7
View File
@@ -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<EnvParadigm, string> = {
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)}`
: "",
+7
View File
@@ -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/<McpDataDir>/<id>` (its setup installs there, so it
+1
View File
@@ -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.
@@ -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` : ""}.`;
}
@@ -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()) {
@@ -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({
@@ -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<string, unknown>) =>
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);
});
});
@@ -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("; ");
}