fix: feat: merge chat container into edge — run chat server inside edge container with full permissions (reverts sandbox from #706) (#1083)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-04-20 15:27:21 +00:00
parent 42e9cae6f8
commit 83176c5f28
7 changed files with 48 additions and 142 deletions

View file

@ -208,8 +208,8 @@ services:
edge: edge:
build: build:
context: docker/edge context: .
dockerfile: Dockerfile dockerfile: docker/edge/Dockerfile
image: disinto/edge:latest image: disinto/edge:latest
container_name: disinto-edge container_name: disinto-edge
security_opt: security_opt:
@ -220,6 +220,8 @@ services:
- ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/root/.claude.json:ro - ${CLAUDE_CONFIG_FILE:-${HOME}/.claude.json}:/root/.claude.json:ro
- ${CLAUDE_DIR:-${HOME}/.claude}:/root/.claude:ro - ${CLAUDE_DIR:-${HOME}/.claude}:/root/.claude:ro
- disinto-logs:/opt/disinto-logs - disinto-logs:/opt/disinto-logs
# Chat history persistence (merged from chat container, #1083)
- ${CHAT_HISTORY_DIR:-./state/chat-history}:/var/lib/chat/history
environment: environment:
- FORGE_SUPERVISOR_TOKEN=${FORGE_SUPERVISOR_TOKEN:-} - FORGE_SUPERVISOR_TOKEN=${FORGE_SUPERVISOR_TOKEN:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
@ -231,6 +233,16 @@ services:
- PRIMARY_BRANCH=main - PRIMARY_BRANCH=main
- DISINTO_CONTAINER=1 - DISINTO_CONTAINER=1
- FORGE_ADMIN_USERS=disinto-admin,vault-bot,admin - 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: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"

View file

@ -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"]

View file

@ -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

View file

@ -41,7 +41,7 @@ import base64
import hashlib import hashlib
# Configuration # 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)) PORT = int(os.environ.get("CHAT_PORT", 8080))
UI_DIR = "/var/chat/ui" UI_DIR = "/var/chat/ui"
STATIC_DIR = os.path.join(UI_DIR, "static") STATIC_DIR = os.path.join(UI_DIR, "static")

View file

@ -1,6 +1,12 @@
FROM caddy:latest FROM caddy:latest
RUN apk add --no-cache bash jq curl git docker-cli python3 openssh-client autossh 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 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 VOLUME /data

View file

@ -244,6 +244,9 @@ else
echo "edge: collect-engagement cron skipped (EDGE_ENGAGEMENT_READY=0)" >&2 echo "edge: collect-engagement cron skipped (EDGE_ENGAGEMENT_READY=0)" >&2
fi 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; # Nomad template renders Caddyfile to /local/Caddyfile via service discovery;
# copy it into the expected location if present (compose uses the mounted path). # copy it into the expected location if present (compose uses the mounted path).
if [ -f /local/Caddyfile ]; then if [ -f /local/Caddyfile ]; then

View file

@ -326,7 +326,6 @@ _generate_compose_impl() {
_record_service "edge" "base compose template" || return 1 _record_service "edge" "base compose template" || return 1
_record_service "staging" "base compose template" || return 1 _record_service "staging" "base compose template" || return 1
_record_service "staging-deploy" "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 # Extract primary woodpecker_repo_id from project TOML files
local wp_repo_id local wp_repo_id
@ -615,6 +614,16 @@ COMPOSEEOF
- EDGE_TUNNEL_FQDN_CHAT=${EDGE_TUNNEL_FQDN_CHAT:-} - EDGE_TUNNEL_FQDN_CHAT=${EDGE_TUNNEL_FQDN_CHAT:-}
# Shared secret for Caddy ↔ chat forward_auth (#709) # Shared secret for Caddy ↔ chat forward_auth (#709)
- FORWARD_AUTH_SECRET=${FORWARD_AUTH_SECRET:-} - 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: volumes:
- ./docker/Caddyfile:/etc/caddy/Caddyfile - ./docker/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data - caddy_data:/data
@ -622,6 +631,8 @@ COMPOSEEOF
- ./secrets/tunnel_key:/run/secrets/tunnel_key:ro - ./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_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 - ${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: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:2019/config/"] test: ["CMD", "curl", "-fsS", "http://localhost:2019/config/"]
interval: 30s interval: 30s
@ -670,64 +681,12 @@ COMPOSEEOF
- disinto-net - disinto-net
command: ["echo", "staging slot — replace with project image"] 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: volumes:
forgejo-data: forgejo-data:
woodpecker-data: woodpecker-data:
agent-data: agent-data:
project-repos: project-repos:
caddy_data: caddy_data:
chat-config:
networks: networks:
disinto-net: disinto-net:
@ -786,7 +745,7 @@ COMPOSEEOF
# In build mode, replace image: with build: for locally-built images # In build mode, replace image: with build: for locally-built images
if [ "$use_build" = true ]; then 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\/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 fi
echo "Created: ${compose_file}" echo "Created: ${compose_file}"
@ -864,22 +823,22 @@ _generate_caddyfile_subpath() {
reverse_proxy staging:80 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) # OAuth routes bypass forward_auth — unauthenticated users need these (#709)
handle /chat/login { handle /chat/login {
reverse_proxy chat:8080 reverse_proxy 127.0.0.1:8080
} }
handle /chat/oauth/callback { 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) # Defense-in-depth: forward_auth stamps X-Forwarded-User from session (#709)
handle /chat/* { handle /chat/* {
forward_auth chat:8080 { forward_auth 127.0.0.1:8080 {
uri /chat/auth/verify uri /chat/auth/verify
copy_headers X-Forwarded-User copy_headers X-Forwarded-User
header_up X-Forward-Auth-Secret {$FORWARD_AUTH_SECRET} header_up X-Forward-Auth-Secret {$FORWARD_AUTH_SECRET}
} }
reverse_proxy chat:8080 reverse_proxy 127.0.0.1:8080
} }
} }
CADDYFILEEOF CADDYFILEEOF
@ -912,18 +871,18 @@ _generate_caddyfile_subdomain() {
# Chat — with forward_auth (#709, on its own host) # Chat — with forward_auth (#709, on its own host)
{$EDGE_TUNNEL_FQDN_CHAT} { {$EDGE_TUNNEL_FQDN_CHAT} {
handle /login { handle /login {
reverse_proxy chat:8080 reverse_proxy 127.0.0.1:8080
} }
handle /oauth/callback { handle /oauth/callback {
reverse_proxy chat:8080 reverse_proxy 127.0.0.1:8080
} }
handle /* { handle /* {
forward_auth chat:8080 { forward_auth 127.0.0.1:8080 {
uri /auth/verify uri /auth/verify
copy_headers X-Forwarded-User copy_headers X-Forwarded-User
header_up X-Forward-Auth-Secret {$FORWARD_AUTH_SECRET} header_up X-Forward-Auth-Secret {$FORWARD_AUTH_SECRET}
} }
reverse_proxy chat:8080 reverse_proxy 127.0.0.1:8080
} }
} }
CADDYFILEEOF CADDYFILEEOF