diff --git a/agent/Dockerfile b/agent/Dockerfile index 1f59bcb..9d2ec17 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -106,21 +106,22 @@ 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 +COPY agent/patches /opt/deepsql-agent/patches 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 + && bash /opt/deepsql-agent/webui-overlay/apply-overlay.sh /opt/deepsql-agent/api || true \ + && python3 /opt/deepsql-agent/patches/stamp-deepsql-mcp-actor.py /opt/deepsql-agent/runtime/tools/mcp_tool.py # 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. +# Agent API liveness on :8787; provisioner /health on :8788 is a separate check. HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=5 \ - CMD curl -fsS http://127.0.0.1:8788/health >/dev/null || exit 1 + CMD curl -fsS http://127.0.0.1:8787/health >/dev/null || exit 1 ENTRYPOINT ["/opt/deepsql-agent/docker-entrypoint.sh"] diff --git a/agent/docker-entrypoint.sh b/agent/docker-entrypoint.sh index 6021ed0..433f45f 100755 --- a/agent/docker-entrypoint.sh +++ b/agent/docker-entrypoint.sh @@ -132,13 +132,15 @@ dashboard.pop("password_hash", None) dashboard.pop("password", None) existing_env = ((cfg.get("mcp_servers") or {}).get("deepsql") or {}).get("env") or {} +profiles_home = f"{os.environ['AGENT_HOME']}/profiles" +provisioner_port = os.environ.get("DEEPSQL_AGENT_PROVISIONER_PORT", "8788") 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_PROFILES_HOME": profiles_home, + "DEEPSQL_ACTOR_PROFILE_FILE": f"{os.environ['AGENT_HOME']}/active-mcp-profile", + "DEEPSQL_PROVISIONER_REFRESH_URL": f"http://127.0.0.1:{provisioner_port}/refresh", "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", diff --git a/agent/patches/stamp-deepsql-mcp-actor.py b/agent/patches/stamp-deepsql-mcp-actor.py new file mode 100644 index 0000000..b2be84b --- /dev/null +++ b/agent/patches/stamp-deepsql-mcp-actor.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Patch Hermes mcp_tool.py to stamp the active profile before each deepsql MCP call. + +The DeepSQL MCP stdio server is process-global; it reads +DEEPSQL_ACTOR_PROFILE_FILE on every tool call to pick the correct +profiles/u-/deepsql.token. Hermes must write that file immediately +before invoking tools on server ``deepsql``. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +MARKER = "deepsql_stamp_active_mcp_profile" +NEEDLE = " gate_error = _trust_gate_check(server_name, tool_name)" +INSERT = f""" gate_error = _trust_gate_check(server_name, tool_name) + # {MARKER}: per-request profile for shared DeepSQL MCP auth + if server_name == "deepsql": + _stamp_deepsql_active_mcp_profile() +""" + + +def patch(path: Path) -> int: + text = path.read_text(encoding="utf-8") + if MARKER in text: + print(f"already patched: {path}") + return 0 + if NEEDLE not in text: + print(f"ERROR: needle not found in {path}", file=sys.stderr) + return 1 + + helper = ''' + +def _stamp_deepsql_active_mcp_profile() -> None: + """Write the active Hermes profile id for the shared DeepSQL MCP subprocess.""" + import os + from pathlib import Path as _Path + + actor_file = (os.environ.get("DEEPSQL_ACTOR_PROFILE_FILE") or "").strip() + if not actor_file: + return + profile = None + try: + from api.profiles import get_active_profile_name as _webui_active + profile = _webui_active() + except Exception: + try: + from hermes_cli.profiles import get_active_profile_name as _cli_active + profile = _cli_active() + except Exception: + profile = None + if not profile or profile == "default": + return + try: + p = _Path(actor_file) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(str(profile).strip() + "\\n", encoding="utf-8") + except Exception: + pass +''' + text = text.replace(NEEDLE, INSERT, 1) + anchor = "def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):" + if anchor not in text: + print(f"ERROR: handler anchor not found in {path}", file=sys.stderr) + return 1 + text = text.replace(anchor, helper + "\n" + anchor, 1) + path.write_text(text, encoding="utf-8") + print(f"patched {path}") + return 0 + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: stamp-deepsql-mcp-actor.py ", file=sys.stderr) + return 2 + return patch(Path(sys.argv[1])) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/main/java/com/dbaagent/config/SecurityConfig.java b/backend/src/main/java/com/dbaagent/config/SecurityConfig.java index 444e94c..74ab79a 100644 --- a/backend/src/main/java/com/dbaagent/config/SecurityConfig.java +++ b/backend/src/main/java/com/dbaagent/config/SecurityConfig.java @@ -105,6 +105,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // Internal test-suite token issuance — validated by pre-shared // INTERNAL_TEST_TOKEN secret, not by user credentials "/auth/internal/token", + "/agent/internal/mcp-token", // Public dashboard sharing — the share token is the authorization; // these endpoints only resolve while the dashboard is published, // and every query is server-enforced read-only. diff --git a/backend/src/main/java/com/dbaagent/controller/AgentBridgeController.java b/backend/src/main/java/com/dbaagent/controller/AgentBridgeController.java index 00bcabc..1f87e54 100644 --- a/backend/src/main/java/com/dbaagent/controller/AgentBridgeController.java +++ b/backend/src/main/java/com/dbaagent/controller/AgentBridgeController.java @@ -14,6 +14,8 @@ import java.util.HashMap; import java.util.Map; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; /** * Bootstrap endpoint for the native "Agent" chat tab. Resolves the current @@ -32,6 +34,42 @@ public class AgentBridgeController { @Value("${security.cookie.name:auth_token}") private String cookieName; + @Value("${agent.provision-secret:}") + private String provisionSecret; + + /** + * Secret-gated token mint for the agent container provisioner ({@code POST /refresh}). + * Not for browser or MCP clients — only the loopback provisioner on :8788. + */ + @PostMapping("/internal/mcp-token") + public ResponseEntity> mintProvisionerToken( + @RequestHeader(value = "X-Provision-Secret", required = false) String providedSecret, + @RequestBody(required = false) Map body) { + if (provisionSecret == null || provisionSecret.isBlank()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(Map.of( + "message", "Agent provisioning is not configured" + )); + } + if (providedSecret == null || !MessageDigest.isEqual( + provisionSecret.getBytes(StandardCharsets.UTF_8), + providedSecret.getBytes(StandardCharsets.UTF_8))) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of( + "message", "Unauthorized" + )); + } + String user = body == null ? null : asString(body.get("user")); + if (user == null || user.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("message", "user is required")); + } + String token = agentBridgeService.mintAgentTokenForProvisioner(user.trim()); + if (token == null || token.isBlank()) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(Map.of( + "message", "Could not mint MCP token for " + user + )); + } + return ResponseEntity.ok(Map.of("token", token, "user", user.trim())); + } + @PostMapping("/session") public ResponseEntity> session( @RequestBody(required = false) Map body, diff --git a/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java b/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java index 28274ca..8af8768 100644 --- a/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java +++ b/backend/src/main/java/com/dbaagent/service/AgentBridgeService.java @@ -176,10 +176,9 @@ public ProfileBootstrap ensureProfile(String username, String authToken, String } agentToken = authToken == null ? "" : authToken; } - // The provisioner also mirrors this token onto every profile - // `deepsql.token` the already-running Hermes MCP subprocess watches. - // Profile switch does not respawn MCP; without that mirror, View as - // Agent chats keep the admin credential and skip policy. + // Per-user credentials live only under profiles/u-/deepsql.token. + // The long-lived MCP subprocess resolves the active profile on each tool + // call (DEEPSQL_ACTOR_PROFILE_FILE) — never a shared root token file. callProvisioner(username, profile, agentToken, connectionId); return new ProfileBootstrap(profile, agentToken); } @@ -374,6 +373,17 @@ private String mintAgentToken(String username) { } } + /** + * Mint endpoint for the agent-side provisioner ({@code POST /refresh} on :8788). + * Returns {@code null} when the user is unknown or minting fails. + */ + public String mintAgentTokenForProvisioner(String username) { + if (username == null || username.isBlank()) { + return null; + } + return mintAgentToken(username); + } + /** Mint a dedicated CHANNEL token (Slack, etc.); null if minting failed. */ private String mintChannelToken(String username) { try { diff --git a/docker-compose.yml b/docker-compose.yml index 3572230..fb9f2db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,7 +167,7 @@ services: volumes: - dba-agent-agent:/var/lib/deepsql-agent healthcheck: - test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8788/health || exit 1"] + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8787/health || exit 1"] interval: 30s timeout: 5s retries: 5 diff --git a/mcp/deepsql-phase1-lib.js b/mcp/deepsql-phase1-lib.js index 6a610ca..f988e4d 100644 --- a/mcp/deepsql-phase1-lib.js +++ b/mcp/deepsql-phase1-lib.js @@ -1,4 +1,6 @@ const fs = require("fs"); +const path = require("path"); +const { AsyncLocalStorage } = require("async_hooks"); const ALLOWED_READ_ONLY_KEYWORDS = new Set([ "SELECT", @@ -1286,18 +1288,23 @@ function clampInteger(value, min, max, fallback) { return Math.min(max, Math.max(min, parsed)); } -// Live bearer-token resolution. The MCP server is a long-lived stdio -// subprocess; inside the DeepSQL Agent container the provisioner rotates the -// token on disk (DEEPSQL_TOKEN_FILE) WITHOUT respawning us. Reading the token -// per request — cached by mtime so we only touch disk when the file actually -// changes — lets a rotated token take effect with no container restart. Editor -// and CLI installs set no token file and keep using the env snapshot -// (config.authToken), so their behaviour is unchanged. +const USER_SESSION_EXPIRED_MESSAGE = + "Your DeepSQL session expired — please sign in again"; + +// Per in-flight MCP tool call — Hermes may interleave users on one stdio server. +const _requestAuthStorage = new AsyncLocalStorage(); + +// Live bearer-token resolution. The MCP server is a long-lived stdio subprocess. +// In the DeepSQL Agent container we resolve credentials per tool call from the +// active Hermes profile's on-disk token (see DEEPSQL_PROFILES_HOME). Editor and +// CLI installs keep the legacy single-user tokenFile / env snapshot path. let _tokenFileCache = null; // { path, mtimeMs, token } +let _actorProfileCache = null; // { path, mtimeMs, profileName } -function readTokenFile(tokenFile) { +function readTokenFile(tokenFile, { bypassCache = false } = {}) { const stat = fs.statSync(tokenFile); if ( + !bypassCache && _tokenFileCache && _tokenFileCache.path === tokenFile && _tokenFileCache.mtimeMs === stat.mtimeMs @@ -1309,26 +1316,149 @@ function readTokenFile(tokenFile) { return token; } -function getAuthToken(config) { +function invalidateTokenCache(tokenFile) { + if (!tokenFile) { + _tokenFileCache = null; + return; + } + if (_tokenFileCache && _tokenFileCache.path === tokenFile) { + _tokenFileCache = null; + } +} + +function invalidateActorProfileCache() { + _actorProfileCache = null; +} + +function isAgentMultiUserMode(config) { + return Boolean(config && config.profilesHome); +} + +function readActorProfileName(config) { + const actorFile = config && config.actorProfileFile; + if (!actorFile) { + return null; + } + try { + const stat = fs.statSync(actorFile); + if ( + _actorProfileCache && + _actorProfileCache.path === actorFile && + _actorProfileCache.mtimeMs === stat.mtimeMs + ) { + return _actorProfileCache.profileName; + } + const profileName = fs.readFileSync(actorFile, "utf8").trim(); + _actorProfileCache = { path: actorFile, mtimeMs: stat.mtimeMs, profileName }; + return profileName; + } catch { + invalidateActorProfileCache(); + return null; + } +} + +function profileTokenPath(profilesHome, profileName) { + return path.join(profilesHome, profileName, "deepsql.token"); +} + +function readProfileUsername(profilesHome, profileName) { + const envPath = path.join(profilesHome, profileName, ".env"); + try { + const text = fs.readFileSync(envPath, "utf8"); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) { + continue; + } + const [key, ...rest] = trimmed.split("="); + if (key.trim() === "DEEPSQL_MCP_USER_ID") { + const value = rest.join("=").trim(); + return value || null; + } + } + } catch { + // fall through + } + return null; +} + +function resolveRequestAuthContext(config) { + if (isAgentMultiUserMode(config)) { + const profileName = readActorProfileName(config); + if (!profileName) { + return { + error: USER_SESSION_EXPIRED_MESSAGE, + errorCode: "MCP_ACTOR_PROFILE_MISSING", + }; + } + const tokenFile = profileTokenPath(config.profilesHome, profileName); + let token = ""; + try { + token = readTokenFile(tokenFile); + } catch { + token = ""; + } + if (!token) { + return { + error: USER_SESSION_EXPIRED_MESSAGE, + errorCode: "MCP_TOKEN_MISSING", + profileName, + tokenFile, + }; + } + const username = + readProfileUsername(config.profilesHome, profileName) || + config.clientAgent || + null; + if (!username) { + return { + error: USER_SESSION_EXPIRED_MESSAGE, + errorCode: "MCP_USER_IDENTITY_MISSING", + profileName, + tokenFile, + }; + } + return { token, username, profileName, tokenFile }; + } + if (config && config.tokenFile) { try { const token = readTokenFile(config.tokenFile); if (token) { - return token; + return { + token, + username: config.clientAgent || null, + tokenFile: config.tokenFile, + }; } } catch { - // Any stat/read error → fall back to the env token snapshot so we never - // hard-fail just because the file is momentarily missing mid-rewrite. + // unreadable token file — fall through to env snapshot for editor installs } } - return (config && config.authToken) || ""; + const envToken = (config && config.authToken) || ""; + if (!envToken) { + return { + error: USER_SESSION_EXPIRED_MESSAGE, + errorCode: "MCP_TOKEN_MISSING", + }; + } + return { token: envToken, username: config.clientAgent || null }; +} + +function getAuthToken(config) { + const active = _requestAuthStorage.getStore(); + if (active && active.token) { + return active.token; + } + const resolved = resolveRequestAuthContext(config); + return resolved.token || ""; } -// Force the next getAuthToken() to re-read from disk regardless of mtime. Used -// by the 401 self-heal path, where the provisioner may have just rewritten the -// token file (same second → identical mtime granularity on some filesystems). -function invalidateTokenCache() { - _tokenFileCache = null; +function mapAuthFailure(status, payloadMessage) { + if (status === 401 || status === 403) { + return USER_SESSION_EXPIRED_MESSAGE; + } + return payloadMessage; } function buildHeaders(config, extraHeaders = {}) { @@ -1337,20 +1467,19 @@ function buildHeaders(config, extraHeaders = {}) { ...extraHeaders, }; - const authToken = getAuthToken(config); + const active = _requestAuthStorage.getStore(); + const authToken = active && active.token ? active.token : getAuthToken(config); if (authToken) { headers.Authorization = `Bearer ${authToken}`; } - // Origin-tracking headers so the backend audit row can distinguish - // CLI/MCP traffic and identify which editor invoked the MCP server. - // `clientAgent` carries the value of DEEPSQL_MCP_USER_ID — editor configs - // set this to "claude-desktop", "cursor-mcp", "codex-mcp", etc. if (config.clientType) { headers["X-DeepSQL-Client-Type"] = config.clientType; } - if (config.clientAgent) { - headers["X-DeepSQL-Client-Agent"] = config.clientAgent; + const clientAgent = + (active && active.username) || config.clientAgent || null; + if (clientAgent) { + headers["X-DeepSQL-Client-Agent"] = clientAgent; } if (config.clientVersion) { headers["X-DeepSQL-Client-Version"] = config.clientVersion; @@ -1398,17 +1527,60 @@ async function performFetch(config, path, { method = "GET", json, headers } = {} } } +async function refreshAuthViaProvisioner(config, auth) { + const url = config && config.provisionerRefreshUrl; + const username = auth && auth.username; + if (!url || !username) { + return false; + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ user: username }), + signal: controller.signal, + }); + if (!response.ok) { + return false; + } + if (auth.tokenFile) { + invalidateTokenCache(auth.tokenFile); + } + invalidateActorProfileCache(); + const tokenAfter = auth.tokenFile + ? readTokenFile(auth.tokenFile, { bypassCache: true }) + : ""; + if (!tokenAfter) { + return false; + } + auth.token = tokenAfter; + return true; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + async function callDeepSqlApi(config, path, options = {}) { + const active = _requestAuthStorage.getStore(); const tokenBefore = getAuthToken(config); let response = await performFetch(config, path, options); - // 401 self-heal: our long-lived subprocess may be holding a token the agent - // provisioner has since rotated on disk. Re-read the token file (bypassing - // the mtime cache) and retry exactly once if it actually changed. We - // deliberately do NOT retry 403 — that's an RBAC denial, not stale creds — - // and only retry when a tokenFile is configured (editor/CLI installs aren't). - if (response.status === 401 && config && config.tokenFile) { - invalidateTokenCache(); + if ( + (response.status === 401 || response.status === 403) && + active && + config && + config.provisionerRefreshUrl + ) { + const refreshed = await refreshAuthViaProvisioner(config, active); + if (refreshed && getAuthToken(config) !== tokenBefore) { + response = await performFetch(config, path, options); + } + } else if (response.status === 401 && config && config.tokenFile) { + invalidateTokenCache(config.tokenFile); const tokenAfter = getAuthToken(config); if (tokenAfter && tokenAfter !== tokenBefore) { response = await performFetch(config, path, options); @@ -1426,10 +1598,11 @@ async function callDeepSqlApi(config, path, options = {}) { } if (!response.ok) { - const message = + const rawMessage = (payload && typeof payload === "object" && payload.message) || response.statusText || "DeepSQL API request failed"; + const message = mapAuthFailure(response.status, rawMessage); throw new DeepSqlApiError(message, response.status, payload); } @@ -2189,6 +2362,14 @@ function attachCallerCapabilities(payload, caps) { } async function handleToolCall(config, name, args = {}) { + const auth = resolveRequestAuthContext(config); + if (auth.error) { + return buildToolError(auth.error, { errorCode: auth.errorCode }); + } + return _requestAuthStorage.run(auth, () => _handleToolCallScoped(config, name, args)); +} + +async function _handleToolCallScoped(config, name, args = {}) { switch (name) { case "list_connections": { const payload = await fetchConnectionsCached(config); @@ -2809,21 +2990,22 @@ function createConfigFromEnv(env = process.env) { // best-effort } + const profilesHome = env.DEEPSQL_PROFILES_HOME || null; + const actorProfileFile = env.DEEPSQL_ACTOR_PROFILE_FILE || null; + const provisionerRefreshUrl = env.DEEPSQL_PROVISIONER_REFRESH_URL || null; + return { baseUrl, - authToken: env.DEEPSQL_AUTH_TOKEN || "", - // Optional path to a file holding just the bearer token. When set (the - // agent-container case), getAuthToken reads it live per request so a - // provisioner-rotated token takes effect without respawning this process. - tokenFile: env.DEEPSQL_TOKEN_FILE || null, + authToken: profilesHome ? "" : (env.DEEPSQL_AUTH_TOKEN || ""), + tokenFile: profilesHome ? null : (env.DEEPSQL_TOKEN_FILE || null), + profilesHome, + actorProfileFile, + provisionerRefreshUrl, timeoutMs: clampInteger(env.DEEPSQL_MCP_TIMEOUT_MS, 1000, 600000, 120000), defaultUserId: env.DEEPSQL_MCP_USER_ID || "mcp-phase1", defaultProjectId: env.DEEPSQL_MCP_PROJECT_ID || "mcp-phase1", - // Origin metadata for the backend audit row. The MCP server always - // identifies as `mcp`; the agent name comes from DEEPSQL_MCP_USER_ID - // which editor configs set to claude-desktop / cursor-mcp / codex-mcp. clientType: "mcp", - clientAgent: env.DEEPSQL_MCP_USER_ID || null, + clientAgent: profilesHome ? null : (env.DEEPSQL_MCP_USER_ID || null), clientVersion, }; } @@ -2843,6 +3025,9 @@ module.exports = { getAuthToken, handleToolCall, invalidateTokenCache, + invalidateActorProfileCache, + resolveRequestAuthContext, + USER_SESSION_EXPIRED_MESSAGE, normalizeSqlForInspection, resolveApiUrl, splitStatements, diff --git a/mcp/deepsql-phase1-lib.test.js b/mcp/deepsql-phase1-lib.test.js index 655b3c7..e36105a 100644 --- a/mcp/deepsql-phase1-lib.test.js +++ b/mcp/deepsql-phase1-lib.test.js @@ -17,6 +17,9 @@ const { createConfigFromEnv, getAuthToken, invalidateTokenCache, + invalidateActorProfileCache, + resolveRequestAuthContext, + USER_SESSION_EXPIRED_MESSAGE, buildCallerCapabilities, summarizeCallerCapabilities, summarizeConnections, @@ -1127,7 +1130,21 @@ test("Phase A tools that require connectionId reject empty input cleanly (no net // mtime-cached live read, buildHeaders/callDeepSqlApi picking up a rotated // token, and the one-shot 401 self-heal retry. -test("createConfigFromEnv wires DEEPSQL_TOKEN_FILE and keeps the env token fallback", () => { +test("createConfigFromEnv wires DEEPSQL_PROFILES_HOME for agent multi-user mode", () => { + const agent = createConfigFromEnv({ + DEEPSQL_PROFILES_HOME: "/data/profiles", + DEEPSQL_ACTOR_PROFILE_FILE: "/data/active-mcp-profile", + DEEPSQL_PROVISIONER_REFRESH_URL: "http://127.0.0.1:8788/refresh", + DEEPSQL_AUTH_TOKEN: "legacy", + DEEPSQL_TOKEN_FILE: "/shared/deepsql.token", + }); + assert.equal(agent.profilesHome, "/data/profiles"); + assert.equal(agent.actorProfileFile, "/data/active-mcp-profile"); + assert.equal(agent.provisionerRefreshUrl, "http://127.0.0.1:8788/refresh"); + assert.equal(agent.authToken, ""); + assert.equal(agent.tokenFile, null); + assert.equal(agent.clientAgent, null); + const withFile = createConfigFromEnv({ DEEPSQL_TOKEN_FILE: "/x/y.token", DEEPSQL_AUTH_TOKEN: "z" }); assert.equal(withFile.tokenFile, "/x/y.token"); assert.equal(withFile.authToken, "z"); @@ -1216,7 +1233,10 @@ test("callDeepSqlApi does not retry a 401 when no token file is configured", asy const cfg = { baseUrl: "http://test/api/", authToken: "envtok", timeoutMs: 5000 }; const stub = installFetchStub([{ status: 401, ok: false, statusText: "Unauthorized", body: { message: "nope" } }]); try { - await assert.rejects(() => callDeepSqlApi(cfg, "/connections"), /nope/); + await assert.rejects( + () => callDeepSqlApi(cfg, "/connections"), + (err) => err.message === USER_SESSION_EXPIRED_MESSAGE, + ); } finally { stub.restore(); } @@ -1229,7 +1249,10 @@ test("callDeepSqlApi does not retry a 401 when the token file is unchanged", asy const cfg = { baseUrl: "http://test/api/", tokenFile: p, timeoutMs: 5000 }; const stub = installFetchStub([{ status: 401, ok: false, statusText: "Unauthorized", body: { message: "nope" } }]); try { - await assert.rejects(() => callDeepSqlApi(cfg, "/connections"), /nope/); + await assert.rejects( + () => callDeepSqlApi(cfg, "/connections"), + (err) => err.message === USER_SESSION_EXPIRED_MESSAGE, + ); } finally { stub.restore(); } @@ -1296,3 +1319,118 @@ test("handleToolCall(get_brain_context) stamps callerCapabilities onto the paylo assert.ok(result.structuredContent.callerCapabilities.doNotOffer.includes("save_brain_note")); assert.match(result.content[0].text, /Do not offer:.*save_brain_note/); }); + +function makeMultiUserProfilesFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dsql-mcp-profiles-")); + const profilesHome = path.join(root, "profiles"); + const actorProfileFile = path.join(root, "active-mcp-profile"); + for (const [profile, user, token] of [ + ["u-alice", "alice", "tok-alice"], + ["u-bob", "bob", "tok-bob"], + ]) { + const home = path.join(profilesHome, profile); + fs.mkdirSync(home, { recursive: true }); + fs.writeFileSync(path.join(home, "deepsql.token"), `${token}\n`); + fs.writeFileSync(path.join(home, ".env"), `DEEPSQL_MCP_USER_ID=${user}\n`); + } + return { + baseUrl: "http://test/api/", + timeoutMs: 5000, + profilesHome, + actorProfileFile, + provisionerRefreshUrl: "http://127.0.0.1:9/refresh", + }; +} + +test("multi-user mode resolves per-profile tokens and never uses another user's credential", async () => { + invalidateActorProfileCache(); + const cfg = makeMultiUserProfilesFixture(); + fs.writeFileSync(cfg.actorProfileFile, "u-alice\n"); + const stub = installFetchStub([{ body: [] }, { body: [] }]); + try { + await handleToolCall(cfg, "list_connections", {}); + fs.writeFileSync(cfg.actorProfileFile, "u-bob\n"); + invalidateActorProfileCache(); + await handleToolCall(cfg, "list_connections", {}); + } finally { + stub.restore(); + } + assert.equal(stub.authHeaders[0], "Bearer tok-alice"); + assert.equal(stub.authHeaders[1], "Bearer tok-bob"); +}); + +test("expired user gets re-login message while the other user still succeeds", async () => { + resetToolCaches(); + invalidateActorProfileCache(); + const cfg = makeMultiUserProfilesFixture(); + fs.writeFileSync(path.join(cfg.profilesHome, "u-alice", "deepsql.token"), "\n"); + fs.writeFileSync(cfg.actorProfileFile, "u-alice\n"); + + const alice = await handleToolCall(cfg, "list_connections", {}); + assert.equal(alice.isError, true); + assert.match(alice.content[0].text, /session expired/i); + + fs.writeFileSync(cfg.actorProfileFile, "u-bob\n"); + invalidateActorProfileCache(); + const stub = installFetchStub([{ body: [{ id: "c1", connectionName: "DB" }] }]); + try { + const bob = await handleToolCall(cfg, "list_connections", {}); + assert.notEqual(bob.isError, true); + } finally { + stub.restore(); + } + assert.equal(stub.authHeaders[0], "Bearer tok-bob"); +}); + +test("concurrent in-flight MCP calls keep isolated Authorization headers", async () => { + resetToolCaches(); + invalidateActorProfileCache(); + const cfg = makeMultiUserProfilesFixture(); + const authAtFetch = []; + let releaseAlice; + const aliceGate = new Promise((resolve) => { releaseAlice = resolve; }); + const responses = [{ body: [] }, { body: [] }]; + const stub = installFetchStub(responses); + const originalFetch = global.fetch; + global.fetch = async (url, init) => { + authAtFetch.push(init?.headers?.Authorization); + if (init?.headers?.Authorization === "Bearer tok-alice") { + await aliceGate; + } + return originalFetch(url, init); + }; + try { + fs.writeFileSync(cfg.actorProfileFile, "u-alice\n"); + invalidateActorProfileCache(); + const alicePromise = handleToolCall(cfg, "list_connections", {}); + + fs.writeFileSync(cfg.actorProfileFile, "u-bob\n"); + invalidateActorProfileCache(); + await handleToolCall(cfg, "list_connections", {}); + + releaseAlice(); + await alicePromise; + } finally { + global.fetch = originalFetch; + stub.restore(); + } + assert.deepEqual(authAtFetch, ["Bearer tok-alice", "Bearer tok-bob"]); +}); + +test("401 from backend maps to the re-login message", async () => { + resetToolCaches(); + invalidateActorProfileCache(); + const cfg = makeMultiUserProfilesFixture(); + fs.writeFileSync(cfg.actorProfileFile, "u-alice\n"); + const stub = installFetchStub([ + { status: 401, ok: false, statusText: "Unauthorized", body: { message: "bad token" } }, + ]); + try { + await assert.rejects( + () => handleToolCall(cfg, "list_connections", {}), + (err) => err instanceof Error && err.message === USER_SESSION_EXPIRED_MESSAGE, + ); + } finally { + stub.restore(); + } +}); diff --git a/scripts/local-agent-provisioner.py b/scripts/local-agent-provisioner.py index 9141204..cc0fa73 100755 --- a/scripts/local-agent-provisioner.py +++ b/scripts/local-agent-provisioner.py @@ -75,32 +75,59 @@ def iter_profile_homes(hermes_home: Path): yield child -def mirror_token_for_live_mcp(hermes_home: Path, token: str) -> list[Path]: - """Publish the provisioned token to the ONE shared path a live MCP process - may have been started from — without touching any other user's profile. - - Hermes starts one DeepSQL MCP stdio server from whichever profile first - loaded ``mcp_servers``, then keeps that subprocess for the life of the - Agent API. ``POST /api/profile/switch`` is explicitly ``process_wide=False`` - — it sets a cookie / thread-local for sessions, but does **not** respawn - MCP with the target profile's ``DEEPSQL_AUTH_TOKEN``. The MCP client - re-reads ``DEEPSQL_TOKEN_FILE`` per request (mtime cache), so the shared - root file is what lets a rotation reach that already-running process. - - This previously also wrote every ``profiles/*/deepsql.token``, which made - the credential globally last-writer-wins: any user opening the Agent tab - overwrote every other user's token, so their agent then authenticated as - the newcomer — a cross-user read whose audit row named the wrong person. - Two concurrent users were enough; no impersonation required. - - The root file alone is still shared, so a mismatched credential can still - reach the live process. That is why the *backend* is the real guard: - ``McpTokenAuthenticationFilter`` refuses a token whose owner differs from - the request's ``DEEPSQL_MCP_USER_ID`` claim, so a stale/foreign token fails - closed with ``mcp_identity_mismatch`` instead of silently reading another - user's data. Never restore the per-profile fan-out. - """ - return [write_token_file(hermes_home, token)] +def iter_profile_homes(hermes_home: Path): + """Yield each ``profiles/`` directory under a Hermes home.""" + profiles = hermes_home / "profiles" + if not profiles.is_dir(): + return + for child in profiles.iterdir(): + if child.is_dir(): + yield child + + +def backend_mcp_token_url() -> str: + base = API_BASE.rstrip("/") + if base.endswith("/api"): + return f"{base}/agent/internal/mcp-token" + return f"{base.rstrip('/')}/agent/internal/mcp-token" + + +def mint_token_from_backend(user: str) -> str: + """Ask Spring to mint a fresh agent MCP token for ``user`` (provisioner-only).""" + import urllib.error + import urllib.request + + if not SECRET: + raise RuntimeError("AGENT_PROVISION_SECRET unset") + payload = json.dumps({"user": user}).encode("utf-8") + req = urllib.request.Request( + backend_mcp_token_url(), + data=payload, + headers={ + "Content-Type": "application/json", + "X-Provision-Secret": SECRET, + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + body = json.loads(resp.read().decode("utf-8") or "{}") + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"backend mcp-token HTTP {e.code}: {detail}") from e + token = str(body.get("token") or "").strip() + if not token: + raise RuntimeError("backend returned no token") + return token + + +def provision_user_credentials(user: str, token: str) -> Path: + profile = profile_for(user) + home = ensure_profile(profile) + token_file = write_token_file(home, token) + write_profile_mcp(home, user=user, token=token, token_file=token_file) + write_profile_env(home, user=user, token=token, token_file=token_file) + return home def ensure_profile(name: str) -> Path: @@ -226,6 +253,8 @@ def do_POST(self): path = self.path.rstrip("/") if path == "/provision": return self._handle_provision() + if path == "/refresh": + return self._handle_refresh() if path == "/revoke": return self._handle_revoke() return self._send(404, {"error": "not found"}) @@ -245,19 +274,33 @@ def _handle_provision(self): return self._send(400, {"error": "user required"}) profile = profile_for(user) try: - home = ensure_profile(profile) - token_file = write_token_file(home, token) - write_profile_mcp(home, user=user, token=token, token_file=token_file) - write_profile_env(home, user=user, token=token, token_file=token_file) - mirrored = mirror_token_for_live_mcp(HERMES_HOME, token) - sys.stderr.write( - f"[agent-provisioner] mirrored MCP token for {user} onto " - f"{len(mirrored)} live token file(s)\n" - ) + home = provision_user_credentials(user, token) except Exception as e: return self._send(500, {"error": str(e)}) return self._send(200, {"ok": True, "profile": profile, "home": str(home)}) + def _handle_refresh(self): + """Mint a fresh MCP token from Spring and rewrite only this user's profile.""" + peer = self.client_address[0] + if peer not in ("127.0.0.1", "::1"): + return self._send(403, {"error": "forbidden"}) + if not SECRET: + return self._send(500, {"error": "AGENT_PROVISION_SECRET unset"}) + try: + body = self._read_json() + except Exception: + return self._send(400, {"error": "invalid json"}) + user = str(body.get("user") or "").strip() + if not user: + return self._send(400, {"error": "user required"}) + profile = profile_for(user) + try: + token = mint_token_from_backend(user) + home = provision_user_credentials(user, token) + except Exception as e: + return self._send(500, {"error": str(e)}) + return self._send(200, {"ok": True, "profile": profile, "home": str(home), "refreshed": True}) + def _handle_revoke(self): """Best-effort disk cleanup on logout: blank the token file and the DEEPSQL_AUTH_TOKEN fallback in both the MCP env and the profile .env @@ -308,34 +351,28 @@ def _self_test() -> int: # Each user opens the Agent tab; each gets their own credential. write_token_file(analyst, "dsql_mcp_analyst.tok") - mirror_token_for_live_mcp(hermes_home, "dsql_mcp_analyst.tok") write_token_file(admin, "dsql_mcp_admin.tok") - mirror_token_for_live_mcp(hermes_home, "dsql_mcp_admin.tok") - # The later open must NOT have rewritten the earlier user's token. + # Provisioning one user must never touch the other's token file. + write_token_file(admin, "dsql_mcp_admin_rotated.tok") + analyst_now = token_file_for(analyst).read_text().strip() if analyst_now != "dsql_mcp_analyst.tok": raise AssertionError( f"cross-user token leak: u-analyst holds {analyst_now!r}, " - "expected 'dsql_mcp_analyst.tok'. A user's Agent-tab open must " - "never overwrite another user's credential." + "expected 'dsql_mcp_analyst.tok'." ) admin_now = token_file_for(admin).read_text().strip() - if admin_now != "dsql_mcp_admin.tok": - raise AssertionError(f"u-admin holds {admin_now!r}, expected own token") - - # The shared root file (the live-process watch path) still rotates, so - # View as keeps working; the backend rejects it if it names another user. - root_now = token_file_for(hermes_home).read_text().strip() - if root_now != "dsql_mcp_admin.tok": - raise AssertionError(f"root token file {root_now!r} did not rotate") + if admin_now != "dsql_mcp_admin_rotated.tok": + raise AssertionError(f"u-admin holds {admin_now!r}, expected rotated token") - # And the fan-out must not come back. - paths = {p.resolve() for p in mirror_token_for_live_mcp(hermes_home, "x")} - if paths != {token_file_for(hermes_home).resolve()}: - raise AssertionError(f"mirror touched more than the root file: {paths}") + root = token_file_for(hermes_home) + if root.exists(): + raise AssertionError( + "shared root deepsql.token must not exist — MCP reads per-profile tokens" + ) - print("ok: per-user tokens stay isolated; only the shared root file rotates") + print("ok: per-user tokens stay isolated; no shared root credential") return 0 except Exception: traceback.print_exc()