diff --git a/docker-compose.yml b/docker-compose.yml index c4676f2..42a02be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -208,8 +208,8 @@ services: edge: build: - context: docker/edge - dockerfile: Dockerfile + context: . + dockerfile: docker/edge/Dockerfile image: disinto/edge:latest container_name: disinto-edge security_opt: @@ -220,6 +220,8 @@ services: - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/root/.claude.json:ro - ${CLAUDE_DIR:-${HOME}/.claude}:/root/.claude:ro - disinto-logs:/opt/disinto-logs + # Chat history persistence (merged from chat container, #1083) + - ${CHAT_HISTORY_DIR:-./state/chat-history}:/var/lib/chat/history environment: - FORGE_SUPERVISOR_TOKEN=${FORGE_SUPERVISOR_TOKEN:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} @@ -231,6 +233,16 @@ services: - PRIMARY_BRANCH=main - DISINTO_CONTAINER=1 - FORGE_ADMIN_USERS=disinto-admin,vault-bot,admin + # Chat env vars (merged from chat container into edge, #1083) + - CHAT_HOST=127.0.0.1 + - CHAT_PORT=8080 + - CHAT_OAUTH_CLIENT_ID=${CHAT_OAUTH_CLIENT_ID:-} + - CHAT_OAUTH_CLIENT_SECRET=${CHAT_OAUTH_CLIENT_SECRET:-} + - DISINTO_CHAT_ALLOWED_USERS=${DISINTO_CHAT_ALLOWED_USERS:-} + - FORWARD_AUTH_SECRET=${FORWARD_AUTH_SECRET:-} + - EDGE_TUNNEL_FQDN=${EDGE_TUNNEL_FQDN:-} + - EDGE_TUNNEL_FQDN_CHAT=${EDGE_TUNNEL_FQDN_CHAT:-} + - EDGE_ROUTING_MODE=${EDGE_ROUTING_MODE:-subpath} ports: - "80:80" - "443:443" diff --git a/docker/chat/Dockerfile b/docker/chat/Dockerfile deleted file mode 100644 index c4cb28b..0000000 --- a/docker/chat/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# disinto-chat — minimal HTTP backend for Claude chat UI -# -# Small Debian slim base with Python runtime and Node.js. -# Chosen for simplicity and small image size (~100MB). -# -# Image size: ~100MB (well under the 200MB ceiling) -# -# Claude CLI is baked into the image — same pattern as the agents container. - -FROM debian:bookworm-slim - -# Install Node.js (required for Claude CLI) and Python -RUN apt-get update && apt-get install -y --no-install-recommends \ - nodejs npm python3 \ - && rm -rf /var/lib/apt/lists/* - -# Install Claude Code CLI — chat backend runtime -RUN npm install -g @anthropic-ai/claude-code@2.1.84 - -# Non-root user — fixed UID 10001 for sandbox hardening (#706) -RUN useradd -m -u 10001 -s /bin/bash chat - -# Copy application files -COPY server.py /usr/local/bin/server.py -COPY entrypoint-chat.sh /entrypoint-chat.sh -COPY ui/ /var/chat/ui/ - -RUN chmod +x /entrypoint-chat.sh /usr/local/bin/server.py - -USER chat -WORKDIR /var/chat - -EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1 - -ENTRYPOINT ["/entrypoint-chat.sh"] diff --git a/docker/chat/entrypoint-chat.sh b/docker/chat/entrypoint-chat.sh deleted file mode 100755 index 00fbe53..0000000 --- a/docker/chat/entrypoint-chat.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# entrypoint-chat.sh — Start the disinto-chat backend server -# -# Exec-replace pattern: this script is the container entrypoint and runs -# the server directly (no wrapper needed). Logs to stdout for docker logs. - -LOGFILE="/tmp/chat.log" - -log() { - printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE" -} - -# Sandbox sanity checks (#706) — fail fast if isolation is broken -if [ -e /var/run/docker.sock ]; then - log "FATAL: /var/run/docker.sock is accessible — sandbox violation" - exit 1 -fi -if [ "$(id -u)" = "0" ]; then - log "FATAL: running as root (uid 0) — sandbox violation" - exit 1 -fi - -# Verify Claude CLI is available (expected via volume mount from host). -if ! command -v claude &>/dev/null; then - log "FATAL: claude CLI not found in PATH" - log "Mount the host binary into the container, e.g.:" - log " volumes:" - log " - /usr/local/bin/claude:/usr/local/bin/claude:ro" - exit 1 -fi -log "Claude CLI: $(claude --version 2>&1 || true)" - -# Start the Python server (exec-replace so signals propagate correctly) -log "Starting disinto-chat server on port 8080..." -exec python3 /usr/local/bin/server.py diff --git a/docker/chat/server.py b/docker/chat/server.py index 0623955..b5252a7 100644 --- a/docker/chat/server.py +++ b/docker/chat/server.py @@ -41,7 +41,7 @@ import base64 import hashlib # Configuration -HOST = os.environ.get("CHAT_HOST", "0.0.0.0") +HOST = os.environ.get("CHAT_HOST", "127.0.0.1") PORT = int(os.environ.get("CHAT_PORT", 8080)) UI_DIR = "/var/chat/ui" STATIC_DIR = os.path.join(UI_DIR, "static") diff --git a/docker/edge/Dockerfile b/docker/edge/Dockerfile index eca7d7e..507c39b 100644 --- a/docker/edge/Dockerfile +++ b/docker/edge/Dockerfile @@ -1,6 +1,12 @@ FROM caddy:latest -RUN apk add --no-cache bash jq curl git docker-cli python3 openssh-client autossh -COPY entrypoint-edge.sh /usr/local/bin/entrypoint-edge.sh +RUN apk add --no-cache bash jq curl git docker-cli python3 openssh-client autossh \ + nodejs npm +# Claude Code CLI — chat backend runtime (merged from docker/chat, #1083) +RUN npm install -g @anthropic-ai/claude-code@2.1.84 +COPY docker/edge/entrypoint-edge.sh /usr/local/bin/entrypoint-edge.sh +# Chat server and UI (merged from docker/chat into edge, #1083) +COPY docker/chat/server.py /usr/local/bin/chat-server.py +COPY docker/chat/ui/ /var/chat/ui/ VOLUME /data diff --git a/docker/edge/entrypoint-edge.sh b/docker/edge/entrypoint-edge.sh index 83131fb..a1511ff 100755 --- a/docker/edge/entrypoint-edge.sh +++ b/docker/edge/entrypoint-edge.sh @@ -244,6 +244,9 @@ else echo "edge: collect-engagement cron skipped (EDGE_ENGAGEMENT_READY=0)" >&2 fi +# Start chat server in background (#1083 — merged from docker/chat into edge) +(python3 /usr/local/bin/chat-server.py 2>&1 | tee -a /opt/disinto-logs/chat.log) & + # Nomad template renders Caddyfile to /local/Caddyfile via service discovery; # copy it into the expected location if present (compose uses the mounted path). if [ -f /local/Caddyfile ]; then diff --git a/lib/generators.sh b/lib/generators.sh index aa8c373..581de8b 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -326,7 +326,6 @@ _generate_compose_impl() { _record_service "edge" "base compose template" || return 1 _record_service "staging" "base compose template" || return 1 _record_service "staging-deploy" "base compose template" || return 1 - _record_service "chat" "base compose template" || return 1 # Extract primary woodpecker_repo_id from project TOML files local wp_repo_id @@ -615,6 +614,16 @@ COMPOSEEOF - EDGE_TUNNEL_FQDN_CHAT=${EDGE_TUNNEL_FQDN_CHAT:-} # Shared secret for Caddy ↔ chat forward_auth (#709) - FORWARD_AUTH_SECRET=${FORWARD_AUTH_SECRET:-} + # Chat env vars (merged from chat container into edge, #1083) + - CHAT_HOST=127.0.0.1 + - CHAT_PORT=8080 + - CHAT_OAUTH_CLIENT_ID=${CHAT_OAUTH_CLIENT_ID:-} + - CHAT_OAUTH_CLIENT_SECRET=${CHAT_OAUTH_CLIENT_SECRET:-} + - DISINTO_CHAT_ALLOWED_USERS=${DISINTO_CHAT_ALLOWED_USERS:-} + # Cost caps / rate limiting (#711) + - CHAT_MAX_REQUESTS_PER_HOUR=${CHAT_MAX_REQUESTS_PER_HOUR:-60} + - CHAT_MAX_REQUESTS_PER_DAY=${CHAT_MAX_REQUESTS_PER_DAY:-500} + - CHAT_MAX_TOKENS_PER_DAY=${CHAT_MAX_TOKENS_PER_DAY:-1000000} volumes: - ./docker/Caddyfile:/etc/caddy/Caddyfile - caddy_data:/data @@ -622,6 +631,8 @@ COMPOSEEOF - ./secrets/tunnel_key:/run/secrets/tunnel_key:ro - ${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared}:${CLAUDE_SHARED_DIR:-/var/lib/disinto/claude-shared} - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/home/agent/.claude.json:ro + # Chat history persistence (merged from chat container, #1083) + - ${CHAT_HISTORY_DIR:-./state/chat-history}:/var/lib/chat/history healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:2019/config/"] interval: 30s @@ -670,64 +681,12 @@ COMPOSEEOF - disinto-net command: ["echo", "staging slot — replace with project image"] - # Chat container — Claude chat UI backend (#705) - # Internal service only; edge proxy routes to chat:8080 - # Sandbox hardened per #706 — no docker.sock, read-only rootfs, minimal caps - chat: - build: - context: ./docker/chat - dockerfile: Dockerfile - container_name: disinto-chat - restart: unless-stopped - read_only: true - tmpfs: - - /tmp:size=64m - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - pids_limit: 128 - mem_limit: 512m - memswap_limit: 512m - volumes: - # Mount claude binary from host (same as agents) - - ${CLAUDE_BIN_DIR}:/usr/local/bin/claude:ro - # Throwaway named volume for chat config (isolated from host ~/.claude) - - chat-config:/var/chat/config - # Chat history persistence: per-user NDJSON files on bind-mounted host volume - - ${CHAT_HISTORY_DIR:-./state/chat-history}:/var/lib/chat/history - environment: - CHAT_HOST: "0.0.0.0" - CHAT_PORT: "8080" - FORGE_URL: http://forgejo:3000 - CHAT_OAUTH_CLIENT_ID: ${CHAT_OAUTH_CLIENT_ID:-} - CHAT_OAUTH_CLIENT_SECRET: ${CHAT_OAUTH_CLIENT_SECRET:-} - EDGE_TUNNEL_FQDN: ${EDGE_TUNNEL_FQDN:-} - EDGE_TUNNEL_FQDN_CHAT: ${EDGE_TUNNEL_FQDN_CHAT:-} - EDGE_ROUTING_MODE: ${EDGE_ROUTING_MODE:-subpath} - DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-} - # Shared secret for Caddy forward_auth verify endpoint (#709) - FORWARD_AUTH_SECRET: ${FORWARD_AUTH_SECRET:-} - # Cost caps / rate limiting (#711) - CHAT_MAX_REQUESTS_PER_HOUR: ${CHAT_MAX_REQUESTS_PER_HOUR:-60} - CHAT_MAX_REQUESTS_PER_DAY: ${CHAT_MAX_REQUESTS_PER_DAY:-500} - CHAT_MAX_TOKENS_PER_DAY: ${CHAT_MAX_TOKENS_PER_DAY:-1000000} - healthcheck: - test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s - networks: - - disinto-net - volumes: forgejo-data: woodpecker-data: agent-data: project-repos: caddy_data: - chat-config: networks: disinto-net: @@ -786,7 +745,7 @@ COMPOSEEOF # In build mode, replace image: with build: for locally-built images if [ "$use_build" = true ]; then sed -i '/^ image: ghcr\.io\/disinto\/agents:/{s|image: ghcr\.io/disinto/agents:.*|build:\n context: .\n dockerfile: docker/agents/Dockerfile\n pull_policy: build|}' "$compose_file" - sed -i '/^ image: ghcr\.io\/disinto\/edge:/{s|image: ghcr\.io/disinto/edge:.*|build: ./docker/edge\n pull_policy: build|}' "$compose_file" + sed -i '/^ image: ghcr\.io\/disinto\/edge:/{s|image: ghcr\.io/disinto/edge:.*|build:\n context: .\n dockerfile: docker/edge/Dockerfile\n pull_policy: build|}' "$compose_file" fi echo "Created: ${compose_file}" @@ -864,22 +823,22 @@ _generate_caddyfile_subpath() { reverse_proxy staging:80 } - # Chat service — reverse proxy to disinto-chat backend (#705) + # Chat service — reverse proxy to in-process chat server (#705, #1083) # OAuth routes bypass forward_auth — unauthenticated users need these (#709) handle /chat/login { - reverse_proxy chat:8080 + reverse_proxy 127.0.0.1:8080 } handle /chat/oauth/callback { - reverse_proxy chat:8080 + reverse_proxy 127.0.0.1:8080 } # Defense-in-depth: forward_auth stamps X-Forwarded-User from session (#709) handle /chat/* { - forward_auth chat:8080 { + forward_auth 127.0.0.1:8080 { uri /chat/auth/verify copy_headers X-Forwarded-User header_up X-Forward-Auth-Secret {$FORWARD_AUTH_SECRET} } - reverse_proxy chat:8080 + reverse_proxy 127.0.0.1:8080 } } CADDYFILEEOF @@ -912,18 +871,18 @@ _generate_caddyfile_subdomain() { # Chat — with forward_auth (#709, on its own host) {$EDGE_TUNNEL_FQDN_CHAT} { handle /login { - reverse_proxy chat:8080 + reverse_proxy 127.0.0.1:8080 } handle /oauth/callback { - reverse_proxy chat:8080 + reverse_proxy 127.0.0.1:8080 } handle /* { - forward_auth chat:8080 { + forward_auth 127.0.0.1:8080 { uri /auth/verify copy_headers X-Forwarded-User header_up X-Forward-Auth-Secret {$FORWARD_AUTH_SECRET} } - reverse_proxy chat:8080 + reverse_proxy 127.0.0.1:8080 } } CADDYFILEEOF