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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 22:06 +0000
1"""Constants and configuration builders for augint-shell.
3Encodes all docker-compose.yml configuration as Python, so no compose file is needed.
4"""
6from __future__ import annotations
8import logging
9import os
10import re
11from hashlib import sha1
12from pathlib import Path
13from typing import TYPE_CHECKING
15if TYPE_CHECKING:
16 from docker.types import Mount
18logger = logging.getLogger(__name__)
20# =============================================================================
21# Image defaults
22# =============================================================================
23DEFAULT_IMAGE = "svange/augint-shell"
24CONTAINER_PREFIX = "augint-shell"
25SHM_SIZE = "2g"
27# =============================================================================
28# Volume names (prefixed to avoid collisions)
29# =============================================================================
30UV_CACHE_VOLUME = "augint-shell-uv-cache"
31GH_CONFIG_VOLUME = "augint-shell-gh-config"
34def uv_venv_path(repo_name: str, worktree_name: str | None = None) -> str:
35 """Return the ``UV_PROJECT_ENVIRONMENT`` path for a repo.
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}"
47NPM_CACHE_VOLUME = "augint-shell-npm-cache"
48NODE_MODULES_VOLUME_PREFIX = "augint-shell-node-modules-"
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.
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.
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}"
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"
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]
121# Deterministic dev port mapping (avoids Chrome debug range 40000-60000)
122DEV_PORT_RANGE_START = 10000
123DEV_PORT_RANGE_SIZE = 30000 # 10000-39999
125# =============================================================================
126# Bedrock defaults
127# =============================================================================
128DEFAULT_BEDROCK_MODEL = "us.anthropic.claude-sonnet-4-20250514-v1:0"
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)
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"
147# =============================================================================
148# Docker network
149# =============================================================================
150LLM_NETWORK = "augint-shell-llm"
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"
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())
165def unique_project_name(path: Path, project_name: str | None = None) -> str:
166 """Build a path-stable project identifier for container naming.
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}"
177def dev_container_name(project_name: str, project_dir: Path | None = None) -> str:
178 """Build the dev container name for a project.
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"
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.
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)
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.
212 Required mounts are always included. Optional mounts are skipped
213 if the source path doesn't exist on the host.
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
223 mounts: list[Mount] = []
224 home = Path.home()
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 )
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)
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 ]
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)
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 )
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 )
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 )
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 )
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 )
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 )
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 )
371 return mounts
374def _find_gh_config_dir() -> Path | None:
375 """Find the gh CLI config directory.
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
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
394 return None
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.
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
410 layers: dict[str, str | None] = {}
412 global_path = Path.home() / ".augint" / ".env"
413 if global_path.is_file():
414 layers.update(dotenv_values(global_path))
416 if env_file is None:
417 return layers
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))
424 if env_file.is_file():
425 layers.update(dotenv_values(env_file))
427 return layers
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)
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.
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.
470 Priority (highest wins): extra_env > CLI flags > ``./.env`` (when ``--env``)
471 > ``~/.augint/.env`` > host ``os.environ`` (allowlisted) > defaults.
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``.
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.
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``.
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)
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)
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 )
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 }
515 # Mirror AWS_REGION to AWS_DEFAULT_REGION so both Node.js SDK paths resolve
516 env["AWS_DEFAULT_REGION"] = env["AWS_REGION"]
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)
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
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
549 if team_mode:
550 env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
552 for var in _SHARED_ENV_PASSTHROUGH:
553 val = _resolve(var)
554 if val:
555 env[var] = val
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
563 if extra_env:
564 env.update(extra_env)
566 return env
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)
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.
585 Loads layered .env files (``~/.augint/.env`` then *env_file*),
586 then falls back to host environment variables.
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)
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 }
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
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
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"
626 return env
629def build_n8n_mounts(
630 workflow_dir: Path | None = None,
631) -> list[Mount]:
632 """Build the mount list for the n8n container.
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
640 home = Path.home()
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 ]
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)
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 )
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 )
687 return mounts