#!/usr/bin/env python3 """etch — drive a persistent EternalTerminal session programmatically. Wraps `et` in a PTY (via a small built-in engine), neutralizes the prompt to get a clean byte stream, and runs commands framed by unique sentinels so output and exit codes extract reliably regardless of the remote prompt's rendering. Library: EtSession (one-shot) / open_session(key=...) (warm daemon). CLI: `etch KEY ...` (see --help). """ from __future__ import annotations import base64 import codecs import fcntl import getpass import glob import json import os import pty import re import select import shlex import signal import socket import struct import subprocess import sys import termios import time import tty import uuid from typing import Any, Callable, Union, cast # The remote PTY runs in canonical mode, which truncates a single input line at # the kernel's cap (~1KB). A framed run() line longer than this is silently cut, # the end sentinel never matches, and the leftover poisons the session. run() # reroutes anything longer than this through run_script()'s chunked path. _LINE_CAP = 900 # ───────────────────────────────────────────────────────────────────────────── # PTY/expect engine # ----------------- # A small, self-contained pseudo-terminal driver: spawn a command under a PTY and # drive it with an expect-style read buffer. This is all etch needs to make `et` # believe a human is on the other end, with no third-party dependencies. # # spawn(cmd, encoding=, timeout=, dimensions=, codec_errors=) -> child # child.expect(pattern | list, timeout=) child.expect_exact(s, timeout=) # child.read_nonblocking(size=, timeout=) child.send(s) child.sendline(s) # child.before child.after child.match child.close(force=) # TIMEOUT / EOF (raised, and usable as expect() patterns to match instead of raise) # ───────────────────────────────────────────────────────────────────────────── class TIMEOUT(Exception): """No match or data arrived before the deadline. Usable as an expect() pattern to match-on-timeout instead of raising.""" class EOF(Exception): """The child's PTY hit end-of-file / the process exited. Usable as an expect() pattern to match-on-EOF instead of raising.""" _Pattern = Union[str, "re.Pattern[str]", type[TIMEOUT], type[EOF]] _ExpectArg = Union[_Pattern, list[_Pattern]] class _PtyProcess: """Spawn a command under a pseudo-terminal and drive it with an expect-style read buffer. A minimal driver: only the behavior etch needs.""" # A brief pause before each write avoids losing input to a remote PTY that is # not yet ready to read. Tunable if latency ever matters. delaybeforesend: float = 0.05 def __init__( self, command: str, encoding: str = "utf-8", timeout: float = 30, dimensions: tuple[int, int] = (24, 80), codec_errors: str = "strict", ) -> None: self.timeout = timeout self.before: str = "" self.after: str | type[TIMEOUT] | type[EOF] = "" self.match: re.Match[str] | None = None self._buf: str = "" self._decoder = codecs.getincrementaldecoder(encoding)(codec_errors) argv = shlex.split(command) pid, fd = pty.fork() if pid == 0: # child: replace ourselves with the command on the PTY slave try: os.execvp(argv[0], argv) except Exception: os._exit(127) self.pid: int = pid self.fd: int = fd rows, cols = dimensions try: fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) except OSError: pass # ── low-level I/O ───────────────────────────────────────────────────────── def _read_fd(self, timeout: float) -> str | None: """Wait up to `timeout` for output; decode and return a chunk (possibly '' on a partial multibyte char). Return None on timeout; raise EOF when gone.""" try: empty: list[int] = [] ready, _, _ = select.select([self.fd], empty, empty, max(0.0, timeout)) except (OSError, ValueError): raise EOF() if not ready: return None try: data = os.read(self.fd, 4096) except OSError: # Linux reports the PTY master EOF as EIO raise EOF() if not data: # macOS reports EOF as an empty read raise EOF() return self._decoder.decode(data) def send(self, s: str) -> int: if self.delaybeforesend: time.sleep(self.delaybeforesend) return os.write(self.fd, s.encode("utf-8", "replace")) def sendline(self, s: str = "") -> int: return self.send(s + "\n") def read_nonblocking(self, size: int = 1, timeout: float | None = None) -> str: """Up to `size` chars: drain the expect buffer first, else read the fd. Raise TIMEOUT if nothing arrives in time, EOF at end of file.""" if self._buf: out, self._buf = self._buf[:size], self._buf[size:] return out chunk = self._read_fd(self.timeout if timeout is None else timeout) if chunk is None: raise TIMEOUT() if size and len(chunk) > size: self._buf = chunk[size:] + self._buf chunk = chunk[:size] return chunk # ── expect machinery ────────────────────────────────────────────────────── @staticmethod def _compile(pattern: _ExpectArg) -> list[object]: items = pattern if isinstance(pattern, list) else [pattern] out: list[object] = [] for p in items: out.append(re.compile(p) if isinstance(p, str) else p) return out def _set( self, before: str, after: str | type[TIMEOUT] | type[EOF], match: re.Match[str] | None, rest: str, ) -> None: self.before, self.after, self.match, self._buf = before, after, match, rest def expect(self, pattern: _ExpectArg, timeout: float | None = None) -> int: """Read until one of `pattern` (a regex, or a list mixing regexes with the EOF/TIMEOUT classes) matches; return its index and set before/after/match. EOF/TIMEOUT in the list match instead of raising.""" compiled = self._compile(pattern) eof_idx: int | None = None tmo_idx: int | None = None regexes: list[tuple[int, re.Pattern[str]]] = [] for i, p in enumerate(compiled): if p is EOF: eof_idx = i elif p is TIMEOUT: tmo_idx = i elif isinstance(p, re.Pattern): regexes.append((i, cast("re.Pattern[str]", p))) deadline = time.time() + (self.timeout if timeout is None else timeout) while True: best: tuple[int, int, re.Match[str]] | None = None for i, rx in regexes: m = rx.search(self._buf) if m is not None and (best is None or m.start() < best[0]): best = (m.start(), i, m) if best is not None: _, i, m = best self._set(self._buf[: m.start()], m.group(0), m, self._buf[m.end() :]) return i remaining = deadline - time.time() if remaining <= 0: if tmo_idx is not None: self._set(self._buf, TIMEOUT, None, "") return tmo_idx raise TIMEOUT() try: chunk = self._read_fd(remaining) except EOF: if eof_idx is not None: self._set(self._buf, EOF, None, "") return eof_idx raise if chunk: self._buf += chunk def expect_exact(self, s: str, timeout: float | None = None) -> int: """Like expect() but matches a literal substring (no regex).""" deadline = time.time() + (self.timeout if timeout is None else timeout) while True: idx = self._buf.find(s) if idx >= 0: self._set(self._buf[:idx], s, None, self._buf[idx + len(s) :]) return 0 remaining = deadline - time.time() if remaining <= 0: raise TIMEOUT() chunk = self._read_fd(remaining) # raises EOF when the child is gone if chunk: self._buf += chunk def close(self, force: bool = True) -> None: try: os.close(self.fd) except OSError: pass if self.pid: try: os.kill(self.pid, signal.SIGKILL if force else signal.SIGHUP) except ProcessLookupError: pass try: os.waitpid(self.pid, 0) except OSError: pass self.pid = 0 # Alias so call sites read `spawn(...)`. spawn = _PtyProcess _ANSI = re.compile( r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[=>]" ) def _clean(s: str) -> str: return _ANSI.sub("", s).replace("\r", "") _TRANSPORTS = {"et": "et {host}", "ssh": "ssh -tt {host}"} def _transport_cmd(via: str, host: str) -> str: """The command that opens an interactive PTY shell on HOST. `via` is a known transport name (et/ssh) or a literal 'CMD {host}' template (host is appended if it has no {host}). The default, et, gives the resilient eternal session.""" tmpl = _TRANSPORTS.get(via, via) return tmpl.format(host=host) if "{host}" in tmpl else "%s %s" % (tmpl, host) class EtSession: def __init__( self, host: str, via: str = "et", connect_timeout: float = 60, dims: tuple[int, int] = (50, 200), remote_histfile: str | None = "/dev/null", cmdlog: str | None = None, ) -> None: self.host = host self.timeout = connect_timeout # remote_histfile: where the remote shell writes history. # '/dev/null' (default) = discard; or a path = dedicated audit file; # set None to leave the shell's default untouched. self.remote_histfile = remote_histfile # cmdlog: optional LOCAL file; the wrapper appends every command it runs. self.cmdlog = cmdlog self.prompt = "" # verbatim remote prompt line from the last expect() # before/after/match are populated dynamically; keep the child typed Any and # lean on the sentinel framing. The engine itself is fully typed above. self.child: Any = spawn( _transport_cmd(via, host), encoding="utf-8", timeout=connect_timeout, dimensions=dims, codec_errors="replace", ) def connect(self) -> EtSession: c = self.child c.expect("❯", timeout=self.timeout) self._drain(0.6) # flush the full p10k prompt render hf = self.remote_histfile # zsh side: stop it saving the exec line / anything to the real history if hf is not None: c.sendline("HISTFILE=%s; fc -p /dev/null 2>/dev/null" % hf) self._drain(0.3) # replace noisy interactive zsh+p10k with a bare, quiet bash c.sendline("exec bash --norc --noprofile") self._drain(0.6) # clean-room env: empty prompts, dumb term, no \r, no pagers/interactive prompts setup = ( "export PS1='' PS2='' PROMPT_COMMAND='' TERM=dumb " "PAGER=cat GIT_PAGER=cat GIT_TERMINAL_PROMPT=0 " "DEBIAN_FRONTEND=noninteractive; stty -echo -onlcr 2>/dev/null" ) if hf is None: pass elif hf == "/dev/null": setup = "set +o history; " + setup # don't record at all else: setup += "; export HISTFILE=%s" % hf # dedicated audit file c.sendline(setup) self._drain(0.6) nonce = "RDY_" + uuid.uuid4().hex[:8] for _ in range(8): c.sendline("echo %s" % nonce) try: c.expect_exact(nonce, timeout=4) # stty -echo => appears once (output) except TIMEOUT: continue except EOF: raise RuntimeError("et session closed during connect") else: self._drain(0.4) return self raise RuntimeError("sync failed; tail=%r" % (c.before[-300:],)) def _drain(self, idle: float = 0.3) -> None: """Discard whatever output is buffered right now (settle the stream).""" self.read_available(idle) def _resync(self, attempts: int = 3) -> bool: """Recover a desynced session after a framing failure (a command that hung reading stdin, or a truncated over-long line). Interrupt whatever is running with Ctrl-C, drain, and confirm a clean prompt with a nonce. Returns True if the session is usable again. This keeps one bad command from poisoning the whole warm key.""" c = self.child try: for _ in range(attempts): c.send("\x03") # Ctrl-C: kill a hung/partial command self._drain(0.4) nonce = "RSY_" + uuid.uuid4().hex[:8] c.sendline("echo %s" % nonce) try: c.expect_exact(nonce, timeout=4) self._drain(0.3) return True except TIMEOUT: continue except EOF: return False except Exception: return False return False def run(self, cmd: str, timeout: float = 60) -> tuple[str, int]: c = self.child if self.cmdlog: with open(self.cmdlog, "a") as fh: fh.write(cmd + chr(10)) s = "S_" + uuid.uuid4().hex[:8] e = "E_" + uuid.uuid4().hex[:8] framed = "printf '%%s\\n' '%s'; %s; printf '%s%%d\\n' \"$?\"" % (s, cmd, e) # Over-long lines would be truncated by the PTY's canonical cap and wedge # the session; route them through the chunk-safe run_script path instead. if len(framed) > _LINE_CAP: return self.run_script(cmd, timeout=timeout) c.sendline(framed) try: c.expect(re.escape(e) + r"(\d+)", timeout=timeout) except TIMEOUT: ok = self._resync() raise RuntimeError( "run() timed out waiting for the end sentinel (the command may " "read stdin or hang; run() does not forward stdin, and is for " "short commands, use run_script/`script` for input or long " "payloads). session %s. cmd=%r" % ("resynced" if ok else "may be wedged", cmd[:120]) ) code = int(c.match.group(1)) raw = _clean(c.before) if s in raw: raw = raw.split(s, 1)[1] return raw.strip("\n"), code def run_script( self, script: str, timeout: float = 180, chunk: int = 400 ) -> tuple[str, int]: """Inject an arbitrarily long/multi-line script safely. PTY canonical mode caps a single input line (~1KB), so we base64-encode and append in small chunks to a remote file, then decode and run it (preserving its exit code).""" blob = base64.b64encode(script.encode()).decode() remote = "/tmp/etch_%s.b64" % uuid.uuid4().hex[:8] _, code = self.run(": > %s" % remote) if code != 0: raise RuntimeError("could not create remote temp file") for i in range(0, len(blob), chunk): _, code = self.run( "printf '%%s' '%s' >> %s" % (blob[i : i + chunk], remote) ) if code != 0: raise RuntimeError("chunk append failed at offset %d" % i) return self.run( "base64 -d %s | bash; rc=$?; rm -f %s; (exit $rc)" % (remote, remote), timeout=timeout, ) # ── Interactive driving (write_bash-style) ──────────────────────────────── # run()/run_script() are for commands that finish and return to the prompt. # For programs that PROMPT mid-run (sudo, [y/n], REPLs, mysql>), use these # raw primitives: send input, expect a prompt pattern, read the live stream. # Output here is raw (the program's own prompts/echo) — you match prompts # yourself. Don't interleave run() while an interactive program is mid-flight; # expect it back to the shell first. (Never hardcode a password — for an auth # prompt, route the secret through secure input, not the script.) def send(self, data: str) -> None: """Write raw bytes/text to the remote stdin (no trailing newline).""" self.child.send(data) def sendline(self, data: str = "") -> None: """Write a line (adds newline) to the remote stdin.""" self.child.sendline(data) def _prompt_line(self) -> str: """Reconstruct the verbatim prompt the program is waiting at: the last non-empty line of what preceded the match, plus the matched text itself (e.g. '[sudo] password for gmendezb:'). Show this to a human who has to answer it, so they know exactly what's being asked.""" before = _clean(self.child.before or "") after = self.child.after if isinstance(self.child.after, str) else "" last = "" for ln in before.splitlines(): if ln.strip(): last = ln return (last + _clean(after)).strip() def expect(self, pattern: str | list[str], timeout: float = 30) -> tuple[str, int]: """Wait for a regex pattern (or list); returns (cleaned_text_before, index). Also sets self.prompt to the verbatim prompt line that matched.""" idx = self.child.expect(pattern, timeout=timeout) self.prompt = self._prompt_line() return _clean(self.child.before), idx def expect_exact(self, s: str, timeout: float = 30) -> str: """Like expect() but matches a literal string (no regex). Returns text before.""" self.child.expect_exact(s, timeout=timeout) self.prompt = self._prompt_line() return _clean(self.child.before) def read_available(self, idle: float = 0.3) -> str: """Drain and return whatever output is currently buffered.""" buf = "" while True: try: buf += self.child.read_nonblocking(4096, timeout=idle) except (TIMEOUT, EOF): break return _clean(buf) def read(self, timeout: float = 10, quiet: float = 0.4) -> str: """Show whatever the other end is saying when you DON'T have a prompt to expect: wait up to `timeout` for output to start, then drain until `quiet` seconds of silence. Returns cleaned text. Pattern-free 'peek at the screen'. Line/stream output works; full-screen TUIs (vim/top) that redraw via cursor escapes won't linearize cleanly — those need a terminal emulator (out of scope).""" deadline = time.time() + timeout buf = "" while True: remaining = deadline - time.time() if remaining <= 0: break try: buf += self.child.read_nonblocking(4096, timeout=min(quiet, remaining)) except TIMEOUT: if buf: break # had output, now silent => done except EOF: break return _clean(buf) def close(self) -> None: try: self.child.sendline("exit") self.child.expect(EOF, timeout=5) except Exception: pass finally: self.child.close(force=True) # ───────────────────────────────────────────────────────────────────────────── # Daemon mode — keep ONE warm EtSession alive across separate processes/one-shot # calls, so any later invocation reuses it (no ~13s reconnect) and shell state # persists for the whole experiment/session. # # CONCURRENCY MODEL (important): a single EtSession is ONE serial conversation — # interleaving run()s would tangle their sentinels. So the daemon is keyed: each # `key` is its own daemon → its own `et` client → its own remote shell. # * Same key = same warm shell, requests SERIALIZED (safe, but not parallel). # * Diff key = isolated shell, runs in PARALLEL. # => give each agent/subagent its own key (e.g. ETCH_KEY). Never share one key # across actors that run concurrently. # ───────────────────────────────────────────────────────────────────────────── # ── Daemon: paths, process liveness & filesystem introspection ── def _safe(key: str) -> str: """Filesystem-safe form of a daemon key.""" return re.sub(r"[^A-Za-z0-9_.-]", "_", key) def _user_prefix() -> str: """Shared /tmp path prefix for this user's daemon files.""" return "/tmp/etch-%s-" % getpass.getuser() def _sock_path(key: str) -> str: return _user_prefix() + _safe(key) + ".sock" def _pid_path(key: str) -> str: return _sock_path(key)[:-5] + ".pid" # /tmp/etch--.pid def _trace_path(key: str) -> str: return _sock_path(key)[:-5] + ".trace" # /tmp/etch--.trace (peep) def _alive(pid: int) -> bool: # zombie-aware: a process that exists only as a defunct/zombie (state 'Z') # is treated as NOT alive, so reap() isn't fooled by a just-killed daemon. try: st = subprocess.run( ["ps", "-p", str(pid), "-o", "state="], capture_output=True, text=True, timeout=5, ).stdout.strip() except Exception: # ps unavailable: fall back to signal-0 probe try: os.kill(pid, 0) return True except OSError: return False return bool(st) and not st.startswith("Z") def reap() -> None: """Sweep leftovers from crashed daemons: if a daemon PID is dead, kill its orphaned `et` child (by recorded PID, verified) and remove its socket+pidfile. Cheap; call opportunistically (open_session does).""" for pf in glob.glob(_user_prefix() + "*.pid"): try: parts = open(pf).read().split() dpid, epid = int(parts[0]), int(parts[1]) # pidfile: " [host]" except Exception: try: os.unlink(pf) except OSError: pass continue if _alive(dpid): continue # daemon still running; leave it if _alive(epid): # verify the PID is really an `et` client before killing (PID reuse guard) try: cmd = subprocess.run( ["ps", "-p", str(epid), "-o", "command="], capture_output=True, text=True, timeout=5, ).stdout except Exception: cmd = "" if "/et " in cmd or cmd.strip().startswith("et ") or " et " in cmd: try: os.kill(epid, signal.SIGTERM) except OSError: pass for p in (pf, pf[:-4] + ".sock", pf[:-4] + ".trace"): try: os.unlink(p) except OSError: pass # Also sweep orphaned sockets with no pidfile (a daemon that crashed after # binding but before writing its pidfile, e.g. connect() failed). The pidfile # loop above can't see those. A socket with a live listener is a daemon still # starting up, so probe it and only remove the ones nothing answers on. for sk in glob.glob(_user_prefix() + "*.sock"): if os.path.exists(sk[:-5] + ".pid"): continue # has a pidfile (handled above, or a running daemon) try: probe = socket.socket(socket.AF_UNIX) probe.settimeout(1) probe.connect(sk) probe.close() continue # someone is listening (a daemon starting up): leave it except OSError: for p in (sk, sk[:-5] + ".trace"): try: os.unlink(p) except OSError: pass def _live_keys() -> list[str]: """Keys of currently-bound daemons (from their socket files).""" prefix = _user_prefix() return [s[len(prefix) : -5] for s in sorted(glob.glob(prefix + "*.sock"))] def _pidfile_fields(key: str) -> tuple[str, str, str | None]: """(daemon_pid_str, et_pid_str, host) from a daemon's pidfile, or ('?','?',None).""" try: parts = open(_pid_path(key)).read().split() return parts[0], parts[1], (parts[2] if len(parts) > 2 else None) except Exception: return "?", "?", None def _daemon_host(key: str) -> str | None: """The host a live daemon for KEY serves (from its pidfile), or None.""" return _pidfile_fields(key)[2] # ── Daemon: length-prefixed JSON wire protocol (4-byte big-endian length + body) ── def _recvn(conn: socket.socket, n: int) -> bytes | None: buf = b"" while len(buf) < n: chunk = conn.recv(n - len(buf)) if not chunk: return None buf += chunk return buf def _send_msg(conn: socket.socket, obj: Any) -> None: data = json.dumps(obj).encode() conn.sendall(struct.pack(">I", len(data)) + data) def _recv_msg(conn: socket.socket) -> dict[str, Any] | None: hdr = _recvn(conn, 4) if not hdr: return None (n,) = struct.unpack(">I", hdr) body = _recvn(conn, n) return None if body is None else json.loads(body.decode()) def _xlate_pat(pat: str | list[str]) -> Any: # the wire can't carry TIMEOUT/EOF objects, so callers pass the # sentinels "<>"/"<>" and we translate them back here. def one(p: str) -> Any: if p == "<>": return TIMEOUT if p == "<>": return EOF return p return [one(p) for p in pat] if isinstance(pat, list) else one(pat) # ── Daemon: client (warm session reuse across processes) ── class DaemonClient: """Thin client to a per-key etch daemon. Auto-starts the daemon on first use. Same .run()/.run_script() API as EtSession, but warm across processes.""" def __init__( self, key: str, host: str | None = None, via: str = "et", idle: int = 900, autostart: bool = True, connect_timeout: float = 90, ) -> None: self.key, self.host, self.via, self.idle = key, host, via, idle self.path = _sock_path(key) self.conn: socket.socket | None = None # set once connected self.prompt = "" # verbatim remote prompt line from the last expect() self.started = ( False # True if this client spawned the daemon (vs reused a warm one) ) if not self._connect(): if not autostart: raise RuntimeError("no daemon for key %r" % key) self._start(connect_timeout) self.started = True def _connect(self, timeout: float = 10) -> bool: try: c = socket.socket(socket.AF_UNIX) c.settimeout(timeout) c.connect(self.path) self.conn = c return True except OSError: return False def _start(self, connect_timeout: float) -> None: if not self.host: raise RuntimeError("no host to start a daemon for key %r" % self.key) try: os.unlink(self.path) # clear a stale socket except OSError: pass log = open("/tmp/etch-%s.log" % _safe(self.key), "a") subprocess.Popen( [ sys.executable, os.path.realpath(__file__), "--daemon", self.key, self.host, self.via, str(self.idle), ], start_new_session=True, stdin=subprocess.DEVNULL, stdout=log, stderr=log, ) # The daemon binds the socket quickly but only accepts after its ~13s # EtSession.connect(). So: wait for the socket to appear, then send one # ping with a long timeout — the daemon answers once the shell is ready. deadline = time.time() + connect_timeout while time.time() < deadline: if self._connect(timeout=connect_timeout): break time.sleep(0.3) else: raise RuntimeError("daemon socket for key %r never appeared" % self.key) try: _send_msg(cast(socket.socket, self.conn), {"op": "ping"}) r = _recv_msg(cast(socket.socket, self.conn)) except OSError as e: raise RuntimeError("daemon ping failed for key %r: %r" % (self.key, e)) if not (r and r.get("ok")): raise RuntimeError("daemon for key %r not ready" % self.key) def _rpc(self, msg: dict[str, Any], timeout: float = 60) -> dict[str, Any]: assert self.conn is not None, "not connected" self.conn.settimeout( timeout + 15 ) # generous margin over the command's own timeout _send_msg(self.conn, msg) r = _recv_msg(self.conn) if r is None: raise RuntimeError("daemon closed the connection") if "error" in r: raise RuntimeError(r["error"]) return r def run(self, cmd: str, timeout: float = 60) -> tuple[str, int]: r = self._rpc({"op": "run", "cmd": cmd, "timeout": timeout}, timeout=timeout) return r["out"], r["code"] def run_script(self, script: str, timeout: float = 180) -> tuple[str, int]: r = self._rpc( {"op": "run_script", "script": script, "timeout": timeout}, timeout=timeout ) return r["out"], r["code"] # ── Interactive driving over the daemon (mirrors EtSession's primitives) ── def send(self, data: str, secret: bool = False) -> None: self._rpc({"op": "send", "data": data, "secret": secret}, timeout=10) def sendline(self, data: str = "", secret: bool = False) -> None: self._rpc({"op": "sendline", "data": data, "secret": secret}, timeout=10) def expect(self, pattern: str | list[str], timeout: float = 30) -> tuple[str, int]: # pass TIMEOUT/EOF as the sentinels "<>"/"<>" r = self._rpc( {"op": "expect", "pattern": pattern, "timeout": timeout}, timeout=timeout ) self.prompt = r.get("prompt", "") return r["out"], r["idx"] def expect_exact(self, s: str, timeout: float = 30) -> str: r = self._rpc( {"op": "expect_exact", "s": s, "timeout": timeout}, timeout=timeout ) self.prompt = r.get("prompt", "") return r["out"] def read(self, timeout: float = 10, quiet: float = 0.4) -> str: r = self._rpc( {"op": "read", "timeout": timeout, "quiet": quiet}, timeout=timeout ) return r["out"] def read_available(self, idle: float = 0.3) -> str: r = self._rpc({"op": "read_available", "idle": idle}, timeout=max(5, idle * 3)) return r["out"] def peep_start(self) -> str: """Enable the daemon's live trace; returns the trace file path to tail.""" return cast(str, self._rpc({"op": "peep_start"}, timeout=10).get("path")) def peep_stop(self) -> None: try: self._rpc({"op": "peep_stop"}, timeout=10) except Exception: pass def shutdown(self) -> None: """Stop the daemon (closes its et session + remote shell).""" try: _send_msg(cast(socket.socket, self.conn), {"op": "shutdown"}) _recv_msg(cast(socket.socket, self.conn)) except OSError: pass self.close() def close(self) -> None: """Disconnect the client; the daemon stays warm for the next caller.""" if self.conn: try: self.conn.close() except OSError: pass self.conn = None def _random_key() -> str: """A short, opaque session handle (used when `open` gets no --key).""" return uuid.uuid4().hex[:8] def open_session( host: str, key: str | None = None, via: str = "et", idle: int = 900 ) -> DaemonClient: """Open (spawn-or-reuse) a warm per-key daemon to HOST and return a client. KEY is a logical handle for the session (random if omitted). The host lives with the daemon, not the key. `via` is the transport (et/ssh or a 'CMD {host}' template). Operate on the result with .run()/.send()/etc.; a later caller reattaches with DaemonClient(key, autostart=False). CONCURRENCY: a daemon is ONE serial conversation. One logical actor per key; give concurrent actors (e.g. subagents) distinct keys, or use EtSession() directly for an isolated one-shot shell. """ reap() # sweep any crashed-daemon orphans first return DaemonClient(key=key or _random_key(), host=host, via=via, idle=idle) # ── Daemon: server (the long-lived worker that owns one warm EtSession) ── def _daemon_main(key: str, host: str, via: str, idle: int) -> None: idle = int(idle) path = _sock_path(key) pidpath = _pid_path(key) srv = socket.socket(socket.AF_UNIX) try: srv.bind(path) # fails if another daemon already won except OSError: return os.chmod(path, 0o600) srv.listen(8) try: sess = EtSession( host=host, via=via, cmdlog="/tmp/etch-%s.cmdlog" % _safe(key), ).connect() except Exception: # connect() failed (e.g. VM unreachable): don't leave the bound socket # behind with no pidfile, that would show in `ls` as a dead orphan. try: srv.close() finally: try: os.unlink(path) except OSError: pass os._exit(1) # record daemon PID + et-client PID (for reap) + the host this daemon serves try: open(pidpath, "w").write("%d %d %s" % (os.getpid(), sess.child.pid, host)) except OSError: pass tracepath = _trace_path(key) trace: dict[str, Any] = {"fh": None, "n": 0} # peep: refcounted op-level live trace def _trace(arrow: str, text: str) -> None: if not trace["fh"]: return # zero cost when nobody is peeping t = text.replace("\n", "\\n") if len(t) > 4000: t = t[:4000] + "...(+%d)" % (len(t) - 4000) try: trace["fh"].write("%s %s %s\n" % (time.strftime("%H:%M:%S"), arrow, t)) trace["fh"].flush() except Exception: pass def _graceful(*_: Any) -> None: # Unlink the fs entries FIRST so a stopped daemon vanishes from ls/peers # immediately, before the (slow) session teardown waits on et to exit. for p in (path, pidpath, tracepath): try: os.unlink(p) except OSError: pass try: srv.close() except Exception: pass try: if trace["fh"]: trace["fh"].close() except Exception: pass try: sess.close() except Exception: pass os._exit(0) # Session ops: each turns a request into a response dict; the loop wraps any # exception into {"error": ...}. (Control ops ping/peep/shutdown are inline.) def _op_run(msg: dict[str, Any]) -> dict[str, Any]: _trace("»", "run: " + msg["cmd"]) out, code = sess.run(msg["cmd"], timeout=msg.get("timeout", 60)) _trace("«", "(rc=%d) %s" % (code, out)) return {"out": out, "code": code} def _op_run_script(msg: dict[str, Any]) -> dict[str, Any]: _trace("»", "run_script: <%d bytes>" % len(msg.get("script", ""))) out, code = sess.run_script(msg["script"], timeout=msg.get("timeout", 180)) _trace("«", "(rc=%d) %s" % (code, out)) return {"out": out, "code": code} def _op_send(msg: dict[str, Any]) -> dict[str, Any]: sess.send(msg.get("data", "")) _trace("»", "" if msg.get("secret") else repr(msg.get("data", ""))) return {"ok": True} def _op_sendline(msg: dict[str, Any]) -> dict[str, Any]: sess.sendline(msg.get("data", "")) _trace("»", "" if msg.get("secret") else msg.get("data", "")) return {"ok": True} def _op_expect(msg: dict[str, Any]) -> dict[str, Any]: out, idx = sess.expect( _xlate_pat(msg["pattern"]), timeout=msg.get("timeout", 30) ) _trace("«", "[matched %d] %s" % (idx, sess.prompt or out[-160:])) return {"out": out, "idx": idx, "prompt": sess.prompt} def _op_expect_exact(msg: dict[str, Any]) -> dict[str, Any]: out = sess.expect_exact(msg["s"], timeout=msg.get("timeout", 30)) _trace("«", sess.prompt or out[-160:]) return {"out": out, "prompt": sess.prompt} def _op_read(msg: dict[str, Any]) -> dict[str, Any]: out = sess.read(timeout=msg.get("timeout", 10), quiet=msg.get("quiet", 0.4)) _trace("«", out) return {"out": out} def _op_read_available(msg: dict[str, Any]) -> dict[str, Any]: out = sess.read_available(idle=msg.get("idle", 0.3)) _trace("«", out) return {"out": out} session_ops: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "run": _op_run, "run_script": _op_run_script, "send": _op_send, "sendline": _op_sendline, "expect": _op_expect, "expect_exact": _op_expect_exact, "read": _op_read, "read_available": _op_read_available, } def _op_peep_start() -> dict[str, Any]: if trace["n"] == 0: try: trace["fh"] = open(tracepath, "a") except Exception: trace["fh"] = None trace["n"] += 1 return {"ok": True, "path": tracepath} def _op_peep_stop() -> dict[str, Any]: if trace["n"] > 0: trace["n"] -= 1 if trace["n"] == 0 and trace["fh"]: try: trace["fh"].close() except Exception: pass trace["fh"] = None try: os.unlink(tracepath) except OSError: pass return {"ok": True} # SIGALRM fires the idle timeout in ANY blocking state (accept OR recv), so a # client that holds a connection open-but-idle can't pin the daemon forever. # SIGTERM (e.g. from reap() on a sibling, or a manual stop) cleans up too. signal.signal(signal.SIGALRM, _graceful) signal.signal(signal.SIGTERM, _graceful) try: while True: signal.alarm(idle) # idle countdown while waiting to accept conn, _ = srv.accept() signal.alarm(0) with conn: try: while True: signal.alarm(idle) # idle countdown while waiting for a request msg = _recv_msg(conn) signal.alarm(0) # request in hand; never interrupt a command if msg is None: break op = msg.get("op") if op in session_ops: try: _send_msg(conn, session_ops[op](msg)) except Exception as e: _send_msg(conn, {"error": repr(e)}) elif op == "ping": _send_msg(conn, {"ok": True}) elif op == "peep_start": _send_msg(conn, _op_peep_start()) elif op == "peep_stop": _send_msg(conn, _op_peep_stop()) elif op == "shutdown": _send_msg(conn, {"ok": True}) _graceful() else: _send_msg(conn, {"error": "unknown op %r" % op}) except OSError: pass # client vanished mid-request; back to accept() finally: _graceful() # ═════════════════════════════════════════════════════════════════════════════ # Command-line interface # helpers (host guard, open/attach, payload) -> command funcs -> argparse # ═════════════════════════════════════════════════════════════════════════════ def _use(key: str, host: str | None = None, via: str = "et") -> DaemonClient: """Open KEY's session for an operation: reuse the warm daemon if it exists, else autospawn it on HOST (the optional --host) via the chosen transport. With no warm daemon and no host, exit with a hint. Shared by every session verb.""" try: s = DaemonClient(key=key, host=host, via=via, autostart=host is not None) except RuntimeError: sys.exit( "etch: no session %r; pass --host to start it, " "or run `etch open ` first" % key ) if host and not s.started: # reused a warm daemon; refuse a --host that contradicts its real host live = _daemon_host(key) if live and live != host: s.close() sys.exit( "etch: session %r is live on %s, not %s; close it (`etch close %s`) " "or use a different key" % (key, live, host, key) ) if s.started: sys.stderr.write( "(started a warm daemon for %r on %s; `close %s` to end it)\n" % (key, s.host, key) ) return s def _payload(key: str, secret: bool, message: str | None) -> str: """Source the bytes to send: -m literal, --secret (silent getpass), a stdin pipe, or an interactive prompt.""" if message is not None: return message if secret: return getpass.getpass("secret for %s: " % key) if not sys.stdin.isatty(): return sys.stdin.read() return input("send to %s: " % key) # ── CLI: command implementations (one per subcommand) ── def _do_send( key: str, newline: bool, secret: bool = False, message: str | None = None, host: str | None = None, via: str = "et", ) -> None: """`send`/`sendline`: feed input into a running daemon's warm session from YOUR own shell. The value goes terminal -> daemon -> remote stdin and is never seen by the agent (not in its transcript, checkpoints, logs, cmdlog, or the remote shell history). With --secret it's also redacted from peep.""" payload = _payload(key, secret, message) if newline and payload.endswith("\n"): payload = payload[:-1] # sendline re-adds exactly one newline cli = _use(key, host, via) (cli.sendline if newline else cli.send)(payload, secret=secret) cli.close() sys.stderr.write( "sent %d chars to %r%s%s\n" % ( len(payload), key, " + newline" if newline else "", " (silent, redacted in peep)" if secret else "", ) ) def _cmd_expect( key: str, pattern: str, timeout: float = 30, exact: bool = False, host: str | None = None, via: str = "et", ) -> None: """Wait for PATTERN (a regex, or a literal with --exact). Prints the matched prompt line and exits 0 on a match, or exits 1 on timeout.""" cli = _use(key, host, via) pat = re.escape(pattern) if exact else pattern out, idx = cli.expect([pat, "<>"], timeout=timeout) prompt = cli.prompt cli.close() if idx == 0: if prompt: print(prompt) sys.exit(0) sys.stderr.write("(timeout after %gs waiting for %r)\n" % (timeout, pattern)) sys.exit(1) def _cmd_open(host: str, key: str | None = None, via: str = "et") -> None: key = key or _random_key() existing = _daemon_host(key) if existing and existing != host: sys.exit( "etch: key %r is already open on %s; close it or pick a different --key" % (key, existing) ) open_session(host, key=key, via=via).close() # spawn-or-reuse, then leave warm print(key) # the handle to reuse; a printed key means the session is open def _cmd_run( key: str, cmd: str, timeout: float, host: str | None = None, via: str = "et" ) -> None: s = _use(key, host, via) try: out, code = s.run(cmd, timeout=timeout) finally: s.close() if out: print(out) sys.exit(code) def _cmd_script( key: str, path: str | None, timeout: float, host: str | None = None, via: str = "et", ) -> None: """Run a multi-line script on the warm session via run_script (line-cap-safe, chunked base64). Reads from FILE, or stdin if FILE is omitted or '-'.""" text = sys.stdin.read() if path in (None, "-") else open(path).read() s = _use(key, host, via) try: out, code = s.run_script(text, timeout=timeout) finally: s.close() if out: print(out) sys.exit(code) def _cmd_attach(key: str, host: str | None = None, via: str = "et") -> None: """Raw interactive terminal bridged into the warm session: drive prompts, REPLs, sudo, etc. live. Ctrl-] detaches (the daemon stays warm). Exclusive: holds the serial daemon for the whole session, so other callers (e.g. an agent run) block until you detach. Don't drive one session from two places at once.""" if not sys.stdin.isatty(): sys.exit("attach: needs an interactive terminal (stdin is not a tty)") s = _use(key, host, via) # turn the clean-room shell into a normal interactive terminal for the attach s.sendline( "stty sane 2>/dev/null; export TERM=xterm-256color PS1='[et:%s] \\w\\$ ' PS2='> '" % key ) s.read(timeout=1.0) sys.stderr.write( "== attached to %r on %s — press Ctrl-] to detach ==\r\n" % (key, s.host or "?") ) sys.stderr.flush() s.send("\n") # draw a fresh prompt fd = sys.stdin.fileno() old = termios.tcgetattr(fd) _no_fds: list[int] = [] try: tty.setraw(fd) while True: r, _, _ = select.select([fd], _no_fds, _no_fds, 0.03) if r: data = os.read(fd, 4096) if not data or b"\x1d" in data: # EOF or Ctrl-] detaches head = data.split(b"\x1d", 1)[0] if data else b"" if head: s.send(head.decode("utf-8", "replace")) break s.send(data.decode("utf-8", "replace")) out = s.read_available(idle=0.02) if out: sys.stdout.write(out.replace("\n", "\r\n")) # raw tty needs CRLF sys.stdout.flush() finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) try: # restore clean-room so scripted run()s keep working s.sendline("stty -echo -onlcr 2>/dev/null; export PS1='' PS2='' TERM=dumb") s.read(timeout=1.0) except Exception: pass s.close() sys.stderr.write("\r\n== detached from %r (still warm) ==\r\n" % key) sys.stderr.flush() def _cmd_peep( key: str, duration: float | None = None, host: str | None = None, via: str = "et", ) -> None: cli = _use(key, host, via) path = cli.peep_start() cli.close() # release the daemon: it stays serial, we just tail the file signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt())) suffix = (" for %gs" % duration) if duration else "" print( "== peep %r%s (» sent « received) — Ctrl-C to stop ==" % (key, suffix), file=sys.stderr, ) for _ in range(20): if path and os.path.exists(path): break time.sleep(0.1) deadline = (time.time() + duration) if duration else None try: with open(path) as fh: fh.seek(0, 2) # only traffic from now on while deadline is None or time.time() < deadline: line = fh.readline() if line: sys.stdout.write(line) sys.stdout.flush() else: time.sleep(0.2) except (KeyboardInterrupt, FileNotFoundError): pass finally: try: DaemonClient(key=key, autostart=False).peep_stop() except Exception: pass print("\n== peep stopped ==", file=sys.stderr) def _cmd_read( key: str, timeout: float | None = None, host: str | None = None, via: str = "et", ) -> None: """Show what the session has emitted, without attaching: a pattern-free peek. Default grabs whatever is buffered right now; --timeout waits for output to start then drains until quiet. Pairs with `send`/`sendline` for step-by-step interactive driving from the shell.""" cli = _use(key, host, via) out = cli.read(timeout=timeout) if timeout else cli.read_available() cli.close() sys.stdout.write(out if (not out or out.endswith("\n")) else out + "\n") def _cmd_ls() -> None: prefix = _user_prefix() socks = sorted(glob.glob(prefix + "*.sock")) if not socks: print("no etch daemons running") return now = time.time() print( "%-22s %-10s %-7s %-6s %-34s %s" % ("KEY", "DAEMON", "ET", "AGE", "HOST", "SOCKET") ) for sk in socks: key = sk[len(prefix) : -5] dpid, epid, host = _pidfile_fields(key) alive = dpid.isdigit() and _alive(int(dpid)) try: age = "%ds" % int(now - os.path.getmtime(sk)) except OSError: age = "?" print( "%-22s %-10s %-7s %-6s %-34s %s" % (key, dpid + ("" if alive else "!dead"), epid, age, host or "?", sk) ) def _cmd_stop(key: str | None, all_: bool) -> None: if all_: keys = _live_keys() elif key: keys = [key] else: sys.exit("close: pass a KEY or --all") if not keys: print("no sessions to close") return for k in keys: try: DaemonClient(key=k, autostart=False).shutdown() print("closed %r" % k) except Exception as e: print("could not close %r: %s" % (k, e)) def _cmd_selftest(host: str, via: str = "et") -> None: t0 = time.time() sess = EtSession(host, via=via).connect() print(f"== connected in {time.time() - t0:.1f}s ==", flush=True) for cmd in [ "hostname", "whoami", "pwd", "echo $((6*7))", "uname -sm", "echo multi; echo line; echo test", "false", "ls /nonexistent 2>&1", ]: out, code = sess.run(cmd) print(f" rc={code:<3} {cmd!r:38} -> {out!r}", flush=True) sess.close() print("== done ==") # ── CLI: argument parsing & dispatch ── def _main_cli(argv: list[str]) -> None: import argparse p = argparse.ArgumentParser( prog="etch", description="Drive a persistent EternalTerminal session to the VM: warm daemon, " "clean output + exit codes, interactive send/read, live peep.", epilog=( "command groups:\n" " run a command: run, script clean output + exit codes\n" " drive a session: attach, send, sendline, expect, read interactive (live or step-by-step)\n" " observe: peep live tap of what's sent/received\n" " daemons: open, close/stop, ls/status, gc open / list / close warm shells\n" "\nexamples:\n" " etch open user@vm prints a key; or: etch open user@vm --key main\n" " etch run main uname -a reuse the open session\n" " etch run --host user@vm main uname -a autospawn 'main' on first use, reuse after\n" " etch run main -- some-tool --timeout 5 -- makes the rest a literal command\n" " etch sendline main --secret feed a password into a parked prompt\n" "\nKEY is a logical handle for a session; the host lives with the daemon, not\n" "the key. Set it once at `open`, or pass --host on any verb to autospawn a\n" "session that isn't open yet (optional once it is). `ls` shows each host.\n" "\nlibrary: from Python, `from etch import open_session, EtSession`.\n" " open_session(host, key=...) opens/reuses a warm daemon; EtSession(host) is\n" " a one-shot with no daemon.\n" "\nadvanced: `--daemon KEY HOST IDLE` is the internal daemon worker,\n" "spawned automatically by open_session(); do not run it by hand.\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) sub = p.add_subparsers(dest="cmd", metavar="") def via_arg(sp: argparse.ArgumentParser) -> None: sp.add_argument( "--via", default="et", metavar="TRANSPORT", help="transport to open with: et (default), ssh, or a 'CMD {host}' template (only when starting)", ) def host_arg(sp: argparse.ArgumentParser) -> None: sp.add_argument( "--host", metavar="USER@HOST", help="autospawn the session here if it isn't open yet (optional once open)", ) via_arg(sp) def key_arg(sp: argparse.ArgumentParser) -> None: sp.add_argument("key", help="session key (a handle from `etch open`)") # ── run a command (clean output + exit codes) ── pr = sub.add_parser( "run", help="run one command on the session (open it first, or --host to autospawn); exit code = remote rc", ) pr.add_argument("--timeout", type=float, default=60) host_arg(pr) key_arg(pr) pr.add_argument( "command", nargs=argparse.REMAINDER, help="the command (rest of the line, flags and all); etch's own options go " "before the key, or put `--` first for a fully literal command", ) psc = sub.add_parser( "script", help="run a multi-line script (from FILE or stdin) on the warm session; exit code = remote rc", ) psc.add_argument("--timeout", type=float, default=180) host_arg(psc) key_arg(psc) psc.add_argument("file", nargs="?", help="script file (default: stdin)") # ── drive a session interactively (live, or step-by-step send/expect/read) ── pat = sub.add_parser( "attach", help="interactive terminal bridged into the warm session: prompts/REPLs/sudo, Ctrl-] detaches (exclusive until you detach)", ) host_arg(pat) key_arg(pat) for name, nl in (("send", False), ("sendline", True)): sp = sub.add_parser( name, help=( "%s to the session (%s); -m TEXT, --secret (silent), or stdin" % (name, "appends a newline" if nl else "raw, no newline") ), ) sp.add_argument("key", help="session key to send to") host_arg(sp) sp.add_argument( "-s", "--secret", action="store_true", help="read the payload via a silent no-echo prompt", ) sp.add_argument( "-m", "--message", help="payload on the command line (visible; not for secrets)", ) pex = sub.add_parser( "expect", help="wait for a regex (or literal with --exact) to appear; exit 0 on match, 1 on timeout", ) pex.add_argument("key", help="daemon key to watch") pex.add_argument("pattern", help="regex to wait for (literal if --exact)") pex.add_argument( "--timeout", type=float, default=30, metavar="SECS", help="give up after SECS (default 30)", ) pex.add_argument( "--exact", action="store_true", help="match PATTERN literally, not as a regex" ) host_arg(pex) prd = sub.add_parser( "read", help="drain session output without attaching: bare = peek what's buffered now (read_available), --timeout = wait for output then drain (read)", ) prd.add_argument("key", help="daemon key to read from") prd.add_argument( "--timeout", type=float, default=None, metavar="SECS", help="wait up to SECS for output to start, then drain until quiet " "(default: just grab what's buffered now)", ) host_arg(prd) # ── observe ── ppe = sub.add_parser( "peep", help="live tap: show what's sent/received on the session" ) ppe.add_argument("key", help="daemon key to tap") ppe.add_argument( "--for", dest="duration", type=float, default=None, metavar="SECS", help="auto-stop after SECS (for non-interactive/scripted taps)", ) host_arg(ppe) # ── sessions: open / close ── po = sub.add_parser( "open", help="open a session on HOST (pre-warm); prints the key to reuse with run/send/...", ) po.add_argument("host", metavar="USER@HOST", help="user@host to connect") po.add_argument("--key", help="name for the session (default: a random handle)") via_arg(po) for nm, h in ( ("close", "close a session (shut down its daemon)"), ("stop", "alias for close"), ): psx = sub.add_parser(nm, help=h) psx.add_argument("key", nargs="?", help="session key to close (or use --all)") psx.add_argument( "--all", action="store_true", help="close every running session" ) # ── inspect / maintain ── sub.add_parser("ls", help="list running sessions") sub.add_parser("status", help="alias for ls") sub.add_parser("gc", help="reap dead daemons / orphaned et processes") pse = sub.add_parser( "selftest", help="connect to HOST and run a few sample commands" ) pse.add_argument("host", metavar="USER@HOST", help="user@host to connect") via_arg(pse) args = p.parse_args(argv) if not args.cmd: p.print_help() return if args.cmd == "run": cmd = " ".join(args.command).strip() if not cmd: sys.exit("run: no command given") if "--" not in argv: _flags = ("--timeout", "--host", "--via") stray = [ t for t in args.command if t in _flags or any(t.startswith(f + "=") for f in _flags) ] if stray: sys.stderr.write( "etch: note: %s is inside the command, not applied as an option. " "etch's flags go before the key (e.g. `run --timeout 5 KEY ...`); " "use `--` for a command that legitimately contains them.\n" % ", ".join(stray) ) _cmd_run(args.key, cmd, args.timeout, args.host, args.via) elif args.cmd == "script": _cmd_script(args.key, args.file, args.timeout, args.host, args.via) elif args.cmd == "open": _cmd_open(args.host, args.key, args.via) elif args.cmd == "attach": _cmd_attach(args.key, args.host, args.via) elif args.cmd in ("send", "sendline"): _do_send( args.key, args.cmd == "sendline", args.secret, args.message, args.host, args.via, ) elif args.cmd == "expect": _cmd_expect( args.key, args.pattern, args.timeout, args.exact, args.host, args.via ) elif args.cmd == "peep": _cmd_peep(args.key, args.duration, args.host, args.via) elif args.cmd == "read": _cmd_read(args.key, args.timeout, args.host, args.via) elif args.cmd in ("ls", "status"): _cmd_ls() elif args.cmd in ("close", "stop"): _cmd_stop(args.key, args.all) elif args.cmd == "gc": reap() print("reaped stale etch daemons/orphans") elif args.cmd == "selftest": _cmd_selftest(args.host, args.via) if __name__ == "__main__": a = sys.argv[1:] if a and a[0] == "--daemon": # internal worker, spawned by DaemonClient._start _daemon_main(a[1], a[2], a[3], int(a[4])) sys.exit(0) _main_cli(a)