diff --git a/src/lecode/agent/tools/bash.py b/src/lecode/agent/tools/bash.py index 31bd319..5030eb1 100644 --- a/src/lecode/agent/tools/bash.py +++ b/src/lecode/agent/tools/bash.py @@ -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 ``/overflow/.log`` with a pointer line. """ @@ -23,6 +24,7 @@ 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 @@ -30,7 +32,7 @@ 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 @@ -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, @@ -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)." ), @@ -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) diff --git a/src/lecode/extras/shell.py b/src/lecode/extras/shell.py new file mode 100644 index 0000000..1c03a67 --- /dev/null +++ b/src/lecode/extras/shell.py @@ -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 diff --git a/src/lecode/tui/app.py b/src/lecode/tui/app.py index cf312ae..dd32fb6 100644 --- a/src/lecode/tui/app.py +++ b/src/lecode/tui/app.py @@ -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, @@ -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 diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000..a92e5bc --- /dev/null +++ b/tests/test_shell.py @@ -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) diff --git a/tests/test_tool_bash.py b/tests/test_tool_bash.py index b310a8e..f25e8d5 100644 --- a/tests/test_tool_bash.py +++ b/tests/test_tool_bash.py @@ -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 diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index d6f5af7..c5c7a1a 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -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")