Meowshell 0.1.545

There is a newer version of this package available.
See the version list below for details.
dotnet add package Meowshell --version 0.1.545
                    
NuGet\Install-Package Meowshell -Version 0.1.545
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Meowshell" Version="0.1.545" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Meowshell" Version="0.1.545" />
                    
Directory.Packages.props
<PackageReference Include="Meowshell" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Meowshell --version 0.1.545
                    
#r "nuget: Meowshell, 0.1.545"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Meowshell@0.1.545
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Meowshell&version=0.1.545
                    
Install as a Cake Addin
#tool nuget:?package=Meowshell&version=0.1.545
                    
Install as a Cake Tool

Meowshell (.NET library)

A complete C# wrapper around tailcat: a real remote shell, a SOCKS5 proxy, port forwarding, and one-shot key/file operations — Android, Linux, or Windows — over Tailscale's data plane, without its control plane. Start with the shell server below; the full surface covers the rest.

Targets net8.0 (plus a Android-specific build for the net10.0-android36.0 target framework), with no dependencies beyond the packaged binaries below.

dotnet add package Meowshell

That alone is enough: Meowshell.Runtime.{linux,windows,android} — whichever matches what you're building — comes along as a transitive dependency, and MeowshellOptions.Create resolves the right paths for the platform it was compiled for. No Context, no path, no platform check of your own.

var options = MeowshellOptions.Create(TimeSpan.FromMinutes(5)) with
{
    AuthorizedKeys  = "alice@github",
    AllowClientKeys = "nodekey:...",
};
await using var server = await MeowshellServer.StartAsync(options);

Console.WriteLine($"connect with: tailcat ssh {server.Address}");
await server.Completed;   // or let the deadline fire, or dispose early

The other machine connects with plain tailcat — tailcat ssh <address> — and needs an ssh binary on its PATH, since tailcat's client shells out to the system one. meowshell itself is server-side only; don't install it on the client.

On Android, the manifest must opt into extraction, or the binaries stay inside the APK and never exist on disk to execute:

<application android:extractNativeLibs="true" />

Meowshell fails your build with a clear error if the corresponding MSBuild property (<AndroidExtractNativeLibraries>true</AndroidExtractNativeLibraries>) isn't set on an Android-targeting project — this is a build-time check, not a runtime surprise.

Supplying a key at runtime

To debug a fleet device, generate its key yourself and keep the address:

tailcat genkey --key=device-0001   # prints the address; keep it in your records

Hand the key to the device only when you want a session — nothing has to come back off the device, since you already know how to reach it:

await using var server = await MeowshellServer.StartAsync(
    MeowshellOptions.Create(TimeSpan.FromMinutes(10)) with
    {
        PrivateKeyJson  = await FetchSessionKeyAsync(),   // from your backend
        AllowClientKeys = "nodekey:<your client key>",
        AuthorizedKeys  = "you@github",
    });
// server.Address is the address you already know; no handoff needed

PrivateKeyJson is piped to meowshell --key-stdin, staged on an unlinked file descriptor, and never exists as a named file. Neither end needs an open port — both dial out to a DERP relay, so this works from behind CGNAT.

Set AllowClientKeys to your own client public key (tailcat printpub), so a leaked device key alone doesn't grant a shell — a caller needs your client key too.

The full surface

Meowshell is a complete C# wrapper around tailcat's CLI, not just the shell server above:

Type Wraps Use it for
MeowshellServer meowshell serve An interactive shell, an SFTP file service, or a forced command for one session — combinable (a shell plus file access), or standalone.
MeowshellSocksProxy meowshell socks A local SOCKS5 proxy that dials out through a tailcat server.
MeowshellPortForward meowshell forward One or more local TCP ports forwarded to a tailcat server.
TailcatClient tailcat genkey / parse / resolve / printpub / ping / ls / ssh / cp One-shot key management, address inspection, connectivity checks, file listing and transfer, and (not on Android) a console-attached ssh session. All work on Android too, ssh included via TailcatSshSession below.
TailcatSshSession meowshell connect A native (no system ssh/sftp binary, on any platform) interactive pseudo-terminal session, driven programmatically — raw Output/WriteAsync, not a console. The one to use anywhere there's no real console to inherit (an Android app, most of all).
MeowshellAgentConnection meowshell agent A persistent, multiplexed connection — shell/exec, the full SFTP verb set, and -L/-R/-D port forwarding, all over one login instead of a fresh process and handshake per operation. Also the only one of these that can reach a general (non-tailcat) SSH host, with real host-key verification and password/keyboard-interactive/certificate/Keystore-callback auth. See below.

