Coverage for src/ai_shell/defaults.py: 97%

241 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-05 22:06 +0000

1"""Constants and configuration builders for augint-shell. 

2 

3Encodes all docker-compose.yml configuration as Python, so no compose file is needed. 

4""" 

5 

6from __future__ import annotations 

7 

8import logging 

9import os 

10import re 

11from hashlib import sha1 

12from pathlib import Path 

13from typing import TYPE_CHECKING 

14 

15if TYPE_CHECKING: 

16 from docker.types import Mount 

17 

18logger = logging.getLogger(__name__) 

19 

20# ============================================================================= 

21# Image defaults 

22# ============================================================================= 

23DEFAULT_IMAGE = "svange/augint-shell" 

24CONTAINER_PREFIX = "augint-shell" 

25SHM_SIZE = "2g" 

26 

27# ============================================================================= 

28# Volume names (prefixed to avoid collisions) 

29# ============================================================================= 

30UV_CACHE_VOLUME = "augint-shell-uv-cache" 

31GH_CONFIG_VOLUME = "augint-shell-gh-config" 

32 

33 

34def uv_venv_path(repo_name: str, worktree_name: str | None = None) -> str: 

35 """Return the ``UV_PROJECT_ENVIRONMENT`` path for a repo. 

36 

37 Matches the venv isolation scheme used by both ``--multi`` and ``--team`` 

38 modes. When *worktree_name* is set, appends ``-wt-{worktree_name}`` to 

39 isolate worktree venvs. 

40 """ 

41 suffix = repo_name 

42 if worktree_name: 

43 suffix = f"{repo_name}-wt-{worktree_name}" 

44 return f"/root/.cache/uv/venvs/{suffix}" 

45 

46 

47NPM_CACHE_VOLUME = "augint-shell-npm-cache" 

48NODE_MODULES_VOLUME_PREFIX = "augint-shell-node-modules-" 

49 

50 

51def node_modules_volume_name( 

52 repo_name: str, 

53 worktree_name: str | None = None, 

54 subpath: str | None = None, 

55) -> str: 

56 """Per-project named volume that overlays a node_modules directory. 

57 

58 Mirrors the UV venv-isolation scheme so the container's Linux node_modules 

59 never collides with the host's (e.g. Windows-built) node_modules in the 

60 bind-mounted project directory. 

61 

62 When *subpath* is given (e.g. ``"apps/web"``), a sanitized slug is appended 

63 so monorepos can isolate each workspace's node_modules independently. 

64 """ 

65 suffix = repo_name 

66 if worktree_name: 

67 suffix = f"{repo_name}-wt-{worktree_name}" 

68 if subpath: 

69 suffix = f"{suffix}-{_sanitize_name(subpath.lower())}" 

70 return f"{NODE_MODULES_VOLUME_PREFIX}{suffix}" 

71 

72 

73PRE_COMMIT_CACHE_VOLUME = "augint-shell-pre-commit-cache" 

74PRE_COMMIT_CACHE_PATH = "/root/.cache/pre-commit-container" 

75OLLAMA_DATA_VOLUME = "augint-shell-ollama-data" 

76WEBUI_DATA_VOLUME = "augint-shell-webui-data" 

77N8N_DATA_VOLUME = "augint-shell-n8n-data" 

78WHISPER_DATA_VOLUME = "augint-shell-whisper-cache" 

79VOICE_AGENT_DATA_VOLUME = "augint-shell-voice-agent-data" 

80COMFYUI_DATA_VOLUME = "augint-shell-comfyui-data" 

81 

82# ============================================================================= 

83# LLM defaults 

84# ============================================================================= 

85OLLAMA_IMAGE = "ollama/ollama" 

86WEBUI_IMAGE = "ghcr.io/open-webui/open-webui:main" 

87KOKORO_IMAGE_CPU = "ghcr.io/remsky/kokoro-fastapi-cpu:latest" 

88KOKORO_IMAGE_GPU = "ghcr.io/remsky/kokoro-fastapi-gpu:latest" 

89N8N_IMAGE = "docker.n8n.io/n8nio/n8n" 

90WHISPER_IMAGE_CPU = "ghcr.io/speaches-ai/speaches:latest-cpu" 

