Skip to content
Select themeSelect language

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.

SupaCloud server ──SSH (tailnet only)──▶ workstation
holds the private key holds the public key,
in the resource secret bag the tools, the GPU, the licence

The server dials out to a machine it names. There is no agent to install and nothing on the workstation calls home.

  • The machine is on the tailnet and you know its tailnet name or 100.x address.

  • 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-01

    bench-01.pub goes on the machine; bench-01 goes into the resource secret bag in step 7 and nowhere else.

Terminal window
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0

Check: Get-WindowsCapability -Online -Name OpenSSH.Server* | Select-Object State reports Installed.

Terminal window
Set-Service -Name sshd -StartupType Automatic
Start-Service -Name sshd

Check: 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):

Terminal window
$file = "C:\Users\<user>\.ssh\authorized_keys"
New-Item -ItemType Directory -Force -Path (Split-Path $file) | Out-Null
Set-Content -Path $file -Value (Get-Content .\bench-01.pub) -Encoding ascii
icacls $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:

Terminal window
$file = "C:\ProgramData\ssh\administrators_authorized_keys"
Set-Content -Path $file -Value (Get-Content .\bench-01.pub) -Encoding ascii
icacls $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.

Installing the capability creates a broad inbound rule. Replace it.

Terminal window
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*').Name

100.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.

Terminal window
ssh-keygen -lf C:\ProgramData\ssh\ssh_host_ed25519_key.pub

It 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.

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:

Terminal window
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:

Terminal window
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.ini

It 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.

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.

From the server side:

Terminal window
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.

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.observe and desktop.act need 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.