nu-plugin-tramp — PLAN #
A TRAMP-inspired remote filesystem plugin for Nushell, written in Rust.
1. Overview #
nu-plugin-tramp allows Nushell users to transparently access remote files using
TRAMP-style URI paths, e.g.:
open /ssh:myvm:/etc/config
ls /ssh:myvm:/var/log
open /ssh:myvm:/app/config.toml | upsert port 8080 | save /ssh:myvm:/app/config.toml
The key design principle — inherited from Emacs TRAMP — is that the shell
and all its tools stay local. Only file I/O crosses the transport boundary.
Nushell's structured data model makes this a natural fit: open returns a
typed Nushell value; pipes operate locally; save writes back remotely.
1.1 Current Status #
All roadmap phases (1–6) are complete. The project is feature-complete for its v1 goals:
| Phase | Name | Status |
|---|---|---|
| 1 | MVP | ✅ Complete |
| 2 | Daily Driver | ✅ Complete |
| 3 | Power Features | ✅ Complete |
| 4 | Polish & Usability | ✅ Complete |
| 4.1 | Tab Completion | ✅ Complete |
| 5 | RPC Agent | ✅ Complete |
| 6 | Future / Advanced | ✅ Complete |
Key metrics:
- 295 tests (175 plugin + 120 agent) — unit, integration, and PTY tests
- All pre-commit hooks pass — clippy, rustfmt, statix, markdown lint
- Two crates —
nu-plugin-tramp(plugin) andtramp-agent(agent) - 4 backends — SSH, Docker, Kubernetes, Sudo (all with RPC agent support)
- 4 cross-compilation targets — x86_64/aarch64 × Linux/macOS
- Interactive remote shell —
tramp shellcommand with full PTY support
2. Repository Layout (Workspace) #
nu-plugin-tramp/
├── Cargo.toml # workspace manifest
├── Cargo.lock
├── PLAN.md # this document
├── README.md # user-facing documentation
├── cross.nix # Cross-compilation infrastructure for agent binaries
├── default.nix # Nix package derivations (plugin + agent)
├── hm-module.nix # Home Manager module
└── crates/
├── plugin/ # nu-plugin-tramp — Nushell plugin
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # plugin entry point + commands
│ ├── protocol.rs # TRAMP URI parser
│ ├── errors.rs # error types
│ ├── vfs.rs # VFS layer (caching, connection pool)
│ ├── completion.rs # tab completion for remote paths
│ └── backend/
│ ├── mod.rs # Backend trait + registry
│ ├── ssh.rs # SSH backend (SFTP + exec)
│ ├── exec.rs # Docker / K8s / sudo backends
│ ├── runner.rs # CommandRunner abstraction (local/remote)
│ ├── rpc.rs # RPC backend (agent communication)
│ ├── rpc_client.rs # MsgPack-RPC client (wire protocol)
│ ├── deploy.rs # Agent deployment via SSH/SFTP
│ ├── deploy_exec.rs # Agent deployment in containers
│ └── socket.rs # TCP/Unix socket transport
└── agent/ # tramp-agent — lightweight RPC agent
├── Cargo.toml
└── src/
├── main.rs # agent entry point (stdin/stdout/TCP/Unix RPC)
├── rpc.rs # MsgPack-RPC framing + message types
└── ops/
├── mod.rs # operation module index
├── file.rs # file.stat, file.read, file.write, …
├── dir.rs # dir.list, dir.create, dir.remove
├── process.rs # process.run, process.start, …
├── pty.rs # process.start_pty, process.resize (PTY)
├── system.rs # system.info, system.getenv, system.statvfs
├── batch.rs # batch (N ops in 1 round-trip)
└── watch.rs # watch.add, watch.remove, watch.list
Placed under dev/nushell/plugin-tramp/ in the noverby/noverby monorepo root, alongside
existing projects such as safety/oxidized/nixos/, safety/oxidized/systemd/, etc.
3. Path Format #
3.1 Basic URI #
/<backend>:<user>@<host>#<port>:<remote-path>
| Segment | Required | Example |
|---|---|---|
backend |
✅ | ssh, docker, k8s |
user |
❌ | admin |
host |
✅ | myvm, 192.168.1.10 |
port |
❌ | 2222 |
remote-path |
✅ | /etc/config |
Examples:
/ssh:myvm:/etc/config
/ssh:admin@myvm:/etc/config
/ssh:admin@myvm#2222:/etc/config
3.2 Chained URIs (Phase 2+) #
Hops are separated by |:
/ssh:jumpbox|ssh:myvm:/etc/config
/ssh:myvm|docker:mycontainer:/app/config.toml
/ssh:myvm|sudo:root:/etc/shadow
The parser must produce a Vec<Hop> to represent chains. Execution of
chains is deferred to Phase 2 but the type system must support it from day 1.
3.3 Parsed Types #
pub struct TrampPath {
pub hops: Vec<Hop>,
pub remote_path: String,
}
pub struct Hop {
pub backend: BackendKind,
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
}
pub enum BackendKind {
Ssh,
Docker,
Kubernetes,
Sudo,
}
4. Architecture #
Nushell command
│
▼
┌────────────���────────────────────────────┐
│ nu-plugin-tramp plugin │
│ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Path Parser │──▶│ Backend Resolver│ │
│ └─────────────┘ └───────┬─────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ VFS Layer │ │
│ └────────┬────────┘ │
│ │ │
│ ┌─────────────┼──────┐ │
│ ▼ ▼ ▼ │
│ ┌─────┐ ┌────────┐ ... │
│ │ SSH │ │ Docker │ │
│ └─────┘ └────────┘ │
└─────────────────────────────────────────┘
4.1 Layer 1 — Path Parser (src/protocol.rs) #
- Parses a string into
TrampPath - Must detect whether a path is a tramp URI (starts with
/<known-backend>:) - Returns
None/Ok(None)for non-tramp paths so Nushell handles them normally - Must round-trip:
parse(format(path)) == path
4.2 Layer 2 — Backend Trait (src/backend/mod.rs) #
#[async_trait]
pub trait Backend: Send + Sync {
async fn read(&self, path: &str) -> Result<Bytes>;
async fn write(&self, path: &str, data: Bytes) -> Result<()>;
async fn list(&self, path: &str) -> Result<Vec<DirEntry>>;
async fn stat(&self, path: &str) -> Result<Metadata>;
async fn exec(&self, cmd: &str, args: &[&str]) -> Result<ExecResult>;
async fn delete(&self, path: &str) -> Result<()>;
}
pub struct DirEntry {
pub name: String,
pub kind: EntryKind, // File | Dir | Symlink
pub size: Option<u64>,
pub modified: Option<SystemTime>,
pub permissions: Option<u32>,
}
pub struct Metadata {
pub kind: EntryKind,
pub size: u64,
pub modified: SystemTime,
pub permissions: u32,
}
pub struct ExecResult {
pub stdout: Bytes,
pub stderr: Bytes,
pub exit_code: i32,
}
4.3 Layer 3 — VFS (src/vfs.rs) #
Responsibilities:
- Resolve a
TrampPathto a concreteBackendinstance - Connection pooling: reuse open connections keyed by
(backend, user, host, port) - Optional: small stat cache with TTL (Phase 2)
- Optional: streaming for large files (Phase 2)
- For Phase 1: single-hop only; log a warning and return an error for chained paths
4.4 Layer 4 — SSH Backend (src/backend/ssh.rs) #
Use the openssh crate (shells out to
system OpenSSH). This gives:
- Full
~/.ssh/configsupport - SSH agent forwarding
ControlMastermultiplexing (fast subsequent ops)- Key management delegated entirely to the user's existing setup
Operations:
| Operation | Implementation |
|---|---|
read |
sftp.read(path) or exec cat <path> |
write |
sftp.write(path, data) |
list |
sftp.read_dir(path) |
stat |
sftp.metadata(path) |
exec |
session.command(cmd).args(args).output() |
delete |
sftp.remove_file(path) or exec rm <path> |
Prefer SFTP subsystem where available; fall back to exec for compatibility.
5. Nushell Plugin Interface #
The plugin is registered with Nushell via:
plugin add ~/.cargo/bin/nu_plugin_tramp
plugin use tramp
It hooks into Nushell's built-in commands by intercepting path arguments that match the TRAMP URI pattern.
5.1 Command Hooks #
| Nushell command | Plugin behavior |
|---|---|
open /ssh:… |
Read remote file; return as Nushell value (auto-detect format: JSON, TOML, CSV, raw) |
save /ssh:… |
Serialise piped Nushell value; write to remote |
ls /ssh:… |
List remote directory; return as Nushell table |
cd /ssh:… |
Set $env.PWD to the tramp URI; resolve relative paths against it |
rm /ssh:… |
Delete remote file |
cp /ssh:… /ssh:… |
Read from source backend; write to destination backend |
5.2 Custom Commands (supplementary) #
# Explicit connection test
tramp ping /ssh:myvm:/
# Show active connections
tramp connections
# Disconnect
tramp disconnect myvm
# Interactive remote shell (requires RPC agent)
tramp shell /ssh:myvm:/
tramp shell /ssh:myvm:/app --shell /bin/bash
tramp shell /docker:mycontainer:/
5.3 cd Semantics #
When $env.PWD is a TRAMP URI, any relative path passed to a TRAMP-aware
command is resolved against it:
cd /ssh:myvm:/app
open config.toml # resolves to /ssh:myvm:/app/config.toml
ls # lists /app on myvm
Non-TRAMP commands (e.g. git) receive $env.PWD as-is and will fail
gracefully — this is expected and documented.
6. Error Handling #
All errors must implement std::error::Error and produce clear, user-facing
messages. Use thiserror.
| Error kind | Message example |
|---|---|
| Parse error | invalid tramp path: missing remote path after ':' |
| Connection failed | ssh: could not connect to myvm: connection refused |
| Auth failed | ssh: authentication failed for admin@myvm |
| File not found | remote: no such file or directory: /etc/missing |
| Permission denied | remote: permission denied: /etc/shadow |
| Chained path (v1) | tramp: chained paths not yet supported (see roadmap) |
7. Dependencies #
[dependencies]
# Nushell plugin API
nu-plugin = "0.101"
nu-protocol = "0.101"
# SSH transport
openssh = { version = "0.10", features = ["native-mux"] }
openssh-sftp-client = "0.14"
# Async runtime
tokio = { version = "1", features = ["full"] }
# Utilities
bytes = "1"
thiserror = "2"
async-trait = "0.1"
Pin nu-plugin / nu-protocol to the same version as the Nushell binary in
use. Nushell's plugin ABI is version-locked.
8. Nix Integration #
Because the monorepo uses Nix + Crane, nu-plugin-tramp should expose:
# flake.nix addition (nu-plugin-tramp/default.nix)
{ crane, rust-overlay, ... }:
crane.buildPackage {
src = ./.;
pname = "nu-plugin-tramp";
}
And optionally a Home Manager module that auto-registers the plugin:
programs.nushell.plugins = [ pkgs.nu-plugin-tramp ];
9. Phased Roadmap #
Phase 1 — MVP (initial PR) #
Phase 2 — Daily Driver #
Phase 3 — Power Features #
Phase 4 — Polish & Usability #
Phase 5 — RPC Agent (inspired by emacs-tramp-rpc) #
The biggest performance opportunity is replacing shell-command-parsing with a
lightweight RPC agent deployed on the remote host. emacs-tramp-rpc
demonstrates 2–38× speedups over traditional shell-based TRAMP by using this
approach.
5.1 — tramp-agent binary ✅ #
A small, statically-linked Rust binary (~1 MB) that runs on the remote host and speaks a length-prefixed MessagePack-RPC protocol over stdin/stdout (piped through the SSH connection).
┌──────────────────┐ SSH stdin/stdout ┌───────────────────┐
│ nu-plugin-tramp │ ◄───────────────► │ tramp-agent │
│ (local Nushell) │ MsgPack-RPC │ (remote Rust) │
└──────────────────┘ └───────────────────┘
RPC methods exposed by the agent:
| Category | Methods |
|---|---|
| File | file.stat, file.stat_batch, file.truename, file.size |
| File I/O | file.read, file.read_range, file.write, |
file.write_range, file.copy, file.rename, |
|
file.delete, file.set_modes |
|
| Directory | dir.list, dir.create, dir.remove |
| Process | process.run, process.start, process.read, |
process.write, process.kill |
|
| System | system.info, system.getenv, system.statvfs |
| Batch | batch (multiple ops in one round-trip) |
| Watch | watch.add, watch.remove, watch.list |
Key advantages over shell parsing:
| Aspect | Current (shell parsing) | With tramp-agent |
|---|---|---|
| Communication | Individual SSH exec commands | MsgPack-RPC over single pipe |
| Latency | N operations = N round-trips | N operations = 1 round-trip |
| Binary data | base64 encode/decode | Native binary (MsgPack bin) |
| Stat + list | Separate commands, text parse | Native lstat() syscalls |
| Shell dependency | Requires sh, stat, cat, etc. | None (self-contained binary) |
| Cache invalidation | TTL-based (5s default) | inotify/kqueue push events |
5.2 — Automatic deployment ✅ #
On first connection (when the agent is not yet present on the remote), the plugin:
- Detects the remote OS and architecture (
uname -sm) - Checks a local cache (
~/.cache/nu-plugin-tramp/VERSION/ARCH/tramp-agent) - Downloads a pre-built binary from GitHub Releases (or builds from source if Rust is installed on the remote)
- Uploads via SFTP to
~/.cache/tramp-agent/tramp-agenton the remote - Starts the agent and switches the backend to RPC mode
If deployment fails, the plugin falls back to the current shell-parsing approach transparently — no user action required.
Connect via SSH
│
▼
Agent already deployed? ──yes──► Start agent, use RPC
│ no
▼
Detect remote arch (uname -sm)
│
▼
Check local cache
│
├─ Found ──────────────► Upload via SFTP, start
│
▼
Download from GitHub Releases
│
├─ Success ────────────► Cache locally, upload, start
│
▼
Build with cargo (if available)
│
├─ Success ────────────► Cache locally, upload, start
│
▼
Fall back to shell-parsing mode (current behavior)
5.3 — Filesystem watching (push cache invalidation) ✅ #
When the agent is running, it uses inotify (Linux) or kqueue (macOS) to
watch directories that the client has recently accessed. On filesystem
changes, the agent sends an fs.changed notification with the affected
paths. The VFS layer uses these to invalidate specific cache entries
immediately, rather than waiting for TTL expiry.
This is especially valuable for workflows where an editor and shell are both operating on the same remote directory — changes are reflected instantly.
5.4 — Batch and parallel operations ✅ #
The batch RPC method allows the client to send multiple operations in a
single request:
Client sends:
batch { requests: [
{ method: "file.stat", params: { path: "/app/config.toml" } },
{ method: "file.stat", params: { path: "/app/data.json" } },
{ method: "dir.list", params: { path: "/app" } },
]}
Agent responds:
{ results: [ {result: ...}, {result: ...}, {result: ...} ] }
The commands.run_parallel method runs multiple commands concurrently using
OS threads (inspired by emacs-tramp-rpc's magit optimization that sends ~60
git commands in a single round-trip).
5.5 — Agent for exec backends ✅ #
The agent concept extends beyond SSH. For Docker and Kubernetes backends,
the agent binary can be copied into containers (docker cp / kubectl cp)
and started via docker exec / kubectl exec, giving the same RPC
performance benefits inside containers.
Implementation (src/backend/deploy_exec.rs):
- Architecture detection: runs
uname -sminside the container via the appropriate exec prefix (docker exec/kubectl exec) - Upload methods:
- Docker:
docker cp <local_tmp> <container>:/tmp/tramp-agent-dir/tramp-agent - Kubernetes:
kubectl cpwith base64 exec fallback (for minimal containers lackingtar) - Docker base64 fallback for remote runners
- Docker:
- Agent startup: spawns
docker exec -i/kubectl exec -ias an interactivetokio::process::Childwith piped stdin/stdout - VFS integration:
connect_hopattempts agent deployment for standalone Docker/K8s containers (no parent backend); on failure, falls back to the shell-parsingExecBackendtransparently - Chained paths: for
/ssh:host|docker:ctr:/path, the parent SSH hop already uses the RPC agent, so Docker commands routed through it are already fast; deploying a second agent inside the container through a remote runner is deferred to Phase 6
Standalone Docker/K8s:
┌──────────────┐ docker exec -i ┌───────────────────┐
│ nu-plugin- │ ◄──────────────► │ tramp-agent │
│ tramp │ MsgPack-RPC │ (in container) │
└──────────────┘ └───────────────────┘
Chained (SSH → Docker):
┌──────────────┐ SSH pipe ┌─────────┐ docker exec ┌───────────┐
│ nu-plugin- │ ◄────────► │ agent │ ◄──────────► │ container │
│ tramp │ MsgPack │ (remote) │ shell cmds │ │
└──────────────┘ └─────────┘ └───────────┘
5.6 — Protocol design ✅ #
- Framing: 4-byte big-endian length prefix + MessagePack payload
- Request:
{ version: "2.0", id: N, method: "...", params: {...} } - Response:
{ version: "2.0", id: N, result: ... }or{ ..., error: { code, message } } - Notification (server → client):
{ version: "2.0", method: "fs.changed", params: { paths: [...] } } - Binary data: MessagePack
bintype for file content (no base64) - Concurrent requests: Multiple requests can be in-flight; responses are matched by
id
Dependencies for the agent binary:
[dependencies]
serde = { version = "1", features = ["derive"] }
rmp-serde = "1.3"
rmpv = { version = "1.3", features = ["with-serde"] }
tokio = { version = "1", features = ["rt-multi-thread", "io-util", "io-std", "fs", "process", "sync"] }
notify = "6.1" # inotify/kqueue
libc = "0.2" # for native stat, uid/gid resolution
Build profile for minimal binary size:
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
Phase 4.1 — Tab Completion for Remote Paths ✅ #
Dynamic tab completion is implemented via get_dynamic_completion on every
plugin command that accepts a TRAMP path argument (src/completion.rs).
Completion stages:
| User input | Completions offered |
|---|---|
/ |
Backend prefixes: /ssh:, /docker:, /k8s:, /sudo: |
/ss |
/ssh: |
/ssh: |
Host names from ~/.ssh/config + active VFS connections |
/docker: |
Running container names (docker ps) |
/k8s: |
Pod names (kubectl get pods) |
/ssh:host: |
Suggest /ssh:host:/ to start remote path |
/ssh:host:/etc/ |
Remote directory listing of /etc/ |
/ssh:host:/etc/ho |
Filtered entries starting with ho |
subdir/ (with CWD) |
Relative path listing against remote CWD |
Architecture additions:
completion.rs— new module with all completion logic:complete_tramp_path()— main entry point, dispatches to stage-specific completers based on how much of the URI has been typedextract_positional_string()— extracts the partial argument text from the ASTCall, handling thestripplaceholder flagcomplete_backend_prefix()— prefix-matches known backend namescomplete_host()— gathers host suggestions from SSH config, active connections,docker ps, andkubectl get podscomplete_remote_path()— lists the parent directory via VFS and filters by the partial filename; directories get a trailing/complete_relative_path()— relative path completion when a remote CWD is active viatramp cdparse_ssh_config_hosts()— parses~/.ssh/configHostentries (skipping wildcards)list_docker_containers()/list_k8s_pods()— best-effort subprocess calls for container/pod name discovery
complete_tramp_arg()— shared helper inmain.rsthat extracts the positional argument and callscomplete_tramp_path()- All path-accepting commands implement
get_dynamic_completion:tramp open,tramp ls,tramp save,tramp rm,tramp cp(both source and destination),tramp cd,tramp exec,tramp info,tramp ping,tramp watch
Phase 6 — Future ✅ #
Streaming for very large files ✅ #
Chunked I/O is implemented via three new agent RPC methods and corresponding
Backend trait methods, enabling streaming reads and writes of arbitrarily
large files without loading them entirely into memory.
New agent RPC methods:
| Method | Description |
|---|---|
file.size |
Get file size (lightweight, no full stat) |
file.read_range |
Read a byte range: { path, offset, length } |
file.write_range |
Write at offset: { path, offset, data, truncate, create } |
file.read_range returns { data: <binary>, eof: <bool> } — the client
reads in a loop, advancing offset by the chunk size each iteration, until
eof is true.
file.write_range supports chunked uploads: send the first chunk with
truncate: true to create/truncate the file, then subsequent chunks with
increasing offsets. An explicit flush() after each write_all ensures
data reaches disk before the success response is sent.
Architecture additions:
Backendtrait — new optional methods with default fallback implementations:file_size(path)— defaults tostat(path).sizeread_range(path, offset, length)— defaults to reading the whole file and slicing (defeats streaming, but works)write_range(path, offset, data, truncate)— defaults to whole-file write (only correct for offset=0 + truncate)supports_streaming()— returnsfalseby default
RpcBackend— implements all streaming methods natively via the agent RPC calls;supports_streaming()returnstrueVFS— new public methods:file_size,read_range,write_range,supports_streamingtramp open— files larger than 1 MB on streaming-capable backends are returned as aByteStream(viaByteStream::from_fn) instead of a singleValue, enabling pipelines liketramp open /ssh:host:/big.bin | save local.binwithout OOMtramp save—ByteStreaminput on streaming-capable backends is written in 1 MB chunks viawrite_range, avoiding buffering the entire payload in memory; non-streaming backends fall back to collecting the stream first
Chunk size: 1 MB (read and write). Agent caps file.read_range at 16 MB
per request for safety.
tramp watch command ✅ #
Implemented as a plugin command that leverages the RPC agent's
watch.add / watch.remove / watch.list methods and fs.changed
push notifications (inotify/kqueue).
tramp watch /ssh:myvm:/app --duration 5000 # watch for 5 seconds
tramp watch /ssh:myvm:/app --recursive # include subdirectories
tramp watch /ssh:myvm:/ --list # show active watches
tramp watch /ssh:myvm:/app --remove # stop watching
Architecture additions:
Backendtrait — new optional methods:watch_add,watch_remove,watch_list,watch_poll,supports_watch(default: not supported)RpcBackend— implements all watch methods by calling the agent's RPC endpoints and draining bufferedfs.changednotificationsVFS— exposes synchronouswatch_add,watch_remove,watch_list,watch_poll,supports_watchmethodstramp watchcommand — adds a watch, polls for events over the specified duration (default 10s, 250ms poll interval), returns a table of{ paths, kind, timestamp }records, then cleans up the watch
Agent version management ✅ #
The agent binary now supports --version / -V flags, printing
tramp-agent <version> to stdout.
The deployment module (deploy.rs and deploy_exec.rs) checks the
remote agent's version by running <agent> --version during the
is_agent_deployed / is_agent_deployed_in_container checks. If the
version doesn't match the plugin's CARGO_PKG_VERSION, the agent is
re-uploaded and restarted automatically — ensuring plugin and agent are
always in sync after upgrades.
TCP/Unix socket transport for agent ✅ #
The agent now supports a --listen flag that makes it listen on a TCP port
or Unix domain socket instead of stdin/stdout. This enables direct socket
connections from the plugin, bypassing the overhead of a docker exec -i or
kubectl exec -i process sitting in the middle of every RPC exchange.
Usage:
# TCP listener (port 0 = ephemeral):
tramp-agent --listen tcp:0.0.0.0:9547
tramp-agent --listen tcp:127.0.0.1:0
# Unix socket listener:
tramp-agent --listen unix:/tmp/tramp-agent.sock
# Formats also accepted without scheme prefix:
tramp-agent --listen 127.0.0.1:9547
tramp-agent --listen /tmp/tramp-agent.sock # (Unix only)
The agent prints a machine-readable LISTEN:tcp:<addr> or
LISTEN:unix:<path> line to stderr so the plugin can discover the actual
bound address (especially useful with ephemeral ports).
Architecture additions:
-
Agent (
main.rs):parse_listen_addr()— parses--listenargument intoListenAddr::TcporListenAddr::Unixserve_connection()— transport-agnostic request loop extracted from the oldmain(), generic overAsyncRead + AsyncWriterun_tcp_listener()— binds a TCP listener and accepts connections sequentiallyrun_unix_listener()— binds a Unix domain socket listener with cleanup on shutdown- Default mode (no
--listen) serves a single connection over stdin/stdout as before
-
Plugin (
backend/socket.rs) — new module:SocketAddrenum — parsed TCP or Unix addressparse_socket_addr()— mirrors the agent's address parserconnect_tcp()/connect_unix()— establish a connection, create anRpcClient, verify with a ping, and return anRpcBackendconnect()— generic dispatcherstart_docker_tcp_agent()— deploys agent in a Docker container with--listen tcp:..., discovers the container IP viadocker inspect, and connects directly via TCPstart_k8s_tcp_agent()— deploys agent in a Kubernetes pod, sets upkubectl port-forward, and connects via the forwarded local portget_docker_container_ip()— helper to query a container's network IP
-
VFS (
vfs.rs) —connect_hopfor standalone Docker and Kubernetes now attempts a TCP socket upgrade after deploying the agent, falling back to the pipe-based RPC backend if TCP is unavailable
Key benefits:
| Aspect | stdin/stdout pipe | TCP/Unix socket |
|---|---|---|
| Intermediate process | docker exec -i alive |
None (direct connection) |
| Connection lifetime | Tied to exec process | Independent / persistent |
| Reconnection | Must restart exec | Just reconnect the socket |
| Multiple clients | One per exec process | Sequential accept loop |
| Latency overhead | Docker/kubectl proxy | Direct TCP or Unix IPC |
Cross-compilation matrix for agent binaries ✅ #
A Nix-based cross-compilation infrastructure is provided in cross.nix for
building the tramp-agent binary for multiple target architectures from a
single host. Linux targets are statically linked via musl for maximum
portability — the resulting binaries can be deployed to any Linux host
regardless of its libc.
Supported targets:
| Target | Triple | Notes |
|---|---|---|
| x86_64-linux | x86_64-unknown-linux-musl | Most servers & desktops |
| aarch64-linux | aarch64-unknown-linux-musl | ARM64 (Raspberry Pi 4+) |
| x86_64-darwin | x86_64-apple-darwin | Intel Mac |
| aarch64-darwin | aarch64-apple-darwin | Apple Silicon Mac |
Usage:
# Build all Linux agent binaries from an x86_64-linux host:
cross = import ./cross.nix { inherit nixpkgs; };
agents = cross.allLinuxFrom "x86_64-linux";
# → agents.x86_64-linux (statically linked musl binary)
# → agents.aarch64-linux (cross-compiled, statically linked)
The cacheLayout helper produces a directory tree matching the plugin's
deployment module expectations:
$out/
├── x86_64-unknown-linux-musl/
│ └── tramp-agent
└── aarch64-unknown-linux-musl/
└── tramp-agent
This can be copied into ~/.cache/nu-plugin-tramp/<version>/ so the
plugin's automatic deployment picks up the correct binary for each remote
host's architecture.
Architecture additions:
cross.nix— new module:buildAgent/buildAgentStatic— derivation builders for native and musl-static targetsgetCrossPkgs— helper to set up Nix cross-compilation pkgsx86_64-linux,aarch64-linux,x86_64-darwin,aarch64-darwin— per-target derivationsallLinuxFrom— build all Linux targets from a given hostmatrix— structured list of all targets for CI iterationcacheLayout— produces the local cache directory structure
default.nix— newtramp-agent-cachepackage that produces the cache layout with both Linux architectures
tramp shell command ✅ #
Interactive remote terminal session leveraging the PTY support built into the agent, providing a seamless shell-in-shell experience without leaving Nushell.
tramp shell /ssh:myvm:/ # default shell on remote
tramp shell /ssh:myvm:/app --shell /bin/bash # specific shell + CWD
tramp shell /docker:mycontainer:/ # shell inside a container
tramp shell /ssh:myvm|docker:ctr:/ # chained: SSH → Docker
The command opens /dev/tty directly (bypassing the plugin's stdin/stdout
MsgPack-RPC channel) and puts it in raw mode, then relays bytes
bidirectionally between the local terminal and the remote PTY.
Architecture:
-
Terminal I/O (
tty_rawmodule inmain.rs) — minimal libc-based terminal helpers:open_tty()— opens/dev/ttyfor read+writeterminal_size()— queriesTIOCGWINSZfor current cols×rowsenable_raw_mode()/restore_mode()— save/restore termios,cfmakerawfor raw moderead_with_timeout()—poll(2)+read(2)with millisecond timeoutwrite_all()— blocking write loop vialibc::writeRawModeGuard— RAII guard that restores termios on drop (panic-safe)
-
TrampShellcommand (#[cfg(unix)]):- Checks
supports_pty()before proceeding - Detects remote default shell via
echo $SHELL(overridable with--shellflag) - Sets remote CWD from the URI's path component via
cd <dir> && exec - Starts a PTY with the detected terminal dimensions
- Spawns an input thread (
/dev/tty→pty_write) with 50ms poll timeout - Runs an output loop on the main thread (
pty_read→/dev/ttywrite) with 5ms idle sleep - Periodically checks for terminal resize (~every 100ms) and sends
pty_resizewhen dimensions change - On session end (remote shell exits), signals the input thread via
AtomicBool, restores the terminal, and kills the PTY
- Checks
-
Dependencies — adds
libc = "0.2"to the plugin crate for raw terminal manipulation (already used by the agent crate)
Key design decisions:
| Aspect | Approach |
|---|---|
| Terminal access | /dev/tty (bypasses plugin MsgPack-RPC stdio) |
| Raw mode | cfmakeraw + RAII guard for safe restore |
| Input forwarding | Dedicated thread with poll(2) + 50ms timeout |
| Output forwarding | Main thread loop with 5ms idle sleep |
| Terminal resize | Periodic size check (~100ms) + pty_resize RPC |
| CWD support | cd <dir> && exec <shell> wrapper |
| Platform | Unix only (#[cfg(unix)]) |
PTY support via agent ✅ #
The agent now supports pseudo-terminal (PTY) allocation for running interactive commands on the remote host. This enables proper TTY-aware behaviour (e.g. colour output, line editing, signal handling) for commands executed through the RPC agent.
New agent RPC methods:
| Method | Description |
|---|---|
process.start_pty |
Start a process with a PTY, return handle + PID |
process.resize |
Send a window size change (TIOCSWINSZ + SIGWINCH) |
PTY processes reuse the existing process.read, process.write, and
process.kill methods — the dispatch layer routes to the correct table
based on handle range (PTY handles ≥ 1,000,000).
process.start_pty parameters:
program(string, required) — the program to executeargs(array of strings, optional) — command argumentscwd(string, optional) — working directoryenv(map of string→string, optional) — environment variablesrows(u16, optional, default 24) — initial terminal rowscols(u16, optional, default 80) — initial terminal columns
Architecture additions:
-
Agent (
ops/pty.rs) — new module implementing PTY support:openpty()— creates a master/slave PTY pair vialibc::openptyset_winsize()— sets terminal dimensions viaTIOCSWINSZioctlAsyncPtyMaster— tokioAsyncFdwrapper for non-blocking I/O on the PTY master fdfork_pty()— synchronous helper that allocates the PTY, forks, setssetsid()+TIOCSCTTY, redirects stdio to the slave PTY, and execs the program — all non-Sendraw pointer work is confined herePtyTable— shared table of managed PTY processes (separate from the regularProcessTableto avoid handle collisions)is_pty_handle()— checks if a handle ID belongs to the PTY table- Full read/write/kill/resize handlers with proper child reaping
(
waitpid) and EIO handling (slave closed on child exit) - Platform-gated: full implementation on Unix, stub errors on non-Unix
-
Agent (
main.rs) — dispatch updated:process.start_pty→ops::pty::start_ptyprocess.resize→ops::pty::resizeprocess.read/process.write/process.killauto-route toops::ptywhen the handle is in the PTY range
-
Plugin (
backend/mod.rs) —Backendtrait additions:PtyHandleandPtyReadResulttypes- Optional methods:
pty_start,pty_read,pty_write,pty_resize,pty_kill,supports_pty(default: not supported)
-
Plugin (
backend/rpc.rs) —RpcBackendimplements all PTY methods natively via the agent RPC calls;supports_pty()returnstrue -
Plugin (
vfs.rs) — new public methods:supports_pty,pty_start,pty_read,pty_write,pty_resize,pty_kill
10. Non-Goals (v1) #
- GUI or TUI
- Windows support (SFTP client may not build; untested)
- Non-Nushell shells
- Encrypted secrets management (delegate to
agenix/ragenixalready in the monorepo) - FUSE mount (use SSHFS if you need that separately)
11. Future Directions (Beyond v1) #
With all v1 roadmap phases complete, potential areas for future work include:
- Chained-path agent nesting — deploy a second
tramp-agentinside a container reached via a chained path (e.g./ssh:host|docker:ctr:/path), giving full RPC performance even for the inner hop (currently the inner hop falls back to shell-parsing through the outer agent) - GitHub Releases CI — automated release pipeline that builds the cross-compilation matrix and publishes agent binaries to GitHub Releases for automatic download during deployment
- FUSE integration — optional FUSE mount mode for applications that expect a real filesystem path (complementary to the current VFS approach)
- Windows agent support — extend PTY and socket transport to Windows targets (ConPTY, named pipes)
- Plugin marketplace — package for Nushell's future plugin registry once it materialises