91WHISPER_IMAGE_GPU = "ghcr.io/speaches-ai/speaches:latest-cuda" 

92# Voice-agent image is built locally from docker/voice-agent/ on first 

93# ensure call. Not pulled. The local tag keeps `images.get` fast once built. 

94VOICE_AGENT_IMAGE = "augint-shell/voice-agent:local" 

95# ComfyUI: ai-dock/comfyui is actively maintained and exposes PROVISIONING_SCRIPT 

96# which we use to download FLUX.1-dev + SDXL on first boot. GPU-only (no CPU variant). 

97COMFYUI_IMAGE = "ghcr.io/ai-dock/comfyui:latest-cuda" 

98# Model slots (RTX 4090-sized, validated April 2026). Primary = best available for 

99# the role; secondary = best uncensored alternative. See README "Local LLM stack" 

100# and the generated .ai-shell.yaml for per-slot rationale and caveats. 

101DEFAULT_PRIMARY_CHAT_MODEL = "qwen3.5:27b" 

102DEFAULT_SECONDARY_CHAT_MODEL = "huihui_ai/qwen3.5-abliterated:27b" 

103DEFAULT_PRIMARY_CODING_MODEL = "qwen3-coder:30b-a3b-q4_K_M" 

104DEFAULT_SECONDARY_CODING_MODEL = "huihui_ai/qwen3-coder-abliterated:30b-a3b-instruct-q4_K_M" 

105DEFAULT_EXTRA_MODELS: list[str] = [ 

106 "qwen3.5:9b", # ~6.6 GB mid-range chat, fast + capable 

107 "devstral:24b", # ~15 GB Mistral agentic coding, dense 24B 

108] 

109DEFAULT_CONTEXT_SIZE = 32768 

110DEFAULT_OLLAMA_PORT = 11434 

111DEFAULT_WEBUI_PORT = 3000 

112DEFAULT_KOKORO_PORT = 8880 

113DEFAULT_N8N_PORT = 5678 

114DEFAULT_WHISPER_PORT = 8001 

115DEFAULT_WHISPER_MODEL = "Systran/faster-distil-whisper-large-v3" 

116DEFAULT_VOICE_AGENT_PORT = 8010 

117DEFAULT_COMFYUI_PORT = 8188 

118DEFAULT_KOKORO_VOICE = "af_bella" 

119DEFAULT_DEV_PORTS = [3000, 4096, 4200, 5000, 5173, 5678, 8000, 8080, 8888, 19432, 31415] 

120 

121# Deterministic dev port mapping (avoids Chrome debug range 40000-60000) 

122DEV_PORT_RANGE_START = 10000 

123DEV_PORT_RANGE_SIZE = 30000 # 10000-39999 

124 

125# ============================================================================= 

126# Bedrock defaults 

127# ============================================================================= 

128DEFAULT_BEDROCK_MODEL = "us.anthropic.claude-sonnet-4-20250514-v1:0" 

129 

130# ============================================================================= 

131# Ollama GPU defaults 

132# ============================================================================= 

133OLLAMA_VRAM_BUFFER_BYTES = 1 * 1024**3 # 1 GiB safety buffer reserved as overhead 

134OLLAMA_CPU_SHARES = 1024 # Docker CPU scheduling priority (default 0 = fair-share) 

135 

136# ============================================================================= 

137# Container names 

138# ============================================================================= 

139OLLAMA_CONTAINER = "augint-shell-ollama" 

140WEBUI_CONTAINER = "augint-shell-webui" 

141KOKORO_CONTAINER = "augint-shell-kokoro" 

142N8N_CONTAINER = "augint-shell-n8n" 

143WHISPER_CONTAINER = "augint-shell-whisper" 

144VOICE_AGENT_CONTAINER = "augint-shell-voice-agent" 

145COMFYUI_CONTAINER = "augint-shell-comfyui" 

146 

147# ============================================================================= 

148# Docker network 

149# ============================================================================= 

150LLM_NETWORK = "augint-shell-llm" 

151 

152 

153def _sanitize_name(name: str) -> str: 

154 """Convert an arbitrary string into a Docker-safe slug.""" 

155 name = re.sub(r"[^a-z0-9-]", "-", name) 

156 name = re.sub(r"-+", "-", name) 

157 return name.strip("-") or "project" 

158 

159 

