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,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("; ");
}