The Docker layer is responsible for producing buildable and runnable artifacts for OpenClaw environments.

openenv.docker

openenv.docker

Docker image and compose generation helpers.

openenv.docker.dockerfile

openenv.docker.dockerfile

Dockerfile generation for OpenClawenv.

render_dockerfile(manifest, lockfile, *, raw_manifest_text, raw_lock_text)

Render a standalone Dockerfile from a manifest and lockfile.

Source code in src/openenv/docker/dockerfile.py
def render_dockerfile(
    manifest: Manifest,
    lockfile: Lockfile,
    *,
    raw_manifest_text: str,
    raw_lock_text: str,
) -> str:
    """Render a standalone Dockerfile from a manifest and lockfile."""
    sandbox_reference = lockfile.base_image["resolved_reference"]
    payload = _render_payload(
        manifest,
        raw_manifest_text=raw_manifest_text,
        raw_lock_text=raw_lock_text,
        image_reference="${OPENCLAW_IMAGE}",
    )
    payload_b64 = encode_payload(payload)
    lines: list[str] = [
        "# syntax=docker/dockerfile:1",
        f"FROM {OPENCLAW_GATEWAY_RUNTIME_IMAGE}",
        f'LABEL org.opencontainers.image.title="{_escape_label(manifest.project.name)}"',
        f'LABEL org.opencontainers.image.version="{_escape_label(manifest.project.version)}"',
        f'LABEL io.openclawenv.manifest-hash="{lockfile.manifest_hash}"',
        f'LABEL io.openclawenv.sandbox-image="{_escape_label(sandbox_reference)}"',
        "ENV PYTHONDONTWRITEBYTECODE=1",
        "USER root",
    ]
    for key, value in sorted(manifest.runtime.env.items()):
        lines.append(f"ENV {key}={json.dumps(value)}")
    lines.extend(_package_install_lines(manifest.runtime.system_packages))
    lines.extend(_python_binary_link_lines())
    lines.extend(_python_venv_lines())
    lines.append(
        "RUN if ! command -v npx >/dev/null 2>&1; then "
        "printf '%s\\n' '#!/bin/sh' 'exec npm exec --yes -- \"$@\"' "
        "> /usr/local/bin/npx && chmod +x /usr/local/bin/npx; fi"
    )
    requirements = [
        SKILL_SCANNER_REQUIREMENT,
        *(_python_package_argument(package) for package in lockfile.python_packages),
    ]
    lines.append(
        "RUN python -m pip install --no-cache-dir " + " ".join(requirements)
    )
    node_requirements = _global_node_requirements(lockfile)
    if node_requirements:
        lines.append(
            "RUN npm install --global --no-fund --no-update-notifier "
            + " ".join(node_requirements)
        )
        lines.append("RUN agent-browser install")
    lines.extend(_optional_browser_install_lines())
    lines.extend(_optional_docker_cli_install_lines())
    lines.extend(_state_link_lines(manifest))
    lines.append("RUN mkdir -p /opt/openclawenv")
    lines.append(
        'RUN ["python", "-c", '
        f'{json.dumps(_payload_writer_script(payload_b64))}'
        "]"
    )
    lines.extend(_catalog_skill_install_lines(manifest))
    lines.extend(_skill_scan_lines(manifest))
    lines.extend(_runtime_permission_lines(manifest))
    lines.append(f"USER {_effective_runtime_user(manifest)}")
    return "\n".join(lines) + "\n"

render_runtime_payload(manifest, lockfile, *, raw_manifest_text, raw_lock_text)

Return the file payload embedded into the runtime image.

Source code in src/openenv/docker/dockerfile.py
def render_runtime_payload(
    manifest: Manifest,
    lockfile: Lockfile,
    *,
    raw_manifest_text: str,
    raw_lock_text: str,
) -> dict[str, object]:
    """Return the file payload embedded into the runtime image."""
    return _render_payload(
        manifest,
        raw_manifest_text=raw_manifest_text,
        raw_lock_text=raw_lock_text,
        image_reference="${OPENCLAW_IMAGE}",
    )

openenv.docker.compose

openenv.docker.compose

docker-compose generation for OpenClawenv.

AllBotsComposeSpec dataclass

Information needed to render one bot entry inside the shared compose stack.

Source code in src/openenv/docker/compose.py
@dataclass(slots=True, frozen=True)
class AllBotsComposeSpec:
    """Information needed to render one bot entry inside the shared compose stack."""

    slug: str
    manifest: Manifest
    image_tag: str

