Skip to content

Commit 67697c1

Browse files
fix(self-host): harden smoke/e2e gates for long brain init and CSRF
Re-login when JWT expires during brain-init wait, send Hermes CSRF on browser-origin agent-api POSTs, and assert current_database() against the selected connection instead of hardcoding dba_agent. Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent d34c8c5 commit 67697c1

2 files changed

Lines changed: 121 additions & 34 deletions

File tree

scripts/self-host/e2e-agent-check.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,29 @@ def main() -> int:
4848
conn = sys.argv[1] if len(sys.argv) > 1 else None
4949

5050
opener = build_opener(HTTPCookieProcessor(CookieJar()))
51+
csrf_token: str | None = None
52+
csrf_header = "X-Hermes-CSRF-Token"
5153

52-
def req(url: str, data=None, *, origin: str | None = None, timeout: int = 180):
54+
def fetch_agent_csrf() -> str | None:
55+
nonlocal csrf_token
56+
r = urllib.request.Request(
57+
f"{frontend}/agent-api/api/auth/status",
58+
headers={"Accept": "application/json"},
59+
)
60+
with opener.open(r, timeout=30) as resp:
61+
data = json.loads(resp.read().decode() or "{}")
62+
csrf_token = data.get("csrf_token") or None
63+
return csrf_token
64+
65+
def req(
66+
url: str,
67+
data=None,
68+
*,
69+
origin: str | None = None,
70+
timeout: int = 180,
71+
_retried: bool = False,
72+
):
73+
nonlocal csrf_token
5374
body = None
5475
headers: dict[str, str] = {}
5576
if data is not None:
@@ -58,15 +79,37 @@ def req(url: str, data=None, *, origin: str | None = None, timeout: int = 180):
5879
if origin:
5980
headers["Origin"] = origin
6081
headers["Referer"] = origin.rstrip("/") + "/"
82+
# Browser Origin POSTs to /agent-api need the Hermes CSRF token once
83+
# trusted-auth is on — same contract as src/lib/api/agentClient.js.
84+
if data is not None and "/agent-api/" in url:
85+
if not csrf_token:
86+
fetch_agent_csrf()
87+
if csrf_token:
88+
headers[csrf_header] = csrf_token
6189
r = urllib.request.Request(
6290
url, data=body, headers=headers, method="POST" if data is not None else "GET"
6391
)
64-
with opener.open(r, timeout=timeout) as resp:
65-
raw = resp.read().decode() or "null"
66-
return json.loads(raw)
92+
try:
93+
with opener.open(r, timeout=timeout) as resp:
94+
raw = resp.read().decode() or "null"
95+
return json.loads(raw)
96+
except urllib.error.HTTPError as e:
97+
if e.code == 403 and not _retried and "/agent-api/" in url and data is not None:
98+
csrf_token = None
99+
fetch_agent_csrf()
100+
return req(url, data, origin=origin, timeout=timeout, _retried=True)
101+
raise
67102

68103
print("→ login")
69-
req(f"{backend}/auth/login", {"email": email, "password": password})
104+
# Prefer the frontend proxy so cookies match the Host /agent-api auth_request uses.
105+
try:
106+
req(
107+
f"{frontend}/api/auth/login",
108+
{"email": email, "password": password},
109+
origin=frontend,
110+
)
111+
except Exception:
112+
req(f"{backend}/auth/login", {"email": email, "password": password})
70113

71114
if not conn:
72115
conns = req(f"{backend}/connections")
@@ -81,6 +124,27 @@ def req(url: str, data=None, *, origin: str | None = None, timeout: int = 180):
81124
return 1
82125
print(f"→ connection {conn}")
83126

127+
# Resolve the expected current_database() value from the connection record.
128+
# Hardcoding dba_agent falsely fails when the demo seed connection (demo_shop)
129+
# is selected — the agent is correct; the gate was wrong.
130+
expected_db = "dba_agent"
131+
try:
132+
conns = req(f"{backend}/connections")
133+
items = conns if isinstance(conns, list) else (conns.get("connections") or conns.get("items") or [])
134+
for c in items:
135+
cid = c.get("connectionId") or c.get("id")
136+
if str(cid) == str(conn):
137+
expected_db = (
138+
c.get("databaseName")
139+
or c.get("database")
140+
or c.get("dbName")
141+
or expected_db
142+
)
143+
break
144+
except Exception as e:
145+
print(f"WARN: could not resolve expected DB name ({e}); defaulting to {expected_db}")
146+
print(f"→ expected current_database() = {expected_db}")
147+
84148
# ── Agent tab ──────────────────────────────────────────────────────────
85149
print("\n=== Agent tab (browser → /agent-api → DeepSQL Agent → MCP) ===")
86150
bridge = req(f"{backend}/agent/session", {"connectionId": conn})
@@ -184,14 +248,14 @@ def req(url: str, data=None, *, origin: str | None = None, timeout: int = 180):
184248
)
185249
seen_failures = [m for m in failure_markers if m in answer_l]
186250
called_sql = any("execute_sql" in t for t in tools)
187-
answered = "dba_agent" in answer_l
251+
answered = expected_db.lower() in answer_l
188252

189253
agent_ok = answered and called_sql and not seen_failures
190254
if not agent_ok:
191255
if not called_sql:
192256
print("AGENT_FAIL: execute_sql was never called")
193257
if not answered:
194-
print("AGENT_FAIL: reply lacks the expected database name 'dba_agent'")
258+
print(f"AGENT_FAIL: reply lacks the expected database name '{expected_db}'")
195259
if seen_failures:
196260
print(f"AGENT_FAIL: reply reports tool failure {seen_failures}")
197261
print("AGENT_OK", agent_ok)

