Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions remoteme-jarvis-node/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Jarvis Node

A small FastAPI server that turns any machine into a remotely-controllable node
on the FactoryLM Tailscale network. It exposes shell execution, screenshots,
file read/write, system info, desktop notifications, and a message queue so that
Jarvis (the Telegram bot / orchestrator) and Claude Code can drive a laptop from
anywhere on the tailnet. Every endpoint is gated behind a bearer token and the
node is meant to bind to its Tailscale IP only — never the public internet.

---

## Security (read this first)

This node can run arbitrary shell commands and read/write files. Two rules are
non-negotiable:

1. **Always set `JARVIS_TOKEN`.** Without it the node *refuses to serve* — every
data-bearing endpoint returns `503` (only the `/` and `/health` healthchecks
answer). With it, every protected request must send
`Authorization: Bearer <JARVIS_TOKEN>` or it gets `401`. The token is compared
with `secrets.compare_digest` (timing-safe).
2. **Always bind to the Tailscale IP, never `0.0.0.0` on an untrusted network.**
The provided launchers auto-detect the Tailscale IPv4 and bind to it; they
fall back to `127.0.0.1` (local-only) if Tailscale is down.

Generate a token once and use the **same value** on every machine that needs to
talk to each other:

```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```

Store it in Doppler (`factorylm/prd → JARVIS_TOKEN`) and/or set it in each
machine's environment.

---

## Quick start

There are two ways to run a node:

| Script | What it does | Use for |
|--------|--------------|---------|
| `install.bat` / `install.sh` | Installs deps, generates a token if unset, detects the Tailscale IP, and runs the node **in the foreground** | Quick start, testing, travel laptop |
| `install-node.sh` | Installs the node as an **always-on background service** (launchd on macOS, systemd `--user` on Linux) with auto-restart | CHARLIE / ALPHA / BRAVO and any always-on host |

### Windows (PLC laptop, travel laptop)

```bat
cd remoteme-jarvis-node
install.bat
```

`install.bat` prints a generated token if `JARVIS_TOKEN` is not already set —
copy it and set it as a System Environment Variable so it persists across
reboots, and set the **same** value on whatever machine will call this node.

### macOS / Linux (foreground)

```bash
cd remoteme-jarvis-node
./install.sh
```

### macOS / Linux (always-on service)

```bash
cd remoteme-jarvis-node
./install-node.sh # installs + starts a launchd/systemd service, then health-checks
```

The service launcher (`run-node.sh`) is **fail-closed**: if `JARVIS_TOKEN` is
neither in the environment nor retrievable from Doppler, it refuses to start.

---

## Fleet topology

```
Tailscale tailnet (100.x.x.x)
┌──────────────┬──────────────┬───┴──────────┬──────────────┐
│ │ │ │ │
CHARLIE ALPHA BRAVO PLC laptop Travel laptop
(Mac mini) (Mac mini) (Mac mini) (Windows) (Windows)
install- install- install- install.bat install.bat
node.sh node.sh node.sh
│ │ │ │ │
└──────────────┴──────────────┴──────────────┴──────────────┘
jarvis_node_client.py / Jarvis bot
(sends Authorization: Bearer $JARVIS_TOKEN)
```

All nodes share one `JARVIS_TOKEN`. Callers use `workers/jarvis_node_client.py`,
which reads `JARVIS_TOKEN` from the environment and attaches it to every request.

---

## Endpoint reference

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | public | Liveness + capability flags (works even with no token set) |
| GET | `/` | public | Service banner: machine name, version, endpoint list |
| GET | `/system-info` | token | Hostname, platform, CPU/memory/disk (psutil) |
| GET | `/processes?top=N` | token | Top N processes by memory |
| POST | `/shell` | token | Run a shell command (`{command, timeout, cwd}`) |
| GET | `/screenshot?monitor=N` | token | Capture screen as base64 PNG (mss) |
| POST | `/files/read` | token | Read a file (`{path}`) |
| POST | `/files/write` | token | Write a file (`{path, content}`) |
| GET | `/files/list?path=...` | token | List a directory |
| POST | `/notify` | token | Desktop notification (`{title, message, type}`) |
| POST | `/messages` | token | Queue a message for `jarvis` / `claude-code` |
| GET | `/messages?for=...` | token | Drain queued messages for a recipient |
| GET | `/messages/peek?for=...` | token | Peek without draining |

### Configuration (environment variables)

| Variable | Default | Description |
|----------|---------|-------------|
| `JARVIS_TOKEN` | *(unset → 503)* | **Required.** Bearer token for all requests |
| `JARVIS_PORT` | `8765` | Listen port |
| `JARVIS_MACHINE_NAME` | hostname | Friendly name reported in `/health` and `/` |
| `JARVIS_WORKSPACE` | `~/jarvis-workspace` | Working directory created on startup |

