Version 0.1.0 · MIT License © 2026 middlered
assh lets an agent on a Linux server work on your computer as if it were sitting at it. Your computer can run Windows, Linux or macOS; Windows is the primary target.
On your computer, assh starts an SSH server for your own user. Your normal OpenSSH client then dials out to the Linux server (the relay), and the reverse tunnel maps that SSH server onto the relay's loopback interface. Anything on the relay can then run PowerShell, move files, forward ports or speak MCP to your computer over plain SSH. No inbound port is opened, no key is copied, and nothing is installed or written on the relay.
your computer relay (Linux)
┌──────────────────────────────┐ ┌───────────────────────────┐
│ assh │ ssh.exe -R │ sshd │
│ ├ embedded SSH server ◄────┼───────────────┼── 127.0.0.1:22222 │
│ │ PowerShell, SFTP, tools │ │ ▲ │
│ └ in-memory agent key ──────┼── forwarded ──┼─► SSH_AUTH_SOCK │
└──────────────────────────────┘ agent │ agent: assh run '...' │
└───────────────────────────┘
| Your computer | Commands run in | Status |
|---|---|---|
| Windows 10 1809+ / 11 | PowerShell 7, falling back to Windows PowerShell 5.1 | Primary target; covered by the full integration suite |
| Linux | bash (change with --shell) |
Tested end to end: tunnel, tools, file transfer, interactive login with MOTD and resize |
| macOS | bash (change with --shell) |
Unit tests in CI on macos-latest; a full SSH QA pass on an arm64 runner with the system Bash 3.2 (tools, files, SCP/SFTP, forwarding, MCP, reconnect) |
These features exist on Windows only:
clip get/set- the Git Bash and WSL entry points (
assh bashon Linux and macOS is the native bash) - the crash-recovery cleanup of escaped processes. It addresses a Windows job-object gap; on Unix, process groups end whole trees.
The relay is always a Linux (or other POSIX) server.
Requirements:
- On your computer: Go 1.26.6+ to build, and the system OpenSSH client 8.7+. Windows also needs PowerShell 7 or Windows PowerShell 5.1.
- On the relay: OpenSSH with TCP and agent forwarding allowed, a POSIX login shell, and
bash,base64andgzip. Python 3.9+ is needed only forassh put/get/sync.
go build -o bin/assh.exe ./cmd/assh # Linux/macOS: go build -o bin/assh ./cmd/assh
# Once: make sure non-interactive key login to the relay works.
.\bin\assh.exe login user@relay
# Connect. You land in a Bash shell on the relay.
.\bin\assh.exe user@relay -p 52000 -o ServerAliveInterval=3In that relay shell, the assh function reaches back to your computer:
assh run 'Get-Location; Get-Date'
assh put ./build.zip 'D:/drop/build.zip'
assh statusExiting the relay shell stops everything: the tunnel, the embedded server and every process it started.
-
One process, one session.
assh user@relayruns the embedded SSH server, the reverse tunnel and your interactive login in a single process.start,runandstopprovide a background service instead. -
Your OpenSSH, your config. The tunnel and the login use the system
sshwith your~/.ssh/config, keys, ssh-agent and jump hosts. The tunnel runs withBatchMode=yesandStrictHostKeyChecking=yes, so it never prompts and never accepts an unknown relay host key. -
In-memory authorization. Each start creates an Ed25519 key that exists only in assh's memory. It is offered to the relay through a dedicated forwarded agent, and the embedded server trusts it. The relay never sees a private key, and authorization ends with the process.
authorize FILEadds further keys for agents elsewhere. -
Strict in both directions. The relay side verifies your computer's persistent host key, which is passed on the command line (
KnownHostsCommand), so no known_hosts file is involved. -
Readiness. Before the tunnel counts as online, the relay checks two things in parallel.
ssh-keyscanmust find your computer's host key behind the forwarded port, and the forwarded agent must answerssh-add -l. Withoutssh-keyscan, a full login to the in-processpingtool is used. -
Native login.
sshruns directly on your terminal, so resizing, wrapping, full-screen programs and Ctrl+C behave exactly as with plainssh. The relay environment arrives as a compressed rcfile inside the session command (about 10 KB). Nothing is typed into the shell. Bash 4+ reads the rcfile from a pipe. Bash 3 (as on macOS) cannot, so it gets a private temporary file that deletes itself before any profile runs. The rcfile:- prints
/run/motd.dynamicand/etc/motd; - loads
/etc/profileand the first of~/.bash_profile,~/.bash_loginand~/.profile, once; - defines the helpers.
Last loginis not shown, and the shell is interactive rather than a login shell;logoutand~/.bash_logoutstill work. - prints
-
Reconnects. A lost tunnel is retried with backoff (1 s doubling to 30 s, with jitter). The relay port is kept, so the helpers stay valid. With the default
--remote-port 0, the first free port from 22222 to 22285 is used.
The login shell defines assh and asscp, plus these variables. Agents started from that shell inherit them.
| Variable | Meaning |
|---|---|
ASSH_HOST, ASSH_PORT, ASSH_USER |
Where your computer's SSH server is reachable on the relay |
ASSH_HOST_KEY |
Its host key, for strict verification |
ASSH_SSH_COMMAND, ASSH_SCP_COMMAND |
Complete ssh/scp prefixes with port and host verification |
ASSH_CONNECTION_JSON |
host, port, user, host_key, target_os as JSON |
ASSH_AGENT_HELP |
The capability hint shown at login |
SSH_AUTH_SOCK |
The forwarded agent socket (set by OpenSSH) |
Calls from one shell share a single SSH connection through OpenSSH ControlMaster. The control socket goes in $XDG_RUNTIME_DIR when that directory is yours and short enough for a socket path; ASSH_NO_MUX=1 disables sharing. Processes outside this shell can use the relay_command printed by status. Re-read it after a reconnect, because the agent socket changes.
| Command | Purpose |
|---|---|
assh run [--timeout 30s] [--json] [--cwd DIR] 'script' |
Run a script: PowerShell on Windows, bash on Linux and macOS |
assh bash [-lc 'script'] |
Git Bash on Windows (never the WSL bash.exe shim), native bash elsewhere |
assh wsl [-d DISTRO] [-u USER] [--cwd DIR] -- ARGS |
Run inside WSL (Windows only) |
assh put SRC DEST / assh get SRC DEST |
Verified, resumable file or directory transfer |
assh sync SRC DEST [--exclude PATTERN] |
Upload only changed 1 MiB blocks; extra target files are kept |
assh fs ls|stat|hash|find PATH --json |
Structured filesystem queries |
assh watch DIR |
Recursive change events as JSON lines |
assh session new|send|read|attach|close |
A long-lived shell that keeps cwd, variables and modules |
assh job start|log|wait|kill|list |
Background jobs that survive the calling SSH connection |
assh info, assh ps, assh ports |
Environment, processes and listening ports as JSON |
assh status |
Tunnel phase, reconnects, uptime, last error, metrics, live sessions and jobs |
assh clip get|set |
Plain-text clipboard (Windows only; needs --allow-clipboard) |
assh mcp |
MCP server over SSH stdio |
assh dry-run OP ... / --dry-run |
Show what an operation would do without doing it |
assh help, assh capabilities --json |
Discovery |
asscp SRC DEST |
scp with the right port, host key and drive-path conversion |
assh -N -L 127.0.0.1:8080:127.0.0.1:3000 and assh -N -R ... forward ports. Any other first argument is passed to ssh as is, so assh 'Get-Process' runs a plain exec.
On Linux and macOS, scripts run as bash -c, and exit codes and timeouts work as usual; process groups end whole trees. The rest of this section describes Windows.
- PowerShell setup. Scripts run as a fresh
pwsh -NoProfile -NonInteractivewith UTF-8 I/O and$ErrorActionPreference='Stop'. The script travels in a temporary file that is deleted once read, so length is unlimited, and syntax errors report your own line and column. - Exit code. An explicit
exit Nwins. Otherwise the last statement decides: a failing native command passes its$LASTEXITCODE, a failing cmdlet gives 1, and success gives 0. Codes are signed, soexit -1is -1. --json. Returnsstdout,stderr,exit_code,duration,timed_outandtruncated, keeping the last 4 MiB per stream. Non-UTF-8 output addsbinaryand a byte-exactstdout_base64.- Timeouts and kills.
--timeout,job kill,session closeand a dropped client end the whole process tree. That includes processes started by a Microsoft Storepwsh.exe, which escape the job object. If assh itself is killed, the next start ends the processes it recorded inprocesses.json, matched by PID and creation time. - Sessions. A multi-line
session sendgets the blank line that PowerShell needs to run the block. - No translation. Scripts are not translated between PowerShell and Bash.
- Tool paths. On Windows,
put/get/sync/fsacceptD:/x,/D:/xand/d/x. Relative paths start at--workdir, which defaults to the directory assh was started in. - SFTP paths. For a Windows computer, plain SFTP and
scpunderstand drives only as/D:/x.asscpconvertsD:/x,D:\xand/d/xfor you; rawscp,sftpand$ASSH_SCP_COMMANDdo not. - How transfers work. Files move in 1 MiB SHA256-checked blocks through a
.partfile, which is renamed once the whole file verifies. Interrupted transfers resume, an identical target is left untouched, and directories stream as tar. All RPCs of one transfer share one SSH connection, and failed connections are retried. - Names Windows would mangle are refused: a
:(alternate data stream),*?"<>|, a trailing dot or space, and device names such asCONornul.txt. sync. It honours the source.gitignoreand--excludepatterns, including*,?,**,!, anchors, character classes and escapes.--textconverts CRLF to LF; otherwise bytes are kept exactly.
assh mcp speaks newline-delimited JSON-RPC on stdout, with no banner. It offers the tools command, run, put, get, ls, hash, info, fs, session, job, ps, ports, clip and status.
- Concurrency: calls run concurrently.
- Output: each stream is capped at 8 MiB and flagged
truncated.stdout_base64is present only for binary output. put: requiresstdin_base64.- Cancellation: a cancelled call is killed and, per the spec, not answered.
{"mcpServers":{"my-pc":{"command":"bash","args":["-c","assh mcp"]}}}assh user@relay [options] # tunnel + interactive login, one process
assh start [options] user@relay # background service only
assh run [options] user@relay # service in the foreground (Ctrl+C stops)
assh status [--json] # state, metrics, relay_command
assh stop
assh login user@relay # plain interactive ssh, e.g. to accept the host key
assh authorize [FILE] # trust extra agent keys (default ~/.ssh/authorized_keys)
assh exec [--json] 'script' # run a tool locally| Option | Meaning |
|---|---|
-p, -i, -F, -o |
Passed to OpenSSH (port, identity, config, any option) |
--remote-port N |
Fixed relay port; 0 (default) picks 22222-22285 |
--workdir DIR |
Initial directory for commands and relative paths |
--shell PATH |
Default shell (pwsh/powershell on Windows, bash on Linux) |
--state-dir DIR |
Profile directory; one connection per profile |
--allow-clipboard |
Enable clip get/set |
--no-audit |
Do not write audit.jsonl |
--events-file PATH, --event-webhook URL |
Where connection events go |
--connect-timeout N |
OpenSSH connect timeout in seconds |
The profile lives in %APPDATA%\agent_ssh on Windows, ~/.config/agent_ssh on Linux, and ~/Library/Application Support/agent_ssh on macOS. It holds:
- the persistent host key, which is your device identity
- extra
authorized_keys assh.logevents.jsonlwith connection state changesaudit.jsonlprocesses.json- job logs under
jobs/
Every remote operation is appended to audit.jsonl, rotated beyond 10 MiB:
- connects and disconnects
- each tool call with its arguments truncated to 512 bytes
- each plain exec with its script
Each entry carries the exit code, error and duration. Input data is never logged. SFTP, scp and port forwards appear only as their connection's connect and disconnect.
- Agents act as your local user with your full permissions.
--workdiris a starting directory, not a sandbox, and the SSH user name is only a label. - Only the relay account that holds the forwarded agent (or a key you
authorize) can connect, and only through the relay's loopback interface. - Nothing persistent is written on the relay; only a Bash 3 relay briefly holds the self-deleting rcfile. The relay still keeps its normal SSH logs and shell history.
- The temporary agent socket disappears with the session.
assh connection --write FILEwrites an explicit snapshot when you want one. It is not a credential and goes stale after a reconnect.
- No Windows service, start-at-boot or self-update. A crashed or logged-off session does not restart itself.
- Jobs and sessions end with the service that owns them and are not resumed after a restart.
- Directory transfers are committed file by file, not atomically. ACLs, owners and modes are not preserved.
- Legacy
scp -Ois not supported. Use the default SFTP mode.
go build ./... && go vet ./... && go test -timeout 90s ./...
GOOS=windows go build -o bin/assh.exe ./cmd/asshtests/integration/windows.ps1, run in PowerShell 7 on Windows, builds everything and starts a disposable Docker OpenSSH relay. It then exercises the real path Windows client → relay → embedded SSH:
- PowerShell 7 and 5.1, exit codes and process-tree cleanup
- SCP/SFTP, forwards, reconnection after a relay restart
- the native login (MOTD, profile loaded once, no bootstrap leaking)
- the tools over SSH and MCP
It uses only credentials under work/integration and removes its container afterwards.
| Package | Role |
|---|---|
internal/app |
CLI, service lifecycle, local control API |
internal/tunnel |
OpenSSH arguments, readiness, reconnects, relay login rcfile and helpers |
internal/localssh |
Embedded SSH server (sshd-lite), exec, audit hooks |
internal/reverseagent |
In-memory agent over a unix socket or a per-user named pipe |
internal/execsvc |
Execution, PowerShell wrapper, PTY/ConPTY, sessions, jobs |
internal/filesvc |
File tools, block transfer, tar, sync, watch, Python relay client |
internal/tools, internal/toolrpc |
Tool dispatch, argv protocol, MCP |
internal/platform |
Job objects, process-tree kill and tracking, profile lock |
internal/state, internal/audit, internal/sysinfo, internal/telemetry |
Keys and state files, audit log, system queries, events and metrics |
Dependencies: github.com/jpillora/sshd-lite at commit 42b505a0cdf50f782210060f9d836974d291dcc3 (MIT), and upstream github.com/jpillora/sftp v1.13.15 (BSD), unpatched.