all_bots_compose_filename()

Return the shared compose filename for all managed bots.

Source code in src/openenv/docker/compose.py
def all_bots_compose_filename() -> str:
    """Return the shared compose filename for all managed bots."""
    return ALL_BOTS_COMPOSE_FILENAME

all_bots_env_filename()

Return the shared env filename used by the all-bots stack.

Source code in src/openenv/docker/compose.py
def all_bots_env_filename() -> str:
    """Return the shared env filename used by the all-bots stack."""
    return ALL_BOTS_ENV_FILENAME

cli_container_name(agent_name)

Return the CLI container name for a bot.

Source code in src/openenv/docker/compose.py
def cli_container_name(agent_name: str) -> str:
    """Return the CLI container name for a bot."""
    return f"{slugify_name(agent_name)}-openclaw-cli"

default_compose_filename(agent_name)

Return the default compose filename for a bot.

Source code in src/openenv/docker/compose.py
def default_compose_filename(agent_name: str) -> str:
    """Return the default compose filename for a bot."""
    return f"docker-compose-{slugify_name(agent_name)}.yml"

default_env_filename(agent_name)

Return the default env filename for a bot.

Source code in src/openenv/docker/compose.py
def default_env_filename(agent_name: str) -> str:
    """Return the default env filename for a bot."""
    return f".{slugify_name(agent_name)}.env"

gateway_container_name(agent_name)

Return the gateway container name for a bot.

Source code in src/openenv/docker/compose.py
def gateway_container_name(agent_name: str) -> str:
    """Return the gateway container name for a bot."""
    return f"{slugify_name(agent_name)}-openclaw-gateway"

generate_gateway_token()

Return a random gateway token for generated local stacks.

Source code in src/openenv/docker/compose.py
def generate_gateway_token() -> str:
    """Return a random gateway token for generated local stacks."""
    return secrets.token_urlsafe(24)

materialize_runtime_mount_tree(root, manifest, lockfile, *, raw_manifest_text, raw_lock_text)

Write the host-side runtime files expected by the generated bind mounts.

Source code in src/openenv/docker/compose.py
def materialize_runtime_mount_tree(
    root: str | Path,
    manifest: Manifest,
    lockfile: Lockfile,
    *,
    raw_manifest_text: str,
    raw_lock_text: str,
) -> None:
    """Write the host-side runtime files expected by the generated bind mounts."""
    root_path = Path(root).resolve()
    state_root = root_path / DEFAULT_OPENCLAW_CONFIG_DIR.removeprefix("./")
    workspace_root = root_path / DEFAULT_OPENCLAW_WORKSPACE_DIR.removeprefix("./")
    payload = render_runtime_payload(
        manifest,
        lockfile,
        raw_manifest_text=raw_manifest_text,
        raw_lock_text=raw_lock_text,
    )
    directories = payload["directories"]
    files = payload["files"]
    if not isinstance(directories, list) or not isinstance(files, dict):
        raise TypeError("Runtime payload shape is invalid.")
    for directory in directories:
        host_path = _host_mount_path_for_container_path(
            str(directory),
            manifest,
            state_root=state_root,
            workspace_root=workspace_root,
        )
        if host_path is not None:
            host_path.mkdir(parents=True, exist_ok=True)
    agent_dir = _host_mount_path_for_container_path(
        manifest.openclaw.agent_dir(),
        manifest,
        state_root=state_root,
        workspace_root=workspace_root,
    )
    if agent_dir is not None:
        agent_dir.mkdir(parents=True, exist_ok=True)
    for container_path, content in sorted(files.items()):
        host_path = _host_mount_path_for_container_path(
            str(container_path),
            manifest,
            state_root=state_root,
            workspace_root=workspace_root,
        )
        if host_path is None or not isinstance(content, str):
            continue
        host_path.parent.mkdir(parents=True, exist_ok=True)
        if _should_preserve_existing_catalog_skill_stub(
            host_path,
            container_path=str(container_path),
            placeholder_paths=_catalog_skill_placeholder_paths(manifest),
        ):
            continue
        host_path.write_text(content, encoding="utf-8")

prepare_runtime_env_values(existing_values=None)

Fill runtime env values that should exist before writing generated env files.

