Operations
Operations
TL;DR. How phux behaves at its operational seams: error translation at the wire boundary, structured and redaction-safe logging, workspace continuity, remote-listener authentication, running the reference relay, and the exact trust boundary. phux has no durable access audit log or runtime status command today.
Error model
Library and binary boundaries use typed Rust errors appropriate to their
module; there is no single workspace-wide error enum. Errors that cross the
IPC boundary translate to ERROR messages with a stable ErrorCode and a
human-readable message. spec/proto.md owns that wire shape
and the code catalog.
Logging and observability
Logs are both an operator surface and a leak surface; ADR-0028 owns that decision and its slicing, and this section is the home for the facts.
tracing is the structured logging substrate, bootstrapped in phux_server::telemetry. Two entry points share one layer builder:
- Server / foreground (
phux server, one-shot control verbs, any--jsonpath) —telemetry::init(). Always logs human-or-JSON text to stderr; stdout is reserved for protocol/PTY traffic. - Client / TUI (
phux attach, nakedphux,phux newwithout--json) —telemetry::init_client(). Logs to a file only: the attach loop owns the alt screen, so a stray log line corrupts the display.
Both fmt layers emit span-close timing (FmtSpan::CLOSE), so any #[instrument] span reports elapsed duration at close.
Environment knobs
| Variable | Effect |
|---|---|
RUST_LOG | Filter directives. Default phux=info,warn. |
PHUX_LOG=<path> | Write logs to <path> via non-blocking file writer. Server tees to this file in addition to stderr; client writes here instead of its per-pid default. Parent directory created if missing. |
PHUX_LOG_FORMAT=text|json | text (default): human single-line layer. json: one JSON object per line for jq/grep. Applies to both stderr and file sinks. |
The client default log path (when PHUX_LOG is unset) is $XDG_STATE_HOME/phux/client-<pid>.log (falls back to $HOME/.local/state/phux/). The pid scope keeps concurrent clients from interleaving. Level defaults to phux=info,warn, so crashes and warnings are always captured without flooding the file.
The non-blocking file writer offloads I/O to a background thread; its WorkerGuard is held for the lifetime of main and flushes on exit.
Sensitive data in logs
Log sinks are created with mode 0o600 (owner-only) on Unix so another user on a shared box cannot read them (ADR-0028). Input atoms are self-narrating and redaction-safe: KeyEvent and PasteEvent have hand-written Debug impls (and InputEvent::narrate) that report only structural facts — action, physical key, modifiers, payload lengths — and never the typed key text or pasted bytes. A trace!(?input, …) therefore records that a keystroke or paste happened, with its shape, without spilling the secret it carried.
Crash capture
Panics are durable on both sides. The client panic hook logs the panic message plus a captured std::backtrace::Backtrace to its file sink before it restores the terminal (survives even though the default hook’s stderr backtrace would vanish into the dead alt screen). The server panic hook logs task/actor panics with their backtrace through tracing, so a daemonized server’s crash lands in the log file. Both honor RUST_BACKTRACE for trace verbosity.
Reading a trace to localize lag
The hot paths carry tracing spans whose CLOSE event reports the span’s duration (time.busy/time.idle), so a captured session shows where time went before a stall. The per-frame and per-tick spans are at debug, so the default phux=info filter leaves them off and effectively free; raise the level only while diagnosing.
PHUX_LOG=/tmp/phux.jsonl PHUX_LOG_FORMAT=json RUST_LOG=phux=debug phux ...
# headless repro that exercises the same server paths:
PHUX_LOG=/tmp/phux.jsonl PHUX_LOG_FORMAT=json RUST_LOG=phux=debug \
cargo run -p phux-server --example e2e-repro
Two spans carry most of the signal. On the server, synthesize_against_reference (fields changed_row_count, out_bytes) is the per-tick CPU cost of diffing engine state for one consumer. On the client, handle_server_frame (grep kind=terminal_output) is the per-frame apply-and-paint cost; its children vt_apply (libghostty parse) and paint_trigger (render) let you attribute a client stall to parse versus paint by comparing their time.busy. Narrow a JSON capture to timed events with jq -c 'select(.fields.message=="close")'. Finer per-PTY-chunk and per-frame-emit detail is at trace; a wedged or leaked consumer shows as consumer mailbox full / consumer mailbox closed at debug.
There is no phux server status, Prometheus/OpenTelemetry exporter, runtime
per-target log-level control, or phux logs discovery verb today. Use phux ls --json for the published session/pane view and the environment-controlled
tracing sinks above for diagnosis. ADR-0028
records the remaining operator surface.
Agent-state detection
To make adoption automatic rather than relying on every operator to remember a special launch command, run this once on each box:
phux agent install-claude
After a new shell starts, plain interactive claude creates and attaches a
phux session when invoked outside one, or runs in the current pane when already
inside. The shim injects additional Claude hook settings for lifecycle
publication: prompts set working, permissions/notifications set blocked
and emit phux ask, stops set done, and session exit clears the record.
Administrative and noninteractive Claude commands bypass the shim. Remove the
owned files and the marked shell-rc block with phux agent uninstall-claude.
The server derives each pane’s phux.agent/v1 record on a timer
(ADR-0046). What it reads,
exactly, and nothing else:
- The pane’s own PTY. Its foreground process group id, and that process’s
argv(/proc/<pid>/cmdlineon Linux, asysctlon macOS; unavailable elsewhere, where the detector simply never identifies an agent). This is used only to answer “which agent binary is running here”, and only for terminals this server owns. - That terminal’s OSC title and its live viewport rows. Both are already in the server’s own engine state; the detector reads them, matches them against its rule manifests, and derives a state word.
Nothing leaves the process: no network call, no subprocess, no file write. Screen
content is not logged — the detector logs its derived state transitions at
debug and its rule-match bookkeeping at trace, never the matched text.
Kill switch. PHUX_AGENT_DETECT=0 in the server’s environment loads an empty
rule set, so no detector is constructed and no pane is scanned. Consumers fall
back to their pre-ADR-0046 title heuristics.
Rule manifests. Built-in manifests for Claude Code, Codex, OpenCode, Pi, and
OMP are compiled into the binary. Additional or replacement manifests are read
from $PHUX_AGENT_RULES_DIR (default
$XDG_CONFIG_HOME/phux/agent-rules), one TOML file per agent kind; a manifest
replaces the built-in of the same kind. Manifests are loaded and their patterns
compiled once, on first use. A manifest that fails to parse, or that carries
an invalid pattern, is logged at warn and dropped whole — never partially
applied — so a bad rule file degrades detection for that agent kind rather than
wedging a pane. Grep the log for the manifest’s path to find it.
When it is wrong. Detection is level-triggered and fail-safe: a pane whose
screen matches no rule reads idle, never blocked, and the next tick
re-derives from scratch, so a wrong value corrects itself rather than sticking.
A stale manifest therefore shows up as agents that never leave idle — not as a
sidebar stuck on red.
Workspace continuity and update survival
phux has two different continuity mechanisms. They are intentionally separate:
- Restart restore:
phux workspace savewrites a typed JSON archive of the running workspace.phux workspace restore ARCHIVEreads that archive and creates any missing session names on a running server. Each restored session starts a fresh PTY process: the archivedcommandis used when present; otherwise phux starts the default shell in the archived cwd when available. This is a restart/recreate path, not a live handoff path. - Live update handoff:
phux upgradeis the mechanism intended to keep existing PTYs alive across a server binary re-exec. Its e2e drill iscargo test -p phux --test upgrade_e2e -- --ignored, which checks that a pane child PID and scrollback marker survive the upgrade.
The workspace archive stores sessions, windows, pane metadata, cwd, dimensions,
and split-layout shape where the server reports it. Restore currently recreates
missing sessions and seed processes only; it does not replay the archived
split tree into multiple live panes. Do not describe workspace restore as PTY
resurrection or full layout replay until a restore-side layout command exists
and has e2e coverage.
Operational smoke checks:
# Process/cwd restart restore smoke. Starts real phux servers.
cargo test -p phux --test workspace_archive_e2e \
workspace_restore_starts_archived_command_process -- --ignored
# Save/restore session inventory smoke. Starts real phux servers.
cargo test -p phux --test workspace_archive_e2e \
workspace_archive_saves_and_restores_sessions -- --ignored
# Live PTY handoff across server update/re-exec.
cargo test -p phux --test upgrade_e2e -- --ignored
Security model and trust boundaries
Design assumption: This is not a security-hardened system for hostile environments. It is suitable for trusted networks and multi-user boxes where Unix permissions are enforced by the kernel.
The trust boundary is the operating system user. A phux server trusts every process running as the same UID that can connect to its Unix socket.
Local trust model (single-machine)
The Unix socket lives in $XDG_RUNTIME_DIR/phux/ (typically /run/user/$UID/ on Linux, or /var/folders/.../T/ on macOS), created with parent directory mode 0o700 (user-only). The OS kernel enforces this boundary at the filesystem level; the socket inherits the parent directory’s permissions.
What this means:
- Another user on the same machine MAY NOT connect to the socket (kernel-enforced).
- If the parent directory or socket permissions are misconfigured (e.g., accidentally mode
0o777), the security boundary is breached. Administrators MUST validate socket permissions in deployment; phux does not re-check at runtime. - The process file descriptor table (
/proc/<pid>/fd/<socket-fd>on Linux) is not readable by other UIDs, so the socket endpoint cannot be enumerated across user boundaries.
Federation trust model (v0.1+, forward-compatible)
v0.1 (current): Remote attach is available for single-server consumers over
WebSocket/TLS and QUIC/TLS. SSH-stdio is built (phux-v45.9): the dialing side
runs ssh HOST phux stdio-bridge, delegating authentication and encryption to
SSH; the remote bridge is an ordinary local UDS client on the target host.
Federation hub (current): Satellites are phux servers on other machines. A server started with --hub dials enabled [[satellites]], aggregates their Terminal inventory, and routes host-qualified Terminal operations over the same wire (ADR-0007). Routes are hub-and-spoke and Terminal-scoped: remote sessions/windows are not merged, and relayed VT bytes remain opaque.
The hub-side enrollment path is two commands: install the hub flag into the mini’s persistent per-user service once, then bootstrap each satellite over existing SSH trust:
phux service install --hub
phux satellite enroll user@devbox
satellite enroll verifies the remote binary, installs its service, runs
phux pair --json, stores the bearer token owner-only, pins the certificate,
and writes the complete local [[satellites]] entry. When the satellite has no
dialable listener it falls back to ssh://user@devbox; sessions still live on
the satellite, and the hub maintains the SSH-stdio route with capped backoff.
Use --ssh-only to choose that route without probing or pairing.
Current remote transports:
- WebSocket/TCP:
phux server --listen HOST:PORT; loopback can be plaintext for browser/dev use, while routable binds auto-provision TLS and require aphux pairbearer token. - QUIC/UDP:
phux server --quic HOST:PORT; always TLS 1.3 encrypted. Routable binds use the same token store andphux paircertificate fingerprint as the WebSocket path. - WebTransport/UDP:
phux server --webtransport HOST:PORT(orPHUX_WT_ADDR); HTTP/3 over QUIC, always TLS 1.3 encrypted — the browser’s door to QUIC-class transport, dialed byphux-webwith a WebSocket fallback. Routable binds require the samephux pairtoken, carried in the CONNECT request:Authorization: Bearer <hex>from native consumers, or?token=<hex>on the session URL from browsers (the JSWebTransportAPI cannot set headers); a missing or invalid token is refused with HTTP 403 before the session exists. Shares the persisted certificate and token store with the WebSocket and QUIC paths. - SSH-stdio:
ssh HOST phux stdio-bridgesplices the wire into the server’s Unix socket on HOST. Reuses established SSH auth (the hub dials withBatchMode=yes, so key material must work non-interactively); inherits SSH’s trust model plus the UDS’s owner-only local boundary. No bearer token or certificate pin on this transport (ADR-0038 addendum).
Remote consumer trust model (opt-in)
A remote consumer (the native mobile app) can attach over the network without an SSH tunnel, behind TLS plus a bearer pairing token (ADR-0031). This is the nearer-term, single-server path, distinct from the federation hub above.
The bind address is the toggle, so there is no remote-mode setup friction. For
TCP/WebSocket, set it either with phux server --listen HOST:PORT or the
PHUX_WS_ADDR environment variable (the flag wins when both are present):
- Loopback address → plaintext, unauthenticated. The historical browser-client dev path; zero config.
- Routable address → TLS + token, auto-provisioned. Binding off-loopback is
treated as exposing the server: phux generates and persists a self-signed
certificate (under the state dir) if none is configured, and reads the default
token store. It terminates TLS and requires an
Authorization: Bearer <token>in the WebSocket upgrade; a missing or unrecognized token is refused with HTTP 401 before any phux frame is read. Plaintext never reaches a routable address. Tokens are minted withphux pair, which prints the token once alongside the certificate’s SHA-256 fingerprint to pin out-of-band. Pair before starting a network listener: the server loads the token store at startup. Adding or deleting a token takes effect after the server restarts.
Native clients can use the same TCP fallback with:
phux attach --ws wss://HOST:PORT --token HEX --cert-fingerprint FP
For UDP/QUIC, set phux server --quic HOST:PORT or PHUX_QUIC_ADDR and attach
with:
phux attach --quic HOST:PORT --token HEX --cert-fingerprint FP
Use WebSocket/TCP when UDP is blocked by a network or firewall; use QUIC when roaming/migration behavior matters and UDP is available.
Remote attach has protocol coverage and manual smoke coverage, but it is not a workspace-restore mechanism. A remote WebSocket or QUIC client attaches to the server state that exists on that server. It does not move PTYs between hosts, and it does not replay a saved archive on the remote side by itself. Validate a remote deployment with a loopback-secure smoke before advertising it:
# Before starting the server, mint a token and record its fingerprint:
phux pair
# Terminal 1:
PHUX_WS_SECURE=1 phux server --listen 127.0.0.1:8787
# Terminal 2:
phux attach --ws wss://127.0.0.1:8787 --token HEX --cert-fingerprint FP
For QUIC, use the same token/fingerprint pair with phux server --quic 127.0.0.1:8788 and phux attach --quic 127.0.0.1:8788 ....
PHUX_WS_SECURE=1 forces the secure path on a loopback address (to exercise the
remote path locally); PHUX_WS_TLS_CERT + PHUX_WS_TLS_KEY substitute an
operator-supplied certificate for the auto-generated one; PHUX_WS_TOKENS
overrides the token-store path.
What this means:
- The trust boundary widens past the OS user: an authenticated network peer is a
first-class consumer whose proof is a bearer token over TLS. This is a larger
attack surface than local UDS. A routable
--listenaddress engages TLS and token auth automatically;PHUX_WS_SECURE=1only forces that path on loopback. - The token is a bearer credential — anyone holding it is the device until the
token is revoked. The store is owner-only (
0o600); the comparison is constant-time; tokens are 256-bit from the OS CSPRNG. A client certificate (mutual TLS) is the stronger v0.2 hardening recorded in ADR-0031. - Certificate lifecycle is an operator responsibility, like socket permissions.
With a self-signed certificate, verifying the
phux pairfingerprint on the device’s first connect is what closes the trust-on-first-use MITM window.
Connecting from another network (overlay reachability)
The remote-consumer path above authenticates and encrypts the link, but it still needs the client to reach the server’s address. A self-hosted server behind NAT/CGNAT/a firewall has no inbound-reachable address, so a phone on cellular or another Wi-Fi cannot dial it directly — same-network or a VPN is required.
The sanctioned answer for self-hosters is a WireGuard-class overlay network, which gives the client a routable address that works through NAT (ADR-0037). phux needs no special configuration for this: an overlay is an L3 substrate, and phux dials the overlay address exactly as it dials a LAN address. Because an overlay IP is non-loopback, the secure path (TLS + token) engages automatically; cert pinning is on the fingerprint, not the hostname, so MagicDNS-style names work unchanged.
phux is overlay-agnostic — pick what fits your trust model:
- Tailscale — the frictionless on-ramp. Install on
the server host and the client; attach to the server’s
100.xIP or its MagicDNS*.ts.netname. - Headscale — a self-hostable, fully-OSS Tailscale control plane, for operators who will not depend on a third-party coordinator.
- Raw WireGuard, Nebula, or Netbird — for hand-rolled overlays. All behave identically to phux; it only ever sees an IP.
For step-by-step Tailscale, Headscale, and raw WireGuard walkthroughs, see Remote access.
# Before starting the server:
phux pair # token + fingerprint, plus the detected overlay address
# Server, reachable on its overlay address:
phux server --listen 0.0.0.0:8787 # binds all interfaces, including the overlay
# Client, from anywhere on the overlay:
phux attach --ws wss://<overlay-host>:8787 --token HEX --cert-fingerprint FP
Overlay-address detection in phux pair is best-effort (the tailscale CLI
when present, else a CGNAT route heuristic); raw-WireGuard operators on
private ranges find the address with their usual tooling. PHUX_TAILSCALE
substitutes the CLI that phux pair runs (default: tailscale on PATH),
mirroring PHUX_SSH for the hub dialer.
Hosted relay infrastructure, rendezvous servers, and NAT hole-punching that would remove the both-ends-install requirement remain out of scope for the self-host repo (see ADR-0037). The one carve-out, per ADR-0057, is the self-hosted reference relay below: a relay you run yourself, never one anyone hosts for you.
Running the reference relay
phux ships a minimal reference relay in-tree — the runnable artifact behind the dial-out connector design (ADR-0051, ADR-0057). A server behind NAT dials out to the relay and holds one QUIC tunnel per named route; remote consumers dial the relay, name the route via TLS SNI, and are spliced onto that tunnel byte for byte. Be blunt about the trust tradeoff, because ADR-0051 is: the relay terminates TLS on both legs, so it sees every phux frame in plaintext — every keystroke and every rendered cell crosses the relay decrypted. The mitigation is not a protocol feature; it is self-hosting. Run the relay yourself, on a host you trust — that is the entire reason it ships in this repo. It is a reference relay for self-hosters and development, not infrastructure software: no accounts, no high availability, no metrics, no config file. That scope fence is normative (ADR-0057).
The server-side connector is part of phux server. It validates every
[[connector]] entry before binding, dials each relay independently, and
redials a lost tunnel with capped exponential backoff. A bad entry fails
startup; a network, certificate, or token failure is logged and retried
without taking the local server down.
The surface is two commands:
# Enroll a route: mints its tunnel token, provisions the certificate on
# first use, and prints the fingerprint both legs pin.
phux relay pair --route studio
# Run the relay in the foreground (Ctrl-C to stop). --listen has no
# default; binding is always explicit. --max-conns (default 64) is the
# sole limiting knob.
phux relay run --listen 0.0.0.0:4433
phux relay pair prints the credentials once, in the spirit of phux pair; its output looks like:
Tunnel token for route "studio" (a secret — give it to the phux server once):
<64-hex token>
Relay certificate SHA-256 (pin it on the dialing side to defeat MITM):
<colon-separated hex fingerprint>
Token written to <state-dir>/relay-tokens
Route names ride TLS SNI, so they follow the DNS-label grammar: lowercase
[a-z0-9-], at most 63 characters, no leading or trailing hyphen. Anything
else is rejected with a nonzero exit — never normalized. Pairing an
already-enrolled route replaces that route’s token, so rotation is one
command and token and route stay one-to-one. The store rewrite is atomic
(a running relay never observes a torn or empty file), but concurrent
phux relay pair invocations are last-write-wins — run one at a time.
phux relay run prints the standard phux build banner and a listening
line to stderr, then blocks. The listening line carries the resolved bind
address (so --listen 127.0.0.1:0 shows the OS-assigned port), the
enrolled-route count, and the certificate fingerprint both legs pin:
phux <version> (pre-alpha; see docs/spec/)
phux relay listening on 0.0.0.0:4433 (routes=1; cert sha256 <colon-separated hex fingerprint>; Ctrl-C to stop)
Lifecycle lines follow on stderr via tracing: tunnel up/down per route,
per-reason refusals (bad tunnel token, no live tunnel for an enrolled route,
connection cap reached), and a warning when a newer claim supersedes a live
tunnel (claims are last-writer-wins; that warning is the operator’s
theft-detection surface). These lines are a diagnostic surface, not
machine-stable output. Shutdown on Ctrl-C or SIGTERM is immediate — a
reference relay makes no availability promise.
State files. Exactly three, at fixed paths in the phux state directory
($XDG_STATE_HOME/phux, or $HOME/.local/state/phux when unset) — siblings
of the server’s remote-* files:
relay-tokens— one<64-char hex token> <route>line per enrolled route;#comments and blank lines are ignored; mode0600. The relay re-reads this file on every connection attempt, sophux relay pairtakes effect on a running relay and deleting a line revokes at the next handshake — no restart, no reload signal. A live tunnel survives its token’s deletion until it drops or the relay restarts; restarting the relay is the immediate revocation path.relay-cert.pem/relay-key.pem— the relay’s self-signed TLS pair, provisioned on first use and left untouched when both files exist, so the pinned fingerprint stays stable across restarts. Operator-supplied certificates work by placing PEM files at these paths. The key is written mode0600.
There are no path flags and no PHUX_RELAY_* environment variables. Listing
enrollments is reading the file; revoking is deleting a line; re-pairing
rotates.
Enrollment flow.
-
On the relay host, enroll the route:
phux relay pair --route studio. -
Copy the printed tunnel token to an owner-only file on the server host, then register the relay endpoint and its printed certificate fingerprint:
install -m 600 /dev/stdin ~/.local/state/phux/relay-studio.token # paste the 64-hex tunnel token, then EOF[[connector]] relay = "relay.example:4433" token-file = "/home/me/.local/state/phux/relay-studio.token" cert-fingerprint = "AB:CD:..."The secret stays in the file, never in
config.toml. Routable relays require both fields. Loopback alone may omit them for development. -
Mint the server’s ordinary consumer credential with
phux pairif the remote device does not already have one. The connector authenticates consumers against that same server token store; relay enrollment grants no access to a terminal. -
Start or restart
phux server. With no selector it supervises every configured connector.phux server --connect relay.example:4433selects exactly the matching entry and its credentials; an unconfigured ad-hoc endpoint is allowed only on loopback. -
Consumers dial the relay as if it were the server, naming the route with SNI and pinning the relay’s certificate:
phux attach --quic RELAY_HOST:4433 --tls-server-name studio \ --cert-fingerprint RELAY_FP --token SERVER_TOKENRELAY_FPis the relay fingerprint fromphux relay pair— the consumer’s TLS terminates at the relay, so that is the certificate it sees.SERVER_TOKENis the server’s ownphux pairtoken: it crosses the relay as opaque bytes and is verified by the server, never by the relay.
The connector token file is re-read on every dial attempt. To rotate it,
run phux relay pair --route studio again, atomically replace the server’s
owner-only token file, then force a redial (restart the server or relay) if
the old live tunnel must be revoked immediately. Otherwise the next natural
redial picks up the new token. Deleting the route from relay-tokens refuses
future dials but, like rotation, does not sever the current tunnel by itself.
Refusals are distinguishable at the consumer. An unknown or absent route name fails during the TLS handshake itself — no phux bytes are exchanged, and the relay is indistinguishable from a non-phux TLS endpoint. An enrolled route with no live tunnel completes the handshake and then closes with a distinct route-offline application code. “Wrong route name” and “server not dialed in” therefore produce different failures.
Output mode for remote consumers
A remote phone link is high-latency and may be lossy. A remote consumer SHOULD
request OutputMode::StateSync (ADR-0018)
at HELLO rather than the default OutputMode::Raw: StateSync ships the minimum
VT to move the consumer’s last-acked state to canonical per tick, coalescing
floods and pacing per-consumer RTT. Raw stays the default for local interactive
peers, where byte-faithful pass-through is lowest-latency on a fast link.
Known limitations
- Local transports are plaintext: UDS and explicit loopback WebSocket carry plaintext. UDS relies on filesystem permissions; loopback WS is a development path. Routable WSS and QUIC listeners use TLS.
- Scrollback unencrypted: Terminal history is stored in the libghostty grid in RAM, unencrypted. A memory dump can recover it.
- Encryption belongs to the transport: phux frames have no independent per-command encryption. WSS and QUIC protect the complete stream with TLS.
- No audit logging: phux does not log which user accessed which terminal or when. Can be added as future hooks.
- SSH is the trust boundary for remote attach (v0.1): phux does not perform additional authentication over SSH; it delegates entirely to SSH key management and host verification.
What you DO get
- Kernel-enforced permission boundary: On Linux and macOS, the OS prevents other users from connecting to your socket.
- No privilege escalation surface: The server runs as your user (not setuid/setgid). A compromised terminal cannot elevate to other UIDs.
- No eval RPC: phux does not evaluate source text inside the server, but an authenticated consumer can spawn commands and drive shells with the server user’s authority. Treat a remote token as command-execution access.
- Process isolation via OS: Each terminal’s PTY is managed by the kernel; one terminal’s PTY cannot directly access another terminal’s memory or file descriptors.