#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""PTY bridge: run an interactive `solver` shell on a pseudo-terminal.`PtySession` starts the shell on a PTY so the child sees a real terminal(`sys.stdin.isatty()` is true) and runs the full prompt-toolkit interactiveloop — completion, multi-line `{ … }` blocks, rich colours — rather than thenon-interactive piped fallback.The child is spawned with `pty.openpty()` + `subprocess.Popen` — **not**`pty.fork()`: the service's event loop runs executor threads (the PTYdrainers), and forking a threaded process to run Python before exec isdeadlock-prone (and a `DeprecationWarning` since 3.12). Popen's C-levelfork+exec is async-signal-safe; a tiny `-c` bootstrap then runs *after* execto make the slave the **controlling terminal** (`setsid` + `TIOCSCTTY`, what`pty.fork` would have done) so the line discipline delivers Ctrl-C as SIGINT.The parent keeps the PTY master file descriptor: read it for the shell'soutput, write to it for keystrokes, ioctl it to propagate the browserterminal's size.Identity transfers by the **one-time shell ticket**, never theenvironment as a credential: the child gets `SOLVER_TICKET` and redeems it atstartup (:mod:`solver.auth.identity`) over `EULER_AUTH_SOCKET` — the samesocket the service minted it from — which consumes the ticket and returns theauthoritative `(email, profile, …)`. The instance's **pin** carries in theenvironment so the child can refuse a ticket routed to the wrong instance: theper-user service passes `slug` (`EULER_USER_SLUG` — the redeemed e-mail mustmap to it); the legacy per-profile ws passes `profile`(`EULER_PROFILE`). Any inherited `SOLVER_USER` is dropped — it is display-onlyand the ticket is the truth."""from__future__importannotations__all__=['PtySession']importfcntlimportosimportptyimportsecretsimportstructimportsubprocessimportsysimporttermiosfromsolver.config.envimportload_spec#: The environment variable that hands the shell its OSC key (:mod:`euler_webshell.shell.osc`).OSC_KEY_ENV:str='EULER_OSC_KEY'#: How many bytes to pull off the master fd per read._READ_CHUNK:int=65536#: Post-exec bootstrap: session leader + controlling terminal, then the shell.#: Runs as `python -c <this> <argv...>` (so `sys.argv[1:]` is the command) —#: after Popen's exec, hence thread-safe where a preexec_fn would not be._CTTY_BOOTSTRAP:str=('import fcntl, os, sys, termios\n''os.setsid()\n''fcntl.ioctl(0, termios.TIOCSCTTY, 0)\n''os.execvp(sys.argv[1], sys.argv[1:])\n')classPtySession:"""An interactive `solver` shell running on a pseudo-terminal. On construction the child is spawned on a fresh PTY: it execs *argv* (the solver shell; tests substitute a stub) with `SOLVER_TICKET` and the instance pin (`EULER_USER_SLUG` or `EULER_PROFILE`) exported and a colour-capable `TERM`; the parent retains the PTY master `fd`. The caller drives I/O — `read()`/`write()` move bytes, `resize()` propagates the browser terminal geometry, `close()` terminates the child. """def__init__(self,ticket:str,profile:str='',argv:tuple[str,...]=(),auth_socket:str='',slug:str='')->None:env=dict(os.environ)env['TERM']='xterm-256color'# prompt-toolkit/rich render styled outputenv['SOLVER_TICKET']=ticket# single-use; consumed at redemption#: The key this shell's OSC 5379 sequences carry; the page learns it on attach and#: ignores any sequence without it (manager.py, terminal.js).self.osc_key:str=secrets.token_hex(16)env[OSC_KEY_ENV]=self.osc_keyifslug:env['EULER_USER_SLUG']=slug# per-user instance pinifprofile:env['EULER_PROFILE']=profile# legacy per-profile ws pinifauth_socket:# The child redeems against the *same* socket the parent minted from.# Without this it falls back to the compiled-in default path, which is# right only when the service happened to be configured by that env var# — the socket is the service's configuration, so pass it explicitly.env[load_spec('ws').entries['auth_socket'].env_var]=auth_socketenv.pop('SOLVER_USER',None)# display-only; the ticket is the identitymaster,slave=pty.openpty()try:self._process=subprocess.Popen([sys.executable,'-c',_CTTY_BOOTSTRAP,*argv],stdin=slave,stdout=slave,stderr=slave,env=env,close_fds=True)finally:os.close(slave)# the child's stdio holds the slave nowself.fd:int=masterself.pid:int=self._process.piddefread(self)->bytes:"""Read available output from the shell; b'' signals the child has exited. A PTY master raises OSError/EIO once the slave side is gone (the child exited); that is reported as end-of-stream rather than an error. """try:returnos.read(self.fd,_READ_CHUNK)exceptOSError:returnb''defwrite(self,data:bytes)->None:"""Forward keystrokes (raw bytes from the browser terminal) to the shell."""os.write(self.fd,data)defresize(self,cols:int,rows:int)->None:"""Set the PTY window size so the shell re-renders at the browser's geometry."""winsize=struct.pack('HHHH',rows,cols,0,0)fcntl.ioctl(self.fd,termios.TIOCSWINSZ,winsize)defis_alive(self)->bool:"""True while the child shell process is still running."""returnself._process.poll()isNonedefclose(self)->None:"""Terminate the shell and release the master fd (idempotent)."""ifself._process.poll()isNone:self._process.terminate()try:self._process.wait(timeout=5)exceptsubprocess.TimeoutExpired:# pragma: no cover — a wedged shellself._process.kill()self._process.wait()# Once only: the number is free again after this, and the next connection accepted# may hold it by the time the drainer's shutdown calls here too.fd,self.fd=self.fd,-1iffd>=0:try:os.close(fd)exceptOSError:pass