core/tools/process: harden PTY sessions for agents

Structured alive/exit_code/data reads; until= wait; SIGTERM then
SIGKILL; exec-fail marker; session limit, idle TTL, output budget.
This commit is contained in:
2026-07-14 19:47:32 +08:00
parent 73c11cee36
commit 1b53b415b6
7 changed files with 456 additions and 69 deletions
+4 -4
View File
@@ -2,11 +2,11 @@ from __future__ import annotations
from plyngent.agent import tool
from .pty_session import PtyManager
from .pty_session import PtyManager, format_close_result
@tool
def close_pty(session_id: int) -> str:
"""Close a PTY session and terminate its process."""
PtyManager.close(session_id)
return f"closed session_id={session_id}"
"""Close a PTY session (SIGTERM, then SIGKILL after a short grace period)."""
result = PtyManager.close(session_id)
return format_close_result(result)
+13 -2
View File
@@ -8,11 +8,22 @@ from .pty_session import PtyManager
@tool
def open_pty(command: list[str], *, cwd: str = ".") -> str:
"""Open a PTY session running ``command`` (argv) under the workspace; returns session id."""
"""Open a Unix PTY session running ``command`` (argv) under the workspace.
Returns structured status including session_id. Not supported on Windows.
Failed exec surfaces via later read_pty data (marker) and exit_code=127.
"""
try:
session = PtyManager.open(command, cwd=cwd)
except WorkspaceError as exc:
return f"error: {exc}"
except OSError as exc:
return f"error: failed to open PTY: {exc}"
return f"session_id={session.session_id}"
return "\n".join(
[
f"session_id={session.session_id}",
"alive=true",
"exit_code=",
f"cmd={' '.join(session.command)}",
]
)
+315 -29
View File
@@ -1,3 +1,5 @@
"""Unix PTY session registry (requires ``pty`` + ``os.fork``; not supported on Windows)."""
from __future__ import annotations
import contextlib
@@ -13,7 +15,13 @@ from typing import ClassVar
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
DEFAULT_PTY_READ_BYTES = 8192
DEFAULT_PTY_POLL_TIMEOUT = 0.2
DEFAULT_MAX_SESSIONS = 8
DEFAULT_IDLE_TTL_SECONDS = 600.0
DEFAULT_SESSION_OUTPUT_BUDGET = 256_000
DEFAULT_CLOSE_GRACE_SECONDS = 0.5
_STDERR_FD = 2
_EXEC_FAIL_MARKER = b"plyngent-pty-exec-failed: "
@dataclass
@@ -22,15 +30,58 @@ class PtySession:
master_fd: int
pid: int
closed: bool = False
alive: bool = True
exit_code: int | None = None
created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time)
bytes_read: int = 0
command: tuple[str, ...] = ()
@dataclass(frozen=True)
class PtyReadResult:
session_id: int
alive: bool
exit_code: int | None
data: str
truncated: bool = False
matched: bool = False
budget_exhausted: bool = False
@dataclass(frozen=True)
class PtyCloseResult:
session_id: int
closed: bool
alive: bool
exit_code: int | None
message: str = ""
class PtyManager:
"""In-process PTY session registry (minimal)."""
"""In-process PTY session registry (process-global; suitable for local CLI)."""
_lock: ClassVar[Lock] = Lock()
_next_id: ClassVar[int] = 1
_sessions: ClassVar[dict[int, PtySession]] = {}
max_sessions: ClassVar[int] = DEFAULT_MAX_SESSIONS
idle_ttl_seconds: ClassVar[float] = DEFAULT_IDLE_TTL_SECONDS
session_output_budget: ClassVar[int] = DEFAULT_SESSION_OUTPUT_BUDGET
@classmethod
def configure(
cls,
*,
max_sessions: int | None = None,
idle_ttl_seconds: float | None = None,
session_output_budget: int | None = None,
) -> None:
if max_sessions is not None:
cls.max_sessions = max(1, max_sessions)
if idle_ttl_seconds is not None:
cls.idle_ttl_seconds = max(0.0, idle_ttl_seconds)
if session_output_budget is not None:
cls.session_output_budget = max(1024, session_output_budget)
@classmethod
def open(
@@ -45,6 +96,13 @@ class PtyManager:
msg = f"cwd is not a directory: {cwd}"
raise WorkspaceError(msg)
_ = cls.reap_idle()
with cls._lock:
alive_count = sum(1 for s in cls._sessions.values() if not s.closed)
if alive_count >= cls.max_sessions:
msg = f"PTY session limit reached ({cls.max_sessions}); close idle sessions"
raise WorkspaceError(msg)
master_fd, slave_fd = pty.openpty()
pid = os.fork()
if pid == 0: # child
@@ -58,15 +116,21 @@ class PtyManager:
os.close(slave_fd)
_ = os.chdir(workdir)
os.execvp(command[0], command)
except OSError:
pass
except OSError as exc:
with contextlib.suppress(OSError):
_ = os.write(1, _EXEC_FAIL_MARKER + str(exc).encode(errors="replace") + b"\n")
os._exit(127)
os.close(slave_fd)
with cls._lock:
session_id = cls._next_id
cls._next_id += 1
session = PtySession(session_id=session_id, master_fd=master_fd, pid=pid)
session = PtySession(
session_id=session_id,
master_fd=master_fd,
pid=pid,
command=tuple(command),
)
cls._sessions[session_id] = session
return session
@@ -76,45 +140,267 @@ class PtyManager:
return cls._sessions.get(session_id)
@classmethod
def read(cls, session_id: int, *, max_bytes: int = DEFAULT_PTY_READ_BYTES, timeout: float = 0.1) -> str:
session = cls.get(session_id)
if session is None or session.closed:
msg = f"unknown or closed PTY session: {session_id}"
raise WorkspaceError(msg)
ready, _, _ = select.select([session.master_fd], [], [], timeout)
if not ready:
return ""
def _touch(cls, session: PtySession) -> None:
session.last_activity = time.time()
@classmethod
def _poll_exit(cls, session: PtySession) -> None:
if not session.alive or session.closed:
return
try:
data = os.read(session.master_fd, max_bytes)
except OSError:
return ""
return data.decode(errors="replace")
waited_pid, status = os.waitpid(session.pid, os.WNOHANG)
except ChildProcessError:
session.alive = False
return
if waited_pid == 0:
return
session.alive = False
if os.WIFEXITED(status):
session.exit_code = os.WEXITSTATUS(status)
elif os.WIFSIGNALED(status):
session.exit_code = -os.WTERMSIG(status)
else:
session.exit_code = status
@classmethod
def refresh(cls, session_id: int) -> PtySession:
session = cls.get(session_id)
if session is None:
msg = f"unknown PTY session: {session_id}"
raise WorkspaceError(msg)
cls._poll_exit(session)
return session
@classmethod
def _read_once(cls, session: PtySession, *, max_bytes: int, timeout: float) -> bytes:
if session.closed or (cls.session_output_budget - session.bytes_read) <= 0:
return b""
to_read = min(max_bytes, cls.session_output_budget - session.bytes_read)
data = b""
try:
ready, _, _ = select.select([session.master_fd], [], [], timeout)
if ready:
data = os.read(session.master_fd, to_read)
except (OSError, ValueError):
data = b""
cls._poll_exit(session)
if not data:
return b""
session.bytes_read += len(data)
cls._touch(session)
return data
@classmethod
def _collect_chunks(
cls,
session: PtySession,
*,
max_bytes: int,
timeout: float,
until: str | None,
) -> tuple[list[bytes], bool]:
chunks: list[bytes] = []
matched = False
if until is None:
chunk = cls._read_once(session, max_bytes=max_bytes, timeout=timeout)
if chunk:
chunks.append(chunk)
return chunks, matched
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
chunk = cls._read_once(
session,
max_bytes=max_bytes,
timeout=min(DEFAULT_PTY_POLL_TIMEOUT, remaining),
)
if chunk:
chunks.append(chunk)
if until in b"".join(chunks).decode(errors="replace"):
matched = True
break
cls._poll_exit(session)
if not session.alive or (cls.session_output_budget - session.bytes_read) <= 0:
break
return chunks, matched
@classmethod
def read(
cls,
session_id: int,
*,
max_bytes: int = DEFAULT_PTY_READ_BYTES,
timeout: float = DEFAULT_PTY_POLL_TIMEOUT,
until: str | None = None,
) -> PtyReadResult:
"""Read PTY output.
Without ``until``, waits up to ``timeout`` for any data (one poll).
With ``until``, polls until the substring appears, the process dies,
``timeout`` deadline elapses, or the session output budget is hit.
"""
if max_bytes < 1:
msg = "max_bytes must be >= 1"
raise WorkspaceError(msg)
if timeout < 0:
msg = "timeout must be >= 0"
raise WorkspaceError(msg)
session = cls.refresh(session_id)
if session.closed:
msg = f"closed PTY session: {session_id}"
raise WorkspaceError(msg)
if (cls.session_output_budget - session.bytes_read) <= 0:
return PtyReadResult(
session_id=session_id,
alive=session.alive,
exit_code=session.exit_code,
data="",
budget_exhausted=True,
)
chunks, matched = cls._collect_chunks(
session, max_bytes=max_bytes, timeout=timeout, until=until
)
data = b"".join(chunks).decode(errors="replace")
truncated = len(data) > max_bytes
if truncated:
data = data[:max_bytes]
return PtyReadResult(
session_id=session_id,
alive=session.alive,
exit_code=session.exit_code,
data=data,
truncated=truncated,
matched=matched,
budget_exhausted=(cls.session_output_budget - session.bytes_read) <= 0,
)
@classmethod
def write(cls, session_id: int, data: str) -> None:
session = cls.get(session_id)
if session is None or session.closed:
msg = f"unknown or closed PTY session: {session_id}"
session = cls.refresh(session_id)
if session.closed:
msg = f"closed PTY session: {session_id}"
raise WorkspaceError(msg)
_ = os.write(session.master_fd, data.encode())
if not session.alive:
msg = f"PTY session process is not alive: {session_id}"
raise WorkspaceError(msg)
try:
_ = os.write(session.master_fd, data.encode())
except OSError as exc:
cls._poll_exit(session)
msg = f"failed to write PTY: {exc}"
raise WorkspaceError(msg) from exc
cls._touch(session)
@classmethod
def close(cls, session_id: int) -> None:
def close(cls, session_id: int, *, grace_seconds: float = DEFAULT_CLOSE_GRACE_SECONDS) -> PtyCloseResult:
with cls._lock:
session = cls._sessions.pop(session_id, None)
session = cls._sessions.get(session_id)
if session is None:
return
session.closed = True
with contextlib.suppress(ProcessLookupError):
os.kill(session.pid, signal.SIGTERM)
with contextlib.suppress(ChildProcessError):
_ = os.waitpid(session.pid, 0)
return PtyCloseResult(
session_id=session_id,
closed=False,
alive=False,
exit_code=None,
message="unknown session",
)
if session.closed:
return PtyCloseResult(
session_id=session_id,
closed=True,
alive=False,
exit_code=session.exit_code,
message="already closed",
)
cls._poll_exit(session)
if session.alive:
with contextlib.suppress(ProcessLookupError):
os.kill(session.pid, signal.SIGTERM)
deadline = time.monotonic() + max(0.0, grace_seconds)
while session.alive and time.monotonic() < deadline:
time.sleep(0.05)
cls._poll_exit(session)
if session.alive:
with contextlib.suppress(ProcessLookupError):
os.kill(session.pid, signal.SIGKILL)
with contextlib.suppress(ChildProcessError):
_ = os.waitpid(session.pid, 0)
session.alive = False
if session.exit_code is None:
session.exit_code = -signal.SIGKILL
else:
# Ensure reaped
with contextlib.suppress(ChildProcessError):
_ = os.waitpid(session.pid, os.WNOHANG)
with contextlib.suppress(OSError):
os.close(session.master_fd)
session.closed = True
with cls._lock:
_ = cls._sessions.pop(session_id, None)
return PtyCloseResult(
session_id=session_id,
closed=True,
alive=False,
exit_code=session.exit_code,
message="closed",
)
@classmethod
def reap_idle(cls) -> list[int]:
"""Close sessions idle longer than ``idle_ttl_seconds`` (0 disables)."""
if cls.idle_ttl_seconds <= 0:
return []
now = time.time()
to_close: list[int] = []
with cls._lock:
for sid, session in list(cls._sessions.items()):
if session.closed:
continue
if now - session.last_activity >= cls.idle_ttl_seconds:
to_close.append(sid)
for sid in to_close:
_ = cls.close(sid)
return to_close
@classmethod
def close_all(cls) -> None:
with cls._lock:
ids = list(cls._sessions.keys())
for session_id in ids:
cls.close(session_id)
_ = cls.close(session_id)
def format_read_result(result: PtyReadResult) -> str:
exit_disp = "" if result.exit_code is None else str(result.exit_code)
lines = [
f"session_id={result.session_id}",
f"alive={'true' if result.alive else 'false'}",
f"exit_code={exit_disp}",
f"matched={'true' if result.matched else 'false'}",
f"truncated={'true' if result.truncated else 'false'}",
f"budget_exhausted={'true' if result.budget_exhausted else 'false'}",
"--- data ---",
result.data,
]
return "\n".join(lines)
def format_close_result(result: PtyCloseResult) -> str:
exit_disp = "" if result.exit_code is None else str(result.exit_code)
return "\n".join(
[
f"session_id={result.session_id}",
f"closed={'true' if result.closed else 'false'}",
f"alive={'true' if result.alive else 'false'}",
f"exit_code={exit_disp}",
f"message={result.message}",
]
)
+25 -4
View File
@@ -3,13 +3,34 @@ from __future__ import annotations
from plyngent.agent import tool
from plyngent.tools.workspace import WorkspaceError
from .pty_session import PtyManager
from .pty_session import DEFAULT_PTY_READ_BYTES, PtyManager, format_read_result
@tool
def read_pty(session_id: int, *, max_bytes: int = 8192, timeout: float = 0.2) -> str:
"""Read available output from a PTY session (may be empty if nothing ready)."""
def read_pty(
session_id: int,
*,
max_bytes: int = DEFAULT_PTY_READ_BYTES,
timeout: float = 2.0,
until: str | None = None,
) -> str:
"""Read PTY output with status.
Returns structured text: session_id, alive, exit_code, matched, truncated,
budget_exhausted, then ``--- data ---`` and the payload.
Without ``until``, waits up to ``timeout`` seconds for available data.
With ``until``, polls until the substring appears, the process exits, the
deadline elapses, or the session output budget is exhausted.
Empty data with alive=true means nothing was ready (not necessarily EOF).
"""
try:
return PtyManager.read(session_id, max_bytes=max_bytes, timeout=timeout)
result = PtyManager.read(
session_id,
max_bytes=max_bytes,
timeout=timeout,
until=until,
)
except WorkspaceError as exc:
return f"error: {exc}"
return format_read_result(result)
+11 -2
View File
@@ -8,11 +8,20 @@ from .pty_session import PtyManager
@tool
def write_pty(session_id: int, data: str) -> str:
"""Write text to a PTY session (e.g. interactive input). Does not append a newline."""
"""Write text to a PTY session (interactive input). Does not append a newline."""
try:
PtyManager.write(session_id, data)
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}"
return f"wrote {len(data)} characters to session_id={session_id}"
exit_disp = "" if session.exit_code is None else str(session.exit_code)
return "\n".join(
[
f"session_id={session_id}",
f"alive={'true' if session.alive else 'false'}",
f"exit_code={exit_disp}",
f"wrote={len(data)}",
]
)