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

1"""docker-compose generation for OpenClawenv.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Sequence 

6from dataclasses import dataclass 

7import json 

8from pathlib import Path 

9import secrets 

10 

11from pathlib import PurePosixPath 

12 

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 

18 

19 

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) 

64 

65 

66@dataclass(slots=True, frozen=True) 

67class AllBotsComposeSpec: 

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

69 

70 slug: str 

71 manifest: Manifest 

72 image_tag: str 

73 

74 

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" 

78 

79 

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" 

83 

84 

85def all_bots_compose_filename() -> str: 

86 """Return the shared compose filename for all managed bots.""" 

87 return ALL_BOTS_COMPOSE_FILENAME 

88 

89 

90def all_bots_env_filename() -> str: 

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

92 return ALL_BOTS_ENV_FILENAME 

93 

94 

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" 

98 

99 

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" 

103 

104 

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" 

124 

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" 

247 

248 

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" 

390 

391 

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") 

395 

396 

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" 

478 

479 

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" 

502 

503 

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") 

507 

508 

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 

515 

516 

517def generate_gateway_token() -> str: 

518 """Return a random gateway token for generated local stacks.""" 

519 return secrets.token_urlsafe(24) 

520 

521 

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) 

533 

534 

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 [] 

548 

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 

591 

592 

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 } 

604 

605 

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 

620 

621 

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 

633 

634 

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 } 

646 

647 

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}" 

651 

652 

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" 

656 

657 

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" 

663 

664 

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 

670 

671 

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" 

677 

678 

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 ] 

688 

689 

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()] 

693 

694 

695def _quoted(value: str) -> str: 

696 """Return a YAML-safe quoted scalar using JSON string escaping.""" 

697 return json.dumps(value) 

698 

699 

700def _sh_quote(value: str) -> str: 

701 """Return a POSIX-shell-safe single-quoted string.""" 

702 return "'" + value.replace("'", "'\"'\"'") + "'" 

703 

704 

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 ) 

716 

717 

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) 

724 

725 

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") 

782 

783 

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) 

806 

807 

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)