Source code in src/openenv/docker/compose.py
def prepare_runtime_env_values(existing_values: dict[str, str] | None = None) -> dict[str, str]:
    """Fill runtime env values that should exist before writing generated env files."""
    values = dict(existing_values or {})
    if not values.get("OPENCLAW_GATEWAY_TOKEN", "").strip():
        values["OPENCLAW_GATEWAY_TOKEN"] = generate_gateway_token()
    return values

render_all_bots_compose(specs)

Render a shared stack with one gateway and CLI services for all bots.

Source code in src/openenv/docker/compose.py
def render_all_bots_compose(specs: Sequence[AllBotsComposeSpec]) -> str:
    """Render a shared stack with one gateway and CLI services for all bots."""
    if not specs:
        raise ValueError("At least one bot is required to render the shared compose stack.")
    shared_env_file = f"./{all_bots_env_filename()}"
    shared_runtime_mount = f"{ALL_BOTS_GATEWAY_ROOT_DIR}:{ALL_BOTS_GATEWAY_CONTAINER_ROOT}"
    shared_runtime_user = _shared_runtime_user(specs)
    gateway_command = _gateway_startup_command(
        [
            (
                str(PurePosixPath(ALL_BOTS_GATEWAY_CONTAINER_ROOT) / "workspace" / spec.slug),
                spec.manifest,
            )
            for spec in specs
        ]
    )
    lines = [
        "services:",
        f"  {ALL_BOTS_GATEWAY_SERVICE}:",
        f'    image: {_quoted(f"${{OPENCLAW_GATEWAY_IMAGE:-{DEFAULT_OPENCLAW_GATEWAY_IMAGE}}}")}',
        f"    container_name: {_quoted(ALL_BOTS_GATEWAY_CONTAINER)}",
        f"    user: {_quoted(shared_runtime_user)}",
        "    env_file:",
        f"      - {_quoted(shared_env_file)}",
        "    environment:",
    ]
    lines.extend(_render_environment(_shared_gateway_environment()))
    lines.extend(
        [
            "    cap_drop:",
            "      - ALL",
        ]
    )
    lines.extend(_runtime_capability_lines(shared_runtime_user))
    lines.extend(
        [
            "    security_opt:",
            "      - no-new-privileges:true",
            "    read_only: true",
            "    tmpfs:",
            f'      - "${{OPENCLAW_TMPFS:-{DEFAULT_OPENCLAW_TMPFS}}}"',
            f'    pids_limit: "${{OPENCLAW_PIDS_LIMIT:-{DEFAULT_OPENCLAW_PIDS_LIMIT}}}"',
            "    ulimits:",
            "      nofile:",
            f'        soft: "${{OPENCLAW_NOFILE_SOFT:-{DEFAULT_OPENCLAW_NOFILE_SOFT}}}"',
            f'        hard: "${{OPENCLAW_NOFILE_HARD:-{DEFAULT_OPENCLAW_NOFILE_HARD}}}"',
            f'      nproc: "${{OPENCLAW_NPROC:-{DEFAULT_OPENCLAW_NPROC}}}"',
            "    volumes:",
            f"      - {_quoted(shared_runtime_mount)}",
            "    ports:",
            (
                f'      - "${{OPENCLAW_GATEWAY_HOST_BIND:-{DEFAULT_OPENCLAW_GATEWAY_HOST_BIND}}}'
                f':${{OPENCLAW_GATEWAY_PORT:-{DEFAULT_OPENCLAW_GATEWAY_PORT}}}:18789"'
            ),
            (
                f'      - "${{OPENCLAW_BRIDGE_HOST_BIND:-{DEFAULT_OPENCLAW_BRIDGE_HOST_BIND}}}'
                f':${{OPENCLAW_BRIDGE_PORT:-{DEFAULT_OPENCLAW_BRIDGE_PORT}}}:18790"'
            ),
            "    init: true",
            "    restart: unless-stopped",
            "    command:",
            "      [",
            '        "sh",',
            '        "-lc",',
            f"        {_quoted(gateway_command)},",
            "      ]",
            "    healthcheck:",
            "      test:",
            "        [",
            '          "CMD",',
            '          "node",',
            '          "-e",',
            '          "fetch(\'http://127.0.0.1:18789/healthz\').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",',
            "        ]",
            "      interval: 30s",
            "      timeout: 5s",
            "      retries: 5",
            "      start_period: 20s",
        ]
    )
    for spec in specs:
        service_name = _all_bots_cli_service_name(spec.slug)
        container_name = _all_bots_cli_container_name(spec.slug)
        env_file = f"./{spec.slug}/{default_env_filename(spec.manifest.openclaw.agent_name)}"
        cli_env = dict(_base_service_environment(spec.manifest))
        cli_env["HOME"] = ALL_BOTS_GATEWAY_HOME
        cli_env["OPENCLAW_CONFIG_PATH"] = ALL_BOTS_GATEWAY_CONFIG_PATH
        cli_env["OPENCLAW_STATE_DIR"] = ALL_BOTS_GATEWAY_STATE_DIR
        cli_env["BROWSER"] = "echo"
        lines.extend(
            [
                "",
                f"  {service_name}:",
                f"    image: {_quoted(spec.image_tag)}",
                "    build:",
                f'      context: {_quoted(f"./{spec.slug}")}',
                f"      dockerfile: {_quoted(DEFAULT_DOCKERFILE_NAME)}",
                "      args:",
                '        OPENCLAW_INSTALL_BROWSER: "${OPENCLAW_INSTALL_BROWSER:-}"',
                '        OPENCLAW_INSTALL_DOCKER_CLI: "${OPENCLAW_INSTALL_DOCKER_CLI:-}"',
                f"    container_name: {_quoted(container_name)}",
                f"    user: {_quoted(shared_runtime_user)}",
                '    network_mode: "service:openclaw-gateway"',
                "    cap_drop:",
                "      - ALL",
            ]
        )
        lines.extend(_runtime_capability_lines(shared_runtime_user))
        lines.extend(
            [
                "    security_opt:",
                "      - no-new-privileges:true",
                "    read_only: true",
                "    tmpfs:",
                f'      - "${{OPENCLAW_TMPFS:-{DEFAULT_OPENCLAW_TMPFS}}}"',
                f'    pids_limit: "${{OPENCLAW_PIDS_LIMIT:-{DEFAULT_OPENCLAW_PIDS_LIMIT}}}"',
                "    ulimits:",
                "      nofile:",
                f'        soft: "${{OPENCLAW_NOFILE_SOFT:-{DEFAULT_OPENCLAW_NOFILE_SOFT}}}"',
                f'        hard: "${{OPENCLAW_NOFILE_HARD:-{DEFAULT_OPENCLAW_NOFILE_HARD}}}"',
                f'      nproc: "${{OPENCLAW_NPROC:-{DEFAULT_OPENCLAW_NPROC}}}"',
                "    env_file:",
                f"      - {_quoted(env_file)}",
                f"      - {_quoted(shared_env_file)}",
                "    environment:",
            ]
        )
        lines.extend(_render_environment(cli_env))
        lines.extend(
            [
                "    volumes:",
                f"      - {_quoted(shared_runtime_mount)}",
                "    stdin_open: true",
                "    tty: true",
                "    init: true",
                f"    entrypoint: [{', '.join(_quoted(part) for part in OPENCLAW_HELPER_ENTRYPOINT)}]",
                "    depends_on:",
                f"      - {ALL_BOTS_GATEWAY_SERVICE}",
            ]
        )
    return "\n".join(lines) + "\n"

