From 5b71f61f5595d69f0656ad2cf641935b4c92b0e6 Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Sat, 23 May 2026 06:00:28 +0800 Subject: [PATCH] fix(exec): uncap config exec timeout; 0 means no limit (#3595) --- nanobot/agent/tools/exec_session.py | 7 ++++--- nanobot/agent/tools/shell.py | 20 +++++++++++++++++--- tests/tools/test_tool_validation.py | 23 ++++++++++++++++++++++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/tools/exec_session.py b/nanobot/agent/tools/exec_session.py index 4dadb2d36..c23d175e2 100644 --- a/nanobot/agent/tools/exec_session.py +++ b/nanobot/agent/tools/exec_session.py @@ -53,14 +53,15 @@ class _ExecSession: process: asyncio.subprocess.Process, command: str, cwd: str, - timeout: int, + timeout: int | None, ) -> None: self.session_id = session_id self.process = process self.command = command self.cwd = cwd self.started_at = time.monotonic() - self.deadline = time.monotonic() + timeout + # timeout None/0 means no limit; an infinite deadline is never reached. + self.deadline = time.monotonic() + timeout if timeout else float("inf") self.last_access = time.monotonic() self._chunks: list[str] = [] self._lock = asyncio.Lock() @@ -169,7 +170,7 @@ class ExecSessionManager: command: str, cwd: str, env: dict[str, str], - timeout: int, + timeout: int | None, shell_program: str | None, login: bool, yield_time_ms: int, diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 537c89343..88c454bfa 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -46,7 +46,7 @@ _WORKSPACE_BOUNDARY_NOTE = ( class ExecToolConfig(Base): """Shell exec tool configuration.""" enable: bool = True - timeout: int = 60 + timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max. path_append: str = "" sandbox: str = "" allowed_env_keys: list[str] = Field(default_factory=list) @@ -59,7 +59,7 @@ class _PreparedCommand: command: str cwd: str env: dict[str, str] - timeout: int + timeout: int | None shell_program: str | None login: bool @@ -324,6 +324,20 @@ class ExecTool(Tool): except Exception as exc: return f"Error executing command: {exc}" + def _resolve_timeout(self, timeout: int | None) -> int | None: + """Resolve the effective hard timeout in seconds (None = no limit). + + A per-call timeout supplied by the model stays capped at _MAX_TIMEOUT so + the LLM cannot request unbounded execution. The config-level default + (self.timeout) may exceed that cap, and 0 disables the limit entirely + for trusted long-running tasks (#3595). + """ + if timeout: + return min(timeout, self._MAX_TIMEOUT) + if self.timeout and self.timeout > 0: + return self.timeout + return None + def _prepare_command( self, command: str, @@ -369,7 +383,7 @@ class ExecTool(Tool): command = wrap_command(self.sandbox, command, workspace, cwd) cwd = str(Path(workspace).resolve()) - effective_timeout = min(timeout or self.timeout, self._MAX_TIMEOUT) + effective_timeout = self._resolve_timeout(timeout) env = self._build_env() if self.path_append: diff --git a/tests/tools/test_tool_validation.py b/tests/tools/test_tool_validation.py index 188a8952f..6a775df75 100644 --- a/tests/tools/test_tool_validation.py +++ b/tests/tools/test_tool_validation.py @@ -4,6 +4,7 @@ import sys from typing import Any import pytest +from pydantic import ValidationError from nanobot.agent.tools import ( ArraySchema, @@ -16,7 +17,7 @@ from nanobot.agent.tools import ( ) from nanobot.agent.tools.base import Tool from nanobot.agent.tools.registry import ToolRegistry -from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.shell import ExecTool, ExecToolConfig from nanobot.security.network import configure_ssrf_whitelist @@ -663,6 +664,26 @@ async def test_exec_timeout_capped_at_max() -> None: assert "Exit code: 0" in result +def test_exec_config_timeout_uncapped_and_zero() -> None: + """Config timeout is no longer capped at 600 and accepts 0 = no limit (#3595).""" + assert ExecToolConfig(timeout=0).timeout == 0 + assert ExecToolConfig(timeout=3600).timeout == 3600 + with pytest.raises(ValidationError): + ExecToolConfig(timeout=-1) + + +def test_resolve_timeout_config_uncapped_and_unlimited() -> None: + """Config timeout drives the hard timeout uncapped; 0 means no limit (#3595).""" + assert ExecTool(timeout=3600)._resolve_timeout(None) == 3600 + assert ExecTool(timeout=0)._resolve_timeout(None) is None + + +def test_resolve_timeout_per_call_still_capped() -> None: + """Per-call (LLM) timeout stays capped at _MAX_TIMEOUT even with unlimited config.""" + assert ExecTool(timeout=0)._resolve_timeout(9999) == ExecTool._MAX_TIMEOUT + assert ExecTool(timeout=60)._resolve_timeout(120) == 120 + + # --- _resolve_type and nullable param tests ---