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

1"""T3 Code integration for dev containers. 

2 

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. 

9 

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. 

13 

14Two connection paths exist and both work through the same running server: 

15 

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. 

24 

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

28 

29from __future__ import annotations 

30 

31import json 

32import logging 

33import shlex 

34import socket 

35import subprocess 

36import time 

37from typing import TYPE_CHECKING 

38 

39from rich.console import Console 

40 

41from ai_shell.defaults import T3_CONTAINER_PORT 

42 

43# Re-exported: the QR renderer is shared with the Expo integration. 

44from ai_shell.qr import render_qr 

45 

46if TYPE_CHECKING: 

47 from ai_shell.config import AiShellConfig 

48 from ai_shell.container import ContainerManager 

49 

50logger = logging.getLogger(__name__) 

51console = Console(stderr=True) 

52 

53#: npm package providing the ``t3`` binary. 

54T3_NPM_PACKAGE = "t3" 

55 

56#: Unauthenticated descriptor endpoint — the readiness probe t3's own CLI uses. 

57T3_WELL_KNOWN_PATH = "/.well-known/t3/environment" 

58 

59#: Where the detached server's stdout/stderr lands inside the container. 

60T3_LOG_PATH = "/var/log/ai-shell/t3-serve.log" 

61 

62#: How long to wait for a freshly started server to answer the probe. 

63T3_READY_TIMEOUT = 90.0 

64 

65#: npm install can be slow on a cold npm cache volume. 

66T3_INSTALL_TIMEOUT = 900 

67 

68 

69class T3Error(Exception): 

70 """T3 Code could not be started or paired inside the dev container.""" 

71 

72 

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) 

88 

89 

90def _login_shell(script: str) -> list[str]: 

91 """Wrap *script* so it runs with the container's login PATH. 

92 

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] 

98 

99 

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) 

108 

109 

110def ensure_cli(container_name: str) -> None: 

111 """Make sure the ``t3`` binary exists in the container. 

112 

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 

119 

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 ) 

131 

132 

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 

147 

148 

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. 

157 

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. 

161 

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) 

177 

178 

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 

192 

193 

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

202 

203 

204def add_project(container_name: str, workdir: str, title: str) -> None: 

205 """Register *workdir* as a T3 project. 

206 

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 ) 

219 

220 

221def mint_pairing_token(container_name: str) -> str | None: 

222 """Mint a one-time pairing token for the running server. 

223 

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 

239 

240 

241def connect_status(container_name: str) -> dict[str, object] | None: 

242 """Return ``t3 connect status --json`` as a dict, or None when unavailable. 

243 

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 

256 

257 

258def host_lan_ip() -> str | None: 

259 """Best-effort LAN address of the machine running ai-shell. 

260 

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 

273 

274 

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 

278 

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 

290 

291 

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

308 

309 

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. 

319 

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) 

324 

325 workdir = f"/root/projects/{config.project_name}" 

326 

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 ) 

341 

342 add_project(container_name, workdir, config.project_name) 

343 

344 host_port = _published_host_port(manager, container_name, port) 

345 token = mint_pairing_token(container_name) 

346 

347 console.print("[bold]T3 Code[/bold]") 

348 console.print(f" [dim]Project:[/dim] {config.project_name} [dim]({workdir})[/dim]") 

349 

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 ) 

384 

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 )