160def sanitize_project_name(path: Path) -> str: 

161 """Derive a safe project slug from a directory basename.""" 

162 return _sanitize_name(path.resolve().name.lower()) 

163 

164 

165def unique_project_name(path: Path, project_name: str | None = None) -> str: 

166 """Build a path-stable project identifier for container naming. 

167 

168 The basename remains human-readable while a short path hash prevents 

169 collisions between repos with the same leaf directory name. 

170 """ 

171 slug = _sanitize_name((project_name or path.resolve().name).lower()) 

172 # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1 

173 digest = sha1(str(path.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:8] 

174 return f"{slug}-{digest}" 

175 

176 

177def dev_container_name(project_name: str, project_dir: Path | None = None) -> str: 

178 """Build the dev container name for a project. 

179 

180 When *project_dir* is provided, the full resolved path is folded into the 

181 name to avoid collisions across nested repo layouts. Without it, the legacy 

182 basename-only format is preserved for compatibility. 

183 """ 

184 if project_dir is None: 

185 return f"{CONTAINER_PREFIX}-{project_name}-dev" 

186 return f"{CONTAINER_PREFIX}-{unique_project_name(project_dir, project_name)}-dev" 

187 

188 

189def project_dev_port( 

190 project_dir: Path, container_port: int, project_name: str | None = None 

191) -> int: 

192 """Map a container port to a stable per-project host port. 

193 

194 Uses the same project identity as container naming (unique_project_name) 

195 combined with the container port to produce a deterministic host port 

196 in the 10000-39999 range. Different projects get different host ports 

197 for the same container port, so multiple projects can run simultaneously. 

198 """ 

199 slug = unique_project_name(project_dir, project_name) 

200 # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1 

201 digest = sha1(f"{slug}:{container_port}".encode(), usedforsecurity=False).hexdigest() 

202 return DEV_PORT_RANGE_START + (int(digest[:8], 16) % DEV_PORT_RANGE_SIZE) 

203 

204 

205def build_dev_mounts( 

206 project_dir: Path, 

207 project_name: str, 

208 extra_node_modules_paths: list[str] | None = None, 

209) -> list[Mount]: 

210 """Build the full mount list matching docker-compose.yml dev service. 

211 

212 Required mounts are always included. Optional mounts are skipped 

213 if the source path doesn't exist on the host. 

214 

215 *extra_node_modules_paths* is a list of glob patterns relative to 

216 *project_dir*. Each glob match (that is a directory) gets its own named 

217 volume overlaid at ``{match}/node_modules`` inside the container, so each 

218 workspace in a monorepo isolates its Linux node_modules from the host 

219 bind mount the same way the root overlay does. 

220 """ 

221 from docker.types import Mount 

222 

223 mounts: list[Mount] = [] 

224 home = Path.home() 

225 

226 # Required: project directory (rw, delegated) 

227 mounts.append( 

228 Mount( 

229 target=f"/root/projects/{project_name}", 

230 source=str(project_dir.resolve()), 

231 type="bind", 

232 read_only=False, 

233 consistency="delegated", 

234 ) 

235 ) 

236 

237 # Ensure directories that tools need for persistent config exist on the 

238 # host so bind mounts aren't silently skipped. 

239 for d in (".pi", ".augint", ".plannotator"): 

240 (home / d).mkdir(parents=True, exist_ok=True) 

241 

242 # Optional bind mounts — skip if source doesn't exist 

243 optional_binds: list[tuple[Path, str, bool]] = [ 

244 (home / ".config", "/root/.config", False), 

245 (home / ".codex", "/root/.codex", False), 

246 (home / ".claude", "/root/.claude", False), 

247 (home / ".claude.json", "/root/.claude.json", False), 

248 (home / ".pi", "/root/.pi", False), 

249 (home / ".augint", "/root/.augint", False), 

250 (home / ".plannotator", "/root/.plannotator", False), 

251 (home / ".ssh", "/root/.ssh", True), 

252 (home / ".gitconfig", "/root/.gitconfig.windows", True), 

253 (home / ".aws", "/root/.aws", False), 

254 ] 

255 

256 for source, target, read_only in optional_binds: 

257 if source.exists(): 

258 mounts.append( 

259 Mount( 

260 target=target, 

261 source=str(source), 

262 type="bind", 

263 read_only=read_only, 

264 ) 

265 ) 

266 else: 

267 logger.debug("Skipping optional mount (not found): %s", source) 

268 

269 # gh CLI config: bind-mount the host path when found (Linux/Mac/WSL2), 

270 # otherwise use a named volume so auth persists across container recreations 

271 # (needed on Windows where gh stores tokens in keyring, not a file). 

272 gh_config = _find_gh_config_dir() 

273 if gh_config is not None: 

274 mounts.append( 

275 Mount( 

276 target="/root/.config/gh", 

277 source=str(gh_config), 

278 type="bind", 

279 read_only=False, 

280 ) 

281 ) 

282 else: 

283 mounts.append( 

284 Mount( 

285 target="/root/.config/gh", 

286 source=GH_CONFIG_VOLUME, 

287 type="volume", 

288 ) 

289 ) 

290 

291 # Optional: Docker socket 

292 docker_sock = Path("/var/run/docker.sock") 

293 if docker_sock.exists(): 

294 mounts.append( 

295 Mount( 

296 target="/var/run/docker.sock", 

297 source=str(docker_sock), 

298 type="bind", 

299 read_only=True, 

300 ) 

301 ) 

302 

303 # Named volume: uv cache (shared across all projects) 

304 mounts.append( 

305 Mount( 

306 target="/root/.cache/uv", 

307 source=UV_CACHE_VOLUME, 

308 type="volume", 

309 ) 

310 ) 

311 

312 # Named volume: npm cache (shared across all projects) 

313 mounts.append( 

314 Mount( 

315 target="/root/.npm", 

316 source=NPM_CACHE_VOLUME, 

317 type="volume", 

318 ) 

319 ) 

320 

321 # Named volume: pre-commit cache (shared across all projects). 

322 # Isolates the container's hook environments from the Windows host's 

323 # ~/.cache/pre-commit so the two installs don't clobber each other. 

324 mounts.append( 

325 Mount( 

326 target=PRE_COMMIT_CACHE_PATH, 

327 source=PRE_COMMIT_CACHE_VOLUME, 

328 type="volume", 

329 ) 

330 ) 

331 

332 # Per-project named volume overlaying node_modules. Without this the 

333 # container's Linux `npm ci` would write into the bind-mounted host 

334 # project dir and collide with host-built (e.g. Windows) node_modules. 

335 # npm has no equivalent of UV_PROJECT_ENVIRONMENT, so we isolate at the 

336 # mount layer instead. Host node_modules underneath stays untouched. 

337 mounts.append( 

338 Mount( 

339 target=f"/root/projects/{project_name}/node_modules", 

340 source=node_modules_volume_name(project_name), 

341 type="volume", 

342 ) 

343 ) 

344 

345 # Monorepo workspaces: overlay an isolated named volume on each matched 

346 # workspace's node_modules so per-app installs (npm/pnpm/yarn workspaces) 

347 # land in container-only storage rather than the host bind mount. 

348 project_root = project_dir.resolve() 

349 seen_targets: set[str] = {f"/root/projects/{project_name}/node_modules"} 

350 for pattern in extra_node_modules_paths or []: 

351 for match in sorted(project_root.glob(pattern)): 

352 if not match.is_dir(): 

353 continue 

354 try: 

355 rel = match.relative_to(project_root) 

356 except ValueError: 

357 continue 

358 rel_posix = rel.as_posix() 

359 target = f"/root/projects/{project_name}/{rel_posix}/node_modules" 

360 if target in seen_targets: 

361 continue 

362 seen_targets.add(target) 

363 mounts.append( 

364 Mount( 

365 target=target, 

366 source=node_modules_volume_name(project_name, subpath=rel_posix), 

367 type="volume", 

368 ) 

369 ) 

370 

371 return mounts 

372 

373 

374def _find_gh_config_dir() -> Path | None: 

375 """Find the gh CLI config directory. 

376 

377 Checks the standard Linux/Mac path (~/.config/gh) first, then falls back 

378 to the Windows APPDATA path for WSL2 environments where gh is installed on 

379 the Windows side (%APPDATA%\\GitHub CLI\\). 

380 """ 

381 linux_path = Path.home() / ".config" / "gh" 

382 if linux_path.exists(): 

383 return linux_path 

384 

385 # WSL2 fallback: APPDATA is set as a Windows path (e.g. C:\Users\foo\AppData\Roaming) 

386 appdata = os.environ.get("APPDATA", "") 

387 if appdata and ":" in appdata: 

388 drive, rest = appdata.split(":", 1) 

389 wsl_appdata = Path(f"/mnt/{drive.lower()}{rest.replace(chr(92), '/')}") 

390 windows_path = wsl_appdata / "GitHub CLI" 

391 if windows_path.exists(): 

392 return windows_path 

393 

394 return None 

395 

396 

397def _load_layered_dotenv( 

398 project_dir: Path | None = None, 

399 env_file: Path | None = None, 

400) -> dict[str, str | None]: 

401 """Load layered .env files. 

402 

403 ``~/.augint/.env`` always loads (global augint suite config). The project 

404 ``./.env`` and explicit *env_file* only load when *env_file* is given 

405 (i.e. user passed ``--env`` on the CLI). Later layers override earlier 

406 ones. 

407 """ 

408 from dotenv import dotenv_values 

409 

410 layers: dict[str, str | None] = {} 

411 

412 global_path = Path.home() / ".augint" / ".env" 

413 if global_path.is_file(): 

414 layers.update(dotenv_values(global_path)) 

415 

416 if env_file is None: 

417 return layers 

418 

419 if project_dir is not None: 

420 project_path = project_dir / ".env" 

421 if project_path.is_file() and project_path.resolve() != env_file.resolve(): 

422 layers.update(dotenv_values(project_path)) 

423 

424 if env_file.is_file(): 

425 layers.update(dotenv_values(env_file)) 

426 

427 return layers 

428 

429 

430_SHARED_ENV_PASSTHROUGH = ( 

431 "ANTHROPIC_API_KEY", 

432 "OPENAI_API_KEY", 

433 "PRIMARY_CHAT_MODEL", 

434 "SECONDARY_CHAT_MODEL", 

435 "PRIMARY_CODING_MODEL", 

436 "SECONDARY_CODING_MODEL", 

437 "CONTEXT_SIZE", 

438 "OLLAMA_PORT", 

439 "WEBUI_PORT", 

440 "KOKORO_PORT", 

441 "WHISPER_PORT", 

442 "N8N_PORT", 

443 "COMFYUI_PORT", 

444 "OPENCODE_SERVER_PASSWORD", 

445 "OPENCODE_SERVER_USERNAME", 

446 "PI_STUDIO_HOST", 

447) 

448 

449 

450def build_dev_environment( 

451 extra_env: dict[str, str] | None = None, 

452 project_dir: Path | None = None, 

453 *, 

454 project_name: str = "", 

455 bedrock: bool = False, 

456 aws_profile: str = "", 

457 aws_region: str = "", 

458 bedrock_profile: str = "", 

459 bedrock_region: str = "", 

460 openai_profile: str = "", 

461 team_mode: bool = False, 

462 env_file: Path | None = None, 

463) -> dict[str, str]: 

464 """Build environment variables matching docker-compose.yml dev service. 

465 

466 ``~/.augint/.env`` always loads (global augint suite config). The project 

467 ``./.env`` only loads when *env_file* is given (``--env`` on the CLI); 

468 when loaded, **all** of its keys flow through to the container. 

469 

470 Priority (highest wins): extra_env > CLI flags > ``./.env`` (when ``--env``) 

471 > ``~/.augint/.env`` > host ``os.environ`` (allowlisted) > defaults. 

472 

473 GitHub auth defaults to SSO via the ``~/.config/gh`` bind mount. To use a 

474 PAT instead, put ``GH_TOKEN`` in ``.env`` and pass ``--env``. 

475 

476 When *bedrock* is True, ``CLAUDE_CODE_USE_BEDROCK=1`` is injected and 

477 *bedrock_profile* (if set) overrides ``AWS_PROFILE`` so the LLM provider 

478 authenticates with the correct AWS account. 

479 

480 When *openai_profile* is set, the suffixed env vars 

481 ``OPENAI_API_KEY_{NAME}`` and ``OPENAI_ORG_ID_{NAME}`` are resolved from 

482 ``.env`` and injected as ``OPENAI_API_KEY`` / ``OPENAI_ORG_ID``. 

483 

484 When *team_mode* is True, ``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`` is 

485 injected to enable Claude Code's Agent Teams feature. 

486 """ 

487 dotenv = _load_layered_dotenv(project_dir, env_file=env_file) 

488 

489 def _resolve(key: str, default: str = "") -> str: 

490 """Resolve a value: .env > os.environ > default.""" 

491 dotenv_val = dotenv.get(key) 

492 if dotenv_val is not None and dotenv_val != "": 

493 return dotenv_val 

494 return os.environ.get(key, default) 

495 

496 _CONTAINER_BASE_PATH = ( 

497 "/root/.local/bin:/root/.opencode/bin:" 

498 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 

499 ) 

500 

501 env: dict[str, str] = { 

502 "PATH": _CONTAINER_BASE_PATH, 

503 "AWS_PROFILE": aws_profile or _resolve("AWS_PROFILE"), 

504 "AWS_REGION": aws_region or _resolve("AWS_REGION", "us-east-1"), 

505 "AWS_PAGER": "", 

506 "HUSKY": "0", 

507 "IS_SANDBOX": "1", 

508 "PRE_COMMIT_HOME": PRE_COMMIT_CACHE_PATH, 

509 "PI_STUDIO_HOST": "0.0.0.0", # nosec B104 

510 "PLANNOTATOR_REMOTE": "1", 

511 "PLANNOTATOR_PORT": "19432", 

512 "PLANNOTATOR_BROWSER": "echo", 

513 } 

514 

515 # Mirror AWS_REGION to AWS_DEFAULT_REGION so both Node.js SDK paths resolve 

516 env["AWS_DEFAULT_REGION"] = env["AWS_REGION"] 

517 

518 # Isolate UV venvs per-project within the shared cache volume. 

519 # Overrides Dockerfile default of /root/.cache/uv/venvs/project. 

520 if project_name: 

521 env["UV_PROJECT_ENVIRONMENT"] = uv_venv_path(project_name) 

522 

523 if bedrock: 

524 env["CLAUDE_CODE_USE_BEDROCK"] = "1" 

525 if bedrock_profile: 

526 env["AWS_PROFILE"] = bedrock_profile 

527 resolved_bedrock_region = ( 

528 bedrock_region or _resolve("AWS_BEDROCK_REGION") or env["AWS_REGION"] 

529 ) 

530 if resolved_bedrock_region != env["AWS_REGION"]: 

531 env["AWS_REGION"] = resolved_bedrock_region 

532 env["AWS_DEFAULT_REGION"] = resolved_bedrock_region 

533 

534 if openai_profile: 

535 suffix = openai_profile.upper() 

536 key_var = f"OPENAI_API_KEY_{suffix}" 

537 api_key = dotenv.get(key_var) 

538 if not api_key: 

539 raise ValueError( 

540 f"OpenAI profile '{openai_profile}' requires {key_var} in " 

541 "~/.augint/.env, or pass --env to load it from ./.env" 

542 ) 

543 env["OPENAI_API_KEY"] = api_key 

544 org_var = f"OPENAI_ORG_ID_{suffix}" 

545 org_id = dotenv.get(org_var) 

546 if org_id: 

547 env["OPENAI_ORG_ID"] = org_id 

548 

549 if team_mode: 

550 env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1" 

551 

552 for var in _SHARED_ENV_PASSTHROUGH: 

553 val = _resolve(var) 

554 if val: 

555 env[var] = val 

556 

557 # Pass through every var loaded from .env, except keys already populated 

558 # above (which preserves CLI-flag wins for AWS_PROFILE etc.). 

559 for key, value in dotenv.items(): 

560 if value is not None and value != "" and key not in env: 

561 env[key] = value 

562 

563 if extra_env: 

564 env.update(extra_env) 

565 

566 return env 

567 

568 

569def _resolve_env(dotenv: dict[str, str | None], key: str, default: str = "") -> str: 

570 """Resolve a value: dotenv > os.environ > default.""" 

571 dotenv_val = dotenv.get(key) 

572 if dotenv_val is not None and dotenv_val != "": 

573 return dotenv_val 

574 return os.environ.get(key, default) 

575 

576 

577def build_n8n_environment( 

578 env_file: Path | None = None, 

579 *, 

580 aws_profile: str = "", 

581 aws_region: str = "", 

582) -> dict[str, str]: 

583 """Build environment variables for the n8n workflow automation container. 

584 

585 Loads layered .env files (``~/.augint/.env`` then *env_file*), 

586 then falls back to host environment variables. 

587 

588 Service discovery URLs use internal Docker network hostnames so n8n 

589 workflows can reference them via ``{{ $env.OLLAMA_BASE_URL }}`` etc. 

590 """ 

591 dotenv = _load_layered_dotenv(env_file=env_file) 

592 

593 env: dict[str, str] = { 

594 # Disable secure-cookie so the UI works over plain http://localhost. 

595 "N8N_SECURE_COOKIE": "false", 

596 # Service discovery (internal Docker network URLs). 

597 "OLLAMA_BASE_URL": f"http://{OLLAMA_CONTAINER}:11434", 

598 "KOKORO_BASE_URL": f"http://{KOKORO_CONTAINER}:8880", 

599 "WHISPER_BASE_URL": f"http://{WHISPER_CONTAINER}:8000", 

600 "VOICE_AGENT_BASE_URL": f"http://{VOICE_AGENT_CONTAINER}:8000", 

601 "WEBUI_BASE_URL": f"http://{WEBUI_CONTAINER}:8080", 

602 "COMFYUI_BASE_URL": f"http://{COMFYUI_CONTAINER}:8188", 

603 } 

604 

605 # AWS credentials 

606 aws_prof = aws_profile or _resolve_env(dotenv, "AWS_PROFILE") 

607 aws_reg = aws_region or _resolve_env(dotenv, "AWS_REGION", "us-east-1") 

608 if aws_prof: 

609 env["AWS_PROFILE"] = aws_prof 

610 env["AWS_REGION"] = aws_reg 

611 env["AWS_DEFAULT_REGION"] = aws_reg 

612 

613 # API keys — only include when non-empty. 

614 for key in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY"): 

615 val = _resolve_env(dotenv, key) 

616 if val: 

617 env[key] = val 

618 

619 if env_file is not None: 

620 gh_token = _resolve_env(dotenv, "GH_TOKEN") 

621 if gh_token: 

622 env["GH_TOKEN"] = gh_token 

623 env["GITHUB_TOKEN"] = gh_token 

624 env["GITHUB_MODELS_BASE_URL"] = "https://models.inference.ai.azure.com" 

625 

626 return env 

627 

628 

629def build_n8n_mounts( 

630 workflow_dir: Path | None = None, 

631) -> list[Mount]: 

632 """Build the mount list for the n8n container. 

633 

634 n8n runs as user ``node`` (UID 1000). Credential directories are mounted 

635 read-only under ``/home/node/`` so the AWS and GitHub CLIs resolve auth 

636 the same way the dev container does. 

637 """ 

638 from docker.types import Mount 

639 

640 home = Path.home() 

641 

642 mounts: list[Mount] = [ 

643 # Persistent data (workflows, credentials DB, settings). 

644 Mount( 

645 target="/home/node/.n8n", 

646 source=N8N_DATA_VOLUME, 

647 type="volume", 

648 ), 

649 ] 

650 

651 # Optional credential bind mounts (read-only). 

652 aws_dir = home / ".aws" 

653 if aws_dir.exists(): 

654 mounts.append( 

655 Mount( 

656 target="/home/node/.aws", 

657 source=str(aws_dir), 

658 type="bind", 

659 read_only=True, 

660 ) 

661 ) 

662 else: 

663 logger.debug("Skipping n8n AWS mount (not found): %s", aws_dir) 

664 

665 gh_config = _find_gh_config_dir() 

666 if gh_config is not None: 

667 mounts.append( 

668 Mount( 

669 target="/home/node/.config/gh", 

670 source=str(gh_config), 

671 type="bind", 

672 read_only=True, 

673 ) 

674 ) 

675 

676 # Starter workflow templates (read-only bind mount). 

677 if workflow_dir is not None and workflow_dir.is_dir(): 

678 mounts.append( 

679 Mount( 

680 target="/workflows", 

681 source=str(workflow_dir), 

682 type="bind", 

683 read_only=True, 

684 ) 

685 ) 

686 

687 return mounts