fix(exec): uncap config exec timeout; 0 means no limit (#3595)

This commit is contained in:
04cb 2026-05-23 06:00:28 +08:00 committed by Xubin Ren
parent 5937236f9d
commit 5b71f61f55
3 changed files with 43 additions and 7 deletions

View File

@ -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,

View File

@ -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:

View File

@ -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 ---