Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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-<user>`) →
`/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).
Expand Down
165 changes: 165 additions & 0 deletions scripts/local-agent-provisioner.py
Original file line number Diff line number Diff line change
@@ -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-<user>` agent profiles with
MCP credentials before the Agent tab opens.

Contract (matches AgentBridgeService.callProvisioner):
POST /provision
Header: X-Provision-Secret: <AGENT_PROVISION_SECRET>
Body: { "user": "<username>", "token": "<mcp-or-jwt>", "connectionId": "<uuid>" }

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()
36 changes: 32 additions & 4 deletions src/lib/api/agentClient.js
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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-<username>` 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. */
Expand All @@ -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"],
Expand Down
Loading