Why the long-lived ones go through meowshell

MeowshellServer, MeowshellSocksProxy, and MeowshellPortForward all launch meowshell rather than tailcat directly — even a SOCKS proxy or a port forward, which don't need meowshell's shell-environment fix at all. The reason is orphan protection: before it execs tailcat, meowshell arms a parent-death signal (Linux and Android) or assigns itself to a kill-on-close job object (Windows), so if the host process dies without stopping the listener first — a crash, an OOM kill, a force-stop — the OS tears it down too, instead of leaving it running as an orphan. That risk exists on any platform, but it's most worth guarding against on Android, where the OS kills app processes far more readily — backgrounding, memory pressure — than it does on a desktop or a server. TailcatClient's operations skip all of this and call tailcat directly: they're one-shot, so there's nothing left running afterward to orphan.

Every option

BinaryDirectory/Naming/HomeDirectory/DerpMapUrl/Verbose (and, on the three listeners, GracePeriod) are declared once, on TailcatOptions and TailcatListenerOptions, and inherited by all four options types below — not duplicated per type.

Leaving DerpMapUrl unset doesn't just mean "tailcat's own default" in the literal sense of always resolving the same way: it means the flag is omitted from the command line entirely, so tailcat's own fallback applies — including its TAILCAT_DERPMAP_URL environment variable. Set that once in the host process's environment to point every Meowshell/TailcatClient call at a self-hosted relay without touching DerpMapUrl anywhere; this repo's own CI does exactly that (see e2e/README.md).

MeowshellOptions (for MeowshellServer) — HomeDirectory and WorkDirectory are required; everything else has a default.

Option Default What it does
Lifetime 5 minutes How long the server may live before it shuts itself down.
AuthorizedKeys SSH key sources allowed to log in (paths, literal keys, or user@github). Mutually exclusive with InsecureNoAuth.
InsecureNoAuth false Serve a shell to anyone holding the address, with no SSH auth — pair with AllowClientKeys.
AllowClientKeys Comma-separated tailcat client keys allowed to connect.
EphemeralKey true Generate a throwaway key so the address dies with the process, instead of reusing a saved one. Ignored when PrivateKeyJson is set.
PrivateKeyJson A *.private.json's contents, piped in on stdin rather than stored on disk.
StartTimeout 30s How long to wait for the server to publish its address.
GracePeriod 3s How long SIGTERM gets before SIGKILL.
DerpMapUrl tailcat's own default A self-hosted, JSON-encoded DERP map to use instead.
Verbose false tailcat's own --verbose.
FullAddress false Embed the DERP server's info in the address, so a client can connect without fetching a DERP map.
Psk true Include a WireGuard pre-shared key in the address. Only disable it for tailcat clients v0.5.0 and earlier.
Files A directory to serve over SFTP, with an optional :ro/:rw/:wo/:wo+ suffix. Combinable with AuthorizedKeys/InsecureNoAuth to also serve a shell — but not together with ForcedCommand, which would leave the ssh service serving nothing but that one command.
AllowExitNode false Let a client's MeowshellPortForward/MeowshellSocksProxy (or a MeowshellAgentConnection's own forward_local/forward_socks) reach any port this machine can dial, not just the ports above — tailcat's own "exit-node" service. Without it, forwarding to an unlisted port is refused outright, no matter which client API asks. Pair with AllowClientKeys to restrict who gets that reach.
ForcedCommand none (login shell) Run this command for every session instead of a shell, OpenSSH-ForceCommand-style. The command sees TAILCAT_PEER_KEY, TAILCAT_REMOTE_ADDR, TAILCAT_LOCAL_ADDR.

AuthorizedKeys/InsecureNoAuth, Files, AllowExitNode, and ForcedCommand combine freely except for that one case above — set none of the four and StartAsync throws, since there'd be nothing to serve.

MeowshellSocksOptions (for MeowshellSocksProxy) — HomeDirectory required.

Option Default What it does
Listen tailcat's own default [address]:port to listen on; a bare port means localhost, a bare address an OS-assigned port.
ClientKey tailcat's saved default tailcat client key name or path.
DerpMapUrl tailcat's own default Same as MeowshellOptions.DerpMapUrl.
Verbose false Same as MeowshellOptions.Verbose.
GracePeriod 3s Same as MeowshellOptions.GracePeriod.

