Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/features/credentials.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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:**
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion docs/sdk/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
```

Expand Down Expand Up @@ -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
Expand All @@ -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_",
)
)

Expand Down Expand Up @@ -565,6 +570,8 @@ logging:
credentials:
enabled: false
file_path: config/credentials.yaml
env_keys: []
env_prefix: ""
```

---
Expand Down
8 changes: 6 additions & 2 deletions mobilerun/agent/tool_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions mobilerun/agent/utils/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
15 changes: 14 additions & 1 deletion mobilerun/config/credentials_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"}
Expand All @@ -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 ===
Expand Down
1 change: 1 addition & 0 deletions mobilerun/config/prompts/executor/system.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
Expand Down
1 change: 1 addition & 0 deletions mobilerun/config/prompts/fast_agent/system.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mobilerun/config/prompts/manager/system.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</available_secrets>
{% endif %}
{% if output_schema %}
Expand Down
4 changes: 4 additions & 0 deletions mobilerun/config_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions mobilerun/config_manager/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions mobilerun/credential_manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
81 changes: 74 additions & 7 deletions mobilerun/credential_manager/file_credential_manager.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import os
from typing import Any, Dict, Optional

import yaml
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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 = {}
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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}'")
Expand Down
Loading