import asyncio import json import os as _os import shutil import tempfile import time import uuid from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Optional, Tuple from openspace.grounding.core.types import BackendType, ToolResult, ToolStatus from openspace.grounding.core.session import BaseSession from openspace.grounding.backends.shell.transport.local_connector import LocalShellConnector from openspace.grounding.core.tool import BaseTool from openspace.grounding.core.security.policies import SecurityPolicyManager from openspace.grounding.core.security.shell_parser import extract_output_redirections from openspace.grounding.backends.shell.bash_security import ( detect_blocked_sleep_pattern, get_destructive_command_warning, interpret_command_result, is_autobackgrounding_allowed, ) from openspace.prompts.tools.bash_prompt import get_simple_prompt from openspace.persistence.file_history import record_snapshot from openspace.services.tooling.context import ReadFileEntry from openspace.utils.logging import Logger logger = Logger.get_logger(__name__) # OpenSpace BashTool defaults. OpenSpace reads these from # ``utils/timeouts.ts`` which is environment-configurable; for OS we # hard-code the OpenSpace defaults of 2 minutes default / 10 minutes cap. # These values also match OpenSpace ``prompt.ts`` L335 which interpolates them # into the tool instructions. _DEFAULT_BASH_TIMEOUT_MS: int = 2 * 60 * 1000 _MAX_BASH_TIMEOUT_MS: int = 10 * 60 * 1000 _PROGRESS_THRESHOLD_MS: int = 2_000 _ASSISTANT_BLOCKING_BUDGET_MS: int = 15_000 _MAX_PERSISTED_SIZE: int = 64 * 1024 * 1024 # Filesystem layout for run_in_background outputs — mirrors OpenSpace # ``getTaskOutputPath`` (``utils/tasks.ts``): one file per task ID under # a stable tool-results subdirectory so subsequent Read calls can stream # the output. _BG_TASK_DIR_NAME = "openspace-bash-tasks" _HOOK_BACKGROUND_TASKS: set[asyncio.Task[Any]] = set() @dataclass(slots=True) class _SandboxExecutionPlan: manager: Any decision: Any wrapped: Any | None = None def _get_bg_task_dir() -> str: path = _os.path.join(tempfile.gettempdir(), _BG_TASK_DIR_NAME) _os.makedirs(path, exist_ok=True) return path def _get_bg_task_output_path(task_id: str) -> str: """OpenSpace ``getTaskOutputPath(taskId)`` — stable path for the merged fd stream.""" return _os.path.join(_get_bg_task_dir(), f"{task_id}.out") def _quick_sandbox_enabled_candidate(cwd: str | None) -> bool: env_value = _os.environ.get("OPENSPACE_SANDBOX_ENABLED") if env_value is not None: return env_value.strip().lower() in {"1", "true", "yes", "on"} candidates: list[str] = [] config_home = _os.environ.get("OPENSPACE_CONFIG_HOME") if config_home: candidates.append(_os.path.join(config_home, "settings.json")) else: candidates.append(_os.path.expanduser("~/.openspace/settings.json")) try: current = _os.path.abspath(_os.path.expanduser(cwd or _os.getcwd())) while True: candidates.append(_os.path.join(current, ".openspace", "settings.json")) candidates.append( _os.path.join(current, ".openspace", "settings.local.json") ) parent = _os.path.dirname(current) if parent == current: break current = parent except OSError: return True for candidate in candidates: if not _os.path.isfile(candidate): continue try: with open(candidate, "r", encoding="utf-8") as handle: raw = json.load(handle) except Exception: return True sandbox = raw.get("sandbox") if isinstance(raw, dict) else None if isinstance(sandbox, dict) and sandbox.get("enabled") is True: return True return False def _parse_shell_result(result: Any) -> Tuple[str, str, int]: """Parse a connector result dict into ``(stdout, stderr, returncode)``.""" if isinstance(result, dict): stdout = ( result.get("content") or result.get("output") or result.get("stdout") or "" ) stderr = result.get("error") or result.get("stderr") or "" rc = result.get("returncode", 0) return stdout, stderr, rc return str(result), "", 0 def _normalize_bash_timeout_seconds(timeout: int | float | None) -> int: timeout_ms = int(timeout) if timeout else _DEFAULT_BASH_TIMEOUT_MS timeout_ms = max(1000, min(timeout_ms, _MAX_BASH_TIMEOUT_MS)) return max(1, timeout_ms // 1000) def _env_truthy(name: str) -> bool: value = _os.environ.get(name) return str(value or "").strip().lower() in {"1", "true", "yes", "on"} def _background_tasks_disabled() -> bool: return _env_truthy("OPENSPACE_DISABLE_BACKGROUND_TASKS") def _assistant_blocking_budget_ms() -> int: raw = _os.environ.get("OPENSPACE_ASSISTANT_BLOCKING_BUDGET_MS") if raw is None: return _ASSISTANT_BLOCKING_BUDGET_MS try: return max(1, int(raw)) except ValueError: return _ASSISTANT_BLOCKING_BUDGET_MS class ShellSession(BaseSession): backend_type = BackendType.SHELL def __init__( self, connector: LocalShellConnector, *, session_id: str, security_manager: SecurityPolicyManager = None, default_working_dir: str = None, default_env: dict = None, default_conda_env: str = None, model: str = None, use_clawwork_productivity: bool = False, productivity_date: str = "default", ): super().__init__(connector=connector, session_id=session_id, backend_type=BackendType.SHELL) self.security_manager = security_manager self.default_working_dir = default_working_dir self.default_env = default_env or {} self.default_conda_env = default_conda_env self.model = model self.use_clawwork_productivity = use_clawwork_productivity self.productivity_date = productivity_date or "default" def configure_workspace(self, workspace_dir: str) -> None: """Update the default workspace used by shell-backed tools.""" self.default_working_dir = workspace_dir for tool in getattr(self, "tools", []): if hasattr(tool, "_default_working_dir"): setattr(tool, "_default_working_dir", workspace_dir) async def initialize(self): from openspace.grounding.backends.shell.file_tools import ( FileEditTool, ReadFileTool, WriteFileTool, ) from openspace.tools.notebook_edit_tool import NotebookEditTool from openspace.grounding.backends.shell.search_tools import ( GlobTool, GrepTool, ListDirTool, ) from openspace.tools.memory_tools import ( MemoryReadTool, MemoryWriteTool, ) self.tools = [ WriteFileTool(self), FileEditTool(self), NotebookEditTool(self), GrepTool(self), GlobTool(self), ListDirTool(self), MemoryReadTool(self), MemoryWriteTool(self), BashTool(self), ] if not self.use_clawwork_productivity: self.tools.insert(1, ReadFileTool(self)) if self.use_clawwork_productivity: from openspace.grounding.backends.shell.productivity_tools import get_productivity_tools extra = get_productivity_tools( self, data_path=self.default_working_dir, current_date=self.productivity_date, ) if extra: self.tools.extend(extra) logger.info("ClawWork productivity tools enabled: %s", [t.name for t in extra]) else: logger.warning("use_clawwork_productivity is True but livebench not available; productivity tools not added.") return {"tools": [t.name for t in self.tools]} class BashTool(BaseTool): """Run a shell command and return stdout/stderr. This class owns the bash tool schema, ``validate_input``, exit-code interpretation, optional background execution, classifier/permission checks, and process sandbox wrapping. Parameters: * ``command`` → executed via the shell connector * ``timeout`` → milliseconds, capped at ``_MAX_BASH_TIMEOUT_MS`` (10 min), default ``_DEFAULT_BASH_TIMEOUT_MS`` (2 min) * ``description`` → human summary for the permission dialog / transcript * ``run_in_background`` → detach and stream output to a per-task file (see :func:`_get_bg_task_output_path`) * ``dangerously_disable_sandbox`` → sandbox bypass request; honored only when sandbox policy allows unsandboxed commands Internal-only parameters: * ``_simulatedSedEdit`` → preview channel used by sed edit permission flows. It is omitted from the model-facing schema but accepted by ``_arun``. """ _name = "bash" _description = get_simple_prompt() backend_type = BackendType.SHELL search_hint = "execute shell commands" parameter_descriptions = { "command": "The command to execute", "timeout": f"Optional timeout in milliseconds (max {_MAX_BASH_TIMEOUT_MS}).", "description": '''Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does. For simple commands (git, npm, standard CLI tools), keep it brief (5-10 words): - ls -> "List files in current directory" - git status -> "Show working tree status" - npm install -> "Install package dependencies" For commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does: - find . -name "*.tmp" -exec rm {} \\; -> "Find and delete all .tmp files recursively" - git reset --hard origin/main -> "Discard all local changes and match remote main" - curl -s url | jq '.data[]' -> "Fetch JSON from URL and extract data array elements"''', "run_in_background": "Set to true to run this command in the background. Use Read to read the output later.", "backgroundedByUser": ( "Internal field: true if the user manually backgrounded the " "command with Ctrl+B." ), "assistantAutoBackgrounded": ( "Internal field: true if assistant-mode auto-backgrounded a " "long-running blocking command." ), "dangerously_disable_sandbox": ( "Set this to true to dangerously override sandbox mode and run " "commands without sandboxing." ), } # Default max result size for shell command output. max_result_size_chars = 30_000 # --- dynamic tool properties ---------------------------------------------- def is_read_only(self, input: dict | None = None) -> bool: """Return True iff the command is classified as read-only. Delegates to :func:`check_read_only_constraints` from :mod:`openspace.grounding.core.security.bash_classifier`. The classifier internally computes ``compound_command_has_cd`` when the caller does not provide it. Any non-string ``command`` or missing input returns ``False`` so the partitioner defaults to the serial path. """ if not isinstance(input, dict): return False command = input.get("command") if not isinstance(command, str) or not command.strip(): return False try: from openspace.grounding.core.security.bash_classifier import ( check_read_only_constraints, ) except Exception: return False try: context = getattr(self, "_current_context", None) cwd = ( input.get("cwd") if isinstance(input.get("cwd"), str) else getattr(context, "cwd", None) or getattr(self._session, "default_working_dir", None) ) original_cwd = ( input.get("original_cwd") if isinstance(input.get("original_cwd"), str) else getattr(context, "original_cwd", None) ) result = check_read_only_constraints( command, cwd=cwd, original_cwd=original_cwd, ) except Exception: return False return result.get("behavior") == "allow" def is_concurrency_safe(self, input: dict | None = None) -> bool: """Bash commands are concurrency-safe when they are read-only.""" return self.is_read_only(input) def is_destructive(self, input: dict | None = None) -> bool: """Best-effort destructive signal for the permission dialog. Destructive-command detection is surfaced through ``getDestructiveCommandWarning`` (see :mod:`bash_security`) and exposed here so ``partition_tool_calls`` and permission prompts can use the unified ``BaseTool.is_destructive`` abstraction. """ if not input: return False cmd = input.get("command") if isinstance(input, dict) else None if not isinstance(cmd, str): return False return get_destructive_command_warning(cmd) is not None # --- lifecycle ------------------------------------------------------ def __init__(self, session: "ShellSession"): self._session = session self._current_context: Any | None = None super().__init__() self.schema.description = get_simple_prompt() def get_prompt(self, context: Any | None = None) -> str: return get_simple_prompt() def set_context(self, context: Any) -> None: self._current_context = context async def _execute_raw(self, **kwargs) -> ToolResult: kwargs.pop("_skill_hook_command", None) simulated_edit = kwargs.pop("_simulatedSedEdit", None) if simulated_edit is None: return await super()._execute_raw(**kwargs) start = time.time() try: self.validate_parameters(kwargs) command = kwargs.get("command", "") or "" description = kwargs.get("description") dangerously_disable_sandbox = bool(kwargs.get("dangerously_disable_sandbox", False)) metadata: dict[str, Any] = {"tool": self._name} if description: metadata["description"] = description destructive_warning = ( get_destructive_command_warning(command) if isinstance(command, str) else None ) if destructive_warning: metadata["destructive_warning"] = destructive_warning if dangerously_disable_sandbox: metadata["dangerously_disable_sandbox"] = True raw = await self._apply_simulated_sed_edit( simulated_edit, metadata=metadata, ) result = self._wrap_result(raw, time.time() - start) except Exception as exc: if self.handle_errors: result = ToolResult( status=ToolStatus.ERROR, error=str(exc), metadata={"tool": self.schema.name}, ) else: raise await self._auto_record_execution( {**kwargs, "_simulatedSedEdit": simulated_edit}, result, time.time() - start, ) return result # --- OpenSpace checkPermissions ------------------------------------------- async def check_permissions(self, input: dict[str, Any], context: Any): """Delegate to bash_permissions.bash_tool_has_permission. OpenSpace behavior: ``BashTool.checkPermissions`` (BashTool.tsx L600+) calls ``bashToolHasPermission`` which performs the full subcommand cascade. Missing permission context is a runtime wiring error and fails closed. """ from openspace.grounding.core.permissions import ( PermissionAllow, bash_tool_has_permission, deny_missing_permission_context, ) perm_ctx = getattr(context, "permission_context", None) if perm_ctx is None: return deny_missing_permission_context(self._name) command = input.get("command", "") or "" if not isinstance(command, str): return PermissionAllow(updated_input=None) result = await bash_tool_has_permission( command=command, cwd=getattr(context, "cwd", _os.getcwd()), original_cwd=getattr(context, "original_cwd", None), description=input.get("description"), context=perm_ctx, tool_name=self._name, connector_kind="local", dangerously_disable_sandbox=bool( input.get("dangerously_disable_sandbox", False) ), ) if isinstance(result, PermissionAllow) and isinstance(result.updated_input, dict): return replace(result, updated_input={**input, **result.updated_input}) return result # --- OpenSpace validateInput ---------------------------------------------- async def validate_input( self, input: dict[str, Any], context: Any = None, ) -> Optional[str]: """OpenSpace ``BashTool.validateInput(input)`` (BashTool.tsx L524-538). Flags leading ``sleep N`` (``N >= 2``) patterns used as the first subcommand, steering the model toward ``run_in_background: true`` or the Monitor tool. OpenSpace gates this on the ``MONITOR_TOOL`` feature flag and the ``OPENSPACE_DISABLE_BACKGROUND_TASKS`` env var; OpenSpace applies the check unconditionally because the guidance is still useful. Returns ``None`` on success (OpenSpace ``{ result: true }``) or an error string (OpenSpace ``{ result: false, message, errorCode: 10 }``). """ command = input.get("command", "") if not isinstance(command, str): return None # Skip when the model has already opted into background — that # is exactly the escape hatch the error message is trying to # recommend. if input.get("run_in_background"): return None sleep_pattern = detect_blocked_sleep_pattern(command) if sleep_pattern is None: return None # Error message is a near-verbatim copy of OpenSpace's (L530), trimmed # of the Monitor-tool reference because that tool is out of scope. return ( f"Blocked: {sleep_pattern}. Run blocking commands in the " "background with run_in_background: true — you'll get the " "output file path immediately and can read it when the task " "finishes. If you genuinely need a delay (rate limiting, " "deliberate pacing), keep it under 2 seconds." ) # --- execution ------------------------------------------------------ async def _arun( self, command: str, timeout: int = _DEFAULT_BASH_TIMEOUT_MS, description: str | None = None, run_in_background: bool = False, backgroundedByUser: bool | None = None, assistantAutoBackgrounded: bool | None = None, dangerously_disable_sandbox: bool = False, ) -> ToolResult: """Execute the command. Implementation notes: * ``_simulatedSedEdit``: supported as an internal-only input, matching OpenSpace's permission preview apply path; it is not model-visible. * ``persistedOutputPath`` for large foreground outputs stores the raw merged fd stream on disk and emits a ```` wrapper. * Sandbox annotation (``SandboxManager.annotateStderrWithSandboxFailures``): stage 25.2 annotates sandboxed command output and records sandbox state in metadata. """ # Normalize milliseconds to seconds before delegating to the connector. timeout_sec = _normalize_bash_timeout_seconds(timeout) # Destructive-command warning is informational and can be surfaced by # permission prompts. destructive_warning = get_destructive_command_warning(command) if destructive_warning: logger.info("[bash] destructive command: %s", destructive_warning) metadata: dict[str, Any] = {"tool": self._name} if description: metadata["description"] = description if dangerously_disable_sandbox: metadata["dangerously_disable_sandbox"] = True if destructive_warning: metadata["destructive_warning"] = destructive_warning context = self._current_context effective_cwd = ( getattr(context, "cwd", None) or getattr(self._session, "default_working_dir", None) or None ) effective_env = dict(getattr(self._session, "default_env", None) or {}) conda_env = getattr(self._session, "default_conda_env", None) sandbox_plan, sandbox_error = await self._prepare_sandbox_execution( command, dangerously_disable_sandbox=dangerously_disable_sandbox, metadata=metadata, cwd=effective_cwd, env=effective_env, conda_env=conda_env, connector_kind="local", ) if sandbox_error is not None: return sandbox_error await self._record_output_redirection_snapshots( command, cwd=effective_cwd, metadata=metadata, ) if run_in_background and not _background_tasks_disabled(): return await self._run_in_background( command, description=description, metadata=metadata, sandbox_plan=sandbox_plan, cwd=effective_cwd, env=effective_env, conda_env=conda_env, ) if ( not _background_tasks_disabled() and getattr(self._current_context, "task_manager", None) is not None ): return await self._run_shell_command_with_progress( command, timeout_sec=timeout_sec, description=description, metadata=metadata, initial_backgrounded_by_user=bool(backgroundedByUser), initial_assistant_auto_backgrounded=bool(assistantAutoBackgrounded), sandbox_plan=sandbox_plan, cwd=effective_cwd, env=effective_env, conda_env=conda_env, ) return await self._run_foreground_direct( command, timeout_sec=timeout_sec, metadata=metadata, sandbox_plan=sandbox_plan, cwd=effective_cwd, env=effective_env, conda_env=conda_env, ) async def _prepare_sandbox_execution( self, command: str, *, dangerously_disable_sandbox: bool, metadata: dict[str, Any], cwd: str | None, env: dict[str, str], conda_env: str | None, connector_kind: str, ) -> tuple[_SandboxExecutionPlan | None, ToolResult | None]: from openspace.grounding.backends.shell.transport.local_connector import ( _wrap_script_with_conda, ) if ( not dangerously_disable_sandbox and not _quick_sandbox_enabled_candidate(cwd) ): return None, None if not dangerously_disable_sandbox: from openspace.services.runtime_support.settings import get_effective_settings sandbox_settings = get_effective_settings(cwd).get("sandbox") if not ( isinstance(sandbox_settings, dict) and bool(sandbox_settings.get("enabled", False)) ): return None, None from openspace.services.sandbox import get_process_sandbox_manager manager = get_process_sandbox_manager(cwd=cwd) if not manager.is_enabled_in_settings() and not dangerously_disable_sandbox: return None, None from openspace.services.sandbox.should_use_sandbox import ( ShouldUseSandboxInput, should_use_sandbox, ) decision = should_use_sandbox( ShouldUseSandboxInput( command=command, dangerously_disable_sandbox=dangerously_disable_sandbox, cwd=cwd, connector_kind=connector_kind, ), sandbox_manager=manager, ) plan = _SandboxExecutionPlan(manager=manager, decision=decision) metadata["sandbox"] = self._sandbox_metadata(manager, decision) if decision.bypassed: metadata["sandbox_bypassed"] = True metadata["sandbox"]["bypassed"] = True metadata["sandbox"]["bypass_reason"] = decision.reason if not decision.should_sandbox: if manager.is_sandbox_required() and decision.reason not in { "settings_disabled", "empty_command", }: return plan, ToolResult( status=ToolStatus.ERROR, content=( "Sandbox is required but unavailable for this command: " f"{decision.unavailable_reason or decision.reason}" ), metadata=metadata, ) return plan, None command_for_sandbox = _wrap_script_with_conda(command, conda_env) try: wrapped = await manager.wrap_command( command_for_sandbox, cwd=cwd, env=env or None, shell="/bin/bash", ) except Exception as exc: metadata["sandbox"]["applied"] = False metadata["sandbox"]["unavailable_reason"] = str(exc) return plan, ToolResult( status=ToolStatus.ERROR, content=f"bash failed to start sandbox: {exc}", metadata=metadata, ) plan.wrapped = wrapped metadata["sandbox"].update(wrapped.to_metadata()) metadata["sandbox"]["enabled"] = True metadata["sandbox"]["applied"] = True return plan, None def _sandbox_metadata(self, manager: Any, decision: Any) -> dict[str, Any]: warnings = [ issue.message for issue in manager.check_dependencies() if getattr(issue, "severity", None) == "warning" ] return { "requested": manager.is_enabled_in_settings(), "enabled": manager.is_sandboxing_enabled(), "applied": False, "platform": manager.platform, "policy_name": manager.runtime_config().policy.name, "bypassed": bool(decision.bypassed), "bypass_reason": decision.reason if decision.bypassed else None, "reason": decision.reason, "unavailable_reason": decision.unavailable_reason, "dependency_warnings": warnings, "violation_count": 0, } async def _run_foreground_direct( self, command: str, *, timeout_sec: int, metadata: dict[str, Any], sandbox_plan: _SandboxExecutionPlan | None, cwd: str | None, env: dict[str, str], conda_env: str | None, ) -> ToolResult: wrapped = sandbox_plan.wrapped if sandbox_plan is not None else None try: if wrapped is not None: result = await self._session.connector.run_command_argv( wrapped.argv, timeout=timeout_sec, working_dir=wrapped.cwd, env=wrapped.env, security_command=command, ) else: result = await self._session.connector.run_bash_command( command, timeout=timeout_sec, working_dir=cwd, env=env or None, conda_env=conda_env, ) stdout, stderr, rc = _parse_shell_result(result) stdout, stderr = self._apply_sandbox_annotation( command, stdout or "", stderr or "", sandbox_plan, metadata, ) tool_result = self._build_completed_result( command, stdout=stdout or "", stderr=stderr or "", rc=rc, metadata=metadata, ) await self._emit_bash_command_executed( command, stdout=stdout or "", stderr=stderr or "", rc=rc, interrupted=False, ) return tool_result except Exception as e: return ToolResult( status=ToolStatus.ERROR, content=f"bash failed: {e}", metadata=metadata, ) finally: if wrapped is not None: try: await sandbox_plan.manager.cleanup_after_command(wrapped) except Exception: logger.debug("sandbox cleanup failed", exc_info=True) async def _run_shell_command_with_progress( self, command: str, *, timeout_sec: int, description: str | None, metadata: dict[str, Any], initial_backgrounded_by_user: bool = False, initial_assistant_auto_backgrounded: bool = False, sandbox_plan: _SandboxExecutionPlan | None = None, cwd: str | None = None, env: dict[str, str] | None = None, conda_env: str | None = None, ) -> ToolResult: from openspace.agents.task_manager import ( TaskType, generate_task_id, get_task_output_path, ) from openspace.grounding.backends.shell.shell_command_handle import ( BackgroundShellHandle, BackgroundShellStatus, ) context = self._current_context manager = getattr(context, "task_manager", None) task_id = generate_task_id(TaskType.LOCAL_BASH) output_path = get_task_output_path(task_id, manager.output_dir) effective_cwd = cwd or getattr(context, "cwd", None) or getattr(self._session, "default_working_dir", None) or None effective_env = dict(env or getattr(self._session, "default_env", None) or {}) wrapped = sandbox_plan.wrapped if sandbox_plan is not None else None try: shell_command = await BackgroundShellHandle.spawn( command, task_id=task_id, output_path=output_path, cwd=wrapped.cwd if wrapped is not None else effective_cwd, env=wrapped.env if wrapped is not None else (effective_env or None), conda_env=None if wrapped is not None else conda_env, argv=wrapped.argv if wrapped is not None else None, cleanup_callbacks=self._sandbox_cleanup_callbacks(sandbox_plan), output_transform=self._sandbox_output_transform( command, sandbox_plan, metadata, ), ) except Exception as exc: return ToolResult( status=ToolStatus.ERROR, content=f"bash failed to start: {exc}", metadata=metadata, ) start = time.monotonic() timeout_deadline = start + timeout_sec budget_ms = _assistant_blocking_budget_ms() should_auto_background = is_autobackgrounding_allowed(command) progress_deadline = ( start + (_PROGRESS_THRESHOLD_MS / 1000) if should_auto_background else float("inf") ) assistant_budget_enabled = ( should_auto_background and not getattr(context, "is_async_agent", False) and budget_ms > 0 ) assistant_budget_deadline = start + (budget_ms / 1000) foreground_task = None background_task_id: str | None = None assistant_auto_backgrounded = initial_assistant_auto_backgrounded backgrounded_by_user = initial_backgrounded_by_user async def register_foreground_if_needed() -> Any: nonlocal foreground_task if foreground_task is not None: return foreground_task foreground_task = await manager.register_local_shell_task( command=command, description=description or command, shell_command=shell_command, task_id=task_id, tool_use_id=metadata.get("tool_use_id"), agent_id=getattr(context, "agent_id", None), is_backgrounded=False, notification_queue=getattr(context, "async_rewake_queue", None), kind="bash", finalize_on_completion=False, ) return foreground_task async def start_backgrounding( *, by_user: bool = False, by_assistant_budget: bool = False, ) -> str | None: nonlocal foreground_task, background_task_id if shell_command.result.done(): return None if foreground_task is None: foreground_task = await manager.register_local_shell_task( command=command, description=description or command, shell_command=shell_command, task_id=task_id, tool_use_id=metadata.get("tool_use_id"), agent_id=getattr(context, "agent_id", None), is_backgrounded=True, backgrounded_by_user=by_user, assistant_auto_backgrounded=by_assistant_budget, notification_queue=getattr( context, "async_rewake_queue", None ), kind="bash", finalize_on_completion=True, ) else: await manager.background_existing_foreground_shell_task( foreground_task.id, backgrounded_by_user=by_user, assistant_auto_backgrounded=by_assistant_budget, ) background_task_id = foreground_task.id return background_task_id try: while True: if shell_command.result.done(): result = shell_command.result.result() if foreground_task is not None: if getattr(foreground_task, "is_backgrounded", False): manager.mark_task_notified(foreground_task.id) else: await manager.unregister_foreground_shell_task( foreground_task.id, result=result, ) await shell_command.cleanup() output = self._read_shell_output(str(output_path)) tool_result = self._build_completed_result( command, stdout=output, stderr="", rc=result.code, metadata=metadata, output_file_path=str(output_path), output_task_id=task_id, ) await self._emit_bash_command_executed( command, stdout=output, stderr="", rc=result.code, interrupted=result.interrupted, ) return tool_result if foreground_task is not None and ( getattr(foreground_task, "is_backgrounded", False) or shell_command.status == BackgroundShellStatus.BACKGROUNDED ): background_task_id = foreground_task.id backgrounded_by_user = bool( backgrounded_by_user or getattr(foreground_task, "backgrounded_by_user", False) ) assistant_auto_backgrounded = bool( assistant_auto_backgrounded or getattr( foreground_task, "assistant_auto_backgrounded", False, ) ) return await self._build_background_result( command, metadata=metadata, task_id=background_task_id, output_path=str(output_path), pid=shell_command.pid, backgrounded_by_user=backgrounded_by_user, assistant_auto_backgrounded=assistant_auto_backgrounded, ) now = time.monotonic() if now >= timeout_deadline: if should_auto_background: maybe_task_id = await start_backgrounding() if maybe_task_id is not None: return await self._build_background_result( command, metadata=metadata, task_id=maybe_task_id, output_path=str(output_path), pid=shell_command.pid, ) await shell_command.kill("TERM") result = await shell_command.result output = self._read_shell_output(str(output_path)) tool_result = self._build_completed_result( command, stdout=output or f"Command timed out after {timeout_sec} seconds", stderr="", rc=result.code, metadata=metadata, ) await self._emit_bash_command_executed( command, stdout=output, stderr="", rc=result.code, interrupted=True, ) return tool_result if assistant_budget_enabled and now >= assistant_budget_deadline: assistant_auto_backgrounded = True maybe_task_id = await start_backgrounding( by_assistant_budget=True ) if maybe_task_id is not None: return await self._build_background_result( command, metadata=metadata, task_id=maybe_task_id, output_path=str(output_path), pid=shell_command.pid, assistant_auto_backgrounded=True, ) if should_auto_background and now >= progress_deadline: await register_foreground_if_needed() progress_deadline = float("inf") next_deadline = min(timeout_deadline, progress_deadline) if assistant_budget_enabled: next_deadline = min(next_deadline, assistant_budget_deadline) sleep_for = min(0.1, max(0.01, next_deadline - now)) await asyncio.sleep(sleep_for) except Exception: if not shell_command.result.done(): await shell_command.kill("TERM") await shell_command.cleanup() raise def _apply_sandbox_annotation( self, command: str, stdout: str, stderr: str, sandbox_plan: _SandboxExecutionPlan | None, metadata: dict[str, Any], ) -> tuple[str, str]: if sandbox_plan is None or sandbox_plan.wrapped is None: return stdout, stderr combined = stdout if stderr: combined = f"{combined}\n{stderr}" if combined else stderr annotated = sandbox_plan.manager.annotate_stderr_with_sandbox_failures( command, combined, command_tag=sandbox_plan.wrapped.command_tag, ) self._record_sandbox_notes( metadata, combined, annotated, manager=sandbox_plan.manager, command_tag=sandbox_plan.wrapped.command_tag, ) return annotated, "" def _sandbox_output_transform( self, command: str, sandbox_plan: _SandboxExecutionPlan | None, metadata: dict[str, Any] | None = None, ): if sandbox_plan is None or sandbox_plan.wrapped is None: return None def _transform(output: str) -> str: annotated = sandbox_plan.manager.annotate_stderr_with_sandbox_failures( command, output, command_tag=sandbox_plan.wrapped.command_tag, ) if metadata is not None: self._record_sandbox_notes( metadata, output, annotated, manager=sandbox_plan.manager, command_tag=sandbox_plan.wrapped.command_tag, ) return annotated return _transform def _sandbox_cleanup_callbacks( self, sandbox_plan: _SandboxExecutionPlan | None, ) -> list[Any] | None: if sandbox_plan is None or sandbox_plan.wrapped is None: return None return [lambda: sandbox_plan.manager.cleanup_after_command(sandbox_plan.wrapped)] @staticmethod def _record_sandbox_notes( metadata: dict[str, Any], original: str, annotated: str, *, manager: Any | None = None, command_tag: str | None = None, ) -> None: if annotated == original: return note = annotated[len(original) :].strip() if annotated.startswith(original) else annotated if note: metadata["sandbox_notes"] = note sandbox_meta = metadata.get("sandbox") if isinstance(sandbox_meta, dict): sandbox_meta["violation_count"] = max( int(sandbox_meta.get("violation_count") or 0), 1 if "" in annotated else 0, ) violations: list[dict[str, Any]] = [] if manager is not None and command_tag: try: violations = [ item.to_json() for item in manager.get_violation_store().for_command_tag(command_tag)[-10:] ] except Exception: violations = [] if not violations and "" in annotated: violations = [{"raw_message": note}] if violations: sandbox_meta["violations"] = violations def _read_shell_output(self, output_path: str) -> str: try: with open(output_path, "r", encoding="utf-8", errors="replace") as handle: return handle.read() except OSError: return "" def _build_completed_result( self, command: str, *, stdout: str, stderr: str, rc: int, metadata: dict[str, Any], output_file_path: str | None = None, output_task_id: str | None = None, ) -> ToolResult: interp = interpret_command_result(command, rc, stdout or "", stderr or "") output = stdout or "" if stderr: output = f"{output}\n[STDERR]\n{stderr}" if output else stderr if output_file_path and output_task_id: output = self._persist_large_shell_output( output, output_file_path=output_file_path, output_task_id=output_task_id, metadata=metadata, ) if interp.is_error and rc != 0 and not output: output = f"Command exited with code {rc}" if interp.message: output = f"{output}\n({interp.message})" if output else f"({interp.message})" metadata["return_code_interpretation"] = interp.message metadata["exit_code"] = rc if output_file_path: metadata["output_file_path"] = output_file_path status = ToolStatus.ERROR if interp.is_error else ToolStatus.SUCCESS return ToolResult( status=status, content=output or "(no output)", metadata=metadata, ) def _persist_large_shell_output( self, output: str, *, output_file_path: str, output_task_id: str, metadata: dict[str, Any], ) -> str: try: original_size = _os.path.getsize(output_file_path) except OSError: return output if original_size <= self.max_result_size_chars: return output try: if original_size > _MAX_PERSISTED_SIZE: _os.truncate(output_file_path, _MAX_PERSISTED_SIZE) metadata["output_truncated_to_bytes"] = _MAX_PERSISTED_SIZE from openspace.services.tooling.results import ( PersistedToolResult, build_persisted_output_message, generate_preview, get_results_dir, ) context = self._current_context results_dir = get_results_dir(getattr(context, "tool_results_dir", None)) dest = _os.path.join(results_dir, f"{output_task_id}.txt") if not _os.path.exists(dest): try: _os.link(output_file_path, dest) except OSError: shutil.copyfile(output_file_path, dest) preview, has_more = generate_preview(output) metadata.update( { "persisted_output_path": dest, "persisted_output_size": original_size, "persisted_path": dest, "original_length": original_size, } ) message = build_persisted_output_message( PersistedToolResult( filepath=dest, original_size=original_size, preview=preview, has_more=has_more or original_size > len(output), ) ) if original_size > _MAX_PERSISTED_SIZE: message += ( f"\nOutput truncated to {_MAX_PERSISTED_SIZE} bytes " f"(original size: {original_size} bytes)." ) return message except Exception: return output async def _build_background_result( self, command: str, *, metadata: dict[str, Any], task_id: str, output_path: str, pid: int | None, backgrounded_by_user: bool = False, assistant_auto_backgrounded: bool = False, ) -> ToolResult: metadata.update( { "background_task_id": task_id, "background_output_path": output_path, "background_pid": pid, "background_task_type": "local_bash", "background_semantics": "task_manager", } ) if backgrounded_by_user: metadata["backgrounded_by_user"] = True metadata["backgroundedByUser"] = True if assistant_auto_backgrounded: metadata["assistant_auto_backgrounded"] = True metadata["assistantAutoBackgrounded"] = True if assistant_auto_backgrounded: content = ( "Command exceeded the assistant-mode blocking budget " f"({_ASSISTANT_BLOCKING_BUDGET_MS // 1000}s) and was moved " f"to the background with ID: {task_id}. It is still running - " "you will be notified when it completes. Output is being " f"written to: {output_path}. In assistant mode, delegate " "long-running work to a subagent or use run_in_background " "to keep this conversation responsive." ) elif backgrounded_by_user: content = ( f"Command was manually backgrounded by user with ID: {task_id}. " f"Output is being written to: {output_path}" ) else: content = ( f"Command running in background with ID: {task_id}. " f"Output is being written to: {output_path} (pid {pid}).\n" "Use TaskGet to inspect status/output or TaskStop to stop it." ) await self._emit_bash_command_executed( command, stdout="", stderr="", rc=0, interrupted=False, ) return ToolResult( status=ToolStatus.SUCCESS, content=content, metadata=metadata, ) async def _record_output_redirection_snapshots( self, command: str, *, cwd: str | None, metadata: dict[str, Any], ) -> None: context = self._current_context if context is None: return try: parsed = extract_output_redirections(command) except Exception: return if parsed.has_dangerous_redirection or not parsed.redirections: return targets: list[str] = [] for redirection in parsed.redirections: path = self._resolve_redirection_target(redirection.target, cwd=cwd) if path is None: continue await record_snapshot(path, context=context) targets.append(str(path)) if targets: metadata["file_history_redirection_paths"] = targets def _resolve_redirection_target( self, target: str, *, cwd: str | None, ) -> str | None: raw = str(target or "").strip() if not raw: return None if raw in {"/dev/null", "/dev/stdout", "/dev/stderr"}: return None raw = raw.strip("'\"") path = Path(raw).expanduser() if not path.is_absolute(): base = Path(cwd or getattr(self._current_context, "cwd", None) or _os.getcwd()) path = base / path try: return str(path.resolve(strict=False)) except OSError: return str(path) async def _emit_bash_command_executed( self, command: str, *, stdout: str, stderr: str, rc: int, interrupted: bool, ) -> None: context = self._current_context if context is None or not hasattr(context, "emit_event"): return await context.emit_event( "bash_tool_command_executed", { "command_type": (command.split(" ")[0] if command else "other"), "stdout_length": len(stdout or ""), "stderr_length": len(stderr or ""), "exit_code": rc, "interrupted": interrupted, }, ) async def _apply_simulated_sed_edit( self, simulated_edit: dict[str, Any], *, metadata: dict[str, Any], ) -> ToolResult: file_path = simulated_edit.get("filePath") or simulated_edit.get("file_path") new_content = simulated_edit.get("newContent", simulated_edit.get("new_content")) if not isinstance(file_path, str) or not isinstance(new_content, str): return ToolResult( status=ToolStatus.ERROR, content="Invalid simulated sed edit payload.", metadata=metadata, ) base_dir = ( getattr(self._current_context, "cwd", None) or getattr(self._session, "default_working_dir", None) or _os.getcwd() ) absolute_path = file_path if _os.path.isabs(file_path) else _os.path.join(base_dir, file_path) try: raw_original = b"" try: with open(absolute_path, "rb") as handle: raw_original = handle.read() except FileNotFoundError: return ToolResult( status=ToolStatus.ERROR, content=f"sed: {file_path}: No such file or directory\nExit code 1", metadata={**metadata, "exit_code": 1}, ) await record_snapshot(absolute_path, context=self._current_context) is_utf16le = ( len(raw_original) >= 2 and raw_original[0] == 0xFF and raw_original[1] == 0xFE ) has_crlf = b"\r\n" in raw_original write_content = new_content.replace("\n", "\r\n") if has_crlf else new_content if is_utf16le: with open(absolute_path, "wb") as handle: handle.write(write_content.encode("utf-16-le")) else: with open(absolute_path, "w", encoding="utf-8") as handle: handle.write(write_content) ctx = self._current_context if ctx is not None and hasattr(ctx, "read_file_state"): ctx.read_file_state[absolute_path] = ReadFileEntry( content=new_content, timestamp=_os.stat(absolute_path).st_mtime_ns, offset=None, limit=None, is_partial_view=False, ) return ToolResult( status=ToolStatus.SUCCESS, content="(no output)", metadata={**metadata, "simulated_sed_edit": True, "file_path": absolute_path}, ) except Exception as exc: return ToolResult( status=ToolStatus.ERROR, content=f"sed edit failed: {exc}", metadata=metadata, ) async def _run_in_background( self, command: str, *, description: str | None, metadata: dict[str, Any], sandbox_plan: _SandboxExecutionPlan | None = None, cwd: str | None = None, env: dict[str, str] | None = None, conda_env: str | None = None, ) -> ToolResult: """Launch *command* detached, stream output to a task file. Implementation: the ``backgroundTaskId`` branch of ``runShellCommand`` (BashTool.tsx L854-...). OpenSpace owns a full ``TaskManager`` (app-state map of running tasks, Ctrl+B interrupt, completion notifications, auto-backgrounding budget). OS has none of that infrastructure yet, so this implementation delivers the *contract* only: * spawn the command detached via ``asyncio.create_subprocess_shell``; * merge stdout+stderr into ``_get_bg_task_output_path(task_id)``; * return immediately with the task id + output path so the model can ``read(output_path)`` when it chooses. Deferred to a later phase (documented so reviewers know): * assistant-mode auto-backgrounding when foreground exceeds a budget (OpenSpace ``assistantAutoBackgrounded``); * user-initiated backgrounding via Ctrl+B (OpenSpace ``backgroundedByUser``); * completion notifications and task-state querying (OpenSpace ``tengu_bash_tool_command_executed`` + TaskManager hooks); * 64 MB truncation of persisted output (OpenSpace ``MAX_PERSISTED_SIZE``). """ manager = getattr(self._current_context, "task_manager", None) wrapped = sandbox_plan.wrapped if sandbox_plan is not None else None if manager is not None: try: effective_cwd = ( cwd or getattr(self._current_context, "cwd", None) or getattr(self._session, "default_working_dir", None) or None ) effective_env = dict(env or getattr(self._session, "default_env", None) or {}) task = await manager.spawn_local_shell_task( command=command, description=description or command, cwd=wrapped.cwd if wrapped is not None else effective_cwd, env=wrapped.env if wrapped is not None else (effective_env or None), conda_env=None if wrapped is not None else conda_env, argv=wrapped.argv if wrapped is not None else None, cleanup_callbacks=self._sandbox_cleanup_callbacks(sandbox_plan), output_transform=self._sandbox_output_transform( command, sandbox_plan, metadata, ), agent_id=getattr(self._current_context, "agent_id", None), notification_queue=getattr( self._current_context, "async_rewake_queue", None ), is_backgrounded=True, kind="bash", ) except Exception as exc: return ToolResult( status=ToolStatus.ERROR, content=f"bash failed to start background task: {exc}", metadata=metadata, ) metadata.update( { "background_task_id": task.id, "background_output_path": task.output_file, "background_pid": task.pid, "background_task_type": task.type.value, "background_semantics": "task_manager", } ) content = ( f"Command running in background with ID: {task.id}. " f"Output is being written to: {task.output_file} (pid {task.pid}).\n" "Use TaskGet to inspect status/output or TaskStop to stop it." ) await self._emit_bash_command_executed( command, stdout="", stderr="", rc=0, interrupted=False, ) return ToolResult( status=ToolStatus.SUCCESS, content=content, metadata=metadata, ) task_id = f"bash-{uuid.uuid4().hex[:12]}" output_path = _get_bg_task_output_path(task_id) # Use /dev/null for stdin and redirect stderr → stdout so the # merged stream matches OpenSpace's ``merged fd`` convention. output_file = open(output_path, "wb") try: if wrapped is not None: process = await asyncio.create_subprocess_exec( *wrapped.argv, stdin=asyncio.subprocess.DEVNULL, stdout=output_file, stderr=asyncio.subprocess.STDOUT, cwd=wrapped.cwd, env={**_os.environ, **wrapped.env}, start_new_session=True, ) else: process = await asyncio.create_subprocess_shell( command, stdin=asyncio.subprocess.DEVNULL, stdout=output_file, stderr=asyncio.subprocess.STDOUT, start_new_session=True, # detach from parent process group ) except Exception as e: output_file.close() return ToolResult( status=ToolStatus.ERROR, content=f"bash failed to start background task: {e}", metadata=metadata, ) # Intentionally do NOT await process.wait() — we release control # to the caller immediately. We close our copy of the fd because # the child already inherited it; the child keeps writing. output_file.close() if wrapped is not None: async def _cleanup_detached() -> None: await process.wait() try: current = "" try: with open(output_path, "r", encoding="utf-8", errors="replace") as handle: current = handle.read() except OSError: current = "" transform = self._sandbox_output_transform(command, sandbox_plan, metadata) if transform is not None: transformed = transform(current) if transformed != current: with open(output_path, "w", encoding="utf-8", errors="replace") as handle: handle.write(transformed) await sandbox_plan.manager.cleanup_after_command(wrapped) except Exception: logger.debug("detached sandbox cleanup failed", exc_info=True) task = asyncio.create_task(_cleanup_detached()) _HOOK_BACKGROUND_TASKS.add(task) task.add_done_callback(_HOOK_BACKGROUND_TASKS.discard) metadata.update({ "background_task_id": task_id, "background_output_path": output_path, "background_pid": process.pid, }) content = ( f"Command running in background with ID: {task_id}. " f"Output is being written to: {output_path} (pid {process.pid}).\n" "Use read on the output path to inspect progress / final output." ) await self._emit_bash_command_executed( command, stdout="", stderr="", rc=0, interrupted=False, ) return ToolResult( status=ToolStatus.SUCCESS, content=content, metadata=metadata, )