MeowshellSocksProxy exposes a real SOCKS5 proxy, including the UDP ASSOCIATE command — but there is currently no MeowshellServer on the other end that will accept a relayed UDP datagram: tailcat serve's shipping CLI never wires up its own UDP relay support (only its test suite and README examples do), so a UDP ASSOCIATE request against any real MeowshellServer/tailcat serve destination fails once traffic actually needs to flow, even though the SOCKS5 handshake for it succeeds. TCP CONNECT and port forwarding are unaffected. Fixing this for real would mean patching tailcat's own vendored CLI (patches/tailcat/), not just meowshell — noted here as a known upstream gap rather than something this library's API can currently paper over.

MeowshellPortForwardOptions (for MeowshellPortForward) — HomeDirectory, Address, and Mappings (at least one) required.

Option Default What it does
Mappings A bare port, local:remote, or local:remote-ip:remote-port (the server must be an exit node). Local port 0 asks the OS for a free port.
Bind 127.0.0.1 Local address for a mapping that only names a port.
ClientKey tailcat's saved default Same as MeowshellSocksOptions.ClientKey.
DerpMapUrl tailcat's own default Same as MeowshellOptions.DerpMapUrl.
Verbose false Same as MeowshellOptions.Verbose.
GracePeriod 3s Same as MeowshellOptions.GracePeriod.

TailcatClientOptions (shared by every TailcatClient call) — HomeDirectory required.

Option Default What it does
DerpMapUrl tailcat's own default Same as MeowshellOptions.DerpMapUrl.
Verbose false Same as MeowshellOptions.Verbose.
Timeout 30s How long to wait for the command to finish before killing it.

