@airterm/cli
v0.5.0
Published
Share local terminal sessions securely through AirTerm
Maintainers
Readme
@airterm/cli
AirTerm exposes a local terminal through an end-to-end encrypted browser session. The foreground CLI process owns the PTYs; the AirTerm server relays encrypted frames and never receives the terminal plaintext.
npx @airterm/cliRun one command instead of a reusable shell session:
npx @airterm/cli -- opencodeUse a terminal font that is available on the agent machine:
npx @airterm/cli --font-file ~/.local/share/fonts/JetBrainsMonoNerdFont-Regular.ttfAirTerm accepts one TTF, OTF, WOFF, or WOFF2 font up to 10 MiB. It streams the font directly through the encrypted session and keeps it in browser memory only. Browser image paste uses the same acknowledged streaming transport to create a private temporary file on the agent and paste its path into the active terminal. Session temporary files are removed when AirTerm exits.
The package installs the airterm executable and requires Node.js 22.12 or
newer. It uses https://airterm.app by default; pass --server or set
AIRTERM_SERVER_URL to use another deployment.
On Linux, [email protected] compiles during installation. Python 3, Make, a C++
compiler, and enabled npm installation scripts are required. On Debian/Ubuntu,
install build-essential and python3. Linux installations with scripts disabled
will not have a usable PTY binary; prebuilt Linux PTYs are not included yet.
Run airterm --help for TTL, shell, tab, replay-buffer, font, and QR options.
Access and lifetime
Anyone holding the full session URL, including its fragment, can control the terminal with the permissions of the user who launched AirTerm. This includes access to that user's files, environment variables, credentials, and network connections. Treat the URL and QR code as credentials; do not include them in logs, screenshots, or public issue reports. Command mode is not a sandbox.
The agent enforces the earlier of the server expiry and the requested lifetime, including while disconnected. Ctrl+C, SIGTERM, browser session close, and expiry stop the session and clean up its temporary files. Managed processes receive a graceful termination signal, followed by SIGKILL after two seconds if necessary. Abrupt termination such as SIGKILL or a machine crash can prevent file cleanup. Remote session deletion is best effort and has a five-second deadline.
On slow connections, the agent pauses terminal output reads until the network catches up. A prolonged stall or output-queue overflow disconnects the transport; reconnection uses retained replay history. History older than the configured per-terminal buffer can be truncated.
Supported release targets are macOS and Linux. Windows is not currently a supported release target. On macOS the agent checks the bundled node-pty helper's execute permissions before starting a terminal and repairs them when possible; a read-only installation with incorrect permissions must be reinstalled in a writable location.
Development and release
From the repository root, run npm ci, then npm run check. Run
npm run test:package to build and install both package tarballs outside the
workspace and exercise the installed executable and a real PTY. The lifecycle
test, npm run test:e2e, also requires the sibling airterm Laravel repository
and its configured local dependencies.
Before release, run these checks on macOS ARM64 and Linux with Node 22.12 and the
current LTS version, run npm audit --omit=dev, and scan the complete Git history
for credentials. Do not publish if a confirmed secret remains unrotated.
Publish @airterm/protocol first, verify its exact version is publicly available
with npm view, and only then publish @airterm/cli. Finally smoke-test the CLI
installed from the public registry. Packing builds fresh artifacts automatically.
Publishing packages and changing repository visibility are separate release
steps, not part of the local verification commands.
Updates
Interactive tunnel launches check the public npm registry for a newer AirTerm version before starting the session. The check phase waits at most two seconds; timeouts and check failures do not prevent launching. Checks, including failed attempts, are limited to once per 12 hours per user. A cached newer version is shown on every interactive launch until you update.
For a verified npm global installation, answer y to Update now? [y/N] to
install the displayed version. AirTerm then exits and asks you to rerun the same
command. Answering no or pressing Enter continues the tunnel launch. Update
installation failure exits with an error; no tunnel is started.
Run airterm update to force a fresh check and install a newer version without a
confirmation prompt. This works in scripts too, waits up to 15 seconds for the
check, and does not downgrade or reinstall an already-current version. Use
airterm -- update to share an executable named update instead.
Automatic checks require stdin, stdout, and stderr to be terminals and are
skipped in CI. Set AIRTERM_NO_UPDATE_CHECK=1 to disable them explicitly,
including in scripts that allocate a pseudo-terminal. Help, version, invalid
arguments, and programmatic runAirTerm() calls do not check for updates.
Self-installation is supported only for verified npm global installations on
macOS and Linux. AirTerm does not run sudo, change a project's lockfile, modify
linked development checkouts, or create a global installation from an npx run.
For npx use npx @airterm/cli@latest; for project dependencies or other package
managers, use the installation's original package manager. Installation uses the
same verified npm prefix, enforces Node compatibility, and has a five-minute
limit. An interrupted installation may require repair using your normal npm
setup before relaunching.
Update metadata contains only timestamps and version information, stored at:
- macOS:
~/Library/Caches/airterm/update.json - Linux:
$XDG_CACHE_HOME/airterm/update.json, or~/.cache/airterm/update.json - Windows cache resolution:
%LOCALAPPDATA%\airterm\Cache\update.json, or~/AppData/Local/airterm/Cache/update.json(Windows self-installation is unsupported)
Relative cache-directory environment overrides are ignored. Cache failures
silently skip automatic checks; airterm update can still check explicitly.
Local sessions
airterm list
airterm list --verbose
airterm list --jsonListing shows live sessions launched by your OS user on this machine, across
shells, Node versions, global installs, and npx. Each row includes an abbreviated
session ID, executable basename, launch directory, active/maximum PTY count,
connection status, and remaining locally enforced lifetime. The directory is the
launch location; it does not follow later shell cd commands. Verbose output
adds full session IDs, CLI PIDs, start times, and every active PTY's ID, generated
title, and PID. Exited PTYs are excluded even while final output is being sent.
Statuses are Connecting (initial gateway authentication), Waiting (authenticated but no encrypted browser connection), Connected (browser encryption handshake completed), and Reconnecting (retrying lost gateway connectivity).
--json returns a schemaVersion: 1 envelope with sessions and warnings, full
session IDs, absolute launch directories, UTC timestamps, and all active PTYs.
It cannot be combined with --verbose. Empty results exit successfully; discovery
errors or potentially incomplete results exit nonzero. Text warnings go to stderr;
JSON warnings stay in the envelope. Listing never checks for updates or contacts
the AirTerm server. Use airterm -- list to tunnel an executable named list.
Discovery uses private, read-only status sockets in /tmp/airterm-<uid> (directory
mode 0700, socket mode 0600) on macOS and Linux. It exposes no capability URLs,
tokens, command arguments, environment, or terminal output. Only sessions started
by a CLI with this implementation are discoverable; relaunch older sessions.
Programmatic runAirTerm() sessions participate; standalone low-level agents do
not register automatically.
If the private directory cannot be created or safely used, the tunnel still runs with a warning, but cannot be listed. Listing rejects insecure directories. Shutdown removes the session's socket immediately. Abrupt termination may leave a dead socket; listing ignores it without deleting it. Probes have a one-second limit and the complete listing has a two-second limit, so unavailable endpoints may produce verified partial results with warnings. Results are live snapshots, not a transactional view across sessions. There is no saved history or discovery of sessions on other machines or under other OS users.
Direct connections
AirTerm starts through its encrypted WSS relay, then optionally upgrades to an application-encrypted WebRTC DataChannel. The relay stays connected for signaling, revocation, and session lifetime enforcement. Direct connection failures recover through a fresh relay connection and stay on relay until the browser page reloads.
Use airterm --relay-only or AIRTERM_NO_P2P=1 to prevent native WebRTC loading and
STUN/ICE activity. Programmatic callers can set relayOnly: true. The browser's
“Prefer direct connection” preference also disables direct attempts. Re-enabling
that preference takes effect on the next page connection.
Direct connections expose network addresses to the other peer. Public STUN also
receives network addressing information; it does not receive terminal contents.
The default is stun:stun.l.google.com:19302. Operators can set
AIRTERM_STUN_URLS to a JSON array of up to four STUN URLs, or [] for host-only
candidates. TURN URLs and credentials are not supported. Invalid overrides and
unavailable native binaries leave the session on relay. Replay buffers smaller
than 512 KiB also use relay only.
During recovery, input is paused and uncertain keystrokes are never resent. Unacknowledged output is retained for up to 30 seconds, pausing noisy PTYs when necessary. After that window, commands resume without a browser and bounded history eviction continues. An unrecoverable output gap disables that terminal's input and rendering; open a new terminal or explicitly relaunch command mode. A terminal reset cannot reconstruct arbitrary missing ANSI/TUI state.
The optional node-datachannel dependency and its platform binaries are licensed
under MPL-2.0; their license and source information are distributed in their npm
packages and at https://github.com/murat-dogan/node-datachannel. AirTerm's own
source remains MIT licensed. The optional adapter runs in a child process; it
receives signaling and encrypted frames, not AirTerm's Noise keys.
P2P verification and release status
P2P is experimental in 0.3.0. The external-network verification scenarios below
remain outstanding; use --relay-only to disable P2P. Run these checks from the
workspace root with the sibling web repository at
../airterm:
npm run check
npm run test:p2p
npm run test:package
npm run test:e2e
npm audit --omit=devOn macOS, npm run test:p2p:browser opens the installed Chrome, Firefox and Safari
against a temporary localhost fixture. It uses the actual browser gateway,
native agent worker and encryption, keeps the connection alive in background
tabs, and verifies a fresh relay connection after direct operation. It does not
contact production. Close its “AirTerm P2P verification” tabs afterward.
AIRTERM_TEST_BLOCK_ICE=1 npm run test:p2p:browser
AIRTERM_TEST_UNAVAILABLE_STUN=1 npm run test:p2p:browser
AIRTERM_BROWSER_REPORT=/tmp/airterm-browser-results.json npm run test:p2p:browser
AIRTERM_P2P_REPORT=/tmp/airterm-output-results.json npm run test:p2pThe blocked-ICE fixture rewrites candidate destinations inside the test peers; it does not alter the machine's firewall. Unavailable STUN may still permit host connections. Reports contain timing and byte counts only. The 100 MiB test uses a real PTY, delayed acknowledgements and a fault-injected transport; native DataChannel exchange is exercised separately.
Verified on 2026-09-05 on an Apple M3 Pro:
| Check | Result | | --- | --- | | CLI/protocol tests | 238 passed; types and builds passed | | Browser unit tests | 13 passed; types and formatting passed | | Backend checks | Passed; 93 tests passed, 38 environment-dependent tests skipped | | Encrypted cross-repository lifecycle | Passed | | Production npm audit | No reported vulnerabilities | | Chrome, Firefox, Safari | Direct encryption, background connection, fresh relay recovery, blocked ICE and unavailable STUN passed | | macOS ARM64/x64, Node 22.12.0 and 24.20.0 | Packed native startup, optional omission and installation checks passed; x64 used Rosetta | | Linux ARM64/x64, Node 22.12.0 and 24.20.0 | Same package checks passed in Docker; x64 used emulation |
The local 100 MiB run recovered all 104,857,600 bytes, retained at most 4 MiB, and delivered exit after output. It completed in 4.66 seconds; sampled parent RSS peaked at 250 MiB including Vitest, with 4.51 seconds user CPU and 0.21 seconds system CPU. These are test-process measurements, not production capacity claims. Local browser echo RTT was 1–1.6 ms; fresh relay echo after intentional fallback was 9–26 ms. The direct integration test asserts no additional terminal payload frames on relay after handoff; relay coordination remains connected.
Before treating P2P as fully verified, repeat the browser checks on separate machines across LAN and NAT/WAN networks, physically block UDP, change network interfaces during direct traffic, and exercise long background/sleep intervals. Run the 100 MiB delayed- ACK workload through actual WebRTC with a forced path failure, measuring parent and worker memory together. Repeat with multiple PTYs, transfers, revocation, expiry and gateway restart, verifying input stays paused until inventory and replay finish and that no uncertain operation is resent. Disconnect for over thirty seconds and confirm commands resume while any evicted terminal history is rejected by the renderer. Record browser versions, network conditions, latency, relay byte counts and recovery results without terminal content or SDP/ICE addresses. These physical-network and combined stress scenarios remain outstanding verification requirements; localhost fixtures do not substitute for them.
Publish a new protocol version before the matching CLI and verify that it resolves publicly. Package publication and production deployment are separate actions.
Background sessions
Use airterm --daemon (or airterm -d) to detach a session, or
airterm -d -- npm run dev to run one command in the background. The launcher
waits up to 30 seconds for a live PTY, an authenticated gateway, and local
discovery/control endpoints. It prints the private URL/QR, expiry, session ID,
PID, diagnostic log path, and stop command before returning. --no-qr still
suppresses the QR code. Interactive update checks run once, in the launcher.
airterm -d
airterm list
airterm list --verbose
airterm stop <session-id-or-prefix>Stop accepts a full session ID or a unique prefix of at least eight characters.
It supports both foreground and background sessions started by this version,
and confirms completion only after cleanup. An ambiguous ID or incomplete
listing is an error. An accepted request with unconfirmed cleanup is reported
as an error, not as a completed stop. Older sessions without a control endpoint
require browser close, SIGTERM, or relaunch with this version. airterm -- stop
still tunnels an executable named stop.
Detached sessions survive closing the launching shell or SSH connection, retain
the launch directory and environment, and remain visible in airterm list.
They obey existing expiry and explicit End session behavior. Shell sessions stay
available with zero open terminals; reconnect and choose New terminal to open one.
Fixed-command sessions stop when their command exits. Stop terminates managed PTYs, escalates surviving process groups
after two seconds, cleans temporary uploads, and closes the remote session.
There is no restart after a crash or reboot. OS policies that terminate all user
processes on logout can still terminate the daemon.
The launcher returns startup status, not the eventual command exit code. A
command that finishes before readiness returns its own status. If startup fails,
times out, or loses its launcher before handoff, the child cleans up rather than
remaining as an undisclosed background session. Calling runAirTerm() directly
continues in the caller's process, even when its parsed options include daemon
mode; detaching is an executable-layer feature.
Daemon diagnostics use private 0600 files in /tmp/airterm-<uid>/logs under
0700 directories. Each session retains at most two 1 MiB files. They contain
bounded lifecycle events and final exit status, never capability URLs, secrets,
command arguments, environment values, SDP/ICE addresses, or terminal output.
Completed-session logs older than 24 hours are pruned on subsequent daemon
launches when discovery is complete. These temporary logs do not survive every
OS cleanup or reboot. A logging failure after startup does not stop the command.
Daemon startup requires working private logs and local discovery/control. Foreground sessions continue with a warning if local control is unavailable. The existing listing socket remains read-only; stop uses a separate private socket and does not signal processes by a saved PID. Supported daemon platforms are macOS and Linux; no service manager or system package installation is added.
Contributor verification: run npm run test:daemon for the local lifecycle
fixture, npm run test:package for isolated tarball and npx launches, and
npm run test:e2e for encrypted background sessions with multiple PTYs and
upload cleanup. These tests use temporary sessions and local test services.
Daemon verification on 2026-09-05 passed 283 CLI/protocol tests and the encrypted cross-repository lifecycle test. Isolated package checks passed on macOS and Linux, ARM64 and x64, with Node 22.12.0 and 24.20.0. macOS x64 used Rosetta; Linux ran in Docker with an init process, with x64 emulation on the ARM64 host. The checks included npx startup, launcher loss before handoff, shell closure after handoff, expiry during connection/reconnection, and an uncooperative PTY.
Account sessions (unreleased)
On a server with account features enabled:
airterm login
airterm whoami
airterm --no-qr
airterm --anonymous
airterm logoutLogin prints a private five-minute approval link. Open it, sign in, unlock your vault, and approve the CLI. The terminal validates that approval and finishes linking automatically; no second confirmation is needed. New sessions then appear in your account dashboard. Anonymous sessions are not retroactively claimed. --anonymous explicitly bypasses a saved account link; an expired or revoked link otherwise fails with relinking guidance. Programmatic runAirTerm() remains anonymous unless account configuration is explicitly supplied.
The CLI pins the approved account public key and stores a create-session-only credential in a private per-user configuration directory, separately for each server origin. On macOS this defaults to ~/Library/Application Support/airterm; Linux uses $XDG_CONFIG_HOME/airterm or ~/.config/airterm. Directories use mode 0700 and files 0600. The server credential expires after 90 days; relink before then. Logout removes local credentials and attempts remote revocation; if the server is unavailable, revoke the CLI in account settings. Existing sessions are unaffected.
The CLI encrypts each session secret to the pinned account public key. It never receives your vault private key, passphrase, or recovery key. Dashboard Join decrypts locally in the same browser tab; Lock disconnects that browser without ending the remote command. Reload or closing the tab discards unlocked account keys. Keep your recovery key: email account recovery does not decrypt the vault. Resetting a lost vault cannot restore old session secrets.
The reserved account commands dispatch before update checks. Use airterm -- login, airterm -- logout, or airterm -- whoami to tunnel executables with those names. Account features are behind a server deployment flag and are not yet a production release promise.
Session lifetime and usage policy
Anonymous sessions default to eight hours and cannot exceed the server's configured anonymous cap. A linked account session has no fixed expiration by default. Use --ttl 2d, for example, to set an explicit account-session lifetime. Operators may configure a finite account default. airterm list displays No expiration for unbounded sessions.
Sessions survive up to seven continuous days without their agent, bounded by any explicit expiry. The running CLI retries transient failures with jitter and delays capped at 30 seconds. The host and CLI must remain running; this does not survive reboot or restart commands automatically. An offline session remains listed and consumes a session slot until ended or expired. Account owners can end offline sessions from the sidebar. Device revocation ends that device's sessions when authorization is checked; the expiry of a CLI login credential alone does not end existing sessions.
The default monthly relay allowance is 1 GiB per anonymous creator IP and 50 GiB per account, shared across sessions and both directions. It resets on the first day of each calendar month at 00:00 UTC. Direct P2P payloads are excluded. Existing short-term abuse limits still apply. Usage is reserved in durable chunks of at most 1 MiB; displayed usage includes reserved bytes, and unused reservations may count after disconnect or restart.
At exhaustion, relay terminal and upload traffic pause while remote commands continue. Direct connections remain usable when available. A separate 1 MiB/day coordination reserve allows encrypted negotiation; because the relay cannot inspect plaintext, this is a bounded opaque-traffic allowance, not proof that every byte is signaling. If it is exhausted too, encrypted relay forwarding waits for its reset. Updated clients keep their bounded control connection open so revocation can still stop the agent immediately. Any interrupted encrypted relay stream is re-paired before it resumes. Missing terminal history still triggers the existing unsafe-restoration warning; no keyboard input is automatically replayed.
Clipboard-image uploads are limited to 10 MiB anonymously and 25 MiB for account sessions, per image on either transport. The browser checks the advertised limit and the CLI verifies offers and cumulative chunks. Older implementations default to 10 MiB. Font asset limits remain unchanged.
Operators configure these policies using AIRTERM_ANONYMOUS_SESSION_TTL_SECONDS, AIRTERM_ACCOUNT_SESSION_TTL_SECONDS (0 means no fixed expiry), AIRTERM_AGENT_OFFLINE_GRACE_SECONDS, AIRTERM_ANONYMOUS_MONTHLY_RELAY_BYTES, AIRTERM_ACCOUNT_MONTHLY_RELAY_BYTES, AIRTERM_COORDINATION_DAILY_BYTES, AIRTERM_ANONYMOUS_UPLOAD_BYTES, and AIRTERM_ACCOUNT_UPLOAD_BYTES. Byte limits use binary units: 1 MiB = 1,048,576 bytes. Deploy database migrations before this server code, keep the hourly pruning scheduler running, and restart the gateway to activate configuration changes. Monthly accounting requires the durable SQL database; Redis/gateway restarts do not reset it.
