Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions src/lecode/agent/tools/bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Commands are first passed through ``rtk rewrite``, which swaps supported
commands for their token-optimized rtk proxies (``git status`` → ``rtk git
status``); the original command runs unchanged when rtk has no equivalent
(fail-open). Runs via ``/bin/sh -c`` with stderr merged into stdout. Output
(fail-open). Runs via the user's shell (``$SHELL``, falling back to
``/bin/sh``) with ``-c``, stderr merged into stdout. Output
over the cap is truncated head/tail and the full text is saved to
``<config_dir>/overflow/<uuid>.log`` with a pointer line.
"""
Expand All @@ -23,14 +24,15 @@
from lecode.agent.tools.base import Tool, ToolContext, ToolResult
from lecode.extras.proc import ProcResult
from lecode.extras.rtk import rewrite_command
from lecode.extras.shell import user_shell

DEFAULT_TIMEOUT_S = 120.0
MAX_TIMEOUT_S = 600.0
MAX_OUTPUT_BYTES = 60_000


def _kill_tree(proc: asyncio.subprocess.Process) -> None:
"""Kill the process *group* — ``sh -c`` children must not survive."""
"""Kill the process *group* — shell children must not survive."""
import os
import signal

Expand All @@ -48,18 +50,21 @@ async def _run_shell(
idle_timeout: float,
max_bytes: int,
*,
shell: str | None = None,
on_chunk: Callable[[bytes], None] | None = None,
proc_slot: dict[str, Any] | None = None,
) -> tuple[bytes, int, bool, bool]:
"""Run a shell command; returns (output, exit_code, timed_out, idle_killed).

``on_chunk`` (background tasks) receives each chunk as it arrives; the
retained buffer is then capped at ``max_bytes`` (the tail), since the
caller streams the full output elsewhere. ``proc_slot`` receives the
spawned process under ``"proc"`` so the caller can signal it.
``shell`` overrides the executable; the default is the user's shell
(:func:`lecode.extras.shell.user_shell`). ``on_chunk`` (background tasks)
receives each chunk as it arrives; the retained buffer is then capped at
``max_bytes`` (the tail), since the caller streams the full output
elsewhere. ``proc_slot`` receives the spawned process under ``"proc"`` so
the caller can signal it.
"""
proc = await asyncio.create_subprocess_exec(
"/bin/sh",
shell or user_shell(),
"-c",
command,
cwd=cwd,
Expand Down Expand Up @@ -137,11 +142,12 @@ def _save_overflow(output: BinaryIO) -> tuple[Path, str, str]:


class BashTool(Tool):
def __init__(self) -> None:
def __init__(self, *, shell: str | None = None) -> None:
self._shell = shell or user_shell()
super().__init__(
name="bash",
description=(
"Run a shell command (/bin/sh -c). Supported commands are "
f"Run a shell command ({self._shell} -c). Supported commands are "
"rewritten to token-optimized rtk proxies when available; "
"output is truncated head/tail (full output saved to a file)."
),
Expand Down Expand Up @@ -204,7 +210,13 @@ async def run(self, args: dict, ctx: ToolContext) -> ToolResult:
try:
with tempfile.SpooledTemporaryFile(max_size=MAX_OUTPUT_BYTES) as output:
_, exit_code, timed_out, idle_killed = await _run_shell(
command, ctx.cwd, timeout, idle, MAX_OUTPUT_BYTES, on_chunk=output.write
command,
ctx.cwd,
timeout,
idle,
MAX_OUTPUT_BYTES,
shell=self._shell,
on_chunk=output.write,
)
truncated = output.tell() > MAX_OUTPUT_BYTES
output.seek(0)
Expand Down
21 changes: 21 additions & 0 deletions src/lecode/extras/shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Detect the user's shell for command execution."""

from __future__ import annotations

import os
import shutil

#: Fallback when $SHELL is unset or does not resolve to an executable.
FALLBACK_SHELL = "/bin/sh"