render_all_bots_env_file(*, existing_values=None)

Render the shared env file consumed by the all-bots gateway and CLI helpers.

Source code in src/openenv/docker/compose.py
def render_all_bots_env_file(*, existing_values: dict[str, str] | None = None) -> str:
    """Render the shared env file consumed by the all-bots gateway and CLI helpers."""
    values = dict(existing_values or {})
    used_keys: set[str] = set()
    lines = [
        "# Shared OpenClaw runtime secrets for the all-bots gateway",
        (
            "# Use with: docker compose -f "
            f"{all_bots_compose_filename()} up -d"
        ),
        "",
    ]
    for key, default in DEFAULT_OPENCLAW_ENV_DEFAULTS:
        lines.append(f"{key}={values.get(key, default)}")
        used_keys.add(key)
    extra_keys = sorted(key for key in values if key not in used_keys)
    if extra_keys:
        lines.append("")
        lines.append("# Preserved custom values")
        for key in extra_keys:
            lines.append(f"{key}={values[key]}")
    return "\n".join(lines).rstrip() + "\n"

render_compose(manifest, image_tag)

Render an OpenClaw-style docker-compose file for the bot image.

Source code in src/openenv/docker/compose.py
def render_compose(manifest: Manifest, image_tag: str) -> str:
    """Render an OpenClaw-style docker-compose file for the bot image."""
    env_file = default_env_filename(manifest.openclaw.agent_name)
    gateway_name = gateway_container_name(manifest.openclaw.agent_name)
    cli_name = cli_container_name(manifest.openclaw.agent_name)
    image_ref = f"${{OPENCLAW_IMAGE:-{image_tag}}}"
    gateway_command = _gateway_startup_command(
        [(manifest.openclaw.workspace, manifest)]
    )
    config_mount = (
        f"${{OPENCLAW_CONFIG_DIR:-{DEFAULT_OPENCLAW_CONFIG_DIR}}}:{manifest.openclaw.state_dir}"
    )
    workspace_mount = (
        f"${{OPENCLAW_WORKSPACE_DIR:-{DEFAULT_OPENCLAW_WORKSPACE_DIR}}}:"
        f"{manifest.openclaw.workspace}"
    )
    gateway_env = _base_service_environment(manifest)
    cli_env = dict(gateway_env)
    cli_env["BROWSER"] = "echo"

    lines = [
        "services:",
        f"  {OPENCLAW_GATEWAY_SERVICE}:",
        f"    image: {_quoted(image_ref)}",
        "    build:",
        f"      context: {_quoted(DEFAULT_BUILD_CONTEXT)}",
        f"      dockerfile: {_quoted(DEFAULT_DOCKERFILE_NAME)}",
        "      args:",
        '        OPENCLAW_INSTALL_BROWSER: "${OPENCLAW_INSTALL_BROWSER:-}"',
        '        OPENCLAW_INSTALL_DOCKER_CLI: "${OPENCLAW_INSTALL_DOCKER_CLI:-}"',
        f"    container_name: {_quoted(gateway_name)}",
        f"    user: {_quoted(_effective_runtime_user(manifest))}",
        "    env_file:",
        f"      - {_quoted(env_file)}",
        "    environment:",
    ]
    lines.extend(_render_environment(gateway_env))
    lines.extend(
        [
            "    cap_drop:",
            "      - ALL",
        ]
    )
    lines.extend(_runtime_capability_lines(_effective_runtime_user(manifest)))
    lines.extend(
        [
            "    security_opt:",
            "      - no-new-privileges:true",
            "    read_only: true",
            "    tmpfs:",
            f'      - "${{OPENCLAW_TMPFS:-{DEFAULT_OPENCLAW_TMPFS}}}"',
            f'    pids_limit: "${{OPENCLAW_PIDS_LIMIT:-{DEFAULT_OPENCLAW_PIDS_LIMIT}}}"',
            "    ulimits:",
            "      nofile:",
            f'        soft: "${{OPENCLAW_NOFILE_SOFT:-{DEFAULT_OPENCLAW_NOFILE_SOFT}}}"',
            f'        hard: "${{OPENCLAW_NOFILE_HARD:-{DEFAULT_OPENCLAW_NOFILE_HARD}}}"',
            f'      nproc: "${{OPENCLAW_NPROC:-{DEFAULT_OPENCLAW_NPROC}}}"',
            "    volumes:",
            f"      - {_quoted(config_mount)}",
            f"      - {_quoted(workspace_mount)}",
            "      ## Uncomment the lines below to enable sandbox isolation",
            "      ## (agents.defaults.sandbox). Requires Docker CLI in the image",
            "      ## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use",
            "      ## scripts/docker/setup.sh with OPENCLAW_SANDBOX=1 for automated setup.",
            "      ## WARNING: mounting /var/run/docker.sock grants host root-equivalent access.",
            "      ## Set DOCKER_GID to the host docker group GID before enabling it.",
            '      # - "/var/run/docker.sock:/var/run/docker.sock"',
            "    # group_add:",
            '    #   - "${DOCKER_GID:-999}"',
            "    ports:",
            (
                f'      - "${{OPENCLAW_GATEWAY_HOST_BIND:-{DEFAULT_OPENCLAW_GATEWAY_HOST_BIND}}}'
                f':${{OPENCLAW_GATEWAY_PORT:-{DEFAULT_OPENCLAW_GATEWAY_PORT}}}:18789"'
            ),
            (
                f'      - "${{OPENCLAW_BRIDGE_HOST_BIND:-{DEFAULT_OPENCLAW_BRIDGE_HOST_BIND}}}'
                f':${{OPENCLAW_BRIDGE_PORT:-{DEFAULT_OPENCLAW_BRIDGE_PORT}}}:18790"'
            ),
            "    init: true",
            "    restart: unless-stopped",
            "    command:",
            "      [",
            '        "sh",',
            '        "-lc",',
            f"        {_quoted(gateway_command)},",
            "      ]",
            "    healthcheck:",
            "      test:",
            "        [",
            '          "CMD",',
            '          "node",',
            '          "-e",',
            '          "fetch(\'http://127.0.0.1:18789/healthz\').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",',
            "        ]",
            "      interval: 30s",
            "      timeout: 5s",
            "      retries: 5",
            "      start_period: 20s",
            "",
            f"  {OPENCLAW_CLI_SERVICE}:",
            f"    image: {_quoted(image_ref)}",
            f"    container_name: {_quoted(cli_name)}",
            f"    user: {_quoted(_effective_runtime_user(manifest))}",
            '    network_mode: "service:openclaw-gateway"',
            "    cap_drop:",
            "      - ALL",
        ]
    )
    lines.extend(_runtime_capability_lines(_effective_runtime_user(manifest)))
    lines.extend(
        [
            "    security_opt:",
            "      - no-new-privileges:true",
            "    read_only: true",
            "    tmpfs:",
            f'      - "${{OPENCLAW_TMPFS:-{DEFAULT_OPENCLAW_TMPFS}}}"',
            f'    pids_limit: "${{OPENCLAW_PIDS_LIMIT:-{DEFAULT_OPENCLAW_PIDS_LIMIT}}}"',
            "    ulimits:",
            "      nofile:",
            f'        soft: "${{OPENCLAW_NOFILE_SOFT:-{DEFAULT_OPENCLAW_NOFILE_SOFT}}}"',
            f'        hard: "${{OPENCLAW_NOFILE_HARD:-{DEFAULT_OPENCLAW_NOFILE_HARD}}}"',
            f'      nproc: "${{OPENCLAW_NPROC:-{DEFAULT_OPENCLAW_NPROC}}}"',
            "    env_file:",
            f"      - {_quoted(env_file)}",
            "    environment:",
        ]
    )
    lines.extend(_render_environment(cli_env))
    lines.extend(
        [
            "    volumes:",
            f"      - {_quoted(config_mount)}",
            f"      - {_quoted(workspace_mount)}",
            "    stdin_open: true",
            "    tty: true",
            "    init: true",
            f"    entrypoint: [{', '.join(_quoted(part) for part in OPENCLAW_HELPER_ENTRYPOINT)}]",
            "    depends_on:",
            f"      - {OPENCLAW_GATEWAY_SERVICE}",
        ]
    )
    return "\n".join(lines) + "\n"

render_env_file(manifest, image_tag, *, existing_values=None)

Render the bot-specific env file with OpenClaw defaults and secrets.

Source code in src/openenv/docker/compose.py
def render_env_file(
    manifest: Manifest,
    image_tag: str,
    *,
    existing_values: dict[str, str] | None = None,
) -> str:
    """Render the bot-specific env file with OpenClaw defaults and secrets."""
    values = dict(existing_values or {})
    secret_names = [secret.name for secret in manifest.runtime.secret_refs]
    used_keys: set[str] = set()
    lines = [
        f"# OpenClaw runtime and secrets for {manifest.openclaw.agent_name}",
        (
            "# Use with: docker compose --env-file "
            f"{default_env_filename(manifest.openclaw.agent_name)} -f "
            f"{default_compose_filename(manifest.openclaw.agent_name)} up -d"
        ),
        "",
        "# OpenClaw runtime overrides",
        "# docker compose builds and tags this image from the adjacent Dockerfile",
    ]
    runtime_defaults = {
        "OPENCLAW_IMAGE": image_tag,
        "OPENCLAW_CONFIG_DIR": DEFAULT_OPENCLAW_CONFIG_DIR,
        "OPENCLAW_WORKSPACE_DIR": DEFAULT_OPENCLAW_WORKSPACE_DIR,
        "OPENCLAW_GATEWAY_HOST_BIND": DEFAULT_OPENCLAW_GATEWAY_HOST_BIND,
        "OPENCLAW_BRIDGE_HOST_BIND": DEFAULT_OPENCLAW_BRIDGE_HOST_BIND,
        "OPENCLAW_GATEWAY_PORT": DEFAULT_OPENCLAW_GATEWAY_PORT,
        "OPENCLAW_BRIDGE_PORT": DEFAULT_OPENCLAW_BRIDGE_PORT,
        "OPENCLAW_GATEWAY_BIND": DEFAULT_OPENCLAW_GATEWAY_BIND,
        "OPENCLAW_STATE_DIR": manifest.openclaw.state_dir,
        "OPENCLAW_CONFIG_PATH": manifest.openclaw.config_path(),
        "OPENCLAW_TMPFS": DEFAULT_OPENCLAW_TMPFS,
        "OPENCLAW_PIDS_LIMIT": DEFAULT_OPENCLAW_PIDS_LIMIT,
        "OPENCLAW_NOFILE_SOFT": DEFAULT_OPENCLAW_NOFILE_SOFT,
        "OPENCLAW_NOFILE_HARD": DEFAULT_OPENCLAW_NOFILE_HARD,
        "OPENCLAW_NPROC": DEFAULT_OPENCLAW_NPROC,
        "OPENCLAW_TZ": DEFAULT_OPENCLAW_TIMEZONE,
        "OPENCLAW_INSTALL_BROWSER": "",
        "OPENCLAW_INSTALL_DOCKER_CLI": "",
    }
    for key, default in runtime_defaults.items():
        if key == "OPENCLAW_IMAGE":
            current_value = values.get(key)
            if current_value == LEGACY_OPENCLAW_IMAGE:
                value = default
            else:
                value = values.get(key, default)
        else:
            value = values.get(key, default)
        lines.append(f"{key}={value}")
        used_keys.add(key)
    for key, default in DEFAULT_OPENCLAW_ENV_DEFAULTS:
        lines.append(f"{key}={values.get(key, default)}")
        used_keys.add(key)
    advisory_values = {key: values.get(key, default) for key, default in runtime_defaults.items()}
    advisory_values.update(
        {key: values.get(key, default) for key, default in DEFAULT_OPENCLAW_ENV_DEFAULTS}
    )
    advisories = assess_runtime_env_security(advisory_values)
    if advisories:
        lines.append("")
        lines.append("# Security advisories for explicit runtime overrides")
        for advisory in advisories:
            lines.append(f"# WARNING: {advisory}")
    if secret_names:
        lines.append("")
        lines.append("# Bot secret references")
        for secret in manifest.runtime.secret_refs:
            lines.append("")
            lines.append(f"# source: {secret.source}")
            lines.append(f"# required: {'true' if secret.required else 'false'}")
            lines.append(f"{secret.name}={values.get(secret.name, '')}")
            used_keys.add(secret.name)
    extra_keys = sorted(key for key in values if key not in used_keys)
    if extra_keys:
        lines.append("")
        lines.append("# Preserved custom values")
        for key in extra_keys:
            lines.append(f"{key}={values[key]}")
    return "\n".join(lines).rstrip() + "\n"