TailcatClient methods: GenerateKeyAsync, DeleteKeyAsync, ListKeysAsync (tailcat genkey's three modes), ParseAsync (returns a typed TailcatParsedAddress, not raw JSON), ResolveAsync (returns a TailcatAddress), PrintPubAsync, PingAsync (returns a TailcatPingResult — its Pong is the parsed "pong in ... via ..." line when tailcat printed one, and Success doesn't throw on its own, since e.g. --until-direct timing out is meaningful information, not an error), ListFilesAsync (tailcat ls, pure Go SFTP — no ssh/sftp binary involved — returns typed TailcatFileEntry records, not raw text), SshAsync, CpAsync, and GetEnvironmentAsync (meowshell env — the shell/home/user/ path/term/lang meowshell resolved for this environment, where it found the tailcat binary, and any resolver warnings, as a typed TailcatEnvironment; useful for diagnosing a broken sandbox up front rather than from a session that fails mysteriously once it's already running). GenerateKeyAsync takes a TailcatKeyOptionsName required:

Option Default What it does
Client false Generate a client identity key (no DERP region) for an --allow list, instead of a server key.
Force false Overwrite an existing key of the same name.
Region auto (nearest by latency, chosen fresh each server start) A DERP region ID/code/substring, or comma-separated custom hostnames.
FixedRegion false Discover the nearest region once, now, and bake it into the key.
EmbedDerpMap false Embed the DERP map nodes in the address (implies FixedRegion unless Region names one).
Psk true Same as MeowshellOptions.Psk.

Remote paths

CpAsync and ListFilesAsync take TailcatPath instead of a hand-built "tc-addr:path" string, so there's no scp-style text to get subtly wrong (a missing colon, sources and target swapped):

// Upload a local file to a directory a server offers read-write:
await TailcatClient.CpAsync(options,
    source: "photo.jpg",                                  // a plain string is always local
    target: TailcatPath.Remote(address, "photos/photo.jpg"));

// Download, and list what's there first:
var entries = await TailcatClient.ListFilesAsync(options, TailcatPath.Remote(address, "photos"));
await TailcatClient.CpAsync(options, TailcatPath.Remote(address, "photos/photo.jpg"), "local-copy.jpg");

TailcatPath.Local(path) (or a bare string, which converts implicitly), TailcatPath.Remote(address, path), and TailcatPath.RemoteHost(dnsName, path) (for a server named by a DNS name with a "tailcat=" TXT record, instead of a literal address) cover every case tailcat's own cp/ls accept. CpAsync also takes a multi-source overload (IReadOnlyList<TailcatPath> sources) for copying several local files to one remote directory in a single call, and throws ArgumentException up front if nothing in the call is remote, or if the sources and target don't all name the same server -- the same rule tailcat itself enforces, just reported before a process ever runs.

Interactive sessions without a console

SshAsync is the simplest option when there's a real console to hand tailcat's own ssh client — it inherits the caller's stdio directly, the same as running ssh yourself. That doesn't work at all in a process with no console of its own (an Android app, most notably), and doesn't fit a GUI app that wants to render the session in its own terminal widget rather than a real OS console either way.

TailcatSshSession is for both of those: native (no system ssh client involved, on any platform), giving you raw bytes instead of a console.

await using var session = await TailcatSshSession.ConnectAsync(options, address);
await session.WriteAsync("ls -la\n"u8.ToArray());
var buffer = new byte[4096];
int n;
while ((n = await session.Output.ReadAsync(buffer)) > 0)
{
    // Feed buffer[..n] to your own terminal renderer, or just Console.Write it.
    Console.Write(Encoding.UTF8.GetString(buffer, 0, n));
}

An interactive shell (the default, no command argument) gets a pseudo-terminal, sized by columns/rows at connect time — there's no live resize once the session is running. Pass command to run something other than a shell, still with a pseudo-terminal unless you set requestPty: false. Completed faults with a TailcatException if the process dies unexpectedly, the same as the three listener types; call StopAsync (or dispose the session) to end it deliberately.

A persistent, multiplexed connection

CpAsync and ListFilesAsync each spawn their own bare-tailcat process and their own full handshake — fine for one thing at a time, but opening a shell and browsing files against the same host means two logins. TailcatSshSession is already built on the daemon underneath (see above), but still opens its own dedicated connection per session rather than sharing one with anything else. MeowshellAgentConnection is what actually shares one: dial once and keep the connection open, multiplexing every operation over it as its own channel:

await using var connection = await MeowshellAgentConnection.ConnectAsync(options, address);

await using var shell = await connection.OpenShellAsync();
await shell.WriteAsync("ls -la\n"u8.ToArray());
await shell.ResizeAsync(columns: 100, rows: 40);   // live, any time -- unlike TailcatSshSession

await connection.UploadAsync("photo.jpg", "photos/photo.jpg", preserve: true);
var entries = await connection.ListFilesAsync("photos");

await using var forward = await connection.OpenLocalForwardAsync("127.0.0.1:0", "10.0.0.5:5432");
Console.WriteLine($"forwarding through {forward.BoundAddress}");

Forward/SOCKS access control. A loopback TCP listener is reachable by any other local process — on Android, by any other app on the device, not just this one. So OpenLocalForwardAsync/OpenRemoteForwardAsync/ OpenSocksForwardAsync refuse to bind anything other than loopback (127.0.0.0/8, ::1, localhost) unless you pass allowNonLoopbackBind: true deliberately, and OpenSocksForwardAsync requires SOCKS5 username/password auth (RFC 1929) by default — a random token is generated for you and comes back on MeowshellForward.SocksUsername/SocksPassword; pass requireAuth: false for the classic unauthenticated behavior, or your own credentials instead of the generated pair. Where a caller (an Android app, say) can hand a filesystem path to whatever will connect to the forward, prefer a Unix domain socket over TCP loopback entirely — OpenLocalForwardOnUnixSocketAsync/OpenSocksForwardOnUnixSocketAsync bind a 0600 socket under a path you choose, so filesystem permissions (not "which port happened to be free") are what restrict access:

await using var forward = await connection.OpenLocalForwardOnUnixSocketAsync(
    Path.Combine(appPrivateDir, "pg.sock"), "10.0.0.5:5432");

await using var socks = await connection.OpenSocksForwardAsync("127.0.0.1:0");
Console.WriteLine($"SOCKS5 proxy at {socks.BoundAddress}, auth {socks.SocksUsername}:{socks.SocksPassword}");

It's also the only type here that can reach a general SSH host, not just a tailcat address — pass "[user@]host[:port]" instead of a tailcat address, and it dials over TCP with real host-key verification (a known_hosts file, trust-on-first-use for a host seen for the first time) instead of tailcat's own WireGuard-peer trust. jumpHosts chains through one or more bastions first, and proxyUrl reaches the first hop through a SOCKS5 or HTTP CONNECT proxy — credentials embedded in it never land on the agent process's own command line (readable via /proc/<pid>/cmdline by anything sharing enough local privilege), the same reasoning the auth material below already gets right.

Auth beyond a local ssh-agent — which Android has none of, so this is what actually makes an authenticated server reachable from an app — comes from MeowshellAgentConfigureOptions (private keys and OpenSSH certificates as bytes, never written to disk; Keystore-backed keys that never leave the app at all, signing through the SignRequested event instead) plus prompt events answered asynchronously: HostKeyPromptRequested, PasswordRequested, PassphraseRequested, KeyboardInteractiveRequested.

Subscribe to them through ConnectAsync's configureConnection callback, which runs on the new connection before a single byte reaches the agent — every one of these prompts is raised during the handshake, which ConnectAsync completes before it returns, so a handler attached to the object it hands back is always too late to be asked:

await using var connection = await MeowshellAgentConnection.ConnectAsync(
    options, "user@host.example", configureConnection: c =>
    {
        c.HostKeyPromptRequested += (prompt, ct) =>
            AskTheUserAsync($"trust {prompt.Remote}, {prompt.Fingerprint}?", ct);
        c.PasswordRequested += (remote, ct) => AskForPasswordAsync(remote, ct);
    });

A prompt nobody is listening for is declined, which is what makes trust-on-first-use a decision and not a default: a first connection to a host absent from known_hosts fails with MeowshellErrorCode.HostKeyUnknown until someone answers. That is a different thing to tell a user than HostKeyChanged — a host whose recorded key no longer matches, which is never a question worth asking, only a warning.

The full SFTP verb set (MkdirAsync, RenameAsync, ChmodAsync, SymlinkAsync, TruncateAsync, ...) is here too, alongside UploadAsync/DownloadAsync with an IProgress<T> callback and cancellation via the token — CpAsync/ListFilesAsync above cover the common case with a simpler call shape; reach for this when you also need a shell or a forward on the same connection, or need an op neither of those expose.

Forwarding note: OpenLocalForwardAsync/OpenSocksForwardAsync also work against a tailcat address, not just a general SSH host: tailcat's own embedded SSH service never implements SSH-level port forwarding at all (it only ever served a shell/SFTP), so these dial out through a native tailcat client instead when the destination is a tailcat address — the same mechanism MeowshellPortForward/MeowshellSocksProxy (over tailcat forward/tailcat socks) already use. Either way, the server has to allow it: MeowshellOptions.AllowExitNode requests tailcat's own "exit-node" service, without which a forward to any port the server isn't already otherwise serving is refused outright — a real, protocol-level requirement of tailcat itself, the same for every client API. OpenRemoteForwardAsync ("-R", asking the far end to open a listener) is the one exception: it's SSH-only, since tailcat has no equivalent feature to fall back to.

Signing with a Keystore-backed key

SignRequested hands you a MeowshellSignRequest and asks for the signature bytes. Its Algorithm is the signature algorithm the SSH handshake negotiated, not the key's type — the two differ for RSA, and the difference is which digest the signature has to commit to. A handler that ignores Algorithm and always computes, say, SHA256withRSA is wrong whenever the server picked rsa-sha2-512, and wrong in a way that surfaces only as a failed handshake with nothing more specific to go on.

For an ECDSA or Ed25519 key the value is the same string as the key type, which is all it ever was before, so a handler written against those keys needs no change. RSA keys are what produce values that did not occur previously:

Algorithm Signature bytes to return Android Signature algorithm Conversion needed
rsa-sha2-512 Raw PKCS#1 v1.5 signature, modulus-sized SHA512withRSA None
rsa-sha2-256 Raw PKCS#1 v1.5 signature, modulus-sized SHA256withRSA None
ssh-rsa Raw PKCS#1 v1.5 signature, modulus-sized SHA1withRSA None
ecdsa-sha2-nistp256 mpint r \|\| mpint s (RFC 5656 §3.1.2) SHA256withECDSA Yes — see below
ssh-ed25519 Raw 64-byte signature n/a None

The bytes you return are the algorithm-specific signature blob, not a DER envelope and not the full SSH signature structure. RSA and Ed25519 need nothing done to them. ECDSA does: Android returns a DER SEQUENCE { INTEGER r, INTEGER s }, and SSH wants r and s as two mpints — each a 4-byte big-endian length followed by the big-endian value, with a leading 0x00 byte when the high bit would otherwise be set. Pass the DER through unconverted and the server rejects the signature without saying why.

RSA keys only became usable here once the negotiated algorithm reached the handler: previously the key's own type (ssh-rsa, meaning SHA-1) was the only thing ever offered, and OpenSSH has refused that by default since 8.8. This matters most on Android, where KeyMint guarantees ECDSA and RSA but not Curve25519, so RSA is the only hardware-backed option on some devices.

Errors

Everything that can go wrong at the process level -- a non-zero exit, or a zero exit with output that doesn't match the shape this library parses -- comes back as one type, TailcatException, carrying ExitCode and Diagnostics (tailcat's own captured stderr, or a description of the unexpected output). Its Message already includes Diagnostics, so catching it is normally enough to know what went wrong, with no need to subscribe to a Log event or inspect a process yourself. A failure from MeowshellAgentConnection also carries a typed Code (MeowshellErrorCode) -- HostKeyChanged, AuthFailed, ConnectionLost, and so on -- so a UI can branch on what happened instead of pattern-matching Diagnostics text; HostKeyChanged in particular is worth checking for explicitly, since it means a possible MITM and should drive a hard-stop warning, not a retry.

