Remote Harness MCP
by nicelight
README.md
# π± Remote Harness MCP β control any PC from Gemini (App & Web)
> **An MCP server that turns Google Gemini from a chatbot into a full-fledged mobile agent.**
> Type a task in the Gemini app on your phone β your PC, laptop or server executes it, and Gemini reports back with real results.
[English](README.md) | [π·πΊ Π ΡΡΡΠΊΠ°Ρ Π²Π΅ΡΡΠΈΡ](README_RU.md)
---
## π‘ The idea
Gemini in a browser or on a phone can talk, but it cannot *do* anything on your computer.
**Remote Harness MCP** closes that gap. It is a free, open-source **Model Context Protocol (MCP)** server that you connect to **Gemini Spark** ([gemini.google.com/customize/connectors](https://gemini.google.com/customize/connectors) or the Gemini mobile app) with a single link. After that, Gemini gets hands:
- runs shell commands, builds and tests code, manages git;
- reads, writes and edits files;
- searches the file system;
- starts long-running jobs and checks on them later;
- watches CPU, RAM and disk.
```text
π± Gemini app / π gemini.google.com (Spark)
β MCP (SSE / Streamable HTTP)
βΌ
βοΈ Gateway racknerd-5a24bf9.merino-carob.ts.net β relay only, TLS
β outbound WebSocket tunnel (no open ports)
βΌ
π» Your PC Β· laptop Β· VPS Β· home server β executes the task
```
**Result:** your phone becomes a remote control for every machine you own β and Gemini is the agent that operates them.
---
## π₯ What you can do with it
Just ask Gemini in plain language, from anywhere:
- *"Check why my home server is slow and show the top processes"*
- *"Pull the latest changes in ~/projects/api, run the tests and tell me what failed"*
- *"Fix the typo in config.yaml and restart the service"*
- *"Start a full backup in the background and tell me when it's done"*
- *"How much free disk space is left on my laptop?"*
- *"Turn on the phone's torch, and tell me its battery level and signal"*
No SSH client, no terminal on your phone, no VPN β only a chat.
---
## πΈ In action
Three exchanges from one chat, each answered by a different machine on the other
side of the bridge. The phone never opened a terminal.
**The laptop describes itself** β *"@Rem Matebook-deb, give me the system and hardware specs"*:

**The phone reports its own battery, radio and VPN** β *"@Rem Phone, check"*.
The SSID and both addresses are mosaicked; everything else came from `device_info`:

**The desktop is photographed, published and read back** β *"take a screenshot of @RemMatebook-deb"*,
then *"describe what is running on the screens"*:

Every answer was produced by `system_vitals`, `device_info`, `device_capture` and
`share_file` running on the node itself β the model only ever saw tool results.
---
## β¨ Why it works this way
| | |
| :--- | :--- |
| π± **Mobile-first agent** | Works in the official Gemini mobile app and on the web through Gemini Spark. |
| π₯οΈ **Any PC** | Linux, macOS, Windows β desktops, laptops, VPS, containers. **Android phones and tablets** run the same node inside Termux. |
| π **Behind any NAT** | The node opens an *outbound* WebSocket tunnel. No public IP, no port forwarding, no router setup. |
| β‘ **One-line install** | Detects OS, architecture and device type; installs autostart (`systemd` / `launchd` / Windows task / runit + Termux:Boot on Android); copies your connection link to the clipboard. |
| π‘οΈ **Two credentials, no secret in a URL** | The **device token** opens the tunnel and travels only in an `Authorization: Bearer` header. The **link key** (`lk_β¦`) is what goes into a URL; it can run tools but cannot open a tunnel, and it is rotatable β so a leaked link is fixed in one call, not by reinstalling the node. Everything else gets HTTP 401. |
| π **Many machines, one gateway** | Each node is addressed by `?user=<node-name>` on one shared domain. |
| π **Free & open source** | MIT license, works with the free Gemini tier. |
---
## π οΈ What Gemini can do on your machine (MCP tools)
**26 tools.** Every one of them runs on your machine under your own user account β the gateway only carries the calls.
### Which calls ask for your confirmation
Gemini Spark decides whether to stop and ask *"confirm this action?"* from the **MCP annotation hints** each tool advertises (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). A tool advertised with no hints at all is treated as destructive by the spec's defaults β that is why a server that declares none gets a confirmation prompt on *every single call*. Here the hints are explicit:
| Class | Tools | Confirmation |
| :--- | :--- | :--- |
| Read-only | `mesh_status`, `system_info`, `system_vitals`, `get_orchestration_skill`, `list_dir`, `read_file`, `grep_search`, `glob_find`, `job_output`, `job_list`, `share_list`, `device_info` | never asked |
| Local work β commands, builds, file writes, jobs, shares, and the whole device branch | `bash_exec`, `run_job`, `write_file`, `edit_file`, `job_kill`, `share_file`, `serve_dir`, `unshare`, `device_control`, `device_capture`, `device_messages` | not asked |
| **Confirmed β install/delete, `sudo`, system paths** | `system_change` (installs/removes software, deletes data, runs `sudo`, edits system paths by shell), `system_write` (writes file content into a system path), `mesh_update` (installs a release) | **asked first** |
A prompt is spent on a **system change** only: `system_change`, `system_write` and `mesh_update` install, remove, delete or replace something you cannot undo in one call. Everything else runs silently, the device branch included β a camera, a microphone or an SMS list installs and removes nothing, and what still refuses such a call is the node's own switch (`MESH_DEVICE`, `MESH_DEVICE_ACTIONS`, `MESH_DEVICE_PIM`, `MESH_READ_ONLY`), which holds no matter what the client does. `unshare` is ordinary local work for the same reason: it stops a local server and removes a copy you can publish again in one call.
The mechanics matter if you also run the node in standalone mode: Gemini Spark skips its dialog only for a tool it sees as `readOnlyHint: true`, so the gateway advertises **every** non-system call that way β the shell and file twins because its classifier refuses the gated command classes, the device branch because the node refuses the call before any `termux-*` command runs. The node's own `tools/list` keeps the literal hint instead (a camera is not a read), which is why a client connected straight to `core/server.py` may still ask.
Four classes are gated, and the gateway β not the hint β enforces them, because a hint is static per tool and cannot tell `ls` from `apt install`:
1. **Installing or removing software** β package managers, uninstallers, `msiexec`, `dpkg`/`rpm`, `curl β¦ | bash`, `iwr β¦ | iex`.
2. **Deleting data** β `rm`, `Remove-Item`, `del`, `rd`, `shred`, `dd`, `mkfs`, `git clean -fd`, `docker rm`/`prune`, `truncate -s 0`.
3. **Running as root** β `sudo`, `doas`, `pkexec`, `runas`, `Start-Process -Verb RunAs`.
4. **System paths** β `/etc`, `/usr`, `/boot`, `/var/lib`, `/opt`, `/dev`, `C:\Windows`, `C:\Program Files`, `ProgramData`, the registry. Reads of those paths (`cat /etc/os-release`, `systemctl status`) are *not* gated, and neither is ordinary work in your own directories.
`bash_exec`/`run_job` refuse such commands and tell the model to re-issue them as `system_change`; `write_file`/`edit_file` refuse a system path and point at `system_write` (which takes the exact content, so there is no shell quoting to get wrong). The gate is moved, not removed β inspection, builds, tests, config edits in your projects and the like still run without a dialog.
The tool surface and the confirmation policy live in [gateway.py](gateway.py) (annotations, `INSTALL_COMMAND_PATTERNS` / `DELETE_COMMAND_PATTERNS`, `SYSTEM_PATH_RE`, `classify_command()`) and in [core/mcp_tools.py](core/mcp_tools.py) (`TOOL_ANNOTATIONS` for the node's own surface, which also carries the device branch's four tools); tests compare the two surfaces and pin the classifier, so a gated command cannot slip through.
> [!NOTE]
> The classifier matches a verb at the start of a command segment (after `sudo`/`env`/`timeout`/`powershell -Command` prefixes), so `echo "rm -rf /"` or `grep rm notes.txt` still run, while `curl β¦ | bash` counts as an install. It is a UX gate, not a sandbox: for hard guarantees independent of any prompt use the node's own switches β `MESH_READ_ONLY=1` and `MESH_WRITE_ROOTS` ([core/agent.py](core/agent.py)).
### Status and host information
| Tool | What it does |
| :--- | :--- |
| `mesh_status()` | Confirms the node is reachable, with live evidence from the host itself: what kind of machine answered (`device_class`, `scenario`) and its `battery_percent`. Call it first if the machine looks offline. |
| `system_info()` | One-call host summary: OS, desktop, user, home, disks, memory, load, top processes, the current wallpaper, `command_shell` β the shell `bash_exec` will actually use β and the device blocks: battery and charging, network and signal, language, time and timezone, CPU, RAM, storage, cameras, microphones and sensors. |
| `system_vitals()` | CPU, RAM and disk metrics, plus the battery block and the thermal sensors the platform exposes. |
### Shell
| Tool | What it does |
| :--- | :--- |
| `bash_exec(command, timeout_sec, max_chars, cursor)` | Runs a shell command. The shell matches the **host**, not the tool's name β `bash` on Linux/macOS, PowerShell or `cmd.exe` on Windows; check `command_shell` from `system_info()` first. Output is paginated: when it is cut, call again with `cursor=next_cursor`, nothing is dropped; output above 2 MB is spooled to a file returned in `saved_to`. `timeout_sec` is 1β120 (default 25). Installs/removals, `sudo` and system paths are **refused here** and have to go through `system_change`. |
| `system_change(command, timeout_sec, background, cwd, max_chars, cursor)` | Installs, removes or deletes on the host, runs privileged commands and edits system paths β the confirmed twin of `bash_exec`, and the only tool allowed to run `apt`/`dnf`/`pacman`/`pip`/`npm`/`winget`/`msiexec`/`rm`/`Remove-Item`/`sudo` or disk tools. It is advertised as destructive, so Gemini asks you first. `background=true` runs it as a job (for long installs) and returns a `job_id` for `job_output`; `cwd` applies to that mode. |
### Files
| Tool | What it does |
| :--- | :--- |
| `list_dir(path)` | Lists files and directories (workspace by default). |
| `read_file(path, start_line, end_line, max_chars, cursor)` | Reads a text file with line numbers, optionally restricted to a line range. Paginated like `bash_exec`. |
| `write_file(path, content, create_dirs, mode)` | Atomically creates or overwrites a file (temp file + `os.replace`). `mode` is POSIX-only: on Windows it is not honoured, and the result says so instead of pretending. A path inside the system is **refused** and has to go through `system_write`. |
| `edit_file(path, old_string, new_string, expected_sha256, replace_all)` | Replaces an exact substring. `old_string` must match exactly once unless `replace_all` is set; `expected_sha256` guards against overwriting a file that changed since it was read. System paths are refused β read the file and send the whole new content to `system_write`. |
| `system_write(path, content, create_dirs, mode)` | Writes a file inside a system path (`/etc`, `/usr`, `/boot`, `/var/lib`, `C:\Windows`, `C:\Program Files`, `ProgramData`, the registry) β the confirmed twin of `write_file` and the only way to write there. Gemini asks you first. Content is exact, so there is no shell quoting to get wrong. |
### Search
| Tool | What it does |
| :--- | :--- |
| `grep_search(pattern, path, glob, limit, ignore_case, fixed, context)` | Recursively searches file contents, skipping binary files and heavy directories. `fixed` treats the pattern as literal text, `context` adds surrounding lines, `limit` is 1β1000 (default 200). |
| `glob_find(pattern, path)` | Finds files by glob pattern (`*` and `**`). |
### Long-running work
| Tool | What it does |
| :--- | :--- |
| `run_job(command, cwd)` | Starts a command in the background and returns a `job_id`. On an Android node it takes the wake lock for the lifetime of the job, so Android does not freeze it when the screen goes off. |
| `job_output(job_id, wait_ms, max_chars, cursor)` | Reads a job's output, optionally waiting up to 20 s for completion. Paginated. |
| `job_kill(job_id, signal)` | Terminates a job. `signal` is `TERM` (default), `KILL`, `INT`, `HUP` or `QUIT`. |
| `job_list(limit)` | Lists recent jobs, newest first (20 by default, max 50), including whether each one holds a `wake_lock`. |
### Device (phone, laptop or server)
These four tools read and act on the device the node itself runs on. They are advertised on **every** node β a phone-only action on a laptop answers with `available: false`, the reason and, where one exists, the fix, instead of failing.
| Tool | What it does |
| :--- | :--- |
| `device_info(section, sensor, fresh, quick)` | One-call device report: battery and charging, network interfaces with Wi-Fi and cellular signal, language, time and timezone, CPU, RAM, storage, cameras, microphones, sensors and what the platform can actually reach. `section` is `summary`, `all` (default) or one of `device`, `battery`, `network`, `locale`, `time`, `hardware`, `storage`, `cameras`, `microphones`, `sensors`, `capabilities`. `sensor` takes one live sample from a named sensor (Android; the name comes from the `sensors` block), `fresh` bypasses the few-second cache and `quick` skips the slow sections. Every block carries `available` and `source`; a block the platform cannot answer says why instead of showing a zero. |
| `device_control(action, on, value, stream, text, title, id, url, path, state, timeout_sec)` | Twenty reversible actions: `torch`, `vibrate`, `volume`, `volume_get`, `brightness`, `tts_speak`, `toast`, `notify`, `notify_list`, `notify_remove`, `clipboard_get`, `clipboard_set`, `media`, `media_scan`, `wakelock`, `download`, `open`, `share`, `dialog`, `wallpaper`. `value` is milliseconds for `vibrate`, a 0β15 level for `volume` and 0β255 for `brightness`; `stream` picks the audio stream (`music` by default); `text` is the body for TTS, toast, notification, clipboard and dialog, and `play\|pause\|stop\|info` for `media`. Nothing here is destructive. |
| `device_capture(action, camera_id, path, seconds, provider, frequency, pattern)` | Eleven actions: `camera_list`, `camera_photo`, `mic_record_start`, `mic_record_stop`, `mic_record_status`, `location`, `fingerprint`, `usb_list`, `usb_access`, `infrared_frequencies`, `infrared_transmit`. **No confirmation prompt** β it installs and removes nothing, and `MESH_DEVICE`/`MESH_DEVICE_ACTIONS`/`MESH_READ_ONLY` on the node are what refuse a call. `path` is the output file for `camera_photo` and `mic_record_start` (default: the node's capture directory) and the device path from `usb_list` for `usb_access`; `camera_id` comes from `camera_list`, `seconds` limits a recording, `provider` is `gps`, `network` or `passive`, and `frequency`/`pattern` drive `infrared_transmit`. A photo or a recording stays in the capture directory and is never uploaded by itself β `share_file` publishes it only when you ask for that. `fingerprint` returns only the verdict; `usb_access` asks Android for permission and returns the descriptor but deliberately refuses to run a program for you. |
| `device_messages(action, number, text, limit, offset, type, query)` | Five actions: `sms_list`, `sms_send`, `call_log`, `contacts`, `call`. **No confirmation prompt, and off until the operator enables it with `MESH_DEVICE_PIM=1`** β it is the private data of whoever holds the phone, so the node itself is the gate. `limit` is 1β50 (default 10), `offset` pages through the list, `type` filters it and `query` filters contacts on the node. |
Example:
```text
"Check the phone's battery and signal."
β device_info(section="summary")
"Turn on the torch."
β device_control(action="torch", on=true)
"Take a photo and share it with me."
β device_capture(action="camera_photo") # runs without a dialog
β share_file(path=<the path it returned>)
```
### Device switches (operator)
Read from `agent.env` at startup ([core/agent.py](core/agent.py), applied in [core/mcp_tools.py](core/mcp_tools.py) and [core/device.py](core/device.py)):
| Variable | Default | What it does |
| :--- | :--- | :--- |
| `MESH_DEVICE` | `auto` | `auto` keeps the branch answering everywhere (only the answer differs per platform), `0` switches the branch off, `1` forces it on. |
| `MESH_DEVICE_ACTIONS` | empty | Comma-separated allowlist of action names for `device_control`, `device_capture` and `device_messages`; empty means every action the build implements. |
| `MESH_DEVICE_CAPTURE_DIR` | shared storage on a phone once `termux-setup-storage` has been granted (`~/storage/dcim/remote-harness-mcp` or `~/storage/shared/RemoteHarnessMCP`), otherwise `~/.cache/remote-harness-mcp/captures` | Where photos and recordings are written. |
| `MESH_DEVICE_PIM` | `0` | `1` enables SMS, the call log, contacts and placing a call. |
| `MESH_DEVICE_QUICK` | `0` | `1` keeps `system_info` instant by skipping network, cameras, microphones and sensors. |
| `MESH_READ_ONLY` | `0` | `1` refuses every `device_control` and `device_capture` action, and blocks `sms_send` and `call`. This is the node-side guarantee that does not depend on any client prompt. |
### Publishing files and web pages
These four make something on your machine readable from the internet. The link is served by the gateway on the shared domain, so **the node has to stay connected for it to work**.
| Tool | What it does |
| :--- | :--- |
| `share_file(path, name, overwrite)` | Publishes **one file**: it is copied into the node's share root and you get `https://<shared-domain>/<node>/<name>-<random>/<filename>`. `overwrite` replaces an existing share with the same name. |
| `serve_dir(path, name)` | Serves a **directory** in place β no copy is made. `index.html` is used when present, otherwise a directory listing is shown. Read-only (GET/HEAD only). Returns `https://<shared-domain>/<node>/<name>-<random>/`. |
| `share_list()` | Lists active shares with their URLs, their roots, and whether the local server is running. |
| `unshare(name)` | Stops a share and revokes its link. Accepts the share name, its slug or the full URL. A file share also deletes the copy in the share root; a served directory is left untouched on disk. |
> [!IMPORTANT]
> The random part of the path **is** the credential β anyone who has the link can read the file or browse the directory. Treat a share link like a password, and `unshare` it when you are done. Size limits: 32 MiB per file by default, 64 MiB ceiling via `MESH_WEB_MAX_BYTES`.
### Agent behaviour
| Tool | What it does |
| :--- | :--- |
| `get_orchestration_skill()` | Loads the agent's current operating rules and orchestration skill. |
### Keeping the node up to date
| Tool | What it does |
| :--- | :--- |
| `mesh_update(action, force, offline, restart)` | Reports, checks for or installs a newer release of the node's own code. `status` reads local state (no network), `check` asks GitHub, `apply` downloads the payload, verifies its published SHA-256, installs it with a rollback backup and restarts the agent. |
> [!TIP]
> Nodes update themselves: a running agent checks for a newer release in the background (every 6 hours by default) and installs what it finds, and on Windows the `RemoteHarnessMCPUpdater` task checks once a day even when no agent is running. `MESH_UPDATE_AUTO=0` switches that to "report only". Details, environment variables and rollback: [docs/UPDATES.md](docs/UPDATES.md).
> [!TIP]
> Gemini gives a single tool call roughly 30 seconds. For anything longer (builds, backups, downloads) the agent uses `run_job` and polls `job_output`, so tasks never get cut off.
---
## π Quick start β 2 minutes
### 1. Install the node on the PC you want to control
**π§ Linux / π macOS**
```bash
curl -fsSL https://racknerd-5a24bf9.merino-carob.ts.net/install.sh | bash
```
**π± Android (Termux)** β no root, works from F-Droid's Termux
```bash
curl -fsSL https://racknerd-5a24bf9.merino-carob.ts.net/install.sh | bash
```
Autostart on a phone is a runit service plus the Termux:Boot app, and the node is
named after the device model β see [docs/TERMUX.md](docs/TERMUX.md).
**πͺ Windows (PowerShell)**
```powershell
irm https://racknerd-5a24bf9.merino-carob.ts.net/install.ps1 | iex
```
**πͺ Windows β visual installer** (from a clone or a release)
```powershell
.\install-gui.cmd
```
A WinForms wizard that mirrors the console installer's variants, switches language at
runtime and shows the MCP link at the end β see [docs/GUI_INSTALLER.md](docs/GUI_INSTALLER.md).
**π¦ Node.js (any OS)**
```bash
npx remote-harness-mcp
```
The pre-rename name still works: `npx gemini-computer-use` is an alias package
that depends on `remote-harness-mcp` and forwards every argument to the same CLI
(see [npm/gemini-computer-use](npm/gemini-computer-use/README.md)).
**π οΈ From source**
```bash
git clone https://github.com/LevRa7/remote-harness-mcp.git
cd remote-harness-mcp
./install.sh --quick
```
At the end the installer prints your personal MCP link and copies it to the clipboard:
```text
https://racknerd-5a24bf9.merino-carob.ts.net/sse?user=<your-node-name>&token=<your_secret_token>
```
### 2. Connect it to Gemini Spark
1. Open **[gemini.google.com/customize/connectors](https://gemini.google.com/customize/connectors)** in a browser or in the Gemini mobile app.
2. Click **Add connector** (or **Settings βοΈ β Connectors**) and name it **MyDevice**.
3. Paste the link (**Ctrl+V**) into the **Server URL** field and click **Connect**.
### 3. Give it a task
> *"Show system_vitals and list the 5 biggest folders in my home directory."*
That's it β Gemini is now an agent running on your machine. Repeat step 1 on other computers to control all of them from the same phone.
---
## π₯οΈ Deployment modes
| Mode | Command | When to use |
| :--- | :--- | :--- |
| **Gateway + tunnel** (recommended) | `./install.sh --quick` | Home PCs and laptops behind NAT β the mobile-agent scenario. |
| **Remote over SSH** | `./install.sh --ssh=user@host` | Deploy a node onto a remote Linux server from your terminal. |
| **Local standalone** | `./install.sh --mode=standalone --port=8096` | Pure localhost FastMCP server at `http://localhost:8096/sse`, no cloud relay. |
On Windows the same variants are available in the visual installer (`.\install-gui.cmd`); local standalone stays console-only there (`.\install.ps1 -Mode standalone -Port 8096`).
On **Android/Termux** the same `./install.sh` variants work; autostart is a runit service plus the Termux:Boot app instead of systemd, and standalone stays reachable from that phone alone β see [docs/TERMUX.md](docs/TERMUX.md).
---
## πͺ Windows installation
Windows already ships PowerShell 5.1, so there is nothing to prepare β no Python, no Node.js, no administrator rights. Both installers finish by putting the same MCP link on the clipboard.
| | Console installer | Visual installer |
| :--- | :--- | :--- |
| Start | `irm https://racknerd-5a24bf9.merino-carob.ts.net/install.ps1 \| iex` | `.\install-gui.cmd` |
| Needs | nothing but PowerShell | a clone or an unpacked release β it drives `install.ps1`, `core/` and `install.sh` |
| Variants | `-Mode tunnel` (default), `-Mode standalone`, `-User`, `-Gateway`, `-Token`, `-Port`, `-DryRun` | Quick setup, Custom setup, Remote over SSH |
| Language | `-Lang en` / `-Lang ru` | switch in the window header, or `-Lang` |
### Automatic dependencies and architecture
Nothing has to be prepared by hand first. Both installers obtain what they need, and if
they cannot, they stop with an explicit message instead of writing an autostart entry that
can never work.
| | Windows (`install.ps1`) | Linux / macOS (`install.sh`) |
| :--- | :--- | :--- |
| Python | `winget` β the python.org installer for **this** architecture β `uv` | `apt` / `dnf` / `yum` / `zypper` / `pacman` / `apk` / `xbps` / `brew` β `uv` |
| `websockets` | `pip` β `pip --user` β `ensurepip` β `uv` β a venv | `pip` β `pip --break-system-packages` β `pip --user` β `ensurepip` β `uv` β a venv |
| If all of that fails | stops, and prints the exact command to run | stops, and prints the exact command to run |
`websockets` is the only external Python requirement: `core/mcp_tools.py` and
`core/server.py` are standard library only. `uv` is fetched only when the cheaper paths
have already failed.
**The architecture decides which build is downloaded.** The installer asks the *OS*, not
the process, because a 32-bit PowerShell on 64-bit Windows reports `x86` and hides the
real value in `PROCESSOR_ARCHITEW6432`, and an emulated x64 process on ARM64 reports
`AMD64`:
| Machine | Python build | `uv` archive |
| :--- | :--- | :--- |
| Windows x64 | `python-3.12.5-amd64.exe` | `x86_64-pc-windows-msvc` |
| Windows ARM64 | `python-3.12.5-arm64.exe` | `aarch64-pc-windows-msvc` |
| Windows 32-bit | `python-3.12.5.exe` | β (uv ships no 32-bit Windows build) |
| Linux `x86_64` | the package manager's own build | `x86_64-unknown-linux-gnu` |
| Linux `aarch64` | the package manager's own build | `aarch64-unknown-linux-gnu` |
| Linux `armv7l` / `i686` | the package manager's own build | `armv7-unknown-linux-gnueabihf` / `i686-unknown-linux-gnu` |
| macOS `arm64` / `x86_64` | `brew`, or `uv` | `aarch64-apple-darwin` / `x86_64-apple-darwin` |
The interpreter that ends up pinned is the one that actually has `websockets` β which can
be a venv or a `uv`-managed CPython, both deliberately outside `PATH`. `install.ps1 -DryRun`
and `install.sh --dry-run` report the architecture without changing anything, and the visual
installer shows it in its preflight.
### Download the setup executable
The release also ships a single compiled installer, for machines where you would rather
not clone or download anything else β `RemoteHarnessMCP-Setup-<version>.exe`:
```powershell
.\RemoteHarnessMCP-Setup-0.4.8.exe # open the visual installer
.\RemoteHarnessMCP-Setup-0.4.8.exe -Lang ru # start in Russian
.\RemoteHarnessMCP-Setup-0.4.8.exe -SelfTest # headless self-check, prints JSON
.\RemoteHarnessMCP-Setup-0.4.8.exe --version # print the version
```
It carries the wizard, `install.ps1`, `core/` and `install.sh` inside itself, unpacks them
to `%LOCALAPPDATA%\RemoteHarnessMCP\setup\<version>` (override with `MESH_SETUP_DIR`) and
runs the wizard from there. It needs nothing but the .NET Framework and PowerShell that
Windows already has, and it is built from this repository by
`.\build-installer-exe.ps1` β no SDK, no NuGet, no network.
It is **not code-signed**, so SmartScreen may ask for confirmation the first time you run
it.
### Visual installer

- **Quick setup** (recommended) β node name = PC name, shared gateway domain, Windows autostart. The one to pick on a home PC or laptop.
- **Custom setup** β your own node name, your own shared domain, optional token.
- **Remote over SSH** β copy the node code to a Linux host and run `install.sh` there. Needs the Windows *OpenSSH Client* feature and key-based authentication; interactive password prompts are not supported.
The first screen runs `install.ps1 -DryRun` and reports what it found β Python, `websockets`, the resolved gateway domain, the config directory, the autostart path β without changing anything. Before starting, the window shows the exact command it will run; during the run it streams the installer output; at the end it shows the MCP link with a copy button, the three-step Gemini instructions and the paths it created.
Full details, screenshots and the SSH notes: [docs/GUI_INSTALLER.md](docs/GUI_INSTALLER.md).
### What a Windows installation creates
| Path | What it is |
| :--- | :--- |
| `%USERPROFILE%\.config\remote-harness-mcp\agent.env` | `MESH_GATEWAY`, `MESH_USER`, `MESH_TOKEN` |
| `%USERPROFILE%\.config\remote-harness-mcp\domain.env` | `MESH_PUBLIC_URL` β the resolved shared domain, so the node's share links, its tunnel and the gateway name the same host (not written when the built-in default is all that is configured) |
| `β¦\Start Menu\Programs\Startup\rhm-agent.vbs` | autostart entry, so the node comes back after a reboot |
| `%USERPROFILE%\.config\remote-harness-mcp\agent.log` | agent output, for when a silent autostart fails |
### Language
The visual installer starts in the language of your Windows language settings β the display language and the preferred-languages list are read from the registry β and falls back to English. `-Lang ru` / `-Lang en` overrides the detection. The switch in the window header changes every label immediately.
### Troubleshooting
- **"No gateway domain configured"** β this copy was not published with a domain. Pass one: use *Custom setup* in the wizard, or `.\install.ps1 -Gateway <shared-domain>`, or set `MESH_PUBLIC_URL`.
- **Registration fails** β the installer stops *before* writing anything when the gateway cannot be reached. Check that the domain resolves, then retry with an explicit `-Gateway`.
- **`python` opens the Microsoft Store** β that is the App Execution Alias, not an interpreter. Both installers resolve a real `python.exe` by full path and ignore the alias; the wizard never pins an alias into the autostart entry.
- **The node does not come back after a reboot** β run `.\ops\doctor.ps1`: it prints the autostart interpreter, the heartbeat age, the last `agent.log` lines (including a torn final line) and the gateway's own view of this node. `.\ops\windows\agent-watchdog.ps1` performs that check once and restarts the agent; the scheduled task `RemoteHarnessMCPWatchdog` runs it every five minutes, so a dead agent comes back on its own.
### Standalone on Windows
The wizard does not offer it; use the console installer:
```powershell
.\install.ps1 -Mode standalone -Port 8096
```
That starts a local-only FastMCP server at `http://localhost:8096/sse` and puts that URL on the clipboard.
---
## π± Android installation (Termux)
The same `install.sh` installs a node on an unrooted phone or tablet, and the phone
then shows up in Gemini Spark like any other machine. Full guide:
**[docs/TERMUX.md](docs/TERMUX.md)**.
```bash
# 1. Termux from F-Droid (not Google Play); optionally Termux:Boot and Termux:API
pkg update -y
# 2. the usual one-liner
curl -fsSL https://racknerd-5a24bf9.merino-carob.ts.net/install.sh | bash
```
What a phone changes, and how the installer handles it:
| | |
| :--- | :--- |
| **Packages** | `pkg` instead of `apt`, `python`/`python-pip` instead of `python3-pip`/`python3-venv` β a phone is never root and has no `sudo`. |
| **Node name** | The device model (`Pixel 7 Pro` β `pixel7pro`): Android answers `localhost` to every app, and the gateway keeps one tunnel per name. |
| **Domain file** | `~/.config/remote-harness-mcp/domain.env` β there is no `/etc` to write to. The installer records the resolved shared domain there, so the phone's share links, its tunnel and the gateway name the same host (the built-in default is never recorded). |
| **Autostart** | A **runit** service (`termux-services`, the `Restart=always` equivalent) plus a **Termux:Boot** script that takes the wake lock after a reboot. |
| **Clipboard** | `termux-clipboard-set` (Termux:API), so the MCP link goes straight into the Gemini app. |
> [!IMPORTANT]
> Two Android-side switches are yours to flip: install **Termux:Boot** and open it
> once, and set battery optimisation to *Unrestricted* for Termux. Without them
> Android unloads the node with the screen off.
The phone must also be able to **resolve the gateway name**. A gateway that lives on
a private network (Tailscale, a VPN, a DNS override on your laptop) resolves there and
nowhere else β mobile data resolves nothing private. Put the phone on that network, or
give the gateway a publicly resolvable domain; the installer detects a name it cannot
resolve and says so before writing anything: see
[docs/TERMUX.md](docs/TERMUX.md#private-gateway-tailscale--vpn).
```bash
sv status rhm-agent # is it up?
sv restart rhm-agent # restart now
tail -f $PREFIX/var/log/sv/rhm-agent/current
```
`--mode=standalone` also works, but it serves `127.0.0.1` only β reachable from
inside that phone alone. Use the default tunnel mode to drive the phone from Gemini.
---
## π Automatic updates
A node installs itself from a GitHub release and then keeps itself current the same
way. You do not re-install anything by hand, and nothing unverified is installed.
- **It checks by itself.** A running agent asks the GitHub Releases API every 6
hours (`MESH_UPDATE_CHECK_INTERVAL`), compares the tag with the version in
`core/version.py` and installs what it finds. On Windows the daily
`RemoteHarnessMCPUpdater` task does the same for a machine whose agent is not
running. `MESH_UPDATE_AUTO=0` turns installing off and keeps reporting.
- **It verifies before it installs.** The payload is downloaded, its SHA-256 is
compared with the published checksum, and only then are `core/`, `skills/` and
`ops/` replaced. No checksum, no update.
- **It can always go back.** The replaced directories are saved under
`%USERPROFILE%\.config\remote-harness-mcp\backups\<version>-<timestamp>\`, and an
update interrupted by a kill or a power cut is rolled back automatically on the
next start.
- **The node comes back new.** The agent is restarted through whatever supervises
it (the watchdog task on Windows, `systemctl --user restart rhm-agent.service`,
`launchctl kickstart -k`) β or started directly when nothing supervises it.
By hand, any time:
```powershell
remote-harness-mcp update -Check # is a newer release published?
remote-harness-mcp update # install it now and restart
remote-harness-mcp update -Check -Json
```
From Gemini: ask the node to run `mesh_update` (`status`, then `check`, then
`apply`).
The state of the last check is in the heartbeat, so `ops/doctor.ps1` shows it, and
the full history is in `%USERPROFILE%\.config\remote-harness-mcp\update.log`.
Configuration, exit codes, rollback and troubleshooting: [docs/UPDATES.md](docs/UPDATES.md).
---
## π One shared domain (canonical URL contract)
A node is **never** addressed by its own hostname. The gateway publishes a single shared domain, and the node name travels as a query parameter:
| Purpose | Canonical URL |
| :--- | :--- |
| MCP over SSE | `https://<shared-domain>/sse?user=<node-name>&token=<link-key>` |
| MCP Streamable HTTP | `https://<shared-domain>/mcp?user=<node-name>&token=<link-key>` |
| Reverse tunnel (agent) | `wss://<shared-domain>/ws/tunnel?user=<node-name>` + `Authorization: Bearer <device-token>` |
Why: every extra hostname would need its own DNS record *and* its own SAN in the TLS certificate. A node whose name is missing from the certificate fails the TLS handshake, and the Gemini client then reports an opaque "cannot connect to host". With one shared domain the certificate covers every node forever, and adding a node is only a registration call.
- The shared domain defaults to `smart-server.online`; override it with `./install.sh --domain=<shared-domain>` (node side) or `MESH_PUBLIC_URL` (gateway side).
- Legacy per-device subdomain URLs still resolve for backwards compatibility and log a deprecation warning; set `MESH_LEGACY_SUBDOMAIN=0` on the gateway to reject them outright.
- The installer now records the domain it resolved in the domain file, so the node's share links, its tunnel and the gateway all name the same host: `MESH_PUBLIC_URL=https://<shared-domain>` in `domain.env`, written in both install branches (standalone, and cloud-gateway/tunnel right after `agent.env`). The value is normalised first, the `__MESH_DOMAIN__` placeholder and an empty value are never written, the write is skipped when the file already names that host, and a file that cannot be written is not fatal β the installer prints the exact command to run by hand. The built-in default is never recorded at all: it is a fallback, not a configuration, and pinning it would stop `core/domain.py` from consulting the legacy `MESH_GATEWAY` at all, so a later change in `agent.env` would be silently ignored β nothing is lost, because with no file that same value is already the resolver's last fallback. `--dry-run` reports the file and the value it would write β including `not written (the built-in default is a fallback, not a configuration)` β and still changes nothing on disk.
- Why this matters: share links are built by `core/domain.py`, whose chain is `MESH_PUBLIC_URL` β `AGY_PUBLIC_BASE_URL` β the domain file β the built-in default, and which deliberately never reads the legacy `MESH_GATEWAY` that `agent.env` carries (only `gateway_host()`, the tunnel host, does). A node installed the normal way β `agent.env` with `MESH_GATEWAY`/`MESH_USER`/`MESH_TOKEN` and no domain file β therefore dialled the right gateway while minting links on the built-in default. On an already-installed node, re-run the installer or write the one line by hand: `mkdir -p ~/.config/remote-harness-mcp && echo 'MESH_PUBLIC_URL=https://<domain>' > ~/.config/remote-harness-mcp/domain.env`, or the same with `sudo tee /etc/remote-harness-mcp/domain.env` on Linux. Existing share links keep working; new ones use the configured domain.
- `MESH_DOMAIN_FILE` only overrides the *path*, and it belongs to the node's own configuration rather than to the installer invocation: keep it in `agent.env` (every `MESH_*` key there is exported to the node), or the domain is written into a file the node never reads. The installer creates the parent directory of whichever file is in effect. Over `--ssh=<host>` the domain resolved here is handed to the remote `install.sh` as `--domain=`, so the target records that host instead of re-resolving it (a repository copy still carrying the `__MESH_DOMAIN__` placeholder would otherwise fall back to the built-in default).
---
## π Security
- **Two credentials per node** β the *device token* opens the tunnel and is accepted only in an `Authorization: Bearer` header; the *link key* (`lk_β¦`) is the only credential allowed in a URL. The device token in a query string is rejected with HTTP 401.
- **Link keys rotate** β `POST /v1/devices/<node>/rotate` issues a new link key and kills the old URL; `POST /v1/devices/<node>/revoke` removes the node. Neither needs a reinstall.
- **The device token rotates too** β no reinstall. From the node's payload directory run `python3 -m core.rekey` (Windows: `cd $env:USERPROFILE\.remote-harness-mcp; python -m core.rekey`). It asks the gateway for a new token, writes it into `agent.env`, the launchd plist on macOS and the Startup launcher on Windows, restarts the supervisor (systemd / launchd / Scheduled Task / runit) and waits for the node to report online. The previous token keeps working until the node connects with the new one, so a failed restart restores the old token instead of locking the machine out. `POST /v1/devices/<node>/rotate-token/finish` retires it immediately when the old token is known to be in someone else's hands. The `remote-harness-mcp rotate` shorthand is the same command through the npm CLI.
- **Enrolment** β `/api/register` refuses to hand a token to a request that only knows a name or a MAC address; possession of the device token is required to reuse a registration. Set `MESH_REGISTER_MODE=pair` (with `MESH_ADMIN_TOKEN`) to replace self-registration entirely with the pairing flow: `POST /v1/devices/pair` β approve the code β collect the credentials.
- **Outbound-only tunnel** β nodes never listen on public ports; the agent dials out to the gateway.
- **Gateway is a relay** β it terminates TLS and forwards calls; commands run only on your node.
- **Unprivileged by default** β the agent runs in user space, without root.
> [!WARNING]
> Whoever has your MCP link can run commands on that machine. Treat it like a password: don't publish it, and rotate the link key (`POST /v1/devices/<node>/rotate`) if it leaks.
---
## π’ Self-hosting the gateway
The gateway is a single host serving every node. Publish it only through the deploy script, so the running gateway, the served installers and the node bootstrap payload cannot drift apart:
```bash
MESH_GATEWAY_SSH=root@<gateway-ip> ./deploy_gateway.sh # deploy
MESH_GATEWAY_SSH=root@<gateway-ip> ./deploy_gateway.sh --dry-run # preview
MESH_GATEWAY_SSH=root@<gateway-ip> MESH_GATEWAY_SSH_PASS_FILE=~/.ssh/gw-pass \
./deploy_gateway.sh # password auth
```
It verifies every upload with an sha256 manifest on the host, takes a timestamped backup, installs with the right owner, restarts the service and finishes with a public health check. Nothing is installed if verification fails.
> [!TIP]
> **One place for the domain.** `MESH_PUBLIC_URL` in `domain.env`
> (`/etc/remote-harness-mcp/domain.env` on Linux, `%USERPROFILE%\.config\remote-harness-mcp\domain.env`
> on Windows) is the only setting that names it: the node, the gateway, the installers, the public
> file links and the nginx vhosts all resolve it through `core/domain.py`. Switching domains is one
> value plus `ops/nginx/render-domain.sh --apply` β see [docs/DOMAIN.md](docs/DOMAIN.md).
> [!IMPORTANT]
> **`install.ps1` encoding β one file, two representations.** The repository copy is UTF-8 *with* a BOM: Windows PowerShell 5.1 decodes a BOM-less script with the ANSI code page, the Russian strings then turn into smart quotes and the parser rejects the whole file, so `.\install.ps1` from a clone would not run. The copy the gateway serves is written *without* the BOM by `deploy_gateway.sh`, because `irm β¦ | iex` receives the BOM as part of the first token and `param(...)` then stops being the first statement. nginx declares `charset utf-8` for that location, so the BOM-less copy still decodes correctly. Please keep both properties when editing the file.
**TLS certificate policy β one name, no per-device SANs.** The certificate must cover the shared domain only; a node is selected by `?user=`, never by a hostname:
```bash
# what the certificate currently covers
sudo certbot certificates | grep -A1 'Certificate Name: smart-server.online'
# canonical renewal check (staging, does not touch the live certificate)
sudo certbot renew --dry-run --cert-name smart-server.online
```
Legacy per-device SANs may still be present from before this contract. They are harmless and keep old bookmarked URLs working; drop them at the next renewal once no client uses subdomain URLs any more (see the `domains =` line in `/etc/letsencrypt/renewal/<name>.conf`, or re-issue with a single `-d`).
---
## π License
**MIT** β see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessNo recent community issues