scripts/self-host/smoke-test.sh

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -80,33 +80,37 @@ fi
8080
base="http://localhost:${DEEPSQL_BACKEND_PORT}/api"
8181
cookie_jar="$(mktemp)"
8282
trap 'rm -f "$cookie_jar"' EXIT
83-
# Retried rather than attempted once. The backend answers /actuator/health UP before it
84-
# serves logins, so this script -- the command install.sh recommends running next -- used
85-
# to abort on a perfectly good install with a bare `curl: (22) 401`. Because curl runs
86-
# under `set -e` with -f, that exit happened before the error message below could print,
87-
# so the failure named neither the endpoint nor the reason.
88-
login_json=""
89-
login_deadline=$((SECONDS + 120))
90-
while (( SECONDS < login_deadline )); do
91-
# Login through the frontend proxy so the cookie jar matches the Host the
92-
# browser (and /agent-api auth_request) will use. A jar filled against
93-
# :8080 alone has made nginx's auth_request return 401 even when /auth/me
94-
# on the backend would succeed with the same cookie.
95-
if login_json="$(curl -fsS -c "$cookie_jar" -H 'Content-Type: application/json' \
96-
-X POST "http://localhost:${DEEPSQL_FRONTEND_PORT}/api/auth/login" \
97-
-d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}" 2>/dev/null)"; then
98-
break
99-
fi
100-
# Fall back to hitting the backend directly (older installs / no frontend).
101-
if login_json="$(curl -fsS -c "$cookie_jar" -H 'Content-Type: application/json' \
102-
-X POST "$base/auth/login" \
103-
-d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}" 2>/dev/null)"; then
104-
break
105-
fi
106-
echo "Waiting for the backend to accept logins..."
107-
sleep 5
108-
done
10983

84+
# Login through the frontend proxy so the cookie jar matches the Host the
85+
# browser (and /agent-api auth_request) will use. A jar filled against
86+
# :8080 alone has made nginx's auth_request return 401 even when /auth/me
87+
# on the backend would succeed with the same cookie.
88+
# Retried rather than attempted once. The backend answers /actuator/health UP
89+
# before it serves logins, so this script used to abort on a good install with
90+
# a bare `curl: (22) 401` under `set -e` + curl -f.
91+
smoke_login() {
92+
local deadline=$((SECONDS + "${1:-120}"))
93+
local body=""
94+
while (( SECONDS < deadline )); do
95+
if body="$(curl -fsS -c "$cookie_jar" -b "$cookie_jar" -H 'Content-Type: application/json' \
96+
-X POST "http://localhost:${DEEPSQL_FRONTEND_PORT}/api/auth/login" \
97+
-d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}" 2>/dev/null)"; then
98+
printf '%s' "$body"
99+
return 0
100+
fi
101+
if body="$(curl -fsS -c "$cookie_jar" -b "$cookie_jar" -H 'Content-Type: application/json' \
102+
-X POST "$base/auth/login" \
103+
-d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}" 2>/dev/null)"; then
104+
printf '%s' "$body"
105+
return 0
106+
fi
107+
echo "Waiting for the backend to accept logins..."
108+
sleep 5
109+
done
110+
return 1
111+
}
112+
113+
login_json="$(smoke_login 120 || true)"
110114
if [[ "$login_json" != *"\"email\""* ]]; then
111115
echo "Error: login failed during smoke test." >&2
112116
echo "$login_json" >&2
@@ -163,8 +167,27 @@ if [[ "$DEEPSQL_SMOKE_WAIT_FOR_INIT" == "true" ]]; then
163167
echo "This calls the LLM once per schema batch, so several minutes is normal."
164168
deadline=$((SECONDS + DEEPSQL_SMOKE_INIT_TIMEOUT_SECONDS))
165169
last_report=""
170+
init_json=""
166171
while (( SECONDS < deadline )); do
167-
init_json="$(curl -fsS -b "$cookie_jar" "$base/connections/${connection_id}/init-status")"
172+
# Do not use curl -f here: brain init often outlives the JWT (~15m), and a
173+
# 401 under set -e aborted the smoke mid-progress with no recovery path.
174+
init_code="$(curl -sS -o /tmp/deepsql-smoke-init.json -w '%{http_code}' \
175+
-b "$cookie_jar" -c "$cookie_jar" \
176+
"$base/connections/${connection_id}/init-status" || echo "000")"
177+
if [[ "$init_code" == "401" ]]; then
178+
echo " [${SECONDS}s] session expired during brain init — re-logging in..."
179+
if ! smoke_login 60 >/dev/null; then
180+
echo "Error: re-login failed while waiting for brain init." >&2
181+
exit 1
182+
fi
183+
continue
184+
fi
185+
if [[ "$init_code" != "200" ]]; then
186+
echo "Error: init-status returned HTTP ${init_code}." >&2
187+
cat /tmp/deepsql-smoke-init.json 2>/dev/null >&2 || true
188+
exit 1
189+
fi
190+
init_json="$(cat /tmp/deepsql-smoke-init.json 2>/dev/null || true)"
168191
init_stage="$(printf '%s' "$init_json" | sed -n 's/.*"currentStage":"\([^"]*\)".*/\1/p')"
169192
init_progress="$(printf '%s' "$init_json" | sed -n 's/.*"progressPercent":\([0-9][0-9]*\).*/\1/p')"
170193
init_message="$(printf '%s' "$init_json" | sed -n 's/.*"stageMessage":"\([^"]*\)".*/\1/p')"

0 commit comments

Comments
 (0)