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.

Secrets Pipeline

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.

./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:

  • slurp reads the file and returns base64-encoded content (Ansible's standard way to read files)
  • b64decode converts it back to text
  • regex_search('KEY=(.+)', '\\1') extracts the value after the = sign
  • default([''], true) | first handles 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