diff --git a/src/agent_env/cli/__init__.py b/src/agent_env/cli/__init__.py index e39688d4..aad88a4a 100644 --- a/src/agent_env/cli/__init__.py +++ b/src/agent_env/cli/__init__.py @@ -1,4 +1,6 @@ import logging +from collections.abc import Iterator +from contextlib import contextmanager import click @@ -43,14 +45,32 @@ def invoke(self, ctx: click.Context): raise click.ClickException("\n".join([str(e), *getattr(e, "__notes__", ())])) from e +@contextmanager +def _verbose_logging() -> Iterator[None]: + """Log agent-env's own DEBUG messages, and only warnings from other libraries, while a command runs: their debug + logs can carry credentials, as hpack's do, which log every HTTP/2 header Modal's client sends, its token among + them. The loggers' levels are put back after it, so a CLI invoked in-process leaves the caller's logging as it was.""" + logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(name)s %(message)s") + loggers = [logging.getLogger(name) for name in ("agent_env", "agentenv_protocol")] + levels = [logger.level for logger in loggers] + for logger in loggers: + logger.setLevel(logging.DEBUG) + try: + yield + finally: + for logger, level in zip(loggers, levels): + logger.setLevel(level) + + @click.group(cls=_UserErrorsAreNotCrashes) @click.version_option(package_name="agentenv-framework", prog_name="agent-env", message="%(prog)s %(version)s") -@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging (DEBUG level)") +@click.option("--verbose", "-v", is_flag=True, + help="Enable agent-env's DEBUG logging (other libraries log warnings only)") @click.pass_context def cli(ctx: click.Context, verbose: bool): """Agent environment CLI.""" if verbose: - logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(name)s %(message)s") + ctx.with_resource(_verbose_logging()) # For this command only, so a CLI invoked in-process leaves the caller's stores as they were. ctx.with_resource(namespace_routing()) diff --git a/tst/unit/cli/verbose_logging_test.py b/tst/unit/cli/verbose_logging_test.py new file mode 100644 index 00000000..d7b4b3b1 --- /dev/null +++ b/tst/unit/cli/verbose_logging_test.py @@ -0,0 +1,41 @@ +"""``agent-env -v`` logs agent-env's own DEBUG messages, and only warnings from other libraries, whose debug logs can +carry credentials: hpack logs every HTTP/2 header Modal's client sends, its token among them.""" + +import logging +import subprocess +import sys + +from click.testing import CliRunner + +from agent_env.cli import cli + +# In a fresh interpreter, as the CLI runs: pytest's own handlers on the root logger would make basicConfig a no-op here. +_LOGS = """ +import logging +from agent_env.cli import _verbose_logging +with _verbose_logging(): + logging.getLogger("agent_env.providers.sandbox_providers.modal_sandbox").debug("creating a sandbox") + logging.getLogger("agentenv_protocol.client").debug("reading a card") + logging.getLogger("hpack.hpack").debug("Adding b'x-modal-token-secret'=b'as-0000' to the header table") + logging.getLogger("hpack.hpack").warning("a header table warning") +""" + + +def test_verbose_logs_agent_envs_debug_messages_and_only_warnings_from_other_libraries(): + done = subprocess.run([sys.executable, "-c", _LOGS], capture_output=True, text=True, timeout=120) + + assert done.returncode == 0, done.stderr + assert "creating a sandbox" in done.stderr and "reading a card" in done.stderr + assert "a header table warning" in done.stderr + assert "x-modal-token-secret" not in done.stderr + + +def test_a_verbose_command_run_in_process_leaves_the_callers_logging_as_it_was(): + """Run in a caller's process, as the tests and any embedding code run it, -v lasts only for its command.""" + loggers = [logging.getLogger(name) for name in ("agent_env", "agentenv_protocol")] + before = [logger.level for logger in loggers] + + result = CliRunner().invoke(cli, ["-v", "config", "--help"]) + + assert result.exit_code == 0, result.output + assert [logger.level for logger in loggers] == before