try
{
    await using var server = await MeowshellServer.StartAsync(options);
    await server.Completed;
}
catch (TailcatException ex)
{
    Console.WriteLine($"tailcat failed ({ex.ExitCode}): {ex.Diagnostics}");
}

MeowshellServer, MeowshellSocksProxy, and MeowshellPortForward all capture their process's stderr internally for this, whether or not anything is subscribed to Log. Their Completed task reflects it too: it completes successfully after a StopAsync call or the deadline, but faults with a TailcatException if the process dies on its own first -- a crash, an OOM kill -- so awaiting Completed is enough to notice and diagnose that without polling.

What's not available on Android

TailcatClient.SshAsync shells out to a system ssh client, which a typical Android app sandbox doesn't provide. It exists in the API on every platform — same class, same signature, full IntelliSense — but throws PlatformNotSupportedException specifically when running on Android, naming the alternative: TailcatSshSession for a programmatic session there (or anywhere else you don't want SshAsync taking over your own console), MeowshellServer for shell access as the server side instead.

Everything else works on Android too, unchanged in the API: CpAsync uses the system scp everywhere else, but on Android routes through meowshell's own native SFTP cp instead — same TailcatPath arguments, same TailcatResult, no platform check needed in your own code. ListFilesAsync, TailcatSshSession, and MeowshellAgentConnection never depended on a system binary anywhere to begin with. Files/ForcedCommand on MeowshellServer also work the same on Android as anywhere else.

