-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsetup-agent.sh
More file actions
executable file
·453 lines (422 loc) · 18.1 KB
/
Copy pathsetup-agent.sh
File metadata and controls
executable file
·453 lines (422 loc) · 18.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
#!/usr/bin/env bash
# Install and start the DeepSQL Agent on the *host* for native (non-Compose) development.
#
# 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 127.0.0.1 by default (set HERMES_WEBUI_HOST=0.0.0.0 only if needed)
# - 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
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
ENV_FILE="${DEEPSQL_ENV_FILE:-$ROOT_DIR/.env}"
# Never inherit a nested profile home from a prior Hermes turn
# (e.g. HERMES_HOME=~/.hermes/profiles/u-admin) — that nests clones/config.
if [[ "${HERMES_HOME:-}" == */profiles/* ]]; then
unset HERMES_HOME
fi
HERMES_HOME="${DEEPSQL_HERMES_HOME:-${HERMES_HOME:-$HOME/.hermes}}"
AGENT_DIR="${HERMES_AGENT_DIR:-$HERMES_HOME/hermes-agent}"
WEBUI_DIR="${HERMES_WEBUI_DIR:-$HERMES_HOME/hermes-webui}"
AGENT_REPO="${HERMES_AGENT_REPO:-https://github.com/NousResearch/hermes-agent.git}"
WEBUI_REPO="${HERMES_WEBUI_REPO:-https://github.com/nesquena/hermes-webui.git}"
# Pinned to the same release tags as agent/Dockerfile. A host install and a
# container install must yield the same runtime pair, or a bug reproduces on one
# path and not the other. Bump both files together.
AGENT_REF="${HERMES_AGENT_REF:-v2026.8.18}"
WEBUI_REF="${HERMES_WEBUI_REF:-v0.52.76}"
WEBUI_PORT="${HERMES_WEBUI_PORT:-8787}"
# Default to loopback so a bare self-host install does not expose the Agent API
# on the WAN (nginx /agent-api already gates via auth_request). Override to
# 0.0.0.0 only when something outside this host must reach Hermes directly.
WEBUI_HOST="${HERMES_WEBUI_HOST:-127.0.0.1}"
PID_FILE="${HERMES_HOME}/webui.pid"
LOG_FILE="${HERMES_HOME}/logs/webui.log"
BACKEND_PORT="${DEEPSQL_BACKEND_PORT:-8080}"
FRONTEND_PORT="${DEEPSQL_FRONTEND_PORT:-3000}"
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: required command '$1' is not installed." >&2
exit 1
fi
}
resolve_venv_python() {
# Prefer the canonical `venv` (webui's lookup order); fall back to `.venv`.
if [[ -x "$AGENT_DIR/venv/bin/python" ]]; then
echo "$AGENT_DIR/venv/bin/python"
elif [[ -x "$AGENT_DIR/.venv/bin/python" ]]; then
echo "$AGENT_DIR/.venv/bin/python"
else
return 1
fi
}
ensure_clone() {
local dir="$1" repo="$2" label="$3" ref="$4"
if [[ -d "$dir/.git" ]]; then
# Note: an install that predates pinning keeps whatever ref it already has.
# Re-pinning an existing checkout is deliberately not automatic — deleting a
# user's agent directory to change a version is not this script's call.
echo "✓ $label already present at $dir (ref unchanged; delete the directory to re-pin)"
return 0
fi
echo "→ Cloning $label at $ref into $dir"
mkdir -p "$(dirname "$dir")"
if ! git clone --depth 1 --branch "$ref" "$repo" "$dir"; then
echo "Error: could not clone $label at pinned ref '$ref' from $repo." >&2
echo " If that tag was removed upstream, pick a current one and update" >&2
echo " BOTH this script and agent/Dockerfile — they must stay in step." >&2
return 1
fi
}
ensure_agent_venv() {
if resolve_venv_python >/dev/null; then
return 0
fi
echo "→ Creating Hermes agent venv"
if command -v uv >/dev/null 2>&1; then
( cd "$AGENT_DIR" && UV_NO_CONFIG=1 uv sync )
else
python3 -m venv "$AGENT_DIR/venv"
"$AGENT_DIR/venv/bin/pip" install -U pip
if [[ -f "$AGENT_DIR/pyproject.toml" ]]; then
"$AGENT_DIR/venv/bin/pip" install -e "$AGENT_DIR"
fi
fi
resolve_venv_python >/dev/null || {
echo "Error: could not create a Hermes agent venv under $AGENT_DIR" >&2
exit 1
}
}
# Importable is not the same as usable. This probe additionally asserts that
# CallToolResult still carries `isError`, because that is the field hermes-agent
# actually reads. A presence-only check reports "✓ SDK available" on an SDK 2.x
# install where every tool call fails at runtime — which is exactly how a broken
# agent shipped while setup-agent.sh printed a tick.
MCP_PROBE='
import sys
try:
from tools.mcp_tool import _MCP_AVAILABLE
except Exception:
sys.exit(1)
if not _MCP_AVAILABLE:
sys.exit(1)
try:
from mcp.types import CallToolResult
except Exception:
sys.exit(2)
sys.exit(0 if "isError" in getattr(CallToolResult, "model_fields", {}) else 2)
'
ensure_mcp_sdk() {
local py rc
py="$(resolve_venv_python)"
# `|| rc=$?` rather than `if`: under set -e a bare call would abort the script,
# and rc 2 (incompatible) must be told apart from rc 1 (absent).
rc=0
"$py" -c "$MCP_PROBE" 2>/dev/null || rc=$?
if [[ $rc -eq 0 ]]; then
echo "✓ Python MCP SDK available ($py)"
return 0
fi
if [[ $rc -eq 2 ]]; then
echo "→ Installed MCP SDK is incompatible with hermes-agent (CallToolResult has no"
echo " 'isError' — SDK 2.x renamed it to 'is_error'). Reinstalling a 1.x SDK."
fi
# Read by start_webui: a running webui holds the old SDK in memory and must be
# restarted, or this reinstall has no effect on the agent at all.
MCP_SDK_REINSTALLED=1
echo "→ Installing Python MCP SDK into $py"
# The upper bound is load-bearing, not caution. MCP SDK 2.0.0 renamed
# CallToolResult.isError -> .is_error (mcp_types/_types.py:1480) and split the
# models out into a separate mcp-types package. hermes-agent 0.20.0 still reads
# result.isError (tools/mcp_tool.py:5222), so an unpinned 'mcp>=1.0' silently
# began resolving to 2.0.0 the day it was published, and every DeepSQL tool call
# started failing with
# AttributeError: 'CallToolResult' object has no attribute 'isError'
# Three of those trip the client's circuit breaker, after which the remaining
# tools are refused with "MCP server 'deepsql' is unreachable" — blaming a healthy
# server for a client-side parse failure. In the UI it surfaces only as
# "The agent run ended early."
#
# Raise this ceiling when hermes-agent reads .is_error (or accepts both), not before.
if command -v uv >/dev/null 2>&1; then
UV_NO_CONFIG=1 uv pip install --python "$py" 'mcp>=1.0,<2'
else
"$py" -m pip install 'mcp>=1.0,<2'
fi
rc=0
"$py" -c "$MCP_PROBE" 2>/dev/null || rc=$?
if [[ $rc -eq 2 ]]; then
echo "Error: the installed MCP SDK is still incompatible with hermes-agent" >&2
echo " (CallToolResult has no 'isError'). Every agent tool call would fail" >&2
echo " with AttributeError and the UI would show only 'The agent run ended" >&2
echo " early.' Check that '$py' resolved mcp<2." >&2
exit 1
fi
if [[ $rc -ne 0 ]]; then
echo "Error: MCP SDK still unavailable after install. Agent tools will not load." >&2
exit 1
fi
echo "✓ Python MCP SDK installed and compatible ($py)"
}
wait_for_http() {
local url="$1" label="$2" retries="${3:-60}" delay="${4:-2}"
for ((i=1; i<=retries; i++)); do
if curl -fsS "$url" >/dev/null 2>&1; then
echo "✓ $label is healthy: $url"
return 0
fi
sleep "$delay"
done
echo "Error: timed out waiting for $label at $url" >&2
return 1
}
provision_user_profile() {
# Mint an MCP token for the admin and write ~/.hermes/profiles/u-<user>/
# so dashboard generation + Agent tab can call DeepSQL MCP as that user.
# Replaces the missing compose provisioner (deepsql-agent:8788).
if [[ ! -f "$ENV_FILE" ]]; then
echo "Warning: no $ENV_FILE — skipping per-user profile provisioning." >&2
return 0
fi
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
local email="${DEEPSQL_INITIAL_ADMIN_EMAIL:-}"
local password="${DEEPSQL_INITIAL_ADMIN_PASSWORD:-}"
if [[ -z "$email" || -z "$password" ]]; then
echo "Warning: admin email/password unset — skipping profile provisioning."
echo " After login, re-run this script to write the MCP token into Hermes."
return 0
fi
local cookie jar base login_json me_json username profile token_json token
jar="$(mktemp)"
trap 'rm -f "$jar"' RETURN
base="http://127.0.0.1:${BACKEND_PORT}/api"
if ! wait_for_http "$base/actuator/health" "Backend" 30 2; then
echo "Warning: backend not up — skipping profile provisioning." >&2
return 0
fi
login_json="$(curl -fsS -c "$jar" -H 'Content-Type: application/json' \
-X POST "$base/auth/login" \
-d "{\"email\":\"${email}\",\"password\":\"${password}\"}" || true)"
if [[ "$login_json" != *"\"email\""* && "$login_json" != *"\"username\""* ]]; then
echo "Warning: admin login failed — skipping profile provisioning." >&2
return 0
fi
me_json="$(curl -fsS -b "$jar" "$base/auth/me")"
username="$(printf '%s' "$me_json" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("username") or d.get("name") or "")' 2>/dev/null || true)"
if [[ -z "$username" ]]; then
username="$(printf '%s' "$email" | cut -d@ -f1)"
fi
profile="u-$(printf '%s' "$username" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
token_json="$(curl -fsS -b "$jar" -H 'Content-Type: application/json' \
-X POST "$base/auth/mcp-tokens" \
-d '{"name":"self-host-agent"}')"
token="$(printf '%s' "$token_json" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("token") or "")')"
if [[ -z "$token" ]]; then
echo "Warning: could not mint MCP token — Agent MCP calls will fail until one is configured." >&2
return 0
fi
local profile_home="$HERMES_HOME/profiles/$profile"
mkdir -p "$profile_home"
# Copy root DBA config into the profile, then inject the MCP token.
if [[ -f "$HERMES_HOME/config.yaml" ]]; then
cp "$HERMES_HOME/config.yaml" "$profile_home/config.yaml"
fi
if [[ -f "$HERMES_HOME/SOUL.md" ]]; then
cp "$HERMES_HOME/SOUL.md" "$profile_home/SOUL.md"
fi
local py
py="$(resolve_venv_python)"
HERMES_HOME="$HERMES_HOME" PROFILE="$profile" TOKEN="$token" REPO_ROOT="$ROOT_DIR" \
BACKEND_PORT="$BACKEND_PORT" "$py" - <<'PY'
import os, pathlib, yaml
home = pathlib.Path(os.environ["HERMES_HOME"]) / "profiles" / os.environ["PROFILE"]
cfg_path = home / "config.yaml"
cfg = yaml.safe_load(cfg_path.read_text()) if cfg_path.exists() else {}
cfg = cfg or {}
repo = os.environ["REPO_ROOT"]
port = os.environ["BACKEND_PORT"]
token = os.environ["TOKEN"]
# Token file: written atomically (temp + rename) so the long-lived MCP
# subprocess (mtime-checked re-read, see deepsql-phase1-lib.js readTokenFile)
# never observes a partial write mid-rotation. DEEPSQL_AUTH_TOKEN stays as a
# fallback for any consumer that only reads the env snapshot.
token_file = home / "deepsql.token"
tmp = token_file.with_suffix(f".tmp-{os.getpid()}")
tmp.write_text(token + "\n")
tmp.chmod(0o600)
os.replace(tmp, token_file)
cfg.setdefault("mcp_servers", {})["deepsql"] = {
"command": "node",
"args": [f"{repo}/mcp/deepsql-phase1-server.js"],
"env": {
"DEEPSQL_API_BASE_URL": f"http://localhost:{port}/api/",
"DEEPSQL_TOKEN_FILE": str(token_file),
"DEEPSQL_AUTH_TOKEN": token,
"DEEPSQL_MCP_USER_ID": os.environ["PROFILE"],
"DEEPSQL_MCP_PROJECT_ID": os.environ["PROFILE"],
},
}
cfg.setdefault("skills", {})["external_dirs"] = [f"{repo}/agent/skills"]
cfg.setdefault("approvals", {})["mode"] = "smart"
cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False))
env_path = home / ".env"
env_path.write_text(
f"DEEPSQL_API_BASE_URL=http://localhost:{port}/api/\n"
f"DEEPSQL_TOKEN_FILE={token_file}\n"
f"DEEPSQL_AUTH_TOKEN={token}\n"
f"DEEPSQL_MCP_USER_ID={os.environ['PROFILE']}\n"
f"DEEPSQL_MCP_PROJECT_ID={os.environ['PROFILE']}\n"
)
env_path.chmod(0o600)
print(f" profile {os.environ['PROFILE']} written ({cfg_path})")
PY
echo "✓ Provisioned Hermes profile $profile with a fresh MCP token"
}
start_webui() {
local py
py="$(resolve_venv_python)"
mkdir -p "$(dirname "$LOG_FILE")" "$HERMES_HOME/logs"
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
# A live process holds its interpreter's modules in memory, so it keeps using
# the SDK that was installed when it started. Re-running this script to repair
# a broken SDK otherwise reports "✓ already running" and changes nothing —
# every tool call keeps failing exactly as before, under a full set of ticks.
if [[ "${MCP_SDK_REINSTALLED:-0}" == "1" ]]; then
echo "→ MCP SDK changed on disk; restarting Hermes webui (pid $(cat "$PID_FILE"))"
kill "$(cat "$PID_FILE")" 2>/dev/null || true
for _ in $(seq 1 20); do
kill -0 "$(cat "$PID_FILE")" 2>/dev/null || break
sleep 0.5
done
kill -9 "$(cat "$PID_FILE")" 2>/dev/null || true
rm -f "$PID_FILE"
else
echo "✓ Hermes webui already running (pid $(cat "$PID_FILE"))"
return 0
fi
fi
# Free a stale listener on the port if our pid file is gone.
if lsof -iTCP:"$WEBUI_PORT" -sTCP:LISTEN >/dev/null 2>&1; then
if [[ "${MCP_SDK_REINSTALLED:-0}" == "1" ]]; then
echo "Error: the MCP SDK was reinstalled but something else is listening on" >&2
echo " port $WEBUI_PORT and this script does not own it. Stop it and re-run," >&2
echo " or the agent will keep using the old SDK." >&2
exit 1
fi
echo "→ Port $WEBUI_PORT already in use; assuming an existing webui and skipping start."
return 0
fi
local certifi
certifi="$("$py" -c 'import certifi; print(certifi.where())' 2>/dev/null || true)"
# CORS for browser → nginx → /agent-api (must include the frontend origin:port).
local allowed_origins="${HERMES_WEBUI_ALLOWED_ORIGINS:-http://localhost:${FRONTEND_PORT},http://127.0.0.1:${FRONTEND_PORT}}"
echo "→ Starting Hermes webui on ${WEBUI_HOST}:${WEBUI_PORT}"
# On macOS, prefer a LaunchAgent. Shell-spawned nohup children get SIGKILL'd when
# some IDE/agent terminals tear down the process group — the Agent tab then
# immediately reports "runtime unavailable".
if [[ "$(uname -s)" == "Darwin" ]] && command -v launchctl >/dev/null 2>&1; then
local label="com.deepsql.hermes-webui"
local plist="$HOME/Library/LaunchAgents/${label}.plist"
local uid
uid="$(id -u)"
mkdir -p "$HOME/Library/LaunchAgents"
launchctl bootout "gui/${uid}/${label}" 2>/dev/null || true
pkill -f "${WEBUI_DIR}/server.py" 2>/dev/null || true
cat >"$plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${py}</string>
<string>${WEBUI_DIR}/server.py</string>
</array>
<key>WorkingDirectory</key><string>${WEBUI_DIR}</string>
<key>EnvironmentVariables</key>
<dict>
<key>HERMES_HOME</key><string>${HERMES_HOME}</string>
<key>HERMES_WEBUI_HOST</key><string>${WEBUI_HOST}</string>
<key>HERMES_WEBUI_PORT</key><string>${WEBUI_PORT}</string>
<key>HERMES_WEBUI_ALLOWED_ORIGINS</key><string>${allowed_origins}</string>
<key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
$(if [[ -n "$certifi" ]]; then
printf ' <key>SSL_CERT_FILE</key><string>%s</string>\n' "$certifi"
printf ' <key>REQUESTS_CA_BUNDLE</key><string>%s</string>\n' "$certifi"
printf ' <key>CURL_CA_BUNDLE</key><string>%s</string>\n' "$certifi"
fi)
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>${HERMES_HOME}/logs/webui.launchd.out.log</string>
<key>StandardErrorPath</key><string>${HERMES_HOME}/logs/webui.launchd.err.log</string>
</dict>
</plist>
PLIST
launchctl bootstrap "gui/${uid}" "$plist" 2>/dev/null \
|| launchctl load -w "$plist"
wait_for_http "http://127.0.0.1:${WEBUI_PORT}/api/mcp/servers" "Hermes webui" 30 1
return 0
fi
# Linux / non-launchd: detach from this script's process group.
rm -f "$PID_FILE"
(
cd "$WEBUI_DIR" || exit 1
export HERMES_HOME
export HERMES_WEBUI_HOST="$WEBUI_HOST"
export HERMES_WEBUI_PORT="$WEBUI_PORT"
export HERMES_WEBUI_ALLOWED_ORIGINS="$allowed_origins"
unset HERMES_WEBUI_PASSWORD
if [[ -n "$certifi" ]]; then
export SSL_CERT_FILE="$certifi"
export REQUESTS_CA_BUNDLE="$certifi"
export CURL_CA_BUNDLE="$certifi"
fi
if command -v setsid >/dev/null 2>&1; then
setsid nohup "$py" server.py >>"$LOG_FILE" 2>&1 </dev/null &
else
nohup "$py" server.py >>"$LOG_FILE" 2>&1 </dev/null &
fi
echo $! >"$PID_FILE"
disown $! 2>/dev/null || true
)
wait_for_http "http://127.0.0.1:${WEBUI_PORT}/api/mcp/servers" "Hermes webui" 30 1
}
# ── main ────────────────────────────────────────────────────────────────────
require_command git
require_command curl
require_command node
require_command python3
mkdir -p "$HERMES_HOME"
ensure_clone "$AGENT_DIR" "$AGENT_REPO" "hermes-agent" "$AGENT_REF"
ensure_clone "$WEBUI_DIR" "$WEBUI_REPO" "hermes-webui" "$WEBUI_REF"
ensure_agent_venv
ensure_mcp_sdk
# DBA persona / model / MCP / skills into ~/.hermes
"$ROOT_DIR/agent/install.sh"
# DeepSQL skin on the webui (idempotent)
"$ROOT_DIR/agent/webui/apply-overlay.sh" "$WEBUI_DIR" || true
provision_user_profile
start_webui
echo
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."