OpenSpace/openspace/grounding/backends/shell/session.py
2026-07-17 11:43:42 +08:00

1657 lines
64 KiB
Python

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. The result "
"will include an output file path; inspect it later with a short "
"bash command such as `cat <path>` or `tail <path>`, unless a "
"TaskGet tool is actually available in this turn."
),
"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 ``<persisted-output>`` 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 "<sandbox_violations>" 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 "<sandbox_violations>" 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. If you need the output "
"before continuing and TaskGet is available in this turn, use "
f"`TaskGet(task_id=\"{task_id}\", block=true, timeout=600000)`. "
"Otherwise inspect it with a short bash command such as "
f"`cat {output_path}` or `tail {output_path}`."
)
elif backgrounded_by_user:
content = (
f"Command was manually backgrounded by user with ID: {task_id}. "
f"Output is being written to: {output_path}. Inspect it with "
f"`cat {output_path}` or `tail {output_path}`."
)
else:
content = (
f"Command running in background with ID: {task_id}. "
f"Output is being written to: {output_path} (pid {pid}).\n"
"If TaskGet is available in this turn, inspect output with "
f"`TaskGet(task_id=\"{task_id}\", block=true, timeout=600000)`. "
"Otherwise use a short bash command such as "
f"`cat {output_path}` or `tail {output_path}`."
)
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"
"If TaskGet is available in this turn, inspect output with "
f"`TaskGet(task_id=\"{task.id}\", block=true, timeout=600000)`. "
"Otherwise use a short bash command such as "
f"`cat {task.output_file}` or `tail {task.output_file}`."
)
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:
from openspace.grounding.backends.shell.transport.local_connector import (
_wrap_script_with_conda,
)
final_command = _wrap_script_with_conda(command, conda_env)
effective_env = {**_os.environ, **(env or {})}
bash_path = shutil.which("bash", path=effective_env.get("PATH"))
if bash_path:
process = await asyncio.create_subprocess_exec(
bash_path,
"-lc",
final_command,
stdin=asyncio.subprocess.DEVNULL,
stdout=output_file,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd or None,
env=effective_env,
start_new_session=True, # detach from parent process group
)
else:
process = await asyncio.create_subprocess_shell(
final_command,
stdin=asyncio.subprocess.DEVNULL,
stdout=output_file,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd or None,
env=effective_env,
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"
"If TaskGet is available in this turn, inspect output with "
f"`TaskGet(task_id=\"{task_id}\", block=true, timeout=600000)`. "
"Otherwise use a short bash command such as "
f"`cat {output_path}` or `tail {output_path}`."
)
await self._emit_bash_command_executed(
command,
stdout="",
stderr="",
rc=0,
interrupted=False,
)
return ToolResult(
status=ToolStatus.SUCCESS,
content=content,
metadata=metadata,
)