write_compose(path, compose_text)

Write the compose file to disk.

Source code in src/openenv/docker/compose.py
def write_compose(path: str | Path, compose_text: str) -> None:
    """Write the compose file to disk."""
    Path(path).write_text(compose_text, encoding="utf-8")

write_env_file(path, env_text)

Write the env file to disk.

Source code in src/openenv/docker/compose.py
def write_env_file(path: str | Path, env_text: str) -> None:
    """Write the env file to disk."""
    Path(path).write_text(env_text, encoding="utf-8")

openenv.docker.builder

openenv.docker.builder

Docker image build helpers.

build_image(dockerfile_text, tag)

Build a Docker image from a rendered Dockerfile.

Source code in src/openenv/docker/builder.py
def build_image(dockerfile_text: str, tag: str) -> None:
    """Build a Docker image from a rendered Dockerfile."""
    build_image_with_args(dockerfile_text, tag, build_args=None)

build_image_with_args(dockerfile_text, tag, *, build_args)

Build a Docker image from a rendered Dockerfile with optional build args.

Source code in src/openenv/docker/builder.py
def build_image_with_args(
    dockerfile_text: str,
    tag: str,
    *,
    build_args: dict[str, str] | None,
) -> None:
    """Build a Docker image from a rendered Dockerfile with optional build args."""
    with tempfile.TemporaryDirectory(prefix="openclawenv-build-") as temp_dir:
        dockerfile_path = Path(temp_dir) / "Dockerfile"
        dockerfile_path.write_text(dockerfile_text, encoding="utf-8")
        command = [
            "docker",
            "build",
            "--tag",
            tag,
            "--file",
            str(dockerfile_path),
            temp_dir,
        ]
        for key, value in sorted((build_args or {}).items()):
            command.extend(["--build-arg", f"{key}={value}"])
        try:
            subprocess.run(command, check=True)
        except OSError as exc:
            raise CommandError("Docker is not available on PATH.") from exc
        except subprocess.CalledProcessError as exc:
            raise CommandError(
                f"Docker build failed for tag {tag} with exit code {exc.returncode}."
            ) from exc

