|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | | -import json |
6 | 5 | import os |
7 | 6 | import sys |
8 | 7 | import time |
9 | | -from datetime import datetime, timezone |
10 | 8 | from pathlib import Path |
11 | 9 | from typing import Annotated, Any |
12 | 10 |
|
|
26 | 24 | from vibepod.core.allowed_dirs import add_allowed_dir, is_dir_allowed, is_protected_dir |
27 | 25 | from vibepod.core.config import get_config |
28 | 26 | from vibepod.core.docker import DockerClientError, DockerManager, _is_latest_tag |
| 27 | +from vibepod.core.launch import ( |
| 28 | + CLAUDE_TOKEN_FILENAME, |
| 29 | + agent_extra_volumes as _agent_extra_volumes, |
| 30 | + agent_init_commands as _agent_init_commands, |
| 31 | + get_container_ip as _get_container_ip, |
| 32 | + host_user as _host_user, |
| 33 | + init_entrypoint as _init_entrypoint, |
| 34 | + parse_env_pairs as _parse_env_pairs, |
| 35 | + read_claude_stored_token as _read_claude_stored_token, |
| 36 | + terminal_env_defaults as _terminal_env_defaults, |
| 37 | + update_container_mapping as _update_container_mapping, |
| 38 | + write_claude_stored_token as _write_claude_stored_token, |
| 39 | + x11_volumes_and_env as _x11_volumes_and_env, |
| 40 | +) |
29 | 41 | from vibepod.core.session_logger import SessionLogger |
30 | 42 | from vibepod.utils.console import error, info, success, warning |
31 | 43 |
|
32 | | -CLAUDE_TOKEN_FILENAME = "oauth-token" |
33 | | - |
34 | | - |
35 | | -def _claude_stored_token_path(config_dir: Path) -> Path: |
36 | | - return config_dir / CLAUDE_TOKEN_FILENAME |
37 | | - |
38 | | - |
39 | | -def _read_claude_stored_token(config_dir: Path) -> str | None: |
40 | | - path = _claude_stored_token_path(config_dir) |
41 | | - try: |
42 | | - token = path.read_text(encoding="utf-8").strip() |
43 | | - except FileNotFoundError: |
44 | | - return None |
45 | | - except OSError as exc: |
46 | | - warning(f"Could not read stored claude token at {path}: {exc}") |
47 | | - return None |
48 | | - return token or None |
49 | | - |
50 | | - |
51 | | -def _write_claude_stored_token(config_dir: Path, token: str) -> Path: |
52 | | - path = _claude_stored_token_path(config_dir) |
53 | | - path.parent.mkdir(parents=True, exist_ok=True) |
54 | | - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) |
55 | | - try: |
56 | | - # fchmod overrides umask; os.open mode alone is umask-filtered |
57 | | - os.fchmod(fd, 0o600) |
58 | | - except OSError: |
59 | | - warning(f"Could not restrict permissions on {path}; token may be readable by other users") |
60 | | - with os.fdopen(fd, "w", encoding="utf-8") as f: |
61 | | - f.write(token.strip() + "\n") |
62 | | - return path |
63 | | - |
64 | | - |
65 | | -def _parse_env_pairs(values: list[str]) -> dict[str, str]: |
66 | | - parsed: dict[str, str] = {} |
67 | | - for entry in values: |
68 | | - if "=" not in entry: |
69 | | - raise typer.BadParameter(f"Invalid --env value '{entry}', expected KEY=VALUE") |
70 | | - key, value = entry.split("=", 1) |
71 | | - if not key: |
72 | | - raise typer.BadParameter("Environment variable key cannot be empty") |
73 | | - parsed[key] = value |
74 | | - return parsed |
75 | | - |
76 | | - |
77 | | -def _agent_init_commands(agent: str, agent_cfg: dict[str, Any]) -> list[str]: |
78 | | - """Read and validate per-agent init commands from config.""" |
79 | | - raw_init = agent_cfg.get("init", []) |
80 | | - if raw_init is None: |
81 | | - return [] |
82 | | - |
83 | | - if isinstance(raw_init, str): |
84 | | - items = [raw_init] |
85 | | - elif isinstance(raw_init, list): |
86 | | - items = raw_init |
87 | | - else: |
88 | | - raise typer.BadParameter( |
89 | | - f"Invalid agents.{agent}.init value, expected a string or list of strings." |
90 | | - ) |
91 | | - |
92 | | - commands: list[str] = [] |
93 | | - for index, item in enumerate(items, start=1): |
94 | | - if not isinstance(item, str): |
95 | | - raise typer.BadParameter( |
96 | | - f"Invalid agents.{agent}.init[{index}] value, expected a string." |
97 | | - ) |
98 | | - command = item.strip() |
99 | | - if not command: |
100 | | - raise typer.BadParameter( |
101 | | - f"Invalid agents.{agent}.init[{index}] value, cannot be empty." |
102 | | - ) |
103 | | - commands.append(command) |
104 | | - return commands |
105 | | - |
106 | | - |
107 | | -def _init_entrypoint(init_commands: list[str]) -> list[str]: |
108 | | - """Build a shell entrypoint that runs init commands before the agent command.""" |
109 | | - script = "\n".join( |
110 | | - [ |
111 | | - "set -e", |
112 | | - *init_commands, |
113 | | - 'exec "$@"', |
114 | | - ] |
115 | | - ) |
116 | | - return ["/bin/sh", "-lc", script, "--"] |
117 | | - |
118 | | - |
119 | | -def _get_container_ip(container: Any, network: str) -> str | None: |
120 | | - """Extract the container's IP address on the given Docker network.""" |
121 | | - try: |
122 | | - network_settings = container.attrs.get("NetworkSettings") |
123 | | - if not isinstance(network_settings, dict): |
124 | | - return None |
125 | | - networks = network_settings.get("Networks") |
126 | | - if not isinstance(networks, dict): |
127 | | - return None |
128 | | - network_data = networks.get(network) |
129 | | - if not isinstance(network_data, dict): |
130 | | - return None |
131 | | - ip = network_data.get("IPAddress") |
132 | | - return ip if isinstance(ip, str) and ip else None |
133 | | - except AttributeError: |
134 | | - return None |
135 | | - |
136 | | - |
137 | | -def _update_container_mapping( |
138 | | - mapping_path: Path, |
139 | | - ip: str, |
140 | | - container_id: str, |
141 | | - container_name: str, |
142 | | - agent: str, |
143 | | -) -> bool: |
144 | | - """Merge a new IP→container entry into containers.json atomically.""" |
145 | | - mapping: dict[str, dict[str, str]] = {} |
146 | | - try: |
147 | | - if mapping_path.exists(): |
148 | | - try: |
149 | | - mapping = json.loads(mapping_path.read_text()) |
150 | | - except (json.JSONDecodeError, OSError): |
151 | | - pass |
152 | | - |
153 | | - mapping[ip] = { |
154 | | - "container_id": container_id, |
155 | | - "container_name": container_name, |
156 | | - "agent": agent, |
157 | | - "started_at": datetime.now(timezone.utc).isoformat(), |
158 | | - } |
159 | | - |
160 | | - tmp_path = mapping_path.with_suffix(".tmp") |
161 | | - tmp_path.write_text(json.dumps(mapping, indent=2)) |
162 | | - os.replace(tmp_path, mapping_path) |
163 | | - except OSError: |
164 | | - return False |
165 | | - return True |
166 | | - |
167 | | - |
168 | | -def _agent_extra_volumes(agent: str, config_dir: Path) -> list[tuple[str, str, str]]: |
169 | | - """Return agent-specific bind mounts as (host_path, container_path, mode).""" |
170 | | - if agent == "auggie": |
171 | | - host = str(config_dir / ".augment") |
172 | | - return [ |
173 | | - (host, "/root/.augment", "rw"), |
174 | | - (host, "/home/node/.augment", "rw"), |
175 | | - ] |
176 | | - if agent == "copilot": |
177 | | - host = str(config_dir / ".copilot") |
178 | | - return [ |
179 | | - (host, "/root/.copilot", "rw"), |
180 | | - (host, "/home/node/.copilot", "rw"), |
181 | | - (host, "/home/coder/.copilot", "rw"), |
182 | | - ] |
183 | | - return [] |
184 | | - |
185 | | - |
186 | | -def _x11_volumes_and_env(display: str) -> tuple[list[tuple[str, str, str]], dict[str, str]]: |
187 | | - """Return X11 socket volumes and DISPLAY env for paste-image support.""" |
188 | | - volumes: list[tuple[str, str, str]] = [("/tmp/.X11-unix", "/tmp/.X11-unix", "rw")] |
189 | | - env: dict[str, str] = {"DISPLAY": display} |
190 | | - return volumes, env |
191 | | - |
192 | | - |
193 | | -def _host_user() -> str | None: |
194 | | - """Return current user id in uid:gid format when available.""" |
195 | | - getuid = getattr(os, "getuid", None) |
196 | | - getgid = getattr(os, "getgid", None) |
197 | | - if not callable(getuid) or not callable(getgid): |
198 | | - return None |
199 | | - return f"{getuid()}:{getgid()}" |
200 | | - |
201 | | - |
202 | | -def _terminal_env_defaults() -> dict[str, str]: |
203 | | - """Return host terminal-related env vars for interactive container apps.""" |
204 | | - keys = ("TERM", "COLORTERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "LANG") |
205 | | - values = {key: value for key in keys if (value := os.environ.get(key))} |
206 | | - values.setdefault("TERM", "xterm-256color") |
207 | | - return values |
208 | | - |
209 | 44 |
|
210 | 45 | def _compose_file_present(workspace: Path) -> bool: |
211 | 46 | return (workspace / "docker-compose.yml").exists() or (workspace / "compose.yml").exists() |
|
0 commit comments