Compare commits

...

3 commits

Author SHA1 Message Date
Claude
94a66e1957 fix: vision(#623): Claude identity isolation for disinto-chat (#707)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/pr/smoke-init Pipeline was successful
2026-04-12 02:22:07 +00:00
34d4136f2e Merge pull request 'fix: vision(#623): Forgejo OAuth gate for disinto-chat (#708)' (#727) from fix/issue-708 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-04-12 02:12:19 +00:00
Claude
30e19f71e2 fix: vision(#623): Forgejo OAuth gate for disinto-chat (#708)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/smoke-init Pipeline was successful
Gate /chat/* behind Forgejo OAuth2 authorization-code flow.

- Extract generic _create_forgejo_oauth_app() helper in lib/ci-setup.sh;
  Woodpecker OAuth becomes a thin wrapper, chat gets its own app.
- bin/disinto init now creates TWO OAuth apps (woodpecker-ci + disinto-chat)
  and writes CHAT_OAUTH_CLIENT_ID / CHAT_OAUTH_CLIENT_SECRET to .env.
- docker/chat/server.py: new routes /chat/login (→ Forgejo authorize),
  /chat/oauth/callback (code→token exchange, user allowlist check, session
  cookie). All other /chat/* routes require a valid session or redirect to
  /chat/login. Session store is in-memory with 24h TTL.
- lib/generators.sh: pass FORGE_URL, CHAT_OAUTH_CLIENT_ID,
  CHAT_OAUTH_CLIENT_SECRET, EDGE_TUNNEL_FQDN, DISINTO_CHAT_ALLOWED_USERS
  to the chat container environment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 01:52:16 +00:00
8 changed files with 547 additions and 103 deletions

View file

@ -69,6 +69,11 @@ WOODPECKER_DB_USER=woodpecker # [CONFIG] Postgres user
WOODPECKER_DB_HOST=127.0.0.1 # [CONFIG] Postgres host WOODPECKER_DB_HOST=127.0.0.1 # [CONFIG] Postgres host
WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
# ── Chat OAuth (#708) ────────────────────────────────────────────────────
CHAT_OAUTH_CLIENT_ID= # [SECRET] Chat OAuth2 client ID (auto-generated by init)
CHAT_OAUTH_CLIENT_SECRET= # [SECRET] Chat OAuth2 client secret (auto-generated by init)
DISINTO_CHAT_ALLOWED_USERS= # [CONFIG] CSV of allowed usernames (disinto-admin always allowed)
# ── Vault-only secrets (DO NOT put these in .env) ──────────────────────── # ── Vault-only secrets (DO NOT put these in .env) ────────────────────────
# These tokens grant access to external systems (GitHub, ClawHub, deploy targets). # These tokens grant access to external systems (GitHub, ClawHub, deploy targets).
# They live ONLY in .env.vault.enc and are injected into the ephemeral runner # They live ONLY in .env.vault.enc and are injected into the ephemeral runner

View file

@ -31,6 +31,9 @@ FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export USER="${USER:-$(id -un)}" export USER="${USER:-$(id -un)}"
export HOME="${HOME:-$(eval echo "~${USER}")}" export HOME="${HOME:-$(eval echo "~${USER}")}"
# Chat Claude identity directory — separate from factory agents (#707)
export CHAT_CLAUDE_DIR="${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}"
source "${FACTORY_ROOT}/lib/env.sh" source "${FACTORY_ROOT}/lib/env.sh"
source "${FACTORY_ROOT}/lib/ops-setup.sh" # setup_ops_repo, migrate_ops_repo source "${FACTORY_ROOT}/lib/ops-setup.sh" # setup_ops_repo, migrate_ops_repo
source "${FACTORY_ROOT}/lib/hire-agent.sh" source "${FACTORY_ROOT}/lib/hire-agent.sh"
@ -542,6 +545,12 @@ create_woodpecker_oauth() {
_create_woodpecker_oauth_impl "$@" _create_woodpecker_oauth_impl "$@"
} }
# Create Chat OAuth2 app on Forgejo (implementation in lib/ci-setup.sh)
create_chat_oauth() {
_load_ci_context
_create_chat_oauth_impl "$@"
}
# Generate WOODPECKER_TOKEN via Forgejo OAuth2 flow (implementation in lib/ci-setup.sh) # Generate WOODPECKER_TOKEN via Forgejo OAuth2 flow (implementation in lib/ci-setup.sh)
generate_woodpecker_token() { generate_woodpecker_token() {
_load_ci_context _load_ci_context
@ -634,6 +643,98 @@ prompt_admin_password() {
exit 1 exit 1
} }
# ── Chat identity prompt helper ───────────────────────────────────────────────
# Prompts for separate Claude identity for disinto-chat (#707).
# On yes, creates ~/.claude-chat/ and runs claude login in subshell.
# Returns 0 on success, 1 on failure/skip.
# Usage: prompt_chat_identity [<env_file>]
prompt_chat_identity() {
local env_file="${1:-${FACTORY_ROOT}/.env}"
local chat_claude_dir="${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}"
local config_dir="${chat_claude_dir}/config"
# Skip if ANTHROPIC_API_KEY is set (API key mode — no OAuth needed)
if grep -q '^ANTHROPIC_API_KEY=' "$env_file" 2>/dev/null; then
local api_key
api_key=$(grep '^ANTHROPIC_API_KEY=' "$env_file" | cut -d= -f2-)
if [ -n "$api_key" ]; then
echo "Chat: ANTHROPIC_API_KEY set — skipping OAuth identity setup"
echo "Chat: Chat will use API key authentication (no ~/.claude-chat mount)"
return 0
fi
fi
# Check if ~/.claude-chat already exists and has credentials
if [ -d "$chat_claude_dir" ]; then
if [ -d "${config_dir}/credentials" ] && [ -n "$(ls -A "${config_dir}/credentials" 2>/dev/null)" ]; then
echo "Chat: ~/.claude-chat already configured (resuming from existing identity)"
return 0
fi
fi
# Non-interactive mode without explicit yes
if [ "$auto_yes" = true ]; then
echo "Chat: --yes provided — creating ~/.claude-chat identity"
# Create directory structure
install -d -m 0700 -o "$USER" "$config_dir"
# Skip claude login in non-interactive mode — user must run manually
echo "Chat: Identity directory created at ${chat_claude_dir}"
echo "Chat: Run 'CLAUDE_CONFIG_DIR=${config_dir} claude login' to authenticate"
return 0
fi
# Interactive mode: prompt for separate identity
if [ -t 0 ]; then
echo ""
echo "── Separate Claude identity for disinto-chat ──────────────────────"
echo "Create a separate Claude identity for disinto-chat to avoid OAuth"
echo "refresh races with factory agents sharing ~/.claude?"
echo ""
printf "Use separate identity for chat? [Y/n] "
local response
IFS= read -r response
response="${response:-Y}"
case "$response" in
[Nn][Oo]|[Nn])
echo "Chat: Skipping separate identity — chat will use shared ~/.claude"
echo "Chat: Note: OAuth refresh races may occur with concurrent agents"
return 1
;;
*)
# User said yes — create identity and run login
echo ""
echo "Chat: Creating identity directory at ${chat_claude_dir}"
install -d -m 0700 -o "$USER" "$config_dir"
# Run claude login in subshell with CLAUDE_CONFIG_DIR set
echo "Chat: Launching Claude login for chat identity..."
echo "Chat: (This will authenticate to a separate Anthropic account)"
echo ""
CLAUDE_CONFIG_DIR="$config_dir" claude login
local login_rc=$?
if [ $login_rc -eq 0 ]; then
echo ""
echo "Chat: Claude identity configured at ${chat_claude_dir}"
echo "Chat: Chat container will use: ${chat_claude_dir}:/home/chat/.claude-chat"
return 0
else
echo "Error: Claude login failed (exit code ${login_rc})" >&2
echo "Chat: Identity not configured"
return 1
fi
;;
esac
fi
# Non-interactive, no TTY
echo "Warning: cannot prompt for chat identity (no TTY)" >&2
echo "Chat: Skipping identity setup — run manually if needed"
return 1
}
# ── init command ───────────────────────────────────────────────────────────── # ── init command ─────────────────────────────────────────────────────────────
disinto_init() { disinto_init() {
@ -762,6 +863,9 @@ p.write_text(text)
# This ensures the password is set before Forgejo user creation # This ensures the password is set before Forgejo user creation
prompt_admin_password "${FACTORY_ROOT}/.env" prompt_admin_password "${FACTORY_ROOT}/.env"
# Prompt for separate chat identity (#707)
prompt_chat_identity "${FACTORY_ROOT}/.env"
# Set up local Forgejo instance (provision if needed, create users/tokens/repo) # Set up local Forgejo instance (provision if needed, create users/tokens/repo)
if [ "$rotate_tokens" = true ]; then if [ "$rotate_tokens" = true ]; then
echo "Note: Forcing token rotation (tokens/passwords will be regenerated)" echo "Note: Forcing token rotation (tokens/passwords will be regenerated)"
@ -860,6 +964,15 @@ p.write_text(text)
_WP_REPO_ID="" _WP_REPO_ID=""
create_woodpecker_oauth "$forge_url" "$forge_repo" create_woodpecker_oauth "$forge_url" "$forge_repo"
# Create OAuth2 app on Forgejo for disinto-chat (#708)
local chat_redirect_uri
if [ -n "${EDGE_TUNNEL_FQDN:-}" ]; then
chat_redirect_uri="https://${EDGE_TUNNEL_FQDN}/chat/oauth/callback"
else
chat_redirect_uri="http://localhost/chat/oauth/callback"
fi
create_chat_oauth "$chat_redirect_uri"
# Generate WOODPECKER_AGENT_SECRET for server↔agent auth # Generate WOODPECKER_AGENT_SECRET for server↔agent auth
local env_file="${FACTORY_ROOT}/.env" local env_file="${FACTORY_ROOT}/.env"
if ! grep -q '^WOODPECKER_AGENT_SECRET=' "$env_file" 2>/dev/null; then if ! grep -q '^WOODPECKER_AGENT_SECRET=' "$env_file" 2>/dev/null; then
@ -976,9 +1089,10 @@ p.write_text(text)
exit 1 exit 1
fi fi
# Write CLAUDE_SHARED_DIR and CLAUDE_CONFIG_DIR to .env (idempotent) # Write CLAUDE_SHARED_DIR, CLAUDE_CONFIG_DIR, and CHAT_CLAUDE_DIR to .env (idempotent)
_env_set_idempotent "CLAUDE_SHARED_DIR" "$CLAUDE_SHARED_DIR" "$env_file" _env_set_idempotent "CLAUDE_SHARED_DIR" "$CLAUDE_SHARED_DIR" "$env_file"
_env_set_idempotent "CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR" "$env_file" _env_set_idempotent "CLAUDE_CONFIG_DIR" "$CLAUDE_CONFIG_DIR" "$env_file"
_env_set_idempotent "CHAT_CLAUDE_DIR" "$CHAT_CLAUDE_DIR" "$env_file"
# Activate default agents (zero-cost when idle — they only invoke Claude # Activate default agents (zero-cost when idle — they only invoke Claude
# when there is actual work, so an empty project burns no LLM tokens) # when there is actual work, so an empty project burns no LLM tokens)
@ -1006,15 +1120,24 @@ p.write_text(text)
fi fi
echo "" echo ""
echo "── Claude authentication ──────────────────────────────" echo "── Claude authentication ──────────────────────────────"
echo " OAuth (shared across containers):" echo " Factory agents (shared OAuth):"
echo " Run 'claude auth login' on the host once." echo " Run 'claude auth login' on the host once."
echo " Credentials in ${CLAUDE_CONFIG_DIR} are shared across containers." echo " Credentials in ${CLAUDE_CONFIG_DIR} are shared across containers."
echo ""
echo " disinto-chat (separate identity #707):"
echo " Separate identity created at ${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}"
echo " Container mounts: ${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}:/home/chat/.claude-chat"
echo " CLAUDE_CONFIG_DIR=/home/chat/.claude-chat/config"
echo ""
echo " API key (alternative — metered billing, no rotation issues):" echo " API key (alternative — metered billing, no rotation issues):"
echo " Set ANTHROPIC_API_KEY in .env to skip OAuth entirely." echo " Set ANTHROPIC_API_KEY in .env to skip OAuth entirely."
echo " Chat container will not mount identity directory."
echo "" echo ""
echo "── Claude config directory ────────────────────────────" echo "── Claude config directories ───────────────────────────"
echo " CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" echo " Factory agents: CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}"
echo " Add this to your shell rc (~/.bashrc or ~/.zshrc):" echo " Chat service: CLAUDE_CONFIG_DIR=/home/chat/.claude-chat/config"
echo ""
echo " Add factory agents CLAUDE_CONFIG_DIR to your shell rc:"
echo " export CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" echo " export CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}"
echo " This ensures interactive Claude Code sessions on this host" echo " This ensures interactive Claude Code sessions on this host"
echo " share the same OAuth lock and token store as the factory." echo " share the same OAuth lock and token store as the factory."

View file

@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \ python3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Non-root user — fixed UID 10001 for sandbox hardening (#706) # Non-root user — fixed UID 10001 for sandbox hardening (#706, #707)
RUN useradd -m -u 10001 -s /bin/bash chat RUN useradd -m -u 10001 -s /bin/bash chat
# Copy application files # Copy application files
@ -25,9 +25,16 @@ COPY ui/ /var/chat/ui/
RUN chmod +x /entrypoint-chat.sh /usr/local/bin/server.py RUN chmod +x /entrypoint-chat.sh /usr/local/bin/server.py
# Create and set ownership of chat identity directory for #707
RUN install -d -m 0700 /home/chat/.claude-chat/config/credentials \
&& chown -R chat:chat /home/chat/.claude-chat
USER chat USER chat
WORKDIR /var/chat WORKDIR /var/chat
# Declare volume for chat identity — mounted from host at runtime (#707)
VOLUME /home/chat/.claude-chat
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/')" || exit 1 CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/')" || exit 1

View file

@ -3,19 +3,32 @@
disinto-chat server minimal HTTP backend for Claude chat UI. disinto-chat server minimal HTTP backend for Claude chat UI.
Routes: Routes:
GET / serves index.html GET /chat/login 302 to Forgejo OAuth authorize
GET /static/* serves static assets (htmx.min.js, etc.) GET /chat/oauth/callback exchange code for token, validate user, set session
POST /chat spawns `claude --print` with user message, returns response GET /chat/ serves index.html (session required)
GET /chat/static/* serves static assets (session required)
POST /chat spawns `claude --print` with user message (session required)
GET /ws reserved for future streaming upgrade (returns 501) GET /ws reserved for future streaming upgrade (returns 501)
OAuth flow:
1. User hits any /chat/* route without a valid session cookie 302 /chat/login
2. /chat/login redirects to Forgejo /login/oauth/authorize
3. Forgejo redirects back to /chat/oauth/callback with ?code=...&state=...
4. Server exchanges code for access token, fetches /api/v1/user
5. Asserts user is in allowlist, sets HttpOnly session cookie
6. Redirects to /chat/
The claude binary is expected to be mounted from the host at /usr/local/bin/claude. The claude binary is expected to be mounted from the host at /usr/local/bin/claude.
""" """
import json
import os import os
import secrets
import subprocess import subprocess
import sys import sys
import time
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs, urlencode
# Configuration # Configuration
HOST = os.environ.get("CHAT_HOST", "0.0.0.0") HOST = os.environ.get("CHAT_HOST", "0.0.0.0")
@ -24,6 +37,30 @@ UI_DIR = "/var/chat/ui"
STATIC_DIR = os.path.join(UI_DIR, "static") STATIC_DIR = os.path.join(UI_DIR, "static")
CLAUDE_BIN = "/usr/local/bin/claude" CLAUDE_BIN = "/usr/local/bin/claude"
# OAuth configuration
FORGE_URL = os.environ.get("FORGE_URL", "http://localhost:3000")
CHAT_OAUTH_CLIENT_ID = os.environ.get("CHAT_OAUTH_CLIENT_ID", "")
CHAT_OAUTH_CLIENT_SECRET = os.environ.get("CHAT_OAUTH_CLIENT_SECRET", "")
EDGE_TUNNEL_FQDN = os.environ.get("EDGE_TUNNEL_FQDN", "")
# Allowed users — disinto-admin always allowed; CSV allowlist extends it
_allowed_csv = os.environ.get("DISINTO_CHAT_ALLOWED_USERS", "")
ALLOWED_USERS = {"disinto-admin"}
if _allowed_csv:
ALLOWED_USERS.update(u.strip() for u in _allowed_csv.split(",") if u.strip())
# Session cookie name
SESSION_COOKIE = "disinto_chat_session"
# Session TTL: 24 hours
SESSION_TTL = 24 * 60 * 60
# In-memory session store: token → {"user": str, "expires": float}
_sessions = {}
# Pending OAuth state tokens: state → expires (float)
_oauth_states = {}
# MIME types for static files # MIME types for static files
MIME_TYPES = { MIME_TYPES = {
".html": "text/html; charset=utf-8", ".html": "text/html; charset=utf-8",
@ -37,14 +74,101 @@ MIME_TYPES = {
} }
def _build_callback_uri():
"""Build the OAuth callback URI based on tunnel configuration."""
if EDGE_TUNNEL_FQDN:
return f"https://{EDGE_TUNNEL_FQDN}/chat/oauth/callback"
return "http://localhost/chat/oauth/callback"
def _session_cookie_flags():
"""Return cookie flags appropriate for the deployment mode."""
flags = "HttpOnly; SameSite=Lax; Path=/chat"
if EDGE_TUNNEL_FQDN:
flags += "; Secure"
return flags
def _validate_session(cookie_header):
"""Check session cookie and return username if valid, else None."""
if not cookie_header:
return None
for part in cookie_header.split(";"):
part = part.strip()
if part.startswith(SESSION_COOKIE + "="):
token = part[len(SESSION_COOKIE) + 1:]
session = _sessions.get(token)
if session and session["expires"] > time.time():
return session["user"]
# Expired — clean up
_sessions.pop(token, None)
return None
return None
def _gc_sessions():
"""Remove expired sessions (called opportunistically)."""
now = time.time()
expired = [k for k, v in _sessions.items() if v["expires"] <= now]
for k in expired:
del _sessions[k]
expired_states = [k for k, v in _oauth_states.items() if v <= now]
for k in expired_states:
del _oauth_states[k]
def _exchange_code_for_token(code):
"""Exchange an authorization code for an access token via Forgejo."""
import urllib.request
import urllib.error
data = urlencode({
"grant_type": "authorization_code",
"code": code,
"client_id": CHAT_OAUTH_CLIENT_ID,
"client_secret": CHAT_OAUTH_CLIENT_SECRET,
"redirect_uri": _build_callback_uri(),
}).encode()
req = urllib.request.Request(
f"{FORGE_URL}/login/oauth/access_token",
data=data,
headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
print(f"OAuth token exchange failed: {e}", file=sys.stderr)
return None
def _fetch_user(access_token):
"""Fetch the authenticated user from Forgejo API."""
import urllib.request
import urllib.error
req = urllib.request.Request(
f"{FORGE_URL}/api/v1/user",
headers={"Authorization": f"token {access_token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, json.JSONDecodeError, OSError) as e:
print(f"User fetch failed: {e}", file=sys.stderr)
return None
class ChatHandler(BaseHTTPRequestHandler): class ChatHandler(BaseHTTPRequestHandler):
"""HTTP request handler for disinto-chat.""" """HTTP request handler for disinto-chat with Forgejo OAuth."""
def log_message(self, format, *args): def log_message(self, format, *args):
"""Log to stdout instead of stderr.""" """Log to stderr."""
print(f"[{self.log_date_time_string()}] {format % args}", file=sys.stderr) print(f"[{self.log_date_time_string()}] {format % args}", file=sys.stderr)
def send_error(self, code, message=None): def send_error_page(self, code, message=None):
"""Custom error response.""" """Custom error response."""
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Type", "text/plain; charset=utf-8")
@ -52,47 +176,148 @@ class ChatHandler(BaseHTTPRequestHandler):
if message: if message:
self.wfile.write(message.encode("utf-8")) self.wfile.write(message.encode("utf-8"))
def _require_session(self):
"""Check session; redirect to /chat/login if missing. Returns username or None."""
user = _validate_session(self.headers.get("Cookie"))
if user:
return user
self.send_response(302)
self.send_header("Location", "/chat/login")
self.end_headers()
return None
def do_GET(self): def do_GET(self):
"""Handle GET requests.""" """Handle GET requests."""
parsed = urlparse(self.path) parsed = urlparse(self.path)
path = parsed.path path = parsed.path
# OAuth routes (no session required)
if path == "/chat/login":
self.handle_login()
return
if path == "/chat/oauth/callback":
self.handle_oauth_callback(parsed.query)
return
# Serve index.html at root # Serve index.html at root
if path == "/" or path == "/chat": if path in ("/", "/chat", "/chat/"):
if not self._require_session():
return
self.serve_index() self.serve_index()
return return
# Serve static files # Serve static files
if path.startswith("/static/"): if path.startswith("/chat/static/") or path.startswith("/static/"):
if not self._require_session():
return
self.serve_static(path) self.serve_static(path)
return return
# Reserved WebSocket endpoint (future use) # Reserved WebSocket endpoint (future use)
if path == "/ws" or path.startswith("/ws"): if path == "/ws" or path.startswith("/ws"):
self.send_error(501, "WebSocket upgrade not yet implemented") self.send_error_page(501, "WebSocket upgrade not yet implemented")
return return
# 404 for unknown paths # 404 for unknown paths
self.send_error(404, "Not found") self.send_error_page(404, "Not found")
def do_POST(self): def do_POST(self):
"""Handle POST requests.""" """Handle POST requests."""
parsed = urlparse(self.path) parsed = urlparse(self.path)
path = parsed.path path = parsed.path
# Chat endpoint # Chat endpoint (session required)
if path == "/chat" or path == "/chat/": if path in ("/chat", "/chat/"):
if not self._require_session():
return
self.handle_chat() self.handle_chat()
return return
# 404 for unknown paths # 404 for unknown paths
self.send_error(404, "Not found") self.send_error_page(404, "Not found")
def handle_login(self):
"""Redirect to Forgejo OAuth authorize endpoint."""
_gc_sessions()
if not CHAT_OAUTH_CLIENT_ID:
self.send_error_page(500, "Chat OAuth not configured (CHAT_OAUTH_CLIENT_ID missing)")
return
state = secrets.token_urlsafe(32)
_oauth_states[state] = time.time() + 600 # 10 min validity
params = urlencode({
"client_id": CHAT_OAUTH_CLIENT_ID,
"redirect_uri": _build_callback_uri(),
"response_type": "code",
"state": state,
})
self.send_response(302)
self.send_header("Location", f"{FORGE_URL}/login/oauth/authorize?{params}")
self.end_headers()
def handle_oauth_callback(self, query_string):
"""Exchange authorization code for token, validate user, set session."""
params = parse_qs(query_string)
code = params.get("code", [""])[0]
state = params.get("state", [""])[0]
# Validate state
expected_expiry = _oauth_states.pop(state, None) if state else None
if not expected_expiry or expected_expiry < time.time():
self.send_error_page(400, "Invalid or expired OAuth state")
return
if not code:
self.send_error_page(400, "Missing authorization code")
return
# Exchange code for access token
token_resp = _exchange_code_for_token(code)
if not token_resp or "access_token" not in token_resp:
self.send_error_page(502, "Failed to obtain access token from Forgejo")
return
access_token = token_resp["access_token"]
# Fetch user info
user_info = _fetch_user(access_token)
if not user_info or "login" not in user_info:
self.send_error_page(502, "Failed to fetch user info from Forgejo")
return
username = user_info["login"]
# Check allowlist
if username not in ALLOWED_USERS:
self.send_response(403)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(
f"Not authorised: user '{username}' is not in the allowed users list.\n".encode()
)
return
# Create session
session_token = secrets.token_urlsafe(48)
_sessions[session_token] = {
"user": username,
"expires": time.time() + SESSION_TTL,
}
cookie_flags = _session_cookie_flags()
self.send_response(302)
self.send_header("Set-Cookie", f"{SESSION_COOKIE}={session_token}; {cookie_flags}")
self.send_header("Location", "/chat/")
self.end_headers()
def serve_index(self): def serve_index(self):
"""Serve the main index.html file.""" """Serve the main index.html file."""
index_path = os.path.join(UI_DIR, "index.html") index_path = os.path.join(UI_DIR, "index.html")
if not os.path.exists(index_path): if not os.path.exists(index_path):
self.send_error(500, "UI not found") self.send_error_page(500, "UI not found")
return return
try: try:
@ -104,19 +329,23 @@ class ChatHandler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(content.encode("utf-8")) self.wfile.write(content.encode("utf-8"))
except IOError as e: except IOError as e:
self.send_error(500, f"Error reading index.html: {e}") self.send_error_page(500, f"Error reading index.html: {e}")
def serve_static(self, path): def serve_static(self, path):
"""Serve static files from the static directory.""" """Serve static files from the static directory."""
# Sanitize path to prevent directory traversal # Strip /chat/static/ or /static/ prefix
if path.startswith("/chat/static/"):
relative_path = path[len("/chat/static/"):]
else:
relative_path = path[len("/static/"):] relative_path = path[len("/static/"):]
if ".." in relative_path or relative_path.startswith("/"): if ".." in relative_path or relative_path.startswith("/"):
self.send_error(403, "Forbidden") self.send_error_page(403, "Forbidden")
return return
file_path = os.path.join(STATIC_DIR, relative_path) file_path = os.path.join(STATIC_DIR, relative_path)
if not os.path.exists(file_path): if not os.path.exists(file_path):
self.send_error(404, "Not found") self.send_error_page(404, "Not found")
return return
# Determine MIME type # Determine MIME type
@ -132,7 +361,7 @@ class ChatHandler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(content) self.wfile.write(content)
except IOError as e: except IOError as e:
self.send_error(500, f"Error reading file: {e}") self.send_error_page(500, f"Error reading file: {e}")
def handle_chat(self): def handle_chat(self):
""" """
@ -142,7 +371,7 @@ class ChatHandler(BaseHTTPRequestHandler):
# Read request body # Read request body
content_length = int(self.headers.get("Content-Length", 0)) content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0: if content_length == 0:
self.send_error(400, "No message provided") self.send_error_page(400, "No message provided")
return return
body = self.rfile.read(content_length) body = self.rfile.read(content_length)
@ -152,16 +381,16 @@ class ChatHandler(BaseHTTPRequestHandler):
params = parse_qs(body_str) params = parse_qs(body_str)
message = params.get("message", [""])[0] message = params.get("message", [""])[0]
except (UnicodeDecodeError, KeyError): except (UnicodeDecodeError, KeyError):
self.send_error(400, "Invalid message format") self.send_error_page(400, "Invalid message format")
return return
if not message: if not message:
self.send_error(400, "Empty message") self.send_error_page(400, "Empty message")
return return
# Validate Claude binary exists # Validate Claude binary exists
if not os.path.exists(CLAUDE_BIN): if not os.path.exists(CLAUDE_BIN):
self.send_error(500, "Claude CLI not found") self.send_error_page(500, "Claude CLI not found")
return return
try: try:
@ -186,7 +415,7 @@ class ChatHandler(BaseHTTPRequestHandler):
# Check for errors # Check for errors
if proc.returncode != 0: if proc.returncode != 0:
self.send_error(500, f"Claude CLI failed with exit code {proc.returncode}") self.send_error_page(500, f"Claude CLI failed with exit code {proc.returncode}")
return return
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Type", "text/plain; charset=utf-8")
@ -195,9 +424,9 @@ class ChatHandler(BaseHTTPRequestHandler):
self.wfile.write(response.encode("utf-8")) self.wfile.write(response.encode("utf-8"))
except FileNotFoundError: except FileNotFoundError:
self.send_error(500, "Claude CLI not found") self.send_error_page(500, "Claude CLI not found")
except Exception as e: except Exception as e:
self.send_error(500, f"Error: {e}") self.send_error_page(500, f"Error: {e}")
def main(): def main():
@ -205,7 +434,12 @@ def main():
server_address = (HOST, PORT) server_address = (HOST, PORT)
httpd = HTTPServer(server_address, ChatHandler) httpd = HTTPServer(server_address, ChatHandler)
print(f"Starting disinto-chat server on {HOST}:{PORT}", file=sys.stderr) print(f"Starting disinto-chat server on {HOST}:{PORT}", file=sys.stderr)
print(f"UI available at http://localhost:{PORT}/", file=sys.stderr) print(f"UI available at http://localhost:{PORT}/chat/", file=sys.stderr)
if CHAT_OAUTH_CLIENT_ID:
print(f"OAuth enabled (client_id={CHAT_OAUTH_CLIENT_ID[:8]}...)", file=sys.stderr)
print(f"Allowed users: {', '.join(sorted(ALLOWED_USERS))}", file=sys.stderr)
else:
print("WARNING: CHAT_OAUTH_CLIENT_ID not set — OAuth disabled", file=sys.stderr)
httpd.serve_forever() httpd.serve_forever()

View file

@ -29,7 +29,7 @@ sourced as needed.
| `lib/forge-push.sh` | `push_to_forge()` — pushes a local clone to the Forgejo remote and verifies the push. `_assert_forge_push_globals()` validates required env vars before use. Requires `FORGE_URL`, `FORGE_PASS`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. **Auth**: uses `FORGE_PASS` (bot password) for git HTTP push — Forgejo 11.x rejects API tokens for `git push` (#361). | bin/disinto (init) | | `lib/forge-push.sh` | `push_to_forge()` — pushes a local clone to the Forgejo remote and verifies the push. `_assert_forge_push_globals()` validates required env vars before use. Requires `FORGE_URL`, `FORGE_PASS`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. **Auth**: uses `FORGE_PASS` (bot password) for git HTTP push — Forgejo 11.x rejects API tokens for `git push` (#361). | bin/disinto (init) |
| `lib/git-creds.sh` | Shared git credential helper configuration. `configure_git_creds([HOME_DIR] [RUN_AS_CMD])` — writes a static credential helper script and configures git globally to use password-based HTTP auth (Forgejo 11.x rejects API tokens for `git push`, #361). `repair_baked_cred_urls([--as RUN_AS_CMD] DIR ...)` — rewrites any git remote URLs that have credentials baked in to use clean URLs instead; uses `safe.directory` bypass for root-owned repos (#671). Requires `FORGE_PASS`, `FORGE_URL`, `FORGE_TOKEN`. | entrypoints (agents, edge) | | `lib/git-creds.sh` | Shared git credential helper configuration. `configure_git_creds([HOME_DIR] [RUN_AS_CMD])` — writes a static credential helper script and configures git globally to use password-based HTTP auth (Forgejo 11.x rejects API tokens for `git push`, #361). `repair_baked_cred_urls([--as RUN_AS_CMD] DIR ...)` — rewrites any git remote URLs that have credentials baked in to use clean URLs instead; uses `safe.directory` bypass for root-owned repos (#671). Requires `FORGE_PASS`, `FORGE_URL`, `FORGE_TOKEN`. | entrypoints (agents, edge) |
| `lib/ops-setup.sh` | `setup_ops_repo()` — creates ops repo on Forgejo if it doesn't exist, configures bot collaborators, clones/initializes ops repo locally, seeds directory structure (vault, knowledge, evidence, sprints). Evidence subdirectories seeded: engagement/, red-team/, holdout/, evolution/, user-test/. Also seeds sprints/ for architect output. Exports `_ACTUAL_OPS_SLUG`. `migrate_ops_repo(ops_root, [primary_branch])` — idempotent migration helper that seeds missing directories and .gitkeep files on existing ops repos (pre-#407 deployments). | bin/disinto (init) | | `lib/ops-setup.sh` | `setup_ops_repo()` — creates ops repo on Forgejo if it doesn't exist, configures bot collaborators, clones/initializes ops repo locally, seeds directory structure (vault, knowledge, evidence, sprints). Evidence subdirectories seeded: engagement/, red-team/, holdout/, evolution/, user-test/. Also seeds sprints/ for architect output. Exports `_ACTUAL_OPS_SLUG`. `migrate_ops_repo(ops_root, [primary_branch])` — idempotent migration helper that seeds missing directories and .gitkeep files on existing ops repos (pre-#407 deployments). | bin/disinto (init) |
| `lib/ci-setup.sh` | `_install_cron_impl()` — installs crontab entries for bare-metal deployments (compose mode uses polling loop instead). `_create_woodpecker_oauth_impl()` — creates OAuth2 app on Forgejo for Woodpecker. `_generate_woodpecker_token_impl()` — auto-generates WOODPECKER_TOKEN via OAuth2 flow. `_activate_woodpecker_repo_impl()` — activates repo in Woodpecker. All gated by `_load_ci_context()` which validates required env vars. | bin/disinto (init) | | `lib/ci-setup.sh` | `_install_cron_impl()` — installs crontab entries for bare-metal deployments (compose mode uses polling loop instead). `_create_forgejo_oauth_app()` — generic helper to create an OAuth2 app on Forgejo (shared by Woodpecker and chat). `_create_woodpecker_oauth_impl()` — creates Woodpecker OAuth2 app (thin wrapper). `_create_chat_oauth_impl()` — creates disinto-chat OAuth2 app, writes `CHAT_OAUTH_CLIENT_ID`/`CHAT_OAUTH_CLIENT_SECRET` to `.env` (#708). `_generate_woodpecker_token_impl()` — auto-generates WOODPECKER_TOKEN via OAuth2 flow. `_activate_woodpecker_repo_impl()` — activates repo in Woodpecker. All gated by `_load_ci_context()` which validates required env vars. | bin/disinto (init) |
| `lib/generators.sh` | Template generation for `disinto init`: `generate_compose()` — docker-compose.yml (uses `codeberg.org/forgejo/forgejo:11.0` tag; adds `security_opt: [apparmor:unconfined]` to all services for rootless container compatibility; Forgejo includes a healthcheck so dependent services use `condition: service_healthy` — fixes cold-start races, #665; adds `chat` service block with isolated `chat-config` named volume; all `depends_on` now use `condition: service_healthy/started` instead of bare service names), `generate_caddyfile()` — Caddyfile (routes: `/forge/*` → forgejo:3000, `/woodpecker/*` → woodpecker:8000, `/staging/*` → staging:80, `/chat/*` → chat:8080; root `/` redirects to `/forge/`), `generate_staging_index()` — staging index, `generate_deploy_pipelines()` — Woodpecker deployment pipeline configs. Requires `FACTORY_ROOT`, `PROJECT_NAME`, `PRIMARY_BRANCH`. | bin/disinto (init) | | `lib/generators.sh` | Template generation for `disinto init`: `generate_compose()` — docker-compose.yml (uses `codeberg.org/forgejo/forgejo:11.0` tag; adds `security_opt: [apparmor:unconfined]` to all services for rootless container compatibility; Forgejo includes a healthcheck so dependent services use `condition: service_healthy` — fixes cold-start races, #665; adds `chat` service block with isolated `chat-config` named volume; all `depends_on` now use `condition: service_healthy/started` instead of bare service names), `generate_caddyfile()` — Caddyfile (routes: `/forge/*` → forgejo:3000, `/woodpecker/*` → woodpecker:8000, `/staging/*` → staging:80, `/chat/*` → chat:8080; root `/` redirects to `/forge/`), `generate_staging_index()` — staging index, `generate_deploy_pipelines()` — Woodpecker deployment pipeline configs. Requires `FACTORY_ROOT`, `PROJECT_NAME`, `PRIMARY_BRANCH`. | bin/disinto (init) |
| `lib/hire-agent.sh` | `disinto_hire_an_agent()` — user creation, `.profile` repo setup, formula copying, branch protection, and state marker creation for hiring a new agent. Requires `FORGE_URL`, `FORGE_TOKEN`, `FACTORY_ROOT`, `PROJECT_NAME`. Extracted from `bin/disinto`. | bin/disinto (hire) | | `lib/hire-agent.sh` | `disinto_hire_an_agent()` — user creation, `.profile` repo setup, formula copying, branch protection, and state marker creation for hiring a new agent. Requires `FORGE_URL`, `FORGE_TOKEN`, `FACTORY_ROOT`, `PROJECT_NAME`. Extracted from `bin/disinto`. | bin/disinto (hire) |
| `lib/release.sh` | `disinto_release()` — vault TOML creation, branch setup on ops repo, PR creation, and auto-merge request for a versioned release. `_assert_release_globals()` validates required env vars. Requires `FORGE_URL`, `FORGE_TOKEN`, `FORGE_OPS_REPO`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. Extracted from `bin/disinto`. | bin/disinto (release) | | `lib/release.sh` | `disinto_release()` — vault TOML creation, branch setup on ops repo, PR creation, and auto-merge request for a versioned release. `_assert_release_globals()` validates required env vars. Requires `FORGE_URL`, `FORGE_TOKEN`, `FORGE_OPS_REPO`, `FACTORY_ROOT`, `PRIMARY_BRANCH`. Extracted from `bin/disinto`. | bin/disinto (release) |

View file

@ -4,7 +4,9 @@
# #
# Internal functions (called via _load_ci_context + _*_impl): # Internal functions (called via _load_ci_context + _*_impl):
# _install_cron_impl() - Install crontab entries (bare-metal only; compose uses polling loop) # _install_cron_impl() - Install crontab entries (bare-metal only; compose uses polling loop)
# _create_forgejo_oauth_app() - Generic: create an OAuth2 app on Forgejo (shared helper)
# _create_woodpecker_oauth_impl() - Create OAuth2 app on Forgejo for Woodpecker # _create_woodpecker_oauth_impl() - Create OAuth2 app on Forgejo for Woodpecker
# _create_chat_oauth_impl() - Create OAuth2 app on Forgejo for disinto-chat
# _generate_woodpecker_token_impl() - Auto-generate WOODPECKER_TOKEN via OAuth2 flow # _generate_woodpecker_token_impl() - Auto-generate WOODPECKER_TOKEN via OAuth2 flow
# _activate_woodpecker_repo_impl() - Activate repo in Woodpecker # _activate_woodpecker_repo_impl() - Activate repo in Woodpecker
# #
@ -90,6 +92,54 @@ _install_cron_impl() {
fi fi
} }
# Create an OAuth2 application on Forgejo.
# Generic helper used by both Woodpecker and chat OAuth setup.
# Sets _OAUTH_CLIENT_ID and _OAUTH_CLIENT_SECRET on success.
# Usage: _create_forgejo_oauth_app <app_name> <redirect_uri>
_create_forgejo_oauth_app() {
local oauth2_name="$1"
local redirect_uri="$2"
local forge_url="${FORGE_URL}"
_OAUTH_CLIENT_ID=""
_OAUTH_CLIENT_SECRET=""
local existing_app
existing_app=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${forge_url}/api/v1/user/applications/oauth2" 2>/dev/null \
| jq -r --arg name "$oauth2_name" '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true
if [ -n "$existing_app" ]; then
echo "OAuth2: ${oauth2_name} (already exists, client_id=${existing_app})"
_OAUTH_CLIENT_ID="$existing_app"
return 0
fi
local oauth2_resp
oauth2_resp=$(curl -sf -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/user/applications/oauth2" \
-d "{\"name\":\"${oauth2_name}\",\"redirect_uris\":[\"${redirect_uri}\"],\"confidential_client\":true}" \
2>/dev/null) || oauth2_resp=""
if [ -z "$oauth2_resp" ]; then
echo "Warning: failed to create OAuth2 app '${oauth2_name}' on Forgejo" >&2
return 1
fi
_OAUTH_CLIENT_ID=$(printf '%s' "$oauth2_resp" | jq -r '.client_id // empty')
_OAUTH_CLIENT_SECRET=$(printf '%s' "$oauth2_resp" | jq -r '.client_secret // empty')
if [ -z "$_OAUTH_CLIENT_ID" ]; then
echo "Warning: OAuth2 app creation returned no client_id" >&2
return 1
fi
echo "OAuth2: ${oauth2_name} created (client_id=${_OAUTH_CLIENT_ID})"
}
# Set up Woodpecker CI to use Forgejo as its forge backend. # Set up Woodpecker CI to use Forgejo as its forge backend.
# Creates an OAuth2 app on Forgejo for Woodpecker, activates the repo. # Creates an OAuth2 app on Forgejo for Woodpecker, activates the repo.
# Usage: create_woodpecker_oauth <forge_url> <repo_slug> # Usage: create_woodpecker_oauth <forge_url> <repo_slug>
@ -100,44 +150,9 @@ _create_woodpecker_oauth_impl() {
echo "" echo ""
echo "── Woodpecker OAuth2 setup ────────────────────────────" echo "── Woodpecker OAuth2 setup ────────────────────────────"
# Create OAuth2 application on Forgejo for Woodpecker _create_forgejo_oauth_app "woodpecker-ci" "http://localhost:8000/authorize" || return 0
local oauth2_name="woodpecker-ci" local client_id="${_OAUTH_CLIENT_ID}"
local redirect_uri="http://localhost:8000/authorize" local client_secret="${_OAUTH_CLIENT_SECRET}"
local existing_app client_id client_secret
# Check if OAuth2 app already exists
existing_app=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${forge_url}/api/v1/user/applications/oauth2" 2>/dev/null \
| jq -r --arg name "$oauth2_name" '.[] | select(.name == $name) | .client_id // empty' 2>/dev/null) || true
if [ -n "$existing_app" ]; then
echo "OAuth2: ${oauth2_name} (already exists, client_id=${existing_app})"
client_id="$existing_app"
else
local oauth2_resp
oauth2_resp=$(curl -sf -X POST \
-H "Authorization: token ${FORGE_TOKEN}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/user/applications/oauth2" \
-d "{\"name\":\"${oauth2_name}\",\"redirect_uris\":[\"${redirect_uri}\"],\"confidential_client\":true}" \
2>/dev/null) || oauth2_resp=""
if [ -z "$oauth2_resp" ]; then
echo "Warning: failed to create OAuth2 app on Forgejo" >&2
return
fi
client_id=$(printf '%s' "$oauth2_resp" | jq -r '.client_id // empty')
client_secret=$(printf '%s' "$oauth2_resp" | jq -r '.client_secret // empty')
if [ -z "$client_id" ]; then
echo "Warning: OAuth2 app creation returned no client_id" >&2
return
fi
echo "OAuth2: ${oauth2_name} created (client_id=${client_id})"
fi
# Store Woodpecker forge config in .env # Store Woodpecker forge config in .env
# WP_FORGEJO_CLIENT/SECRET match the docker-compose.yml variable references # WP_FORGEJO_CLIENT/SECRET match the docker-compose.yml variable references
@ -166,6 +181,39 @@ _create_woodpecker_oauth_impl() {
echo "Config: Woodpecker forge vars written to .env" echo "Config: Woodpecker forge vars written to .env"
} }
# Create OAuth2 app on Forgejo for disinto-chat.
# Writes CHAT_OAUTH_CLIENT_ID / CHAT_OAUTH_CLIENT_SECRET to .env.
# Usage: _create_chat_oauth_impl <redirect_uri>
_create_chat_oauth_impl() {
local redirect_uri="$1"
echo ""
echo "── Chat OAuth2 setup ──────────────────────────────────"
_create_forgejo_oauth_app "disinto-chat" "$redirect_uri" || return 0
local client_id="${_OAUTH_CLIENT_ID}"
local client_secret="${_OAUTH_CLIENT_SECRET}"
local env_file="${FACTORY_ROOT}/.env"
local chat_vars=()
if [ -n "${client_id:-}" ]; then
chat_vars+=("CHAT_OAUTH_CLIENT_ID=${client_id}")
fi
if [ -n "${client_secret:-}" ]; then
chat_vars+=("CHAT_OAUTH_CLIENT_SECRET=${client_secret}")
fi
for var_line in "${chat_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
echo "Config: Chat OAuth vars written to .env"
}
# Auto-generate WOODPECKER_TOKEN by driving the Forgejo OAuth2 login flow. # Auto-generate WOODPECKER_TOKEN by driving the Forgejo OAuth2 login flow.
# Requires _FORGE_ADMIN_PASS (set by setup_forge when admin user was just created). # Requires _FORGE_ADMIN_PASS (set by setup_forge when admin user was just created).
# Called after compose stack is up, before activate_woodpecker_repo. # Called after compose stack is up, before activate_woodpecker_repo.

View file

@ -1,8 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# lib/claude-config.sh — Shared Claude config directory helpers (#641) # lib/claude-config.sh — Shared Claude config directory helpers (#641, #707)
# #
# Provides setup_claude_config_dir() for creating/migrating CLAUDE_CONFIG_DIR # Provides:
# and _env_set_idempotent() for writing env vars to .env files. # setup_claude_dir <dir> [<auto_yes>] — Create/migrate a Claude config directory
# setup_claude_config_dir [auto_yes] — Wrapper for default CLAUDE_CONFIG_DIR
# _env_set_idempotent() — Write env vars to .env files
# #
# Requires: CLAUDE_CONFIG_DIR, CLAUDE_SHARED_DIR (set by lib/env.sh) # Requires: CLAUDE_CONFIG_DIR, CLAUDE_SHARED_DIR (set by lib/env.sh)
@ -21,24 +23,33 @@ _env_set_idempotent() {
fi fi
} }
# Create the shared CLAUDE_CONFIG_DIR, optionally migrating ~/.claude. # Create a Claude config directory, optionally migrating ~/.claude.
# Usage: setup_claude_config_dir [auto_yes] # This is the parameterized helper that handles any CLAUDE_CONFIG_DIR path.
setup_claude_config_dir() { # Usage: setup_claude_dir <config_dir> [auto_yes]
local auto_yes="${1:-false}" # setup_claude_dir /path/to/config [true]
#
# Parameters:
# $1 - Path to the Claude config directory to create
# $2 - Auto-confirm mode (true/false), defaults to false
#
# Returns: 0 on success, 1 on failure
setup_claude_dir() {
local config_dir="$1"
local auto_yes="${2:-false}"
local home_claude="${HOME}/.claude" local home_claude="${HOME}/.claude"
# Create the shared config directory (idempotent) # Create the config directory (idempotent)
install -d -m 0700 -o "$USER" "$CLAUDE_CONFIG_DIR" install -d -m 0700 -o "$USER" "$config_dir"
echo "Claude: ${CLAUDE_CONFIG_DIR} (ready)" echo "Claude: ${config_dir} (ready)"
# If ~/.claude is already a symlink to CLAUDE_CONFIG_DIR, nothing to do # If ~/.claude is already a symlink to config_dir, nothing to do
if [ -L "$home_claude" ]; then if [ -L "$home_claude" ]; then
local link_target local link_target
link_target=$(readlink -f "$home_claude") link_target=$(readlink -f "$home_claude")
local config_real local config_real
config_real=$(readlink -f "$CLAUDE_CONFIG_DIR") config_real=$(readlink -f "$config_dir")
if [ "$link_target" = "$config_real" ]; then if [ "$link_target" = "$config_real" ]; then
echo "Claude: ${home_claude} -> ${CLAUDE_CONFIG_DIR} (symlink OK)" echo "Claude: ${home_claude} -> ${config_dir} (symlink OK)"
return 0 return 0
fi fi
fi fi
@ -54,25 +65,25 @@ setup_claude_config_dir() {
fi fi
fi fi
# Check CLAUDE_CONFIG_DIR contents # Check config_dir contents
if [ -n "$(ls -A "$CLAUDE_CONFIG_DIR" 2>/dev/null)" ]; then if [ -n "$(ls -A "$config_dir" 2>/dev/null)" ]; then
config_nonempty=true config_nonempty=true
fi fi
# Case: both non-empty — abort, operator must reconcile # Case: both non-empty — abort, operator must reconcile
if [ "$home_nonempty" = true ] && [ "$config_nonempty" = true ]; then if [ "$home_nonempty" = true ] && [ "$config_nonempty" = true ]; then
echo "ERROR: both ${home_claude} and ${CLAUDE_CONFIG_DIR} exist and are non-empty" >&2 echo "ERROR: both ${home_claude} and ${config_dir} exist and are non-empty" >&2
echo " Reconcile manually: merge or remove one, then re-run disinto init" >&2 echo " Reconcile manually: merge or remove one, then re-run disinto init" >&2
return 1 return 1
fi fi
# Case: ~/.claude exists and CLAUDE_CONFIG_DIR is empty — offer migration # Case: ~/.claude exists and config_dir is empty — offer migration
if [ "$home_nonempty" = true ] && [ "$config_nonempty" = false ]; then if [ "$home_nonempty" = true ] && [ "$config_nonempty" = false ]; then
local do_migrate=false local do_migrate=false
if [ "$auto_yes" = true ]; then if [ "$auto_yes" = true ]; then
do_migrate=true do_migrate=true
elif [ -t 0 ]; then elif [ -t 0 ]; then
read -rp "Migrate ${home_claude} to ${CLAUDE_CONFIG_DIR}? [Y/n] " confirm read -rp "Migrate ${home_claude} to ${config_dir}? [Y/n] " confirm
if [[ ! "$confirm" =~ ^[Nn] ]]; then if [[ ! "$confirm" =~ ^[Nn] ]]; then
do_migrate=true do_migrate=true
fi fi
@ -83,11 +94,11 @@ setup_claude_config_dir() {
fi fi
if [ "$do_migrate" = true ]; then if [ "$do_migrate" = true ]; then
# Move contents (not the dir itself) to preserve CLAUDE_CONFIG_DIR ownership # Move contents (not the dir itself) to preserve config_dir ownership
cp -a "$home_claude/." "$CLAUDE_CONFIG_DIR/" cp -a "$home_claude/." "$config_dir/"
rm -rf "$home_claude" rm -rf "$home_claude"
ln -sfn "$CLAUDE_CONFIG_DIR" "$home_claude" ln -sfn "$config_dir" "$home_claude"
echo "Claude: migrated ${home_claude} -> ${CLAUDE_CONFIG_DIR}" echo "Claude: migrated ${home_claude} -> ${config_dir}"
return 0 return 0
fi fi
fi fi
@ -97,7 +108,15 @@ setup_claude_config_dir() {
rmdir "$home_claude" 2>/dev/null || true rmdir "$home_claude" 2>/dev/null || true
fi fi
if [ ! -e "$home_claude" ]; then if [ ! -e "$home_claude" ]; then
ln -sfn "$CLAUDE_CONFIG_DIR" "$home_claude" ln -sfn "$config_dir" "$home_claude"
echo "Claude: ${home_claude} -> ${CLAUDE_CONFIG_DIR} (symlink created)" echo "Claude: ${home_claude} -> ${config_dir} (symlink created)"
fi fi
} }
# Create the shared CLAUDE_CONFIG_DIR, optionally migrating ~/.claude.
# Wrapper around setup_claude_dir for the default config directory.
# Usage: setup_claude_config_dir [auto_yes]
setup_claude_config_dir() {
local auto_yes="${1:-false}"
setup_claude_dir "$CLAUDE_CONFIG_DIR" "$auto_yes"
}

View file

@ -473,9 +473,10 @@ services:
- 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) # Chat container — Claude chat UI backend (#705, #707)
# Internal service only; edge proxy routes to chat:8080 # Internal service only; edge proxy routes to chat:8080
# Sandbox hardened per #706 — no docker.sock, read-only rootfs, minimal caps # Sandbox hardened per #706 — no docker.sock, read-only rootfs, minimal caps
# Separate identity mount (#707) to avoid OAuth refresh races with factory agents
chat: chat:
build: build:
context: ./docker/chat context: ./docker/chat
@ -495,11 +496,19 @@ services:
volumes: volumes:
# Mount claude binary from host (same as agents) # Mount claude binary from host (same as agents)
- CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro
# Throwaway named volume for chat config (isolated from host ~/.claude) # Separate Claude identity mount for chat — isolated from factory agents (#707)
- chat-config:/var/chat/config - ${CHAT_CLAUDE_DIR:-${HOME}/.claude-chat}:/home/chat/.claude-chat
environment: environment:
CHAT_HOST: "0.0.0.0" CHAT_HOST: "0.0.0.0"
CHAT_PORT: "8080" 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:-}
DISINTO_CHAT_ALLOWED_USERS: ${DISINTO_CHAT_ALLOWED_USERS:-}
# Point Claude to separate identity directory (#707)
CLAUDE_CONFIG_DIR: /home/chat/.claude-chat/config
CLAUDE_CREDENTIALS_DIR: /home/chat/.claude-chat/config/credentials
networks: networks:
- disinto-net - disinto-net
@ -509,7 +518,6 @@ volumes:
agent-data: agent-data:
project-repos: project-repos:
caddy_data: caddy_data:
chat-config:
networks: networks:
disinto-net: disinto-net: