diff --git a/pyproject.toml b/pyproject.toml index ef59ed7..6c3b532 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "textual-serve>=1.1.0", "mcp[cli]>=1.2.0", "truststore>=0.9.0", + "keyring>=25.6.0", ] [dependency-groups] diff --git a/src/a2a_handler/cli/__init__.py b/src/a2a_handler/cli/__init__.py index 894964a..89fe49e 100644 --- a/src/a2a_handler/cli/__init__.py +++ b/src/a2a_handler/cli/__init__.py @@ -14,6 +14,8 @@ truststore.inject_into_ssl() import logging +import shlex +import subprocess logging.getLogger().setLevel(logging.WARNING) @@ -21,6 +23,7 @@ from a2a_handler import __version__ from a2a_handler.common import Output, configure_output, get_logger, setup_logging +from a2a_handler.common.input_validation import reject_control_chars from a2a_handler.common.output import OutputFormat from a2a_handler.tui import HandlerTUI @@ -99,12 +102,74 @@ def version(ctx: click.Context) -> None: @cli.command() -@click.option("--bearer", "-b", "bearer_token", help="Bearer token for agent auth") -def tui(bearer_token: str | None) -> None: +@click.option( + "--bearer", + "-b", + "bearer_token", + help="Temporary bearer token for this TUI run (not saved)", +) +@click.option( + "--bearer-command", + help="Command that prints a temporary bearer token to stdout", +) +@click.option( + "--bearer-stdin", + is_flag=True, + help="Read a temporary bearer token from stdin", +) +def tui( + bearer_token: str | None, + bearer_command: str | None, + bearer_stdin: bool, +) -> None: """Launch the interactive terminal interface.""" + auth_sources = [ + bool(bearer_token), + bool(bearer_command), + bearer_stdin, + ] + if sum(auth_sources) > 1: + raise click.ClickException( + "Use only one of --bearer, --bearer-command, or --bearer-stdin" + ) + + resolved_bearer_token = bearer_token + if bearer_command: + reject_control_chars(bearer_command, "bearer_command") + try: + command_parts = shlex.split(bearer_command) + if not command_parts: + raise click.ClickException("--bearer-command cannot be empty") + command_result = subprocess.run( + command_parts, + check=True, + capture_output=True, + text=True, + ) + except ValueError as error: + raise click.ClickException(f"Invalid --bearer-command: {error}") from error + except subprocess.CalledProcessError as error: + stderr = error.stderr.strip() if error.stderr else "" + message = f"--bearer-command failed with exit code {error.returncode}" + if stderr: + message = f"{message}: {stderr}" + raise click.ClickException(message) from error + + resolved_bearer_token = command_result.stdout.strip() + if not resolved_bearer_token: + raise click.ClickException("--bearer-command produced an empty token") + + elif bearer_stdin: + resolved_bearer_token = click.get_text_stream("stdin").read().strip() + if not resolved_bearer_token: + raise click.ClickException("--bearer-stdin received an empty token") + + if resolved_bearer_token: + reject_control_chars(resolved_bearer_token, "bearer_token") + log.info("Launching TUI") logging.getLogger().handlers = [] - app = HandlerTUI(initial_bearer_token=bearer_token) + app = HandlerTUI(initial_bearer_token=resolved_bearer_token) app.run() diff --git a/src/a2a_handler/cli/_config.py b/src/a2a_handler/cli/_config.py index 5be3544..49c47c1 100644 --- a/src/a2a_handler/cli/_config.py +++ b/src/a2a_handler/cli/_config.py @@ -67,6 +67,12 @@ "options": ["--bearer", "--api-key", "--api-key-header"], }, ], + "handler auth source set": [ + { + "name": "Source Options", + "options": ["--provider", "--command"], + }, + ], } click.rich_click.COMMAND_GROUPS = { @@ -97,6 +103,12 @@ {"name": "Session Commands", "commands": ["list", "show", "clear"]}, ], "handler auth": [ - {"name": "Auth Commands", "commands": ["set", "show", "clear"]}, + { + "name": "Auth Commands", + "commands": ["set", "show", "clear", "source"], + }, + ], + "handler auth source": [ + {"name": "Source Commands", "commands": ["set", "show", "clear"]}, ], } diff --git a/src/a2a_handler/cli/auth.py b/src/a2a_handler/cli/auth.py index bbe67fa..7a5f7cd 100644 --- a/src/a2a_handler/cli/auth.py +++ b/src/a2a_handler/cli/auth.py @@ -6,11 +6,22 @@ from a2a_handler.auth import AuthType, create_api_key_auth, create_bearer_auth from a2a_handler.common import Output +from a2a_handler.common import ( + clear_agent_bearer_command, + get_agent_bearer_command, + get_default_bearer_command, + save_agent_bearer_command, + save_default_bearer_command, +) from a2a_handler.common.input_validation import ( InputValidationError, reject_control_chars, validate_agent_url, ) +from a2a_handler.credentials import ( + BUILTIN_BEARER_PROVIDERS, + get_builtin_provider_command, +) from a2a_handler.session import clear_credentials, get_credentials, set_credentials from ._helpers import handle_validation_error @@ -70,7 +81,7 @@ def auth_set( set_credentials(agent_url, credentials) - output.success(f"Set {auth_type_display} for {agent_url}") + output.success(f"Set {auth_type_display} for {agent_url} (saved to OS keyring)") @auth.command("show") @@ -117,3 +128,130 @@ def auth_clear(agent_url: str) -> None: clear_credentials(agent_url) output.success(f"Cleared credentials for {agent_url}") + + +@auth.group("source") +def auth_source() -> None: + """Manage automatic bearer token sources.""" + pass + + +@auth_source.command("set") +@click.argument("agent_url", required=False) +@click.option( + "--provider", + type=click.Choice(sorted(BUILTIN_BEARER_PROVIDERS.keys())), + help="Built-in provider for bearer tokens (for example: gcloud)", +) +@click.option( + "--command", + "bearer_command", + help="Command that prints a bearer token to stdout", +) +def auth_source_set( + agent_url: str | None, + provider: str | None, + bearer_command: str | None, +) -> None: + """Set an automatic bearer token command for an agent or globally. + + If AGENT_URL is omitted, sets the global default source. + """ + output = Output() + try: + if agent_url: + validate_agent_url(agent_url) + if provider and bearer_command: + raise InputValidationError( + code="invalid_auth_source_arguments", + message="Provide either --provider or --command, not both", + suggestion="Pick one auth source mode", + ) + if not provider and not bearer_command: + raise InputValidationError( + code="missing_auth_source_arguments", + message="Provide --provider or --command", + suggestion="For gcloud use: --provider gcloud", + ) + if bearer_command: + reject_control_chars(bearer_command, "bearer_command") + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error + + command = bearer_command + if provider: + command = get_builtin_provider_command(provider) + if command is None: + output.error(f"Unsupported provider: {provider}") + raise click.Abort() + + assert command is not None + if agent_url: + save_agent_bearer_command(agent_url, command) + output.success(f"Set auth source for {agent_url}") + else: + save_default_bearer_command(command) + output.success("Set global auth source") + + if provider: + output.field("Provider", provider) + output.field("Command", command) + + +@auth_source.command("show") +@click.argument("agent_url", required=False) +def auth_source_show(agent_url: str | None) -> None: + """Show configured automatic bearer token sources.""" + output = Output() + try: + if agent_url: + validate_agent_url(agent_url) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error + + output.header("Auth Source") + + if agent_url: + command = get_agent_bearer_command(agent_url) + if command: + output.field("Scope", f"Agent: {agent_url}") + output.field("Command", command) + return + + default_command = get_default_bearer_command() + if default_command: + output.field("Scope", f"Default fallback for {agent_url}") + output.field("Command", default_command) + return + + output.dim("No auth source configured") + return + + command = get_default_bearer_command() + if command: + output.field("Scope", "Global") + output.field("Command", command) + else: + output.dim("No global auth source configured") + + +@auth_source.command("clear") +@click.argument("agent_url", required=False) +def auth_source_clear(agent_url: str | None) -> None: + """Clear automatic bearer token source for an agent or globally.""" + output = Output() + try: + if agent_url: + validate_agent_url(agent_url) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error + + if agent_url: + clear_agent_bearer_command(agent_url) + output.success(f"Cleared auth source for {agent_url}") + else: + save_default_bearer_command(None) + output.success("Cleared global auth source") diff --git a/src/a2a_handler/cli/card.py b/src/a2a_handler/cli/card.py index 9454dff..b2ec99d 100644 --- a/src/a2a_handler/cli/card.py +++ b/src/a2a_handler/cli/card.py @@ -8,8 +8,8 @@ from a2a_handler.common import Output, get_logger from a2a_handler.common.input_validation import InputValidationError, validate_agent_url +from a2a_handler.credentials import resolve_auth_credentials from a2a_handler.service import A2AService -from a2a_handler.session import get_credentials from a2a_handler.validation import ( ValidationResult, validate_agent_card_from_file, @@ -46,9 +46,15 @@ def card_get(agent_url: str, authenticated: bool) -> None: raise click.Abort() from error log.info("Fetching agent card from %s", agent_url) - credentials = get_credentials(agent_url) if authenticated else None - if authenticated and credentials is None: - log.warning("No saved credentials found for %s", agent_url) + credentials = None + if authenticated: + try: + credentials = resolve_auth_credentials(agent_url) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error + if credentials is None: + log.warning("No credentials or auth source found for %s", agent_url) async def do_get() -> None: try: diff --git a/src/a2a_handler/cli/message.py b/src/a2a_handler/cli/message.py index ddb02a8..fc5f426 100644 --- a/src/a2a_handler/cli/message.py +++ b/src/a2a_handler/cli/message.py @@ -6,7 +6,7 @@ import rich_click as click -from a2a_handler.auth import AuthCredentials, create_api_key_auth, create_bearer_auth +from a2a_handler.auth import AuthCredentials from a2a_handler.common import Output, get_logger from a2a_handler.common.input_validation import ( InputValidationError, @@ -17,8 +17,9 @@ validate_resource_id, validate_webhook_url, ) +from a2a_handler.credentials import resolve_auth_credentials from a2a_handler.service import A2AService, SendResult -from a2a_handler.session import get_credentials, get_session, update_session +from a2a_handler.session import get_session, update_session from ._helpers import build_http_client, handle_client_error, handle_validation_error @@ -154,12 +155,15 @@ def message_send( log.info("Using saved context: %s", context_id) credentials: AuthCredentials | None = None - if bearer_token: - credentials = create_bearer_auth(bearer_token) - elif api_key: - credentials = create_api_key_auth(api_key) - else: - credentials = get_credentials(agent_url) + try: + credentials = resolve_auth_credentials( + agent_url, + bearer_token=bearer_token, + api_key=api_key, + ) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error async def do_send() -> None: try: @@ -272,6 +276,9 @@ async def _stream_message( output.line( "Set credentials with: handler auth set --bearer " ) + output.line( + "Or configure automatic tokens: handler auth source set --provider gcloud" + ) def _format_send_result(result: SendResult, output: Output) -> None: @@ -294,6 +301,9 @@ def _format_send_result(result: SendResult, output: Output) -> None: output.line( "Or provide inline: handler message send --bearer ..." ) + output.line( + "Or configure automatic tokens: handler auth source set --provider gcloud" + ) elif result.text: output.markdown(result.text) else: diff --git a/src/a2a_handler/cli/session.py b/src/a2a_handler/cli/session.py index 9749dfc..6f1264a 100644 --- a/src/a2a_handler/cli/session.py +++ b/src/a2a_handler/cli/session.py @@ -63,7 +63,7 @@ def session_clear(agent_url: Optional[str], clear_all: bool) -> None: output = Output() if clear_all: clear_session() - output.success("Cleared all sessions") + output.success("Cleared all sessions (credentials preserved)") elif agent_url: try: validate_agent_url(agent_url) @@ -72,6 +72,6 @@ def session_clear(agent_url: Optional[str], clear_all: bool) -> None: raise click.Abort() from error clear_session(agent_url) - output.success(f"Cleared session for {agent_url}") + output.success(f"Cleared session for {agent_url} (credentials preserved)") else: output.warning("Provide AGENT_URL or use --all to clear sessions") diff --git a/src/a2a_handler/cli/task.py b/src/a2a_handler/cli/task.py index 1c4e317..7a5c1d0 100644 --- a/src/a2a_handler/cli/task.py +++ b/src/a2a_handler/cli/task.py @@ -6,7 +6,7 @@ import rich_click as click -from a2a_handler.auth import AuthCredentials, create_api_key_auth, create_bearer_auth +from a2a_handler.auth import AuthCredentials from a2a_handler.common import Output, get_logger from a2a_handler.common.input_validation import ( InputValidationError, @@ -17,8 +17,8 @@ validate_resource_id, validate_webhook_url, ) +from a2a_handler.credentials import resolve_auth_credentials from a2a_handler.service import A2AService, TaskResult -from a2a_handler.session import get_credentials from ._helpers import ( build_http_client, @@ -96,12 +96,15 @@ def task_get( log.info("Getting task %s from %s", task_id, agent_url) credentials: AuthCredentials | None = None - if bearer_token: - credentials = create_bearer_auth(bearer_token) - elif api_key: - credentials = create_api_key_auth(api_key) - else: - credentials = get_credentials(agent_url) + try: + credentials = resolve_auth_credentials( + agent_url, + bearer_token=bearer_token, + api_key=api_key, + ) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error async def do_get() -> None: try: @@ -143,12 +146,15 @@ def task_cancel( log.info("Canceling task %s at %s", task_id, agent_url) credentials: AuthCredentials | None = None - if bearer_token: - credentials = create_bearer_auth(bearer_token) - elif api_key: - credentials = create_api_key_auth(api_key) - else: - credentials = get_credentials(agent_url) + try: + credentials = resolve_auth_credentials( + agent_url, + bearer_token=bearer_token, + api_key=api_key, + ) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error async def do_cancel() -> None: try: @@ -196,12 +202,15 @@ def task_resubscribe( log.info("Resubscribing to task %s at %s", task_id, agent_url) credentials: AuthCredentials | None = None - if bearer_token: - credentials = create_bearer_auth(bearer_token) - elif api_key: - credentials = create_api_key_auth(api_key) - else: - credentials = get_credentials(agent_url) + try: + credentials = resolve_auth_credentials( + agent_url, + bearer_token=bearer_token, + api_key=api_key, + ) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error async def do_resubscribe() -> None: try: @@ -279,12 +288,15 @@ def notification_set( log.info("Setting push config for task %s at %s", task_id, agent_url) credentials: AuthCredentials | None = None - if bearer_token: - credentials = create_bearer_auth(bearer_token) - elif api_key: - credentials = create_api_key_auth(api_key) - else: - credentials = get_credentials(agent_url) + try: + credentials = resolve_auth_credentials( + agent_url, + bearer_token=bearer_token, + api_key=api_key, + ) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error async def do_set() -> None: try: @@ -343,12 +355,15 @@ def notification_get( log.info("Getting push config for task %s at %s", task_id, agent_url) credentials: AuthCredentials | None = None - if bearer_token: - credentials = create_bearer_auth(bearer_token) - elif api_key: - credentials = create_api_key_auth(api_key) - else: - credentials = get_credentials(agent_url) + try: + credentials = resolve_auth_credentials( + agent_url, + bearer_token=bearer_token, + api_key=api_key, + ) + except InputValidationError as error: + handle_validation_error(error, output) + raise click.Abort() from error async def do_get() -> None: try: diff --git a/src/a2a_handler/common/__init__.py b/src/a2a_handler/common/__init__.py index f65f5dc..bb6f129 100644 --- a/src/a2a_handler/common/__init__.py +++ b/src/a2a_handler/common/__init__.py @@ -4,7 +4,12 @@ """ from .config import ( + clear_agent_bearer_command, + get_agent_bearer_command, + get_default_bearer_command, get_theme, + save_agent_bearer_command, + save_default_bearer_command, save_theme, ) from .logging import ( @@ -24,11 +29,16 @@ "LogRecord", "Output", "configure_output", + "clear_agent_bearer_command", + "get_agent_bearer_command", + "get_default_bearer_command", "TUILogHandler", "get_logger", "get_theme", "get_tui_log_handler", "install_tui_log_handler", + "save_agent_bearer_command", + "save_default_bearer_command", "save_theme", "setup_logging", ] diff --git a/src/a2a_handler/common/config.py b/src/a2a_handler/common/config.py index 3fc0585..2cd26a3 100644 --- a/src/a2a_handler/common/config.py +++ b/src/a2a_handler/common/config.py @@ -14,6 +14,9 @@ CONFIG_FILE = CONFIG_DIR / "config.json" DEFAULT_THEME = "gruvbox" +AUTH_CONFIG_KEY = "auth" +DEFAULT_BEARER_COMMAND_KEY = "default_bearer_command" +AGENT_BEARER_COMMANDS_KEY = "agent_bearer_commands" def _ensure_config_dir() -> None: @@ -41,6 +44,15 @@ def _save_config(config: dict[str, Any]) -> None: logger.warning("Failed to save config: %s", e) +def _get_auth_config(config: dict[str, Any]) -> dict[str, Any]: + """Ensure auth config object exists and return it.""" + auth_config = config.get(AUTH_CONFIG_KEY) + if not isinstance(auth_config, dict): + auth_config = {} + config[AUTH_CONFIG_KEY] = auth_config + return auth_config + + def get_theme() -> str: """Get the saved theme, or default if not set.""" config = _load_config() @@ -52,3 +64,69 @@ def save_theme(theme: str) -> None: config = _load_config() config["theme"] = theme _save_config(config) + + +def get_default_bearer_command() -> str | None: + """Get the configured default bearer token command, if present.""" + config = _load_config() + auth_config = config.get(AUTH_CONFIG_KEY) + if not isinstance(auth_config, dict): + return None + + command = auth_config.get(DEFAULT_BEARER_COMMAND_KEY) + if isinstance(command, str) and command.strip(): + return command + return None + + +def save_default_bearer_command(command: str | None) -> None: + """Set or clear the global default bearer token command.""" + config = _load_config() + auth_config = _get_auth_config(config) + + if command is None: + auth_config.pop(DEFAULT_BEARER_COMMAND_KEY, None) + else: + auth_config[DEFAULT_BEARER_COMMAND_KEY] = command + + _save_config(config) + + +def get_agent_bearer_command(agent_url: str) -> str | None: + """Get a per-agent bearer token command, if configured.""" + config = _load_config() + auth_config = config.get(AUTH_CONFIG_KEY) + if not isinstance(auth_config, dict): + return None + + by_agent = auth_config.get(AGENT_BEARER_COMMANDS_KEY) + if not isinstance(by_agent, dict): + return None + + command = by_agent.get(agent_url) + if isinstance(command, str) and command.strip(): + return command + return None + + +def save_agent_bearer_command(agent_url: str, command: str) -> None: + """Persist a per-agent bearer token command.""" + config = _load_config() + auth_config = _get_auth_config(config) + by_agent = auth_config.get(AGENT_BEARER_COMMANDS_KEY) + if not isinstance(by_agent, dict): + by_agent = {} + auth_config[AGENT_BEARER_COMMANDS_KEY] = by_agent + + by_agent[agent_url] = command + _save_config(config) + + +def clear_agent_bearer_command(agent_url: str) -> None: + """Remove a configured per-agent bearer token command.""" + config = _load_config() + auth_config = _get_auth_config(config) + by_agent = auth_config.get(AGENT_BEARER_COMMANDS_KEY) + if isinstance(by_agent, dict): + by_agent.pop(agent_url, None) + _save_config(config) diff --git a/src/a2a_handler/common/output.py b/src/a2a_handler/common/output.py index 0bd0490..d341322 100644 --- a/src/a2a_handler/common/output.py +++ b/src/a2a_handler/common/output.py @@ -6,6 +6,7 @@ from __future__ import annotations import json as json_module +import re import sys from typing import Any, Literal, TextIO @@ -27,6 +28,80 @@ _DEFAULT_OUTPUT_FORMAT: OutputFormat = "text" _DEFAULT_QUIET = False +_REDACTED = "[REDACTED]" +_SENSITIVE_KEY_MARKERS = ( + "password", + "passwd", + "passphrase", + "token", + "secret", + "api_key", + "apikey", + "authorization", + "bearer", + "credential", +) +_AUTH_BEARER_PATTERN = re.compile(r"(?i)(authorization\s*[:=]\s*bearer\s+)([^\s,;]+)") +_AUTH_GENERIC_PATTERN = re.compile( + r"(?i)(authorization\s*[:=]\s*)(?!bearer\b)([^\s,;]+)" +) +_CLI_SECRET_OPTION_PATTERN = re.compile( + r"(?i)(--(?:password|passwd|passphrase|token|api-key|api_key|bearer|secret))(=|\s+)([^\s]+)" +) +_SENSITIVE_ASSIGNMENT_PATTERN = re.compile( + r"(?i)(\"?(?:password|passwd|passphrase|token|api[_-]?key|secret)\"?\s*[:=]\s*\"?)([^\"'\s,;]+)(\"?)" +) + + +def _is_sensitive_key(key: str) -> bool: + """Return True when a key name likely contains a secret value.""" + normalized = "_".join(key.strip().lower().split()).replace("-", "_") + return any(marker in normalized for marker in _SENSITIVE_KEY_MARKERS) + + +def _redact_text(text: str) -> str: + """Mask common inline secret patterns in plain text strings.""" + redacted = _AUTH_BEARER_PATTERN.sub(r"\1[REDACTED]", text) + redacted = _AUTH_GENERIC_PATTERN.sub(r"\1[REDACTED]", redacted) + redacted = _CLI_SECRET_OPTION_PATTERN.sub(r"\1\2[REDACTED]", redacted) + return _SENSITIVE_ASSIGNMENT_PATTERN.sub(r"\1[REDACTED]\3", redacted) + + +def _redact_secret_value(value: Any) -> Any: + """Replace a secret value while preserving explicit nulls.""" + if value is None: + return None + return _REDACTED + + +def _redact_data(value: Any) -> Any: + """Recursively redact secrets from structured output payloads.""" + if isinstance(value, dict): + redacted: dict[Any, Any] = {} + for key, inner_value in value.items(): + key_str = str(key) + if _is_sensitive_key(key_str): + redacted[key] = _redact_secret_value(inner_value) + continue + redacted[key] = _redact_data(inner_value) + + field_name = redacted.get("name") + if isinstance(field_name, str) and _is_sensitive_key(field_name): + if "value" in redacted: + redacted["value"] = _redact_secret_value(redacted["value"]) + + return redacted + + if isinstance(value, list): + return [_redact_data(item) for item in value] + + if isinstance(value, tuple): + return tuple(_redact_data(item) for item in value) + + if isinstance(value, str): + return _redact_text(value) + + return value def configure_output(output_format: OutputFormat = "text", quiet: bool = False) -> None: @@ -69,22 +144,23 @@ def _style(self, text: str, *codes: str) -> str: def _print(self, text: str) -> None: """Print text to stdout.""" - print(text) + sys.stdout.write(f"{text}\n") def _emit_text(self, text: str, force: bool = False) -> None: """Print plain text output with quiet-mode handling.""" if self._quiet and not force: return - self._print(text) + self._print(_redact_text(text)) def _emit_structured(self, payload: dict[str, Any], force: bool = False) -> None: """Emit structured output in json/ndjson mode.""" if self._quiet and not force: return + safe_payload = _redact_data(payload) if self._output_format == "ndjson": - self._print(json_module.dumps(payload, default=str)) + self._print(json_module.dumps(safe_payload, default=str)) else: - self._print(json_module.dumps(payload, indent=2, default=str)) + self._print(json_module.dumps(safe_payload, indent=2, default=str)) def line(self, text: str, style: str | None = None) -> None: """Print a line of text with optional style.""" @@ -111,13 +187,18 @@ def field( value_style: str | None = None, ) -> None: """Print a field as 'Name: value' with formatting.""" - value_str = str(value) if value is not None else "none" + safe_value = ( + _redact_secret_value(value) + if _is_sensitive_key(name) + else _redact_data(value) + ) + value_str = str(safe_value) if safe_value is not None else "none" if self._output_format != "text": self._emit_structured( { "type": "field", "name": name, - "value": value, + "value": safe_value, "dim_value": dim_value, "value_style": value_style, } diff --git a/src/a2a_handler/credentials.py b/src/a2a_handler/credentials.py new file mode 100644 index 0000000..a180443 --- /dev/null +++ b/src/a2a_handler/credentials.py @@ -0,0 +1,150 @@ +"""Credential resolution helpers for CLI, TUI, and MCP surfaces.""" + +from __future__ import annotations + +import os +import shlex +import subprocess + +from a2a_handler.auth import ( + AuthCredentials, + AuthType, + create_api_key_auth, + create_bearer_auth, +) +from a2a_handler.common import ( + get_agent_bearer_command, + get_default_bearer_command, + get_logger, +) +from a2a_handler.common.input_validation import ( + InputValidationError, + reject_control_chars, +) +from a2a_handler.session import get_credentials + +logger = get_logger(__name__) + +DEFAULT_BEARER_COMMAND_ENV = "HANDLER_BEARER_COMMAND" +BUILTIN_BEARER_PROVIDERS: dict[str, str] = { + "gcloud": "gcloud auth print-identity-token", +} + + +def get_builtin_provider_command(provider: str) -> str | None: + """Return the built-in token command for a provider name.""" + return BUILTIN_BEARER_PROVIDERS.get(provider) + + +def resolve_configured_bearer_command(agent_url: str) -> tuple[str, str] | None: + """Resolve configured bearer command and its source. + + Precedence: + 1. Per-agent command from config + 2. Global default command from config + 3. Environment variable fallback (HANDLER_BEARER_COMMAND) + """ + if command := get_agent_bearer_command(agent_url): + return command, "agent" + if command := get_default_bearer_command(): + return command, "default" + if command := os.getenv(DEFAULT_BEARER_COMMAND_ENV): + return command, "env" + return None + + +def _token_from_command(command: str) -> str: + """Execute a command and return bearer token output.""" + reject_control_chars(command, "bearer_command") + + try: + command_parts = shlex.split(command) + except ValueError as error: + raise InputValidationError( + code="invalid_bearer_command", + message=f"Invalid bearer command: {error}", + suggestion="Provide a valid shell command string", + ) from error + + if not command_parts: + raise InputValidationError( + code="empty_bearer_command", + message="bearer_command cannot be empty", + suggestion="Provide a command that prints a token to stdout", + ) + + try: + command_result = subprocess.run( + command_parts, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as error: + stderr = error.stderr.strip() if error.stderr else "" + message = f"bearer_command failed with exit code {error.returncode}" + if stderr: + message = f"{message}: {stderr}" + raise InputValidationError( + code="bearer_command_failed", + message=message, + suggestion="Verify CLI login and command arguments", + ) from error + + token = command_result.stdout.strip() + if not token: + raise InputValidationError( + code="empty_bearer_token", + message="bearer_command produced an empty token", + suggestion="Use a command that prints only a token to stdout", + ) + + reject_control_chars(token, "bearer_token") + return token + + +def resolve_auth_credentials( + agent_url: str, + bearer_token: str | None = None, + api_key: str | None = None, + bearer_command: str | None = None, +) -> AuthCredentials | None: + """Resolve credentials from explicit inputs, config sources, and keyring. + + Precedence: + 1. Explicit bearer token + 2. Explicit API key + 3. Explicit bearer command + 4. Configured bearer command (per-agent/default/env) + 5. Saved credentials from keyring-backed session data + """ + if bearer_token: + reject_control_chars(bearer_token, "bearer_token") + return create_bearer_auth(bearer_token) + + if api_key: + reject_control_chars(api_key, "api_key") + return create_api_key_auth(api_key) + + command = bearer_command + command_source = "explicit" + if command is None: + configured = resolve_configured_bearer_command(agent_url) + if configured is not None: + command, command_source = configured + + if command: + token = _token_from_command(command) + logger.debug( + "Resolved bearer token via %s command for %s", + command_source, + agent_url, + ) + return create_bearer_auth(token) + + credentials = get_credentials(agent_url) + if credentials and credentials.auth_type == AuthType.BEARER: + logger.debug("Using saved bearer credentials for %s", agent_url) + elif credentials and credentials.auth_type == AuthType.API_KEY: + logger.debug("Using saved API key credentials for %s", agent_url) + return credentials diff --git a/src/a2a_handler/mcp/server.py b/src/a2a_handler/mcp/server.py index 481edc2..b39f2b8 100644 --- a/src/a2a_handler/mcp/server.py +++ b/src/a2a_handler/mcp/server.py @@ -14,13 +14,13 @@ validate_resource_id, validate_webhook_url, ) +from a2a_handler.credentials import resolve_auth_credentials from a2a_handler.service import A2AService from a2a_handler.session import ( clear_credentials as session_clear_credentials, ) from a2a_handler.session import ( clear_session, - get_credentials, get_session, get_session_store, set_credentials, @@ -53,11 +53,14 @@ def _resolve_credentials( api_key: str | None = None, ) -> AuthCredentials | None: """Resolve credentials from explicit args or saved session.""" - if bearer_token: - return create_bearer_auth(bearer_token) - if api_key: - return create_api_key_auth(api_key) - return get_credentials(agent_url) + try: + return resolve_auth_credentials( + agent_url, + bearer_token=bearer_token, + api_key=api_key, + ) + except InputValidationError as error: + raise _validation_error(error) from error def create_mcp_server() -> FastMCP: diff --git a/src/a2a_handler/session.py b/src/a2a_handler/session.py index d088df6..1c1a45b 100644 --- a/src/a2a_handler/session.py +++ b/src/a2a_handler/session.py @@ -5,6 +5,7 @@ """ import json +import os from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -12,10 +13,32 @@ from a2a_handler.auth import AuthCredentials from a2a_handler.common import get_logger +keyring: Any | None + +try: + import keyring as _keyring + from keyring.errors import KeyringError + + keyring = _keyring +except ImportError: # pragma: no cover - exercised when keyring dependency is absent + keyring = None + + class KeyringError(Exception): + """Fallback KeyringError when keyring is unavailable.""" + + pass + + logger = get_logger(__name__) DEFAULT_SESSION_DIRECTORY = Path.home() / ".handler" SESSION_FILENAME = "sessions.json" +SESSION_DIRECTORY_MODE = 0o700 +SESSION_FILE_MODE = 0o600 + +KEYRING_SERVICE_NAME = "a2a-handler" +CREDENTIAL_STORAGE_KEYRING = "keyring" +CREDENTIAL_STORAGE_PLAINTEXT = "plaintext" @dataclass @@ -58,24 +81,170 @@ def session_file_path(self) -> Path: """Path to the session file.""" return self.session_directory / SESSION_FILENAME + def _harden_path_permissions(self, path: Path, mode: int) -> None: + """Apply restrictive permissions to session storage paths.""" + try: + path.chmod(mode) + except OSError as error: + logger.warning("Failed to set permissions on %s: %s", path, error) + def _ensure_directory_exists(self) -> None: """Ensure the session directory exists.""" - self.session_directory.mkdir(parents=True, exist_ok=True) + self.session_directory.mkdir( + parents=True, + exist_ok=True, + mode=SESSION_DIRECTORY_MODE, + ) + self._harden_path_permissions(self.session_directory, SESSION_DIRECTORY_MODE) + + def _store_credential_in_keyring(self, agent_url: str, value: str) -> bool: + """Persist credential values in the OS keyring when available.""" + if keyring is None: + return False + + try: + keyring.set_password(KEYRING_SERVICE_NAME, agent_url, value) + return True + except KeyringError as error: + logger.warning( + "Keyring storage unavailable for %s, falling back to plaintext: %s", + agent_url, + error, + ) + except Exception as error: # pragma: no cover - defensive fallback + logger.warning( + "Unexpected keyring failure for %s, falling back to plaintext: %s", + agent_url, + error, + ) + + return False + + def _get_credential_from_keyring(self, agent_url: str) -> str | None: + """Load credential values from the OS keyring.""" + if keyring is None: + return None + + try: + return keyring.get_password(KEYRING_SERVICE_NAME, agent_url) + except KeyringError as error: + logger.warning( + "Failed to read keyring credential for %s: %s", agent_url, error + ) + except Exception as error: # pragma: no cover - defensive fallback + logger.warning( + "Unexpected keyring read failure for %s: %s", + agent_url, + error, + ) + + return None + + def _delete_credential_from_keyring(self, agent_url: str) -> None: + """Remove a credential value from the OS keyring.""" + if keyring is None: + return + + try: + keyring.delete_password(KEYRING_SERVICE_NAME, agent_url) + except KeyringError: + # Missing/unsupported entries are safe to ignore. + return + except Exception as error: # pragma: no cover - defensive fallback + logger.warning( + "Unexpected keyring delete failure for %s: %s", + agent_url, + error, + ) + + def _serialize_credentials( + self, + agent_url: str, + credentials: AuthCredentials, + ) -> dict[str, str | None]: + """Serialize credentials while storing secret values in keyring.""" + serialized: dict[str, str | None] = { + "auth_type": credentials.auth_type.value, + "header_name": credentials.header_name, + } + + if self._store_credential_in_keyring(agent_url, credentials.value): + serialized["storage"] = CREDENTIAL_STORAGE_KEYRING + else: + serialized["storage"] = CREDENTIAL_STORAGE_PLAINTEXT + serialized["value"] = credentials.value + + return serialized + + def _deserialize_credentials( + self, + agent_url: str, + cred_data: dict[str, Any], + ) -> AuthCredentials | None: + """Deserialize credentials from session data with keyring support.""" + storage = cred_data.get("storage") + value: str | None = None + + if storage == CREDENTIAL_STORAGE_KEYRING: + value = self._get_credential_from_keyring(agent_url) + if value is None and isinstance(cred_data.get("value"), str): + # Compatibility fallback for mixed-format data. + value = cred_data["value"] + elif storage is None: + # Legacy data may not include storage metadata. Prefer keyring when + # available so stale plaintext values don't override managed secrets. + value = self._get_credential_from_keyring(agent_url) + if value is None and isinstance(cred_data.get("value"), str): + value = cred_data["value"] + elif isinstance(cred_data.get("value"), str): + value = cred_data["value"] + + if value is None: + logger.warning( + "Credentials missing for %s (storage=%s)", agent_url, storage + ) + return None + + normalized_data = { + "auth_type": cred_data.get("auth_type"), + "value": value, + "header_name": cred_data.get("header_name"), + } + + try: + return AuthCredentials.from_dict(normalized_data) + except (KeyError, TypeError, ValueError) as error: + logger.warning("Invalid credential data for %s: %s", agent_url, error) + return None def load(self) -> None: """Load sessions from disk.""" + if self.session_directory.exists(): + self._harden_path_permissions( + self.session_directory, SESSION_DIRECTORY_MODE + ) if not self.session_file_path.exists(): logger.debug("No session file found at %s", self.session_file_path) return try: + self._harden_path_permissions(self.session_file_path, SESSION_FILE_MODE) with open(self.session_file_path) as session_file: session_data = json.load(session_file) for agent_url, agent_session_data in session_data.items(): + if not isinstance(agent_session_data, dict): + logger.warning("Skipping invalid session entry for %s", agent_url) + continue + credentials = None if cred_data := agent_session_data.get("credentials"): - credentials = AuthCredentials.from_dict(cred_data) + if isinstance(cred_data, dict): + credentials = self._deserialize_credentials( + agent_url, cred_data + ) + else: + logger.warning("Skipping invalid credentials for %s", agent_url) self.sessions[agent_url] = AgentSession( agent_url=agent_url, @@ -106,12 +275,21 @@ def save(self) -> None: "task_id": agent_session.task_id, } if agent_session.credentials: - data["credentials"] = agent_session.credentials.to_dict() + data["credentials"] = self._serialize_credentials( + agent_url, + agent_session.credentials, + ) session_data[agent_url] = data try: - with open(self.session_file_path, "w") as session_file: + file_descriptor = os.open( + self.session_file_path, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + SESSION_FILE_MODE, + ) + with os.fdopen(file_descriptor, "w") as session_file: json.dump(session_data, session_file, indent=2) + self._harden_path_permissions(self.session_file_path, SESSION_FILE_MODE) logger.debug( "Saved %d sessions to %s", len(self.sessions), @@ -161,6 +339,7 @@ def set_credentials( def clear_credentials(self, agent_url: str) -> None: """Clear credentials for an agent.""" + self._delete_credential_from_keyring(agent_url) if agent_url in self.sessions: self.sessions[agent_url].clear_credentials() self.save() @@ -181,11 +360,20 @@ def clear(self, agent_url: str | None = None) -> None: """ if agent_url: if agent_url in self.sessions: - del self.sessions[agent_url] + agent_session = self.sessions[agent_url] + agent_session.context_id = None + agent_session.task_id = None + if agent_session.credentials is None: + del self.sessions[agent_url] logger.info("Cleared session for %s", agent_url) else: session_count = len(self.sessions) - self.sessions.clear() + for existing_agent_url in list(self.sessions): + existing_session = self.sessions[existing_agent_url] + existing_session.context_id = None + existing_session.task_id = None + if existing_session.credentials is None: + del self.sessions[existing_agent_url] logger.info("Cleared all %d sessions", session_count) self.save() diff --git a/src/a2a_handler/tui/app.py b/src/a2a_handler/tui/app.py index 6c59cb7..623ca3b 100644 --- a/src/a2a_handler/tui/app.py +++ b/src/a2a_handler/tui/app.py @@ -21,6 +21,8 @@ from a2a_handler.auth import AuthCredentials from a2a_handler.common import get_theme, install_tui_log_handler, save_theme +from a2a_handler.common.input_validation import InputValidationError +from a2a_handler.credentials import resolve_auth_credentials from a2a_handler.service import A2AService from a2a_handler.tui.components import ( AgentCardPanel, @@ -145,6 +147,14 @@ async def _connect_to_agent( ) return await self._agent_service.get_card() + def _resolve_auth_for_agent(self, agent_url: str) -> AuthCredentials | None: + """Resolve credentials from TUI input first, then configured defaults.""" + messages_panel = self.query_one("#messages-container", TabbedMessagesPanel) + credentials = messages_panel.get_auth_credentials() + if credentials: + return credentials + return resolve_auth_credentials(agent_url) + def _update_ui_for_connected_state(self, agent_card: AgentCard) -> None: agent_card_panel = self.query_one("#agent-card-container", AgentCardPanel) agent_card_panel.update_card(agent_card) @@ -167,7 +177,7 @@ async def handle_connect_button(self) -> None: messages_panel.add_system_message(f"Connecting to {agent_url}...") try: - credentials = messages_panel.get_auth_credentials() + credentials = self._resolve_auth_for_agent(agent_url) agent_card = await self._connect_to_agent(agent_url, credentials) self.current_agent_card = agent_card @@ -182,6 +192,10 @@ async def handle_connect_button(self) -> None: agent_card_panel = self.query_one("#agent-card-container", AgentCardPanel) agent_card_panel.focus() + except InputValidationError as error: + messages_panel.add_system_message( + f"Authentication setup error: {error.message}" + ) except Exception as error: logger.error("Connection failed: %s", error, exc_info=True) messages_panel.add_system_message(f"Connection failed: {error!s}") @@ -224,7 +238,7 @@ async def _send_message(self) -> None: try: logger.info("Sending message: %s", message_text[:50]) - credentials = messages_panel.get_auth_credentials() + credentials = self._resolve_auth_for_agent(self.current_agent_url) if credentials: self._agent_service.set_credentials(credentials) else: @@ -264,6 +278,16 @@ async def _send_message(self) -> None: self.current_context_id or "", ) + if send_result.needs_auth: + messages_panel.add_system_message( + "Authentication required. Run 'handler auth source set --provider gcloud' " + "or set credentials with 'handler auth set --bearer '." + ) + + except InputValidationError as error: + messages_panel.add_system_message( + f"Authentication setup error: {error.message}" + ) except Exception as error: logger.error("Error sending message: %s", error, exc_info=True) messages_panel.add_system_message(f"Error: {error!s}") diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py index 508fd46..7812a0f 100644 --- a/tests/test_cli_auth.py +++ b/tests/test_cli_auth.py @@ -184,3 +184,67 @@ def test_clear_rejects_invalid_agent_url(self, runner): assert result.exit_code == 1 assert "agent_url must be a valid http(s) URL" in result.output + + +class TestAuthSource: + """Tests for auth source command group.""" + + def test_source_set_global_provider(self, runner): + """Setting global auth source with built-in provider saves command.""" + with patch("a2a_handler.cli.auth.save_default_bearer_command") as mock_save: + result = runner.invoke(auth, ["source", "set", "--provider", "gcloud"]) + + assert result.exit_code == 0 + mock_save.assert_called_once_with("gcloud auth print-identity-token") + assert "Set global auth source" in result.output + + def test_source_set_agent_command(self, runner): + """Setting per-agent auth source stores command for that agent.""" + with patch("a2a_handler.cli.auth.save_agent_bearer_command") as mock_save: + result = runner.invoke( + auth, + [ + "source", + "set", + "http://localhost:8000", + "--command", + "gcloud auth print-identity-token", + ], + ) + + assert result.exit_code == 0 + mock_save.assert_called_once_with( + "http://localhost:8000", + "gcloud auth print-identity-token", + ) + + def test_source_set_requires_provider_or_command(self, runner): + """Auth source set requires one source mode.""" + result = runner.invoke(auth, ["source", "set"]) + + assert result.exit_code == 1 + assert "Provide --provider or --command" in result.output + + def test_source_show_agent_falls_back_to_default(self, runner): + """Agent source show falls back to configured global default.""" + with ( + patch("a2a_handler.cli.auth.get_agent_bearer_command", return_value=None), + patch( + "a2a_handler.cli.auth.get_default_bearer_command", + return_value="gcloud auth print-identity-token", + ), + ): + result = runner.invoke(auth, ["source", "show", "http://localhost:8000"]) + + assert result.exit_code == 0 + assert "Default fallback" in result.output + assert "gcloud auth print-identity-token" in result.output + + def test_source_clear_global(self, runner): + """Clearing global source removes default bearer command.""" + with patch("a2a_handler.cli.auth.save_default_bearer_command") as mock_save: + result = runner.invoke(auth, ["source", "clear"]) + + assert result.exit_code == 0 + mock_save.assert_called_once_with(None) + assert "Cleared global auth source" in result.output diff --git a/tests/test_cli_card.py b/tests/test_cli_card.py index 90c7f0d..1489d6e 100644 --- a/tests/test_cli_card.py +++ b/tests/test_cli_card.py @@ -97,14 +97,16 @@ def test_card_get_authenticated_uses_saved_credentials(self, runner): with ( patch("a2a_handler.cli.card.build_http_client") as mock_client, - patch("a2a_handler.cli.card.get_credentials") as mock_get_credentials, + patch( + "a2a_handler.cli.card.resolve_auth_credentials" + ) as mock_resolve_credentials, patch("a2a_handler.cli.card.A2AService") as mock_service_cls, ): mock_http = AsyncMock() mock_http.__aenter__.return_value = mock_http mock_http.__aexit__.return_value = None mock_client.return_value = mock_http - mock_get_credentials.return_value = credentials + mock_resolve_credentials.return_value = credentials mock_service = AsyncMock() mock_service.get_card.return_value = mock_card @@ -113,7 +115,7 @@ def test_card_get_authenticated_uses_saved_credentials(self, runner): result = runner.invoke(card, ["get", "http://localhost:8000", "-a"]) assert result.exit_code == 0 - mock_get_credentials.assert_called_once_with("http://localhost:8000") + mock_resolve_credentials.assert_called_once_with("http://localhost:8000") mock_service_cls.assert_called_once_with( mock_http, "http://localhost:8000", diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py index f7035f1..0f3e6a4 100644 --- a/tests/test_cli_main.py +++ b/tests/test_cli_main.py @@ -1,5 +1,7 @@ """Tests for top-level CLI commands.""" +import re +import subprocess from unittest.mock import patch import pytest @@ -7,6 +9,14 @@ from a2a_handler.cli import cli +ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m") + + +def _normalized_output(output: str) -> str: + """Normalize rich-formatted CLI output for stable assertions.""" + without_ansi = ANSI_ESCAPE_PATTERN.sub("", output) + return " ".join(without_ansi.split()) + @pytest.fixture def runner() -> CliRunner: @@ -24,6 +34,80 @@ def test_tui_passes_bearer_token_to_app(runner: CliRunner) -> None: mock_tui_cls.return_value.run.assert_called_once() +def test_tui_passes_bearer_token_from_command(runner: CliRunner) -> None: + """TUI command resolves bearer token from subprocess stdout.""" + with ( + patch("a2a_handler.cli.HandlerTUI") as mock_tui_cls, + patch("a2a_handler.cli.subprocess.run") as mock_run, + ): + mock_run.return_value = subprocess.CompletedProcess( + args=["gcloud", "auth", "print-identity-token"], + returncode=0, + stdout="token-from-command\n", + stderr="", + ) + + result = runner.invoke( + cli, + ["tui", "--bearer-command", "gcloud auth print-identity-token"], + ) + + assert result.exit_code == 0 + mock_tui_cls.assert_called_once_with(initial_bearer_token="token-from-command") + mock_tui_cls.return_value.run.assert_called_once() + + +def test_tui_passes_bearer_token_from_stdin(runner: CliRunner) -> None: + """TUI command reads bearer token from stdin.""" + with patch("a2a_handler.cli.HandlerTUI") as mock_tui_cls: + result = runner.invoke(cli, ["tui", "--bearer-stdin"], input="token-stdin\n") + + assert result.exit_code == 0 + mock_tui_cls.assert_called_once_with(initial_bearer_token="token-stdin") + mock_tui_cls.return_value.run.assert_called_once() + + +def test_tui_rejects_multiple_bearer_sources(runner: CliRunner) -> None: + """TUI command rejects multiple bearer token sources.""" + result = runner.invoke( + cli, + ["tui", "--bearer", "token-123", "--bearer-stdin"], + ) + + assert result.exit_code == 1 + assert "Use only one of --bearer, --bearer-command, or --bearer-stdin" in ( + _normalized_output(result.output) + ) + + +def test_tui_reports_bearer_command_failure(runner: CliRunner) -> None: + """TUI command surfaces subprocess failures from --bearer-command.""" + with patch("a2a_handler.cli.subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError( + returncode=2, + cmd=["gcloud", "auth", "print-identity-token"], + stderr="permission denied", + ) + + result = runner.invoke( + cli, + ["tui", "--bearer-command", "gcloud auth print-identity-token"], + ) + + assert result.exit_code == 1 + assert "--bearer-command failed with exit code 2: permission denied" in ( + _normalized_output(result.output) + ) + + +def test_tui_rejects_empty_stdin_token(runner: CliRunner) -> None: + """TUI command rejects empty --bearer-stdin tokens.""" + result = runner.invoke(cli, ["tui", "--bearer-stdin"], input="\n") + + assert result.exit_code == 1 + assert "--bearer-stdin received an empty token" in _normalized_output(result.output) + + def test_version_plain_text_default(runner: CliRunner) -> None: """Version command defaults to text output.""" result = runner.invoke(cli, ["version"]) diff --git a/tests/test_cli_message.py b/tests/test_cli_message.py index 8fd6ddc..d7fcf29 100644 --- a/tests/test_cli_message.py +++ b/tests/test_cli_message.py @@ -6,6 +6,7 @@ from click.testing import CliRunner from a2a.types import Task, TaskState, TaskStatus +from a2a_handler.auth import create_bearer_auth from a2a_handler.cli.message import message, _format_send_result, _stream_message from a2a_handler.common import Output from a2a_handler.service import SendResult, StreamEvent @@ -161,6 +162,39 @@ def test_message_send_with_bearer_auth(self, runner): call_kwargs = mock_service_cls.call_args.kwargs assert call_kwargs["credentials"] is not None + def test_message_send_uses_resolved_auth_source(self, runner): + """Message send uses resolver output when inline creds are omitted.""" + mock_task = _make_task(TaskState.completed) + mock_result = SendResult(task=mock_task, text="Response") + + with ( + patch("a2a_handler.cli.message.build_http_client") as mock_client, + patch("a2a_handler.cli.message.A2AService") as mock_service_cls, + patch("a2a_handler.cli.message.resolve_auth_credentials") as mock_resolve, + patch("a2a_handler.cli.message.update_session"), + ): + mock_http = AsyncMock() + mock_http.__aenter__.return_value = mock_http + mock_http.__aexit__.return_value = None + mock_client.return_value = mock_http + + mock_service = AsyncMock() + mock_service.send.return_value = mock_result + mock_service_cls.return_value = mock_service + mock_resolve.return_value = create_bearer_auth("resolved-token") + + result = runner.invoke( + message, + ["send", "http://localhost:8000", "Hello"], + ) + + assert result.exit_code == 0 + mock_resolve.assert_called_once_with( + "http://localhost:8000", + bearer_token=None, + api_key=None, + ) + def test_message_send_with_push_url(self, runner): """Test message send with push notification URL.""" mock_task = _make_task(TaskState.completed) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 957aec8..3a3cb9c 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,10 +1,16 @@ """Tests for MCP server tool registration and validation guards.""" +import json +import tempfile +from pathlib import Path from unittest.mock import patch import pytest +import a2a_handler.session as session_module +from a2a_handler.common.input_validation import InputValidationError from a2a_handler.mcp.server import create_mcp_server +from a2a_handler.session import SessionStore def _tool_fn(server, name: str): @@ -74,9 +80,58 @@ async def test_set_agent_credentials_rejects_multiple_auth_values() -> None: server = create_mcp_server() set_agent_credentials = _tool_fn(server, "set_agent_credentials") - with pytest.raises(ValueError, match="invalid_auth_arguments"): - await set_agent_credentials( - agent_url="http://localhost:8000", - bearer_token="token", - api_key="secret-key", - ) + with patch("a2a_handler.mcp.server.set_credentials") as mock_set: + with pytest.raises(ValueError, match="invalid_auth_arguments"): + await set_agent_credentials( + agent_url="http://localhost:8000", + bearer_token="token", + api_key="secret-key", + ) + + mock_set.assert_not_called() + + +@pytest.mark.asyncio +async def test_set_agent_credentials_serializes_with_keyring_metadata() -> None: + server = create_mcp_server() + set_agent_credentials = _tool_fn(server, "set_agent_credentials") + agent_url = "http://localhost:8000" + + with tempfile.TemporaryDirectory() as temp_directory: + store = SessionStore(session_directory=Path(temp_directory)) + + with ( + patch.object(session_module, "_global_session_store", store), + patch.object( + SessionStore, + "_store_credential_in_keyring", + return_value=True, + ), + ): + result = await set_agent_credentials( + agent_url=agent_url, + bearer_token="secret-token", + ) + + assert result == {"agent_url": agent_url, "auth_type": "bearer"} + + serialized = json.loads(store.session_file_path.read_text()) + credentials_data = serialized[agent_url]["credentials"] + assert credentials_data["storage"] == "keyring" + assert "value" not in credentials_data + + +@pytest.mark.asyncio +async def test_send_message_reports_configured_auth_source_failures() -> None: + server = create_mcp_server() + send_message = _tool_fn(server, "send_message") + + with patch( + "a2a_handler.mcp.server.resolve_auth_credentials", + side_effect=InputValidationError( + code="bearer_command_failed", + message="bearer_command failed with exit code 1", + ), + ): + with pytest.raises(ValueError, match="bearer_command_failed"): + await send_message(agent_url="http://localhost:8000", message="hello") diff --git a/tests/test_output.py b/tests/test_output.py index 7d96595..4d51f0b 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,5 +1,6 @@ """Tests for the Output class and related utilities.""" +import json from io import StringIO import pytest @@ -109,6 +110,21 @@ def test_line_with_style(self, output, captured_output): output.line("Styled text", style="green") assert captured_output == ["Styled text"] + def test_line_redacts_inline_secret_assignment(self, output, captured_output): + """Test line output masks inline password values.""" + output.line("password=my-secret-value") + assert captured_output == ["password=[REDACTED]"] + + def test_line_redacts_bearer_token_but_keeps_scheme(self, output, captured_output): + """Test line output masks bearer token while preserving context.""" + output.line("Authorization: Bearer my-secret-token") + assert captured_output == ["Authorization: Bearer [REDACTED]"] + + def test_line_redacts_generic_authorization_value(self, output, captured_output): + """Test line output masks non-bearer authorization values.""" + output.line("Authorization=ApiKeyValue") + assert captured_output == ["Authorization=[REDACTED]"] + def test_field_basic(self, output, captured_output): """Test basic field output.""" output.field("Name", "Value") @@ -121,6 +137,12 @@ def test_field_with_none_value(self, output, captured_output): output.field("Name", None) assert "none" in captured_output[0] + def test_field_masks_sensitive_name_value(self, output, captured_output): + """Test field output masks values for sensitive field names.""" + output.field("Bearer Token", "secret-token") + assert "[REDACTED]" in captured_output[0] + assert "secret-token" not in captured_output[0] + def test_header(self, output, captured_output): """Test header output.""" output.header("Section Title") @@ -309,6 +331,35 @@ def test_ndjson_mode_emits_single_line(self): assert captured == ['{"type": "error", "code": "x", "message": "bad"}'] + def test_json_mode_masks_sensitive_field_value(self): + output = Output(output_format="json") + captured: list[str] = [] + setattr(output, "_print", captured.append) + + output.field("API Key", "secret-key") + + assert len(captured) == 1 + payload = json.loads(captured[0]) + assert payload["type"] == "field" + assert payload["name"] == "API Key" + assert payload["value"] == "[REDACTED]" + + def test_json_mode_masks_error_details(self): + output = Output(output_format="json") + captured: list[str] = [] + setattr(output, "_print", captured.append) + + output.error_obj( + code="x", + message="bad", + details={"password": "swordfish", "nested": {"token": "abc123"}}, + ) + + assert len(captured) == 1 + payload = json.loads(captured[0]) + assert payload["details"]["password"] == "[REDACTED]" + assert payload["details"]["nested"]["token"] == "[REDACTED]" + def test_quiet_mode_suppresses_non_errors(self): output = Output(output_format="text", quiet=True) captured: list[str] = [] diff --git a/tests/test_session.py b/tests/test_session.py index 7319f66..b441983 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,9 +1,21 @@ """Tests for the session state management module.""" +import json +import stat +import sys import tempfile from pathlib import Path +from unittest.mock import patch -from a2a_handler.session import AgentSession, SessionStore +import pytest + +from a2a_handler.auth import create_bearer_auth +from a2a_handler.session import ( + CREDENTIAL_STORAGE_KEYRING, + CREDENTIAL_STORAGE_PLAINTEXT, + AgentSession, + SessionStore, +) class TestAgentSession: @@ -133,6 +145,67 @@ def test_clear_all_sessions(self): assert len(store.sessions) == 0 + def test_clear_preserves_credentials_for_agent(self): + """Clearing session state keeps saved credentials for that agent.""" + with tempfile.TemporaryDirectory() as temp_directory: + store = SessionStore(session_directory=Path(temp_directory)) + store.sessions["http://localhost:8000"] = AgentSession( + agent_url="http://localhost:8000", + context_id="ctx-1", + task_id="task-1", + credentials=create_bearer_auth("token-1"), + ) + + store.clear("http://localhost:8000") + + assert "http://localhost:8000" in store.sessions + session = store.sessions["http://localhost:8000"] + assert session.context_id is None + assert session.task_id is None + assert session.credentials is not None + + def test_clear_all_preserves_credentials(self): + """Clearing all session state retains entries with credentials.""" + with tempfile.TemporaryDirectory() as temp_directory: + store = SessionStore(session_directory=Path(temp_directory)) + store.sessions["http://localhost:8000"] = AgentSession( + agent_url="http://localhost:8000", + context_id="ctx-1", + task_id="task-1", + credentials=create_bearer_auth("token-1"), + ) + store.sessions["http://localhost:9000"] = AgentSession( + agent_url="http://localhost:9000", + context_id="ctx-2", + task_id="task-2", + ) + + store.clear() + + assert list(store.sessions.keys()) == ["http://localhost:8000"] + kept = store.sessions["http://localhost:8000"] + assert kept.context_id is None + assert kept.task_id is None + + def test_clear_does_not_delete_keyring_credentials(self): + """Session clearing does not touch keyring credential entries.""" + with tempfile.TemporaryDirectory() as temp_directory: + store = SessionStore(session_directory=Path(temp_directory)) + store.sessions["http://localhost:8000"] = AgentSession( + agent_url="http://localhost:8000", + context_id="ctx-1", + task_id="task-1", + credentials=create_bearer_auth("token-1"), + ) + + with patch.object( + SessionStore, + "_delete_credential_from_keyring", + ) as mock_delete: + store.clear("http://localhost:8000") + + mock_delete.assert_not_called() + def test_list_all_sessions(self): """Test listing all sessions.""" store = SessionStore() @@ -165,6 +238,142 @@ def test_save_and_load_sessions(self): assert loaded_session.context_id == "ctx-123" assert loaded_session.task_id == "task-456" + def test_save_and_load_sessions_with_keyring_credentials(self): + """Test credential values are loaded from keyring-backed storage.""" + agent_url = "http://localhost:8000" + + with tempfile.TemporaryDirectory() as temp_directory: + store = SessionStore(session_directory=Path(temp_directory)) + store.sessions[agent_url] = AgentSession( + agent_url=agent_url, + credentials=create_bearer_auth("secret-token"), + ) + + with ( + patch.object( + SessionStore, + "_store_credential_in_keyring", + return_value=True, + ), + patch.object( + SessionStore, + "_get_credential_from_keyring", + return_value="secret-token", + ), + ): + store.save() + + raw_data = json.loads(store.session_file_path.read_text()) + credentials_data = raw_data[agent_url]["credentials"] + assert credentials_data["storage"] == CREDENTIAL_STORAGE_KEYRING + assert "value" not in credentials_data + + new_store = SessionStore(session_directory=Path(temp_directory)) + new_store.load() + + loaded_credentials = new_store.get_credentials(agent_url) + assert loaded_credentials is not None + assert loaded_credentials.value == "secret-token" + + def test_load_legacy_credentials_prefers_keyring_secret(self): + """Legacy plaintext credentials should defer to available keyring values.""" + agent_url = "http://localhost:8000" + + with tempfile.TemporaryDirectory() as temp_directory: + store = SessionStore(session_directory=Path(temp_directory)) + store._ensure_directory_exists() + store.session_file_path.write_text( + json.dumps( + { + agent_url: { + "context_id": None, + "task_id": None, + "credentials": { + "auth_type": "bearer", + "value": "legacy-plaintext-token", + "header_name": None, + }, + } + } + ) + ) + + with patch.object( + SessionStore, + "_get_credential_from_keyring", + return_value="keyring-token", + ): + store.load() + + loaded_credentials = store.get_credentials(agent_url) + assert loaded_credentials is not None + assert loaded_credentials.value == "keyring-token" + + def test_save_falls_back_to_plaintext_credentials(self): + """Test session file stores plaintext when keyring is unavailable.""" + agent_url = "http://localhost:8000" + + with tempfile.TemporaryDirectory() as temp_directory: + store = SessionStore(session_directory=Path(temp_directory)) + store.sessions[agent_url] = AgentSession( + agent_url=agent_url, + credentials=create_bearer_auth("secret-token"), + ) + + with patch.object( + SessionStore, + "_store_credential_in_keyring", + return_value=False, + ): + store.save() + + raw_data = json.loads(store.session_file_path.read_text()) + credentials_data = raw_data[agent_url]["credentials"] + assert credentials_data["storage"] == CREDENTIAL_STORAGE_PLAINTEXT + assert credentials_data["value"] == "secret-token" + + def test_clear_credentials_deletes_keyring_entry(self): + """Test clearing credentials removes any keyring secret.""" + agent_url = "http://localhost:8000" + + with tempfile.TemporaryDirectory() as temp_directory: + store = SessionStore(session_directory=Path(temp_directory)) + store.sessions[agent_url] = AgentSession( + agent_url=agent_url, + credentials=create_bearer_auth("secret-token"), + ) + + with patch.object( + SessionStore, + "_delete_credential_from_keyring", + ) as mock_delete: + store.clear_credentials(agent_url) + + mock_delete.assert_called_once_with(agent_url) + + @pytest.mark.skipif( + sys.platform.startswith("win"), + reason="Permission mode checks are POSIX-specific", + ) + def test_save_hardens_session_storage_permissions(self): + """Test save enforces restrictive directory and file permissions.""" + with tempfile.TemporaryDirectory() as temp_directory: + session_directory = Path(temp_directory) / "handler-session" + session_directory.mkdir(mode=0o755) + + store = SessionStore(session_directory=session_directory) + store.sessions["http://localhost:8000"] = AgentSession( + agent_url="http://localhost:8000", + context_id="ctx-123", + ) + store.save() + + directory_mode = stat.S_IMODE(session_directory.stat().st_mode) + file_mode = stat.S_IMODE(store.session_file_path.stat().st_mode) + + assert directory_mode == 0o700 + assert file_mode == 0o600 + def test_load_nonexistent_file(self): """Test loading from nonexistent file does nothing.""" with tempfile.TemporaryDirectory() as temp_directory: diff --git a/tests/test_tui.py b/tests/test_tui.py index 372a403..17303b9 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -52,3 +52,34 @@ async def test_connect_to_agent_uses_credentials_for_card_request(): "https://agent.example.com", credentials=credentials, ) + + +@pytest.mark.asyncio +async def test_tui_uses_resolved_credentials_when_auth_panel_empty(): + """TUI resolves configured credentials when auth panel has no value.""" + app = HandlerTUI() + async with app.run_test() as _: + resolved_credentials = create_bearer_auth("resolved-token") + mock_card = AsyncMock(spec=AgentCard) + + with ( + patch("a2a_handler.tui.app.resolve_auth_credentials") as mock_resolve, + patch("a2a_handler.tui.app.A2AService") as mock_service_cls, + ): + mock_resolve.return_value = resolved_credentials + mock_service = AsyncMock() + mock_service.get_card.return_value = mock_card + mock_service_cls.return_value = mock_service + + card = await app._connect_to_agent( + "https://agent.example.com", + app._resolve_auth_for_agent("https://agent.example.com"), + ) + + assert card is mock_card + mock_resolve.assert_called_once_with("https://agent.example.com") + mock_service_cls.assert_called_once_with( + app.http_client, + "https://agent.example.com", + credentials=resolved_credentials, + ) diff --git a/uv.lock b/uv.lock index 99d7898..717f175 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ dependencies = [ { name = "click" }, { name = "google-adk" }, { name = "httpx" }, + { name = "keyring" }, { name = "litellm" }, { name = "mcp", extra = ["cli"] }, { name = "python-dotenv" }, @@ -43,6 +44,7 @@ requires-dist = [ { name = "click", specifier = ">=8.0.0" }, { name = "google-adk", specifier = ">=1.26.0" }, { name = "httpx", specifier = ">=0.27.0" }, + { name = "keyring", specifier = ">=25.6.0" }, { name = "litellm", specifier = ">=1.0.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.2.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, @@ -292,6 +294,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" }, ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -1268,7 +1279,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, @@ -1279,7 +1289,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, @@ -1290,7 +1299,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -1301,7 +1309,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, @@ -1538,6 +1545,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/7b/c3081ff1af947915503121c649f26a778e1a2101fd525f74aef997d75b7e/jaraco_context-6.1.1.tar.gz", hash = "sha256:bc046b2dc94f1e5532bd02402684414575cc11f565d929b6563125deb0a6e581", size = 15832, upload-time = "2026-03-07T15:46:04.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/49/c152890d49102b280ecf86ba5f80a8c111c3a155dafa3bd24aeb64fde9e1/jaraco_context-6.1.1-py3-none-any.whl", hash = "sha256:0df6a0287258f3e364072c3e40d5411b20cafa30cb28c4839d24319cecf9f808", size = 7005, upload-time = "2026-03-07T15:46:03.515Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1662,6 +1714,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -1852,6 +1922,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + [[package]] name = "msgpack" version = "1.1.2" @@ -2633,6 +2712,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2982,6 +3070,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "shellingham" version = "1.5.4"