Coverage for src/ai_shell/defaults.py: 96%
310 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 01:05 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 01:05 +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 collections.abc import Callable
18 from docker.types import Mount
20logger = logging.getLogger(__name__)
22# =============================================================================
23# Image defaults
24# =============================================================================
25DEFAULT_IMAGE = "svange/augint-shell"
26CONTAINER_PREFIX = "augint-shell"
27SHM_SIZE = "2g"
29# =============================================================================
30# Volume names (prefixed to avoid collisions)
31# =============================================================================
32UV_CACHE_VOLUME = "augint-shell-uv-cache"
33GH_CONFIG_VOLUME = "augint-shell-gh-config"
35# Prefix for shared named volumes that back isolated home configs (e.g.
36# ~/.claude, ~/.codex). One volume per config dir, shared across all projects
37# on the host, so tool state persists in Docker instead of the host home dir.
38HOME_CONFIG_VOLUME_PREFIX = "augint-shell-home-"
40# Home configs that are single files rather than directories. A named volume
41# can only back a directory, so these can't be volume-isolated; when named in
42# the isolate set their host bind is dropped instead (config stays
43# container-local).
44_SINGLE_FILE_HOME_CONFIGS = frozenset({".claude.json", ".gitconfig", ".zapierrc"})
47def home_config_volume_name(config_name: str) -> str:
48 """Shared named-volume name backing an isolated home config directory.
50 ``.claude`` -> ``augint-shell-home-claude``. The volume is shared across
51 all projects on the host (mirroring how the host ``~/.claude`` bind is
52 shared today), so a single Claude/codex config persists in Docker instead
53 of being written into the host home directory.
54 """
55 return f"{HOME_CONFIG_VOLUME_PREFIX}{_sanitize_name(config_name.lstrip('.').lower())}"
58def uv_venv_path(repo_name: str, worktree_name: str | None = None) -> str:
59 """Return the ``UV_PROJECT_ENVIRONMENT`` path for a repo.
61 Matches the venv isolation scheme used by both ``--multi`` and ``--team``
62 modes. When *worktree_name* is set, appends ``-wt-{worktree_name}`` to
63 isolate worktree venvs.
64 """
65 suffix = repo_name
66 if worktree_name:
67 suffix = f"{repo_name}-wt-{worktree_name}"
68 return f"/root/.cache/uv/venvs/{suffix}"
71NPM_CACHE_VOLUME = "augint-shell-npm-cache"
72NODE_MODULES_VOLUME_PREFIX = "augint-shell-node-modules-"
75def node_modules_volume_name(
76 repo_name: str,
77 worktree_name: str | None = None,
78 subpath: str | None = None,
79) -> str:
80 """Per-project named volume that overlays a node_modules directory.
82 Mirrors the UV venv-isolation scheme so the container's Linux node_modules
83 never collides with the host's (e.g. Windows-built) node_modules in the
84 bind-mounted project directory.
86 When *subpath* is given (e.g. ``"apps/web"``), a sanitized slug is appended
87 so monorepos can isolate each workspace's node_modules independently.
88 """
89 suffix = repo_name
90 if worktree_name:
91 suffix = f"{repo_name}-wt-{worktree_name}"
92 if subpath:
93 suffix = f"{suffix}-{_sanitize_name(subpath.lower())}"
94 return f"{NODE_MODULES_VOLUME_PREFIX}{suffix}"
97COMPOSER_CACHE_VOLUME = "augint-shell-composer-cache"
98PNPM_STORE_VOLUME = "augint-shell-pnpm-store"
99# T3 Code: per-project data dir (~/.t3 holds the server DB, pairing sessions
100# and the T3 Connect credential) plus a shared cache for the managed
101# cloudflared binary so only the first project pays for the download.
102T3_HOME_VOLUME_PREFIX = "augint-shell-t3-"
103T3_TOOLS_VOLUME = "augint-shell-t3-tools"
104T3_HOME_PATH = "/root/.t3"
105T3_TOOLS_PATH = "/root/.t3/tools"
106VENDOR_VOLUME_PREFIX = "augint-shell-vendor-"
109def vendor_volume_name(repo_name: str, worktree_name: str | None = None) -> str:
110 """Per-project named volume that overlays composer's ``vendor/`` directory.
112 Same rationale as :func:`node_modules_volume_name`: ``vendor/`` on a
113 drvfs/9p bind mount is slow, and overlaying keeps the container's
114 Linux-built vendor tree separate from whatever the host has.
115 """
116 suffix = repo_name
117 if worktree_name:
118 suffix = f"{repo_name}-wt-{worktree_name}"
119 return f"{VENDOR_VOLUME_PREFIX}{suffix}"
122def t3_home_volume_name(project_dir: Path, project_name: str | None = None) -> str:
123 """Per-project named volume backing ``~/.t3`` inside the dev container.
125 A T3 Code environment *is* its data directory: the sqlite state, the
126 pairing sessions and the T3 Connect credential all live there. Since each
127 project gets its own container, each also gets its own environment — a
128 shared volume would mean several containers writing one sqlite file while
129 listing projects that only exist in one of them. The host's own ``~/.t3``
130 is deliberately not bind-mounted so a desktop T3 Code install and the
131 container servers never share a database.
132 """
133 return f"{T3_HOME_VOLUME_PREFIX}{unique_project_name(project_dir, project_name)}"
136PRE_COMMIT_CACHE_VOLUME = "augint-shell-pre-commit-cache"
137PRE_COMMIT_CACHE_PATH = "/root/.cache/pre-commit-container"
138OLLAMA_DATA_VOLUME = "augint-shell-ollama-data"
139WEBUI_DATA_VOLUME = "augint-shell-webui-data"
140N8N_DATA_VOLUME = "augint-shell-n8n-data"
141WHISPER_DATA_VOLUME = "augint-shell-whisper-cache"
142VOICE_AGENT_DATA_VOLUME = "augint-shell-voice-agent-data"
143COMFYUI_DATA_VOLUME = "augint-shell-comfyui-data"
145# =============================================================================
146# LLM defaults
147# =============================================================================
148OLLAMA_IMAGE = "ollama/ollama"
149WEBUI_IMAGE = "ghcr.io/open-webui/open-webui:main"
150KOKORO_IMAGE_CPU = "ghcr.io/remsky/kokoro-fastapi-cpu:latest"
151KOKORO_IMAGE_GPU = "ghcr.io/remsky/kokoro-fastapi-gpu:latest"
152N8N_IMAGE = "docker.n8n.io/n8nio/n8n"
153WHISPER_IMAGE_CPU = "ghcr.io/speaches-ai/speaches:latest-cpu"
154WHISPER_IMAGE_GPU = "ghcr.io/speaches-ai/speaches:latest-cuda"
155# Voice-agent image is built locally from docker/voice-agent/ on first
156# ensure call. Not pulled. The local tag keeps `images.get` fast once built.
157VOICE_AGENT_IMAGE = "augint-shell/voice-agent:local"
158# ComfyUI: ai-dock/comfyui is actively maintained and exposes PROVISIONING_SCRIPT
159# which we use to download FLUX.1-dev + SDXL on first boot. GPU-only (no CPU variant).
160COMFYUI_IMAGE = "ghcr.io/ai-dock/comfyui:latest-cuda"
161# Model slots (RTX 4090-sized, validated April 2026). Primary = best available for
162# the role; secondary = best uncensored alternative. See README "Local LLM stack"
163# and the generated .ai-shell.yaml for per-slot rationale and caveats.
164DEFAULT_PRIMARY_CHAT_MODEL = "qwen3.5:27b"
165DEFAULT_SECONDARY_CHAT_MODEL = "huihui_ai/qwen3.5-abliterated:27b"
166DEFAULT_PRIMARY_CODING_MODEL = "qwen3-coder:30b-a3b-q4_K_M"
167DEFAULT_SECONDARY_CODING_MODEL = "huihui_ai/qwen3-coder-abliterated:30b-a3b-instruct-q4_K_M"
168DEFAULT_EXTRA_MODELS: list[str] = [
169 "qwen3.5:9b", # ~6.6 GB mid-range chat, fast + capable
170 "devstral:24b", # ~15 GB Mistral agentic coding, dense 24B
171]
172DEFAULT_CONTEXT_SIZE = 32768
173DEFAULT_OLLAMA_PORT = 11434
174DEFAULT_WEBUI_PORT = 3000
175DEFAULT_KOKORO_PORT = 8880
176DEFAULT_N8N_PORT = 5678
177DEFAULT_WHISPER_PORT = 8001
178DEFAULT_WHISPER_MODEL = "Systran/faster-distil-whisper-large-v3"
179DEFAULT_VOICE_AGENT_PORT = 8010
180DEFAULT_COMFYUI_PORT = 8188
181DEFAULT_KOKORO_VOICE = "af_bella"
182# T3 Code server port inside the dev container (t3's own default). It is in
183# DEFAULT_DEV_PORTS so every dev container publishes it, which is what lets a
184# phone or the desktop app pair with the in-container server over the LAN.
185T3_CONTAINER_PORT = 3773
186# Metro / Expo dev server port inside the dev container (Expo's own default).
187# The tunnel path does not need it published — ngrok dials out from the
188# container — but publishing it lets a host browser reach Metro and
189# `expo start --web`.
190EXPO_METRO_PORT = 8081
191DEFAULT_DEV_PORTS = [
192 3000,
193 3773,
194 4096,
195 4200,
196 5000,
197 5173,
198 5678,
199 8000,
200 8080,
201 8081,
202 8888,
203 19432,
204 31415,
205]
207# Deterministic dev port mapping (avoids Chrome debug range 40000-60000)
208DEV_PORT_RANGE_START = 10000
209DEV_PORT_RANGE_SIZE = 30000 # 10000-39999
211# =============================================================================
212# Bedrock defaults
213# =============================================================================
214DEFAULT_BEDROCK_MODEL = "us.anthropic.claude-sonnet-4-20250514-v1:0"
216# =============================================================================
217# Ollama GPU defaults
218# =============================================================================
219OLLAMA_VRAM_BUFFER_BYTES = 1 * 1024**3 # 1 GiB safety buffer reserved as overhead
220OLLAMA_CPU_SHARES = 1024 # Docker CPU scheduling priority (default 0 = fair-share)
222# =============================================================================
223# Container names
224# =============================================================================
225OLLAMA_CONTAINER = "augint-shell-ollama"
226WEBUI_CONTAINER = "augint-shell-webui"
227KOKORO_CONTAINER = "augint-shell-kokoro"
228N8N_CONTAINER = "augint-shell-n8n"
229WHISPER_CONTAINER = "augint-shell-whisper"
230VOICE_AGENT_CONTAINER = "augint-shell-voice-agent"
231COMFYUI_CONTAINER = "augint-shell-comfyui"
233# =============================================================================
234# Docker network
235# =============================================================================
236LLM_NETWORK = "augint-shell-llm"
239def _sanitize_name(name: str) -> str:
240 """Convert an arbitrary string into a Docker-safe slug."""
241 name = re.sub(r"[^a-z0-9-]", "-", name)
242 name = re.sub(r"-+", "-", name)
243 return name.strip("-") or "project"
246def sanitize_project_name(path: Path) -> str:
247 """Derive a safe project slug from a directory basename."""
248 return _sanitize_name(path.resolve().name.lower())
251def unique_project_name(path: Path, project_name: str | None = None) -> str:
252 """Build a path-stable project identifier for container naming.
254 The basename remains human-readable while a short path hash prevents
255 collisions between repos with the same leaf directory name.
256 """
257 slug = _sanitize_name((project_name or path.resolve().name).lower())
258 # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1
259 digest = sha1(str(path.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:8]
260 return f"{slug}-{digest}"
263def dev_container_name(project_name: str, project_dir: Path | None = None) -> str:
264 """Build the dev container name for a project.
266 When *project_dir* is provided, the full resolved path is folded into the
267 name to avoid collisions across nested repo layouts. Without it, the legacy
268 basename-only format is preserved for compatibility.
269 """
270 if project_dir is None:
271 return f"{CONTAINER_PREFIX}-{project_name}-dev"
272 return f"{CONTAINER_PREFIX}-{unique_project_name(project_dir, project_name)}-dev"
275# Salted re-hash attempts per port before giving up. Each attempt is an
276# independent draw from 30000 slots, so exhaustion means the host is
277# rejecting essentially the whole range.
278DEV_PORT_MAX_ATTEMPTS = 64
281def project_dev_port_map(
282 project_dir: Path,
283 dev_ports: list[int],
284 project_name: str | None = None,
285 is_available: Callable[[int], bool] | None = None,
286) -> dict[int, int]:
287 """Map each container port to a unique, stable per-project host port.
289 Uses the same project identity as container naming (unique_project_name)
290 combined with each container port to produce deterministic host ports in
291 the 10000-39999 range. Different projects get different host ports for
292 the same container port, so multiple projects can run simultaneously.
294 A candidate slot is rejected when it is already taken by another port in
295 this map or when *is_available* (e.g. a host test-bind probe) refuses it.
296 Rejected candidates are re-hashed with a salt counter rather than probed
297 linearly: host-side reservations (Windows winnat excluded port ranges)
298 are contiguous blocks, so adjacent slots tend to fail together while a
299 re-hash jumps elsewhere in the range. Assignment runs in ascending
300 container-port order so the result is deterministic, and an unsalted
301 first attempt keeps existing assignments stable for the common case.
302 """
303 slug = unique_project_name(project_dir, project_name)
304 assigned: dict[int, int] = {}
305 used: set[int] = set()
306 for port in sorted(set(dev_ports)):
307 for salt in range(DEV_PORT_MAX_ATTEMPTS):
308 key = f"{slug}:{port}" if salt == 0 else f"{slug}:{port}:{salt}"
309 # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1
310 digest = sha1(key.encode(), usedforsecurity=False).hexdigest()
311 candidate = DEV_PORT_RANGE_START + (int(digest[:8], 16) % DEV_PORT_RANGE_SIZE)
312 if candidate not in used and (is_available is None or is_available(candidate)):
313 break
314 else:
315 raise RuntimeError(
316 f"Could not find a free host port for container port {port} "
317 f"after {DEV_PORT_MAX_ATTEMPTS} attempts in range "
318 f"{DEV_PORT_RANGE_START}-{DEV_PORT_RANGE_START + DEV_PORT_RANGE_SIZE - 1}. "
319 "The host is rejecting nearly all bind attempts — check firewall "
320 "or reserved port configuration."
321 )
322 assigned[port] = candidate
323 used.add(candidate)
324 return assigned
327def project_dev_port(
328 project_dir: Path,
329 container_port: int,
330 project_name: str | None = None,
331 dev_ports: list[int] | None = None,
332) -> int:
333 """Host port for one container port, consistent with project_dev_port_map.
335 *dev_ports* must be the full port set the container was created with —
336 collision resolution depends on it. Defaults to DEFAULT_DEV_PORTS.
337 """
338 ports = list(dev_ports) if dev_ports is not None else list(DEFAULT_DEV_PORTS)
339 if container_port not in ports:
340 ports.append(container_port)
341 return project_dev_port_map(project_dir, ports, project_name)[container_port]
344def build_dev_mounts(
345 project_dir: Path,
346 project_name: str,
347 extra_node_modules_paths: list[str] | None = None,
348 isolate_home_paths: set[str] | None = None,
349) -> list[Mount]:
350 """Build the full mount list matching docker-compose.yml dev service.
352 Required mounts are always included. Optional mounts are skipped
353 if the source path doesn't exist on the host.
355 *extra_node_modules_paths* is a list of glob patterns relative to
356 *project_dir*. Each glob match (that is a directory) gets its own named
357 volume overlaid at ``{match}/node_modules`` inside the container, so each
358 workspace in a monorepo isolates its Linux node_modules from the host
359 bind mount the same way the root overlay does.
361 *isolate_home_paths* is a set of home-config basenames (e.g. ``.claude``,
362 ``.codex``) that should be backed by a shared named volume instead of a
363 host bind mount, so nothing is written into the host home directory. The
364 volume persists across container recreations and is shared across projects.
365 Single-file configs (``.claude.json`` etc.) can't be volume-backed; when
366 named — or, for ``.claude.json``, when ``.claude`` is isolated — their host
367 bind is dropped and the config stays container-local.
368 """
369 from docker.types import Mount
371 mounts: list[Mount] = []
372 home = Path.home()
374 # Required: project directory (rw, delegated)
375 mounts.append(
376 Mount(
377 target=f"/root/projects/{project_name}",
378 source=str(project_dir.resolve()),
379 type="bind",
380 read_only=False,
381 consistency="delegated",
382 )
383 )
385 # Ensure directories that tools need for persistent config exist on the
386 # host so bind mounts aren't silently skipped.
387 for d in (".pi", ".augint", ".plannotator", ".expo"):
388 (home / d).mkdir(parents=True, exist_ok=True)
390 # Zapier CLI auth is a single JSON file; pre-create it so the bind mount
391 # exists and `zapier login` inside the container persists across
392 # container recreations (mirrors the .claude.json single-file bind).
393 zapierrc = home / ".zapierrc"
394 if not zapierrc.exists():
395 zapierrc.write_text("{}\n")
397 # Optional bind mounts — skip if source doesn't exist
398 optional_binds: list[tuple[Path, str, bool]] = [
399 (home / ".config", "/root/.config", False),
400 (home / ".codex", "/root/.codex", False),
401 (home / ".claude", "/root/.claude", False),
402 (home / ".claude.json", "/root/.claude.json", False),
403 (home / ".pi", "/root/.pi", False),
404 (home / ".augint", "/root/.augint", False),
405 (home / ".plannotator", "/root/.plannotator", False),
406 # Expo/EAS share ~/.expo for the auth session (state.json) and the
407 # ngrok config, so binding it keeps `expo login` across recreations.
408 (home / ".expo", "/root/.expo", False),
409 (home / ".zapierrc", "/root/.zapierrc", False),
410 (home / ".ssh", "/root/.ssh", True),
411 (home / ".gitconfig", "/root/.gitconfig.windows", True),
412 (home / ".aws", "/root/.aws", False),
413 ]
415 isolate = isolate_home_paths or set()
416 for source, target, read_only in optional_binds:
417 name = source.name
419 # Isolation: back the config with a shared named volume instead of a
420 # host bind, so nothing is written into the host home directory.
421 if name in isolate:
422 if name in _SINGLE_FILE_HOME_CONFIGS:
423 # A named volume can't back a single file — drop the bind so
424 # the host file is never touched (config stays container-local).
425 logger.debug("Isolating single-file home config (not persisted): %s", name)
426 continue
427 mounts.append(
428 Mount(
429 target=target,
430 source=home_config_volume_name(name),
431 type="volume",
432 )
433 )
434 continue
436 # Keep ~/.claude.json off the host whenever ~/.claude is isolated.
437 if name == ".claude.json" and ".claude" in isolate:
438 logger.debug("Isolating .claude.json alongside isolated .claude (not persisted)")
439 continue
441 if source.exists():
442 mounts.append(
443 Mount(
444 target=target,
445 source=str(source),
446 type="bind",
447 read_only=read_only,
448 )
449 )
450 else:
451 logger.debug("Skipping optional mount (not found): %s", source)
453 # gh CLI config: bind-mount the host path when found (Linux/Mac/WSL2),
454 # otherwise use a named volume so auth persists across container recreations
455 # (needed on Windows where gh stores tokens in keyring, not a file).
456 gh_config = _find_gh_config_dir()
457 if gh_config is not None:
458 mounts.append(
459 Mount(
460 target="/root/.config/gh",
461 source=str(gh_config),
462 type="bind",
463 read_only=False,
464 )
465 )
466 else:
467 mounts.append(
468 Mount(
469 target="/root/.config/gh",
470 source=GH_CONFIG_VOLUME,
471 type="volume",
472 )
473 )
475 # Optional: Docker socket
476 docker_sock = Path("/var/run/docker.sock")
477 if docker_sock.exists():
478 mounts.append(
479 Mount(
480 target="/var/run/docker.sock",
481 source=str(docker_sock),
482 type="bind",
483 read_only=True,
484 )
485 )
487 # Named volume: uv cache (shared across all projects)
488 mounts.append(
489 Mount(
490 target="/root/.cache/uv",
491 source=UV_CACHE_VOLUME,
492 type="volume",
493 )
494 )
496 # Named volume: npm cache (shared across all projects)
497 mounts.append(
498 Mount(
499 target="/root/.npm",
500 source=NPM_CACHE_VOLUME,
501 type="volume",
502 )
503 )
505 # Named volume: composer cache (shared across all projects). Without it
506 # composer re-downloads every package on container recreation.
507 mounts.append(
508 Mount(
509 target="/root/.cache/composer",
510 source=COMPOSER_CACHE_VOLUME,
511 type="volume",
512 )
513 )
515 # Named volume: pnpm content-addressable store (shared across all
516 # projects). Corepack's pnpm is container-local otherwise, so every
517 # container rebuild is a cold install; the npm cache doesn't help pnpm.
518 mounts.append(
519 Mount(
520 target="/root/.local/share/pnpm",
521 source=PNPM_STORE_VOLUME,
522 type="volume",
523 )
524 )
526 # Per-project named volume: T3 Code data dir (~/.t3). Keeps the server
527 # database, paired devices and the T3 Connect credential across container
528 # recreations without ever touching the host's own ~/.t3.
529 mounts.append(
530 Mount(
531 target=T3_HOME_PATH,
532 source=t3_home_volume_name(project_dir, project_name),
533 type="volume",
534 )
535 )
537 # Shared named volume nested inside it: the managed cloudflared binary T3
538 # Connect downloads (~35 MB). Shared so only the first project pays.
539 mounts.append(
540 Mount(
541 target=T3_TOOLS_PATH,
542 source=T3_TOOLS_VOLUME,
543 type="volume",
544 )
545 )
547 # Named volume: pre-commit cache (shared across all projects).
548 # Isolates the container's hook environments from the Windows host's
549 # ~/.cache/pre-commit so the two installs don't clobber each other.
550 mounts.append(
551 Mount(
552 target=PRE_COMMIT_CACHE_PATH,
553 source=PRE_COMMIT_CACHE_VOLUME,
554 type="volume",
555 )
556 )
558 # Per-project named volume overlaying node_modules. Without this the
559 # container's Linux `npm ci` would write into the bind-mounted host
560 # project dir and collide with host-built (e.g. Windows) node_modules.
561 # npm has no equivalent of UV_PROJECT_ENVIRONMENT, so we isolate at the
562 # mount layer instead. Host node_modules underneath stays untouched.
563 mounts.append(
564 Mount(
565 target=f"/root/projects/{project_name}/node_modules",
566 source=node_modules_volume_name(project_name),
567 type="volume",
568 )
569 )
571 # Per-project named volume overlaying composer's vendor/ — the exact
572 # node_modules rationale applies verbatim. Conditional on composer.json
573 # so non-PHP projects don't grow an empty vendor/ dir on the host.
574 if (project_dir / "composer.json").is_file():
575 mounts.append(
576 Mount(
577 target=f"/root/projects/{project_name}/vendor",
578 source=vendor_volume_name(project_name),
579 type="volume",
580 )
581 )
583 # Monorepo workspaces: overlay an isolated named volume on each matched
584 # workspace's node_modules so per-app installs (npm/pnpm/yarn workspaces)
585 # land in container-only storage rather than the host bind mount.
586 project_root = project_dir.resolve()
587 seen_targets: set[str] = {f"/root/projects/{project_name}/node_modules"}
588 for pattern in extra_node_modules_paths or []:
589 for match in sorted(project_root.glob(pattern)):
590 if not match.is_dir():
591 continue
592 try:
593 rel = match.relative_to(project_root)
594 except ValueError:
595 continue
596 rel_posix = rel.as_posix()
597 target = f"/root/projects/{project_name}/{rel_posix}/node_modules"
598 if target in seen_targets:
599 continue
600 seen_targets.add(target)
601 mounts.append(
602 Mount(
603 target=target,
604 source=node_modules_volume_name(project_name, subpath=rel_posix),
605 type="volume",
606 )
607 )
609 return mounts
612def docker_tcp_fallback_host() -> str | None:
613 """DOCKER_HOST value for dev containers when no Unix socket exists to mount.
615 On Windows hosts there is no /var/run/docker.sock for build_dev_mounts to
616 bind-mount, but Docker Desktop can expose the daemon on localhost:2375
617 ("Expose daemon on tcp://localhost:2375 without TLS"). When that endpoint
618 is reachable, containers reach the same daemon via host.docker.internal —
619 return the DOCKER_HOST value to inject. Returns None when the socket
620 mount already covers Docker access or no tcp endpoint is listening.
621 """
622 import socket
624 if Path("/var/run/docker.sock").exists():
625 return None
626 try:
627 with socket.create_connection(("localhost", 2375), timeout=1):
628 pass
629 except OSError:
630 return None
631 return "tcp://host.docker.internal:2375"
634def _find_gh_config_dir() -> Path | None:
635 """Find the gh CLI config directory.
637 Checks the standard Linux/Mac path (~/.config/gh) first, then falls back
638 to the Windows APPDATA path for WSL2 environments where gh is installed on
639 the Windows side (%APPDATA%\\GitHub CLI\\).
640 """
641 linux_path = Path.home() / ".config" / "gh"
642 if linux_path.exists():
643 return linux_path
645 # WSL2 fallback: APPDATA is set as a Windows path (e.g. C:\Users\foo\AppData\Roaming)
646 appdata = os.environ.get("APPDATA", "")
647 if appdata and ":" in appdata:
648 drive, rest = appdata.split(":", 1)
649 wsl_appdata = Path(f"/mnt/{drive.lower()}{rest.replace(chr(92), '/')}")
650 windows_path = wsl_appdata / "GitHub CLI"
651 if windows_path.exists():
652 return windows_path
654 return None
657def _load_layered_dotenv(
658 project_dir: Path | None = None,
659 env_file: Path | None = None,
660) -> dict[str, str | None]:
661 """Load layered .env files.
663 ``~/.augint/.env`` always loads (global augint suite config). The project
664 ``./.env`` and explicit *env_file* only load when *env_file* is given
665 (i.e. user passed ``--env`` on the CLI). Later layers override earlier
666 ones.
667 """
668 from dotenv import dotenv_values
670 layers: dict[str, str | None] = {}
672 global_path = Path.home() / ".augint" / ".env"
673 if global_path.is_file():
674 layers.update(dotenv_values(global_path))
676 if env_file is None:
677 return layers
679 if project_dir is not None:
680 project_path = project_dir / ".env"
681 if project_path.is_file() and project_path.resolve() != env_file.resolve():
682 layers.update(dotenv_values(project_path))
684 if env_file.is_file():
685 layers.update(dotenv_values(env_file))
687 return layers
690_SHARED_ENV_PASSTHROUGH = (
691 "ANTHROPIC_API_KEY",
692 "OPENAI_API_KEY",
693 "PRIMARY_CHAT_MODEL",
694 "SECONDARY_CHAT_MODEL",
695 "PRIMARY_CODING_MODEL",
696 "SECONDARY_CODING_MODEL",
697 "CONTEXT_SIZE",
698 "OLLAMA_PORT",
699 "WEBUI_PORT",
700 "KOKORO_PORT",
701 "WHISPER_PORT",
702 "N8N_PORT",
703 "COMFYUI_PORT",
704 "OPENCODE_SERVER_PASSWORD",
705 "OPENCODE_SERVER_USERNAME",
706 "PI_STUDIO_HOST",
707)
710def build_dev_environment(
711 extra_env: dict[str, str] | None = None,
712 project_dir: Path | None = None,
713 *,
714 project_name: str = "",
715 bedrock: bool = False,
716 aws_profile: str = "",
717 aws_region: str = "",
718 bedrock_profile: str = "",
719 bedrock_region: str = "",
720 bedrock_model: str = "",
721 openai_profile: str = "",
722 team_mode: bool = False,
723 env_file: Path | None = None,
724) -> dict[str, str]:
725 """Build environment variables matching docker-compose.yml dev service.
727 ``~/.augint/.env`` always loads (global augint suite config). The project
728 ``./.env`` only loads when *env_file* is given (``--env`` on the CLI);
729 when loaded, **all** of its keys flow through to the container.
731 Priority (highest wins): extra_env > CLI flags > ``./.env`` (when ``--env``)
732 > ``~/.augint/.env`` > host ``os.environ`` (allowlisted) > defaults.
734 GitHub auth defaults to SSO via the ``~/.config/gh`` bind mount. To use a
735 PAT instead, put ``GH_TOKEN`` in ``.env`` and pass ``--env``.
737 When *bedrock* is True, ``CLAUDE_CODE_USE_BEDROCK=1`` is injected and
738 *bedrock_profile* (if set) overrides ``AWS_PROFILE`` so the LLM provider
739 authenticates with the correct AWS account.
741 When *openai_profile* is set, the suffixed env vars
742 ``OPENAI_API_KEY_{NAME}`` and ``OPENAI_ORG_ID_{NAME}`` are resolved from
743 ``.env`` and injected as ``OPENAI_API_KEY`` / ``OPENAI_ORG_ID``.
745 When *team_mode* is True, ``CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`` is
746 injected to enable Claude Code's Agent Teams feature.
747 """
748 dotenv = _load_layered_dotenv(project_dir, env_file=env_file)
750 def _resolve(key: str, default: str = "") -> str:
751 """Resolve a value: .env > os.environ > default."""
752 dotenv_val = dotenv.get(key)
753 if dotenv_val is not None and dotenv_val != "":
754 return dotenv_val
755 return os.environ.get(key, default)
757 _CONTAINER_BASE_PATH = (
758 "/root/.local/bin:/root/.opencode/bin:"
759 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
760 )
762 env: dict[str, str] = {
763 "PATH": _CONTAINER_BASE_PATH,
764 "AWS_PROFILE": aws_profile or _resolve("AWS_PROFILE"),
765 "AWS_REGION": aws_region or _resolve("AWS_REGION", "us-east-1"),
766 "AWS_PAGER": "",
767 "HUSKY": "0",
768 "IS_SANDBOX": "1",
769 "PRE_COMMIT_HOME": PRE_COMMIT_CACHE_PATH,
770 "PI_STUDIO_HOST": "0.0.0.0", # nosec B104
771 "PLANNOTATOR_REMOTE": "1",
772 "PLANNOTATOR_PORT": "19432",
773 "PLANNOTATOR_BROWSER": "echo",
774 }
776 # Mirror AWS_REGION to AWS_DEFAULT_REGION so both Node.js SDK paths resolve
777 env["AWS_DEFAULT_REGION"] = env["AWS_REGION"]
779 # Isolate UV venvs per-project within the shared cache volume.
780 # Overrides Dockerfile default of /root/.cache/uv/venvs/project.
781 if project_name:
782 env["UV_PROJECT_ENVIRONMENT"] = uv_venv_path(project_name)
784 if bedrock:
785 env["CLAUDE_CODE_USE_BEDROCK"] = "1"
786 if bedrock_profile:
787 env["AWS_PROFILE"] = bedrock_profile
788 resolved_bedrock_region = (
789 bedrock_region or _resolve("AWS_BEDROCK_REGION") or env["AWS_REGION"]
790 )
791 if resolved_bedrock_region != env["AWS_REGION"]:
792 env["AWS_REGION"] = resolved_bedrock_region
793 env["AWS_DEFAULT_REGION"] = resolved_bedrock_region
794 if bedrock_model:
795 env["ANTHROPIC_MODEL"] = bedrock_model
797 if openai_profile:
798 suffix = openai_profile.upper()
799 key_var = f"OPENAI_API_KEY_{suffix}"
800 api_key = dotenv.get(key_var)
801 if not api_key:
802 raise ValueError(
803 f"OpenAI profile '{openai_profile}' requires {key_var} in "
804 "~/.augint/.env, or pass --env to load it from ./.env"
805 )
806 env["OPENAI_API_KEY"] = api_key
807 org_var = f"OPENAI_ORG_ID_{suffix}"
808 org_id = dotenv.get(org_var)
809 if org_id:
810 env["OPENAI_ORG_ID"] = org_id
812 if team_mode:
813 env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1"
815 for var in _SHARED_ENV_PASSTHROUGH:
816 val = _resolve(var)
817 if val:
818 env[var] = val
820 # Pass through every var loaded from .env, except keys already populated
821 # above (which preserves CLI-flag wins for AWS_PROFILE etc.).
822 for key, value in dotenv.items():
823 if value is not None and value != "" and key not in env:
824 env[key] = value
826 if extra_env:
827 env.update(extra_env)
829 return env
832def _resolve_env(dotenv: dict[str, str | None], key: str, default: str = "") -> str:
833 """Resolve a value: dotenv > os.environ > default."""
834 dotenv_val = dotenv.get(key)
835 if dotenv_val is not None and dotenv_val != "":
836 return dotenv_val
837 return os.environ.get(key, default)
840def build_n8n_environment(
841 env_file: Path | None = None,
842 *,
843 aws_profile: str = "",
844 aws_region: str = "",
845) -> dict[str, str]:
846 """Build environment variables for the n8n workflow automation container.
848 Loads layered .env files (``~/.augint/.env`` then *env_file*),
849 then falls back to host environment variables.
851 Service discovery URLs use internal Docker network hostnames so n8n
852 workflows can reference them via ``{{ $env.OLLAMA_BASE_URL }}`` etc.
853 """
854 dotenv = _load_layered_dotenv(env_file=env_file)
856 env: dict[str, str] = {
857 # Disable secure-cookie so the UI works over plain http://localhost.
858 "N8N_SECURE_COOKIE": "false",
859 # Service discovery (internal Docker network URLs).
860 "OLLAMA_BASE_URL": f"http://{OLLAMA_CONTAINER}:11434",
861 "KOKORO_BASE_URL": f"http://{KOKORO_CONTAINER}:8880",
862 "WHISPER_BASE_URL": f"http://{WHISPER_CONTAINER}:8000",
863 "VOICE_AGENT_BASE_URL": f"http://{VOICE_AGENT_CONTAINER}:8000",
864 "WEBUI_BASE_URL": f"http://{WEBUI_CONTAINER}:8080",
865 "COMFYUI_BASE_URL": f"http://{COMFYUI_CONTAINER}:8188",
866 }
868 # AWS credentials
869 aws_prof = aws_profile or _resolve_env(dotenv, "AWS_PROFILE")
870 aws_reg = aws_region or _resolve_env(dotenv, "AWS_REGION", "us-east-1")
871 if aws_prof:
872 env["AWS_PROFILE"] = aws_prof
873 env["AWS_REGION"] = aws_reg
874 env["AWS_DEFAULT_REGION"] = aws_reg
876 # API keys — only include when non-empty.
877 for key in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
878 val = _resolve_env(dotenv, key)
879 if val:
880 env[key] = val
882 if env_file is not None:
883 gh_token = _resolve_env(dotenv, "GH_TOKEN")
884 if gh_token:
885 env["GH_TOKEN"] = gh_token
886 env["GITHUB_TOKEN"] = gh_token
887 env["GITHUB_MODELS_BASE_URL"] = "https://models.inference.ai.azure.com"
889 return env
892def build_n8n_mounts(
893 workflow_dir: Path | None = None,
894) -> list[Mount]:
895 """Build the mount list for the n8n container.
897 n8n runs as user ``node`` (UID 1000). Credential directories are mounted
898 read-only under ``/home/node/`` so the AWS and GitHub CLIs resolve auth
899 the same way the dev container does.
900 """
901 from docker.types import Mount
903 home = Path.home()
905 mounts: list[Mount] = [
906 # Persistent data (workflows, credentials DB, settings).
907 Mount(
908 target="/home/node/.n8n",
909 source=N8N_DATA_VOLUME,
910 type="volume",
911 ),
912 ]
914 # Optional credential bind mounts (read-only).
915 aws_dir = home / ".aws"
916 if aws_dir.exists():
917 mounts.append(
918 Mount(
919 target="/home/node/.aws",
920 source=str(aws_dir),
921 type="bind",
922 read_only=True,
923 )
924 )
925 else:
926 logger.debug("Skipping n8n AWS mount (not found): %s", aws_dir)
928 gh_config = _find_gh_config_dir()
929 if gh_config is not None:
930 mounts.append(
931 Mount(
932 target="/home/node/.config/gh",
933 source=str(gh_config),
934 type="bind",
935 read_only=True,
936 )
937 )
939 # Starter workflow templates (read-only bind mount).
940 if workflow_dir is not None and workflow_dir.is_dir():
941 mounts.append(
942 Mount(
943 target="/workflows",
944 source=str(workflow_dir),
945 type="bind",
946 read_only=True,
947 )
948 )
950 return mounts