Secrets Pipeline¶
Secrets flow from your host machine into the VM, through the gateway, and into sandbox containers without ever appearing in logs, process lists, or Ansible output. This page traces the full path.
End-to-End Flow¶
Host secrets file (~/.openclaw-secrets.env)
|
v (virtiofs mount, read-only)
/mnt/secrets/<filename> (inside VM)
|
v (Ansible: slurp + b64decode + regex_search)
Ansible variables (in memory, no_log: true)
|
v (Ansible: template, no_log: true)
/etc/openclaw/secrets.env (mode 0600, owned by user)
|
v (systemd EnvironmentFile=)
Gateway process environment (NOT in systemctl show output)
|
v (openclaw.json sandbox.docker.env passthrough)
Docker container environment (selective: GH_TOKEN only)
Three Secret Sources¶
The secrets role (ansible/roles/secrets/) supports three input methods, resolved in priority order:
1. Direct Injection (highest priority)¶
./bootstrap.sh --openclaw ../openclaw -e "secrets_anthropic_api_key=sk-ant-xxx"
Values are passed as Ansible extra vars (-e) and take precedence over everything else. Useful for CI/CD or one-off testing.
2. Mounted Secrets File (recommended for development)¶
./bootstrap.sh --openclaw ../openclaw --secrets ~/.openclaw-secrets.env
bootstrap.sh mounts the parent directory of the secrets file at /mnt/secrets (Lima requires directory mounts, not individual files). The Ansible role looks for the specific filename inside that directory.
3. Config Directory Mount (lowest priority)¶
./bootstrap.sh --openclaw ../openclaw --config ~/.openclaw
If neither direct injection nor a secrets file is provided, the role checks for a .env file inside the mounted config directory at /mnt/openclaw-config/.env.
Note
Priority resolution happens in the Ansible tasks, not in bootstrap.sh. The role checks has_direct_secrets first, then mounted_secrets_file.stat.exists, then mounted_config_dir.stat.exists.
The Regex Extraction Pattern¶
When reading from a mounted file (sources 2 or 3), the secrets role uses a slurp + b64decode + regex_search pipeline:
- name: Read mounted secrets file
ansible.builtin.slurp:
path: "{{ mounted_secrets_path }}"
register: mounted_secrets_content
no_log: true
- name: Extract secrets from mounted file
ansible.builtin.set_fact:
secrets_anthropic_api_key: >-
{{ (mounted_secrets_content.content | b64decode
| regex_search('ANTHROPIC_API_KEY=(.+)', '\\1'))
| default([''], true) | first }}
secrets_github_token: >-
{{ (mounted_secrets_content.content | b64decode
| regex_search('GH_TOKEN=(.+)', '\\1'))
| default([''], true) | first }}
# ... same pattern for all 10 supported secrets
no_log: true
Why this pattern:
slurpreads the file and returns base64-encoded content (Ansible's standard way to read files)b64decodeconverts it back to textregex_search('KEY=(.+)', '\\1')extracts the value after the=signdefault([''], true) | firsthandles missing keys gracefully (returns empty string instead of failing)
Every task in this chain has no_log: true to prevent secret values from appearing in Ansible output.
The secrets.env Template¶
Extracted secrets are rendered into /etc/openclaw/secrets.env via a Jinja2 template (ansible/roles/secrets/templates/secrets.env.j2):
# OpenClaw Secrets Environment File
# Generated by Ansible - do not edit manually
ANTHROPIC_API_KEY=sk-ant-xxx
GH_TOKEN=ghp_xxx
TELEGRAM_BOT_TOKEN=...
Only secrets with non-empty values are included (each key is wrapped in {% if secrets_xxx | length > 0 %}).
File Permissions¶
| Path | Mode | Owner | Purpose |
|---|---|---|---|
/etc/openclaw/ |
0755 |
root:root | Secrets directory |
/etc/openclaw/secrets.env |
0600 |
user:user | Secrets file |
The 0600 permission means only the owning user can read or write the file. Not even group members can access it.
Gateway: EnvironmentFile vs Environment¶
The gateway systemd service loads secrets using EnvironmentFile=, not Environment=:
[Service]
# Load secrets from file (- prefix = don't fail if file is missing)
EnvironmentFile=-/etc/openclaw/secrets.env
This distinction matters. Environment= values are embedded in the systemd unit file and visible via systemctl show. EnvironmentFile= reads values from a file at service start -- they are not embedded in the unit file and are not visible via systemctl show. Both methods make values available in /proc/<pid>/environ (readable by root), but EnvironmentFile= avoids exposing secrets in unit files and systemd metadata.
The - prefix before the path tells systemd to continue silently if the file does not exist (e.g., when no secrets are configured).
GH_TOKEN Container Passthrough¶
The sandbox role (ansible/roles/sandbox/tasks/main.yml) adds GH_TOKEN to the container environment via the combine() pattern on openclaw.json:
- name: Add GH_TOKEN env passthrough to sandbox config
when: gh_token_check.rc == 0
ansible.builtin.set_fact:
openclaw_config: >-
{{ openclaw_config | combine({
'agents': openclaw_config.agents | default({}) | combine({
'defaults': (openclaw_config.agents.defaults | default({})) | combine({
'sandbox': (openclaw_config.agents.defaults.sandbox | default({})) | combine({
'docker': (openclaw_config.agents.defaults.sandbox.docker | default({})) | combine({
'env': (openclaw_config.agents.defaults.sandbox.docker.env | default({})) | combine({
'GH_TOKEN': '${GH_TOKEN}'
})
})
})
})
})
}, recursive=true) }}
The key detail: the value is '${GH_TOKEN}' (a shell variable reference), not the actual token. When the gateway spawns a container, it expands ${GH_TOKEN} from its own environment (loaded via EnvironmentFile=) into the container's environment. The gh CLI natively respects the GH_TOKEN env var, so no gh auth login is needed.
Important
The passthrough only happens if GH_TOKEN exists in /etc/openclaw/secrets.env. The sandbox role checks with grep -c '^GH_TOKEN=' /etc/openclaw/secrets.env before adding the config entry.
Resulting openclaw.json¶
After the sandbox role runs, the relevant section of ~/.openclaw/openclaw.json looks like:
{
"agents": {
"defaults": {
"sandbox": {
"mode": "all",
"scope": "session",
"workspaceAccess": "rw",
"docker": {
"network": "bridge",
"env": {
"GH_TOKEN": "${GH_TOKEN}"
}
}
}
}
}
}
Supported Secrets¶
| Ansible Variable | Env Key | Usage |
|---|---|---|
secrets_anthropic_api_key |
ANTHROPIC_API_KEY |
Claude API access |
secrets_openai_api_key |
OPENAI_API_KEY |
OpenAI API access |
secrets_gemini_api_key |
GEMINI_API_KEY |
Google Gemini API access |
secrets_openrouter_api_key |
OPENROUTER_API_KEY |
OpenRouter API access |
secrets_gateway_password |
OPENCLAW_GATEWAY_PASSWORD |
Gateway authentication |
secrets_gateway_token |
OPENCLAW_GATEWAY_TOKEN |
Gateway token auth |
secrets_github_token |
GH_TOKEN |
GitHub CLI + container passthrough |
secrets_slack_bot_token |
SLACK_BOT_TOKEN |
Slack integration |
secrets_discord_bot_token |
DISCORD_BOT_TOKEN |
Discord integration |
secrets_telegram_bot_token |
TELEGRAM_BOT_TOKEN |
Telegram integration |
Security Summary¶
| Property | How |
|---|---|
| Never in Ansible output | no_log: true on every secret-handling task |
| Never in process list | EnvironmentFile= instead of Environment= |
| Restricted file access | 0600 permissions on secrets.env |
| Selective container exposure | Only GH_TOKEN is passed through; other secrets stay in the gateway |
| No host-side persistence | Secrets file is inside the VM at /etc/openclaw |
| Graceful degradation | Missing secrets produce empty strings, not failures |