From b86edd7e5d10f6c35f1b8fa2ed7581077ac30756 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 25 Mar 2026 00:28:04 +0000 Subject: [PATCH 1/2] fix: Add Dendrite to docker-compose stack (#619) Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 12 ++- bin/disinto | 158 +++++++++++++++++++++++++++++++++++- docker/agents/entrypoint.sh | 8 ++ lib/env.sh | 12 +++ lib/matrix_listener.service | 3 + lib/matrix_listener.sh | 11 ++- 6 files changed, 195 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 65c20b7..0ac4b8e 100644 --- a/.env.example +++ b/.env.example @@ -38,10 +38,14 @@ WOODPECKER_DB_HOST=127.0.0.1 # [CONFIG] Postgres host WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name # ── Matrix (optional — real-time notifications & escalation replies) ────── -MATRIX_HOMESERVER=http://localhost:8008 # [CONFIG] Dendrite/Synapse URL -MATRIX_BOT_USER=@factory:your.server # [CONFIG] bot's Matrix user ID -MATRIX_TOKEN= # [SECRET] bot's access token -MATRIX_ROOM_ID= # [CONFIG] coordination room ID +# In compose mode, Dendrite runs inside the Docker network. `disinto init` +# provisions the bot user, room, and token automatically. +# Compose: MATRIX_HOMESERVER defaults to http://dendrite:8008 (set by env.sh) +# Bare metal: MATRIX_HOMESERVER defaults to http://localhost:8008 +MATRIX_HOMESERVER=http://dendrite:8008 # [CONFIG] Dendrite URL (compose default) +MATRIX_BOT_USER=@factory-bot:disinto.local # [CONFIG] bot's Matrix user ID +MATRIX_TOKEN= # [SECRET] bot's access token (auto-provisioned) +MATRIX_ROOM_ID= # [CONFIG] coordination room ID (auto-provisioned) # ── Project-specific secrets ────────────────────────────────────────────── # Store all project secrets here so formulas reference env vars, never hardcode. diff --git a/bin/disinto b/bin/disinto index 8d314a7..a9850c5 100755 --- a/bin/disinto +++ b/bin/disinto @@ -155,7 +155,7 @@ generate_compose() { cat > "$compose_file" <<'COMPOSEEOF' # docker-compose.yml — generated by disinto init -# Brings up Forgejo, Woodpecker, and the agent runtime. +# Brings up Forgejo, Woodpecker, Dendrite (Matrix), and the agent runtime. services: forgejo: @@ -194,6 +194,16 @@ services: networks: - disinto-net + dendrite: + image: matrixdotorg/dendrite-monolith:latest + restart: unless-stopped + volumes: + - dendrite-data:/etc/dendrite + environment: + DENDRITE_DOMAIN: disinto.local + networks: + - disinto-net + agents: build: ./docker/agents restart: unless-stopped @@ -208,18 +218,21 @@ services: environment: FORGE_URL: http://forgejo:3000 WOODPECKER_SERVER: http://woodpecker:8000 + MATRIX_HOMESERVER: http://dendrite:8008 DISINTO_CONTAINER: "1" env_file: - .env depends_on: - forgejo - woodpecker + - dendrite networks: - disinto-net volumes: forgejo-data: woodpecker-data: + dendrite-data: agent-data: project-repos: claude-auth: @@ -899,6 +912,144 @@ setup_woodpecker() { fi } +# Provision Dendrite Matrix homeserver: create bot user, room, and access token. +# Stores MATRIX_TOKEN, MATRIX_ROOM_ID, MATRIX_BOT_USER in .env. +setup_matrix() { + local use_bare="${DISINTO_BARE:-false}" + local env_file="${FACTORY_ROOT}/.env" + + echo "" + echo "── Matrix setup ───────────────────────────────────────" + + # In compose mode, Dendrite runs inside the network at http://dendrite:8008. + # For provisioning from the host during init, we exec into the container. + local matrix_host="http://dendrite:8008" + + # Skip if MATRIX_TOKEN is already configured + if [ -n "${MATRIX_TOKEN:-}" ]; then + echo "Matrix: already configured (MATRIX_TOKEN set)" + return + fi + + if [ "$use_bare" = true ]; then + echo "Matrix: skipped in bare mode (configure manually or install Dendrite)" + echo " See: https://matrix-org.github.io/dendrite/" + return + fi + + # Wait for Dendrite to become healthy + echo -n "Waiting for Dendrite to start" + local retries=0 + while true; do + # Probe Dendrite via docker compose exec since it's not exposed on the host + local version_resp + version_resp=$(docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \ + curl -sf --max-time 3 "http://localhost:8008/_matrix/client/versions" 2>/dev/null) || version_resp="" + if [ -n "$version_resp" ]; then + break + fi + retries=$((retries + 1)) + if [ "$retries" -gt 60 ]; then + echo "" + echo "Warning: Dendrite did not become ready within 60s — skipping Matrix setup" >&2 + echo " Run 'disinto init' again after Dendrite is healthy" >&2 + return + fi + echo -n "." + sleep 1 + done + echo " ready" + + # Create bot user via Dendrite's create-account tool + local bot_localpart="factory-bot" + local bot_user="@${bot_localpart}:disinto.local" + local bot_pass + bot_pass="matrix-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 24)" + + echo "Creating Matrix bot user: ${bot_user}" + docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \ + /usr/bin/create-account \ + -config /etc/dendrite/dendrite.yaml \ + -username "${bot_localpart}" \ + -password "${bot_pass}" 2>/dev/null || true + + # Log in to get an access token + local login_resp + login_resp=$(docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \ + curl -sf -X POST "http://localhost:8008/_matrix/client/v3/login" \ + -H "Content-Type: application/json" \ + -d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"${bot_localpart}\"},\"password\":\"${bot_pass}\"}" \ + 2>/dev/null) || login_resp="" + + local access_token + access_token=$(printf '%s' "$login_resp" | jq -r '.access_token // empty' 2>/dev/null) || access_token="" + + if [ -z "$access_token" ]; then + echo "Warning: failed to obtain Matrix access token — skipping Matrix setup" >&2 + echo " Create the bot user manually via Dendrite admin API" >&2 + return + fi + + echo " Bot login successful" + + # Create coordination room + local room_resp + room_resp=$(docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \ + curl -sf -X POST "http://localhost:8008/_matrix/client/v3/createRoom" \ + -H "Authorization: Bearer ${access_token}" \ + -H "Content-Type: application/json" \ + -d '{"room_alias_name":"factory","name":"disinto factory","topic":"Autonomous code factory coordination room","preset":"private_chat"}' \ + 2>/dev/null) || room_resp="" + + local room_id + room_id=$(printf '%s' "$room_resp" | jq -r '.room_id // empty' 2>/dev/null) || room_id="" + + if [ -z "$room_id" ]; then + # Room might already exist — try resolving the alias + local alias_resp + alias_resp=$(docker compose -f "${FACTORY_ROOT}/docker-compose.yml" exec -T dendrite \ + curl -sf "http://localhost:8008/_matrix/client/v3/directory/room/%23factory%3Adisinto.local" \ + -H "Authorization: Bearer ${access_token}" \ + 2>/dev/null) || alias_resp="" + room_id=$(printf '%s' "$alias_resp" | jq -r '.room_id // empty' 2>/dev/null) || room_id="" + fi + + if [ -z "$room_id" ]; then + echo "Warning: failed to create or find coordination room — skipping Matrix setup" >&2 + return + fi + + echo " Room: ${room_id} (alias: #factory:disinto.local)" + + # Store Matrix credentials in .env + local matrix_vars=( + "MATRIX_HOMESERVER=${matrix_host}" + "MATRIX_BOT_USER=${bot_user}" + "MATRIX_TOKEN=${access_token}" + "MATRIX_ROOM_ID=${room_id}" + ) + + for var_line in "${matrix_vars[@]}"; do + local var_name="${var_line%%=*}" + if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then + sed -i "s|^${var_name}=.*|${var_line}|" "$env_file" + else + printf '%s\n' "$var_line" >> "$env_file" + fi + done + + export MATRIX_TOKEN="$access_token" + export MATRIX_BOT_USER="$bot_user" + export MATRIX_ROOM_ID="$room_id" + export MATRIX_HOMESERVER="$matrix_host" + + echo " Credentials saved to .env" + echo "" + echo " To receive notifications in your Matrix client:" + echo " 1. Add 'ports: [\"8008:8008\"]' to the dendrite service in docker-compose.yml" + echo " 2. Join #factory:disinto.local from Element or another Matrix client" +} + # ── init command ───────────────────────────────────────────────────────────── disinto_init() { @@ -1065,6 +1216,9 @@ p.write_text(text) fi fi + # Provision Matrix homeserver (compose mode only) + setup_matrix + # Create labels on remote create_labels "$forge_repo" "$forge_url" @@ -1100,7 +1254,7 @@ p.write_text(text) echo "" echo "── Starting full stack ────────────────────────────────" docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d - echo "Stack: running (forgejo + woodpecker + agents)" + echo "Stack: running (forgejo + woodpecker + dendrite + agents)" fi echo "" diff --git a/docker/agents/entrypoint.sh b/docker/agents/entrypoint.sh index d84c64a..993f721 100644 --- a/docker/agents/entrypoint.sh +++ b/docker/agents/entrypoint.sh @@ -57,6 +57,14 @@ log "Claude CLI: $(claude --version 2>&1 || true)" install_project_crons +# Start matrix listener in background (if configured) +if [ -n "${MATRIX_TOKEN:-}" ] && [ -n "${MATRIX_ROOM_ID:-}" ]; then + log "Starting matrix listener in background" + su -s /bin/bash agent -c "${DISINTO_DIR}/lib/matrix_listener.sh" & +else + log "Matrix listener: skipped (MATRIX_TOKEN or MATRIX_ROOM_ID not set)" +fi + # Run cron in the foreground. Cron jobs execute as the agent user. log "Starting cron daemon" exec cron -f diff --git a/lib/env.sh b/lib/env.sh index 72a8e0a..29eb14c 100755 --- a/lib/env.sh +++ b/lib/env.sh @@ -67,6 +67,18 @@ export WOODPECKER_REPO_ID="${WOODPECKER_REPO_ID:-}" export WOODPECKER_SERVER="${WOODPECKER_SERVER:-http://localhost:8000}" export CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}" +# Matrix homeserver: inside compose Dendrite is at http://dendrite:8008, +# on bare metal it defaults to http://localhost:8008. +if [ -z "${MATRIX_HOMESERVER:-}" ]; then + if [ "${DISINTO_CONTAINER:-}" = "1" ]; then + export MATRIX_HOMESERVER="http://dendrite:8008" + else + export MATRIX_HOMESERVER="http://localhost:8008" + fi +else + export MATRIX_HOMESERVER +fi + # Shared log helper log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" diff --git a/lib/matrix_listener.service b/lib/matrix_listener.service index 98da8ed..1925155 100644 --- a/lib/matrix_listener.service +++ b/lib/matrix_listener.service @@ -1,3 +1,6 @@ +# Legacy systemd unit for bare-metal deployments (disinto init --bare). +# In compose mode, the matrix listener runs inside the agent container +# as a background process — see docker/agents/entrypoint.sh. [Unit] Description=Disinto Matrix Listener After=network.target dendrite.service diff --git a/lib/matrix_listener.sh b/lib/matrix_listener.sh index ebee8f1..de9d202 100755 --- a/lib/matrix_listener.sh +++ b/lib/matrix_listener.sh @@ -20,14 +20,19 @@ set -euo pipefail # Load shared environment source "$(dirname "$0")/../lib/env.sh" -# Pidfile guard — prevent duplicate listener processes +# Pidfile guard — prevent duplicate listener processes. +# Inside a container the PID file from a previous run is stale (container +# restart resets the PID namespace), so we only honour it when the recorded +# PID is still alive. PIDFILE="/tmp/matrix-listener.pid" if [ -f "$PIDFILE" ]; then - OLD_PID=$(cat "$PIDFILE") - if kill -0 "$OLD_PID" 2>/dev/null; then + OLD_PID=$(cat "$PIDFILE" 2>/dev/null || true) + if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then echo "Listener already running (PID $OLD_PID)" >&2 exit 0 fi + # Stale pidfile (previous container run or crashed process) — remove it + rm -f "$PIDFILE" fi echo $$ > "$PIDFILE" trap 'rm -f "$PIDFILE"' EXIT From a0cdf87a21dbaf29afef8d60a16b337b59fd40cb Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 25 Mar 2026 00:35:20 +0000 Subject: [PATCH 2/2] fix: move setup_matrix after compose up, use Python for .env writes - Critical: setup_matrix now runs after docker compose up -d so Dendrite is actually running when provisioning is attempted - Minor: replace sed with Python for .env credential writes to avoid delimiter collisions with opaque Matrix access tokens - Info: update matrix_listener.sh header to mention container mode Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/disinto | 15 +++++++++++---- lib/matrix_listener.sh | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/bin/disinto b/bin/disinto index a9850c5..cf05ad2 100755 --- a/bin/disinto +++ b/bin/disinto @@ -1032,7 +1032,14 @@ setup_matrix() { for var_line in "${matrix_vars[@]}"; do local var_name="${var_line%%=*}" if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then - sed -i "s|^${var_name}=.*|${var_line}|" "$env_file" + # Use Python to avoid sed delimiter collisions with opaque tokens + python3 -c " +import sys, re, pathlib +p = pathlib.Path(sys.argv[1]) +text = p.read_text() +text = re.sub(r'^' + re.escape(sys.argv[2]) + r'=.*$', sys.argv[3], text, flags=re.MULTILINE) +p.write_text(text) +" "$env_file" "$var_name" "$var_line" else printf '%s\n' "$var_line" >> "$env_file" fi @@ -1216,9 +1223,6 @@ p.write_text(text) fi fi - # Provision Matrix homeserver (compose mode only) - setup_matrix - # Create labels on remote create_labels "$forge_repo" "$forge_url" @@ -1255,6 +1259,9 @@ p.write_text(text) echo "── Starting full stack ────────────────────────────────" docker compose -f "${FACTORY_ROOT}/docker-compose.yml" up -d echo "Stack: running (forgejo + woodpecker + dendrite + agents)" + + # Provision Matrix now that Dendrite is running + setup_matrix fi echo "" diff --git a/lib/matrix_listener.sh b/lib/matrix_listener.sh index de9d202..3c1b5c8 100755 --- a/lib/matrix_listener.sh +++ b/lib/matrix_listener.sh @@ -12,7 +12,8 @@ # Thread reply to [vault] message → APPROVE/REJECT dispatched via vault-fire/vault-reject # Thread reply to [action] message → injected into action tmux session # -# Run as systemd service (see matrix_listener.service) or manually: +# In compose mode, started by docker/agents/entrypoint.sh as a background process. +# On bare metal, run as systemd service (see matrix_listener.service) or manually: # ./matrix_listener.sh set -euo pipefail