Compare commits
3 commits
a15f0763b7
...
94a66e1957
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94a66e1957 | ||
| 34d4136f2e | |||
|
|
30e19f71e2 |
8 changed files with 547 additions and 103 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
133
bin/disinto
133
bin/disinto
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 /ws → reserved for future streaming upgrade (returns 501)
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
relative_path = path[len("/static/"):]
|
if path.startswith("/chat/static/"):
|
||||||
|
relative_path = path[len("/chat/static/"):]
|
||||||
|
else:
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
124
lib/ci-setup.sh
124
lib/ci-setup.sh
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue