Coverage for src / openenv / docker / compose.py: 91.24%
312 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
1"""docker-compose generation for OpenClawenv."""
3from __future__ import annotations
5from collections.abc import Sequence
6from dataclasses import dataclass
7import json
8from pathlib import Path
9import secrets
11from pathlib import PurePosixPath
13from openenv.core.models import Lockfile, Manifest
14from openenv.core.skills import catalog_install_dir_name, catalog_skill_specs
15from openenv.core.security import assess_runtime_env_security
16from openenv.core.utils import slugify_name
17from openenv.docker.dockerfile import render_runtime_payload
20OPENCLAW_GATEWAY_SERVICE = "openclaw-gateway"
21OPENCLAW_CLI_SERVICE = "openclaw-cli"
22OPENCLAW_HELPER_ENTRYPOINT = ("tail", "-f", "/dev/null")
23DEFAULT_OPENCLAW_HOME = "/home/node"
24ROOT_RUNTIME_HOME = "/root"
25DEFAULT_OPENCLAW_CONFIG_DIR = "./.openclaw"
26DEFAULT_OPENCLAW_WORKSPACE_DIR = "./workspace"
27DEFAULT_OPENCLAW_GATEWAY_HOST_BIND = "127.0.0.1"
28DEFAULT_OPENCLAW_BRIDGE_HOST_BIND = "127.0.0.1"
29DEFAULT_OPENCLAW_GATEWAY_PORT = "18789"
30DEFAULT_OPENCLAW_BRIDGE_PORT = "18790"
31DEFAULT_OPENCLAW_GATEWAY_BIND = "lan"
32DEFAULT_OPENCLAW_TIMEZONE = "UTC"
33DEFAULT_OPENCLAW_TMPFS = "/tmp:rw,noexec,nosuid,nodev,size=64m"
34DEFAULT_OPENCLAW_PIDS_LIMIT = "256"
35DEFAULT_OPENCLAW_NOFILE_SOFT = "1024"
36DEFAULT_OPENCLAW_NOFILE_HARD = "2048"
37DEFAULT_OPENCLAW_NPROC = "512"
38DEFAULT_NPM_CACHE_DIR = "/tmp/.npm"
39DEFAULT_XDG_CACHE_HOME = "/tmp/.cache"
40DEFAULT_BUILD_CONTEXT = "."
41DEFAULT_DOCKERFILE_NAME = "Dockerfile"
42DEFAULT_OPENCLAW_GATEWAY_IMAGE = "ghcr.io/openclaw/openclaw:latest"
43LEGACY_OPENCLAW_IMAGE = "alpine/openclaw:main"
44ALL_BOTS_GATEWAY_SERVICE = "openclaw-gateway"
45ALL_BOTS_GATEWAY_CONTAINER = "all-bots-openclaw-gateway"
46ALL_BOTS_COMPOSE_FILENAME = "all-bots-compose.yml"
47ALL_BOTS_ENV_FILENAME = ".all-bots.env"
48ALL_BOTS_GATEWAY_ROOT_DIR = "./.all-bots"
49ALL_BOTS_GATEWAY_CONFIG_DIR = "./.all-bots/.openclaw"
50ALL_BOTS_GATEWAY_WORKSPACE_DIR = "./.all-bots/workspace"
51ALL_BOTS_GATEWAY_CONTAINER_ROOT = "/opt/openclaw"
52ALL_BOTS_GATEWAY_HOME = ALL_BOTS_GATEWAY_CONTAINER_ROOT
53ALL_BOTS_GATEWAY_STATE_DIR = f"{ALL_BOTS_GATEWAY_CONTAINER_ROOT}/.openclaw"
54ALL_BOTS_GATEWAY_CONFIG_PATH = f"{ALL_BOTS_GATEWAY_STATE_DIR}/openclaw.json"
55CLAWHUB_NPX_PACKAGE = "clawhub@latest"
56CATALOG_SKILL_PLACEHOLDER_MARKER = "This skill is referenced from an external catalog."
57DEFAULT_OPENCLAW_ENV_DEFAULTS: tuple[tuple[str, str], ...] = (
58 ("OPENCLAW_GATEWAY_TOKEN", ""),
59 ("OPENCLAW_ALLOW_INSECURE_PRIVATE_WS", ""),
60 ("CLAUDE_AI_SESSION_KEY", ""),
61 ("CLAUDE_WEB_SESSION_KEY", ""),
62 ("CLAUDE_WEB_COOKIE", ""),
63)
66@dataclass(slots=True, frozen=True)
67class AllBotsComposeSpec:
68 """Information needed to render one bot entry inside the shared compose stack."""
70 slug: str
71 manifest: Manifest
72 image_tag: str
75def default_compose_filename(agent_name: str) -> str:
76 """Return the default compose filename for a bot."""
77 return f"docker-compose-{slugify_name(agent_name)}.yml"
80def default_env_filename(agent_name: str) -> str:
81 """Return the default env filename for a bot."""
82 return f".{slugify_name(agent_name)}.env"
85def all_bots_compose_filename() -> str:
86 """Return the shared compose filename for all managed bots."""
87 return ALL_BOTS_COMPOSE_FILENAME
90def all_bots_env_filename() -> str:
91 """Return the shared env filename used by the all-bots stack."""
92 return ALL_BOTS_ENV_FILENAME
95def gateway_container_name(agent_name: str) -> str:
96 """Return the gateway container name for a bot."""
97 return f"{slugify_name(agent_name)}-openclaw-gateway"
100def cli_container_name(agent_name: str) -> str:
101 """Return the CLI container name for a bot."""
102 return f"{slugify_name(agent_name)}-openclaw-cli"
105def render_compose(manifest: Manifest, image_tag: str) -> str:
106 """Render an OpenClaw-style docker-compose file for the bot image."""
107 env_file = default_env_filename(manifest.openclaw.agent_name)
108 gateway_name = gateway_container_name(manifest.openclaw.agent_name)
109 cli_name = cli_container_name(manifest.openclaw.agent_name)
110 image_ref = f"${{OPENCLAW_IMAGE:-{image_tag}}}"
111 gateway_command = _gateway_startup_command(
112 [(manifest.openclaw.workspace, manifest)]
113 )
114 config_mount = (
115 f"${{OPENCLAW_CONFIG_DIR:-{DEFAULT_OPENCLAW_CONFIG_DIR}}}:{manifest.openclaw.state_dir}"
116 )
117 workspace_mount = (
118 f"${{OPENCLAW_WORKSPACE_DIR:-{DEFAULT_OPENCLAW_WORKSPACE_DIR}}}:"
119 f"{manifest.openclaw.workspace}"
120 )
121 gateway_env = _base_service_environment(manifest)
122 cli_env = dict(gateway_env)
123 cli_env["BROWSER"] = "echo"
125 lines = [
126 "services:",
127 f" {OPENCLAW_GATEWAY_SERVICE}:",
128 f" image: {_quoted(image_ref)}",
129 " build:",
130 f" context: {_quoted(DEFAULT_BUILD_CONTEXT)}",
131 f" dockerfile: {_quoted(DEFAULT_DOCKERFILE_NAME)}",
132 " args:",
133 ' OPENCLAW_INSTALL_BROWSER: "${OPENCLAW_INSTALL_BROWSER:-}"',
134 ' OPENCLAW_INSTALL_DOCKER_CLI: "${OPENCLAW_INSTALL_DOCKER_CLI:-}"',
135 f" container_name: {_quoted(gateway_name)}",
136 f" user: {_quoted(_effective_runtime_user(manifest))}",
137 " env_file:",
138 f" - {_quoted(env_file)}",
139 " environment:",
140 ]
141 lines.extend(_render_environment(gateway_env))
142 lines.extend(
143 [
144 " cap_drop:",
145 " - ALL",
146 ]
147 )
148 lines.extend(_runtime_capability_lines(_effective_runtime_user(manifest)))
149 lines.extend(
150 [
151 " security_opt:",
152 " - no-new-privileges:true",
153 " read_only: true",
154 " tmpfs:",
155 f' - "${{OPENCLAW_TMPFS:-{DEFAULT_OPENCLAW_TMPFS}}}"',
156 f' pids_limit: "${{OPENCLAW_PIDS_LIMIT:-{DEFAULT_OPENCLAW_PIDS_LIMIT}}}"',
157 " ulimits:",
158 " nofile:",
159 f' soft: "${{OPENCLAW_NOFILE_SOFT:-{DEFAULT_OPENCLAW_NOFILE_SOFT}}}"',
160 f' hard: "${{OPENCLAW_NOFILE_HARD:-{DEFAULT_OPENCLAW_NOFILE_HARD}}}"',
161 f' nproc: "${{OPENCLAW_NPROC:-{DEFAULT_OPENCLAW_NPROC}}}"',
162 " volumes:",
163 f" - {_quoted(config_mount)}",
164 f" - {_quoted(workspace_mount)}",
165 " ## Uncomment the lines below to enable sandbox isolation",
166 " ## (agents.defaults.sandbox). Requires Docker CLI in the image",
167 " ## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use",
168 " ## scripts/docker/setup.sh with OPENCLAW_SANDBOX=1 for automated setup.",
169 " ## WARNING: mounting /var/run/docker.sock grants host root-equivalent access.",
170 " ## Set DOCKER_GID to the host docker group GID before enabling it.",
171 ' # - "/var/run/docker.sock:/var/run/docker.sock"',
172 " # group_add:",
173 ' # - "${DOCKER_GID:-999}"',
174 " ports:",
175 (
176 f' - "${{OPENCLAW_GATEWAY_HOST_BIND:-{DEFAULT_OPENCLAW_GATEWAY_HOST_BIND}}}'
177 f':${{OPENCLAW_GATEWAY_PORT:-{DEFAULT_OPENCLAW_GATEWAY_PORT}}}:18789"'
178 ),
179 (
180 f' - "${{OPENCLAW_BRIDGE_HOST_BIND:-{DEFAULT_OPENCLAW_BRIDGE_HOST_BIND}}}'
181 f':${{OPENCLAW_BRIDGE_PORT:-{DEFAULT_OPENCLAW_BRIDGE_PORT}}}:18790"'
182 ),
183 " init: true",
184 " restart: unless-stopped",
185 " command:",
186 " [",
187 ' "sh",',
188 ' "-lc",',
189 f" {_quoted(gateway_command)},",
190 " ]",
191 " healthcheck:",
192 " test:",
193 " [",
194 ' "CMD",',
195 ' "node",',
196 ' "-e",',
197 ' "fetch(\'http://127.0.0.1:18789/healthz\').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",',
198 " ]",
199 " interval: 30s",
200 " timeout: 5s",
201 " retries: 5",
202 " start_period: 20s",
203 "",
204 f" {OPENCLAW_CLI_SERVICE}:",
205 f" image: {_quoted(image_ref)}",
206 f" container_name: {_quoted(cli_name)}",
207 f" user: {_quoted(_effective_runtime_user(manifest))}",
208 ' network_mode: "service:openclaw-gateway"',
209 " cap_drop:",
210 " - ALL",
211 ]
212 )
213 lines.extend(_runtime_capability_lines(_effective_runtime_user(manifest)))
214 lines.extend(
215 [
216 " security_opt:",
217 " - no-new-privileges:true",
218 " read_only: true",
219 " tmpfs:",
220 f' - "${{OPENCLAW_TMPFS:-{DEFAULT_OPENCLAW_TMPFS}}}"',
221 f' pids_limit: "${{OPENCLAW_PIDS_LIMIT:-{DEFAULT_OPENCLAW_PIDS_LIMIT}}}"',
222 " ulimits:",
223 " nofile:",
224 f' soft: "${{OPENCLAW_NOFILE_SOFT:-{DEFAULT_OPENCLAW_NOFILE_SOFT}}}"',
225 f' hard: "${{OPENCLAW_NOFILE_HARD:-{DEFAULT_OPENCLAW_NOFILE_HARD}}}"',
226 f' nproc: "${{OPENCLAW_NPROC:-{DEFAULT_OPENCLAW_NPROC}}}"',
227 " env_file:",
228 f" - {_quoted(env_file)}",
229 " environment:",
230 ]
231 )
232 lines.extend(_render_environment(cli_env))
233 lines.extend(
234 [
235 " volumes:",
236 f" - {_quoted(config_mount)}",
237 f" - {_quoted(workspace_mount)}",
238 " stdin_open: true",
239 " tty: true",
240 " init: true",
241 f" entrypoint: [{', '.join(_quoted(part) for part in OPENCLAW_HELPER_ENTRYPOINT)}]",
242 " depends_on:",
243 f" - {OPENCLAW_GATEWAY_SERVICE}",
244 ]
245 )
246 return "\n".join(lines) + "\n"
249def render_all_bots_compose(specs: Sequence[AllBotsComposeSpec]) -> str:
250 """Render a shared stack with one gateway and CLI services for all bots."""
251 if not specs: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true
252 raise ValueError("At least one bot is required to render the shared compose stack.")
253 shared_env_file = f"./{all_bots_env_filename()}"
254 shared_runtime_mount = f"{ALL_BOTS_GATEWAY_ROOT_DIR}:{ALL_BOTS_GATEWAY_CONTAINER_ROOT}"
255 shared_runtime_user = _shared_runtime_user(specs)
256 gateway_command = _gateway_startup_command(
257 [
258 (
259 str(PurePosixPath(ALL_BOTS_GATEWAY_CONTAINER_ROOT) / "workspace" / spec.slug),
260 spec.manifest,
261 )
262 for spec in specs
263 ]
264 )
265 lines = [
266 "services:",
267 f" {ALL_BOTS_GATEWAY_SERVICE}:",
268 f' image: {_quoted(f"${{OPENCLAW_GATEWAY_IMAGE:-{DEFAULT_OPENCLAW_GATEWAY_IMAGE}}}")}',
269 f" container_name: {_quoted(ALL_BOTS_GATEWAY_CONTAINER)}",
270 f" user: {_quoted(shared_runtime_user)}",
271 " env_file:",
272 f" - {_quoted(shared_env_file)}",
273 " environment:",
274 ]
275 lines.extend(_render_environment(_shared_gateway_environment()))
276 lines.extend(
277 [
278 " cap_drop:",
279 " - ALL",
280 ]
281 )
282 lines.extend(_runtime_capability_lines(shared_runtime_user))
283 lines.extend(
284 [
285 " security_opt:",
286 " - no-new-privileges:true",
287 " read_only: true",
288 " tmpfs:",
289 f' - "${{OPENCLAW_TMPFS:-{DEFAULT_OPENCLAW_TMPFS}}}"',
290 f' pids_limit: "${{OPENCLAW_PIDS_LIMIT:-{DEFAULT_OPENCLAW_PIDS_LIMIT}}}"',
291 " ulimits:",
292 " nofile:",
293 f' soft: "${{OPENCLAW_NOFILE_SOFT:-{DEFAULT_OPENCLAW_NOFILE_SOFT}}}"',
294 f' hard: "${{OPENCLAW_NOFILE_HARD:-{DEFAULT_OPENCLAW_NOFILE_HARD}}}"',
295 f' nproc: "${{OPENCLAW_NPROC:-{DEFAULT_OPENCLAW_NPROC}}}"',
296 " volumes:",
297 f" - {_quoted(shared_runtime_mount)}",
298 " ports:",
299 (
300 f' - "${{OPENCLAW_GATEWAY_HOST_BIND:-{DEFAULT_OPENCLAW_GATEWAY_HOST_BIND}}}'
301 f':${{OPENCLAW_GATEWAY_PORT:-{DEFAULT_OPENCLAW_GATEWAY_PORT}}}:18789"'
302 ),
303 (
304 f' - "${{OPENCLAW_BRIDGE_HOST_BIND:-{DEFAULT_OPENCLAW_BRIDGE_HOST_BIND}}}'
305 f':${{OPENCLAW_BRIDGE_PORT:-{DEFAULT_OPENCLAW_BRIDGE_PORT}}}:18790"'
306 ),
307 " init: true",
308 " restart: unless-stopped",
309 " command:",
310 " [",
311 ' "sh",',
312 ' "-lc",',
313 f" {_quoted(gateway_command)},",
314 " ]",
315 " healthcheck:",
316 " test:",
317 " [",
318 ' "CMD",',
319 ' "node",',
320 ' "-e",',
321 ' "fetch(\'http://127.0.0.1:18789/healthz\').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",',
322 " ]",
323 " interval: 30s",
324 " timeout: 5s",
325 " retries: 5",
326 " start_period: 20s",
327 ]
328 )
329 for spec in specs:
330 service_name = _all_bots_cli_service_name(spec.slug)
331 container_name = _all_bots_cli_container_name(spec.slug)
332 env_file = f"./{spec.slug}/{default_env_filename(spec.manifest.openclaw.agent_name)}"
333 cli_env = dict(_base_service_environment(spec.manifest))
334 cli_env["HOME"] = ALL_BOTS_GATEWAY_HOME
335 cli_env["OPENCLAW_CONFIG_PATH"] = ALL_BOTS_GATEWAY_CONFIG_PATH
336 cli_env["OPENCLAW_STATE_DIR"] = ALL_BOTS_GATEWAY_STATE_DIR
337 cli_env["BROWSER"] = "echo"
338 lines.extend(
339 [
340 "",
341 f" {service_name}:",
342 f" image: {_quoted(spec.image_tag)}",
343 " build:",
344 f' context: {_quoted(f"./{spec.slug}")}',
345 f" dockerfile: {_quoted(DEFAULT_DOCKERFILE_NAME)}",
346 " args:",
347 ' OPENCLAW_INSTALL_BROWSER: "${OPENCLAW_INSTALL_BROWSER:-}"',
348 ' OPENCLAW_INSTALL_DOCKER_CLI: "${OPENCLAW_INSTALL_DOCKER_CLI:-}"',
349 f" container_name: {_quoted(container_name)}",
350 f" user: {_quoted(shared_runtime_user)}",
351 ' network_mode: "service:openclaw-gateway"',
352 " cap_drop:",
353 " - ALL",
354 ]
355 )
356 lines.extend(_runtime_capability_lines(shared_runtime_user))
357 lines.extend(
358 [
359 " security_opt:",
360 " - no-new-privileges:true",
361 " read_only: true",
362 " tmpfs:",
363 f' - "${{OPENCLAW_TMPFS:-{DEFAULT_OPENCLAW_TMPFS}}}"',
364 f' pids_limit: "${{OPENCLAW_PIDS_LIMIT:-{DEFAULT_OPENCLAW_PIDS_LIMIT}}}"',
365 " ulimits:",
366 " nofile:",
367 f' soft: "${{OPENCLAW_NOFILE_SOFT:-{DEFAULT_OPENCLAW_NOFILE_SOFT}}}"',
368 f' hard: "${{OPENCLAW_NOFILE_HARD:-{DEFAULT_OPENCLAW_NOFILE_HARD}}}"',
369 f' nproc: "${{OPENCLAW_NPROC:-{DEFAULT_OPENCLAW_NPROC}}}"',
370 " env_file:",
371 f" - {_quoted(env_file)}",
372 f" - {_quoted(shared_env_file)}",
373 " environment:",
374 ]
375 )
376 lines.extend(_render_environment(cli_env))
377 lines.extend(
378 [
379 " volumes:",
380 f" - {_quoted(shared_runtime_mount)}",
381 " stdin_open: true",
382 " tty: true",
383 " init: true",
384 f" entrypoint: [{', '.join(_quoted(part) for part in OPENCLAW_HELPER_ENTRYPOINT)}]",
385 " depends_on:",
386 f" - {ALL_BOTS_GATEWAY_SERVICE}",
387 ]
388 )
389 return "\n".join(lines) + "\n"
392def write_compose(path: str | Path, compose_text: str) -> None:
393 """Write the compose file to disk."""
394 Path(path).write_text(compose_text, encoding="utf-8")
397def render_env_file(
398 manifest: Manifest,
399 image_tag: str,
400 *,
401 existing_values: dict[str, str] | None = None,
402) -> str:
403 """Render the bot-specific env file with OpenClaw defaults and secrets."""
404 values = dict(existing_values or {})
405 secret_names = [secret.name for secret in manifest.runtime.secret_refs]
406 used_keys: set[str] = set()
407 lines = [
408 f"# OpenClaw runtime and secrets for {manifest.openclaw.agent_name}",
409 (
410 "# Use with: docker compose --env-file "
411 f"{default_env_filename(manifest.openclaw.agent_name)} -f "
412 f"{default_compose_filename(manifest.openclaw.agent_name)} up -d"
413 ),
414 "",
415 "# OpenClaw runtime overrides",
416 "# docker compose builds and tags this image from the adjacent Dockerfile",
417 ]
418 runtime_defaults = {
419 "OPENCLAW_IMAGE": image_tag,
420 "OPENCLAW_CONFIG_DIR": DEFAULT_OPENCLAW_CONFIG_DIR,
421 "OPENCLAW_WORKSPACE_DIR": DEFAULT_OPENCLAW_WORKSPACE_DIR,
422 "OPENCLAW_GATEWAY_HOST_BIND": DEFAULT_OPENCLAW_GATEWAY_HOST_BIND,
423 "OPENCLAW_BRIDGE_HOST_BIND": DEFAULT_OPENCLAW_BRIDGE_HOST_BIND,
424 "OPENCLAW_GATEWAY_PORT": DEFAULT_OPENCLAW_GATEWAY_PORT,
425 "OPENCLAW_BRIDGE_PORT": DEFAULT_OPENCLAW_BRIDGE_PORT,
426 "OPENCLAW_GATEWAY_BIND": DEFAULT_OPENCLAW_GATEWAY_BIND,
427 "OPENCLAW_STATE_DIR": manifest.openclaw.state_dir,
428 "OPENCLAW_CONFIG_PATH": manifest.openclaw.config_path(),
429 "OPENCLAW_TMPFS": DEFAULT_OPENCLAW_TMPFS,
430 "OPENCLAW_PIDS_LIMIT": DEFAULT_OPENCLAW_PIDS_LIMIT,
431 "OPENCLAW_NOFILE_SOFT": DEFAULT_OPENCLAW_NOFILE_SOFT,
432 "OPENCLAW_NOFILE_HARD": DEFAULT_OPENCLAW_NOFILE_HARD,
433 "OPENCLAW_NPROC": DEFAULT_OPENCLAW_NPROC,
434 "OPENCLAW_TZ": DEFAULT_OPENCLAW_TIMEZONE,
435 "OPENCLAW_INSTALL_BROWSER": "",
436 "OPENCLAW_INSTALL_DOCKER_CLI": "",
437 }
438 for key, default in runtime_defaults.items():
439 if key == "OPENCLAW_IMAGE":
440 current_value = values.get(key)
441 if current_value == LEGACY_OPENCLAW_IMAGE: 441 ↛ 442line 441 didn't jump to line 442 because the condition on line 441 was never true
442 value = default
443 else:
444 value = values.get(key, default)
445 else:
446 value = values.get(key, default)
447 lines.append(f"{key}={value}")
448 used_keys.add(key)
449 for key, default in DEFAULT_OPENCLAW_ENV_DEFAULTS:
450 lines.append(f"{key}={values.get(key, default)}")
451 used_keys.add(key)
452 advisory_values = {key: values.get(key, default) for key, default in runtime_defaults.items()}
453 advisory_values.update(
454 {key: values.get(key, default) for key, default in DEFAULT_OPENCLAW_ENV_DEFAULTS}
455 )
456 advisories = assess_runtime_env_security(advisory_values)
457 if advisories:
458 lines.append("")
459 lines.append("# Security advisories for explicit runtime overrides")
460 for advisory in advisories:
461 lines.append(f"# WARNING: {advisory}")
462 if secret_names:
463 lines.append("")
464 lines.append("# Bot secret references")
465 for secret in manifest.runtime.secret_refs:
466 lines.append("")
467 lines.append(f"# source: {secret.source}")
468 lines.append(f"# required: {'true' if secret.required else 'false'}")
469 lines.append(f"{secret.name}={values.get(secret.name, '')}")
470 used_keys.add(secret.name)
471 extra_keys = sorted(key for key in values if key not in used_keys)
472 if extra_keys: 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true
473 lines.append("")
474 lines.append("# Preserved custom values")
475 for key in extra_keys:
476 lines.append(f"{key}={values[key]}")
477 return "\n".join(lines).rstrip() + "\n"
480def render_all_bots_env_file(*, existing_values: dict[str, str] | None = None) -> str:
481 """Render the shared env file consumed by the all-bots gateway and CLI helpers."""
482 values = dict(existing_values or {})
483 used_keys: set[str] = set()
484 lines = [
485 "# Shared OpenClaw runtime secrets for the all-bots gateway",
486 (
487 "# Use with: docker compose -f "
488 f"{all_bots_compose_filename()} up -d"
489 ),
490 "",
491 ]
492 for key, default in DEFAULT_OPENCLAW_ENV_DEFAULTS:
493 lines.append(f"{key}={values.get(key, default)}")
494 used_keys.add(key)
495 extra_keys = sorted(key for key in values if key not in used_keys)
496 if extra_keys:
497 lines.append("")
498 lines.append("# Preserved custom values")
499 for key in extra_keys:
500 lines.append(f"{key}={values[key]}")
501 return "\n".join(lines).rstrip() + "\n"
504def write_env_file(path: str | Path, env_text: str) -> None:
505 """Write the env file to disk."""
506 Path(path).write_text(env_text, encoding="utf-8")
509def prepare_runtime_env_values(existing_values: dict[str, str] | None = None) -> dict[str, str]:
510 """Fill runtime env values that should exist before writing generated env files."""
511 values = dict(existing_values or {})
512 if not values.get("OPENCLAW_GATEWAY_TOKEN", "").strip():
513 values["OPENCLAW_GATEWAY_TOKEN"] = generate_gateway_token()
514 return values
517def generate_gateway_token() -> str:
518 """Return a random gateway token for generated local stacks."""
519 return secrets.token_urlsafe(24)
522def _gateway_startup_command(workspaces: Sequence[tuple[str, Manifest]]) -> str:
523 """Return the shell command used to bootstrap catalog skills before starting the gateway."""
524 commands = ["set -eu"]
525 bootstrap_commands = _catalog_skill_bootstrap_commands(workspaces)
526 if bootstrap_commands: 526 ↛ 528line 526 didn't jump to line 528 because the condition on line 526 was always true
527 commands.extend(bootstrap_commands)
528 commands.append(
529 'exec node dist/index.js gateway --bind "${OPENCLAW_GATEWAY_BIND:-'
530 f'{DEFAULT_OPENCLAW_GATEWAY_BIND}}}" --port 18789'
531 )
532 return "; ".join(commands)
535def _catalog_skill_bootstrap_commands(workspaces: Sequence[tuple[str, Manifest]]) -> list[str]:
536 """Return shell fragments that install missing catalog skills into bind-mounted workspaces."""
537 specs: list[tuple[str, str, str]] = []
538 seen: set[tuple[str, str, str]] = set()
539 for workspace, manifest in workspaces:
540 for skill_name, source in catalog_skill_specs(manifest.skills):
541 spec = (workspace, skill_name, source)
542 if spec in seen: 542 ↛ 543line 542 didn't jump to line 543 because the condition on line 542 was never true
543 continue
544 seen.add(spec)
545 specs.append(spec)
546 if not specs: 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true
547 return []
549 commands = [
550 (
551 "run_clawhub() { if command -v clawhub >/dev/null 2>&1; then "
552 f'clawhub "$@"; else npx --yes {CLAWHUB_NPX_PACKAGE} "$@"; fi; }}'
553 ),
554 (
555 "ensure_catalog_skill() { "
556 'source_name="$$1"; workspace_root="$$2"; skill_dir="$$3"; installed_name="$$4"; skill_md="$$5"; '
557 'if [ ! -f "$$skill_md" ] || grep -qF '
558 f"{_sh_quote(CATALOG_SKILL_PLACEHOLDER_MARKER)} "
559 '"$$skill_md"; then '
560 'install_root="$$(mktemp -d)" && '
561 'if run_clawhub install "$$source_name" --workdir "$$install_root" --force --no-input; then '
562 'if [ -d "$$install_root/skills/$$installed_name" ]; then '
563 'rm -rf "$$skill_dir" "$$workspace_root/skills/$$installed_name" && '
564 'mv "$$install_root/skills/$$installed_name" "$$skill_dir"; '
565 'elif [ -f "$$skill_md" ]; then '
566 'echo "WARNING: ClawHub install for $$source_name did not materialize an expected skill directory; keeping placeholder at $$skill_dir." >&2; '
567 'else echo "ERROR: ClawHub install for $$source_name did not materialize an expected skill directory and no placeholder exists at $$skill_dir." >&2; exit 1; fi; '
568 'else '
569 'if [ -f "$$skill_md" ]; then '
570 'echo "WARNING: ClawHub skill source $$source_name was not found; keeping placeholder at $$skill_dir." >&2; '
571 'else echo "ERROR: ClawHub skill source $$source_name was not found and no placeholder exists at $$skill_dir." >&2; exit 1; fi; '
572 'fi && rm -rf "$$install_root"; '
573 'fi; '
574 "}"
575 ),
576 ]
577 prepared_workspaces: set[str] = set()
578 for workspace, skill_name, source in specs:
579 if workspace not in prepared_workspaces:
580 prepared_workspaces.add(workspace)
581 commands.append(f"mkdir -p {_sh_quote(str(PurePosixPath(workspace) / 'skills'))}")
582 skill_dir = str(PurePosixPath(workspace) / "skills" / skill_name)
583 installed_name = catalog_install_dir_name(source)
584 skill_md = str(PurePosixPath(skill_dir) / "SKILL.md")
585 commands.append(
586 "ensure_catalog_skill "
587 f"{_sh_quote(source)} {_sh_quote(workspace)} {_sh_quote(skill_dir)} "
588 f"{_sh_quote(installed_name)} {_sh_quote(skill_md)}"
589 )
590 return commands
593def _catalog_skill_placeholder_paths(
594 manifest: Manifest,
595 *,
596 workspace: str | None = None,
597) -> set[str]:
598 """Return container paths of placeholder `SKILL.md` files for catalog-backed skills."""
599 workspace_root = workspace or manifest.openclaw.workspace
600 return {
601 str(PurePosixPath(workspace_root) / "skills" / skill_name / "SKILL.md")
602 for skill_name, _ in catalog_skill_specs(manifest.skills)
603 }
606def _should_preserve_existing_catalog_skill_stub(
607 host_path: Path,
608 *,
609 container_path: str,
610 placeholder_paths: set[str],
611) -> bool:
612 """Keep installed catalog skills instead of rewriting them back to placeholder content."""
613 if container_path not in placeholder_paths or not host_path.exists():
614 return False
615 try:
616 existing = host_path.read_text(encoding="utf-8")
617 except (OSError, UnicodeDecodeError):
618 return True
619 return CATALOG_SKILL_PLACEHOLDER_MARKER not in existing
622def _base_service_environment(manifest: Manifest) -> dict[str, str]:
623 """Return environment variables shared by the gateway and CLI services for one bot."""
624 environment = dict(sorted(manifest.runtime.env.items()))
625 environment["HOME"] = _single_bot_runtime_home(manifest)
626 environment["NPM_CONFIG_CACHE"] = DEFAULT_NPM_CACHE_DIR
627 environment["XDG_CACHE_HOME"] = DEFAULT_XDG_CACHE_HOME
628 environment["TERM"] = "xterm-256color"
629 environment["OPENCLAW_CONFIG_PATH"] = manifest.openclaw.config_path()
630 environment["OPENCLAW_STATE_DIR"] = manifest.openclaw.state_dir
631 environment["TZ"] = f"${{OPENCLAW_TZ:-{DEFAULT_OPENCLAW_TIMEZONE}}}"
632 return environment
635def _shared_gateway_environment() -> dict[str, str]:
636 """Return the baseline environment used by the shared gateway in the all-bots stack."""
637 return {
638 "HOME": ALL_BOTS_GATEWAY_HOME,
639 "NPM_CONFIG_CACHE": DEFAULT_NPM_CACHE_DIR,
640 "XDG_CACHE_HOME": DEFAULT_XDG_CACHE_HOME,
641 "TERM": "xterm-256color",
642 "OPENCLAW_CONFIG_PATH": ALL_BOTS_GATEWAY_CONFIG_PATH,
643 "OPENCLAW_STATE_DIR": ALL_BOTS_GATEWAY_STATE_DIR,
644 "TZ": f"${{OPENCLAW_TZ:-{DEFAULT_OPENCLAW_TIMEZONE}}}",
645 }
648def _all_bots_cli_service_name(slug: str) -> str:
649 """Build the service name used for one bot in the shared stack."""
650 return f"bot-{slug}"
653def _all_bots_cli_container_name(slug: str) -> str:
654 """Build the container name used for one bot CLI in the shared stack."""
655 return f"all-bots-{slug}-openclaw-cli"
658def _effective_runtime_user(manifest: Manifest) -> str:
659 """Return the runtime user supported by generated compose services."""
660 if manifest.runtime.user.strip().casefold() == "root":
661 return "root"
662 return "node"
665def _single_bot_runtime_home(manifest: Manifest) -> str:
666 """Return the home directory used by one bot's dedicated runtime container."""
667 if _effective_runtime_user(manifest) == "root":
668 return ROOT_RUNTIME_HOME
669 return DEFAULT_OPENCLAW_HOME
672def _shared_runtime_user(specs: Sequence[AllBotsComposeSpec]) -> str:
673 """Return the shared user used by the all-bots gateway and CLI helpers."""
674 if any(_effective_runtime_user(spec.manifest) == "root" for spec in specs): 674 ↛ 676line 674 didn't jump to line 676 because the condition on line 674 was always true
675 return "root"
676 return "node"
679def _runtime_capability_lines(runtime_user: str) -> list[str]:
680 """Return the minimal extra capabilities needed by the chosen runtime user."""
681 if runtime_user != "root":
682 return []
683 return [
684 " cap_add:",
685 " - DAC_OVERRIDE",
686 " - FOWNER",
687 ]
690def _render_environment(environment: dict[str, str]) -> list[str]:
691 """Render compose `environment:` entries preserving the caller's ordering."""
692 return [f" {key}: {_quoted(value)}" for key, value in environment.items()]
695def _quoted(value: str) -> str:
696 """Return a YAML-safe quoted scalar using JSON string escaping."""
697 return json.dumps(value)
700def _sh_quote(value: str) -> str:
701 """Return a POSIX-shell-safe single-quoted string."""
702 return "'" + value.replace("'", "'\"'\"'") + "'"
705def _clawhub_post_install_move(skill_dir: str, installed_dir: str) -> str:
706 """Return an optional rename step when ClawHub's directory differs from the wrapper name."""
707 if skill_dir == installed_dir:
708 return ""
709 return (
710 " && if [ -d "
711 f"{_sh_quote(installed_dir)}"
712 " ]; then mv "
713 f"{_sh_quote(installed_dir)} {_sh_quote(skill_dir)}; "
714 "fi"
715 )
718def _rm_target_arguments(skill_dir: str, installed_dir: str) -> str:
719 """Render unique `rm -rf` target arguments for pre-install cleanup."""
720 targets = [_sh_quote(skill_dir)]
721 if installed_dir != skill_dir:
722 targets.append(_sh_quote(installed_dir))
723 return " ".join(targets)
726def materialize_runtime_mount_tree(
727 root: str | Path,
728 manifest: Manifest,
729 lockfile: Lockfile,
730 *,
731 raw_manifest_text: str,
732 raw_lock_text: str,
733) -> None:
734 """Write the host-side runtime files expected by the generated bind mounts."""
735 root_path = Path(root).resolve()
736 state_root = root_path / DEFAULT_OPENCLAW_CONFIG_DIR.removeprefix("./")
737 workspace_root = root_path / DEFAULT_OPENCLAW_WORKSPACE_DIR.removeprefix("./")
738 payload = render_runtime_payload(
739 manifest,
740 lockfile,
741 raw_manifest_text=raw_manifest_text,
742 raw_lock_text=raw_lock_text,
743 )
744 directories = payload["directories"]
745 files = payload["files"]
746 if not isinstance(directories, list) or not isinstance(files, dict): 746 ↛ 747line 746 didn't jump to line 747 because the condition on line 746 was never true
747 raise TypeError("Runtime payload shape is invalid.")
748 for directory in directories:
749 host_path = _host_mount_path_for_container_path(
750 str(directory),
751 manifest,
752 state_root=state_root,
753 workspace_root=workspace_root,
754 )
755 if host_path is not None:
756 host_path.mkdir(parents=True, exist_ok=True)
757 agent_dir = _host_mount_path_for_container_path(
758 manifest.openclaw.agent_dir(),
759 manifest,
760 state_root=state_root,
761 workspace_root=workspace_root,
762 )
763 if agent_dir is not None: 763 ↛ 765line 763 didn't jump to line 765 because the condition on line 763 was always true
764 agent_dir.mkdir(parents=True, exist_ok=True)
765 for container_path, content in sorted(files.items()):
766 host_path = _host_mount_path_for_container_path(
767 str(container_path),
768 manifest,
769 state_root=state_root,
770 workspace_root=workspace_root,
771 )
772 if host_path is None or not isinstance(content, str):
773 continue
774 host_path.parent.mkdir(parents=True, exist_ok=True)
775 if _should_preserve_existing_catalog_skill_stub(
776 host_path,
777 container_path=str(container_path),
778 placeholder_paths=_catalog_skill_placeholder_paths(manifest),
779 ):
780 continue
781 host_path.write_text(content, encoding="utf-8")
784def _host_mount_path_for_container_path(
785 container_path: str,
786 manifest: Manifest,
787 *,
788 state_root: Path,
789 workspace_root: Path,
790) -> Path | None:
791 """Map one container path from the runtime payload to the exported host tree."""
792 container = PurePosixPath(container_path)
793 workspace = PurePosixPath(manifest.openclaw.workspace)
794 state_dir = PurePosixPath(manifest.openclaw.state_dir)
795 try:
796 relative = container.relative_to(workspace)
797 except ValueError:
798 pass
799 else:
800 return _join_posix_relative(workspace_root, relative)
801 try:
802 relative = container.relative_to(state_dir)
803 except ValueError:
804 return None
805 return _join_posix_relative(state_root, relative)
808def _join_posix_relative(root: Path, relative: PurePosixPath) -> Path:
809 """Join a POSIX-style relative path onto a host path."""
810 if not relative.parts:
811 return root
812 return root.joinpath(*relative.parts)