Packages

Package Contents
Meowshell Everything above: MeowshellServer, MeowshellSocksProxy, MeowshellPortForward, TailcatClient, BinaryLocator.
Meowshell.Runtime.linux tailcat/meowshell for linux-x64, linux-arm64, linux-arm, linux-x86.
Meowshell.Runtime.windows tailcat.exe/meowshell.exe for win-x64, win-arm64.
Meowshell.Runtime.android libtailcat.so/libmeowshell.so for android-arm64, android-arm, android-x64, android-x86.

Each runtime package lays its binaries out under runtimes/<rid>/native/, NuGet's own convention for native assets, so a plain BinaryLocator.Locate finds them with no configuration. Set BinaryDirectory on MeowshellOptions, or the MEOWSHELL_BINARIES environment variable, to point at a different location instead.

What MeowshellServer handles, and what it cannot

It sets TAILCAT_BIN (meowshell looks for a sibling literally named tailcat, which doesn't exist under Android's native library directory where everything is lib*.so), sets a writable HOME (tailcat aborts a session when user.Current fails, which on Android means whenever HOME is unset), reads the address from a handoff file rather than scraping stdout, and stops with SIGTERM before SIGKILL. It defaults to a fresh ephemeral key per call — without that, tailcat would reuse a saved default key if one exists, silently turning a throwaway address into a permanent one.

The deadline itself is enforced in-process, so it only fires while your app is alive and running its own code. If the host process is killed outright instead — a crash, an OOM kill, a force-stop — the OS closes the gap for you; see why the long-lived ones go through meowshell above for how.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-android36.0 is compatible.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.809 1,313 9/15/2026
0.1.788 41 9/15/2026
0.1.767 488 9/14/2026
0.1.762 83 9/14/2026
0.1.757 279 9/14/2026
0.1.749 204 9/14/2026
0.1.726 76 9/14/2026
0.1.615 1,030 9/13/2026
0.1.545 84 9/12/2026
0.1.474 122 9/12/2026
0.1.180 86 9/11/2026
0.1.94 97 9/10/2026
0.1.63 87 9/9/2026
0.1.11 94 9/8/2026