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.
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.
flowchart LR
subgraph computer["Your computer — Windows, Linux or macOS"]
server["assh embedded SSH server<br/>Shell, SFTP and tools"]
key["In-memory agent key"]
client["Native OpenSSH client"]
end
subgraph relay["Relay — Linux / POSIX"]
sshd["sshd"]
port["127.0.0.1:22222"]
socket["SSH_AUTH_SOCK"]
agent["Agent running assh commands"]
end
client -->|"Outbound SSH connection"| sshd
port -->|"Reverse tunnel: ssh -R"| server
key -->|"Agent forwarding"| socket
agent -->|"SSH commands / SCP / SFTP / MCP"| port
agent -.->|"Authenticate"| socket
| 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.
Download the archive for your platform from Releases: Windows, Linux and macOS, each for amd64 and arm64. Every archive has a .sha256 file next to it and contains assh (assh.exe on Windows), LICENSE and README.md.
To build any platform yourself, use Go 1.26.6+:
go build -o bin/assh ./cmd/assh # bin/assh.exe on WindowsRequirements:
- On your computer: 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.
# Once: make sure non-interactive key login to the relay works.
assh login user@relay
# Connect. You land in a Bash shell on the relay.
assh user@relay -p 2222 -o ServerAliveInterval=3
# Any ssh arguments work, e.g. a jump host, a port forward and your own agent.
assh -J me@bastion user@relay -L 8080:127.0.0.1:3000 -AIn 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, and with your ssh arguments as given (see OpenSSH arguments). 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, and the helpers find the new agent socket by themselves. 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, host verification and IdentityAgent=$ASSH_AGENT_SOCKET |
ASSH_AGENT_SOCKET |
The tunnel's forwarded agent socket, which holds the in-memory key |
ASSH_CONNECTION_JSON |
host, port, user, host_key, target_os as JSON |
ASSH_AGENT_HELP |
The capability hint shown at login |
SSH_AUTH_SOCK is left to OpenSSH: it is your own agent when you log in with -A (or ForwardAgent), and unset otherwise. The helpers never use it.
The tunnel's agent socket changes when the tunnel reconnects. Before each call, the helpers look it up in the environment of the tunnel's session process on the relay (Linux /proc; only your own processes are readable) and update ASSH_AGENT_SOCKET. Programs that use ASSH_SSH_COMMAND directly read ASSH_AGENT_SOCKET from their own environment; after a reconnect, run __assh_agent first (it is exported to child Bash shells) or go through the assh function. Without /proc, the socket from login time is used.
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 |
On the relay, assh -N -L 127.0.0.1:8080:127.0.0.1:3000 and assh -N -R ... forward ports to and from your computer. 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 [options] SSH-ARGS # tunnel + interactive login, one process
assh start [options] SSH-ARGS # background service only
assh run [options] SSH-ARGS # service in the foreground (Ctrl+C stops)
assh status [--json] # state, metrics, relay_command
assh stop
assh login SSH-ARGS # plain ssh SSH-ARGS, e.g. to accept the host key
assh authorize [FILE] # trust extra agent keys (default ~/.ssh/authorized_keys)
assh init # create the profile and host key ahead of time (optional)
assh exec [--json] 'script' # run a tool locally
assh --versionSSH-ARGS is the relay destination plus any ssh options, before or after it; see OpenSSH arguments. assh's own options are long names:
| Option | Meaning |
|---|---|
--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 and macOS) |
--ssh PATH |
OpenSSH client to use (default ssh from PATH) |
--no-shell |
With assh user@relay: start the tunnel detached, like start |
--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 |
assh takes only its own long options (above; --name or -name). Every other argument goes to ssh unchanged and in its original order: -J, -L, -R, -D, -A, -v, -p, -i, -F, -o and any option a future OpenSSH adds. assh does not check them. Before connecting, it lets ssh -G evaluate them, so a mistake stops assh at once with OpenSSH's own message. Everything after -- also goes to OpenSSH.
assh runs up to three ssh processes, and they receive these arguments differently:
- Interactive login: all of them. So
-L/-R/-Dforwards live as long as the login,-Aforwards your own agent to the relay shell, and-vshows OpenSSH's debug output. assh only adds-ttand its session command, which it passes asRemoteCommand. A remote command given after the destination is therefore rejected by OpenSSH. - Background tunnel and health probe: the connection options only, such as jump host, port, user, identity, config, ciphers,
-ooptions,-v. Options that belong to a session are skipped: forwards,-A/-a,-N,-f,-t/-T,-W,-O,-M/-S,-q,-E/-y, X11, and session-level-ooptions such asLocalForwardorRequestTTY. Options assh does not know are passed on. The tunnel always forwards assh's in-memory agent, never yours. - Without a login (
start,run,--no-shell), the tunnel is the only connection, so it also takes the-L/-R/-Dforwards (and the matching-ooptions).
One limit comes from OpenSSH itself: forwards written in ~/.ssh/config cannot be removed for a single connection (ClearAllForwardings would also remove assh's own -R). The background tunnel opens them too, and the login then reports Address already in use for those ports. The forwards still work, through the tunnel. To give them to the login alone, put them on the command line or under a separate Host alias.
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.