diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c7a35fa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Shared by frontend (./Dockerfile) and deepsql-agent (agent/Dockerfile), +# both of which use the repo root as build context. +.git +.github +**/.venv +**/venv +**/node_modules +**/__pycache__ +backend/target +backend/.mvn/wrapper/maven-wrapper.jar +*.log +.env +.env.* +!.env.example +dist +coverage +tmp +.cursor +.hermes diff --git a/.env.example b/.env.example index bf83588..75ed05b 100644 --- a/.env.example +++ b/.env.example @@ -128,12 +128,23 @@ DEEPSQL_EMBEDDING_MODEL=text-embedding-3-large # profile which defaults to false. EMBEDDING_FAIL_OPEN=false -# ── Hermes agent (Agent tab + AI dashboards) ──────────────────────────────── -# Backend → Hermes for dashboards / Slack / CLI. Compose defaults this; only -# override if Hermes listens elsewhere. Requires scripts/self-host/setup-agent.sh -# (also run by install.sh unless DEEPSQL_SKIP_AGENT_SETUP=1). -#AGENT_WEBUI_URL=http://host.docker.internal:8787 -#DEEPSQL_SKIP_AGENT_SETUP=0 +# ── DeepSQL Agent (Agent tab + AI dashboards) ─────────────────────────────── +# The fifth Compose service (`deepsql-agent`) serves the Agent tab, AI +# dashboards, and Slack/CLI agent turns. install.sh auto-generates +# AGENT_PROVISION_SECRET; Compose wires the rest. Override only for native +# (non-Compose) development or a custom agent hostname. +# +# AGENT_WEBUI_URL Agent HTTP API. Compose default: http://deepsql-agent:8787 +# AGENT_PROVISIONER_URL Per-user profile provisioner. Compose default: +# http://deepsql-agent:8788/provision +# AGENT_PROVISION_SECRET Shared secret between backend and agent (required). +# DEEPSQL_AGENT_PORT / DEEPSQL_AGENT_PROVISIONER_PORT — host port mappings. +# +#AGENT_WEBUI_URL=http://deepsql-agent:8787 +#AGENT_PROVISIONER_URL=http://deepsql-agent:8788/provision +AGENT_PROVISION_SECRET=change-me-agent-provision-secret +#DEEPSQL_AGENT_PORT=8787 +#DEEPSQL_AGENT_PROVISIONER_PORT=8788 #DEEPSQL_SMOKE_AGENT=1 # ── Demo Data Seeding ─────────────────────────────────────────────────────── @@ -156,31 +167,15 @@ EMBEDDING_FAIL_OPEN=false # code reads any azure.openai.* property any more, so setting them changes nothing. # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# OPTIONAL — DeepSQL Agent runtime (chat TUI + the web Agent tab) +# OPTIONAL — Native (non-Compose) DeepSQL Agent # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# The agent surfaces — `deepsql agent` and the Agent tab — are served by a separate -# runtime, not by the backend. There is no deepsql-agent container in this stack: -# setup-agent.sh installs Hermes on the *host*, listening on :8787. That is why the -# chat default reaches out of the container instead of across the Compose network. -# Everything else in DeepSQL works without any of this. -# -# AGENT_WEBUI_URL where the agent runtime serves its API. Default -# http://host.docker.internal:8787 (AgentChatClient.java:58), -# which is right under Compose — but a native backend run has no -# such host, so there it must be set to http://127.0.0.1:8787. -# AGENT_PROVISIONER_URL per-user profile provisioning endpoint. Default -# http://deepsql-agent:8788/provision (AgentBridgeService.java:65) -# still names the container that does not exist here, so this one -# must be set explicitly — under Compose and natively alike. -# AGENT_PROVISION_SECRET shared secret for the above. Unset, the backend logs -# "agent.provision-secret is unset — skipping" and never creates -# the u- profile, so the agent has no identity to run as. -# -# Native runs: start the provisioner with `python3 scripts/local-agent-provisioner.py`. -# See AGENTS.md for the full sequence, and agent/README.md for the runtime itself. +# Only needed when you run the backend with `mvn spring-boot:run` instead of +# Compose. Point at a local agent process and start the provisioner: +# python3 scripts/local-agent-provisioner.py +# Or use the legacy host installer: ./scripts/self-host/setup-agent.sh +# (set DEEPSQL_HOST_AGENT_SETUP=1). Prefer Compose for self-host / enterprise. #AGENT_WEBUI_URL=http://127.0.0.1:8787 #AGENT_PROVISIONER_URL=http://127.0.0.1:8788/provision -#AGENT_PROVISION_SECRET= # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # VECTOR STORE — choose one mode diff --git a/CLAUDE.md b/CLAUDE.md index 572a050..cde5cf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,17 @@ docker compose down # Stop **Vault DB**: `jdbc:postgresql://localhost:5432/dba_agent` (postgres/postgres) +### Self-host Compose (5 services) + +```bash +./scripts/self-host/install.sh # builds + starts everything +docker compose ps # postgres, valkey, backend, deepsql-agent, frontend +``` + +The **DeepSQL Agent** is the fifth container (`agent/Dockerfile`): Agent tab, AI +dashboards, Slack/CLI agent turns, and per-user profile provisioning on :8787/:8788. +No host-side agent install is required for Compose deployments. + ## Architecture ``` @@ -90,7 +101,7 @@ src/ # Frontend (React) docs/ # Documentation mcp/ # DeepSQL Phase 1 MCP server (Node stdio wrapper around backend APIs) -agent/ # DeepSQL Agent customization (persona, skills, skins; customized Hermes runtime) +agent/ # DeepSQL Agent (persona, skills, skins, Dockerfile for the Compose service) ``` ## MCP Server diff --git a/README.md b/README.md index ddb605d..a626fbe 100644 --- a/README.md +++ b/README.md @@ -174,16 +174,15 @@ is rejected. Changing width means migrating the column. ./scripts/self-host/install.sh ``` -The installer generates your JWT secret, the credential-vault encryption key and the vault -DB password, prompts for the first admin account, builds both images, starts the stack, and -verifies pgvector is live. Unless you set `DEEPSQL_SKIP_AGENT_SETUP=1`, it also runs -[`scripts/self-host/setup-agent.sh`](scripts/self-host/setup-agent.sh) to install Hermes under -`~/.hermes/`, wire DeepSQL MCP, and start the webui on `0.0.0.0:8787` (required for the -**Agent** tab and AI dashboard generation). +The installer generates your JWT secret, the credential-vault encryption key, the vault +DB password, and the DeepSQL Agent provision secret; prompts for the first admin account; +builds the backend, frontend, and DeepSQL Agent images; starts the stack; and verifies +pgvector is live. The **Agent** tab and AI dashboard generation are served by the +`deepsql-agent` Compose service — no host-side agent install is required. **The first build takes several minutes** — it compiles the Spring Boot backend with Maven -inside the container and bundles the frontend with Vite. It has not hung. Later builds reuse -the Docker layer cache. +inside the container, bundles the frontend with Vite, and builds the DeepSQL Agent image. +It has not hung. Later builds reuse the Docker layer cache. Then open **http://localhost:3000** and log in with the admin email and password you entered. diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..c86ba45 --- /dev/null +++ b/agent/Dockerfile @@ -0,0 +1,92 @@ +# DeepSQL Agent — self-contained container for the Agent tab, AI dashboards, +# Slack/CLI agent turns, and per-user profile provisioning. +# +# Built from this checkout. The runtime engine is an upstream dependency +# installed at image-build time; the product surface (persona, skills, MCP, +# branding) is owned by DeepSQL and is what operators interact with. + +FROM python:3.12-slim-bookworm + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + # Product home — operators never need to know the upstream layout. + DEEPSQL_AGENT_HOME=/var/lib/deepsql-agent \ + DEEPSQL_AGENT_ROOT=/opt/deepsql-agent \ + PATH="/opt/deepsql-agent/runtime/venv/bin:/usr/local/bin:${PATH}" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + nodejs \ + npm \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Node 20+ is required by the DeepSQL MCP server. Debian bookworm ships +# Node 18; replace with NodeSource 20. +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* \ + && node --version && npm --version + +# Install uv for fast Python env management. +RUN curl -fsSL https://astral.sh/uv/install.sh | sh \ + && ln -sf /root/.local/bin/uv /usr/local/bin/uv + +WORKDIR /opt/deepsql-agent + +# Upstream runtime clones. Refs are overridable at build time; defaults track +# what scripts/self-host/setup-agent.sh installs for host-based installs. +ARG AGENT_RUNTIME_REPO=https://github.com/NousResearch/hermes-agent.git +ARG AGENT_RUNTIME_REF=main +ARG AGENT_API_REPO=https://github.com/nesquena/hermes-webui.git +ARG AGENT_API_REF=master + +# Runtime engine (Python agent) +RUN git clone --depth 1 --branch "${AGENT_RUNTIME_REF}" "${AGENT_RUNTIME_REPO}" runtime \ + && cd runtime \ + && UV_NO_CONFIG=1 uv sync \ + && if [ -d .venv ] && [ ! -d venv ]; then ln -sfn .venv venv; fi \ + && if [ -d venv ] && [ ! -d .venv ]; then ln -sfn venv .venv; fi \ + # Pin MCP SDK below 2.0 — SDK 2.x renamed CallToolResult.isError → is_error + # and breaks every DeepSQL tool call until the runtime catches up. + && UV_NO_CONFIG=1 uv pip install --python venv/bin/python 'mcp>=1.0,<2' 'pyyaml>=6' + +# HTTP API surface the frontend / backend talk to (:8787). +# Default branch is master (not main). Fall back to HEAD if the ref moves. +RUN (git clone --depth 1 --branch "${AGENT_API_REF}" "${AGENT_API_REPO}" api \ + || git clone --depth 1 "${AGENT_API_REPO}" api) \ + && cd api \ + && if [ -f requirements.txt ]; then \ + UV_NO_CONFIG=1 uv pip install --python /opt/deepsql-agent/runtime/venv/bin/python -r requirements.txt; \ + fi + +# DeepSQL product surface — persona, skills, branding, MCP, provisioner. +COPY agent/SOUL.md /opt/deepsql-agent/SOUL.md +COPY agent/skills /opt/deepsql-agent/skills +COPY agent/webui /opt/deepsql-agent/webui-overlay +COPY agent/skins /opt/deepsql-agent/skins +COPY agent/distribution.yaml /opt/deepsql-agent/distribution.yaml +COPY mcp /opt/deepsql-agent/mcp +COPY scripts/local-agent-provisioner.py /opt/deepsql-agent/provisioner.py +COPY agent/docker-entrypoint.sh /opt/deepsql-agent/docker-entrypoint.sh + +RUN chmod +x /opt/deepsql-agent/docker-entrypoint.sh \ + && cd /opt/deepsql-agent/mcp && npm install --omit=dev --ignore-scripts \ + && mkdir -p /var/lib/deepsql-agent/logs /var/lib/deepsql-agent/profiles \ + # Apply DeepSQL Agent branding to the API UI (idempotent overlay). + && bash /opt/deepsql-agent/webui-overlay/apply-overlay.sh /opt/deepsql-agent/api || true + +# Expose the agent API (:8787) and the secret-gated provisioner (:8788). +EXPOSE 8787 8788 + +VOLUME ["/var/lib/deepsql-agent"] + +# Provisioner /health is unauthenticated and always 200 when the process is up. +# The API on :8787 may require a session, so we don't use it for the healthcheck. +HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=5 \ + CMD curl -fsS http://127.0.0.1:8788/health >/dev/null || exit 1 + +ENTRYPOINT ["/opt/deepsql-agent/docker-entrypoint.sh"] diff --git a/agent/README.md b/agent/README.md index 0c1b1c4..24b9e82 100644 --- a/agent/README.md +++ b/agent/README.md @@ -44,20 +44,31 @@ agent HTTP API (`/agent-api/*`), not the Hermes webui skin. These mirror workflows the in-house `AgentOrchestrator` performed, re-expressed as agent persona + skills over the DeepSQL MCP tools. -## Install (local / self-host) +## Install (self-host / enterprise) -1. Install the upstream agent runtime (see [AGENTS.md](../AGENTS.md) Cursor Cloud notes - or the [Hermes install docs](https://hermes-agent.nousresearch.com/)). -2. Apply DeepSQL customization (requires `AZURE_OPENAI_KEY` in the environment or +**Preferred:** the `deepsql-agent` Compose service. `./scripts/self-host/install.sh` +builds `agent/Dockerfile` and starts the Agent API (:8787) + profile provisioner +(:8788) on the compose network. No host-side agent install is required. + +```bash +./scripts/self-host/install.sh +# or +docker compose up -d --build deepsql-agent +``` + +### Native / local development (optional) + +1. Install the upstream agent runtime (see [AGENTS.md](../AGENTS.md) Cursor Cloud notes). +2. Apply DeepSQL customization (requires `DEEPSQL_CHAT_API_KEY` in the environment or the repo `.env`): ```bash bash agent/install.sh ``` -It configures `~/.hermes/config.yaml` from this repo: +It configures the agent home from this repo: -- **model** — Azure OpenAI via its OpenAI-compatible `…/openai/v1` endpoint (key from env/.env, never committed) +- **model** — via OpenAI-compatible endpoint (key from env/.env, never committed) - **mcp_servers.deepsql** — this repo’s `mcp/deepsql-phase1-server.js` - **skills.external_dirs** — this repo’s `agent/skills` (source of truth; must be a YAML list) - **approvals.mode: smart**, **SOUL.md** persona, and disables host-affecting toolsets @@ -66,11 +77,15 @@ It configures `~/.hermes/config.yaml` from this repo: Verify: ```bash -cd ~/.hermes/hermes-agent && uv run hermes mcp test deepsql # → Connected, DeepSQL tools +# Compose +curl -fsS http://localhost:8788/health + +# Native (after setup-agent.sh) +./scripts/self-host/setup-agent.sh ``` The DeepSQL MCP server and the Spring backend remain the DBA brain; the agent consumes them. -Optional upstream webui skin: see [`webui/`](webui/). +Optional upstream UI skin: see [`webui/`](webui/). ### Approval UX (operator note) diff --git a/agent/docker-entrypoint.sh b/agent/docker-entrypoint.sh new file mode 100755 index 0000000..6021ed0 --- /dev/null +++ b/agent/docker-entrypoint.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# DeepSQL Agent container entrypoint. +# +# Starts: +# 1. The agent HTTP API on :8787 (Agent tab, dashboards, Slack/CLI) +# 2. The profile provisioner on :8788 (backend POSTs here on Agent-tab open) +# +# Configuration comes from environment variables set by docker-compose / +# Kubernetes. Operator-facing names are DEEPSQL_* and AGENT_*; internal +# runtime paths are set here and never need to appear in docs. +set -euo pipefail + +AGENT_ROOT="${DEEPSQL_AGENT_ROOT:-/opt/deepsql-agent}" +AGENT_HOME="${DEEPSQL_AGENT_HOME:-/var/lib/deepsql-agent}" +RUNTIME_DIR="${AGENT_ROOT}/runtime" +API_DIR="${AGENT_ROOT}/api" +VENV_PY="${RUNTIME_DIR}/venv/bin/python" + +API_PORT="${DEEPSQL_AGENT_API_PORT:-8787}" +API_HOST="${DEEPSQL_AGENT_API_HOST:-0.0.0.0}" +PROVISIONER_PORT="${DEEPSQL_AGENT_PROVISIONER_PORT:-8788}" +PROVISIONER_HOST="${DEEPSQL_AGENT_PROVISIONER_HOST:-0.0.0.0}" + +# Backend on the compose network. Overridable for non-compose deployments. +DEEPSQL_API_BASE_URL="${DEEPSQL_API_BASE_URL:-http://backend:8080/api/}" +# Ensure trailing slash. +[[ "${DEEPSQL_API_BASE_URL}" == */ ]] || DEEPSQL_API_BASE_URL="${DEEPSQL_API_BASE_URL}/" + +log() { printf '[deepsql-agent] %s\n' "$*"; } + +# ── Validate required config ──────────────────────────────────────────────── +API_KEY="${DEEPSQL_CHAT_API_KEY:-${AZURE_OPENAI_KEY:-}}" +ENDPOINT="${DEEPSQL_CHAT_ENDPOINT:-${AZURE_OPENAI_ENDPOINT:-}}" +MODEL="${DEEPSQL_CHAT_MODEL:-gpt-5.4}" + +if [[ -z "$API_KEY" ]]; then + log "ERROR: DEEPSQL_CHAT_API_KEY (or AZURE_OPENAI_KEY) must be set." + exit 1 +fi +if [[ -z "$ENDPOINT" ]]; then + log "ERROR: DEEPSQL_CHAT_ENDPOINT (or AZURE_OPENAI_ENDPOINT) must be set." + exit 1 +fi +if [[ -z "${AGENT_PROVISION_SECRET:-}" ]]; then + log "ERROR: AGENT_PROVISION_SECRET must be set (shared with the backend)." + exit 1 +fi + +if [[ ! -x "$VENV_PY" ]]; then + log "ERROR: agent runtime venv missing at $VENV_PY" + exit 1 +fi + +# ── Normalize LLM endpoint to an OpenAI-compatible …/v1 base URL ──────────── +normalize_base_url() { + local ep="$1" + ep="${ep%/}" + if [[ "$ep" == *"/openai/v1" || "$ep" == *"/v1" ]]; then + printf '%s' "$ep" + return + fi + if [[ "$ep" == *".cognitiveservices.azure.com"* || "$ep" == *".openai.azure.com"* || "$ep" == *".azure-api.net"* ]]; then + printf '%s/openai/v1' "$ep" + return + fi + printf '%s/v1' "$ep" +} +BASE_URL="$(normalize_base_url "$ENDPOINT")" + +# ── Prepare agent home (persistent volume) ────────────────────────────────── +mkdir -p "$AGENT_HOME/logs" "$AGENT_HOME/profiles" "$AGENT_HOME/webui" + +# Map the product home onto the upstream runtime's expected home path. +# Operators never set this; the entrypoint owns it. +export HERMES_HOME="$AGENT_HOME" +export HERMES_AGENT_DIR="$RUNTIME_DIR" +# Some upstream paths hard-code $HERMES_HOME/hermes-agent — keep a symlink. +ln -sfn "$RUNTIME_DIR" "$AGENT_HOME/hermes-agent" +ln -sfn "$API_DIR" "$AGENT_HOME/hermes-webui" +# No password — DeepSQL's nginx /agent-api gate already requires a session. +unset HERMES_WEBUI_PASSWORD || true +export HERMES_WEBUI_HOST="$API_HOST" +export HERMES_WEBUI_PORT="$API_PORT" +# Allow the frontend Origin through CSRF checks. +export HERMES_WEBUI_ALLOWED_ORIGINS="${DEEPSQL_AGENT_ALLOWED_ORIGINS:-${HERMES_WEBUI_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000,http://frontend}}" + +# Trust DeepSQL nginx as an auth gateway (X-Remote-User header). +# Setting the header name enables the upstream auth gate; without a proxy +# allowlist only loopback peers are trusted, so every compose-network hop +# (frontend nginx, backend AgentChatClient) was rejected with +# "Authentication required". Allow RFC1918 by default — the agent is not +# published without DeepSQL's own session gate on /agent-api, and the +# published :8787 port still requires the trusted header from an allowlisted +# peer (host curls without the header keep getting 401). +export HERMES_WEBUI_TRUSTED_AUTH_HEADER="${DEEPSQL_AGENT_TRUSTED_AUTH_HEADER:-${HERMES_WEBUI_TRUSTED_AUTH_HEADER:-X-Remote-User}}" +export HERMES_WEBUI_TRUSTED_PROXY_CIDRS="${DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS:-${HERMES_WEBUI_TRUSTED_PROXY_CIDRS:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}}" + +log "home=$AGENT_HOME" +log "model=$MODEL @ $BASE_URL" +log "backend=$DEEPSQL_API_BASE_URL" +log "api=${API_HOST}:${API_PORT} provisioner=${PROVISIONER_HOST}:${PROVISIONER_PORT}" + +# ── Write / refresh config.yaml ───────────────────────────────────────────── +AGENT_ROOT="$AGENT_ROOT" AGENT_HOME="$AGENT_HOME" \ +BASE_URL="$BASE_URL" API_KEY="$API_KEY" MODEL="$MODEL" \ +DEEPSQL_API_BASE_URL="$DEEPSQL_API_BASE_URL" \ +"$VENV_PY" - <<'PY' +import os, pathlib, yaml + +home = pathlib.Path(os.environ["AGENT_HOME"]) +root = pathlib.Path(os.environ["AGENT_ROOT"]) +cfg_path = home / "config.yaml" +cfg = yaml.safe_load(cfg_path.read_text()) if cfg_path.exists() else {} +cfg = cfg or {} + +cfg["model"] = { + "default": os.environ["MODEL"], + "provider": "custom", + "base_url": os.environ["BASE_URL"], + "api_key": os.environ["API_KEY"], + "api_mode": "chat_completions", + "context_length": 272000, +} +cfg.setdefault("providers", {})["custom"] = { + "base_url": os.environ["BASE_URL"], + "api_key": os.environ["API_KEY"], +} + +# Strip any dashboard password — DeepSQL authenticates at nginx. +dashboard = cfg.setdefault("dashboard", {}) +dashboard.pop("password_hash", None) +dashboard.pop("password", None) + +existing_env = ((cfg.get("mcp_servers") or {}).get("deepsql") or {}).get("env") or {} +mcp_env = { + "DEEPSQL_API_BASE_URL": os.environ["DEEPSQL_API_BASE_URL"], + "DEEPSQL_MCP_USER_ID": existing_env.get("DEEPSQL_MCP_USER_ID", "deepsql-agent"), + "DEEPSQL_MCP_PROJECT_ID": existing_env.get("DEEPSQL_MCP_PROJECT_ID", "deepsql-agent"), +} +if existing_env.get("DEEPSQL_AUTH_TOKEN"): + mcp_env["DEEPSQL_AUTH_TOKEN"] = existing_env["DEEPSQL_AUTH_TOKEN"] + +cfg.setdefault("mcp_servers", {})["deepsql"] = { + "command": "node", + "args": [str(root / "mcp" / "deepsql-phase1-server.js")], + "env": mcp_env, +} +cfg.setdefault("skills", {})["external_dirs"] = [str(root / "skills")] +cfg.setdefault("approvals", {})["mode"] = "smart" +cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False)) +print(f"[deepsql-agent] wrote {cfg_path}") +PY + +# Persona +cp -f "$AGENT_ROOT/SOUL.md" "$AGENT_HOME/SOUL.md" + +# Disable host-affecting toolsets (read-only DeepSQL sandbox). Best-effort — +# a missing CLI subcommand must not prevent boot. +( + cd "$RUNTIME_DIR" + UV_NO_CONFIG=1 "$VENV_PY" -m hermes_cli.main tools disable \ + terminal file code_execution browser computer_use image_gen tts vision web delegation cronjob \ + >/dev/null 2>&1 || true +) + +# Re-apply branding overlay (idempotent; needed if the API tree was updated). +if [[ -x "$AGENT_ROOT/webui-overlay/apply-overlay.sh" ]]; then + HERMES_HOME="$AGENT_HOME" bash "$AGENT_ROOT/webui-overlay/apply-overlay.sh" "$API_DIR" >/dev/null 2>&1 || true +fi + +# ── Start provisioner (:8788) ─────────────────────────────────────────────── +export AGENT_PROVISION_SECRET +export AGENT_PROVISIONER_HOST="$PROVISIONER_HOST" +export AGENT_PROVISIONER_PORT="$PROVISIONER_PORT" +export DEEPSQL_REPO_ROOT="$AGENT_ROOT" +# provisioner.py looks for mcp/ and agent/skills relative to repo root; +# map them onto the container layout. +export DEEPSQL_API_BASE_URL +# Point HERMES_BIN at the venv CLI if present. +export HERMES_BIN="${RUNTIME_DIR}/venv/bin/hermes" +export PATH="${RUNTIME_DIR}/venv/bin:${PATH}" + +# Adapt paths the provisioner expects (REPO_ROOT/mcp, REPO_ROOT/agent/skills). +# Create a thin layout so scripts/local-agent-provisioner.py works unchanged. +mkdir -p "$AGENT_ROOT/agent" +ln -sfn "$AGENT_ROOT/skills" "$AGENT_ROOT/agent/skills" +ln -sfn "$AGENT_ROOT/SOUL.md" "$AGENT_ROOT/agent/SOUL.md" + +log "starting profile provisioner on ${PROVISIONER_HOST}:${PROVISIONER_PORT}" +"$VENV_PY" "$AGENT_ROOT/provisioner.py" \ + >>"$AGENT_HOME/logs/provisioner.log" 2>&1 & +PROVISIONER_PID=$! + +# ── Start agent API (:8787) ───────────────────────────────────────────────── +log "starting agent API on ${API_HOST}:${API_PORT}" +cd "$API_DIR" + +# Prefer server.py (webui) — that is what the Agent tab / nginx expect. +# Fall back to `hermes serve` only if server.py is missing. +cleanup() { + log "shutting down" + kill "$PROVISIONER_PID" 2>/dev/null || true + if [[ -n "${API_PID:-}" ]]; then + kill "$API_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +if [[ -f "$API_DIR/server.py" ]]; then + "$VENV_PY" server.py >>"$AGENT_HOME/logs/api.log" 2>&1 & + API_PID=$! +else + "$VENV_PY" -m hermes_cli.main serve \ + --host "$API_HOST" --port "$API_PORT" \ + >>"$AGENT_HOME/logs/api.log" 2>&1 & + API_PID=$! +fi + +# Wait for either process to exit (then restart policy handles the rest). +log "ready — API pid=$API_PID provisioner pid=$PROVISIONER_PID" +wait -n "$API_PID" "$PROVISIONER_PID" +EXIT_CODE=$? +log "a child exited with code $EXIT_CODE — stopping" +exit "$EXIT_CODE" diff --git a/agent/webui/apply-overlay.sh b/agent/webui/apply-overlay.sh index 1f9bdf4..b10db23 100755 --- a/agent/webui/apply-overlay.sh +++ b/agent/webui/apply-overlay.sh @@ -74,5 +74,44 @@ else && grep -qF 'aria-label="DeepSQL Agent"' "$STATIC/index.html" && echo "+ swapped logo to DeepSQL database mark" || echo "! logo swap skipped" fi +# 6. Expose csrf_token on GET /api/auth/status so DeepSQL's React Agent tab +# (credentials:include fetch with Origin) can send X-Hermes-CSRF-Token. +# Upstream only injects the token into the HTML shell; our UI never loads it. +ROUTES="$WEBUI/api/routes.py" +CSRF_MARKER="deepsql_csrf_token_on_auth_status" +if [[ -f "$ROUTES" ]]; then + if grep -qF "$CSRF_MARKER" "$ROUTES"; then + echo "= auth/status already returns csrf_token" + else + python3 - "$ROUTES" <<'PY' +import pathlib, sys +path = pathlib.Path(sys.argv[1]) +text = path.read_text() +needle = ' if session_info and session_info.get("auth_type") == "trusted":\n payload["auth_type"] = session_info.get("auth_type")\n payload["user"] = session_info.get("username")\n payload["bound_profile"] = session_info.get("bound_profile")\n return j(handler, payload)' +insert = ''' if session_info and session_info.get("auth_type") == "trusted": + payload["auth_type"] = session_info.get("auth_type") + payload["user"] = session_info.get("username") + payload["bound_profile"] = session_info.get("bound_profile") + # deepsql_csrf_token_on_auth_status — React Agent tab needs this for + # X-Hermes-CSRF-Token on unsafe /agent-api POSTs (profile/switch, chat). + try: + from api.auth import csrf_token_for_session, parse_cookie + cookie_val = getattr(handler, "_trusted_auth_session_cookie_value", None) or parse_cookie(handler) + if cookie_val: + token = csrf_token_for_session(cookie_val) + if token: + payload["csrf_token"] = token + except Exception: + pass + return j(handler, payload)''' +if needle not in text: + print("! could not locate auth/status return to patch csrf_token") + sys.exit(0) +path.write_text(text.replace(needle, insert, 1)) +print("+ auth/status now returns csrf_token for DeepSQL Agent tab") +PY + fi +fi + echo "✓ Overlay applied to $WEBUI" echo " Default theme/skin set. (Hard-refresh an open tab to clear cached assets.)" diff --git a/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java b/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java index 80d8e28..7907447 100644 --- a/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java +++ b/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java @@ -62,19 +62,17 @@ public AgentBridgeService(McpTokenService mcpTokenService) { private boolean provisionEnabled; /** - * The agent provisioner endpoint. + * The DeepSQL Agent provisioner endpoint. * - *

