Arm a desktop host for SSH
Before an agent can run a tool on a workstation, the machine has to let the server in — once, on one network, with one key. This page is the whole procedure for a Windows host, in order, with a check after every step. Work through it and you will not have to guess anything.
Do the steps on the machine, in an elevated PowerShell, unless a step says
otherwise. Where you see <user>, use the account the tools are licensed to.
What you are building
Section titled “What you are building”SupaCloud server ──SSH (tailnet only)──▶ workstation holds the private key holds the public key, in the resource secret bag the tools, the GPU, the licenceThe server dials out to a machine it names. There is no agent to install and nothing on the workstation calls home.
0. Before you start
Section titled “0. Before you start”-
The machine is on the tailnet and you know its tailnet name or
100.xaddress. -
You are not doing this on a daily driver. A desktop host runs agent commands as a real user, with no container boundary — no
cap_drop, no read-only root, no pid limits. Use a dedicated machine. -
You have a key pair for this host. Generate one on the server side, not on the workstation:
Terminal window ssh-keygen -t ed25519 -N '' -C 'supacloud→bench-01' -f ./bench-01bench-01.pubgoes on the machine;bench-01goes into the resource secret bag in step 7 and nowhere else.
1. Install the OpenSSH server
Section titled “1. Install the OpenSSH server”Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0Check: Get-WindowsCapability -Online -Name OpenSSH.Server* | Select-Object State
reports Installed.
2. Start it and keep it started
Section titled “2. Start it and keep it started”Set-Service -Name sshd -StartupType AutomaticStart-Service -Name sshdCheck: Get-Service sshd | Select-Object Status, StartType reports
Running and Automatic. A host that only answers until the next reboot is a
host that fails at 3 a.m.
3. Install the public key — and read this before you do
Section titled “3. Install the public key — and read this before you do”Windows OpenSSH does not read ~/.ssh/authorized_keys for accounts in the
local Administrators group. The Match Group administrators block at the end of
C:\ProgramData\ssh\sshd_config redirects those to
C:\ProgramData\ssh\administrators_authorized_keys. Silently. This is the single
most common reason a correct key is refused.
Pick one:
A — the account is not an administrator (preferred; the tools rarely need it):
$file = "C:\Users\<user>\.ssh\authorized_keys"New-Item -ItemType Directory -Force -Path (Split-Path $file) | Out-NullSet-Content -Path $file -Value (Get-Content .\bench-01.pub) -Encoding asciiicacls $file /inheritance:r /grant "<user>:R" /grant "SYSTEM:F"B — the account is an administrator: put the same key in
C:\ProgramData\ssh\administrators_authorized_keys instead, with the ACL that
file requires:
$file = "C:\ProgramData\ssh\administrators_authorized_keys"Set-Content -Path $file -Value (Get-Content .\bench-01.pub) -Encoding asciiicacls $file /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F"Either way the ACL is the point: OpenSSH refuses a key file that anyone else can write, and it refuses it by ignoring the key, not by saying so.
Check: icacls $file lists only the two entries you granted.
4. Open port 22 on the tailnet only
Section titled “4. Open port 22 on the tailnet only”Installing the capability creates a broad inbound rule. Replace it.
Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue | Set-NetFirewallRule -Enabled False
New-NetFirewallRule ` -Name 'sshd-tailnet-only' ` -DisplayName 'OpenSSH Server (tailnet only)' ` -Direction Inbound -Protocol TCP -LocalPort 22 -Action Allow ` -RemoteAddress 100.64.0.0/10 ` -InterfaceAlias (Get-NetAdapter | Where-Object InterfaceDescription -like '*Tailscale*').Name100.64.0.0/10 is the address range a tailnet hands out, so the rule stays
correct if the adapter is renamed; the interface restriction is the second lock.
Check: from a machine outside the tailnet, Test-NetConnection <public-ip> -Port 22 fails. From inside it succeeds. Both halves matter — a rule that lets
everyone in still passes the second test.
5. Read back the host-key fingerprint
Section titled “5. Read back the host-key fingerprint”ssh-keygen -lf C:\ProgramData\ssh\ssh_host_ed25519_key.pubIt prints something like
256 SHA256:9k2f… C:\ProgramData\ssh\ssh_host_ed25519_key.pub (ED25519).
Send the SHA256:… part back. It goes into the resource’s
host_key_fingerprint field and is what stops the server from talking to a
different machine that answers on that address. There is no trust-on-first-use
here: an unpinned host is a host we do not connect to.
6. Install the two helper commands
Section titled “6. Install the two helper commands”The evidence reader needs to do exactly two things on the machine: print one file,
and say when that file was last written. Both are declared, not assumed,
because cat / type / Get-Content are not the same word on every host.
Create C:\ProgramData\supacloud\bin\sc-read.ps1:
param([Parameter(Mandatory)][string]$Path)if (-not (Test-Path -LiteralPath $Path)) { exit 1 }$bytes = [System.IO.File]::ReadAllBytes($Path)$out = [Console]::OpenStandardOutput()$out.Write($bytes, 0, $bytes.Length)$out.Flush()It writes bytes, not text, on purpose: a pipeline that tees its report through PowerShell writes UTF-16LE with a byte-order mark, and the reader decodes that correctly only if the mark survives.
Create C:\ProgramData\supacloud\bin\sc-mtime-ms.ps1:
param([Parameter(Mandatory)][string]$Path)if (-not (Test-Path -LiteralPath $Path)) { exit 1 }$utc = (Get-Item -LiteralPath $Path).LastWriteTimeUtc[DateTimeOffset]::new($utc, [TimeSpan]::Zero).ToUnixTimeMilliseconds()Declare them on the resource:
"read_command": "powershell -NoProfile -NonInteractive -File C:\\ProgramData\\supacloud\\bin\\sc-read.ps1","stat_command": "powershell -NoProfile -NonInteractive -File C:\\ProgramData\\supacloud\\bin\\sc-mtime-ms.ps1"Invoking them through powershell -File means the login shell stops mattering —
the line runs the same whether the SSH session lands in cmd.exe or PowerShell.
Check, in an SSH session, against a file that exists:
powershell -NoProfile -NonInteractive -File C:\ProgramData\supacloud\bin\sc-mtime-ms.ps1 C:/Windows/win.iniIt prints a 13-digit number. Note the forward slashes in the path: a backslash is a refused metacharacter in a declared path, and always will be — the driver refuses tokens rather than escaping them, because the remote dialect is not knowable.
On a Linux workstation the same two commands are cat -- and a two-line
mtime-ms script; see
Add a local desktop tool.
7. Hand the private key over — once
Section titled “7. Hand the private key over — once”Create the ssh_host resource in the workspace with the host, port, username and
the host_key_fingerprint from step 5, and put the private key in its secret
bag:
{ "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----\n" }Then delete your local copy.
There is deliberately no endpoint that mints this for you. A minting endpoint would be a second place the same credential lives, and a credential with two homes has two chances to leak and no single place to revoke. The key exists in the secret bag and on the workstation, and nowhere else — not in the agent, not in the MCP layer, not on the machine’s own tooling, which never sees it either.
8. Prove the whole chain
Section titled “8. Prove the whole chain”From the server side:
ssh -i ./bench-01 -o StrictHostKeyChecking=yes <user>@<tailnet-name> "echo ok"Then, as an agent or over the API:
tool.list { "host": "bench-01", "probe": true }Every declared tool should come back with available: true and a
reported_version that satisfies its contract. If a version comes back
unreadable, fix that before you run anything: a pin that cannot be read blocks
the run rather than passing silently, which is what you want and is easier to
diagnose now than during a job.
What this does not give you
Section titled “What this does not give you”A command started over SSH on Windows runs in a non-interactive session with no desktop. That is a property of Windows, not a configuration mistake, and it does not go away.
- The tool drivers are unaffected. Blender, Unity, FreeCAD, KiCad and OpenSCAD all run headless; that is the whole reason the batch path is the primary one.
- The screen half is not available.
desktop.observeanddesktop.actneed the machine logged in and the agent running inside that interactive session. Standing it up means autologon plus running the agent as a foreground process in that session — never as a Windows service, because a service lives in Session 0 and Session 0 has no desktop to capture or click.
That is operations work on the machine, not configuration on this page. Until it
is done, desktop.observe and desktop.act on a Windows host will fail on the
absent session, and every headless tool will keep working.
Related
Section titled “Related”- Add a local desktop tool — the declaration a tool needs once the machine answers.