From a7529eb0b7647a56c2f8b16e938a6d3eeae7b560 Mon Sep 17 00:00:00 2001 From: Keysat Date: Mon, 15 Jun 2026 18:40:05 -0500 Subject: [PATCH] Containerize Phase 1 bot: Docker deployment on the Spark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Dockerfile, docker-compose.yml, docker-entrypoint.sh, and .dockerignore so the bot runs detached and survives reboots, replacing the foreground venv run. The image is generic (no secrets/deployment specifics baked in): host networking reaches both Synapse and the Mac; .env, config.toml, and the SSH key are mounted read-only. The entrypoint is the container's environment seam (D4 analog of launch-claude.sh) — it generates ~/.ssh/config for the mac-bridge alias from config.toml [mac] (new hostname/user fields) so the bot's `ssh mac-bridge` stays unchanged. SSH key mounted not baked; first connect uses accept-new host trust. Proven live on the Spark: container connects to Synapse and real messages launched drivable sessions on the phone across 2 rooms via the full chain. --- .dockerignore | 21 ++++++++++++++++++ AGENTS.md | 53 ++++++++++++++++++++++++++++++++++---------- Dockerfile | 27 ++++++++++++++++++++++ config.example.toml | 10 +++++++++ docker-compose.yml | 19 ++++++++++++++++ docker-entrypoint.sh | 40 +++++++++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82b289d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Keep the build context minimal and the image generic/secret-free. +# .env, config.toml, and the SSH key arrive via read-only mounts at runtime — never baked in. +.env +.env.* +!.env.example +config.toml + +.git +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ + +# Mac-side launch scripts run on the Mac, not in this container. +scripts/ + +# Docs / OS cruft — not needed in the image. +*.md +.claude/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md index 55ada2c..b6382c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,12 +51,18 @@ v1 decision surface. - `scripts/launch-claude.sh ` — the Mac wrapper (Phase 0 deliverable; validate by hand before any bot code). -- **Bot (Phase 1), on the Spark:** `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, - then `.venv/bin/python src/bot.py`. Deploy by pulling `src/`, `requirements.txt`, `config.toml`, - `.env` from the Mac via `scp mac-bridge:/Users/macpro/Projects/matrix-bridge/ .` (no Gitea - needed). `MB_SSH_ALIAS` env var overrides the SSH target for testing. -- _TODO (Phase 1 sub-step 4):_ containerize — `docker compose up` on the Spark (host networking; - mount `.env`/`config.toml`/SSH key read-only) once the `Dockerfile` exists. +- **Bot (Phase 1), containerized on the Spark — preferred:** from `~/matrix-bridge`, + `docker compose up -d --build` (host networking, `restart: unless-stopped` so it survives + reboots; read-only mounts of `.env`/`config.toml`/SSH key). Logs: `docker compose logs -f`. + The entrypoint generates `~/.ssh/config` for the `mac-bridge` alias from `config.toml [mac]` + (`hostname`/`user`), so the alias resolves inside the container. Override the host key path with + `MB_SSH_KEY_HOST` if it isn't `/home/modelo/.ssh/id_ed25519`. +- **Bot — venv (dev/fallback):** `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`, + then `.venv/bin/python src/bot.py` — uses modelo's host `~/.ssh/config` for the alias. + `MB_SSH_ALIAS` overrides the SSH target for testing. +- **Deploy:** pull the bot files from the Mac (no Gitea needed) — + `scp mac-bridge:/Users/macpro/Projects/matrix-bridge/{Dockerfile,docker-compose.yml,docker-entrypoint.sh,requirements.txt,config.toml,.env} .` + and `scp -r mac-bridge:/Users/macpro/Projects/matrix-bridge/src .`, then rebuild. ## Layout @@ -72,7 +78,11 @@ v1 decision surface. `ssh mac-bridge gui-launch.sh`; fans out for all-projects; reports failures back to the room. - `requirements.txt` (matrix-nio) · `.env.example` (credential schema; real `.env` gitignored). - `.claude/` — Claude wiring (dir only for now). -- _Future:_ `Dockerfile` + `docker-compose.yml` — Phase 1 sub-step 4. +- `Dockerfile` · `docker-compose.yml` · `docker-entrypoint.sh` · `.dockerignore` — the Phase 1 + container (Spark). Generic image (no secrets/deployment specifics baked in); host networking; + read-only mounts of `.env`/`config.toml`/SSH key. The entrypoint generates `~/.ssh/config` for + the `mac-bridge` alias from `config.toml [mac]` — the container's environment seam (D4 analog + of `launch-claude.sh`). ## Decisions (already made — don't relitigate without new information) @@ -195,8 +205,27 @@ once" is not done. for `#all-projects` (each session named ` - `), and reports failures back (fail-loud). Tested on the **Spark** (`~/matrix-bridge`, venv) — launches worked across several rooms (N=3). Now 11 project rooms + all-projects; `config.toml` has a `[mac]` section (ssh_alias + launcher). -- **Next: Phase 1 sub-step 4 — containerize.** Dockerfile + `docker-compose.yml`, host networking, - mount `.env`/`config.toml`/SSH key read-only, so the bot runs detached and survives reboots - (today it's a foreground venv run). Then FF `master` ← `phase-1`. Work is on branch **`phase-1`** - (pushed); `master` is at Phase 0 (`b6cc829`). Longer-term backlog (incl. headless "ask" mode) in - `ROADMAP.md`. +- **Phase 1 — DONE: containerized + proven on the Spark (2026-06-15).** The bot runs as a Docker + container on the Spark (`~/matrix-bridge`, `docker compose up -d --build`): generic image + (`python:3.12-slim` + `openssh-client`), host networking, `restart: unless-stopped` (survives + reboots), read-only mounts of `.env`/`config.toml`/SSH key. `docker-entrypoint.sh` generates + `~/.ssh/config` for `mac-bridge` from `config.toml [mac]` (added `hostname`=`10.59.211.5`, + `user`=`macpro`) — the container's env seam (D4 analog of `launch-claude.sh`); SSH key mounted + not baked; first connect uses `StrictHostKeyChecking=accept-new` (private-WireGuard tradeoff, D9). + *Proven live:* container connects to Synapse (`listening as @agent… 11 rooms`) and real messages + in **2 different rooms** each launched a drivable session on the phone via the full chain + (container → `ssh mac-bridge` → `gui-launch.sh` → `claude` → phone), rc=0 — confirming the new + container→Mac SSH hop over WireGuard (mounted key + accept-new host trust). *Formal exit was N=3; + the owner accepted 2 live launches across 2 rooms + the clear repeatable pattern as done.* + Build-time checks on the Mac also passed (image builds, `ssh -G mac-bridge` resolves, entrypoint + perms 700/600). +- **Spark-side ops are owner-run.** The Mac has **no** authorized SSH key into the Spark + (`modelo@10.59.211.6` — reachable over WireGuard but not authenticated; Phase 0 only set up the + reverse, `mac-bridge`). So deploys/restarts on the Spark are run by the owner from the Spark, not + driven from the Mac — until Phase 3 wires it behind Spark Control. +- **Next (open — discuss before building):** Phase 2 (multi-room routing) is effectively already + satisfied — the bot was built multi-room (11 rooms + all-projects) and routed correctly across 2 + rooms in the Phase 1 proof; only a formal confirmation pass remains. Live candidates: **Phase 3** + (Spark Control: bot status + one-click update/restart on the dashboard, the SSH-behind-buttons + pattern — also closes the owner-run-ops gap above) or the **headless "ask" mode** from + `ROADMAP.md` (a message runs `claude -p` and posts the answer back into the room). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6b4bf55 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# matrix-bridge bot — Phase 1 container. +# +# Runs on the Spark (always-on Linux + Docker). docker-compose uses host networking so the +# bot reaches BOTH Synapse (clearnet TLS) and the Mac (WireGuard, via the `mac-bridge` SSH alias). +# +# The image is GENERIC: no deployment specifics and no secrets are baked in. At runtime +# docker-compose mounts .env, config.toml, and the SSH key (all read-only); the entrypoint +# generates ~/.ssh/config for the alias from config.toml's [mac] section before launching. +FROM python:3.12-slim + +# openssh-client: the bot shells out to `ssh mac-bridge ...` (the proven Phase 0 seam). +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# .env and config.toml arrive via read-only mounts at runtime (never baked). +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["python", "-u", "src/bot.py"] diff --git a/config.example.toml b/config.example.toml index 4ae4c76..b948b04 100644 --- a/config.example.toml +++ b/config.example.toml @@ -10,6 +10,16 @@ user = "@matrix-bridge-bot:" # a dedicated bot Matrix account (not # Credentials (access token or password) come from the environment or a gitignored secret — # never commit them. The bot reads the homeserver URL + bot creds at startup. +# How the bot reaches the Mac (the proven Phase 0 seam). The bot runs on the Spark, +# where `ssh_alias` resolves; `launcher` is the absolute path to gui-launch.sh on the Mac. +[mac] +ssh_alias = "mac-bridge" +launcher = "/Users/macpro/Projects//scripts/gui-launch.sh" +# Container only: docker-entrypoint.sh generates ~/.ssh/config for `ssh_alias` from these. +# (On a host with `ssh_alias` already in ~/.ssh/config these are ignored.) +hostname = "10.0.0.0" # the Mac's address reachable from the Spark (e.g. WireGuard IP) +user = "" + # One [[room]] block per project. # room_id — the internal Matrix room ID (starts with '!'), NOT the human alias (#name:domain) # repo_dir — an absolute path on the Mac (note: ~/Projects uses a capital P) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..408e0fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +# matrix-bridge bot — Phase 1 deployment on the Spark. +# +# `docker compose up -d` runs the bot detached; `restart: unless-stopped` brings it back after +# a Spark reboot. Host networking lets it reach BOTH Synapse (clearnet TLS) and the Mac +# (WireGuard, via the mac-bridge alias the entrypoint generates). The image stays generic — all +# deployment specifics and secrets arrive through the read-only mounts below. +services: + bot: + build: . + image: matrix-bridge-bot + container_name: matrix-bridge + network_mode: host + restart: unless-stopped + volumes: + - ./.env:/app/.env:ro + - ./config.toml:/app/config.toml:ro + # Dedicated Phase 0 key (spark-control@spark-32d0). Must be chmod 600 on the host. + # Override the host path with MB_SSH_KEY_HOST if the key lives elsewhere. + - ${MB_SSH_KEY_HOST:-/home/modelo/.ssh/id_ed25519}:/root/.ssh/id_ed25519:ro diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..21ae297 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# matrix-bridge container entrypoint — the container's "environment seam". +# +# Generates ~/.ssh/config for the `mac-bridge` alias from config.toml's [mac] section, then +# execs the bot. This mirrors the Mac side, where launch-claude.sh owns environment setup and +# the bot stays dumb (AGENTS.md D4): SSH-client wiring lives here, not in bot.py. On the Spark +# HOST the bot uses modelo's existing ~/.ssh/config; in the container we recreate just the one +# alias we need, pointing at the mounted key. +set -e + +SSH_DIR="$HOME/.ssh" +mkdir -p "$SSH_DIR" +chmod 700 "$SSH_DIR" + +# Write ~/.ssh/config straight from config.toml [mac] (no eval; values never hit a shell). +# IdentityFile is the in-container mount target (a container constant, see docker-compose.yml). +# StrictHostKeyChecking=accept-new auto-trusts the Mac's host key on first connect — acceptable +# on the private WireGuard network (same transport-trust reasoning as D9) and avoids an +# interactive prompt that would otherwise hang the bot. +MB_SSH_KEY="${MB_SSH_KEY:-$SSH_DIR/id_ed25519}" \ +SSH_CONFIG="$SSH_DIR/config" \ +KNOWN_HOSTS="$SSH_DIR/known_hosts" \ +python - <<'PY' +import os, tomllib +with open("/app/config.toml", "rb") as f: + mac = tomllib.load(f)["mac"] +config = f"""Host {mac.get('ssh_alias', 'mac-bridge')} + HostName {mac['hostname']} + User {mac['user']} + IdentityFile {os.environ['MB_SSH_KEY']} + IdentitiesOnly yes + StrictHostKeyChecking accept-new + UserKnownHostsFile {os.environ['KNOWN_HOSTS']} +""" +with open(os.environ['SSH_CONFIG'], "w") as f: + f.write(config) +PY +chmod 600 "$SSH_DIR/config" + +exec "$@"