From 3c98245c149597391f80892e19dc861712369a31 Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Tue, 20 Oct 2026 02:39:56 +0000 Subject: [PATCH] fix(agents): exec_command points at write_stdin for a still-running command instead of saying its output is lost --- strix/agents/factory.py | 32 ++++++++++-------------- strix/agents/prompts/system_prompt.jinja | 13 +++++----- strix/tools/shell/README.md | 18 +++++++++---- tests/test_agent_factory_shell.py | 16 ++++++------ 4 files changed, 40 insertions(+), 39 deletions(-) diff --git a/strix/agents/factory.py b/strix/agents/factory.py index ca27e4f9..09661e28 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -446,35 +446,29 @@ _EXEC_YIELD_CAP_S = PTY_YIELD_TIME_MS_MAX // 1000 _EXEC_COMMAND_DESCRIPTION_NOTE = ( f"The wait for output is capped at {_EXEC_YIELD_CAP_S} s whatever yield_time_ms says. " - "A non-TTY command still running at the yield cannot be read later (write_stdin fails " - "on it and its output is lost): run anything longer in the background with its output " - "redirected to a file (`cmd > /tmp/out.log 2>&1 &`, then read the log), or start it with " - 'tty=true and poll with write_stdin(session_id=, chars="").' -) - -_RUNNING_NON_TTY_NOTICE = ( - "Note: this non-TTY process cannot be polled and its later output is lost. Rerun it in " - "the background (`cmd > /tmp/out.log 2>&1 &`, then read the log) or with tty=true." + "A command still running at the yield keeps running: read the rest of its output and " + 'its exit code with write_stdin(session_id=, chars="").' ) _PROCESS_RUNNING_RE = re.compile(r"^Process running with session ID \d+$", re.MULTILINE) _OUTPUT_HEADER_RE = re.compile(r"^Output:\n", re.MULTILINE) -def _annotate_running_non_tty(parsed: dict[str, Any], result: Any) -> Any: - if parsed.get("tty") or not isinstance(result, str): +def _annotate_capped_yield(parsed: dict[str, Any], result: Any) -> Any: + requested = parsed.get("yield_time_ms") + if not isinstance(result, str) or not isinstance(requested, int | float): + return result + if requested <= PTY_YIELD_TIME_MS_MAX: return result header = _OUTPUT_HEADER_RE.search(result) head_end = len(result) if header is None else header.start() if _PROCESS_RUNNING_RE.search(result, 0, head_end) is None: return result - notice = _RUNNING_NON_TTY_NOTICE - requested = parsed.get("yield_time_ms") - if isinstance(requested, int | float) and requested > PTY_YIELD_TIME_MS_MAX: - notice = ( - f"Note: yield_time_ms={int(requested)} was capped at {PTY_YIELD_TIME_MS_MAX} " - f"({_EXEC_YIELD_CAP_S} s). " + notice.removeprefix("Note: ") - ) + notice = ( + f"Note: yield_time_ms={int(requested)} was capped at {PTY_YIELD_TIME_MS_MAX} " + f"({_EXEC_YIELD_CAP_S} s); the command is still running, read it with " + 'write_stdin(session_id=, chars="").' + ) if header is None: return f"{result}\n{notice}" return f"{result[: header.end()]}{notice}\n{result[header.end() :]}" @@ -506,7 +500,7 @@ def _wrap_exec_command(tool: FunctionTool) -> FunctionTool: f"Got: {rel!r}." ) if isinstance(parsed, dict): - return _annotate_running_non_tty(parsed, result) + return _annotate_capped_yield(parsed, result) return result tool.on_invoke_tool = invoke diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index b13c9a4c..bfc59767 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -172,12 +172,13 @@ EFFICIENCY TACTICS: (it installs into that active venv) before importing, rather than letting the script fail with `ModuleNotFoundError`. - `exec_command` runs each command in a fresh non-interactive shell (plain - pipes, no TTY). To drive an interactive or long-running process with - `write_stdin` — REPLs, `ssh`/`nc`/`ftp`, `sqlmap`, or to send Ctrl-C — - you MUST start it with `exec_command(cmd="...", tty=true)` and then - `write_stdin(session_id=, chars="...")`. Calling `write_stdin` on a - default (non-TTY) command or on a process that has already exited fails with - "stdin is not available". + pipes, no TTY) and waits at most 30 s for output. A command still running + at that point keeps running: read the rest of its output and its exit code + with `write_stdin(session_id=, chars="")`; never re-run it or wait for + it with `sleep`. To type into a process — REPLs, `ssh`/`nc`/`ftp`, + `sqlmap` — start it with `exec_command(cmd="...", tty=true)`, then + `write_stdin(session_id=, chars="...")`. Typing into a non-TTY command + fails with "stdin is not available". - For Caido proxy automation inside Python, explicitly import from `caido_api`: `from caido_api import list_requests, view_request, repeat_request, list_sitemap, view_sitemap_entry, scope_rules` diff --git a/strix/tools/shell/README.md b/strix/tools/shell/README.md index b842e317..98b2f04e 100644 --- a/strix/tools/shell/README.md +++ b/strix/tools/shell/README.md @@ -5,19 +5,27 @@ invocation the agent makes (nmap, ffuf, agent-browser, python3, …) goes through `exec_command`. `write_stdin` streams input to a still-running process started by an earlier `exec_command` (for interactive prompts). -## `write_stdin` requires a TTY-backed process +## Reading a running command and typing into it `exec_command` runs each command in a fresh **non-interactive** shell (plain -pipes, no TTY) by default. `write_stdin` only works against a process that is -still running **and** was started with a PTY. The canonical sequence is: +pipes, no TTY) by default and waits at most 30 s for output. A command still +running at that point keeps running; read the rest of its output and its exit +code with an empty write: + +```text +exec_command(cmd="nmap -sV 10.0.0.5") # returns "Process running with session ID " +write_stdin(session_id=, chars="") # returns the new output, or the exit code +``` + +Typing into a process needs a PTY: ```text exec_command(cmd="python3", tty=true) # start a PTY-backed process write_stdin(session_id=, chars="print(1)\n") ``` -Calling `write_stdin` on a command started with the default `tty=false`, or on -a process that has already exited, fails with +Non-empty `chars` on a command started with the default `tty=false`, or on a +process that has already exited, fails with `stdin is not available for this process. Start the command with 'tty=true' in 'exec_command' before using 'write_stdin'.` Use `tty=true` for REPLs, `ssh`/`nc`/`ftp`, `msfconsole`, or to deliver a Ctrl-C to a long-running job. diff --git a/tests/test_agent_factory_shell.py b/tests/test_agent_factory_shell.py index c924af19..726be0e0 100644 --- a/tests/test_agent_factory_shell.py +++ b/tests/test_agent_factory_shell.py @@ -101,12 +101,12 @@ def test_wrap_exec_command_describes_the_yield_cap() -> None: wrapped = factory._wrap_exec_command(_running_exec_tool("ok")) assert wrapped.description.startswith("Runs a command. The wait for output is capped at 30 s") - assert "cannot be read later" in wrapped.description - assert "tty=true" in wrapped.description + assert 'write_stdin(session_id=, chars="")' in wrapped.description + assert "lost" not in wrapped.description @pytest.mark.asyncio -async def test_wrap_exec_command_flags_a_running_non_tty_process() -> None: +async def test_wrap_exec_command_points_a_capped_running_process_at_write_stdin() -> None: wrapped = factory._wrap_exec_command(_running_exec_tool(_RUNNING_RESULT)) result = await wrapped.on_invoke_tool( @@ -116,20 +116,18 @@ async def test_wrap_exec_command_flags_a_running_non_tty_process() -> None: assert result == ( "Chunk ID: abc123\nWall time: 30.0012 seconds\n" "Process running with session ID 7\nOutput:\n" - "Note: yield_time_ms=90000 was capped at 30000 (30 s). this non-TTY process cannot be " - "polled and its later output is lost. Rerun it in the background " - "(`cmd > /tmp/out.log 2>&1 &`, then read the log) or with tty=true.\n" + "Note: yield_time_ms=90000 was capped at 30000 (30 s); the command is still running, " + 'read it with write_stdin(session_id=, chars="").\n' ) @pytest.mark.asyncio -async def test_wrap_exec_command_flags_a_running_non_tty_process_within_the_cap() -> None: +async def test_wrap_exec_command_leaves_a_running_process_within_the_cap_alone() -> None: wrapped = factory._wrap_exec_command(_running_exec_tool(_RUNNING_RESULT)) result = await wrapped.on_invoke_tool(cast("Any", None), json.dumps({"cmd": "sleep 60"})) - assert "Note: this non-TTY process cannot be polled" in result - assert "was capped" not in result + assert result == _RUNNING_RESULT @pytest.mark.asyncio