diff --git a/docs/features/credentials.mdx b/docs/features/credentials.mdx index 6e24ffbb..b854d158 100644 --- a/docs/features/credentials.mdx +++ b/docs/features/credentials.mdx @@ -6,9 +6,10 @@ description: 'Extend Mobilerun with secure credential management' ## Overview Secure storage for passwords, API keys, and tokens. -- Stored in YAML files or in-memory dicts +- Stored in YAML files, environment variables, or in-memory dicts - Never logged or exposed - Auto-injected as `type_secret` action +- Resolved in `{{SECRET_ID}}` placeholders at execution time - Simple string or dict format ## Quick Start @@ -56,6 +57,11 @@ secrets: value: "gmail_pass_123" enabled: true + # Environment variable reference + CI_PASSWORD: + env: "MOBILERUN_CI_PASSWORD" + enabled: true + # Simple string format (auto-enabled) API_KEY: "sk-1234567890abcdef" @@ -72,6 +78,8 @@ secrets: credentials: enabled: true file_path: config/credentials.yaml + env_keys: ["PASSWORD"] # optional: reads PASSWORD or MOBILERUN_PASSWORD + env_prefix: "MOBILERUN_" # optional ``` 3. **Use in code:** @@ -115,6 +123,21 @@ When credentials are provided, the `type_secret` action is **automatically avail The agent never sees the actual value - only the secret ID. +### Placeholder Mode + +Regular `type` and `type_text` actions can include credential placeholders: + +```json +{ + "action": "type", + "text": "{{EMAIL_USER}} / {{EMAIL_PASS}}", + "index": 5 +} +``` + +Known credential placeholders are resolved immediately before tool execution. +Tool events and action logs keep the original placeholder text instead of the secret value. + --- ## Example: Login Automation diff --git a/docs/sdk/configuration.mdx b/docs/sdk/configuration.mdx index 9849566c..5ddc0380 100644 --- a/docs/sdk/configuration.mdx +++ b/docs/sdk/configuration.mdx @@ -211,6 +211,8 @@ from mobilerun import CredentialsConfig CredentialsConfig( enabled=True, # Enable credential manager file_path="config/credentials.yaml", # Path to credentials file + env_keys=["PASSWORD"], # Optional environment variable names/secret IDs + env_prefix="MOBILERUN_", # Optional prefix for env-backed secrets ) ``` @@ -289,6 +291,7 @@ agent = MobileAgent( } ) # Agent can call type_secret("USERNAME", index) and type_secret("PASSWORD", index) +# Agent can also type "{{USERNAME}}" or "{{PASSWORD}}" in regular type/type_text actions. ``` ### Config Format @@ -298,7 +301,9 @@ from mobilerun import MobileConfig, CredentialsConfig config = MobileConfig( credentials=CredentialsConfig( enabled=True, - file_path="config/credentials.yaml" + file_path="config/credentials.yaml", + env_keys=["PASSWORD"], + env_prefix="MOBILERUN_", ) ) @@ -565,6 +570,8 @@ logging: credentials: enabled: false file_path: config/credentials.yaml + env_keys: [] + env_prefix: "" ``` --- diff --git a/mobilerun/agent/tool_registry.py b/mobilerun/agent/tool_registry.py index 72120022..b684dcae 100644 --- a/mobilerun/agent/tool_registry.py +++ b/mobilerun/agent/tool_registry.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Set from mobilerun.agent.action_result import ActionResult +from mobilerun.credential_manager.placeholders import resolve_credential_placeholders if TYPE_CHECKING: from llama_index.core.workflow import Context as WorkflowContext @@ -134,10 +135,13 @@ async def execute( entry = self.tools[name] try: + call_args = await resolve_credential_placeholders( + args, getattr(ctx, "credential_manager", None) + ) if inspect.iscoroutinefunction(entry.fn): - result = await entry.fn(**args, ctx=ctx) + result = await entry.fn(**call_args, ctx=ctx) else: - result = entry.fn(**args, ctx=ctx) + result = entry.fn(**call_args, ctx=ctx) except TypeError as e: result = ActionResult( success=False, diff --git a/mobilerun/agent/utils/actions.py b/mobilerun/agent/utils/actions.py index 35dd8350..f526491a 100644 --- a/mobilerun/agent/utils/actions.py +++ b/mobilerun/agent/utils/actions.py @@ -14,6 +14,7 @@ from mobilerun.agent.action_result import ActionResult from mobilerun.agent.oneflows.app_starter_workflow import AppStarter +from mobilerun.credential_manager.placeholders import resolve_credential_placeholders logger = logging.getLogger("mobilerun") @@ -155,6 +156,9 @@ async def type_text( ) -> ActionResult: """Type text into an indexed element or the currently focused input.""" try: + text = await resolve_credential_placeholders( + text, getattr(ctx, "credential_manager", None) + ) if index is not None and index != -1: x, y = ctx.ui.get_element_coords(index) await ctx.driver.tap(x, y) @@ -177,6 +181,9 @@ async def type_text_direct( ) -> ActionResult: """Type text into the currently focused input.""" try: + text = await resolve_credential_placeholders( + text, getattr(ctx, "credential_manager", None) + ) success = await ctx.driver.input_text(text, clear) if success: return ActionResult( diff --git a/mobilerun/config/credentials_example.yaml b/mobilerun/config/credentials_example.yaml index 318b9e9c..97d5d80f 100644 --- a/mobilerun/config/credentials_example.yaml +++ b/mobilerun/config/credentials_example.yaml @@ -24,6 +24,13 @@ secrets: value: "example_password" enabled: true + # === Environment Variable Format === + # Reads the secret value from the named environment variable at runtime + + ENV_PASSWORD: + env: "MOBILERUN_ENV_PASSWORD" + enabled: true + # === Simple String Format === # Automatically enabled when loaded @@ -43,11 +50,16 @@ secrets: # credentials: # enabled: true # file_path: config/credentials.yaml +# env_keys: ["PASSWORD"] # optional: reads PASSWORD or env_prefix + PASSWORD +# env_prefix: "MOBILERUN_" # optional: also discovers MOBILERUN_* as secrets # # 2. The agent can use the type_secret tool: # {"action": "type_secret", "secret_id": "MY_PASSWORD", "index": 5} # -# 3. Or pass credentials programmatically: +# 3. The agent can use placeholders in regular type/type_text actions: +# {"action": "type", "text": "{{MY_PASSWORD}}", "index": 5} +# +# 4. Or pass credentials programmatically: # agent = MobileAgent( # goal="login to app", # credentials={"PASSWORD": "secret123"} @@ -59,6 +71,7 @@ secrets: # - Error messages show secret IDs but never values # - Access is logged: "🔑 Accessing secret: 'MY_PASSWORD'" # - The type_secret tool types secrets without exposing them +# - Placeholder tool events keep "{{MY_PASSWORD}}" instead of the resolved value # - CredentialManager validates and sanitizes all inputs # === File Format Notes === diff --git a/mobilerun/config/prompts/executor/system.jinja2 b/mobilerun/config/prompts/executor/system.jinja2 index 51787a96..ef796d33 100644 --- a/mobilerun/config/prompts/executor/system.jinja2 +++ b/mobilerun/config/prompts/executor/system.jinja2 @@ -71,6 +71,7 @@ The following secret IDs are available for use with the type_secret action: {% endfor %} When the current subgoal requires typing a secret (password, API key, etc.), use `type_secret` with the appropriate secret_id instead of the regular `type` action. +You can also type ordinary text that contains secret placeholders like `{% raw %}{{MY_PASSWORD}}{% endraw %}`; placeholders are resolved only during tool execution and logs keep the placeholder, not the secret value. {% endif %} ### Latest Action History ### diff --git a/mobilerun/config/prompts/fast_agent/system.jinja2 b/mobilerun/config/prompts/fast_agent/system.jinja2 index 9f9bda1f..713a3d9d 100644 --- a/mobilerun/config/prompts/fast_agent/system.jinja2 +++ b/mobilerun/config/prompts/fast_agent/system.jinja2 @@ -54,6 +54,7 @@ The credential manager has the following secret IDs available for use with the ` {% endfor %} Use `type_secret` to type these secrets into input fields without exposing their values. +You may also use placeholders like `{% raw %}{{MY_PASSWORD}}{% endraw %}` in `type` or `type_text` parameters; placeholders are resolved only during tool execution and logs keep the placeholder, not the secret value. {% endif %} ## Response Format diff --git a/mobilerun/config/prompts/manager/system.jinja2 b/mobilerun/config/prompts/manager/system.jinja2 index 8a97d8e0..7e7b0412 100644 --- a/mobilerun/config/prompts/manager/system.jinja2 +++ b/mobilerun/config/prompts/manager/system.jinja2 @@ -69,6 +69,7 @@ The executor has access to the following secret IDs via the type_secret custom a {% endfor %} You can include these secret IDs in your plan when the task requires entering sensitive information (passwords, API keys, etc.). The executor will use `type_secret(secret_id, index)` to type them securely without exposing values. +For mixed text, you may plan placeholders like `{% raw %}{{MY_PASSWORD}}{% endraw %}` inside `type` text; placeholders are resolved only during tool execution and logs keep the placeholder, not the secret value. {% endif %} {% if output_schema %} diff --git a/mobilerun/config_example.yaml b/mobilerun/config_example.yaml index 0f963767..0244332e 100644 --- a/mobilerun/config_example.yaml +++ b/mobilerun/config_example.yaml @@ -187,6 +187,10 @@ credentials: enabled: false # Path to credentials file (see config/credentials_example.yaml for format) file_path: config/credentials.yaml + # Optional environment variables to expose as credentials. + # With env_prefix set, PASSWORD reads from MOBILERUN_PASSWORD. + env_keys: [] + env_prefix: "" # === MCP (Model Context Protocol) Settings === # Connect to MCP servers to extend agent capabilities with external tools diff --git a/mobilerun/config_manager/config_manager.py b/mobilerun/config_manager/config_manager.py index d2d29d41..009ba187 100644 --- a/mobilerun/config_manager/config_manager.py +++ b/mobilerun/config_manager/config_manager.py @@ -199,6 +199,8 @@ class CredentialsConfig: enabled: bool = False file_path: str = "config/credentials.yaml" + env_keys: List[str] = field(default_factory=list) + env_prefix: str = "" @dataclass diff --git a/mobilerun/credential_manager/__init__.py b/mobilerun/credential_manager/__init__.py index 2639616a..59319e39 100644 --- a/mobilerun/credential_manager/__init__.py +++ b/mobilerun/credential_manager/__init__.py @@ -5,9 +5,11 @@ CredentialNotFoundError, ) from mobilerun.credential_manager.file_credential_manager import FileCredentialManager +from mobilerun.credential_manager.placeholders import resolve_credential_placeholders __all__ = [ "CredentialManager", "CredentialNotFoundError", "FileCredentialManager", + "resolve_credential_placeholders", ] diff --git a/mobilerun/credential_manager/file_credential_manager.py b/mobilerun/credential_manager/file_credential_manager.py index 8bb5fda6..eb31d360 100644 --- a/mobilerun/credential_manager/file_credential_manager.py +++ b/mobilerun/credential_manager/file_credential_manager.py @@ -1,4 +1,5 @@ import logging +import os from typing import Any, Dict, Optional import yaml @@ -14,7 +15,7 @@ class FileCredentialManager(CredentialManager): """ - Credential manager that supports both dict and YAML file sources. + Credential manager that supports dict, YAML file, and environment sources. """ def __init__(self, credentials: Any): @@ -30,10 +31,10 @@ def __init__(self, credentials: Any): if self.path: logger.debug(f"✅ Loaded {len(self.secrets)} secrets from {self.path}") else: - logger.debug(f"✅ Loaded {len(self.secrets)} secrets from in-memory dict") + logger.debug(f"✅ Loaded {len(self.secrets)} secrets from configured sources") def _load(self, credentials: Any) -> Dict[str, str]: - """Load credentials from dict or file.""" + """Load credentials from dict, file, or environment config.""" from mobilerun.config_manager.config_manager import CredentialsConfig # Dict mode @@ -45,8 +46,7 @@ def _load(self, credentials: Any) -> Dict[str, str]: if not credentials.enabled: logger.debug("Credentials disabled in config") return {} - self.path = credentials.file_path - return self._load_from_file(credentials.file_path) + return self._load_from_config(credentials) # String mode (direct file path) if isinstance(credentials, str): @@ -56,6 +56,37 @@ def _load(self, credentials: Any) -> Dict[str, str]: logger.warning(f"Unknown credentials type: {type(credentials)}") return {} + def _load_from_config(self, credentials: Any) -> Dict[str, str]: + """Load credentials from a CredentialsConfig instance.""" + secrets: Dict[str, str] = {} + env_keys = credentials.env_keys or [] + if isinstance(env_keys, str): + env_keys = [env_keys] + env_prefix = credentials.env_prefix or "" + has_env_sources = bool(env_keys or env_prefix) + + if credentials.file_path: + self.path = credentials.file_path + try: + secrets.update(self._load_from_file(credentials.file_path)) + except FileNotFoundError: + self.path = None + if has_env_sources: + logger.debug( + f"Credentials file not found: {credentials.file_path}; " + "continuing with environment credential sources" + ) + else: + raise + + secrets.update( + self._load_from_env( + env_keys=env_keys, + env_prefix=env_prefix, + ) + ) + return secrets + def _load_from_dict(self, credentials_dict: dict) -> Dict[str, str]: """Load credentials from in-memory dict.""" secrets = {} @@ -78,12 +109,15 @@ def _load_from_file(self, file_path: str) -> Dict[str, str]: value: "secret123" enabled: true SIMPLE_KEY: "simple_value" # Auto-enabled + ENV_KEY: + env: "MY_ENV_VAR" + enabled: true Returns: Dict of enabled secrets {secret_id: secret_value} """ path = PathResolver.resolve(file_path, must_exist=True) - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) if not data or "secrets" not in data: @@ -94,7 +128,11 @@ def _load_from_file(self, file_path: str) -> Dict[str, str]: for secret_id, secret_data in data["secrets"].items(): if isinstance(secret_data, dict): enabled = secret_data.get("enabled", True) - value = secret_data.get("value", "") + env_var = secret_data.get("env") + if env_var: + value = os.environ.get(str(env_var), "") + else: + value = secret_data.get("value", "") else: enabled = True value = secret_data @@ -109,6 +147,35 @@ def _load_from_file(self, file_path: str) -> Dict[str, str]: return secrets + def _load_from_env(self, env_keys: list[str], env_prefix: str = "") -> Dict[str, str]: + """Load credentials from environment variables.""" + secrets: Dict[str, str] = {} + + if env_keys: + for secret_id in env_keys: + env_var = f"{env_prefix}{secret_id}" if env_prefix else secret_id + value = os.environ.get(env_var) + if value: + secrets[secret_id] = value + logger.debug(f"Loaded secret: {secret_id} from environment") + else: + logger.debug( + f"Skipped environment secret: {secret_id} (env={env_var}, has_value=False)" + ) + return secrets + + if env_prefix: + for env_var, value in os.environ.items(): + if not env_var.startswith(env_prefix) or not value: + continue + secret_id = env_var[len(env_prefix) :] + if not secret_id: + continue + secrets[secret_id] = value + logger.debug(f"Loaded secret: {secret_id} from environment prefix") + + return secrets + async def resolve_key(self, key: str) -> str: """Get secret value by key.""" logger.debug(f"🔑 Accessing secret: '{key}'") diff --git a/mobilerun/credential_manager/placeholders.py b/mobilerun/credential_manager/placeholders.py new file mode 100644 index 00000000..c520c0d6 --- /dev/null +++ b/mobilerun/credential_manager/placeholders.py @@ -0,0 +1,87 @@ +"""Credential placeholder resolution helpers.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mobilerun.credential_manager.credential_manager import CredentialManager + + +CREDENTIAL_PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_.:\-]*)\s*\}\}") + + +async def resolve_credential_placeholders( + value: Any, + credential_manager: "CredentialManager | None", +) -> Any: + """Resolve credential placeholders in a value without mutating the input. + + Strings may contain placeholders such as ``{{PASSWORD}}``. Known credential + IDs are replaced with their secret values at execution time. Unknown + placeholders are left untouched so non-secret templating keeps working. + """ + if credential_manager is None: + return value + + if isinstance(value, str): + return await _resolve_string(value, credential_manager) + + if isinstance(value, list): + return [ + await resolve_credential_placeholders(item, credential_manager) + for item in value + ] + + if isinstance(value, tuple): + return tuple( + [ + await resolve_credential_placeholders(item, credential_manager) + for item in value + ] + ) + + if isinstance(value, dict): + return { + key: item + if key == "secret_id" + else await resolve_credential_placeholders(item, credential_manager) + for key, item in value.items() + } + + return value + + +async def _resolve_string( + text: str, + credential_manager: "CredentialManager", +) -> str: + if "{{" not in text: + return text + + matches = list(CREDENTIAL_PLACEHOLDER_RE.finditer(text)) + if not matches: + return text + + try: + available_keys = set(await credential_manager.get_keys()) + except Exception: + return text + + parts: list[str] = [] + cursor = 0 + for match in matches: + parts.append(text[cursor : match.start()]) + secret_id = match.group(1) + if secret_id in available_keys: + try: + parts.append(await credential_manager.resolve_key(secret_id)) + except Exception: + parts.append(match.group(0)) + else: + parts.append(match.group(0)) + cursor = match.end() + + parts.append(text[cursor:]) + return "".join(parts) diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 00000000..3132655b --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,146 @@ +import asyncio +import os +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from mobilerun.agent.action_result import ActionResult +from mobilerun.agent.tool_registry import ToolRegistry +from mobilerun.config_manager.config_manager import CredentialsConfig +from mobilerun.credential_manager.file_credential_manager import FileCredentialManager +from mobilerun.credential_manager.placeholders import resolve_credential_placeholders + + +class FakeWorkflowContext: + def __init__(self): + self.events = [] + + def write_event_to_stream(self, event): + self.events.append(event) + + +class CredentialsTest(unittest.TestCase): + def test_credentials_config_loads_env_keys_and_overrides_file(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "credentials.yaml" + path.write_text( + "secrets:\n" + " PASSWORD:\n" + " value: file-secret\n" + " enabled: true\n", + encoding="utf-8", + ) + + with patch.dict(os.environ, {"MOBILERUN_PASSWORD": "env-secret"}): + manager = FileCredentialManager( + CredentialsConfig( + enabled=True, + file_path=str(path), + env_keys=["PASSWORD"], + env_prefix="MOBILERUN_", + ) + ) + + self.assertEqual(asyncio.run(manager.resolve_key("PASSWORD")), "env-secret") + + def test_yaml_env_reference_loads_secret_from_environment(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "credentials.yaml" + path.write_text( + "secrets:\n" + " API_TOKEN:\n" + " env: MOBILERUN_API_TOKEN\n" + " enabled: true\n", + encoding="utf-8", + ) + + with patch.dict(os.environ, {"MOBILERUN_API_TOKEN": "token-secret"}): + manager = FileCredentialManager(str(path)) + + self.assertEqual(asyncio.run(manager.resolve_key("API_TOKEN")), "token-secret") + + def test_credentials_config_can_load_env_prefix_without_explicit_keys(self): + with patch.dict(os.environ, {"MOBILERUN_USERNAME": "alice"}, clear=False): + manager = FileCredentialManager( + CredentialsConfig( + enabled=True, + file_path="", + env_prefix="MOBILERUN_", + ) + ) + + self.assertEqual(asyncio.run(manager.resolve_key("USERNAME")), "alice") + + def test_credentials_config_accepts_single_env_key_string(self): + with patch.dict(os.environ, {"MOBILERUN_PASSWORD": "secret"}): + config = CredentialsConfig( + enabled=True, + file_path="", + env_prefix="MOBILERUN_", + ) + config.env_keys = "PASSWORD" + manager = FileCredentialManager(config) + + self.assertEqual(asyncio.run(manager.resolve_key("PASSWORD")), "secret") + + def test_placeholder_resolver_replaces_known_secrets_only(self): + async def run(): + manager = FileCredentialManager( + {"USERNAME": "alice@example.com", "PASSWORD": "secret"} + ) + return await resolve_credential_placeholders( + { + "text": "login {{ USERNAME }} with {{PASSWORD}}", + "secret_id": "{{PASSWORD}}", + "unknown": "{{MISSING}}", + "items": ["{{PASSWORD}}"], + }, + manager, + ) + + result = asyncio.run(run()) + + self.assertEqual(result["text"], "login alice@example.com with secret") + self.assertEqual(result["secret_id"], "{{PASSWORD}}") + self.assertEqual(result["unknown"], "{{MISSING}}") + self.assertEqual(result["items"], ["secret"]) + + def test_tool_registry_resolves_placeholders_without_emitting_secret_values(self): + async def run(): + seen = {} + + async def capture(text, *, ctx): + seen["text"] = text + return ActionResult(success=True, summary="captured") + + registry = ToolRegistry() + registry.register( + "capture", + fn=capture, + params={"text": {"type": "string", "required": True}}, + description="Capture text", + ) + ctx = SimpleNamespace( + credential_manager=FileCredentialManager({"PASSWORD": "secret"}) + ) + workflow_ctx = FakeWorkflowContext() + result = await registry.execute( + "capture", + {"text": "{{PASSWORD}}"}, + ctx=ctx, + workflow_ctx=workflow_ctx, + ) + return result, seen, workflow_ctx.events + + result, seen, events = asyncio.run(run()) + + self.assertTrue(result.success) + self.assertEqual(seen["text"], "secret") + self.assertEqual(events[0].tool_args, {"text": "{{PASSWORD}}"}) + self.assertNotIn("secret", str(events[0].tool_args)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_type_action.py b/tests/test_type_action.py index f1956baf..3368bc7e 100644 --- a/tests/test_type_action.py +++ b/tests/test_type_action.py @@ -4,6 +4,7 @@ from mobilerun.agent.utils.actions import type_text from mobilerun.agent.utils.signatures import build_tool_registry +from mobilerun.credential_manager.file_credential_manager import FileCredentialManager class FakeDriver: @@ -65,6 +66,21 @@ def test_minus_one_index_keeps_backward_compatible_direct_typing(self): self.assertEqual(driver.taps, []) self.assertEqual(ui.requested_indices, []) + def test_type_text_resolves_credential_placeholders_without_leaking_summary(self): + driver = FakeDriver() + ui = FakeUI() + ctx = SimpleNamespace( + driver=driver, + ui=ui, + credential_manager=FileCredentialManager({"PASSWORD": "secret"}), + ) + + result = asyncio.run(type_text("{{PASSWORD}}", clear=True, ctx=ctx)) + + self.assertTrue(result.success) + self.assertEqual(driver.inputs, [("secret", True)]) + self.assertNotIn("secret", result.summary) + def test_type_schema_makes_index_optional_and_explains_focused_input(self): async def run(): registry, _ = await build_tool_registry(supported_buttons={"enter"})