default_image_tag(project_name, version)

Compute the default docker tag for a project.

Source code in src/openenv/docker/builder.py
def default_image_tag(project_name: str, version: str) -> str:
    """Compute the default docker tag for a project."""
    return f"openclawenv/{slugify_name(project_name)}:{version}"

openenv.docker.runtime

openenv.docker.runtime

Runtime inspection helpers for running bot containers.

CapturedSkill dataclass

A skill snapshot collected from a running container.

Source code in src/openenv/docker/runtime.py
@dataclass(slots=True)
class CapturedSkill:
    """A skill snapshot collected from a running container."""

    name: str
    description: str
    content: str
    source: str | None = None
    assets: dict[str, str] = field(default_factory=dict)

fetch_container_logs(container_name, *, tail=120)

Return recent logs for a running container.

Source code in src/openenv/docker/runtime.py
def fetch_container_logs(container_name: str, *, tail: int = 120) -> str:
    """Return recent logs for a running container."""
    return _run_command(
        ["docker", "logs", "--tail", str(tail), container_name],
        unavailable_message=(
            "Docker is not available on PATH. Install Docker or Docker Desktop "
            "before reading bot logs."
        ),
        failure_message=f"Failed to read logs for container `{container_name}`.",
    )

list_running_container_names()

Return the names of running Docker containers.

