diff --git a/CLAUDE.md b/CLAUDE.md index 47bc8b4..d60e14e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,8 +80,8 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use. - **`workspace`**: path resolve under root; path substring denylist; command basename denylist; clearer deny messages. - **`file`**: `read_file`, `write_file`, `listdir`, `tree` (VCS + default noise dirs + optional `skip_dirs` / denylist walk), `glob_paths`, `grep_files` (regex, skip VCS/binary), `edit_replace`, `edit_lineno` (1-based range), `copy_path` / `move_path` / `delete_path`. -- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep). -- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (CSI sanitized for host safety); `write_pty` key escapes (`\xHH`, `ctrl+x`, `key=esc`); session limit/idle TTL/output budget; close terminate→kill + **host TTY restore**. +- **`process`**: `run_command` (argv, no shell, timeout, optional stdin/env); PTY `open_pty` / `read_pty` / `write_pty` (literal) / `write_pty_keys` (escapes) / `close_pty` (POSIX: `pty`+`fork`; Windows: ConPTY via `pywinpty` env marker dep). +- PTY: backend in `pty_backend.py`; structured status (`alive`/`exit_code`/`data`); `read_pty(..., until=)` (CSI sanitized for host safety); keys via `write_pty_keys` only (`\xHH`, `ctrl+x`, `key=esc`); session limit/idle TTL/output budget; close terminate→kill + **host TTY restore**. - CLI limit hooks: interactive confirm to raise tool-loop rounds, PTY session cap, or PTY output budget. - Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn). - **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems. diff --git a/README.md b/README.md index 172211c..a468412 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Safety defaults: - PTY sessions: caps, idle TTL, output budget; master FD is non-inheritable; sessions closed on chat exit. Prefer file tools over full-screen editors (`vim`/`nano`) for edits. `read_pty` escapes CSI so tool results cannot reprogram the host TTY; `close_pty` / chat exit also restore the host terminal. - `write_pty` accepts escapes: `\xHH`, `ctrl+x`, `key=esc|enter|…`. + `write_pty` is literal text only; use `write_pty_keys` for `\xHH`, `ctrl+x`, `key=esc|enter|…`. ## Usage / context (CLI) diff --git a/src/plyngent/tools/__init__.py b/src/plyngent/tools/__init__.py index eee6f40..7c740a8 100644 --- a/src/plyngent/tools/__init__.py +++ b/src/plyngent/tools/__init__.py @@ -21,6 +21,7 @@ from .process import open_pty as open_pty from .process import read_pty as read_pty from .process import run_command as run_command from .process import write_pty as write_pty +from .process import write_pty_keys as write_pty_keys from .todo import TODO_TOOLS as TODO_TOOLS from .todo import get_todo_stack as get_todo_stack from .todo import set_todo_stack as set_todo_stack diff --git a/src/plyngent/tools/process/__init__.py b/src/plyngent/tools/process/__init__.py index 7cf8366..d4adb25 100644 --- a/src/plyngent/tools/process/__init__.py +++ b/src/plyngent/tools/process/__init__.py @@ -6,5 +6,6 @@ from .pty_terminal import sanitize_pty_output_for_tool as sanitize_pty_output_fo from .read_pty import read_pty as read_pty from .run_command import run_command as run_command from .write_pty import write_pty as write_pty +from .write_pty_keys import write_pty_keys as write_pty_keys -PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, close_pty] +PROCESS_TOOLS = [run_command, open_pty, read_pty, write_pty, write_pty_keys, close_pty] diff --git a/src/plyngent/tools/process/write_pty.py b/src/plyngent/tools/process/write_pty.py index 7d629dd..49f09c5 100644 --- a/src/plyngent/tools/process/write_pty.py +++ b/src/plyngent/tools/process/write_pty.py @@ -4,25 +4,12 @@ from plyngent.agent import tool from plyngent.tools.workspace import WorkspaceError from .pty_session import PtyManager -from .pty_terminal import decode_write_data -@tool -def write_pty(session_id: int, data: str) -> str: - """Write text to a PTY session (interactive input). Does not append a newline. - - Escapes (literal backslash sequences in ``data``): ``\\n`` ``\\r`` ``\\t`` - ``\\e``/``\\E`` (ESC), ``\\xHH``, ``\\uHHHH``, ``ctrl+x`` / ``ctrl+c``, and - ``key=esc|enter|tab|up|down|left|right``. - """ - try: - raw = decode_write_data(data) - PtyManager.write(session_id, raw) - session = PtyManager.refresh(session_id) - except WorkspaceError as exc: - return f"error: {exc}" - except OSError as exc: - return f"error: failed to write PTY: {exc}" +def write_pty_payload(session_id: int, raw: str) -> str: + """Write raw bytes (as str) to the PTY and format the tool status string.""" + PtyManager.write(session_id, raw) + session = PtyManager.refresh(session_id) exit_disp = "" if session.exit_code is None else str(session.exit_code) return "\n".join( [ @@ -32,3 +19,18 @@ def write_pty(session_id: int, data: str) -> str: f"wrote={len(raw.encode())}", ] ) + + +@tool +def write_pty(session_id: int, data: str) -> str: + """Write **literal** text to a PTY session. Does not append a newline. + + ``data`` is sent unchanged (no ``ctrl+x`` / ``\\\\xHH`` expansion). For + control sequences use :func:`write_pty_keys`. + """ + try: + return write_pty_payload(session_id, data) + except WorkspaceError as exc: + return f"error: {exc}" + except OSError as exc: + return f"error: failed to write PTY: {exc}" diff --git a/src/plyngent/tools/process/write_pty_keys.py b/src/plyngent/tools/process/write_pty_keys.py new file mode 100644 index 0000000..7472c21 --- /dev/null +++ b/src/plyngent/tools/process/write_pty_keys.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from plyngent.agent import tool +from plyngent.tools.workspace import WorkspaceError + +from .pty_terminal import decode_write_data +from .write_pty import write_pty_payload + + +@tool +def write_pty_keys(session_id: int, data: str) -> str: + """Write to a PTY after expanding key escapes (never for normal typing). + + Use this only for control sequences. Prefer :func:`write_pty` for plain text + so strings like ``press ctrl+c`` are not rewritten. + + Escapes (literal characters in ``data``): + + - ``\\\\n`` ``\\\\r`` ``\\\\t`` ``\\\\e``/``\\\\E`` (ESC) ``\\\\0`` + - ``\\\\xHH`` byte, ``\\\\uHHHH`` Unicode code point + - ``ctrl+c`` / ``ctrl+x`` ... (case-insensitive) + - ``key=esc|enter|tab|up|down|left|right`` + """ + try: + raw = decode_write_data(data) + return write_pty_payload(session_id, raw) + except WorkspaceError as exc: + return f"error: {exc}" + except OSError as exc: + return f"error: failed to write PTY: {exc}" diff --git a/tests/test_tools/test_process.py b/tests/test_tools/test_process.py index 3d940e2..2d36c0a 100644 --- a/tests/test_tools/test_process.py +++ b/tests/test_tools/test_process.py @@ -6,7 +6,14 @@ from pathlib import Path import pytest -from plyngent.tools.process import close_pty, open_pty, read_pty, run_command, write_pty +from plyngent.tools.process import ( + close_pty, + open_pty, + read_pty, + run_command, + write_pty, + write_pty_keys, +) from plyngent.tools.process.pty_session import PtyManager from plyngent.tools.workspace import set_command_denylist from tests.test_tools.helpers import call_async, call_sync @@ -375,7 +382,7 @@ async def test_read_pty_sanitizes_esc(workspace: object) -> None: PtyManager.close_all() -async def test_write_pty_ctrl_escape(workspace: object) -> None: +async def test_write_pty_keys_ctrl_escape(workspace: object) -> None: del workspace try: opened = call_sync( @@ -388,17 +395,29 @@ async def test_write_pty_ctrl_escape(workspace: object) -> None: ), ) session_id = _session_id(opened) - written = call_sync(write_pty, session_id, "ctrl+c") + written = call_sync(write_pty_keys, session_id, "ctrl+c") assert "wrote=1" in written text = await call_async(read_pty, session_id, timeout=2.0) - # ESC sanitized; Ctrl+C is 0x03 — may appear as raw or escaped depending - # on printable; just ensure write succeeded and process got input. assert "error" not in text.lower() or "alive=" in text _ = await call_async(close_pty, session_id) finally: PtyManager.close_all() +def test_write_pty_is_literal() -> None: + """Plain write_pty must not call the keys decoder.""" + import inspect + + from plyngent.tools.process.pty_terminal import decode_write_data + + # Decoder is intentionally aggressive; that is why write_pty stays literal. + assert decode_write_data("press ctrl+c to cancel") == "press \x03 to cancel" + src = inspect.getsource(write_pty.handler) + assert "decode_write_data" not in src + doc = write_pty.description or write_pty.handler.__doc__ or "" + assert "literal" in doc.lower() + + def test_pty_backend_available() -> None: from plyngent.tools.process.pty_backend import pty_available