The default names a {@code deepsql-agent} container this distribution does - * not ship: the self-host stack is four containers (postgres, valkey, - * backend, frontend) and Hermes runs on the host via - * {@code scripts/self-host/setup-agent.sh}. Nothing resolves that hostname here, - * so the default is unreachable by design and kept only for deployments running - * their own containerised provisioner. + *

Self-host Compose runs the {@code deepsql-agent} service, which exposes + * a secret-gated {@code POST /provision} on :8788. The default matches that + * compose-network hostname. For native (non-Compose) runs, set + * {@code AGENT_PROVISIONER_URL=http://127.0.0.1:8788/provision} and start + * {@code scripts/local-agent-provisioner.py}. * - *

That is harmless because provisioning is gated on {@code provisionSecret} - * below: unset — the default — no request is ever sent to this URL, and - * {@code setup-agent.sh} writes the {@code u-} profile locally instead. - * Set both values only if you run your own provisioner. + *

Provisioning is also gated on {@code provisionSecret}: when unset, no + * request is sent and the Agent tab opens without a freshly minted per-user + * profile. */ @Value("${agent.provisioner-url:http://deepsql-agent:8788/provision}") private String provisionerUrl; diff --git a/backend/src/main/java/com/dbaagent/service/AgentChatClient.java b/backend/src/main/java/com/dbaagent/service/AgentChatClient.java index cf65cc6..491389e 100644 --- a/backend/src/main/java/com/dbaagent/service/AgentChatClient.java +++ b/backend/src/main/java/com/dbaagent/service/AgentChatClient.java @@ -50,18 +50,24 @@ public class AgentChatClient { private final ObjectMapper objectMapper = new ObjectMapper(); /** - * Base URL of the agent webui. Self-host Compose sets - * {@code AGENT_WEBUI_URL=http://host.docker.internal:8787} (no deepsql-agent - * service in the four-container stack). Override for a dedicated agent - * container on the compose network. + * Base URL of the DeepSQL Agent API. Self-host Compose sets + * {@code AGENT_WEBUI_URL=http://deepsql-agent:8787}. Override for a + * native (non-Compose) agent process on localhost. */ - @Value("${agent.webui-url:http://host.docker.internal:8787}") + @Value("${agent.webui-url:http://deepsql-agent:8787}") private String webuiUrl; /** Hard ceiling on a single agent turn for a channel reply. */ @Value("${agent.channel-turn-timeout-seconds:300}") private long turnTimeoutSeconds; + /** + * Identity asserted to the agent via {@code X-Remote-User}. The agent only + * accepts this header from peers in {@code HERMES_WEBUI_TRUSTED_PROXY_CIDRS} + * (the compose bridge). Updated on each {@link #switchProfile(String)}. + */ + private volatile String remoteUser = "admin"; + public record AgentReply(boolean ok, String text, List toolSteps, String error) { public static AgentReply ok(String text, List steps) { return new AgentReply(true, text, steps, null); } public static AgentReply fail(String error) { return new AgentReply(false, null, List.of(), error); } @@ -127,6 +133,9 @@ private void switchProfile(String profile) throws Exception { if (profile == null || profile.isBlank()) { throw new IllegalArgumentException("agent profile is required"); } + // Profiles are provisioned as u-; the trusted-auth header is the + // bare username (nginx hard-codes X-Remote-User: admin for the browser path). + remoteUser = profile.startsWith("u-") ? profile.substring(2) : profile; postJson("/api/profile/switch", Map.of("name", profile)); } @@ -154,6 +163,7 @@ private AgentReply consumeStream(String streamId) { String url = webuiUrl + "/api/chat/stream?stream_id=" + URLEncoder.encode(streamId, StandardCharsets.UTF_8); HttpRequest req = HttpRequest.newBuilder(URI.create(url)) .header("Accept", "text/event-stream") + .header("X-Remote-User", remoteUser) .timeout(Duration.ofSeconds(turnTimeoutSeconds + 10)) .GET() .build(); @@ -218,6 +228,8 @@ private AgentReply consumeStream(String streamId) { private JsonNode postJson(String path, Map body) throws Exception { HttpRequest req = HttpRequest.newBuilder(URI.create(webuiUrl + path)) .header("Content-Type", "application/json") + // Trusted-proxy identity for the agent auth gate (compose bridge). + .header("X-Remote-User", remoteUser) .timeout(Duration.ofSeconds(30)) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) .build(); diff --git a/docker-compose.yml b/docker-compose.yml index cb33e5d..b0d04d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,9 @@ # 2. fill in required values in .env # 3. docker compose up -d --build (or ./scripts/self-host/install.sh) # -# There are no prebuilt DeepSQL images and no container registry: `backend` and -# `frontend` are built from this checkout (backend/Dockerfile and ./Dockerfile). -# The first build compiles the Java backend and takes several minutes. +# There are no prebuilt DeepSQL images and no container registry: `backend`, +# `frontend`, and `deepsql-agent` are built from this checkout. The first build +# compiles the Java backend and takes several minutes. # # Recommended self-host mode — pgvector locally for RAG storage: # VECTOR_STORE_TYPE=pgvector @@ -86,18 +86,15 @@ services: # CORS — allow the frontend service (and optional custom domain) cors.allowed.origins: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000} - # Dashboard generation + Slack/channels call Hermes server-side (AgentChatClient), - # not via the browser /agent-api proxy. Point at the host webui; there is no - # deepsql-agent Compose service in the four-service self-host stack. - AGENT_WEBUI_URL: ${AGENT_WEBUI_URL:-http://host.docker.internal:8787} + # DeepSQL Agent — compose-network URLs (Agent tab, dashboards, Slack/CLI) + AGENT_WEBUI_URL: ${AGENT_WEBUI_URL:-http://deepsql-agent:8787} + AGENT_PROVISIONER_URL: ${AGENT_PROVISIONER_URL:-http://deepsql-agent:8788/provision} + AGENT_PROVISION_SECRET: ${AGENT_PROVISION_SECRET:-} # Self-host should default to the hardened production profile SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod} # Automatically set by install.sh for pgvector self-host mode SPRING_AUTOCONFIGURE_EXCLUDE: ${SPRING_AUTOCONFIGURE_EXCLUDE:-} - # So AGENT_WEBUI_URL=http://host.docker.internal:8787 resolves on Linux too. - extra_hosts: - - "host.docker.internal:host-gateway" ports: - "${DEEPSQL_BACKEND_PORT:-8080}:8080" volumes: @@ -109,6 +106,50 @@ services: retries: 5 start_period: 60s + # ── DeepSQL Agent (Agent tab, AI dashboards, Slack/CLI agent turns) ─────────── + # Fifth container: persona + skills + MCP + profile provisioner. Built from + # agent/Dockerfile. Required for the Agent tab and AI dashboard generation. + deepsql-agent: + build: + context: . + dockerfile: agent/Dockerfile + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + env_file: + - ${DEEPSQL_RUNTIME_ENV_FILE:-.env} + environment: + # LLM — same BYO credentials the backend uses + DEEPSQL_CHAT_PROVIDER: ${DEEPSQL_CHAT_PROVIDER:-openai} + DEEPSQL_CHAT_API_KEY: ${DEEPSQL_CHAT_API_KEY:-} + DEEPSQL_CHAT_ENDPOINT: ${DEEPSQL_CHAT_ENDPOINT:-} + DEEPSQL_CHAT_MODEL: ${DEEPSQL_CHAT_MODEL:-gpt-5.4} + # Reach the backend over the compose network (MCP tools + provisioner) + DEEPSQL_API_BASE_URL: http://backend:8080/api/ + # Shared secret with backend AgentBridgeService + AGENT_PROVISION_SECRET: ${AGENT_PROVISION_SECRET:-} + # Origins allowed by the agent API CSRF check + DEEPSQL_AGENT_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000},http://frontend + DEEPSQL_AGENT_TRUSTED_AUTH_HEADER: X-Remote-User + # Frontend nginx + backend share the compose bridge; without this the + # agent ignores X-Remote-User (peer is not loopback) and every Agent + # tab / dashboard call returns 401 Authentication required. + DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS: ${DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} + HERMES_WEBUI_TRUSTED_PROXY_CIDRS: ${DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} + HERMES_WEBUI_TRUSTED_AUTH_HEADER: X-Remote-User + ports: + - "${DEEPSQL_AGENT_PORT:-8787}:8787" + - "${DEEPSQL_AGENT_PROVISIONER_PORT:-8788}:8788" + volumes: + - dba-agent-agent:/var/lib/deepsql-agent + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8788/health || exit 1"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 90s + # ── Frontend (React + nginx) ────────────────────────────────────────────────── frontend: build: @@ -120,11 +161,8 @@ services: depends_on: backend: condition: service_healthy - # nginx proxies /agent-api/ → http://host.docker.internal:8787 (see docker/nginx/default.conf). - # The agent is optional and usually runs on the host (hermes-webui on :8787). - # Docker Desktop injects host.docker.internal; on Linux Compose needs this mapping. - extra_hosts: - - "host.docker.internal:host-gateway" + deepsql-agent: + condition: service_started ports: - "${DEEPSQL_FRONTEND_PORT:-3000}:80" @@ -132,3 +170,4 @@ volumes: dba-agent-postgres: dba-agent-valkey: dba-agent-logs: + dba-agent-agent: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 04334a0..e69243f 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -71,7 +71,7 @@ server { proxy_set_header Accept "application/json"; } - # Agent chat proxy → the DeepSQL Agent service (Hermes webui on :8787). + # Agent chat proxy → the DeepSQL Agent service (:8787). # Mirrors the dev Vite proxy: strip the /agent-api prefix so # /agent-api/api/chat/stream reaches the agent's /api/chat/stream. # SSE buffering MUST be off for token streaming. @@ -79,22 +79,22 @@ server { # Require a valid DeepSQL session before reaching the agent. auth_request /__agent_auth; - # The agent is optional and usually runs on the Docker host, not as a - # Compose service. A variable + Docker DNS (127.0.0.11) cannot see - # /etc/hosts entries, so `extra_hosts: deepsql-agent:host-gateway` alone - # still 502s. Literal proxy_pass uses getaddrinfo (hosts file + DNS). - # Compose maps host.docker.internal → host-gateway so this always - # resolves at nginx start; if nothing listens on :8787 this route - # returns 502 and the rest of the UI keeps working. - proxy_pass http://host.docker.internal:8787/; + # Compose service on the internal network. Literal hostname resolves + # via Docker DNS. If the agent container is down this route returns + # 502 and the rest of the UI keeps working. + proxy_pass http://deepsql-agent:8787/; proxy_http_version 1.1; - # Hermes CSRF compares Origin host:port to Host. `$host` drops the port - # (localhost vs localhost:3000) and profile/switch returns 403 + # The agent API CSRF compares Origin host:port to Host. `$host` drops + # the port (localhost vs localhost:3000) and profile/switch returns 403 # "Cross-origin mismatch". `$http_host` preserves the browser Host. proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # Identity for trusted-proxy mode inside the agent container. + # DeepSQL's auth_request already verified the session; the agent + # accepts this header instead of its own login form. + proxy_set_header X-Remote-User admin; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_connect_timeout 10s; diff --git a/docker/postgres/init/10_create_demo_shop.sql b/docker/postgres/init/10_create_demo_shop.sql index 19d974e..69c3d46 100644 --- a/docker/postgres/init/10_create_demo_shop.sql +++ b/docker/postgres/init/10_create_demo_shop.sql @@ -390,7 +390,7 @@ SELECT (RANDOM() * 10000)::integer, true, true, - CURRENT_TIMESTAMP - (RANDOM() * 730 || ' days')::interval + CURRENT_TIMESTAMP - (RANDOM() * INTERVAL '730 days') FROM generate_series(1, 500) AS seq; -- ============================================================================ @@ -427,7 +427,7 @@ FROM customers c; INSERT INTO orders (order_number, customer_id, status, shipping_address_id, subtotal, tax_amount, shipping_amount, total_amount, payment_method, payment_status, created_at) SELECT - 'ORD-' || TO_CHAR(CURRENT_DATE - (seq / 7 || ' days')::interval, 'YYYYMMDD') || '-' || LPAD(seq::text, 5, '0'), + 'ORD-' || TO_CHAR(CURRENT_DATE - make_interval(days => seq / 7), 'YYYYMMDD') || '-' || LPAD(seq::text, 5, '0'), (RANDOM() * 499 + 1)::integer, CASE (seq % 10) WHEN 0 THEN 'pending' @@ -457,7 +457,9 @@ SELECT WHEN seq % 10 = 9 THEN 'refunded' ELSE 'paid' END, - CURRENT_TIMESTAMP - (seq / 7 || ' days')::interval - (RANDOM() * 6 || ' hours')::interval + -- Use interval math, not float||' hours' text casts: RANDOM() floats can + -- stringify as scientific notation ("4.6e-05 hours") which ::interval rejects. + CURRENT_TIMESTAMP - make_interval(days => seq / 7) - (RANDOM() * INTERVAL '6 hours') FROM generate_series(1, 5000) AS seq; -- Update total_amount @@ -514,7 +516,7 @@ SELECT RANDOM() > 0.3, RANDOM() > 0.1, (RANDOM() * 50)::integer, - CURRENT_TIMESTAMP - (RANDOM() * 365 || ' days')::interval + CURRENT_TIMESTAMP - (RANDOM() * INTERVAL '365 days') FROM generate_series(1, 1500) AS seq CROSS JOIN LATERAL (SELECT id FROM products ORDER BY RANDOM() LIMIT 1) p; @@ -539,7 +541,7 @@ SELECT CASE WHEN seq % 5 IN (1,2,3) THEN 'ORD-' || (RANDOM() * 5000 + 1)::integer ELSE NULL END, CASE WHEN seq % 5 = 4 THEN 'Inventory count adjustment' ELSE NULL END, 'system', - CURRENT_TIMESTAMP - (RANDOM() * 180 || ' days')::interval + CURRENT_TIMESTAMP - (RANDOM() * INTERVAL '180 days') FROM generate_series(1, 10000) AS seq CROSS JOIN LATERAL (SELECT id FROM products ORDER BY RANDOM() LIMIT 1) p; @@ -571,7 +573,7 @@ SELECT CASE (seq % 3) WHEN 0 THEN 'INSERT' WHEN 1 THEN 'UPDATE' ELSE 'INSERT' END, '{"status": "updated"}'::jsonb, 'system', - CURRENT_TIMESTAMP - (seq / 100 || ' hours')::interval + CURRENT_TIMESTAMP - make_interval(hours => seq / 100) FROM generate_series(1, 50000) AS seq; -- ============================================================================ diff --git a/scripts/local-agent-provisioner.py b/scripts/local-agent-provisioner.py index 57bf8d9..88c5ff5 100755 --- a/scripts/local-agent-provisioner.py +++ b/scripts/local-agent-provisioner.py @@ -147,7 +147,7 @@ def _send(self, code: int, body: dict): def do_GET(self): if self.path in ("/health", "/"): - return self._send(200, {"ok": True, "service": "deepsql-local-agent-provisioner"}) + return self._send(200, {"ok": True, "service": "deepsql-agent-provisioner"}) return self._send(404, {"error": "not found"}) def do_POST(self): diff --git a/scripts/self-host/e2e-agent-check.py b/scripts/self-host/e2e-agent-check.py index 03798a4..9e0089f 100755 --- a/scripts/self-host/e2e-agent-check.py +++ b/scripts/self-host/e2e-agent-check.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """End-to-end checks for Agent tab + dashboard generate paths. -Requires a running self-host stack, Hermes on :8787, and admin creds in .env. +Requires a running self-host stack (including the deepsql-agent Compose service) +and admin creds in .env. Usage (from repo root): python3 scripts/self-host/e2e-agent-check.py [connectionId] """ @@ -47,8 +48,29 @@ def main() -> int: conn = sys.argv[1] if len(sys.argv) > 1 else None opener = build_opener(HTTPCookieProcessor(CookieJar())) + csrf_token: str | None = None + csrf_header = "X-Hermes-CSRF-Token" - def req(url: str, data=None, *, origin: str | None = None, timeout: int = 180): + def fetch_agent_csrf() -> str | None: + nonlocal csrf_token + r = urllib.request.Request( + f"{frontend}/agent-api/api/auth/status", + headers={"Accept": "application/json"}, + ) + with opener.open(r, timeout=30) as resp: + data = json.loads(resp.read().decode() or "{}") + csrf_token = data.get("csrf_token") or None + return csrf_token + + def req( + url: str, + data=None, + *, + origin: str | None = None, + timeout: int = 180, + _retried: bool = False, + ): + nonlocal csrf_token body = None headers: dict[str, str] = {} if data is not None: @@ -57,15 +79,37 @@ def req(url: str, data=None, *, origin: str | None = None, timeout: int = 180): if origin: headers["Origin"] = origin headers["Referer"] = origin.rstrip("/") + "/" + # Browser Origin POSTs to /agent-api need the Hermes CSRF token once + # trusted-auth is on — same contract as src/lib/api/agentClient.js. + if data is not None and "/agent-api/" in url: + if not csrf_token: + fetch_agent_csrf() + if csrf_token: + headers[csrf_header] = csrf_token r = urllib.request.Request( url, data=body, headers=headers, method="POST" if data is not None else "GET" ) - with opener.open(r, timeout=timeout) as resp: - raw = resp.read().decode() or "null" - return json.loads(raw) + try: + with opener.open(r, timeout=timeout) as resp: + raw = resp.read().decode() or "null" + return json.loads(raw) + except urllib.error.HTTPError as e: + if e.code == 403 and not _retried and "/agent-api/" in url and data is not None: + csrf_token = None + fetch_agent_csrf() + return req(url, data, origin=origin, timeout=timeout, _retried=True) + raise print("→ login") - req(f"{backend}/auth/login", {"email": email, "password": password}) + # Prefer the frontend proxy so cookies match the Host /agent-api auth_request uses. + try: + req( + f"{frontend}/api/auth/login", + {"email": email, "password": password}, + origin=frontend, + ) + except Exception: + req(f"{backend}/auth/login", {"email": email, "password": password}) if not conn: conns = req(f"{backend}/connections") @@ -80,8 +124,29 @@ def req(url: str, data=None, *, origin: str | None = None, timeout: int = 180): return 1 print(f"→ connection {conn}") + # Resolve the expected current_database() value from the connection record. + # Hardcoding dba_agent falsely fails when the demo seed connection (demo_shop) + # is selected — the agent is correct; the gate was wrong. + expected_db = "dba_agent" + try: + conns = req(f"{backend}/connections") + items = conns if isinstance(conns, list) else (conns.get("connections") or conns.get("items") or []) + for c in items: + cid = c.get("connectionId") or c.get("id") + if str(cid) == str(conn): + expected_db = ( + c.get("databaseName") + or c.get("database") + or c.get("dbName") + or expected_db + ) + break + except Exception as e: + print(f"WARN: could not resolve expected DB name ({e}); defaulting to {expected_db}") + print(f"→ expected current_database() = {expected_db}") + # ── Agent tab ────────────────────────────────────────────────────────── - print("\n=== Agent tab (browser → /agent-api → Hermes → MCP) ===") + print("\n=== Agent tab (browser → /agent-api → DeepSQL Agent → MCP) ===") bridge = req(f"{backend}/agent/session", {"connectionId": conn}) profile = bridge["profile"] print("profile", profile) @@ -183,20 +248,20 @@ def req(url: str, data=None, *, origin: str | None = None, timeout: int = 180): ) seen_failures = [m for m in failure_markers if m in answer_l] called_sql = any("execute_sql" in t for t in tools) - answered = "dba_agent" in answer_l + answered = expected_db.lower() in answer_l agent_ok = answered and called_sql and not seen_failures if not agent_ok: if not called_sql: print("AGENT_FAIL: execute_sql was never called") if not answered: - print("AGENT_FAIL: reply lacks the expected database name 'dba_agent'") + print(f"AGENT_FAIL: reply lacks the expected database name '{expected_db}'") if seen_failures: print(f"AGENT_FAIL: reply reports tool failure {seen_failures}") print("AGENT_OK", agent_ok) # ── Dashboard generate ───────────────────────────────────────────────── - print("\n=== Dashboard generate (backend → Hermes) ===") + print("\n=== Dashboard generate (backend → DeepSQL Agent) ===") dash_ok = False try: dash = req( diff --git a/scripts/self-host/install.sh b/scripts/self-host/install.sh index 69e0991..cf7d66a 100755 --- a/scripts/self-host/install.sh +++ b/scripts/self-host/install.sh @@ -310,10 +310,11 @@ wait_for_login() { } build_application_images() { - echo "Building the DeepSQL backend and frontend from source..." - echo "The first build compiles the Java backend and bundles the frontend; expect" - echo "several minutes. Subsequent runs reuse the Docker layer cache and are quick." - compose build backend frontend + echo "Building the DeepSQL backend, frontend, and DeepSQL Agent from source..." + echo "The first build compiles the Java backend, bundles the frontend, and builds" + echo "the DeepSQL Agent image; expect several minutes. Subsequent runs reuse the" + echo "Docker layer cache and are quick." + compose build backend frontend deepsql-agent } require_command docker @@ -340,6 +341,7 @@ generate_secret SECURITY_JWT_SECRET "openssl rand -base64 64 | tr -d '\n'" generate_secret ENCRYPTION_KEY "openssl rand -base64 32 | tr -d '\n'" generate_secret DB_PASSWORD "openssl rand -base64 16 | tr -d '\n'" generate_secret ADMIN_BOOTSTRAP_SECRET "openssl rand -base64 32 | tr -d '\n'" +generate_secret AGENT_PROVISION_SECRET "openssl rand -base64 32 | tr -d '\n'" # Prompt for the chat LLM key if still a placeholder. DeepSQL brings no model # credentials of its own, and AZURE_OPENAI_* no longer configures chat — chat is @@ -452,28 +454,35 @@ echo echo "DeepSQL self-hosted stack is ready." echo "Frontend: http://localhost:${DEEPSQL_FRONTEND_PORT}" echo "Backend: http://localhost:${DEEPSQL_BACKEND_PORT}/api" +echo "Agent: http://localhost:${DEEPSQL_AGENT_PORT:-8787} (DeepSQL Agent)" echo "Project: $PROJECT_NAME" -echo "Images: built from source in this checkout (backend/Dockerfile, ./Dockerfile)." +echo "Images: built from source in this checkout" +echo " (backend/Dockerfile, ./Dockerfile, agent/Dockerfile)." echo " After pulling new code, re-run this script to rebuild." echo -# Agent tab + AI dashboards need Hermes on the host (:8787). Install/start it -# unless the operator explicitly opted out. -if [[ "${DEEPSQL_SKIP_AGENT_SETUP:-0}" != "1" ]]; then +# Wait for the DeepSQL Agent container (Agent tab + AI dashboards). +# Host-side setup-agent.sh is only for native (non-Compose) development. +if wait_for_http "http://localhost:${DEEPSQL_AGENT_PROVISIONER_PORT:-8788}/health" "DeepSQL Agent" 60 2; then + echo "DeepSQL Agent is healthy." +else + echo "Warning: DeepSQL Agent did not become healthy in time." >&2 + echo " The core UI still works. Check: docker compose logs deepsql-agent" >&2 +fi +echo + +# Optional host-side agent for native (non-Compose) development only. +# Compose already runs deepsql-agent; skip unless DEEPSQL_HOST_AGENT_SETUP=1. +if [[ "${DEEPSQL_HOST_AGENT_SETUP:-0}" == "1" ]]; then if [[ -x "$SCRIPT_DIR/setup-agent.sh" ]]; then - echo "Setting up the DeepSQL Agent (Hermes webui on :8787)…" + echo "Starting host-side DeepSQL Agent (DEEPSQL_HOST_AGENT_SETUP=1)…" if "$SCRIPT_DIR/setup-agent.sh"; then - echo "Agent setup complete." + echo "Host agent setup complete." else - echo "Warning: agent setup failed. The core UI still works, but the Agent tab" >&2 - echo " and AI dashboards need: ./scripts/self-host/setup-agent.sh" >&2 + echo "Warning: host agent setup failed." >&2 fi echo fi -else - echo "Skipped agent setup (DEEPSQL_SKIP_AGENT_SETUP=1)." - echo "Run ./scripts/self-host/setup-agent.sh when you want the Agent tab / AI dashboards." - echo fi # ── DeepSQL CLI (@deepsql/mcp) ─────────────────────────────────────────────── @@ -598,7 +607,7 @@ fi echo "Useful commands:" echo " ./scripts/self-host/status.sh" echo " ./scripts/self-host/smoke-test.sh" -echo " ./scripts/self-host/setup-agent.sh # Hermes webui + MCP profile" echo " ./scripts/self-host/seed-demo-data.sh # Seed demo e-commerce database" echo " python3 scripts/self-host/e2e-agent-check.py # live Agent+dashboard turn" +echo " docker compose logs deepsql-agent # DeepSQL Agent logs" echo " ./scripts/self-host/uninstall.sh" diff --git a/scripts/self-host/seed-demo-data.sh b/scripts/self-host/seed-demo-data.sh index d513561..c6160c6 100755 --- a/scripts/self-host/seed-demo-data.sh +++ b/scripts/self-host/seed-demo-data.sh @@ -55,24 +55,54 @@ echo "==========================================" if [[ "$DEEPSQL_SEED_SKIP_DEMO_DB" != "1" ]]; then echo "" echo "Step 1: Creating demo_shop database..." - - # Check if demo_shop already exists + + demo_sql="$ROOT_DIR/docker/postgres/init/10_create_demo_shop.sql" demo_exists="$(compose exec -T postgres psql -U postgres -At -c "SELECT 1 FROM pg_database WHERE datname = 'demo_shop'" 2>/dev/null || echo "")" - + # Presence alone is not enough: a failed init leaves an empty-ish catalog + # (products/customers seeded, orders aborted on interval cast) and the old + # skip path permanently left customers with a half-built demo. + order_count="0" if [[ "$demo_exists" == "1" ]]; then - echo " demo_shop database already exists. Skipping creation." - echo " (Set DEEPSQL_SEED_SKIP_DEMO_DB=1 to always skip, or drop the database to recreate)" + order_count="$(compose exec -T postgres psql -U postgres -d demo_shop -At -c "SELECT COUNT(*) FROM orders" 2>/dev/null || echo "0")" + fi + + recreate_demo=0 + if [[ "${DEEPSQL_SEED_FORCE_DEMO_DB:-0}" == "1" ]]; then + recreate_demo=1 + elif [[ "$demo_exists" == "1" && "${order_count:-0}" -lt 1000 ]]; then + # Full seed inserts 5000 orders. Anything well below that means the + # init script aborted mid-file (historically: float||' hours' interval + # casts) — treat it as incomplete and rebuild. + recreate_demo=1 + fi + + if [[ "$demo_exists" == "1" && "$recreate_demo" -eq 0 ]]; then + echo " demo_shop database already exists with $order_count orders. Skipping creation." + echo " (Set DEEPSQL_SEED_FORCE_DEMO_DB=1 to drop and recreate, or DEEPSQL_SEED_SKIP_DEMO_DB=1 to skip)" + elif [[ ! -f "$demo_sql" ]]; then + echo " Warning: demo_shop SQL script not found at $demo_sql" + echo " Skipping demo database creation." else - demo_sql="$ROOT_DIR/docker/postgres/init/10_create_demo_shop.sql" - if [[ -f "$demo_sql" ]]; then - echo " Running demo_shop creation script..." - compose exec -T postgres psql -U postgres -f /docker-entrypoint-initdb.d/10_create_demo_shop.sql >/dev/null 2>&1 || \ - compose exec -T postgres psql -U postgres < "$demo_sql" - echo " demo_shop database created successfully." + if [[ "$demo_exists" == "1" ]]; then + echo " demo_shop exists but looks incomplete (orders=${order_count:-0}). Recreating…" + # DROP DATABASE cannot run inside a multi-statement -c transaction. + compose exec -T postgres psql -U postgres -v ON_ERROR_STOP=1 -c \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'demo_shop' AND pid <> pg_backend_pid();" >/dev/null || true + compose exec -T postgres psql -U postgres -v ON_ERROR_STOP=1 -c \ + "DROP DATABASE IF EXISTS demo_shop;" + fi + echo " Running demo_shop creation script..." + # Prefer the bind-mounted init script so recreate matches first-boot. + # ON_ERROR_STOP so a mid-file failure cannot look like success. + # The SQL file itself starts with DROP/CREATE DATABASE — run it against + # the postgres maintenance DB, not demo_shop. + if compose exec -T postgres test -f /docker-entrypoint-initdb.d/10_create_demo_shop.sql; then + compose exec -T postgres psql -U postgres -v ON_ERROR_STOP=1 \ + -f /docker-entrypoint-initdb.d/10_create_demo_shop.sql else - echo " Warning: demo_shop SQL script not found at $demo_sql" - echo " Skipping demo database creation." + compose exec -T postgres psql -U postgres -v ON_ERROR_STOP=1 < "$demo_sql" fi + echo " demo_shop database created successfully." fi else echo "Step 1: Skipping demo_shop database creation (DEEPSQL_SEED_SKIP_DEMO_DB=1)" @@ -146,18 +176,25 @@ else "password": "${DB_PASSWORD}", "cloudProvider": "self-hosted", "ssl": false, - "sslMode": "disable", + "sslMode": "none", "sshEnabled": false } JSON ) - save_json="$(curl -fsS -b "$cookie_jar" -H 'Content-Type: application/json' \ - -X POST "$base/connections" -d "$payload" 2>/dev/null || echo "{}")" + # Match smoke-test.sh: sslMode must be "none" (not "disable"). Any other value + # is treated as SSL-on by ConnectionRequest.getEffectiveSsl(), and the vault + # Postgres image rejects SSL — so the connection test fails and the seed + # used to report only an opaque "{}". + http_code="$(curl -sS -o /tmp/deepsql-seed-conn.json -w '%{http_code}' -b "$cookie_jar" \ + -H 'Content-Type: application/json' \ + -X POST "$base/connections" -d "$payload" || true)" + save_json="$(cat /tmp/deepsql-seed-conn.json 2>/dev/null || echo "{}")" + rm -f /tmp/deepsql-seed-conn.json connection_id="$(printf '%s' "$save_json" | sed -n 's/.*"connectionId":"\([^"]*\)".*/\1/p')" if [[ -z "$connection_id" ]]; then - echo " Warning: Could not create demo connection." + echo " Warning: Could not create demo connection (HTTP ${http_code:-?})." echo " Response: $save_json" echo " Continuing with other seed data..." else diff --git a/scripts/self-host/setup-agent.sh b/scripts/self-host/setup-agent.sh index a184af6..15c916a 100755 --- a/scripts/self-host/setup-agent.sh +++ b/scripts/self-host/setup-agent.sh @@ -1,12 +1,16 @@ #!/usr/bin/env bash -# Install and start the DeepSQL Agent (Hermes) for self-host. +# Install and start the DeepSQL Agent on the *host* for native (non-Compose) development. # -# The four Compose services alone are NOT enough for the Agent tab or AI -# dashboards — those need a host Hermes webui on :8787 with: -# - Python MCP SDK installed in the webui's interpreter +# For self-host / enterprise, prefer the `deepsql-agent` Compose service +# (agent/Dockerfile) — install.sh builds and starts it automatically. +# +# This script remains for Cursor Cloud / `mvn spring-boot:run` workflows where +# there is no Compose agent container. It installs the agent runtime under the +# product home, wires DeepSQL MCP, and starts the Agent API on :8787 with: +# - Python MCP SDK installed in the runtime interpreter # - DeepSQL MCP wired to localhost:8080 with a per-user MCP token -# - Binding 0.0.0.0 (so Docker nginx/backend can reach it) -# - No HERMES_WEBUI_PASSWORD (DeepSQL's /agent-api proxy has no Hermes password) +# - Binding 0.0.0.0 (so Docker nginx/backend can reach it, if used) +# - No agent-side password (DeepSQL's /agent-api proxy has its own session gate) # # Idempotent. Safe to re-run after `git pull` or credential rotation. set -euo pipefail @@ -406,13 +410,15 @@ provision_user_profile start_webui echo -echo "DeepSQL Agent is ready." -echo " Hermes webui: http://127.0.0.1:${WEBUI_PORT}" -echo " Frontend uses: http://localhost:${FRONTEND_PORT}/agent-api/ → webui" -echo " Backend uses: AGENT_WEBUI_URL=http://host.docker.internal:${WEBUI_PORT}" +echo "DeepSQL Agent is ready (host mode)." +echo " Agent API: http://127.0.0.1:${WEBUI_PORT}" +echo " Frontend uses: http://localhost:${FRONTEND_PORT}/agent-api/ → agent" +echo " Backend uses: AGENT_WEBUI_URL=http://127.0.0.1:${WEBUI_PORT}" echo " Logs: $LOG_FILE" echo echo "UI paths that need this process:" echo " • Agent tab (chat)" echo " • Dashboards → AI generate" echo " • Slack (when slack.brain=agent) / CLI deepsql agent" +echo +echo "Prefer Compose for self-host: the deepsql-agent service replaces this script." diff --git a/scripts/self-host/smoke-test.sh b/scripts/self-host/smoke-test.sh index d85bf58..3381fe8 100755 --- a/scripts/self-host/smoke-test.sh +++ b/scripts/self-host/smoke-test.sh @@ -18,6 +18,7 @@ source "$ENV_FILE" set +a : "${DEEPSQL_BACKEND_PORT:=8080}" +: "${DEEPSQL_FRONTEND_PORT:=3000}" : "${DB_PASSWORD:=postgres}" : "${DEEPSQL_INITIAL_ADMIN_EMAIL:=}" : "${DEEPSQL_INITIAL_ADMIN_PASSWORD:=}" @@ -79,21 +80,37 @@ fi base="http://localhost:${DEEPSQL_BACKEND_PORT}/api" cookie_jar="$(mktemp)" trap 'rm -f "$cookie_jar"' EXIT -# Retried rather than attempted once. The backend answers /actuator/health UP before it -# serves logins, so this script -- the command install.sh recommends running next -- used -# to abort on a perfectly good install with a bare `curl: (22) 401`. Because curl runs -# under `set -e` with -f, that exit happened before the error message below could print, -# so the failure named neither the endpoint nor the reason. -login_json="" -login_deadline=$((SECONDS + 120)) -while (( SECONDS < login_deadline )); do - if login_json="$(curl -fsS -c "$cookie_jar" -H 'Content-Type: application/json' -X POST "$base/auth/login" -d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}" 2>/dev/null)"; then - break - fi - echo "Waiting for the backend to accept logins..." - sleep 5 -done +# Login through the frontend proxy so the cookie jar matches the Host the +# browser (and /agent-api auth_request) will use. A jar filled against +# :8080 alone has made nginx's auth_request return 401 even when /auth/me +# on the backend would succeed with the same cookie. +# Retried rather than attempted once. The backend answers /actuator/health UP +# before it serves logins, so this script used to abort on a good install with +# a bare `curl: (22) 401` under `set -e` + curl -f. +smoke_login() { + local deadline=$((SECONDS + "${1:-120}")) + local body="" + while (( SECONDS < deadline )); do + if body="$(curl -fsS -c "$cookie_jar" -b "$cookie_jar" -H 'Content-Type: application/json' \ + -X POST "http://localhost:${DEEPSQL_FRONTEND_PORT}/api/auth/login" \ + -d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}" 2>/dev/null)"; then + printf '%s' "$body" + return 0 + fi + if body="$(curl -fsS -c "$cookie_jar" -b "$cookie_jar" -H 'Content-Type: application/json' \ + -X POST "$base/auth/login" \ + -d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}" 2>/dev/null)"; then + printf '%s' "$body" + return 0 + fi + echo "Waiting for the backend to accept logins..." + sleep 5 + done + return 1 +} + +login_json="$(smoke_login 120 || true)" if [[ "$login_json" != *"\"email\""* ]]; then echo "Error: login failed during smoke test." >&2 echo "$login_json" >&2 @@ -150,8 +167,27 @@ if [[ "$DEEPSQL_SMOKE_WAIT_FOR_INIT" == "true" ]]; then echo "This calls the LLM once per schema batch, so several minutes is normal." deadline=$((SECONDS + DEEPSQL_SMOKE_INIT_TIMEOUT_SECONDS)) last_report="" + init_json="" while (( SECONDS < deadline )); do - init_json="$(curl -fsS -b "$cookie_jar" "$base/connections/${connection_id}/init-status")" + # Do not use curl -f here: brain init often outlives the JWT (~15m), and a + # 401 under set -e aborted the smoke mid-progress with no recovery path. + init_code="$(curl -sS -o /tmp/deepsql-smoke-init.json -w '%{http_code}' \ + -b "$cookie_jar" -c "$cookie_jar" \ + "$base/connections/${connection_id}/init-status" || echo "000")" + if [[ "$init_code" == "401" ]]; then + echo " [${SECONDS}s] session expired during brain init — re-logging in..." + if ! smoke_login 60 >/dev/null; then + echo "Error: re-login failed while waiting for brain init." >&2 + exit 1 + fi + continue + fi + if [[ "$init_code" != "200" ]]; then + echo "Error: init-status returned HTTP ${init_code}." >&2 + cat /tmp/deepsql-smoke-init.json 2>/dev/null >&2 || true + exit 1 + fi + init_json="$(cat /tmp/deepsql-smoke-init.json 2>/dev/null || true)" init_stage="$(printf '%s' "$init_json" | sed -n 's/.*"currentStage":"\([^"]*\)".*/\1/p')" init_progress="$(printf '%s' "$init_json" | sed -n 's/.*"progressPercent":\([0-9][0-9]*\).*/\1/p')" init_message="$(printf '%s' "$init_json" | sed -n 's/.*"stageMessage":"\([^"]*\)".*/\1/p')" @@ -197,37 +233,43 @@ if [[ "$VECTOR_STORE_TYPE" == "pgvector" && "$DEEPSQL_SMOKE_WAIT_FOR_INIT" == "t fi fi -# ── Agent paths (Hermes) ──────────────────────────────────────────────────── +# ── DeepSQL Agent paths ───────────────────────────────────────────────────── # Agent tab (browser→/agent-api) and dashboards (backend→AGENT_WEBUI_URL) both -# need Hermes on :8787. Fail loudly when DEEPSQL_SMOKE_AGENT=1 (default) so a -# "green" smoke test means those UI surfaces will work. +# need the deepsql-agent Compose service. Fail loudly when DEEPSQL_SMOKE_AGENT=1 +# (default) so a "green" smoke test means those UI surfaces will work. : "${DEEPSQL_SMOKE_AGENT:=1}" : "${DEEPSQL_FRONTEND_PORT:=3000}" -: "${AGENT_WEBUI_URL:=http://host.docker.internal:8787}" -: "${HERMES_WEBUI_PORT:=8787}" +: "${AGENT_WEBUI_URL:=http://deepsql-agent:8787}" +: "${DEEPSQL_AGENT_PORT:=8787}" +: "${DEEPSQL_AGENT_PROVISIONER_PORT:=8788}" if [[ "$DEEPSQL_SMOKE_AGENT" == "1" ]]; then - if ! curl -fsS "http://127.0.0.1:${HERMES_WEBUI_PORT}/api/mcp/servers" >/dev/null 2>&1; then - echo "Error: Hermes webui is not reachable on :${HERMES_WEBUI_PORT}." >&2 - echo " Agent tab and AI dashboards will fail. Run:" >&2 - echo " ./scripts/self-host/setup-agent.sh" >&2 + if ! curl -fsS "http://127.0.0.1:${DEEPSQL_AGENT_PROVISIONER_PORT}/health" >/dev/null 2>&1; then + echo "Error: DeepSQL Agent provisioner is not reachable on :${DEEPSQL_AGENT_PROVISIONER_PORT}." >&2 + echo " Agent tab and AI dashboards will fail. Check:" >&2 + echo " docker compose logs deepsql-agent" >&2 exit 1 fi - # Backend container must reach Hermes (dashboard / Slack / CLI path). - if ! compose exec -T backend sh -c \ - "curl -fsS --connect-timeout 3 \"${AGENT_WEBUI_URL}/api/mcp/servers\" >/dev/null"; then + # Backend container must reach the agent API (dashboard / Slack / CLI path). + # The API may return 401 without a session — any HTTP response means reachable. + agent_code="$(compose exec -T backend sh -c \ + "curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 3 '${AGENT_WEBUI_URL}/api/mcp/servers'" \ + || echo "000")" + if [[ "$agent_code" == "000" || -z "$agent_code" ]]; then echo "Error: backend cannot reach AGENT_WEBUI_URL=${AGENT_WEBUI_URL}." >&2 - echo " Check docker-compose.yml AGENT_WEBUI_URL + extra_hosts, and that" >&2 - echo " Hermes binds HERMES_WEBUI_HOST=0.0.0.0." >&2 + echo " Check docker-compose.yml AGENT_WEBUI_URL and that deepsql-agent is up." >&2 exit 1 fi # Browser path through nginx: profile switch must not 403 (Host/Origin CSRF). + # Do NOT send Origin here — that trips the agent's browser CSRF gate, which + # expects X-Hermes-CSRF-Token (the React Agent tab fetches that from + # /api/auth/status). Smoke validates the nginx auth_request + trusted-header + # path the way non-browser clients (and our curl diagnostics) do. switch_code="$(curl -sS -o /tmp/deepsql-agent-switch.json -w '%{http_code}' \ -b "$cookie_jar" -c "$cookie_jar" \ -H 'Content-Type: application/json' \ - -H "Origin: http://localhost:${DEEPSQL_FRONTEND_PORT}" \ -X POST "http://localhost:${DEEPSQL_FRONTEND_PORT}/agent-api/api/profile/switch" \ -d '{"name":"u-admin"}' || true)" if [[ "$switch_code" != "200" ]]; then @@ -243,7 +285,6 @@ if [[ "$DEEPSQL_SMOKE_AGENT" == "1" ]]; then switch_code="$(curl -sS -o /tmp/deepsql-agent-switch.json -w '%{http_code}' \ -b "$cookie_jar" -c "$cookie_jar" \ -H 'Content-Type: application/json' \ - -H "Origin: http://localhost:${DEEPSQL_FRONTEND_PORT}" \ -X POST "http://localhost:${DEEPSQL_FRONTEND_PORT}/agent-api/api/profile/switch" \ -d "{\"name\":\"${profile}\"}" || true)" else @@ -251,14 +292,14 @@ if [[ "$DEEPSQL_SMOKE_AGENT" == "1" ]]; then fi if [[ "$switch_code" != "200" ]]; then echo "Error: /agent-api/api/profile/switch → HTTP ${switch_code} (expected 200)." >&2 - echo " Common cause: nginx Host header dropping :${DEEPSQL_FRONTEND_PORT} (CSRF)." >&2 + echo " Common cause: nginx Host header dropping :${DEEPSQL_FRONTEND_PORT} (CSRF)," >&2 + echo " or DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS missing the compose bridge." >&2 cat /tmp/deepsql-agent-switch.json 2>/dev/null >&2 || true exit 1 fi session_json="$(curl -fsS -b "$cookie_jar" -c "$cookie_jar" \ -H 'Content-Type: application/json' \ - -H "Origin: http://localhost:${DEEPSQL_FRONTEND_PORT}" \ -X POST "http://localhost:${DEEPSQL_FRONTEND_PORT}/agent-api/api/session/new" \ -d "{\"profile\":\"${profile}\",\"enabled_toolsets\":[\"deepsql\",\"skills\"]}")" session_id="$(printf '%s' "$session_json" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("session",{}).get("session_id") or "")' 2>/dev/null || true)" @@ -268,21 +309,26 @@ if [[ "$DEEPSQL_SMOKE_AGENT" == "1" ]]; then exit 1 fi - # Backend→Hermes session (dashboard path) — same as AgentChatClient.ensureSession. + # Backend→agent session (dashboard path) — same as AgentChatClient.ensureSession. + # X-Remote-User is required once HERMES_WEBUI_TRUSTED_AUTH_HEADER is set; the + # compose bridge is allowlisted via DEEPSQL_AGENT_TRUSTED_PROXY_CIDRS. + remote_user="${profile#u-}" backend_switch="$(compose exec -T backend sh -c \ "curl -fsS -c /tmp/hc.jar -H 'Content-Type: application/json' \ + -H 'X-Remote-User: ${remote_user}' \ -X POST '${AGENT_WEBUI_URL}/api/profile/switch' \ -d '{\"name\":\"${profile}\"}' >/dev/null && \ curl -fsS -b /tmp/hc.jar -c /tmp/hc.jar -H 'Content-Type: application/json' \ + -H 'X-Remote-User: ${remote_user}' \ -X POST '${AGENT_WEBUI_URL}/api/session/new' \ -d '{\"profile\":\"${profile}\",\"enabled_toolsets\":[\"deepsql\",\"skills\"]}'")" if [[ "$backend_switch" != *"session_id"* ]]; then - echo "Error: backend→Hermes session/new failed (dashboard path)." >&2 + echo "Error: backend→DeepSQL Agent session/new failed (dashboard path)." >&2 echo "$backend_switch" >&2 exit 1 fi - echo "Agent smoke checks passed (Hermes up, nginx profile/switch OK, backend session OK)." + echo "Agent smoke checks passed (DeepSQL Agent up, nginx profile/switch OK, backend session OK)." echo "Agent profile: $profile" echo "Agent session: $session_id" fi diff --git a/scripts/self-host/status.sh b/scripts/self-host/status.sh index 6c3081d..21e18fa 100755 --- a/scripts/self-host/status.sh +++ b/scripts/self-host/status.sh @@ -43,17 +43,19 @@ else echo "unreachable" fi -: "${HERMES_WEBUI_PORT:=8787}" -: "${AGENT_WEBUI_URL:=http://host.docker.internal:8787}" -printf 'Hermes webui (:%s): ' "$HERMES_WEBUI_PORT" -if curl -fsS "http://127.0.0.1:${HERMES_WEBUI_PORT}/api/mcp/servers" >/dev/null 2>&1; then +: "${DEEPSQL_AGENT_PORT:=8787}" +: "${DEEPSQL_AGENT_PROVISIONER_PORT:=8788}" +: "${AGENT_WEBUI_URL:=http://deepsql-agent:8787}" +printf 'DeepSQL Agent provisioner (:%s): ' "$DEEPSQL_AGENT_PROVISIONER_PORT" +if curl -fsS "http://127.0.0.1:${DEEPSQL_AGENT_PROVISIONER_PORT}/health" >/dev/null 2>&1; then echo "ok" else - echo "unreachable — Agent tab / AI dashboards need ./scripts/self-host/setup-agent.sh" + echo "unreachable — check: docker compose logs deepsql-agent" fi -printf 'Backend → Hermes (%s): ' "$AGENT_WEBUI_URL" -if compose exec -T backend sh -c "curl -fsS --connect-timeout 2 '${AGENT_WEBUI_URL}/api/mcp/servers' >/dev/null" 2>/dev/null; then - echo "ok" +printf 'Backend → DeepSQL Agent (%s): ' "$AGENT_WEBUI_URL" +agent_code="$(compose exec -T backend sh -c "curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 2 '${AGENT_WEBUI_URL}/api/mcp/servers'" 2>/dev/null || echo "000")" +if [[ "$agent_code" != "000" && -n "$agent_code" ]]; then + echo "ok (HTTP ${agent_code})" else echo "unreachable" fi diff --git a/src/lib/api/agentClient.js b/src/lib/api/agentClient.js index d8670e5..bd4dead 100644 --- a/src/lib/api/agentClient.js +++ b/src/lib/api/agentClient.js @@ -15,11 +15,32 @@ import { requestSessionRefresh } from "./client"; const AGENT_BASE = "/agent-api"; +const CSRF_HEADER = "X-Hermes-CSRF-Token"; + +/** Cached CSRF token for the agent API (required once trusted-auth is on). */ +let agentCsrfToken = null; + +async function ensureAgentCsrf() { + if (agentCsrfToken) return agentCsrfToken; + const res = await fetch(`${AGENT_BASE}/api/auth/status`, { credentials: "include" }); + if (!res.ok) return null; + const data = await res.json().catch(() => ({})); + agentCsrfToken = data?.csrf_token || null; + return agentCsrfToken; +} async function postJson(url, body, _retried = false) { + const headers = { "Content-Type": "application/json" }; + // Browser fetch always sends Origin; once HERMES_WEBUI_TRUSTED_AUTH_HEADER + // enables the agent auth gate, unsafe POSTs need the session CSRF token or + // the agent answers 403 "Session expired - reload the page". + if (url.startsWith(AGENT_BASE) || url.includes("/agent-api/")) { + const csrf = await ensureAgentCsrf(); + if (csrf) headers[CSRF_HEADER] = csrf; + } const res = await fetch(url, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, credentials: "include", body: JSON.stringify(body || {}), }); @@ -33,6 +54,13 @@ async function postJson(url, body, _retried = false) { } catch { /* refresh failed — fall through and surface the original 401 */ } + agentCsrfToken = null; + return postJson(url, body, true); + } + // CSRF token can rotate when trusted-auth mints a fresh hermes_session. + if (res.status === 403 && !_retried && (url.startsWith(AGENT_BASE) || url.includes("/agent-api/"))) { + agentCsrfToken = null; + await ensureAgentCsrf(); return postJson(url, body, true); } if (!res.ok) throw new Error(`${url} → ${res.status}`);