diff --git a/.gitleaksignore b/.gitleaksignore index d9b9fcb25..f8bc1e186 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,3 +1,5 @@ 8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:174 8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:175 8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:176 +b45446d25724585fee8379601c351e0bb1960373:e2e/tests/console/mcpBridgePermissions.spec.js:generic-api-key:9 +b45446d25724585fee8379601c351e0bb1960373:src/utils/mcp/consumePendingPair.test.ts:generic-api-key:27 diff --git a/e2e/commands.js b/e2e/commands.js index 785af348d..d939c9bc9 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -722,8 +722,13 @@ Cypress.Commands.add("getSearchResultGroups", () => { return cy.getByDataHook("search-result-buffer-group") }) -Cypress.Commands.add("createTabWithContent", (content, title) => { +Cypress.Commands.add("addEditorTab", () => { cy.getByDataHook("new-tab-button").click() + cy.getByDataHook("new-tab-editor").click() +}) + +Cypress.Commands.add("createTabWithContent", (content, title) => { + cy.addEditorTab() cy.get(".chrome-tab-was-just-added").should("be.visible") cy.get(".chrome-tab-was-just-added").should("not.exist") cy.typeQueryDirectly(content) diff --git a/e2e/questdb b/e2e/questdb index 3b6d3ed93..88f051fee 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 3b6d3ed9325c78c6e3ccc6942608afab772c03a0 +Subproject commit 88f051feefbb5cdfe986851a14f33277a0f838f4 diff --git a/e2e/tests/console/aiAssistant.spec.js b/e2e/tests/console/aiAssistant.spec.js index 248aa5119..5bc3533f1 100644 --- a/e2e/tests/console/aiAssistant.spec.js +++ b/e2e/tests/console/aiAssistant.spec.js @@ -396,8 +396,9 @@ describe("ai assistant", () => { // Then cy.getByDataHook("ai-settings-modal-step-two").should("be.visible") - // When - cy.getByDataHook("ai-settings-schema-access").click() + // When - drop permissions to None so schema tools are excluded. + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-none").click() cy.getByDataHook("multi-step-modal-next-button").click() // Then - AI chat should be available @@ -420,9 +421,10 @@ describe("ai assistant", () => { }) }) - // When - Open settings modal and enable schema access + // When - Open settings modal and re-enable schema access cy.getByDataHook("ai-assistant-settings-button").click() - cy.getByDataHook("ai-settings-schema-access").click() + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-schema").click() cy.getByDataHook("ai-settings-save").click() cy.get(".toast-success-container").should("be.visible").click() @@ -1234,7 +1236,7 @@ describe("ai assistant", () => { // When - Close chat and create a new tab cy.getByDataHook("chat-window-close").click() - cy.get(".new-tab-button").click() + cy.addEditorTab() // Then - New tab should be created (2 tabs total now) cy.getEditorTabs().should("have.length", 2) @@ -3026,7 +3028,7 @@ Syntax: \`avg(column)\` // Close the tab that the suggestion was applied to (archive the buffer) // First, create another tab so we can close the current one - cy.get(".new-tab-button").click() + cy.addEditorTab() cy.getEditorTabs().should("have.length", 2) // Close the first tab (the one with the accepted query) @@ -3336,7 +3338,8 @@ describe("custom providers", () => { cy.getByDataHook("custom-provider-remove-model").click() cy.getByDataHook("custom-provider-model-chip").should("not.exist") - cy.getByDataHook("custom-provider-schema-access").check() + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-schema").click() cy.getByDataHook("multi-step-modal-next-button").click() cy.contains("AI Assistant activated successfully").should("be.visible") @@ -3911,7 +3914,7 @@ describe("custom providers", () => { "have.value", "200000", ) - cy.getByDataHook("custom-provider-schema-access").should("be.checked") + cy.getByDataHook("permissions-trigger").should("contain", "Schema access") cy.getByDataHook("custom-provider-add-model-button").should("be.disabled") cy.getByDataHook("custom-provider-manual-model-input").type( @@ -4266,8 +4269,8 @@ describe("custom providers", () => { cy.get("[data-model='llama3']").should("exist") cy.get("[data-model='mistral']").should("exist") - // Schema access toggle is not disabled - cy.getByDataHook("ai-settings-schema-access").should("not.be.disabled") + // Permissions select is not disabled + cy.getByDataHook("permissions-trigger").should("not.be.disabled") // Manage models button visible cy.getByDataHook("ai-settings-manage-models").should("be.visible") diff --git a/e2e/tests/console/aiAssistantPermissions.spec.js b/e2e/tests/console/aiAssistantPermissions.spec.js new file mode 100644 index 000000000..f8f7651d4 --- /dev/null +++ b/e2e/tests/console/aiAssistantPermissions.spec.js @@ -0,0 +1,243 @@ +/// + +const { + PROVIDERS, + getOpenAIConfiguredSettings, + getOpenAIPermissionedSettings, + createToolCallFlow, +} = require("../../utils/aiAssistant") + +const installValidateIntercept = () => { + cy.intercept("GET", "**/api/v1/sql/validate*", (req) => { + const url = new URL(req.url) + const sql = (url.searchParams.get("query") || "").trim().toUpperCase() + if (sql.startsWith("SELECT") || sql.startsWith("SHOW")) { + req.reply({ + statusCode: 200, + body: { + query: sql, + columns: [{ name: "c1", type: "LONG" }], + timestamp: -1, + }, + }) + return + } + if ( + sql.startsWith("INSERT") || + sql.startsWith("UPDATE") || + sql.startsWith("DELETE") + ) { + req.reply({ statusCode: 200, body: { queryType: "INSERT" } }) + return + } + req.reply({ statusCode: 200, body: { queryType: "CREATE TABLE" } }) + }).as("validate") +} + +const installExecDqlIntercept = () => { + cy.intercept("GET", "**/exec*", (req) => { + req.reply({ + statusCode: 200, + body: { + query: "SELECT 1", + columns: [{ name: "c1", type: "LONG" }], + dataset: [[1]], + count: 1, + timestamp: -1, + }, + }) + }).as("exec") +} + +// get_questdb_toc fetches the docs TOC over the network; stub it so the free +// tool resolves deterministically instead of reaching questdb.com. +const installDocsTocIntercept = () => { + cy.intercept("GET", "**/web-console/toc-list.json", { + statusCode: 200, + body: { functions: ["count()"], operators: ["="], sql: ["SELECT"] }, + }).as("docsToc") +} + +// Every backend a granted tool can touch, stubbed in one call so a single +// conversation can drive the full surface matrix without hitting the live DB. +const installToolStubs = () => { + installValidateIntercept() + installExecDqlIntercept() + installDocsTocIntercept() +} + +describe("ai assistant permissions", () => { + beforeEach(() => { + // Fail loudly on any unmocked provider request — each test scripts its own intercept. + cy.intercept("POST", PROVIDERS.openai.endpoint, (req) => { + throw new Error( + `Unhandled OpenAI request detected! Request body: ${JSON.stringify( + req.body, + ).slice(0, 200)}...`, + ) + }).as("unhandledOpenAI") + }) + + describe("PermissionsSection in settings modals", () => { + beforeEach(() => { + cy.loadConsoleWithAuth(false, getOpenAIConfiguredSettings()) + }) + + it("renders permission level select with cascading levels in SettingsModal", () => { + cy.getByDataHook("ai-assistant-settings-button") + .should("be.visible") + .click() + + cy.getByDataHook("permissions").should("be.visible") + cy.getByDataHook("permissions-trigger").should("contain", "Schema access") + + // Raise to Write: trigger label updates and all levels listed in menu. + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-write").click() + cy.getByDataHook("permissions-trigger").should("contain", "Write") + + // Drop to None: trigger label updates back. + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-none").click() + cy.getByDataHook("permissions-trigger").should("contain", "None") + }) + }) + + describe("tool permission gate — one pass per level", () => { + const GRANTED = { excludes: ["PERMISSION_DENIED"] } + const GRANTED_DQL = { + includes: ['"type":"dql"'], + excludes: ["PERMISSION_DENIED"], + } + const DENIED_SCHEMA = { + includes: ["PERMISSION_DENIED", "grantSchemaAccess"], + } + const DENIED_READ = { includes: ["PERMISSION_DENIED", "'read' permission"] } + const DENIED_WRITE = { + includes: ["PERMISSION_DENIED", "'write' permission"], + } + + const hasRead = (level) => level === "read" || level === "write" + + // Each surface is one tool call; `expect(level)` is the assertion for that + // surface's tool result at the configured level. free tools never gate; + // schema tools need grantSchemaAccess; run_query DQL needs read, DDL/DML + // needs write. + const SURFACES = [ + { + toolCall: { name: "validate_query", args: { query: "SELECT 1" } }, + expect: () => GRANTED, + }, + { + toolCall: { name: "suggest_query", args: { query: "SELECT 1" } }, + expect: () => GRANTED, + }, + { + toolCall: { name: "get_questdb_toc", args: {} }, + expect: () => GRANTED, + }, + { + toolCall: { name: "get_tables", args: {} }, + expect: (level) => (level === "none" ? DENIED_SCHEMA : GRANTED), + }, + { + toolCall: { + name: "get_table_schema", + args: { table_name: "btc_trades" }, + }, + expect: (level) => (level === "none" ? DENIED_SCHEMA : GRANTED), + }, + { + toolCall: { + name: "get_table_details", + args: { table_name: "btc_trades" }, + }, + expect: (level) => (level === "none" ? DENIED_SCHEMA : GRANTED), + }, + { + toolCall: { + name: "run_query", + args: { sql: "SELECT count(*) FROM btc_trades" }, + }, + expect: (level) => (hasRead(level) ? GRANTED_DQL : DENIED_READ), + }, + { + toolCall: { name: "run_query", args: { sql: "DROP TABLE btc_trades" } }, + expect: (level) => (level === "write" ? GRANTED : DENIED_WRITE), + }, + ] + + const buildSteps = (level) => { + const steps = SURFACES.map((surface, index) => ({ + toolCall: surface.toolCall, + ...(index > 0 + ? { expectToolResult: SURFACES[index - 1].expect(level) } + : {}), + })) + steps.push({ + finalResponse: { + explanation: "Permission matrix probe complete.", + sql: null, + }, + expectToolResult: SURFACES[SURFACES.length - 1].expect(level), + }) + return steps + } + + const runMatrix = (level) => { + const flow = createToolCallFlow({ + provider: "openai", + streaming: true, + question: `Probe tool permissions for the ${level} level.`, + steps: buildSteps(level), + }) + + flow.intercept() + cy.getByDataHook("ai-chat-button").click() + cy.getByDataHook("chat-input-textarea") + .should("be.visible") + .type(flow.question) + cy.getByDataHook("chat-send-button").click() + flow.waitForCompletion() + + cy.getByDataHook("chat-message-assistant") + .should("be.visible") + .should("contain", "matrix probe complete") + } + + const LEVELS = { + none: { read: false, write: false, grantSchemaAccess: false }, + schema: { read: false, write: false, grantSchemaAccess: true }, + read: { read: true, write: false, grantSchemaAccess: true }, + write: { read: true, write: true, grantSchemaAccess: true }, + } + + const loadAtLevel = (level) => { + cy.loadConsoleWithAuth( + false, + getOpenAIPermissionedSettings(LEVELS[level]), + ) + installToolStubs() + } + + it("none: free tools pass, schema and SQL tools are denied", () => { + loadAtLevel("none") + runMatrix("none") + }) + + it("schema access: schema tools pass, SQL tools still denied", () => { + loadAtLevel("schema") + runMatrix("schema") + }) + + it("read access: DQL runs, write SQL still denied", () => { + loadAtLevel("read") + runMatrix("read") + }) + + it("write access: every tool surface is granted", () => { + loadAtLevel("write") + runMatrix("write") + }) + }) +}) diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index ebacbd173..b49ef326a 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -1360,7 +1360,7 @@ describe("editor tabs", () => { }) it("should open the second empty tab on plus icon click", () => { - cy.get(".new-tab-button").click() + cy.addEditorTab() cy.get(".chrome-tab-was-just-added").should("not.exist") cy.getEditorTabs().should("have.length", 2) ;["SQL", "SQL 1"].forEach((title) => { @@ -1411,7 +1411,7 @@ describe("editor tabs", () => { }) it("should drag tabs", () => { - cy.get(".new-tab-button").click() + cy.addEditorTab() cy.get(".chrome-tab-was-just-added").should("not.exist") cy.getEditorTabByTitle("SQL").should("be.visible") cy.getEditorTabByTitle("SQL 1").should("be.visible") @@ -1451,7 +1451,7 @@ describe.skip("editor tabs history", () => { it("should close and archive tabs", () => { cy.typeQuery("--1") ;["SQL 1", "SQL 2"].forEach((title) => { - cy.get(".new-tab-button").click() + cy.addEditorTab() const dragHandle = getTabDragHandleByTitle(title) cy.get(dragHandle).should("be.visible") }) @@ -1487,9 +1487,7 @@ describe.skip("editor tabs history", () => { describe("handling comments", () => { beforeEach(() => { - cy.loadConsoleWithAuth(false, { - "splitter.results.basis": Number.MAX_SAFE_INTEGER.toString(), - }) + cy.loadConsoleWithAuth(false) }) beforeEach(() => { @@ -1763,7 +1761,7 @@ describe("multiple run buttons with dynamic query log", () => { cy.getCursorQueryGlyph().should("have.length", 3) // When - cy.get(".new-tab-button").click() + cy.addEditorTab() // Then cy.getEditorTabByTitle("SQL 1") .should("be.visible") @@ -1904,7 +1902,7 @@ describe("import/export tabs", () => { cy.getEditorTabs().should("be.visible") }) - it("should show error toast when importing invalid file", () => { + it("should show validation reason in import summary when importing invalid file", () => { cy.getByDataHook("editor-tabs-menu-button").click() cy.getByDataHook("editor-tabs-menu").should("be.visible") @@ -1914,11 +1912,19 @@ describe("import/export tabs", () => { { force: true }, ) - cy.get(".toast-error-container") - .should("be.visible") - .should("contain", "Failed to import tabs") - .should("contain", "(id: 1)") - .should("contain", "label must be a string") + // An invalid tab is skipped rather than aborting the whole import; its + // validation reason is surfaced in the summary dialog. + cy.getByDataHook("import-summary-dialog").should("be.visible") + cy.getByDataHook("import-summary-dialog").should("contain", "0 tab") + cy.getByDataHook("import-summary-dialog").should("contain", "1 tab skipped") + cy.getByDataHook("import-summary-skipped-item").should("have.length", 1) + cy.getByDataHook("import-summary-skipped-item").should( + "contain", + "label must be a string", + ) + + cy.getByDataHook("import-summary-close").click() + cy.getByDataHook("import-summary-dialog").should("not.exist") }) it("should import tabs successfully with active and archived tabs", () => { diff --git a/e2e/tests/console/mcpBridgePermissions.spec.js b/e2e/tests/console/mcpBridgePermissions.spec.js new file mode 100644 index 000000000..bfeaa1bcb --- /dev/null +++ b/e2e/tests/console/mcpBridgePermissions.spec.js @@ -0,0 +1,433 @@ +/// + +// E2E coverage for the MCP bridge permission system. + +const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "" +const baseUrl = `http://localhost:9999${contextPath}` + +const TEST_BRIDGE_URL = "ws://127.0.0.1:57123" +const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789" +// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped. +const PROTOCOL_VERSION = "1" + +// Fake WebSocket installed before the app boots; exposed on `win.__mcpFakeWS`. +const installFakeWebSocket = (win) => { + class FakeWS { + constructor(url) { + this.url = url + this.readyState = 0 + this.sent = [] + this.listeners = {} + win.__mcpFakeWS = this + // Async "open" mirrors real WebSocket — lets listener registration finish. + win.setTimeout(() => { + this.readyState = 1 + this._dispatch("open", {}) + }, 0) + } + addEventListener(type, fn) { + if (!this.listeners[type]) this.listeners[type] = new Set() + this.listeners[type].add(fn) + } + removeEventListener(type, fn) { + this.listeners[type]?.delete(fn) + } + send(data) { + if (this.readyState !== 1) { + throw new Error("FakeWS.send while not open") + } + this.sent.push(data) + const parsed = JSON.parse(data) + if (parsed.type === "ping") { + this.receive({ + v: PROTOCOL_VERSION, + type: "pong", + nonce: parsed.nonce, + }) + } + } + close() { + this.readyState = 3 + this._dispatch("close", { code: 1000, reason: "test-close" }) + } + receive(payload) { + this._dispatch("message", { data: JSON.stringify(payload) }) + } + helloAck() { + this.receive({ + v: PROTOCOL_VERSION, + type: "hello_ack", + sessionId: "test-session", + heartbeatIntervalMs: 60000, + seenToolCount: 1, + }) + } + toolCall(name, args, requestId) { + const id = requestId || "req-" + Math.random().toString(36).slice(2) + this.receive({ + v: PROTOCOL_VERSION, + type: "tool_call", + requestId: id, + name, + arguments: args, + deadlineMs: 15000, + }) + return id + } + _dispatch(type, ev) { + this.listeners[type]?.forEach((fn) => fn(ev)) + } + framesOfType(type) { + return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type) + } + } + // Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN. + FakeWS.CONNECTING = 0 + FakeWS.OPEN = 1 + FakeWS.CLOSING = 2 + FakeWS.CLOSED = 3 + win.WebSocket = FakeWS +} + +// Permission classifier calls /api/v1/sql/validate to distinguish DQL vs DDL/DML. +const installValidateIntercept = () => { + cy.intercept("GET", "**/api/v1/sql/validate*", (req) => { + const url = new URL(req.url) + const sql = (url.searchParams.get("query") || "").trim().toUpperCase() + if (sql.startsWith("SELECT") || sql.startsWith("SHOW")) { + req.reply({ + statusCode: 200, + body: { + query: sql, + columns: [{ name: "c1", type: "LONG" }], + timestamp: -1, + }, + }) + return + } + if ( + sql.startsWith("INSERT") || + sql.startsWith("UPDATE") || + sql.startsWith("DELETE") + ) { + req.reply({ statusCode: 200, body: { queryType: "INSERT" } }) + return + } + req.reply({ statusCode: 200, body: { queryType: "CREATE TABLE" } }) + }).as("validate") +} + +const deepLinkSuffix = () => + `?mcp-pair=1&mcp-ws=${encodeURIComponent(TEST_BRIDGE_URL)}` + + `&mcp-token=${encodeURIComponent(TEST_BRIDGE_TOKEN)}` + +// Visit with deep-link params before login so they survive into the SPA boot. +const loginAndVisitDeepLink = (seedLocalStorage = {}) => { + cy.visit(`${baseUrl}/${deepLinkSuffix()}`, { + onBeforeLoad: (win) => { + win.localStorage.clear() + win.sessionStorage.clear() + win.indexedDB.deleteDatabase("web-console") + for (const [k, v] of Object.entries(seedLocalStorage)) { + win.localStorage.setItem(k, v) + } + installFakeWebSocket(win) + }, + }) + cy.loginWithUserAndPassword() +} + +const waitForPaired = () => { + cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist") + cy.window({ timeout: 10000 }).should((win) => { + expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan(0) + }) + cy.window().then((win) => win.__mcpFakeWS.helloAck()) + cy.getByDataHook("mcp-bridge-status-pill", { timeout: 10000 }).should( + "contain", + "MCP connected", + ) +} + +const lastToolResult = (win, requestId) => { + const results = win.__mcpFakeWS.framesOfType("tool_result") + return results.find((r) => r.requestId === requestId) +} + +describe("MCP bridge permissions (e2e)", () => { + beforeEach(() => { + installValidateIntercept() + }) + + describe("consent modal", () => { + it("default is Read; user raises to Write before Connect; hello carries { grantSchemaAccess:T, read:T, write:T }", () => { + loginAndVisitDeepLink() + + cy.getByDataHook("permissions").should("be.visible") + cy.getByDataHook("permissions-trigger").should("contain", "Read") + + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-write").click() + cy.getByDataHook("permissions-trigger").should("contain", "Write") + + cy.getByDataHook("mcp-pair-consent-connect").click() + + cy.window().should((win) => { + const hellos = win.__mcpFakeWS.framesOfType("hello") + expect(hellos.length).to.be.greaterThan(0) + expect(hellos[0].permissions).to.deep.equal({ + grantSchemaAccess: true, + read: true, + write: true, + }) + expect(hellos[0].token).to.equal(TEST_BRIDGE_TOKEN) + }) + + cy.window().then((win) => win.__mcpFakeWS.helloAck()) + cy.getByDataHook("mcp-bridge-status-pill").should( + "contain", + "MCP connected", + ) + cy.window().then((win) => { + expect( + JSON.parse(win.localStorage.getItem("mcp:permissions")), + ).to.deep.equal({ + grantSchemaAccess: true, + read: true, + write: true, + }) + }) + }) + + it("user drops to None; hello carries all-false", () => { + loginAndVisitDeepLink() + + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-none").click() + cy.getByDataHook("permissions-trigger").should("contain", "None") + + cy.getByDataHook("mcp-pair-consent-connect").click() + cy.window().should((win) => { + const hellos = win.__mcpFakeWS.framesOfType("hello") + expect(hellos.length).to.be.greaterThan(0) + expect(hellos[0].permissions).to.deep.equal({ + grantSchemaAccess: false, + read: false, + write: false, + }) + }) + }) + }) + + describe("permission gate over the wire", () => { + const expectedLabel = (p) => { + if (p.write) return "Write" + if (p.read) return "Read" + if (p.grantSchemaAccess) return "Schema access" + return "None" + } + const setupPaired = (permissions) => { + loginAndVisitDeepLink({ + "mcp:permissions": JSON.stringify(permissions), + }) + cy.getByDataHook("permissions-trigger").should( + "contain", + expectedLabel(permissions), + ) + cy.getByDataHook("mcp-pair-consent-connect").click() + waitForPaired() + } + + it("read+write granted: DQL, DML, schema all run", () => { + setupPaired({ grantSchemaAccess: true, read: true, write: true }) + + const ids = {} + cy.window().then((win) => { + ids.dql = win.__mcpFakeWS.toolCall("run_query", { + sql: "SELECT 1", + limit: 10, + }) + ids.dml = win.__mcpFakeWS.toolCall("run_query", { + sql: "INSERT INTO t VALUES (1)", + limit: 10, + }) + ids.schema = win.__mcpFakeWS.toolCall("get_tables", {}) + }) + + cy.window({ timeout: 10000 }).should((w) => { + expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(3) + }) + + cy.window().then((w) => { + // Only PERMISSION_DENIED matters here; other errors are ignored. + for (const id of Object.values(ids)) { + const r = lastToolResult(w, id) + expect(r, `result for ${id}`).to.exist + if (r.isError) { + expect(r.content[0].text).to.not.match(/PERMISSION_DENIED/) + } + } + }) + }) + + it("read-only: DQL granted, DDL/DML denied via /validate classification", () => { + setupPaired({ grantSchemaAccess: true, read: true, write: false }) + + const ids = {} + cy.window().then((win) => { + ids.dql = win.__mcpFakeWS.toolCall("run_query", { + sql: "SELECT * FROM t", + limit: 10, + }) + ids.dml = win.__mcpFakeWS.toolCall("run_query", { + sql: "INSERT INTO t VALUES (1)", + limit: 10, + }) + ids.ddl = win.__mcpFakeWS.toolCall("run_query", { + sql: "CREATE TABLE t (a INT)", + limit: 10, + }) + ids.schema = win.__mcpFakeWS.toolCall("get_tables", {}) + }) + + cy.window({ timeout: 10000 }).should((w) => { + expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(4) + }) + + cy.window().then((w) => { + const dml = lastToolResult(w, ids.dml) + expect(dml.isError).to.equal(true) + expect(dml.content[0].text).to.match(/PERMISSION_DENIED/) + expect(dml.content[0].text).to.match(/'write' permission/) + + const ddl = lastToolResult(w, ids.ddl) + expect(ddl.isError).to.equal(true) + expect(ddl.content[0].text).to.match(/PERMISSION_DENIED/) + + const schema = lastToolResult(w, ids.schema) + if (schema.isError) { + expect(schema.content[0].text).to.not.match(/PERMISSION_DENIED/) + } + const dql = lastToolResult(w, ids.dql) + if (dql.isError) { + expect(dql.content[0].text).to.not.match(/PERMISSION_DENIED/) + } + }) + }) + + it("no permissions: even DQL + schema reads are denied", () => { + setupPaired({ grantSchemaAccess: false, read: false, write: false }) + + const ids = {} + cy.window().then((win) => { + ids.dql = win.__mcpFakeWS.toolCall("run_query", { + sql: "SELECT 1", + limit: 10, + }) + ids.schema = win.__mcpFakeWS.toolCall("get_tables", {}) + }) + + cy.window({ timeout: 10000 }).should((w) => { + expect(w.__mcpFakeWS.framesOfType("tool_result").length).to.be.gte(2) + }) + + cy.window().then((w) => { + const dql = lastToolResult(w, ids.dql) + expect(dql.isError).to.equal(true) + expect(dql.content[0].text).to.match(/PERMISSION_DENIED/) + + const schema = lastToolResult(w, ids.schema) + expect(schema.isError).to.equal(true) + expect(schema.content[0].text).to.match(/PERMISSION_DENIED/) + expect(schema.content[0].text).to.match(/'grantSchemaAccess'/) + }) + }) + }) + + describe("popover: submit-only persistence", () => { + it("raising to Write without clicking Apply does NOT change localStorage", () => { + loginAndVisitDeepLink() + cy.getByDataHook("mcp-pair-consent-connect").click() + waitForPaired() + + cy.getByDataHook("mcp-bridge-status-pill").click() + cy.getByDataHook("mcp-pair-popover").should("be.visible") + + cy.window().then((win) => { + expect( + JSON.parse(win.localStorage.getItem("mcp:permissions")), + ).to.deep.equal({ grantSchemaAccess: true, read: true, write: false }) + }) + + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-write").click() + cy.getByDataHook("mcp-pair-cancel").click() + + cy.window().then((win) => { + expect( + JSON.parse(win.localStorage.getItem("mcp:permissions")), + ).to.deep.equal({ grantSchemaAccess: true, read: true, write: false }) + }) + + cy.getByDataHook("mcp-bridge-status-pill").click() + cy.getByDataHook("permissions-trigger").should("contain", "Read") + }) + + it("raise to Write + Apply: localStorage updates, no reconnect", () => { + loginAndVisitDeepLink() + cy.getByDataHook("mcp-pair-consent-connect").click() + waitForPaired() + + cy.getByDataHook("mcp-bridge-status-pill").click() + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-write").click() + cy.getByDataHook("mcp-pair-submit").should("contain", "Connect").click() + + cy.window().then((win) => { + expect( + JSON.parse(win.localStorage.getItem("mcp:permissions")), + ).to.deep.equal({ grantSchemaAccess: true, read: true, write: true }) + expect(win.__mcpFakeWS.framesOfType("hello")).to.have.length(1) + }) + cy.getByDataHook("mcp-bridge-status-pill").should( + "contain", + "MCP connected", + ) + }) + }) + + describe("persistence across refresh", () => { + it("consented pair auto-restores; new hello carries the previously-committed permissions", () => { + loginAndVisitDeepLink() + cy.getByDataHook("permissions-trigger").click() + cy.getByDataHook("permission-level-write").click() + cy.getByDataHook("mcp-pair-consent-connect").click() + waitForPaired() + + // cy.reload doesn't accept onBeforeLoad — re-visit so the fake WS reinstalls. + cy.visit(baseUrl, { + onBeforeLoad: (win) => installFakeWebSocket(win), + }) + + cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist") + cy.window({ timeout: 10000 }).should((win) => { + expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan( + 0, + ) + }) + cy.window().then((win) => { + const hello = win.__mcpFakeWS.framesOfType("hello")[0] + expect(hello.permissions).to.deep.equal({ + grantSchemaAccess: true, + read: true, + write: true, + }) + win.__mcpFakeWS.helloAck() + }) + cy.getByDataHook("mcp-bridge-status-pill").should( + "contain", + "MCP connected", + ) + }) + }) +}) diff --git a/e2e/utils/aiAssistant.js b/e2e/utils/aiAssistant.js index 8a6f208e7..c3bbecbe1 100644 --- a/e2e/utils/aiAssistant.js +++ b/e2e/utils/aiAssistant.js @@ -34,6 +34,34 @@ function getOpenAIConfiguredSettings(schemaAccess = true) { } } +/** + * OpenAI settings with the AI permission scopes (grantSchemaAccess/read/write) + * set explicitly. These are the per-provider scopes `getAiPermissions` reads — + * NOT the MCP `mcp:permissions` key — so the permission gate decides off them + * directly. `grantSchemaAccess` defaults to true; pass false for the "none" + * level where even schema tools are denied. + */ +function getOpenAIPermissionedSettings({ + read, + write, + grantSchemaAccess = true, +}) { + return { + "ai.assistant.settings": JSON.stringify({ + selectedModel: "gpt-5-mini", + providers: { + openai: { + apiKey: "test-openai-key", + enabledModels: ["gpt-5-mini", "gpt-5"], + grantSchemaAccess, + read, + write, + }, + }, + }), + } +} + function getAnthropicConfiguredSettings(schemaAccess = true) { return { "ai.assistant.settings": JSON.stringify({ @@ -945,6 +973,9 @@ function createToolCallFlow(config) { for (const expected of step.expectToolResult.includes || []) { expect(toolOutputContent).to.include(expected) } + for (const forbidden of step.expectToolResult.excludes || []) { + expect(toolOutputContent).to.not.include(forbidden) + } } // Send response @@ -1136,6 +1167,7 @@ module.exports = { PROVIDERS, CUSTOM_PROVIDER_DEFAULTS, getOpenAIConfiguredSettings, + getOpenAIPermissionedSettings, getAnthropicConfiguredSettings, getCustomProviderConfiguredSettings, getCustomProviderEndpoint, diff --git a/package.json b/package.json index 15d6a5664..845ec20a1 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,8 @@ "dexie-react-hooks": "^4.2.0", "dotenv": "^10.0.0", "draggabilly": "^3.0.0", - "echarts": "^5.2.2", + "echarts": "^5.6.0", + "echarts-for-react": "^3.0.6", "eventemitter3": "^5.0.1", "fflate": "^0.8.2", "intersection-observer": "^0.12.2", @@ -91,6 +92,7 @@ "react": "17.0.2", "react-calendar": "^4.0.0", "react-dom": "17.0.2", + "react-grid-layout": "^2.2.3", "react-highlight-words": "^0.20.0", "react-hook-form": "^7.56.0", "react-is": "^18.1.0", @@ -124,13 +126,13 @@ "@styled-icons/styled-icon": "^10.7.0", "@types/babel__core": "^7", "@types/draggabilly": "^2.1.6", - "@types/echarts": "^4.9.22", "@types/jquery": "3.5.1", "@types/lodash.merge": "^4.6.9", "@types/node": "^18.0.0", "@types/ramda": "0.27.40", "@types/react": "17.0.2", "@types/react-dom": "17.0.2", + "@types/react-grid-layout": "^2.1.0", "@types/react-highlight-words": "^0.16.7", "@types/react-redux": "7.1.9", "@types/react-transition-group": "4.4.0", @@ -186,7 +188,7 @@ }, { "path": "dist/assets/index-*.js", - "maxSize": "3MB", + "maxSize": "3.5MB", "compression": "none" }, { diff --git a/public/assets/icon-notebook.svg b/public/assets/icon-notebook.svg new file mode 100644 index 000000000..61f8e88bc --- /dev/null +++ b/public/assets/icon-notebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/AIStatusIndicator/AssistantModes.tsx b/src/components/AIStatusIndicator/AssistantModes.tsx index f2b2bf51c..35a592041 100644 --- a/src/components/AIStatusIndicator/AssistantModes.tsx +++ b/src/components/AIStatusIndicator/AssistantModes.tsx @@ -82,6 +82,7 @@ export const getIsExpandableSection = (section: OperationSection) => { AIOperationStatus.InvestigatingTable, AIOperationStatus.InvestigatingDocs, AIOperationStatus.Thinking, + AIOperationStatus.RunningQuery, ].includes(section.type) } diff --git a/src/components/AIStatusIndicator/AssistantModesCompact.tsx b/src/components/AIStatusIndicator/AssistantModesCompact.tsx index 9344e885b..345e8480a 100644 --- a/src/components/AIStatusIndicator/AssistantModesCompact.tsx +++ b/src/components/AIStatusIndicator/AssistantModesCompact.tsx @@ -361,10 +361,12 @@ export const AssistantModesCompact: React.FC = ({ nextSection, !nextSection ? endTimestamp : undefined, ) - const thinkingSegmentText = - section.type === AIOperationStatus.Thinking - ? (section.operations[0]?.content ?? "") - : "" + const isContentSection = + section.type === AIOperationStatus.Thinking || + section.type === AIOperationStatus.RunningQuery + const sectionContentText = isContentSection + ? (section.operations[0]?.content ?? "") + : "" return ( = ({ )} - {isExpandable && section.type === AIOperationStatus.Thinking && ( + {isExpandable && isContentSection && ( - {thinkingSegmentText ? ( - {thinkingSegmentText} + {sectionContentText ? ( + {sectionContentText} ) : null} )} - {isExpandable && section.type !== AIOperationStatus.Thinking && ( + {isExpandable && !isContentSection && ( diff --git a/src/components/Button/skin.ts b/src/components/Button/skin.ts index 600e6ecda..1b4580f54 100644 --- a/src/components/Button/skin.ts +++ b/src/components/Button/skin.ts @@ -13,6 +13,7 @@ export const skins = [ "secondary", "success", "error", + "danger", "warning", "transparent", "gradient", @@ -114,6 +115,23 @@ const themes: { color: "gray1", }, }, + danger: { + normal: { + background: "dangerBackground", + border: "transparent", + color: "dangerForeground", + }, + hover: { + background: "dangerBackgroundHover", + border: "transparent", + color: "dangerForeground", + }, + disabled: { + background: "selection", + border: "gray1", + color: "gray1", + }, + }, warning: { normal: { background: "selection", diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 8253d8f29..cf1c204b8 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -3,29 +3,28 @@ import styled from "styled-components" import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" const StyledContent = styled(ContextMenuPrimitive.Content)` - background-color: #343846; /* vscode-menu-background */ + background-color: ${({ theme }) => theme.color.backgroundDarker}; border-radius: 0.5rem; padding: 0.4rem; - box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); /* vscode-widget-shadow */ + box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); z-index: 9999; - min-width: 160px; + min-width: 16rem; ` const StyledItem = styled(ContextMenuPrimitive.Item)` - font-size: 1.3rem; - height: 3rem; + font-size: 1.4rem; font-family: "system-ui", sans-serif; cursor: pointer; - color: rgb(248, 248, 242); /* vscode-menu-foreground */ + color: ${({ theme }) => theme.color.foreground}; display: flex; + gap: 1rem; + min-height: 3rem; align-items: center; - padding: 1rem 1.2rem; + padding: 0.5rem 1rem; border-radius: 0.4rem; - border: 1px solid transparent; &[data-highlighted] { - background: #043c5c; - border: 1px solid #8be9fd; + background: ${({ theme }) => theme.color.tableSelection}; } &[data-disabled] { @@ -35,7 +34,6 @@ const StyledItem = styled(ContextMenuPrimitive.Item)` ` const IconWrapper = styled.span` - margin-right: 1.2rem; display: flex; align-items: center; justify-content: center; diff --git a/src/components/CopyButton/index.tsx b/src/components/CopyButton/index.tsx index 1ad8ff249..9b6381fc5 100644 --- a/src/components/CopyButton/index.tsx +++ b/src/components/CopyButton/index.tsx @@ -12,7 +12,9 @@ const StyledButton = styled(Button)` const StyledCheckboxCircle = styled(CheckboxCircle)` position: absolute; - transform: translate(75%, -75%); + top: 0; + right: 0; + transform: translate(50%, -50%); color: ${({ theme }) => theme.color.green}; ` @@ -44,6 +46,7 @@ export const CopyButton = ({ skin="secondary" size={size} data-hook="copy-value" + data-copied={copied || undefined} title="Copy to clipboard" onClick={(e: React.MouseEvent) => { void copyToClipboard(text) @@ -57,9 +60,7 @@ export const CopyButton = ({ })} {...props} > - {copied && ( - - )} + {copied && } {iconOnly ? : "Copy"} ) diff --git a/src/components/DropdownMenu/index.tsx b/src/components/DropdownMenu/index.tsx index 63022fa63..9f7e887c5 100644 --- a/src/components/DropdownMenu/index.tsx +++ b/src/components/DropdownMenu/index.tsx @@ -11,13 +11,13 @@ export const DropdownMenu = { Portal: styled(RadixDropdownMenu.Portal)``, Content: styled(RadixDropdownMenu.Content)` - display: grid; - gap: 0.2rem; - min-width: 22rem; - background: ${({ theme }) => theme.color.backgroundLighter}; - border-radius: ${({ theme }) => theme.borderRadius}; - box-shadow: 0 5px 5px 0 ${({ theme }) => theme.color.black40}; - padding: 0.5rem 0; + background-color: ${({ theme }) => theme.color.backgroundDarker}; + border-radius: 0.5rem; + border: 1px solid ${({ theme }) => theme.color.baseGrey}; + padding: 0.4rem; + box-shadow: 0 0.2rem 0.8rem rgba(0, 0, 0, 0.36); + z-index: 9999; + min-width: 16rem; `, Arrow: styled(RadixDropdownMenu.Arrow)` @@ -25,29 +25,31 @@ export const DropdownMenu = { `, Item: styled(RadixDropdownMenu.Item)` - border-radius: 3px; + font-size: 1.4rem; + cursor: pointer; + color: ${({ theme }) => theme.color.foreground}; display: flex; - gap: 1.5rem; + gap: 1rem; + min-height: 3rem; align-items: center; padding: 0.5rem 1rem; - margin: 0 0.5rem; + border-radius: 0.4rem; user-select: none; outline: none; - &[data-disabled] { - pointer-events: none; - opacity: 0.8; + &[data-highlighted] { + background: ${({ theme }) => theme.color.tableSelection}; } - &:focus { - background: ${({ theme }) => theme.color.comment}; - cursor: pointer; + &[data-disabled] { + opacity: 0.5; + pointer-events: none; } `, Divider: styled.div` height: 1px; background: ${({ theme }) => theme.color.selection}; - margin: 0.5rem 0; + margin: 0.3rem 0; `, } diff --git a/src/components/ExplainQueryButton/index.tsx b/src/components/ExplainQueryButton/index.tsx index 8c07d9a10..580829c7c 100644 --- a/src/components/ExplainQueryButton/index.tsx +++ b/src/components/ExplainQueryButton/index.tsx @@ -7,7 +7,7 @@ import { AISparkle } from "../AISparkle" import { QuestContext } from "../../providers" import { selectors } from "../../store" import { useAIStatus } from "../../providers/AIStatusProvider" -import { useAIConversation } from "../../providers/AIConversationProvider" +import { useAIConversationActions } from "../../providers/AIConversationProvider" import type { ConversationId } from "../../providers/AIConversationProvider/types" import { executeAIFlow, @@ -54,7 +54,7 @@ export const ExplainQueryButton = ({ updateConversationName, persistMessages, setIsStreaming, - } = useAIConversation() + } = useAIConversationActions() const handleExplainQuery = () => { void trackEvent(ConsoleEvent.AI_EXPLAIN_QUERY) diff --git a/src/components/HighlightedSql/index.tsx b/src/components/HighlightedSql/index.tsx new file mode 100644 index 000000000..5b08a925b --- /dev/null +++ b/src/components/HighlightedSql/index.tsx @@ -0,0 +1,42 @@ +import React, { useEffect, useState } from "react" +import styled from "styled-components" +import { monacoPromise } from "../../utils/monacoInit" +import { QuestDBLanguageName } from "../../scenes/Editor/Monaco/utils" + +type Props = { + code: string + language?: string + className?: string +} + +const Pre = styled.pre` + white-space: pre-wrap; + overflow-wrap: normal; + word-break: normal; +` + +export const HighlightedSql: React.FC = ({ + code, + language = QuestDBLanguageName, + className, +}) => { + const [html, setHtml] = useState(null) + + useEffect(() => { + let cancelled = false + void monacoPromise + .then((monaco) => monaco.editor.colorize(code, language, {})) + .then((colorized) => { + if (!cancelled) setHtml(colorized) + }) + return () => { + cancelled = true + } + }, [code, language]) + + return html === null ? ( +
{code}
+ ) : ( +
+  )
+}
diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx
index 9f17b5e32..c1d14b0b9 100644
--- a/src/components/Input/index.tsx
+++ b/src/components/Input/index.tsx
@@ -10,6 +10,10 @@ type InputProps = React.InputHTMLAttributes & {
 const errorStyle = css`
   border-color: ${({ theme }) => theme.color.red};
   background-color: #ff555515;
+  &:focus {
+    border-color: ${({ theme }) => theme.color.red};
+    background: #ff555515;
+  }
 `
 
 export const Input = styled.input.attrs((props) => ({
diff --git a/src/components/Markdown/SafeMarkdown.test.tsx b/src/components/Markdown/SafeMarkdown.test.tsx
new file mode 100644
index 000000000..d06353458
--- /dev/null
+++ b/src/components/Markdown/SafeMarkdown.test.tsx
@@ -0,0 +1,59 @@
+import React from "react"
+import { renderToStaticMarkup } from "react-dom/server"
+import { SafeMarkdown } from "./SafeMarkdown"
+
+const render = (markdown: string, allowImages = false) =>
+  renderToStaticMarkup(
+    {markdown},
+  )
+
+describe("SafeMarkdown", () => {
+  it("never renders images by default", () => {
+    const html = render("![pixel](http://evil.example/track.png)")
+    expect(html).not.toContain(" {
+    expect(render("![ok](https://example.com/a.png)", true)).toContain(" {
+    expect(render("![x](javascript:alert(1))", true)).not.toContain(" {
+    const html = render("")
+    // Raw HTML is escaped to inert text, never a live element.
+    expect(html).not.toContain("', true)
+    // The raw  is escaped (rendered as text), not emitted as an element.
+    expect(imgHtml).toContain("<img")
+    expect(imgHtml).not.toMatch(/]*onerror/i)
+  })
+
+  it("opens http(s) links in a new tab with noopener noreferrer", () => {
+    const html = render("[site](https://example.com)")
+    expect(html).toContain('target="_blank"')
+    expect(html).toContain('rel="noopener noreferrer"')
+  })
+
+  it("does not add target/rel to relative or anchor links", () => {
+    const html = render("[here](#section)")
+    expect(html).not.toContain('target="_blank"')
+  })
+
+  it("neutralizes javascript: link hrefs (react-markdown default)", () => {
+    const html = render("[x](javascript:alert(1))")
+    expect(html).not.toContain("javascript:alert")
+  })
+
+  it("renders GFM tables by default", () => {
+    const html = render("| a | b |\n| - | - |\n| 1 | 2 |")
+    expect(html).toContain(" — no wrapper element — so each call site keeps its own
+// styled container.
+//
+// Guarantees:
+//   - No raw HTML: react-markdown@8 escapes raw HTML by default and we never
+//     add rehype-raw, so embedded