This commit is contained in:
devin-ai-integration[bot] 2026-10-21 03:37:42 +00:00 • committed by GitHub
commit d7d55ffa99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 40 additions and 39 deletions

View file

@ -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=<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=<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=<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

View file

@ -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=<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=<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=<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`

View file

@ -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 <id>"
write_stdin(session_id=<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=<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.

View file

@ -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=<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=<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