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
6 changed files with 377 additions and 70 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

@ -545,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
@ -958,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

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 /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()

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

@ -501,7 +501,12 @@ services:
environment: environment:
CHAT_HOST: "0.0.0.0" CHAT_HOST: "0.0.0.0"
CHAT_PORT: "8080" CHAT_PORT: "8080"
# Point Claude to separate identity directory 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_CONFIG_DIR: /home/chat/.claude-chat/config
CLAUDE_CREDENTIALS_DIR: /home/chat/.claude-chat/config/credentials CLAUDE_CREDENTIALS_DIR: /home/chat/.claude-chat/config/credentials
networks: networks: