Coverage for src/ai_shell/t3.py: 83%
154 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"""T3 Code integration for dev containers.
3`T3 Code <https://github.com/pingdotgg/t3code>`_ is a GUI (desktop, web and
4mobile) that drives coding agents — Claude Code, Codex, OpenCode — through a
5small HTTP/WebSocket server. The server owns the workspace: it spawns the
6agent processes, reads the git state and hosts the terminals. So to control a
7project from the T3 phone app, the *server* has to run where the project lives
8— which, for ai-shell, is the per-project dev container.
10That is what ``--t3`` does. It starts ``t3 serve`` inside the dev container,
11registers the project, and prints a pairing URL/QR pointing at the container's
12published port on this machine's LAN address.
14Two connection paths exist and both work through the same running server:
16* **LAN pairing** (default, no account): the container port 3773 is published
17 on a stable per-project host port, so a phone on the same network can reach
18 it directly. Pairing is a one-time token exchange; the device keeps a
19 session afterwards.
20* **T3 Connect** (optional, needs a T3 account): ``t3 connect link`` inside the
21 container records the intent and the next ``t3 serve`` provisions the relay
22 and launches a managed cloudflared tunnel. That path is outbound-only, so it
23 works from anywhere without the published port.
25The server keeps running in the container after the local tool exits, which is
26the point: the terminal session ends, remote control does not.
27"""
29from __future__ import annotations
31import json
32import logging
33import shlex
34import socket
35import subprocess
36import time
37from typing import TYPE_CHECKING
39from rich.console import Console
41from ai_shell.defaults import T3_CONTAINER_PORT
43# Re-exported: the QR renderer is shared with the Expo integration.
44from ai_shell.qr import render_qr
46if TYPE_CHECKING:
47 from ai_shell.config import AiShellConfig
48 from ai_shell.container import ContainerManager
50logger = logging.getLogger(__name__)
51console = Console(stderr=True)
53#: npm package providing the ``t3`` binary.
54T3_NPM_PACKAGE = "t3"
56#: Unauthenticated descriptor endpoint — the readiness probe t3's own CLI uses.
57T3_WELL_KNOWN_PATH = "/.well-known/t3/environment"
59#: Where the detached server's stdout/stderr lands inside the container.
60T3_LOG_PATH = "/var/log/ai-shell/t3-serve.log"
62#: How long to wait for a freshly started server to answer the probe.
63T3_READY_TIMEOUT = 90.0
65#: npm install can be slow on a cold npm cache volume.
66T3_INSTALL_TIMEOUT = 900
69class T3Error(Exception):
70 """T3 Code could not be started or paired inside the dev container."""
73def _exec(
74 container_name: str,
75 args: list[str],
76 *,
77 extra_env: dict[str, str] | None = None,
78 timeout: float | None = 60,
79) -> subprocess.CompletedProcess[str]:
80 """Run a command in *container_name* and capture its output."""
81 cmd = ["docker", "exec"]
82 for key, value in (extra_env or {}).items():
83 cmd.extend(["-e", f"{key}={value}"])
84 cmd.append(container_name)
85 cmd.extend(args)
86 logger.debug("t3 exec: %s", " ".join(args))
87 return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
90def _login_shell(script: str) -> list[str]:
91 """Wrap *script* so it runs with the container's login PATH.
93 ``.bashrc`` returns early for non-interactive shells, so this picks up
94 ``/etc/profile.d`` PATH additions without any MOTD noise polluting the
95 output we parse.
96 """
97 return ["bash", "-lc", script]
100def _t3(
101 container_name: str,
102 argv: list[str],
103 *,
104 timeout: float | None = 60,
105) -> subprocess.CompletedProcess[str]:
106 """Run a ``t3`` subcommand through the login shell so PATH is resolved."""
107 return _exec(container_name, _login_shell(shlex.join(["t3", *argv])), timeout=timeout)
110def ensure_cli(container_name: str) -> None:
111 """Make sure the ``t3`` binary exists in the container.
113 Images from before T3 support shipped don't have it, so install on demand
114 rather than forcing an image pull. Newer images bake it in and this is a
115 single cheap ``command -v``.
116 """
117 if _exec(container_name, _login_shell("command -v t3"), timeout=30).returncode == 0:
118 return
120 console.print("[dim]Installing the T3 Code CLI (npm install -g t3)...[/dim]")
121 result = _exec(
122 container_name,
123 _login_shell(f"npm install -g {shlex.quote(T3_NPM_PACKAGE)}"),
124 timeout=T3_INSTALL_TIMEOUT,
125 )
126 if result.returncode != 0:
127 raise T3Error(
128 "Failed to install the T3 Code CLI in the dev container.\n"
129 f" npm said: {(result.stderr or result.stdout).strip()[-500:]}"
130 )
133def server_running(container_name: str, port: int = T3_CONTAINER_PORT) -> bool:
134 """Return True when a T3 Code server answers on *port* in the container."""
135 result = _exec(
136 container_name,
137 [
138 "curl",
139 "-fsS",
140 "-m",
141 "3",
142 f"http://127.0.0.1:{port}{T3_WELL_KNOWN_PATH}",
143 ],
144 timeout=15,
145 )
146 return result.returncode == 0
149def start_server(
150 container_name: str,
151 workdir: str,
152 *,
153 port: int = T3_CONTAINER_PORT,
154 extra_env: dict[str, str] | None = None,
155) -> None:
156 """Start ``t3 serve`` detached inside the container.
158 Bound to ``0.0.0.0`` so the published Docker port actually reaches it, and
159 pinned to *port* because t3's web mode otherwise scans upward from 3773 for
160 a free port — which would land outside the published mapping.
162 *extra_env* is the same environment an interactive tool launch gets, so the
163 agents T3 spawns see the same credentials and settings.
164 """
165 inner = (
166 f"mkdir -p $(dirname {shlex.quote(T3_LOG_PATH)}) && "
167 f"exec t3 serve --host 0.0.0.0 --port {port} {shlex.quote(workdir)} "
168 f">>{shlex.quote(T3_LOG_PATH)} 2>&1"
169 )
170 cmd = ["docker", "exec", "-d"]
171 for key, value in (extra_env or {}).items():
172 cmd.extend(["-e", f"{key}={value}"])
173 cmd.append(container_name)
174 cmd.extend(_login_shell(inner))
175 logger.debug("t3 serve: %s", inner)
176 subprocess.run(cmd, check=True, capture_output=True)
179def wait_until_ready(
180 container_name: str,
181 *,
182 port: int = T3_CONTAINER_PORT,
183 timeout: float = T3_READY_TIMEOUT,
184) -> bool:
185 """Poll the descriptor endpoint until the server answers or *timeout*."""
186 deadline = time.monotonic() + timeout
187 while time.monotonic() < deadline:
188 if server_running(container_name, port):
189 return True
190 time.sleep(1.0)
191 return False
194def server_log_tail(container_name: str, lines: int = 20) -> str:
195 """Return the tail of the detached server's log, for failure reporting."""
196 result = _exec(
197 container_name,
198 _login_shell(f"tail -n {lines} {shlex.quote(T3_LOG_PATH)} 2>/dev/null || true"),
199 timeout=15,
200 )
201 return (result.stdout or "").strip()
204def add_project(container_name: str, workdir: str, title: str) -> None:
205 """Register *workdir* as a T3 project.
207 ``t3 serve`` deliberately does not auto-create a project for its cwd, so
208 without this the paired device connects to an empty environment. Adding an
209 already-known project is an error in t3, which is fine — it means the
210 project survived from a previous run.
211 """
212 result = _t3(container_name, ["project", "add", workdir, "--title", title])
213 if result.returncode != 0:
214 logger.debug(
215 "t3 project add returned %s (already registered?): %s",
216 result.returncode,
217 (result.stderr or result.stdout).strip(),
218 )
221def mint_pairing_token(container_name: str) -> str | None:
222 """Mint a one-time pairing token for the running server.
224 ``t3 pair`` prints a pairing URL built from the container's own address,
225 which no phone can reach; only the token is portable, so that is all we
226 take. The URL is rebuilt against the published host port by the caller.
227 """
228 result = _t3(container_name, ["pair"])
229 if result.returncode != 0:
230 logger.debug("t3 pair failed: %s", (result.stderr or result.stdout).strip())
231 return None
232 for line in (result.stdout or "").splitlines():
233 stripped = line.strip()
234 if stripped.startswith("Token:"):
235 token = stripped.split(":", 1)[1].strip()
236 if token:
237 return token
238 return None
241def connect_status(container_name: str) -> dict[str, object] | None:
242 """Return ``t3 connect status --json`` as a dict, or None when unavailable.
244 The connect command group is absent from builds without cloud
245 configuration, and present-but-unauthorized otherwise, so a failure here is
246 informational only.
247 """
248 result = _t3(container_name, ["connect", "status", "--json"], timeout=30)
249 if result.returncode != 0:
250 return None
251 try:
252 parsed = json.loads(result.stdout)
253 except (ValueError, TypeError):
254 return None
255 return parsed if isinstance(parsed, dict) else None
258def host_lan_ip() -> str | None:
259 """Best-effort LAN address of the machine running ai-shell.
261 A UDP socket sends nothing on ``connect``; it just makes the kernel pick
262 the interface it would route through, which is the address a phone on the
263 same network can reach.
264 """
265 try:
266 with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
267 sock.settimeout(0.5)
268 sock.connect(("8.8.8.8", 80))
269 address: str = sock.getsockname()[0]
270 except OSError:
271 return None
272 return address or None
275def _published_host_port(manager: ContainerManager, container_name: str, port: int) -> int | None:
276 """Host port bound to *port*, read from the live container."""
277 from docker.errors import DockerException
279 try:
280 ports = manager.container_ports(container_name)
281 except DockerException:
282 return None
283 binding = (ports or {}).get(f"{port}/tcp")
284 if not binding:
285 return None
286 try:
287 return int(binding.rsplit(":", 1)[1])
288 except (IndexError, ValueError):
289 return None
292def _print_connect_status(status: dict[str, object] | None) -> None:
293 """Print the T3 Connect line, which only matters once linked."""
294 if status is None:
295 return
296 if not status.get("desired"):
297 console.print(
298 " [dim]T3 Connect: off — run [/dim][cyan]t3 connect link --headless[/cyan]"
299 "[dim] inside [/dim][cyan]ai-shell shell[/cyan][dim] "
300 "to reach this project from outside the LAN.[/dim]"
301 )
302 return
303 relay = status.get("relayUrl")
304 if status.get("linked") and relay:
305 console.print(f" [green]T3 Connect: linked[/green] [dim]({relay})[/dim]")
306 else:
307 console.print(" [yellow]T3 Connect: enabled, waiting for the relay link[/yellow]")
310def attach(
311 manager: ContainerManager,
312 container_name: str,
313 config: AiShellConfig,
314 exec_env: dict[str, str] | None = None,
315 *,
316 port: int = T3_CONTAINER_PORT,
317) -> None:
318 """Start (or reuse) the T3 Code server and print pairing details.
320 Idempotent: re-running against a live server skips the start and just mints
321 a fresh pairing token, which is how you pair a second device.
322 """
323 ensure_cli(container_name)
325 workdir = f"/root/projects/{config.project_name}"
327 if server_running(container_name, port):
328 console.print(f"[dim]T3 Code server already running in {container_name}.[/dim]")
329 else:
330 console.print(f"[bold]Starting the T3 Code server in {container_name}...[/bold]")
331 start_server(container_name, workdir, port=port, extra_env=exec_env)
332 with console.status("[bold]Waiting for T3 Code to come up...[/bold]", spinner="dots"):
333 ready = wait_until_ready(container_name, port=port)
334 if not ready:
335 tail = server_log_tail(container_name)
336 raise T3Error(
337 f"The T3 Code server did not come up on port {port} within "
338 f"{int(T3_READY_TIMEOUT)}s.\n"
339 f" Log ({T3_LOG_PATH}):\n{tail or ' (empty)'}"
340 )
342 add_project(container_name, workdir, config.project_name)
344 host_port = _published_host_port(manager, container_name, port)
345 token = mint_pairing_token(container_name)
347 console.print("[bold]T3 Code[/bold]")
348 console.print(f" [dim]Project:[/dim] {config.project_name} [dim]({workdir})[/dim]")
350 if host_port is None:
351 console.print(
352 f" [yellow]Container port {port} is not published, so this server cannot be "
353 "paired over the LAN.[/yellow]"
354 )
355 console.print(
356 " [yellow]This container predates T3 support — recreate it with "
357 "[/yellow][cyan]ai-shell manage clean[/cyan][yellow] and rerun.[/yellow]"
358 )
359 elif token is None:
360 console.print(
361 " [yellow]Could not mint a pairing token; the server is up at "
362 f"http://localhost:{host_port} — pair from the desktop app instead.[/yellow]"
363 )
364 else:
365 lan_ip = host_lan_ip()
366 pair_host = lan_ip or "localhost"
367 pairing_url = f"http://{pair_host}:{host_port}/pair#token={token}"
368 console.print(
369 f" [dim]Server:[/dim] http://localhost:{host_port} [dim](this machine)[/dim]"
370 )
371 console.print(f" [green bold]Pair:[/green bold] {pairing_url}")
372 console.print(f" [dim]Host:[/dim] http://{pair_host}:{host_port}")
373 console.print(f" [dim]Token:[/dim] {token}")
374 qr = render_qr(pairing_url)
375 if qr:
376 console.print()
377 # Printed raw: rich would try to interpret the block art as markup.
378 print(qr)
379 if lan_ip is None:
380 console.print(
381 " [yellow]Could not determine this machine's LAN address; the pairing URL "
382 "only works from this machine.[/yellow]"
383 )
385 _print_connect_status(connect_status(container_name))
386 console.print(
387 " [dim]The server keeps running in the container after this session exits.[/dim]"
388 )