Source code in src/openenv/docker/runtime.py
def list_running_container_names() -> set[str]:
    """Return the names of running Docker containers."""
    stdout = _run_command(
        ["docker", "ps", "--format", "{{.Names}}"],
        unavailable_message=(
            "Docker is not available on PATH. Install Docker or Docker Desktop "
            "before listing running bots."
        ),
        failure_message="Failed to list running Docker containers.",
    )
    return {line.strip() for line in stdout.splitlines() if line.strip()}

snapshot_installed_skills(container_name, *, workspace)

Snapshot installed skills from a running bot container.

Source code in src/openenv/docker/runtime.py
def snapshot_installed_skills(
    container_name: str,
    *,
    workspace: str,
) -> list[CapturedSkill]:
    """Snapshot installed skills from a running bot container."""
    stdout = _run_command(
        [
            "docker",
            "exec",
            container_name,
            "python",
            "-c",
            SNAPSHOT_SCRIPT_TEMPLATE.format(workspace=workspace),
        ],
        unavailable_message=(
            "Docker is not available on PATH. Install Docker or Docker Desktop "
            "before creating a skill snapshot."
        ),
        failure_message=f"Failed to snapshot installed skills for `{container_name}`.",
    )
    try:
        payload = json.loads(stdout.strip() or "[]")
    except json.JSONDecodeError as exc:
        raise CommandError(
            f"Container `{container_name}` returned an unreadable skill snapshot payload."
        ) from exc
    if not isinstance(payload, list):
        raise CommandError(
            f"Container `{container_name}` returned an invalid skill snapshot payload."
        )

    snapshots: list[CapturedSkill] = []
    for item in payload:
        if not isinstance(item, dict):
            continue
        name = str(item.get("name", "")).strip()
        files = item.get("files", {})
        if not name or not isinstance(files, dict):
            continue
        skill_md = files.get("SKILL.md")
        if not isinstance(skill_md, str) or not skill_md.strip():
            continue
        assets = {
            path: content
            for path, content in sorted(files.items())
            if path != "SKILL.md" and isinstance(path, str) and isinstance(content, str)
        }
        frontmatter = _parse_frontmatter(skill_md)
        snapshots.append(
            CapturedSkill(
                name=name,
                description=frontmatter.get(
                    "description",
                    f"Snapshotted skill from running container {container_name}",
                ),
                content=skill_md,
                source=frontmatter.get("source"),
                assets=assets,
            )
        )
    return snapshots