diff --git a/remoteme-jarvis-node/README.md b/remoteme-jarvis-node/README.md new file mode 100644 index 0000000..8762f6a --- /dev/null +++ b/remoteme-jarvis-node/README.md @@ -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 ` 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://:8765/health + +# Authenticated call +curl -H "Authorization: Bearer $JARVIS_TOKEN" http://:8765/system-info + +# Without a token you should get 401 (or 503 if the node has no token configured) +curl -i http://:8765/system-info +``` diff --git a/remoteme-jarvis-node/install-node.sh b/remoteme-jarvis-node/install-node.sh new file mode 100755 index 0000000..eaa953e --- /dev/null +++ b/remoteme-jarvis-node/install-node.sh @@ -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" < + + + + Label$LABEL + ProgramArguments + $DIR/run-node.sh + RunAtLoad + KeepAlive + StandardOutPath$HOME/Library/Logs/jarvis-node.log + StandardErrorPath$HOME/Library/Logs/jarvis-node.err + + +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" </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 diff --git a/remoteme-jarvis-node/install.bat b/remoteme-jarvis-node/install.bat new file mode 100644 index 0000000..2785955 --- /dev/null +++ b/remoteme-jarvis-node/install.bat @@ -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 diff --git a/remoteme-jarvis-node/install.sh b/remoteme-jarvis-node/install.sh new file mode 100755 index 0000000..4146d19 --- /dev/null +++ b/remoteme-jarvis-node/install.sh @@ -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 diff --git a/remoteme-jarvis-node/jarvis_node.py b/remoteme-jarvis-node/jarvis_node.py index e3d8b4b..7e89cf6 100644 --- a/remoteme-jarvis-node/jarvis_node.py +++ b/remoteme-jarvis-node/jarvis_node.py @@ -14,6 +14,7 @@ import os import sys +import secrets import base64 import socket import subprocess @@ -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 @@ -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 `. +# 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 @@ -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 `. 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 # ============================================================================ diff --git a/remoteme-jarvis-node/requirements.txt b/remoteme-jarvis-node/requirements.txt new file mode 100644 index 0000000..dda04a7 --- /dev/null +++ b/remoteme-jarvis-node/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.100.0 +uvicorn>=0.23.0 +psutil>=5.9.0 +mss>=9.0.0 diff --git a/remoteme-jarvis-node/run-node.sh b/remoteme-jarvis-node/run-node.sh new file mode 100755 index 0000000..d37cc31 --- /dev/null +++ b/remoteme-jarvis-node/run-node.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Jarvis Node launcher — portable (macOS + Linux). +# +# Binds to the Tailscale IPv4 ONLY. The /shell, /files, /screenshot endpoints are +# unauthenticated, so the node must never be reachable off the tailnet. We never +# bind 0.0.0.0 (that would expose it on the LAN too). If Tailscale is down we fall +# back to 127.0.0.1 (local-only) rather than something world-reachable. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" + +# launchd/systemd start us with a minimal PATH. Add ~/.local/bin so the doppler +# CLI is found. We deliberately do NOT prepend Homebrew here — that would shadow +# /usr/bin/python3 (which holds the deps) with a bare Homebrew python. +export PATH="$HOME/.local/bin:$PATH" + +PORT="${JARVIS_PORT:-8765}" + +# Pick the Python that actually has the deps, regardless of PATH order. +PY="" +for c in /usr/bin/python3 python3 /opt/homebrew/bin/python3 /usr/local/bin/python3 python; do + cc="$(command -v "$c" 2>/dev/null || true)" + [ -z "$cc" ] && continue + if "$cc" -c "import uvicorn, fastapi" 2>/dev/null; then PY="$cc"; break; fi +done +if [ -z "$PY" ]; then + echo "ERROR: no python found with uvicorn+fastapi installed. Run install-node.sh." >&2 + exit 1 +fi + +# Resolve the Tailscale IPv4 (tailnet-only bind). Try explicit binary locations; +# Homebrew's tailscaled on this fleet uses a non-default socket. +TS_BIN="" +for c in /opt/homebrew/bin/tailscale /usr/local/bin/tailscale /usr/bin/tailscale; do + [ -x "$c" ] && TS_BIN="$c" && break +done +[ -z "$TS_BIN" ] && TS_BIN="$(command -v tailscale 2>/dev/null || true)" +HOST="" +if [ -n "$TS_BIN" ]; then + 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 +fi +if [ -z "${HOST:-}" ]; then + echo "WARN: Tailscale IP not found; binding 127.0.0.1 (node will be local-only)" >&2 + HOST="127.0.0.1" +fi + +PY="$(command -v python3 || command -v python)" + +# Resolve the bearer token. Prefer an explicit env var (laptops/Pi without Doppler); +# otherwise pull the single secret from Doppler factorylm/prd. Fail-closed: a node +# with no token refuses to start rather than serving /shell wide open. +TOKEN="${JARVIS_TOKEN:-}" +if [ -z "$TOKEN" ] && command -v doppler >/dev/null 2>&1; then + TOKEN="$(doppler secrets get JARVIS_TOKEN -p factorylm -c prd --plain 2>/dev/null || true)" +fi +if [ -z "$TOKEN" ]; then + echo "ERROR: JARVIS_TOKEN unset and not retrievable from Doppler — refusing to start (fail-closed)." >&2 + echo " Set JARVIS_TOKEN in the env, or run 'doppler login' so the secret can be read." >&2 + exit 1 +fi +export JARVIS_TOKEN="$TOKEN" + +exec "$PY" -m uvicorn jarvis_node:app --host "$HOST" --port "$PORT" diff --git a/workers/jarvis_node_client.py b/workers/jarvis_node_client.py index 26da530..d5683cc 100644 --- a/workers/jarvis_node_client.py +++ b/workers/jarvis_node_client.py @@ -4,12 +4,18 @@ Used by Clawdbot to control Windows laptops via Telegram """ +import os import requests import base64 from pathlib import Path from typing import Optional, Dict, Any from datetime import datetime +# Bearer token sent on every request. Nodes refuse to run unauthenticated, so a +# missing token here will surface as 401/503 from the node. Set JARVIS_TOKEN in the +# environment (same value provisioned on the nodes via Doppler factorylm/prd). +JARVIS_TOKEN = os.getenv("JARVIS_TOKEN", "") + # Node registry NODES = { "plc-laptop": { @@ -48,7 +54,13 @@ def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: """Make HTTP request to node""" url = f"{self.base_url}{endpoint}" kwargs.setdefault("timeout", 30) - + + # Authenticate every request. Merge into any caller-supplied headers. + headers = dict(kwargs.pop("headers", {}) or {}) + if JARVIS_TOKEN: + headers["Authorization"] = f"Bearer {JARVIS_TOKEN}" + kwargs["headers"] = headers + try: resp = requests.request(method, url, **kwargs) resp.raise_for_status()