---

## Verify it's working

```bash
# Liveness (no token needed)
curl http://<tailscale-ip>:8765/health

# Authenticated call
curl -H "Authorization: Bearer $JARVIS_TOKEN" http://<tailscale-ip>:8765/system-info

# Without a token you should get 401 (or 503 if the node has no token configured)
curl -i http://<tailscale-ip>:8765/system-info
```
99 changes: 99 additions & 0 deletions remoteme-jarvis-node/install-node.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# One-command Jarvis Node installer.
#
# macOS -> launchd LaunchAgent (com.factorylm.jarvis-node), KeepAlive
# Linux -> systemd --user unit (jarvis-node.service), Restart=always + linger
#
# Idempotent: re-running re-installs the service and restarts the node.
# Binds tailnet-only via run-node.sh. See README-DEPLOY.md for the Windows path.
#
# curl/clone the repo, then: ./install-node.sh
set -euo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PORT="${JARVIS_PORT:-8765}"
LABEL="com.factorylm.jarvis-node"

echo "==> Jarvis Node installer (repo: $DIR)"

# 1. Dependencies
PY="$(command -v python3 || command -v python)"
echo "==> Python: $PY ($("$PY" --version 2>&1))"
"$PY" -m pip install --user --quiet --disable-pip-version-check \
fastapi uvicorn psutil mss 2>&1 | tail -1 || \
echo " (pip reported issues — continuing; deps may already be satisfied)"

chmod +x "$DIR/run-node.sh"

OS="$(uname -s)"
case "$OS" in
Darwin)
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
echo "==> macOS / launchd -> $PLIST"
launchctl unload "$PLIST" 2>/dev/null || true
pkill -f "uvicorn jarvis_node:app" 2>/dev/null || true
sleep 1
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
cat > "$PLIST" <<PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array><string>$DIR/run-node.sh</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>$HOME/Library/Logs/jarvis-node.log</string>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/jarvis-node.err</string>
</dict>
</plist>
PLISTEOF
launchctl load "$PLIST"
;;
Linux)
UNIT="$HOME/.config/systemd/user/jarvis-node.service"
echo "==> Linux / systemd --user -> $UNIT"
pkill -f "uvicorn jarvis_node:app" 2>/dev/null || true
mkdir -p "$(dirname "$UNIT")"
cat > "$UNIT" <<UNITEOF
[Unit]
Description=Jarvis Node (FastAPI remote-control MCP, tailnet-only)
After=network-online.target

[Service]
ExecStart=$DIR/run-node.sh
Restart=always
RestartSec=3

[Install]
WantedBy=default.target
UNITEOF
systemctl --user daemon-reload
systemctl --user enable --now jarvis-node.service
loginctl enable-linger "$USER" 2>/dev/null || true # survive logout / boot
;;
*)
echo "Unsupported OS: $OS"
echo "Windows: run start-jarvis-node.bat, or wrap it with NSSM / Task Scheduler."
exit 1
;;
esac

# 2. Healthcheck
sleep 3
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
TS_BIN="$(command -v tailscale || echo /opt/homebrew/bin/tailscale)"
HOST="$("$TS_BIN" ip -4 2>/dev/null | head -1 || true)"
if [ -z "${HOST:-}" ] && [ -S /var/run/tailscale/tailscaled.sock ]; then
HOST="$("$TS_BIN" --socket=/var/run/tailscale/tailscaled.sock ip -4 2>/dev/null | head -1 || true)"
fi
HOST="${HOST:-127.0.0.1}"
echo "==> Health: http://$HOST:$PORT/health"
if curl -s --max-time 5 "http://$HOST:$PORT/health"; then
echo ""
echo "✅ Jarvis Node up on http://$HOST:$PORT (machine: $(hostname -s 2>/dev/null || hostname))"
else
echo "❌ health check failed — check logs (macOS: ~/Library/Logs/jarvis-node.err ; Linux: journalctl --user -u jarvis-node)"
exit 1
fi
45 changes: 45 additions & 0 deletions remoteme-jarvis-node/install.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
@echo off
echo === Jarvis Node Installer (Windows) ===
echo.

REM Check Python
python --version >nul 2>&1
if errorlevel 1 (
echo ERROR: Python not found. Install Python 3.10+ from python.org
pause
exit /b 1
)

REM Install dependencies
echo Installing dependencies...
pip install fastapi uvicorn psutil mss

REM Generate token if not set
if "%JARVIS_TOKEN%"=="" (
echo.
echo WARNING: JARVIS_TOKEN not set. Generating one...
for /f %%i in ('python -c "import secrets; print(secrets.token_hex(32))"') do set JARVIS_TOKEN=%%i
echo Your token: %JARVIS_TOKEN%
echo.
echo SET THIS on all machines that need to talk to this node:
echo set JARVIS_TOKEN=%JARVIS_TOKEN%
echo.
echo To persist, add to System Environment Variables.
echo.
)

