diff --git a/AGENTS.md b/AGENTS.md index bd2cc43..ff7edf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,10 @@ only covers cloud-specific, non-obvious caveats. Redis degrades gracefully but the local `.env` points at it. - **Backend** (port 8080, base path `/api`): `bash scripts/start-backend.sh` (wraps `./mvnw spring-boot:run`; it strips `SPRING_PROFILES_ACTIVE=prod` for local runs → dev mode). -- **Frontend** (port 3000): `npm run dev` (Vite proxies `/api` → 8080 and `/agent-api` → 8787). +- **Frontend** (port 3000): prefer `npx vite --host 0.0.0.0 --port 3000` (or `npm run dev` + with `server.host` set). Plain `npm run dev` can bind **IPv6-only** (`::1:3000`) in this + VM so `curl http://127.0.0.1:3000` fails even though Vite looks healthy. Vite proxies + `/api` → 8080 and `/agent-api` → 8787. - **DeepSQL Agent API** (port 8787, optional): needed for the sidebar **Agent** tab. Runtime is a customized Nous Hermes Agent; see caveats below for install + `HERMES_WEBUI_ALLOWED_ORIGINS` (upstream env name). @@ -211,16 +214,32 @@ only covers cloud-specific, non-obvious caveats. `agent/install.sh` reads those. After changing LLM env, restart the backend (`scripts/start-backend.sh`); `/api/setup/status` should show `hasLlmConfig: true`. - **Agent tab is optional but required for the in-app Agent chat UI.** The Agent tab - is DeepSQL’s own React (`AgentChatPanel`); it talks to the DeepSQL Agent HTTP API - on `:8787` (a heavily customized [Nous Hermes Agent](https://hermes-agent.nousresearch.com/) - runtime — see [`agent/README.md`](agent/README.md)). Install upstream via + is DeepSQL’s own React (`AgentChatPanel` via `agentClient.js`); it talks to the + DeepSQL Agent HTTP API on `:8787` (a heavily customized + [Nous Hermes Agent](https://hermes-agent.nousresearch.com/) runtime — see + [`agent/README.md`](agent/README.md)). Not a skin on the upstream webui. + Flow: `POST /api/agent/session` (Spring provisions `u-`) → + `/agent-api/api/profile/switch` (sets upstream `hermes_profile` cookie) → + `session/new` / `chat/start` / SSE `chat/stream`. Without the profile switch, + the agent API 404s with "Session not found" (UI surfaces as a boot failure / + early 500). Install upstream via `curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --non-interactive --skip-setup`, symlink `~/.hermes/hermes-agent/.venv` → `venv` (DeepSQL’s `agent/install.sh` expects `.venv`), then `bash agent/install.sh`. Start the agent API/webui with `HERMES_WEBUI_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000` (upstream env var; without it Vite’s Origin header yields **403** “Cross-origin mismatch”). - Listens on `:8787`; Vite proxies `/agent-api` → there. Profile cookie name - `hermes_profile` is an upstream contract — do not rename it in DeepSQL clients. + Listens on `:8787`; Vite proxies `/agent-api` → there. Do not rename the + `hermes_profile` cookie in DeepSQL clients — it is an upstream contract. +- **Local provisioner required for native (non-Compose) runs.** + `AgentBridgeService` POSTs to `AGENT_PROVISIONER_URL` (default Compose: + `http://deepsql-agent:8788/provision`) with `AGENT_PROVISION_SECRET`. In this VM run + `python3 scripts/local-agent-provisioner.py` (needs those two env vars in `.env`). + Without it, Spring logs `agent.provision-secret is unset — skipping…` and the + `u-admin` agent profile is never created/token-refreshed. +- **Spring CORS must allow both loopback hosts.** Set + `CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000` in `.env`. Opening + the UI as `http://127.0.0.1:3000` while only `localhost` is allowlisted yields **403** + on `POST /api/agent/session` (and other cookie-auth APIs). - **Before running backend tests that boot the Spring context** (e.g. `ApiSmokeTest`), stop the running backend first — both use `ddl-auto=update` on the same `dba_agent` DB and can deadlock on an `ALTER TABLE`. Test env vars are documented in `CLAUDE.md` (Testing). diff --git a/scripts/local-agent-provisioner.py b/scripts/local-agent-provisioner.py new file mode 100755 index 0000000..c7e74ce --- /dev/null +++ b/scripts/local-agent-provisioner.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Local DeepSQL Agent profile provisioner (native/dev stand-in for the agent container). + +Production Compose runs an agent-side secret-gated provisioner on :8788 that +AgentBridgeService POSTs to (see agent.provisioner-url). That binary is not in +this OSS checkout. For Cursor Cloud / native local dev, this script provides the +same contract so /api/agent/session can create `u-` agent profiles with +MCP credentials before the Agent tab opens. + +Contract (matches AgentBridgeService.callProvisioner): + POST /provision + Header: X-Provision-Secret: + Body: { "user": "", "token": "", "connectionId": "" } + +Idempotent: creates the profile on first call (cloning default), then refreshes +DEEPSQL_AUTH_TOKEN / DEEPSQL_API_BASE_URL / DEEPSQL_MCP_USER_ID in the profile .env. +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")).expanduser() +REPO_ROOT = Path(os.environ.get("DEEPSQL_REPO_ROOT", Path(__file__).resolve().parents[1])) +API_BASE = os.environ.get("DEEPSQL_API_BASE_URL", "http://localhost:8080/api/") +SECRET = os.environ.get("AGENT_PROVISION_SECRET", "") +HOST = os.environ.get("AGENT_PROVISIONER_HOST", "127.0.0.1") +PORT = int(os.environ.get("AGENT_PROVISIONER_PORT", "8788")) +HERMES_BIN = os.environ.get("HERMES_BIN", str(Path.home() / ".local/bin/hermes")) + + +def profile_for(username: str) -> str: + safe = re.sub(r"[^a-z0-9]+", "-", (username or "").lower()).strip("-") + return f"u-{safe or 'user'}" + + +def ensure_profile(name: str) -> Path: + home = HERMES_HOME / "profiles" / name + if home.exists(): + return home + cmd = [HERMES_BIN, "profile", "create", name, "--clone", "--no-alias", + "--description", f"DeepSQL Agent profile for {name}"] + subprocess.run(cmd, check=True, env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ.get('PATH','')}"}) + return home + + +def write_profile_env(home: Path, *, user: str, token: str) -> None: + env_path = home / ".env" + keys: dict[str, str] = {} + if env_path.exists(): + for line in env_path.read_text().splitlines(): + if not line.strip() or line.strip().startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + keys[k.strip()] = v + # Prefer workspace/.env Azure key if profile has none yet + if not keys.get("AZURE_OPENAI_KEY") and not keys.get("OPENAI_API_KEY"): + ws_env = REPO_ROOT / ".env" + if ws_env.exists(): + for line in ws_env.read_text().splitlines(): + if line.startswith("AZURE_OPENAI_KEY=") or line.startswith("DEEPSQL_CHAT_API_KEY="): + keys["AZURE_OPENAI_KEY"] = line.split("=", 1)[1] + keys["OPENAI_API_KEY"] = keys["AZURE_OPENAI_KEY"] + keys["DEEPSQL_API_BASE_URL"] = API_BASE + keys["DEEPSQL_AUTH_TOKEN"] = token or "" + keys["DEEPSQL_MCP_USER_ID"] = user + keys["DEEPSQL_MCP_PROJECT_ID"] = "deepsql-agent" + env_path.write_text("\n".join(f"{k}={v}" for k, v in keys.items()) + "\n") + os.chmod(env_path, 0o600) + + +def write_profile_mcp(home: Path, *, user: str, token: str) -> None: + import yaml # agent venv / system PyYAML + + cfg_path = home / "config.yaml" + cfg = yaml.safe_load(cfg_path.read_text()) if cfg_path.exists() else {} + cfg = cfg or {} + # Token must live on the MCP subprocess env — the agent runtime does not auto-forward + # the profile .env into mcp_servers.*.env. + cfg.setdefault("mcp_servers", {})["deepsql"] = { + "command": "node", + "args": [str(REPO_ROOT / "mcp" / "deepsql-phase1-server.js")], + "env": { + "DEEPSQL_API_BASE_URL": API_BASE, + "DEEPSQL_MCP_USER_ID": user, + "DEEPSQL_MCP_PROJECT_ID": "deepsql-agent", + "DEEPSQL_AUTH_TOKEN": token or "", + }, + } + cfg.setdefault("skills", {})["external_dirs"] = [str(REPO_ROOT / "agent" / "skills")] + cfg.setdefault("approvals", {})["mode"] = "smart" + cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False)) + soul_src = REPO_ROOT / "agent" / "SOUL.md" + if soul_src.exists(): + (home / "SOUL.md").write_text(soul_src.read_text()) + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + sys.stderr.write(f"[agent-provisioner] {self.address_string()} - {fmt % args}\n") + + def _read_json(self): + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"{}" + return json.loads(raw.decode("utf-8") or "{}") + + def _send(self, code: int, body: dict): + data = json.dumps(body).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path in ("/health", "/"): + return self._send(200, {"ok": True, "service": "deepsql-local-agent-provisioner"}) + return self._send(404, {"error": "not found"}) + + def do_POST(self): + if self.path.rstrip("/") != "/provision": + return self._send(404, {"error": "not found"}) + if not SECRET: + return self._send(500, {"error": "AGENT_PROVISION_SECRET unset"}) + if self.headers.get("X-Provision-Secret") != SECRET: + return self._send(401, {"error": "unauthorized"}) + try: + body = self._read_json() + except Exception: + return self._send(400, {"error": "invalid json"}) + user = str(body.get("user") or "").strip() + token = str(body.get("token") or "") + if not user: + return self._send(400, {"error": "user required"}) + profile = profile_for(user) + try: + home = ensure_profile(profile) + write_profile_mcp(home, user=user, token=token) + write_profile_env(home, user=user, token=token) + except Exception as e: + return self._send(500, {"error": str(e)}) + return self._send(200, {"ok": True, "profile": profile, "home": str(home)}) + + +def main(): + if not SECRET: + print("AGENT_PROVISION_SECRET is required", file=sys.stderr) + sys.exit(1) + # Prefer agent venv PyYAML + venv_site = HERMES_HOME / "hermes-agent" / "venv" / "lib" + if venv_site.exists(): + for p in venv_site.glob("python*/site-packages"): + sys.path.insert(0, str(p)) + httpd = ThreadingHTTPServer((HOST, PORT), Handler) + print(f"[agent-provisioner] listening on http://{HOST}:{PORT}/provision", flush=True) + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/src/lib/api/agentClient.js b/src/lib/api/agentClient.js index 1ef5f6e..9f7fe97 100644 --- a/src/lib/api/agentClient.js +++ b/src/lib/api/agentClient.js @@ -1,10 +1,11 @@ -// Client for the native "Agent" chat tab. +// Client for the native "Agent" chat tab (AgentChatPanel — DeepSQL's own React UI). // // Two hops: // 1. POST /api/agent/session → Spring backend (cookie auth) resolves/provisions // the user's agent profile and returns { profile }. -// 2. /agent-api/* → DeepSQL Agent chat service (Vite-proxied to :8787): -// create a session, start a turn, stream it over SSE. +// 2. /agent-api/* → the DeepSQL Agent HTTP API (Vite-proxied to :8787; +// customized Hermes runtime): profile/switch, session/new, chat/start, then +// chat/stream over SSE. // // SSE event shapes: // token { text } @@ -38,6 +39,20 @@ async function postJson(url, body, _retried = false) { return res.json(); } +/** + * Bind the agent API to this user's profile via the upstream `hermes_profile` cookie. + * + * The agent scopes session visibility to the active profile. Spring returns + * `u-` from /api/agent/session; if we create a session under that + * profile but never switch, subsequent /api/session/yolo and /api/chat/start + * calls 404 with "Session not found" (the Agent tab surfaces this as a boot + * failure / early 500). credentials:"include" sends the Set-Cookie back. + */ +async function switchAgentProfile(profile) { + if (!profile) return; + await postJson(`${AGENT_BASE}/api/profile/switch`, { name: profile }); +} + /** Prepend a one-line connection context so the agent grounds on the active DB * without the user pasting a UUID (the provisioned USER.md isn't injected into * webui sessions). Sent to the agent only — the UI displays the raw message. */ @@ -50,11 +65,24 @@ export function withConnectionContext(message, connectionId, connectionName) { export const agentChatAPI = { /** Resolve/provision the current user's agent profile (via Spring → cookie auth). */ async bootstrap(connectionId) { - return postJson("/api/agent/session", { connectionId }); + const data = await postJson("/api/agent/session", { connectionId }); + // Must happen before any session/new / resume path that hits /agent-api. + try { + await switchAgentProfile(data?.profile); + } catch { + /* older agent / missing profile — newSession may still work on default */ + } + return data; }, /** Create a lean DBA chat session for this profile; returns the session id. */ async newSession(profile) { + // Idempotent re-bind in case bootstrap's switch was skipped or the cookie aged out. + try { + await switchAgentProfile(profile); + } catch { + /* non-fatal */ + } const data = await postJson(`${AGENT_BASE}/api/session/new`, { profile, enabled_toolsets: ["deepsql", "skills"],