mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/process+cli: PTY hardening and config/log polish
Mark PTY master FD non-inheritable; run read_pty/close_pty via to_thread; close_all PTY sessions on chat exit. Add --log-level; clearer invalid TOML errors. Keep export/status free of provider secrets.
This commit is contained in:
+40
-2
@@ -9,6 +9,7 @@ import click
|
||||
import msgspec
|
||||
from platformdirs import user_data_path
|
||||
|
||||
from plyngent import config as config_mod
|
||||
from plyngent.agent.loop import DEFAULT_MAX_ROUNDS
|
||||
from plyngent.cli.editor import (
|
||||
load_config_with_optional_edit,
|
||||
@@ -34,7 +35,12 @@ _DEFAULT_DB_FILENAME = "chat.db"
|
||||
|
||||
|
||||
def _load_config(config_path: Path | None) -> ConfigStore:
|
||||
return load_config_with_optional_edit(config_path)
|
||||
try:
|
||||
return load_config_with_optional_edit(config_path)
|
||||
except config_mod.ConfigFormatError as exc:
|
||||
path = resolve_config_path(config_path)
|
||||
msg = f"invalid config TOML ({path}): {exc}"
|
||||
raise click.ClickException(msg) from exc
|
||||
|
||||
|
||||
def _database_config(store: ConfigStore, *, quiet: bool = False) -> DatabaseConfig:
|
||||
@@ -208,12 +214,44 @@ async def _run_chat(
|
||||
return EXIT_OK
|
||||
finally:
|
||||
await memory.close()
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
|
||||
PtyManager.close_all()
|
||||
|
||||
|
||||
def _configure_logging(level: str) -> None:
|
||||
import logging
|
||||
|
||||
name = level.upper()
|
||||
numeric = getattr(logging, name, None)
|
||||
if not isinstance(numeric, int):
|
||||
msg = f"invalid --log-level {level!r}"
|
||||
raise click.ClickException(msg)
|
||||
logging.basicConfig(
|
||||
level=numeric,
|
||||
format="%(levelname)s %(name)s: %(message)s",
|
||||
stream=sys.stderr,
|
||||
force=True,
|
||||
)
|
||||
# Avoid accidental secret leakage via HTTP libraries at DEBUG.
|
||||
if numeric <= logging.DEBUG:
|
||||
logging.getLogger("niquests").setLevel(logging.INFO)
|
||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||
|
||||
|
||||
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
||||
@click.version_option(package_name="plyngent")
|
||||
def main() -> None:
|
||||
@click.option(
|
||||
"--log-level",
|
||||
default="WARNING",
|
||||
show_default=True,
|
||||
help="Logging level for stderr (DEBUG, INFO, WARNING, ERROR).",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, log_level: str) -> None:
|
||||
"""Plyngent — LLM chat and agent toolkit."""
|
||||
_ = ctx
|
||||
_configure_logging(log_level)
|
||||
|
||||
|
||||
@main.command("chat")
|
||||
|
||||
@@ -148,7 +148,11 @@ def prompt_edit_config(path: Path, *, reason: str | None = None) -> bool:
|
||||
|
||||
|
||||
def load_config_with_optional_edit(config_path: Path | None) -> ConfigStore:
|
||||
"""Load config; if there are no providers and EDITOR is set, offer to edit and reload."""
|
||||
"""Load config; if there are no providers and EDITOR is set, offer to edit and reload.
|
||||
|
||||
Raises:
|
||||
config_mod.ConfigFormatError: Invalid TOML (caller should surface path).
|
||||
"""
|
||||
path = resolve_config_path(config_path)
|
||||
store = config_mod.load(path)
|
||||
if store.providers:
|
||||
|
||||
@@ -35,7 +35,11 @@ def session_export_payload(
|
||||
updated_at: datetime | None,
|
||||
messages: Sequence[AnyChatMessage],
|
||||
) -> dict[str, object]:
|
||||
"""Build a JSON-serializable dict for a session transcript."""
|
||||
"""Build a JSON-serializable dict for a session transcript.
|
||||
|
||||
Never includes provider tokens or config secrets — only session metadata
|
||||
and chat messages from the DB.
|
||||
"""
|
||||
return {
|
||||
"session_id": sid,
|
||||
"name": name,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from plyngent.agent import tool
|
||||
|
||||
from .pty_session import PtyManager, format_close_result
|
||||
|
||||
|
||||
@tool
|
||||
def close_pty(session_id: int) -> str:
|
||||
"""Close a PTY session (SIGTERM, then SIGKILL after a short grace period)."""
|
||||
result = PtyManager.close(session_id)
|
||||
async def close_pty(session_id: int) -> str:
|
||||
"""Close a PTY session (SIGTERM, then SIGKILL after a short grace period).
|
||||
|
||||
Runs off the event loop so grace sleeps do not freeze the chat UI.
|
||||
"""
|
||||
result = await asyncio.to_thread(PtyManager.close, session_id)
|
||||
return format_close_result(result)
|
||||
|
||||
@@ -132,8 +132,11 @@ class PtyManager:
|
||||
raise WorkspaceError(msg)
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
# Parent must not pass master into the child (or future children).
|
||||
with contextlib.suppress(OSError, AttributeError):
|
||||
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
|
||||
pid = os.fork()
|
||||
if pid == 0: # child
|
||||
if pid == 0: # child — keep path fork-then-exec only (no Python after exec fail except _exit)
|
||||
try:
|
||||
os.close(master_fd)
|
||||
_ = os.setsid()
|
||||
@@ -150,6 +153,8 @@ class PtyManager:
|
||||
os._exit(127)
|
||||
|
||||
os.close(slave_fd)
|
||||
with contextlib.suppress(OSError, AttributeError):
|
||||
os.set_inheritable(master_fd, False) # noqa: FBT003 — stdlib API
|
||||
with cls._lock:
|
||||
session_id = cls._next_id
|
||||
cls._next_id += 1
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from plyngent.agent import tool
|
||||
from plyngent.tools.workspace import WorkspaceError
|
||||
|
||||
from .pty_session import DEFAULT_PTY_READ_BYTES, PtyManager, format_read_result
|
||||
|
||||
# Cap per-call wait so a misbehaving tool arg cannot stall forever even off-loop.
|
||||
_MAX_READ_TIMEOUT = 120.0
|
||||
|
||||
|
||||
@tool
|
||||
def read_pty(
|
||||
async def read_pty(
|
||||
session_id: int,
|
||||
*,
|
||||
max_bytes: int = DEFAULT_PTY_READ_BYTES,
|
||||
timeout: float = 2.0,
|
||||
timeout: float = 2.0, # noqa: ASYNC109 — PTY poll budget (seconds), not asyncio.timeout
|
||||
until: str | None = None,
|
||||
) -> str:
|
||||
"""Read PTY output with status.
|
||||
@@ -23,12 +28,16 @@ def read_pty(
|
||||
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).
|
||||
|
||||
Blocking I/O runs in a worker thread so the asyncio loop stays responsive.
|
||||
"""
|
||||
wait = min(max(0.0, timeout), _MAX_READ_TIMEOUT)
|
||||
try:
|
||||
result = PtyManager.read(
|
||||
result = await asyncio.to_thread(
|
||||
PtyManager.read,
|
||||
session_id,
|
||||
max_bytes=max_bytes,
|
||||
timeout=timeout,
|
||||
timeout=wait,
|
||||
until=until,
|
||||
)
|
||||
except WorkspaceError as exc:
|
||||
|
||||
Reference in New Issue
Block a user