From 3444e9ca79d15a55d1b8db49448a7b52b0bb409e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 20:10:29 +0000 Subject: [PATCH] =?UTF-8?q?feat(vps):=20Hermes=20agent=20VPS=20=E2=80=94?= =?UTF-8?q?=20Cloudflare=20DNS=20+=20AWS=20EC2=20+=20host=20CLIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained infra/vps/ area to stand up a single VPS running Hermes WebUI (self-hosted UI/server for the Hermes agent) so it can be driven from the Hermex iPhone app. - aws/ EC2 provisioning pinned to Virginia us-east-1 / us-east-1a (default-VPC subnet in the AZ, N2 SG 22/80/443, EIP). - cloudflare/ hermes.code42.dev A-record via the Cloudflare provider, DNS-only by default so Caddy owns ACME/TLS. - host/ install-clis.sh installs Claude Code, Codex and OpenCode plus Playwright + Chromium directly on the host (no container) so the agent can test the real machine. - hermes/ Hermes WebUI in its own container (vendor standard image) behind Caddy auto-TLS; N2 (no published WebUI port). Each dir ships a ready .env.example for deploy-time fill-in; filled .env files stay gitignored. README documents the container-vs-local split and the provision -> DNS -> host -> up -> Hermex runbook. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015SLi6iLJ7eGDW9LnYqRFJ1 --- infra/vps/README.md | 92 ++++++++++++++++++++++ infra/vps/aws/.env.example | 28 +++++++ infra/vps/aws/main.tf | 114 ++++++++++++++++++++++++++++ infra/vps/aws/outputs.tf | 16 ++++ infra/vps/aws/variables.tf | 44 +++++++++++ infra/vps/aws/versions.tf | 10 +++ infra/vps/cloudflare/.env.example | 22 ++++++ infra/vps/cloudflare/main.tf | 19 +++++ infra/vps/cloudflare/outputs.tf | 12 +++ infra/vps/cloudflare/variables.tf | 39 ++++++++++ infra/vps/cloudflare/versions.tf | 10 +++ infra/vps/hermes/.env.example | 33 ++++++++ infra/vps/hermes/Caddyfile | 13 ++++ infra/vps/hermes/docker-compose.yml | 56 ++++++++++++++ infra/vps/host/.env.example | 29 +++++++ infra/vps/host/install-clis.sh | 98 ++++++++++++++++++++++++ 16 files changed, 635 insertions(+) create mode 100644 infra/vps/README.md create mode 100644 infra/vps/aws/.env.example create mode 100644 infra/vps/aws/main.tf create mode 100644 infra/vps/aws/outputs.tf create mode 100644 infra/vps/aws/variables.tf create mode 100644 infra/vps/aws/versions.tf create mode 100644 infra/vps/cloudflare/.env.example create mode 100644 infra/vps/cloudflare/main.tf create mode 100644 infra/vps/cloudflare/outputs.tf create mode 100644 infra/vps/cloudflare/variables.tf create mode 100644 infra/vps/cloudflare/versions.tf create mode 100644 infra/vps/hermes/.env.example create mode 100644 infra/vps/hermes/Caddyfile create mode 100644 infra/vps/hermes/docker-compose.yml create mode 100644 infra/vps/host/.env.example create mode 100755 infra/vps/host/install-clis.sh diff --git a/infra/vps/README.md b/infra/vps/README.md new file mode 100644 index 0000000..a8a73d7 --- /dev/null +++ b/infra/vps/README.md @@ -0,0 +1,92 @@ +# infra/vps — Hermes agent VPS + +Provisions and configures a single VPS that runs the **Hermes WebUI** (the +self-hosted web UI + server for the [Hermes agent](https://github.com/NousResearch/hermes-agent)), +so it can be driven from the **[Hermex](https://hermexapp.com/)** iPhone app. + +- **DNS:** Cloudflare, zone `code42.dev` → `hermes.code42.dev`. +- **Compute:** AWS EC2, **Virginia `us-east-1`**, AZ **`us-east-1a`**. +- **Hermes WebUI:** runs **in a container** (vendor's standard image), behind + Caddy for auto-TLS. N2 posture — Caddy is the only public port. +- **Coding CLIs:** **Claude Code, Codex, OpenCode + Playwright** run **on the + host, not containerized**, so the agent (and you) can test against a real + browser on the real machine. + +``` +infra/vps/ +├── aws/ # EC2 in us-east-1a (tofu) + .env.example +├── cloudflare/ # hermes.code42.dev A-record (tofu) + .env.example +├── host/ # install-clis.sh (host-local CLIs) + .env.example +└── hermes/ # Hermes WebUI container + Caddy + .env.example +``` + +Every `.env.example` is committed empty-of-secrets; copy each to `.env` (or the +noted path), fill it at deploy time, and `chmod 600`. Filled `.env` files are +gitignored — never commit them. + +## Why the split (container vs. local) + +Hermes WebUI runs the Hermes agent **in-process** (it reads `HERMES_HOME`, it +does not call an external agent API). Containerizing the WebUI keeps it a clean, +disposable unit. The **CLIs and Playwright are host-local** because their whole +point here is to exercise the real machine — installing them in the WebUI image +would sandbox them away from the host's browser and Docker. The two are bridged +by shared API keys and (optionally) the Docker socket / workspace mounts in +`hermes/docker-compose.yml`. + +## Deploy order + +1. **Provision AWS** (`infra/vps/aws/`) + ```bash + cd infra/vps/aws && cp .env.example .env && $EDITOR .env # chmod 600 .env + set -a; . ./.env; set +a + tofu init && tofu apply + tofu output -raw public_ip # -> the EIP for the next step + ``` + +2. **Point DNS** (`infra/vps/cloudflare/`) + ```bash + cd infra/vps/cloudflare && cp .env.example .env && $EDITOR .env # paste the EIP + set -a; . ./.env; set +a + tofu init && tofu apply + ``` + +3. **Install host CLIs + Playwright** (`infra/vps/host/`) + ```bash + scp infra/vps/host/install-clis.sh ubuntu@:/tmp/ + ssh ubuntu@ 'bash /tmp/install-clis.sh' + # keys: + scp infra/vps/host/.env.example ubuntu@:~/.config/hermes/cli.env + ssh ubuntu@ 'chmod 600 ~/.config/hermes/cli.env && $EDITOR ~/.config/hermes/cli.env' + ``` + +4. **Bring up Hermes WebUI** (`infra/vps/hermes/`) + ```bash + rsync -a infra/vps/hermes/ ubuntu@:/opt/hermes/ + ssh ubuntu@ + cd /opt/hermes && cp .env.example .env && $EDITOR .env # chmod 600 .env + docker compose config # lint + docker compose up -d + curl -fsS https://hermes.code42.dev/health # verify (evidence!) + ``` + +5. **Connect Hermex** — open the iPhone app, point it at + `https://hermes.code42.dev`, and enter `HERMES_WEBUI_PASSWORD`. + +## Guardrails (repo doctrine) + +- **N2:** the WebUI publishes no host port; only Caddy exposes 80/443. Don't add + a `ports:` line to `hermes-webui`. +- **Config in the environment:** all secrets live in the `.env` files here — no + keys in compose, Caddyfile, or the install script. +- **Verify the real thing:** confirm with `curl .../health` and the Hermex + handshake, not a green `tofu apply`. + +## Teardown + +```bash +tofu -chdir=infra/vps/cloudflare destroy # remove the DNS record first +tofu -chdir=infra/vps/aws destroy # then the instance + EIP +``` +Nothing here is backed up; `tofu destroy` on `aws/` loses `/data` (agent state +and workspace). Snapshot the volume first if you care about it. diff --git a/infra/vps/aws/.env.example b/infra/vps/aws/.env.example new file mode 100644 index 0000000..a3ebc76 --- /dev/null +++ b/infra/vps/aws/.env.example @@ -0,0 +1,28 @@ +# infra/vps/aws — AWS EC2 provisioning (Virginia us-east-1 / us-east-1a) +# Copy to .env, fill in, and `chmod 600 .env`. Consumed as TF_VAR_* by tofu. +# Never commit the filled .env (see repo .gitignore). +# +# Usage: +# set -a; . ./.env; set +a +# tofu -chdir=infra/vps/aws init +# tofu -chdir=infra/vps/aws apply +# tofu -chdir=infra/vps/aws output -raw public_ip # -> Cloudflare record IP + +# ---- AWS credentials (the AWS provider reads these standard vars) ---- +AWS_ACCESS_KEY_ID=change-me +AWS_SECRET_ACCESS_KEY=change-me +# AWS_SESSION_TOKEN= # only if using temporary/STS credentials + +# ---- instance placement (task: Virginia 1a) ---- +TF_VAR_region=us-east-1 +TF_VAR_availability_zone=us-east-1a +TF_VAR_name=devtools-hermes +TF_VAR_instance_type=t3.large +TF_VAR_root_volume_gb=60 + +# ---- SSH access ---- +# Your public egress IP — only this /32 may reach port 22. +TF_VAR_owner_ip=change-me-your.public.ip +# Contents of your SSH public key, e.g. "$(cat ~/.ssh/id_ed25519.pub)" +TF_VAR_public_key=ssh-ed25519 AAAA...change-me you@host +TF_VAR_ssh_key_name=devtools-hermes diff --git a/infra/vps/aws/main.tf b/infra/vps/aws/main.tf new file mode 100644 index 0000000..14632d5 --- /dev/null +++ b/infra/vps/aws/main.tf @@ -0,0 +1,114 @@ +# EC2 host for the Hermes agent VPS. N2 posture: SG opens only 22 (owner IP), +# 80, 443. Docker + the host CLIs (Claude Code / Codex / OpenCode / Playwright) +# are installed post-boot by infra/vps/host/install-clis.sh over SSH, so no +# user_data here. /data lives on the (enlarged) root volume in v1. +# +# Region/AZ are pinned to Virginia us-east-1 / us-east-1a per the task. +provider "aws" { + region = var.region +} + +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] # Canonical + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# Place the instance in the requested AZ via the default VPC's subnet there. +data "aws_vpc" "default" { + default = true +} + +data "aws_subnet" "az" { + vpc_id = data.aws_vpc.default.id + availability_zone = var.availability_zone + default_for_az = true +} + +resource "aws_key_pair" "this" { + key_name = var.ssh_key_name + public_key = var.public_key +} + +resource "aws_security_group" "this" { + name = "${var.name}-sg" + description = "hermes-vps N2: ssh(owner), http, https" + vpc_id = data.aws_vpc.default.id + + ingress { + description = "ssh (owner only)" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["${var.owner_ip}/32"] + } + + ingress { + description = "http (Caddy ACME + redirect)" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "https (Caddy → Hermes WebUI)" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "all egress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.name}-sg" + } +} + +resource "aws_instance" "this" { + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + availability_zone = var.availability_zone + subnet_id = data.aws_subnet.az.id + key_name = aws_key_pair.this.key_name + vpc_security_group_ids = [aws_security_group.this.id] + + root_block_device { + volume_size = var.root_volume_gb + volume_type = "gp3" + } + + tags = { + Name = var.name + } +} + +# Stable address (survives stop/start). Feed this IP into infra/vps/cloudflare. +resource "aws_eip" "this" { + domain = "vpc" + + tags = { + Name = "${var.name}-ip" + } +} + +resource "aws_eip_association" "this" { + instance_id = aws_instance.this.id + allocation_id = aws_eip.this.id +} diff --git a/infra/vps/aws/outputs.tf b/infra/vps/aws/outputs.tf new file mode 100644 index 0000000..81c72a8 --- /dev/null +++ b/infra/vps/aws/outputs.tf @@ -0,0 +1,16 @@ +output "public_ip" { + value = aws_eip.this.public_ip + description = "Elastic IP — set this as CLOUDFLARE_RECORD_IP for the DNS module." +} + +output "ssh_user" { + value = "ubuntu" +} + +output "availability_zone" { + value = aws_instance.this.availability_zone +} + +output "instance_id" { + value = aws_instance.this.id +} diff --git a/infra/vps/aws/variables.tf b/infra/vps/aws/variables.tf new file mode 100644 index 0000000..0318434 --- /dev/null +++ b/infra/vps/aws/variables.tf @@ -0,0 +1,44 @@ +variable "name" { + type = string + default = "devtools-hermes" + description = "Instance + resource name prefix." +} + +variable "region" { + type = string + default = "us-east-1" # Virginia + description = "AWS region. Task pins Virginia (us-east-1)." +} + +variable "availability_zone" { + type = string + default = "us-east-1a" # Virginia 1a + description = "AZ for the instance + EBS. Task pins us-east-1a." +} + +variable "instance_type" { + type = string + default = "t3.large" # 8 GB — Hermes agent + Chromium/Playwright headroom + description = "EC2 size. Bump to t3.xlarge if Playwright + parallel CLIs get tight." +} + +variable "root_volume_gb" { + type = number + default = 60 # OS + Docker image + Playwright browsers + ~/.hermes state + description = "Root gp3 volume size (GiB). /data is a dir on the root volume (v1)." +} + +variable "owner_ip" { + type = string + description = "Operator public IP allowed to SSH (no CIDR suffix; /32 is added)." +} + +variable "public_key" { + type = string + description = "SSH public key contents for the instance key pair." +} + +variable "ssh_key_name" { + type = string + default = "devtools-hermes" +} diff --git a/infra/vps/aws/versions.tf b/infra/vps/aws/versions.tf new file mode 100644 index 0000000..f514570 --- /dev/null +++ b/infra/vps/aws/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} diff --git a/infra/vps/cloudflare/.env.example b/infra/vps/cloudflare/.env.example new file mode 100644 index 0000000..ea143c6 --- /dev/null +++ b/infra/vps/cloudflare/.env.example @@ -0,0 +1,22 @@ +# infra/vps/cloudflare — DNS for hermes.code42.dev (Cloudflare) +# Copy to .env, fill in, and `chmod 600 .env`. Consumed as TF_VAR_* by tofu. +# Never commit the filled .env (see repo .gitignore). +# +# Usage: +# set -a; . ./.env; set +a +# tofu -chdir=infra/vps/cloudflare init +# tofu -chdir=infra/vps/cloudflare apply +# +# The token needs, for the code42.dev zone: Zone:Read + DNS:Edit. + +# ---- Cloudflare API token (Zone:DNS:Edit + Zone:Read) ---- +TF_VAR_cloudflare_api_token=change-me-cloudflare-api-token + +# ---- record ---- +TF_VAR_zone_name=code42.dev +TF_VAR_record_name=hermes +# Paste the EIP from: tofu -chdir=infra/vps/aws output -raw public_ip +TF_VAR_record_ip=change-me-eip-from-aws-output +# false = DNS-only (Caddy owns TLS). true = Cloudflare proxied (CF owns edge TLS). +TF_VAR_proxied=false +TF_VAR_ttl=300 diff --git a/infra/vps/cloudflare/main.tf b/infra/vps/cloudflare/main.tf new file mode 100644 index 0000000..53ada66 --- /dev/null +++ b/infra/vps/cloudflare/main.tf @@ -0,0 +1,19 @@ +# Cloudflare DNS for the Hermes VPS. Creates hermes.code42.dev -> the EC2 EIP. +# DNS-only by default (see var.proxied) so Caddy on the host owns TLS. +provider "cloudflare" { + api_token = var.cloudflare_api_token +} + +data "cloudflare_zone" "this" { + name = var.zone_name +} + +resource "cloudflare_record" "hermes" { + zone_id = data.cloudflare_zone.this.id + name = var.record_name + type = "A" + content = var.record_ip + proxied = var.proxied + ttl = var.proxied ? 1 : var.ttl # Cloudflare requires ttl=1 (auto) when proxied + comment = "Hermes WebUI — managed by infra/vps/cloudflare (tofu)" +} diff --git a/infra/vps/cloudflare/outputs.tf b/infra/vps/cloudflare/outputs.tf new file mode 100644 index 0000000..1537f63 --- /dev/null +++ b/infra/vps/cloudflare/outputs.tf @@ -0,0 +1,12 @@ +output "fqdn" { + value = cloudflare_record.hermes.hostname + description = "Full hostname of the Hermes WebUI record (e.g. hermes.code42.dev)." +} + +output "record_ip" { + value = cloudflare_record.hermes.content +} + +output "proxied" { + value = cloudflare_record.hermes.proxied +} diff --git a/infra/vps/cloudflare/variables.tf b/infra/vps/cloudflare/variables.tf new file mode 100644 index 0000000..af1a0ff --- /dev/null +++ b/infra/vps/cloudflare/variables.tf @@ -0,0 +1,39 @@ +variable "cloudflare_api_token" { + type = string + sensitive = true + description = "Cloudflare API token scoped to Zone:DNS:Edit + Zone:Read for the zone." +} + +variable "zone_name" { + type = string + default = "code42.dev" + description = "The Cloudflare zone (apex domain)." +} + +variable "record_name" { + type = string + default = "hermes" + description = "Subdomain label for the Hermes WebUI (=> hermes.code42.dev)." +} + +variable "record_ip" { + type = string + description = "EC2 Elastic IP (infra/vps/aws output public_ip)." +} + +variable "proxied" { + type = bool + default = false + description = <<-EOT + DNS-only (grey cloud) by default so Caddy on the host can complete the ACME + HTTP-01 / TLS-ALPN challenge and terminate TLS itself. Set true only if you + instead front the box with Cloudflare's proxy (then let CF handle edge TLS + and switch Caddy's site to `tls internal` or an origin cert). + EOT +} + +variable "ttl" { + type = number + default = 300 + description = "Record TTL (seconds). Ignored by Cloudflare when proxied = true." +} diff --git a/infra/vps/cloudflare/versions.tf b/infra/vps/cloudflare/versions.tf new file mode 100644 index 0000000..ceccbad --- /dev/null +++ b/infra/vps/cloudflare/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.0" + } + } +} diff --git a/infra/vps/hermes/.env.example b/infra/vps/hermes/.env.example new file mode 100644 index 0000000..d5c84d2 --- /dev/null +++ b/infra/vps/hermes/.env.example @@ -0,0 +1,33 @@ +# infra/vps/hermes — Hermes WebUI container + Caddy front door +# Copy to .env on the host, fill in, and `chmod 600 .env`. +# Never commit the filled .env (see repo .gitignore). +# +# Deploy (on the host, after ../host/install-clis.sh has run): +# docker compose -f infra/vps/hermes/docker-compose.yml config # lint +# docker compose -f infra/vps/hermes/docker-compose.yml up -d +# curl -fsS https://hermes.code42.dev/health # verify + +# ---- data root (bind mounts) ---- +DATA_ROOT=/data + +# ---- image ---- +# Vendor ships :latest as its standard; pin a digest/tag for a real deploy. +HERMES_WEBUI_TAG=latest + +# ---- ingress / TLS ---- +ACME_EMAIL=admin@analyticsbi.cloud +HERMES_DOMAIN=hermes.code42.dev + +# ---- Hermes WebUI auth (REQUIRED — this is exposed via HTTPS for Hermex) ---- +# Long random secret; Hermex asks for it when you connect the iPhone app. +HERMES_WEBUI_PASSWORD=change-me-long-random-password + +# ---- container file ownership (match the host login user, usually 1000:1000) ---- +WANTED_UID=1000 +WANTED_GID=1000 + +# ---- model provider keys for the in-process Hermes agent ---- +# Same keys as infra/vps/host/.env.example; the agent uses these to reach the +# providers. Keep them in sync (or mount ~/.config/hermes/cli.env instead). +ANTHROPIC_API_KEY=change-me-anthropic-key +OPENAI_API_KEY=change-me-openai-key diff --git a/infra/vps/hermes/Caddyfile b/infra/vps/hermes/Caddyfile new file mode 100644 index 0000000..abc7f70 --- /dev/null +++ b/infra/vps/hermes/Caddyfile @@ -0,0 +1,13 @@ +{ + email {$ACME_EMAIL} +} + +# Single public ingress (N2). Caddy terminates TLS (Let's Encrypt) and reverse +# proxies to the Hermes WebUI container. This is the origin Hermex talks to. +# +# NOTE: requires the DNS record to be DNS-only (Cloudflare grey cloud) so the +# ACME challenge reaches Caddy. If you flip Cloudflare to proxied (orange), +# switch this to `tls internal` or an origin cert and let Cloudflare do edge TLS. +{$HERMES_DOMAIN} { + reverse_proxy hermes-webui:8787 +} diff --git a/infra/vps/hermes/docker-compose.yml b/infra/vps/hermes/docker-compose.yml new file mode 100644 index 0000000..b7d5ad0 --- /dev/null +++ b/infra/vps/hermes/docker-compose.yml @@ -0,0 +1,56 @@ +name: hermes-vps + +# Hermes WebUI in its own container (vendor's standard single-container image), +# fronted by Caddy for auto-TLS at hermes.code42.dev. N2 posture: only Caddy +# publishes host ports (80/443); the WebUI is reachable only over the internal +# network and, from your phone, through Caddy → Hermex connects over HTTPS. +# +# The coding CLIs (claude / codex / opencode) and Playwright are NOT here — they +# are installed on the host by ../host/install-clis.sh. This container carries +# the API keys so the in-process Hermes agent can talk to the model providers; +# the optional mounts below bridge it to the host's Docker + workspace. + +networks: + net: + +volumes: + caddy_data: + driver: local + driver_opts: { type: none, o: bind, device: "${DATA_ROOT:-/data}/caddy/data" } + caddy_config: + driver: local + driver_opts: { type: none, o: bind, device: "${DATA_ROOT:-/data}/caddy/config" } + +services: + hermes-webui: + image: ghcr.io/nesquena/hermes-webui:${HERMES_WEBUI_TAG:-latest} + restart: unless-stopped + env_file: [.env] + environment: + # Bind on all interfaces *inside the container* so Caddy can reach it over + # the docker network. It is still not published to the host (N2). + HERMES_WEBUI_HOST: "0.0.0.0" + HERMES_WEBUI_PORT: "8787" + HERMES_WEBUI_PASSWORD: ${HERMES_WEBUI_PASSWORD} + WANTED_UID: ${WANTED_UID:-1000} + WANTED_GID: ${WANTED_GID:-1000} + volumes: + # Persistent agent config/state + shared workspace on the /data disk. + - "${DATA_ROOT:-/data}/hermes/home:/home/hermeswebui/.hermes" + - "${DATA_ROOT:-/data}/hermes/workspace:/workspace" + # Optional: let the containerized agent drive the host's Docker + host-local + # CLIs/Playwright. Uncomment to bridge container → host tooling. + # - /var/run/docker.sock:/var/run/docker.sock + networks: [net] # N2: no host port; reached via Caddy + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: ["80:80", "443:443"] + env_file: [.env] + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + networks: [net] + depends_on: [hermes-webui] diff --git a/infra/vps/host/.env.example b/infra/vps/host/.env.example new file mode 100644 index 0000000..98d4f18 --- /dev/null +++ b/infra/vps/host/.env.example @@ -0,0 +1,29 @@ +# infra/vps/host — API keys + versions for the host-local CLIs +# On the host this becomes ~/.config/hermes/cli.env (chmod 600), which +# install-clis.sh sources for login shells and mounts into the Hermes container. +# Never commit the filled file (see repo .gitignore). +# +# Deploy: +# scp infra/vps/host/.env.example ubuntu@:~/.config/hermes/cli.env +# ssh ubuntu@ 'chmod 600 ~/.config/hermes/cli.env && nano ~/.config/hermes/cli.env' + +# ---- Claude Code (claude) ---- +ANTHROPIC_API_KEY=change-me-anthropic-key + +# ---- OpenAI Codex CLI (codex) ---- +OPENAI_API_KEY=change-me-openai-key + +# ---- OpenCode CLI (opencode) — set the provider key(s) you actually use ---- +# OpenCode reads standard provider vars; e.g. reuse the two above, or add others: +# OPENROUTER_API_KEY=change-me-openrouter-key +# GEMINI_API_KEY=change-me-gemini-key + +# ---- Playwright ---- +# Browsers are installed here by install-clis.sh; keep in sync if you change it. +PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers + +# ---- CLI versions (optional pins; default is each tool's latest) ---- +# CLAUDE_CODE_VERSION=latest +# CODEX_VERSION=latest +# OPENCODE_VERSION=latest +# NODE_MAJOR=22 diff --git a/infra/vps/host/install-clis.sh b/infra/vps/host/install-clis.sh new file mode 100755 index 0000000..002cf29 --- /dev/null +++ b/infra/vps/host/install-clis.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# infra/vps/host/install-clis.sh +# +# Installs the coding CLIs **directly on the host** (no container) so the Hermes +# agent — and you — can drive the real machine, including Playwright with a real +# Chromium. Per the task the CLIs are local; only Hermes WebUI runs in a +# container (see infra/vps/hermes/). Idempotent; safe to re-run. Ubuntu/Debian. +# +# Run over SSH after provisioning, e.g.: +# scp infra/vps/host/install-clis.sh ubuntu@:/tmp/ +# ssh ubuntu@ 'bash /tmp/install-clis.sh' +# +# API keys are NOT baked in here — they are runtime config. Copy +# infra/vps/host/.env.example -> .env on the host, `chmod 600`, and source it +# (the script drops a /etc/profile.d loader that sources ~/.config/hermes/cli.env +# for interactive shells + the Hermes container's mounted env). +set -euo pipefail + +SUDO="" +[ "$(id -u)" -ne 0 ] && SUDO="sudo" +LOGIN_USER="$(id -un)" +NODE_MAJOR="${NODE_MAJOR:-22}" # Node LTS; all three CLIs ship as npm packages + +echo "== base packages ==" +export DEBIAN_FRONTEND=noninteractive +$SUDO apt-get update -y +$SUDO apt-get install -y ca-certificates curl git gnupg unzip + +echo "== docker (for the Hermes WebUI container) ==" +if ! command -v docker >/dev/null 2>&1; then + $SUDO install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | $SUDO gpg --dearmor -o /etc/apt/keyrings/docker.gpg + $SUDO chmod a+r /etc/apt/keyrings/docker.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ +https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + | $SUDO tee /etc/apt/sources.list.d/docker.list >/dev/null + $SUDO apt-get update -y + $SUDO apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +fi +$SUDO usermod -aG docker "$LOGIN_USER" || true # effective next login + +echo "== node ${NODE_MAJOR}.x (NodeSource) ==" +if ! command -v node >/dev/null 2>&1 || [ "$(node -v | sed 's/v\([0-9]*\).*/\1/')" -lt "$NODE_MAJOR" ]; then + curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | $SUDO -E bash - + $SUDO apt-get install -y nodejs +fi +echo " node $(node -v), npm $(npm -v)" + +echo "== coding CLIs (global npm, on the host) ==" +# Pin nothing here to stay on each tool's fast-moving latest; override via *_VERSION. +CLAUDE_PKG="@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION:-latest}" +CODEX_PKG="@openai/codex@${CODEX_VERSION:-latest}" # scoped pkg — NOT bare "codex" +OPENCODE_PKG="opencode-ai@${OPENCODE_VERSION:-latest}" +$SUDO npm install -g "$CLAUDE_PKG" "$CODEX_PKG" "$OPENCODE_PKG" +echo " claude: $(command -v claude || echo MISSING)" +echo " codex: $(command -v codex || echo MISSING)" +echo " opencode: $(command -v opencode || echo MISSING)" + +echo "== Playwright + Chromium (+ OS deps) ==" +# Global Playwright so the CLIs (and Hermes) can spawn a real browser on the host. +$SUDO npm install -g playwright +export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/opt/pw-browsers}" +$SUDO mkdir -p "$PLAYWRIGHT_BROWSERS_PATH" +$SUDO chown -R "$LOGIN_USER":"$LOGIN_USER" "$PLAYWRIGHT_BROWSERS_PATH" +$SUDO PLAYWRIGHT_BROWSERS_PATH="$PLAYWRIGHT_BROWSERS_PATH" npx --yes playwright install --with-deps chromium + +echo "== runtime env loader (API keys) ==" +# Keys live in ~/.config/hermes/cli.env (chmod 600), sourced by login shells. +# Also mounted read-only into the Hermes WebUI container (see infra/vps/hermes). +CLI_ENV_DIR="$HOME/.config/hermes" +mkdir -p "$CLI_ENV_DIR" +if [ ! -f "$CLI_ENV_DIR/cli.env" ]; then + cat > "$CLI_ENV_DIR/cli.env" <<'EOF' +# Fill from infra/vps/host/.env.example, then `chmod 600` this file. +# ANTHROPIC_API_KEY=... +# OPENAI_API_KEY=... +# OPENCODE_* / provider keys as needed +EOF + chmod 600 "$CLI_ENV_DIR/cli.env" +fi +LOADER=/etc/profile.d/hermes-cli-env.sh +echo '[ -f "$HOME/.config/hermes/cli.env" ] && set -a && . "$HOME/.config/hermes/cli.env" && set +a' \ + | $SUDO tee "$LOADER" >/dev/null +echo "export PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH" | $SUDO tee -a "$LOADER" >/dev/null + +echo "== data dir for Hermes state ==" +$SUDO mkdir -p /data/hermes/home /data/hermes/workspace +$SUDO chown -R "$LOGIN_USER":"$LOGIN_USER" /data/hermes + +cat <