def user_shell() -> str:
"""The user's shell: ``$SHELL`` resolved, else ``/bin/sh``.

``shutil.which`` accepts absolute paths and bare names alike; an unset,
empty, or non-executable ``$SHELL`` falls back to ``/bin/sh`` — the
previously hardcoded behavior.
"""
shell = os.environ.get("SHELL", "")
resolved = shutil.which(shell) if shell else None
return resolved or FALLBACK_SHELL
5 changes: 4 additions & 1 deletion src/lecode/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
from lecode.extras.loop_mode import run_plan_loop
from lecode.extras.mcp_client import MCP_EXTRA, attach_mcp
from lecode.extras.proc import run_proc
from lecode.extras.shell import user_shell
from lecode.extras.status_signals import START, STOP, StatusEmitter
from lecode.extras.subagents import (
SubagentError,
Expand Down Expand Up @@ -1691,7 +1692,9 @@ async def _run_shell(self, cmd: str, *, share_with_llm: bool, steer: bool) -> No
self._activity("running shell")
self._shell_task = asyncio.current_task()
try:
result = await run_proc(["bash", "-c", cmd], cwd=self._cwd, timeout=SHELL_TIMEOUT_S)
result = await run_proc(
[user_shell(), "-c", cmd], cwd=self._cwd, timeout=SHELL_TIMEOUT_S
)
except asyncio.CancelledError:
self._feed.info("shell command cancelled")
return
Expand Down
51 changes: 51 additions & 0 deletions tests/test_shell.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Tests for user shell detection (extras/shell.py)."""

from __future__ import annotations

import os
import stat

from lecode.extras.shell import FALLBACK_SHELL, user_shell


def _make_executable(path, body="#!/bin/sh\nexit 0\n"):
path.write_text(body)
path.chmod(path.stat().st_mode | stat.S_IXUSR)
return path


def test_falls_back_to_sh_when_shell_unset(monkeypatch):
monkeypatch.delenv("SHELL", raising=False)
assert user_shell() == FALLBACK_SHELL


def test_falls_back_to_sh_when_shell_empty(monkeypatch):
monkeypatch.setenv("SHELL", "")
assert user_shell() == FALLBACK_SHELL


def test_falls_back_when_shell_path_missing(monkeypatch, tmp_path):
monkeypatch.setenv("SHELL", str(tmp_path / "nope"))
assert user_shell() == FALLBACK_SHELL


def test_falls_back_when_shell_not_executable(monkeypatch, tmp_path):
plain = tmp_path / "not-a-shell"
plain.write_text("text\n")
monkeypatch.setenv("SHELL", str(plain))
assert user_shell() == FALLBACK_SHELL


def test_absolute_shell_path_is_used(monkeypatch, tmp_path):
shell = _make_executable(tmp_path / "myshell")
monkeypatch.setenv("SHELL", str(shell))
assert user_shell() == str(shell)


def test_bare_shell_name_resolves_on_path(monkeypatch, tmp_path):
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
shell = _make_executable(bin_dir / "myshell")
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}")
monkeypatch.setenv("SHELL", "myshell")
assert user_shell() == str(shell)
16 changes: 16 additions & 0 deletions tests/test_tool_bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ async def test_echo(tool_ctx):
assert not proc.timed_out


async def test_uses_detected_user_shell(tool_ctx, monkeypatch):
"""Commands run through $SHELL -c — here /bin/echo, which prints its argv."""
monkeypatch.setenv("SHELL", "/bin/echo")
result = await bash.make_tool().run({"command": "hello-shell"}, tool_ctx)
assert result.content.startswith("-c hello-shell")


def test_description_names_the_detected_shell(tmp_path, monkeypatch):
"""The model sees the real shell so it can adapt its syntax."""
shell = tmp_path / "mysh"
shell.write_text("#!/bin/sh\n")
shell.chmod(shell.stat().st_mode | stat.S_IXUSR)
monkeypatch.setenv("SHELL", str(shell))
assert f"{shell} -c" in bash.make_tool().description


async def test_nonzero_exit_reported(tool_ctx):
result = await bash.make_tool().run({"command": "exit 7"}, tool_ctx)
assert result.is_error
Expand Down
8 changes: 8 additions & 0 deletions tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,14 @@ async def test_bang_runs_shell_without_llm(tmp_path, monkeypatch):
assert provider.requests == []


async def test_bang_uses_the_detected_user_shell(tmp_path, monkeypatch):
"""!cmd runs through $SHELL -c — here /bin/echo, which prints its argv."""
monkeypatch.setenv("SHELL", "/bin/echo")
app, _, out = make_app(tmp_path, monkeypatch, [])
await app._submit("!echo-marker")
assert "-c echo-marker" in out.getvalue()


async def test_double_bang_feeds_output_to_llm(tmp_path, monkeypatch):
app, provider, out = make_app(tmp_path, monkeypatch, [{"text": "noted"}])
await app._submit("!!echo from-shell")
Expand Down
Loading