REM Get Tailscale IP
echo Detecting Tailscale IP...
for /f "tokens=*" %%i in ('tailscale ip -4 2^>nul') do set TAILSCALE_IP=%%i
if "%TAILSCALE_IP%"=="" (
echo WARNING: Tailscale not detected. Binding to 0.0.0.0 (UNSAFE on public networks)
set TAILSCALE_IP=0.0.0.0
) else (
echo Tailscale IP: %TAILSCALE_IP%
)

echo.
echo Starting Jarvis Node on %TAILSCALE_IP%:8765...
echo Press Ctrl+C to stop.
echo.
python -m uvicorn jarvis_node:app --host %TAILSCALE_IP% --port 8765
34 changes: 34 additions & 0 deletions remoteme-jarvis-node/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -e
echo "=== Jarvis Node Installer (macOS/Linux) ==="

# Check Python
command -v python3 >/dev/null 2>&1 || { echo "ERROR: Python 3 not found"; exit 1; }

# Install deps
echo "Installing dependencies..."
pip3 install fastapi uvicorn psutil mss --break-system-packages 2>/dev/null || pip3 install fastapi uvicorn psutil mss

# Generate token if not set
if [ -z "$JARVIS_TOKEN" ]; then
TOKEN=$(python3 -c "import secrets; print(secrets.token_hex(32))")
echo ""
echo "WARNING: JARVIS_TOKEN not set. Generated one:"
echo " export JARVIS_TOKEN=$TOKEN"
echo ""
echo "Add to your ~/.zshrc or ~/.bashrc to persist."
export JARVIS_TOKEN=$TOKEN
fi

# Get Tailscale IP
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "")
if [ -z "$TAILSCALE_IP" ]; then
echo "WARNING: Tailscale not detected. Binding to 127.0.0.1 (local only)"
TAILSCALE_IP="127.0.0.1"
else
echo "Tailscale IP: $TAILSCALE_IP"
fi

echo ""
echo "Starting Jarvis Node on $TAILSCALE_IP:8765..."
python3 -m uvicorn jarvis_node:app --host "$TAILSCALE_IP" --port 8765
29 changes: 28 additions & 1 deletion remoteme-jarvis-node/jarvis_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import os
import sys
import secrets
import base64
import socket
import subprocess
Expand All @@ -23,8 +24,9 @@
from typing import Optional, List, Dict, Any
from collections import deque

from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel

# Optional imports with fallbacks
Expand All @@ -44,6 +46,14 @@
# Configuration
MACHINE_NAME = os.getenv("JARVIS_MACHINE_NAME", socket.gethostname())
PORT = int(os.getenv("JARVIS_PORT", "8765"))

# Bearer token guarding every data-bearing endpoint. "/" and "/health" stay public
# so liveness/healthchecks respond without auth; everything else (/shell, /files,
# /screenshot, /notify, /messages, ...) requires `Authorization: Bearer <JARVIS_TOKEN>`.
# Fail-closed: if JARVIS_TOKEN is unset, protected endpoints return 503 rather than
# running wide open. Provisioned in Doppler factorylm/prd; injected by run-node.sh.
JARVIS_TOKEN = os.getenv("JARVIS_TOKEN", "")
PUBLIC_PATHS = {"/", "/health"}
WORKSPACE = Path(os.getenv("JARVIS_WORKSPACE", Path.home() / "jarvis-workspace"))

# Ensure workspace exists
Expand Down Expand Up @@ -112,6 +122,23 @@ class Message(BaseModel):
allow_headers=["*"],
)


@app.middleware("http")
async def require_bearer_token(request: Request, call_next):
"""Bearer-token gate. Every path except PUBLIC_PATHS (and CORS preflight)
requires `Authorization: Bearer <JARVIS_TOKEN>`. Fail-closed when unset."""
if request.method != "OPTIONS" and request.url.path not in PUBLIC_PATHS:
if not JARVIS_TOKEN:
return JSONResponse(
{"detail": "JARVIS_TOKEN not configured on this node"}, status_code=503
)
provided = request.headers.get("authorization", "")
if not secrets.compare_digest(provided, f"Bearer {JARVIS_TOKEN}"):
return JSONResponse(
{"detail": "missing or invalid bearer token"}, status_code=401
)
return await call_next(request)

# ============================================================================
# Health & Info Endpoints
# ============================================================================
Expand Down
4 changes: 4 additions & 0 deletions remoteme-jarvis-node/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fastapi>=0.100.0
uvicorn>=0.23.0
psutil>=5.9.0
mss>=9.0.0
Loading
Loading