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

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 collections.abc import Callable 

17 

18 from docker.types import Mount 

19 

20logger = logging.getLogger(__name__) 

21 

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

23# Image defaults 

24# ============================================================================= 

25DEFAULT_IMAGE = "svange/augint-shell" 

26CONTAINER_PREFIX = "augint-shell" 

27SHM_SIZE = "2g" 

28 

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

30# Volume names (prefixed to avoid collisions) 

31# ============================================================================= 

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

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

34 

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

39 

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

45 

46 

47def home_config_volume_name(config_name: str) -> str: 

48 """Shared named-volume name backing an isolated home config directory. 

49 

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

56 

57 

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

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

60 

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

69 

70 

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

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

73 

74 

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. 

81 

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. 

85 

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

95 

96 

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

107 

108 

109def vendor_volume_name(repo_name: str, worktree_name: str | None = None) -> str: 

110 """Per-project named volume that overlays composer's ``vendor/`` directory. 

111 

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

120 

121 

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. 

124 

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

134 

135 

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" 

144 

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] 

206 

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

208DEV_PORT_RANGE_START = 10000 

209DEV_PORT_RANGE_SIZE = 30000 # 10000-39999 

210 

211# ============================================================================= 

212# Bedrock defaults 

213# ============================================================================= 

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

215 

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) 

221 

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" 

232 

233# ============================================================================= 

234# Docker network 

235# ============================================================================= 

236LLM_NETWORK = "augint-shell-llm" 

237 

238 

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" 

244 

245 

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

249 

250 

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

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

253 

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

261 

262 

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

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

265 

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" 

273 

274 

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 

279 

280 

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. 

288 

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. 

293 

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 

325 

326 

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. 

334 

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] 

342 

343 

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. 

351 

352 Required mounts are always included. Optional mounts are skipped 

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

354 

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. 

360 

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 

370 

371 mounts: list[Mount] = [] 

372 home = Path.home() 

373 

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 ) 

384 

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) 

389 

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

396 

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 ] 

414 

415 isolate = isolate_home_paths or set() 

416 for source, target, read_only in optional_binds: 

417 name = source.name 

418 

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 

435 

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 

440 

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) 

452 

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 ) 

474 

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 ) 

486 

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 ) 

495 

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 ) 

504 

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 ) 

514 

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 ) 

525 

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 ) 

536 

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 ) 

546 

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 ) 

557 

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 ) 

570 

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 ) 

582 

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 ) 

608 

609 return mounts 

610 

611 

612def docker_tcp_fallback_host() -> str | None: 

613 """DOCKER_HOST value for dev containers when no Unix socket exists to mount. 

614 

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 

623 

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" 

632 

633 

634def _find_gh_config_dir() -> Path | None: 

635 """Find the gh CLI config directory. 

636 

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 

644 

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 

653 

654 return None 

655 

656 

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. 

662 

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 

669 

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

671 

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

673 if global_path.is_file(): 

674 layers.update(dotenv_values(global_path)) 

675 

676 if env_file is None: 

677 return layers 

678 

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

683 

684 if env_file.is_file(): 

685 layers.update(dotenv_values(env_file)) 

686 

687 return layers 

688 

689 

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) 

708 

709 

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. 

726 

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. 

730 

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

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

733 

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``. 

736 

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. 

740 

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``. 

744 

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) 

749 

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) 

756 

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 ) 

761 

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 } 

775 

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

777 env["AWS_DEFAULT_REGION"] = env["AWS_REGION"] 

778 

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) 

783 

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 

796 

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 

811 

812 if team_mode: 

813 env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1" 

814 

815 for var in _SHARED_ENV_PASSTHROUGH: 

816 val = _resolve(var) 

817 if val: 

818 env[var] = val 

819 

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 

825 

826 if extra_env: 

827 env.update(extra_env) 

828 

829 return env 

830 

831 

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) 

838 

839 

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. 

847 

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

849 then falls back to host environment variables. 

850 

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) 

855 

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 } 

867 

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 

875 

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 

881 

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" 

888 

889 return env 

890 

891 

892def build_n8n_mounts( 

893 workflow_dir: Path | None = None, 

894) -> list[Mount]: 

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

896 

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 

902 

903 home = Path.home() 

904 

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 ] 

913 

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) 

927 

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 ) 

938 

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 ) 

949 

950 return mounts