Files
tai/tests/plexer.py
T
retoorandClaude Sonnet 5 9dbd1fe667 Add plexer subsystem, container test infra, and last-resort opencode box backend
- Plexer: always-active terminal multiplexing. NativePlexerController/
  TmuxPlexerController share one interface, chosen by which env var is
  already set (TAI_PLEXER_SOCKET vs TMUX_PANE); NativePlexerBackend is
  the pty.fork()-based wrapper (raw mode, SIGWINCH resize forwarding,
  a Unix-socket control protocol, continuous session logging, a
  Ctrl+A prefix key distinct from tmux's own Ctrl+B). Auto-wraps new
  interactive shells via a .bashrc hook mirroring the existing
  command-not-found install pattern, gated on $TMUX/$TAI_PLEXER_ACTIVE/
  a $HOME/.tai-no-plexer bypass. New plexer_send/read/type/status
  tools, env-gated on a detected controller.
- tests/container.py + docker-compose.yml + tests/docker/Dockerfile:
  a disposable podman/docker image for the tests that must never touch
  a real machine (installing the plexer hook into a real ~/.bashrc, a
  live tmux session). Required suite (test_seal.py, test_tai.py,
  tests/plexer.py, tests/bashrc.py) gates the result; tests/rag.py
  runs as best-effort since its real-LLM dependency can't be satisfied
  inside a disposable container with no nested engine.
- tests/bashrc.py: real end-to-end hook install + bash -i auto-wrap,
  anti-recursion, tmux-active skip, non-interactive skip, a live
  TmuxPlexerController test, and an install/uninstall round-trip.
- tests/plexer.py: NativePlexerBackend under a real pty, plus a
  dedicated robustness pass (file permissions, >64KB payload framing,
  a malformed control request, a genuine self-deadlock fix in the
  write-to-child path).
- tai_test.py: the shared async process-driving framework (pipe/pty
  transport, group-wide kill-and-reap, a StdinBridge for --interactive)
  both test files build on.
- ChatClient last-resort backend: when there's no local opencode CLI
  but the sandbox box is already running, install opencode into it
  (official Node tarball, not apt -- apt's nodejs/npm pull in
  webpack/babel/jest as transitive deps) and route through it instead
  of giving up. Only reachable after the first retry round already
  failed everything else; never creates the box itself. TmuxPlexerController.status()
  fixed to use `list-panes` instead of `display-message -p`, which
  silently fell back to the default pane instead of erroring against
  a dead target.
- Fix 4 pre-existing test_tai.py bugs surfaced by running it in a
  clean environment for the first time: two stale mock signatures
  (subprocess.run's stdout= kwarg, Chat.complete's deadline= kwarg),
  one $TMUX-dependent catalog assertion, one tty-dependent color
  assertion.
- research/: a sourced research archive (Source Dossier/Citation
  Card/Conclusion tiers) backing the above design decisions, with its
  own audit.py consistency checker.
- .gitignore: fix a blanket *.md rule that was about to silently drop
  the entire research/ archive from version control; add __pycache__/
  and the one-off debug transcript this session started from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-10 04:36:05 +02:00

290 lines
13 KiB
Python

#!/usr/bin/env python3
"""tests/plexer.py — integration tests for tai's Plexer subsystem (NativePlexerBackend).
Deterministic throughout: this drives tai.py --plexer-wrap, a plumbing mode
that forks a real shell onto a real pty with no LLM involvement at all, so
"test with the real AI" (tests/README.md) doesn't apply here the way it does
to tests/rag.py — there is no model decision anywhere in this path to test.
What's real instead is the pty/signal/socket mechanics themselves: the whole
point of this file is that it exercises the actual `tai.py --plexer-wrap`
binary under a real pty (via tai_test.py's own pty mode — testing a pty
wrapper by driving it through another pty), not mocked termios/signal calls.
A real bug was caught by actually running this, not by reading the code: an
external SIGTERM (and SIGHUP) does not terminate an interactive bash running
as a pty.fork() session leader in this environment -- verified with a bare
pty.fork()+bash+os.kill script, independent of any tai.py code.
NativePlexerBackend's Ctrl+A,q shortcut uses SIGKILL specifically because of
this, not out of caution -- SIGTERM was tried first and silently did nothing
for ~10s in a real run.
"""
import glob
import os
import shutil
import struct
import sys
import tempfile
import asyncio
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tai_test import TestCase, TestAssertionFailed, main_cli # noqa: E402
import tai # noqa: E402
async def _drain_forever(proc):
"""A real terminal emulator is always reading, continuously, for as long
as it's open -- that's what keeps a pty's output buffer from ever
backing up. This test harness has to replicate that explicitly: without
a background task doing this, NativePlexerBackend's relay loop can block
on its own os.write(stdout_fd, ...) the moment a command's echoed output
is large enough to fill the (unread) outer pty buffer, which stalls the
*entire* relay loop -- including replying to later control-socket calls,
which is what made this look like a control-protocol deadlock the first
time this was debugged, when the actual missing piece was this drain.
A second real bug, found immediately after fixing the first: once the
wrapped shell exits, the stream hits EOF and read() returns b"" forever
without blocking -- so a plain `while True: await proc._read_chunk()`
busy-spins at that point instead of idling, and spins hard enough to
starve the event loop so badly that even asyncio.wait_for's own timeout
machinery stops getting a fair turn. Hence the explicit EOF check."""
try:
while True:
chunk = await proc._read_chunk()
if not chunk:
await asyncio.sleep(0.05)
except asyncio.CancelledError:
pass
class NativePlexerBackendTest(TestCase):
"""Spawns `tai.py --plexer-wrap` under a real pty (tai_test's pty mode),
then drives it as an external controller would: over its control socket,
plus raw keystrokes written straight to the outer pty master to exercise
the Ctrl+A prefix-key path exactly as a human's terminal would deliver it."""
name = "plexer-native-backend"
mode = "pty"
timeout = 30.0
async def setup(self):
self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-")
self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": ""}
async def run(self):
proc = await self.spawn(extra_args=["--plexer-wrap"])
self._drain_task = asyncio.ensure_future(_drain_forever(proc))
sock_path = None
for _ in range(50):
matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.sock"))
if matches:
sock_path = matches[0]
break
await asyncio.sleep(0.2)
if not sock_path:
raise TestAssertionFailed("NativePlexerBackend never created its control socket")
controller = tai.NativePlexerController(sock_path)
await asyncio.sleep(0.3)
status = controller.status()
child_pid = status.get("pid")
if not child_pid or child_pid <= 0:
raise TestAssertionFailed("status() did not report a valid child pid: %r" % status)
controller.send_keys("echo plexer_test_marker", enter=True)
await asyncio.sleep(0.4)
after_send = controller.read_pane(50)
if "plexer_test_marker" not in after_send:
raise TestAssertionFailed("send_keys/read_pane round trip failed: %r" % after_send[-300:])
controller.type_text("echo typed_marker_ok", delay=0.01, enter=True)
await asyncio.sleep(0.4)
after_type = controller.read_pane(50)
if "typed_marker_ok" not in after_type:
raise TestAssertionFailed("type_text did not reach the shell: %r" % after_type[-300:])
import fcntl
import termios
fcntl.ioctl(proc._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 17, 66, 0, 0))
os.kill(proc.proc.pid, tai.signal.SIGWINCH)
await asyncio.sleep(0.4)
controller.send_keys("stty size", enter=True)
await asyncio.sleep(0.4)
after_resize = controller.read_pane(50)
if "17 66" not in after_resize:
raise TestAssertionFailed("SIGWINCH resize was not forwarded to the inner shell: %r" % after_resize[-300:])
controller.send_keys("echo contains_a_d_character", enter=True)
await asyncio.sleep(0.3)
after_plain = controller.read_pane(50)
if "contains_a_d_character" not in after_plain:
raise TestAssertionFailed("a plain command containing the letter 'd' was mangled by prefix-key handling")
os.write(proc._master_fd, tai.PLEXER_PREFIX_KEY)
os.write(proc._master_fd, b"q")
await proc.wait(timeout=10.0)
await asyncio.sleep(0.2)
socket_survived = os.path.exists(sock_path)
import subprocess
still_running = subprocess.run(["ps", "-p", str(child_pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
log_matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.log"))
log_has_marker = False
if log_matches:
with open(log_matches[0], "rb") as handle:
log_has_marker = b"plexer_test_marker" in handle.read()
return {
"socket_survived": socket_survived,
"still_running": still_running,
"log_has_marker": log_has_marker,
}
async def verify(self, result):
if result["socket_survived"]:
raise TestAssertionFailed("control socket file was not cleaned up after Ctrl+A,q")
if result["still_running"]:
raise TestAssertionFailed("the wrapped shell process was still alive after Ctrl+A,q (leaked process)")
if not result["log_has_marker"]:
raise TestAssertionFailed("the continuous session log did not capture the session's actual output")
async def teardown(self):
self._drain_task.cancel()
shutil.rmtree(self.tmp_home, ignore_errors=True)
class NativePlexerBackendRobustnessTest(TestCase):
"""Targets three specific bugs found and fixed during a deliberate review
pass after NativePlexerBackendTest first went green -- a happy-path test
passing does not mean the code is reliable, and all three of these would
have sailed through that test unnoticed:
1. File permissions: the control socket and the session log (which
captures the ENTIRE terminal session verbatim -- anything typed,
including passwords) were being created with umask-default
permissions, often world-readable/connectable on a multi-user box.
2. The control protocol's server side did a single recv() instead of
looping like the client side already did, silently truncating any
request too large for one read -- untestable with short demo strings,
which is exactly why the first test never caught it.
3. A malformed (non-JSON-object) request on the control socket was not
defensively handled and would have propagated an uncaught exception
out of the relay loop, crashing the user's entire live wrapped
terminal session over one bad control message.
4. A genuine deadlock: plexer_send used to write its text directly into
the pty master from inside the control-request handler. For a large
payload on an echo-enabled pty, that write can block waiting for
buffer space that only frees up once something reads the echoed
output back out -- which is this very relay loop's own job, and it
was the one blocked. Fixed by queuing all writes-to-child and only
ever draining them from the main select() loop, cooperatively with
reading the child's output, never as a blocking write from inside
request handling.
"""
name = "plexer-native-backend-robustness"
mode = "pty"
timeout = 30.0
async def setup(self):
self.tmp_home = tempfile.mkdtemp(prefix="tai-test-home-")
self.env_overrides = {"TAI_HOME": self.tmp_home, "TAI_PASSPHRASE": ""}
async def run(self):
proc = await self.spawn(extra_args=["--plexer-wrap"])
self._drain_task = asyncio.ensure_future(_drain_forever(proc))
sock_path = None
for _ in range(50):
matches = glob.glob(os.path.join(self.tmp_home, "plexer", "*.sock"))
if matches:
sock_path = matches[0]
break
await asyncio.sleep(0.2)
if not sock_path:
raise TestAssertionFailed("NativePlexerBackend never created its control socket")
controller = tai.NativePlexerController(sock_path)
await asyncio.sleep(0.3)
plexer_dir_path = os.path.dirname(sock_path)
log_matches = glob.glob(os.path.join(plexer_dir_path, "*.log"))
if not log_matches:
raise TestAssertionFailed("no log file found to check permissions on")
log_path = log_matches[0]
dir_mode = os.stat(plexer_dir_path).st_mode & 0o777
sock_mode = os.stat(sock_path).st_mode & 0o777
log_mode = os.stat(log_path).st_mode & 0o777
# Large payload: bigger than the control protocol's 65536-byte recv
# chunk size, to actually exercise the server-side framing loop
# rather than the common case that fits in one read.
needle_head = "LARGE_PAYLOAD_START_"
needle_tail = "_LARGE_PAYLOAD_END"
padding = "x" * 90000
large_text = "echo %s%s%s" % (needle_head, padding, needle_tail)
controller.send_keys(large_text, enter=True)
await asyncio.sleep(0.6)
large_result = controller.read_pane(2000)
# Malformed control request: connect directly to the socket (bypassing
# the well-formed NativePlexerController entirely) and send something
# that is valid JSON but not an object, which the old code would have
# handed straight to request.get("cmd") and crashed on.
import socket as socket_module
import json as json_module
raw_sock = socket_module.socket(socket_module.AF_UNIX, socket_module.SOCK_STREAM)
raw_sock.settimeout(5)
raw_sock.connect(sock_path)
raw_sock.sendall(b"[1, 2, 3]\n")
malformed_reply_raw = raw_sock.recv(65536)
raw_sock.close()
malformed_reply = json_module.loads(malformed_reply_raw.decode("utf-8", "replace"))
# The backend must still be alive and responsive after that malformed
# request -- this is the actual point of the test, not the error
# shape above.
status_after = controller.status()
controller.send_keys("exit", enter=True)
await proc.wait(timeout=10.0)
return {
"dir_mode": dir_mode,
"sock_mode": sock_mode,
"log_mode": log_mode,
"large_result": large_result,
"malformed_reply": malformed_reply,
"status_after_pid": status_after.get("pid"),
}
async def verify(self, result):
if result["dir_mode"] != 0o700:
raise TestAssertionFailed("plexer dir mode is %o, expected 0700 (owner-only)" % result["dir_mode"])
if result["sock_mode"] != 0o600:
raise TestAssertionFailed("control socket mode is %o, expected 0600 (owner-only)" % result["sock_mode"])
if result["log_mode"] != 0o600:
raise TestAssertionFailed("session log mode is %o, expected 0600 (owner-only) -- it captures everything typed" % result["log_mode"])
large = result["large_result"]
if "LARGE_PAYLOAD_START_" not in large or "_LARGE_PAYLOAD_END" not in large:
raise TestAssertionFailed("large (>65536 byte) plexer_send payload was truncated by the control protocol")
if result["malformed_reply"].get("ok") is not False:
raise TestAssertionFailed("a malformed control request did not come back as a clean error: %r" % result["malformed_reply"])
if not result["status_after_pid"] or result["status_after_pid"] <= 0:
raise TestAssertionFailed("backend did not survive the malformed request -- a bad control message must never crash the whole wrapped session")
async def teardown(self):
self._drain_task.cancel()
shutil.rmtree(self.tmp_home, ignore_errors=True)
ALL_CASES = [NativePlexerBackendTest(), NativePlexerBackendRobustnessTest()]
if __name__ == "__main__":
sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs")))