diff --git a/plugins/glean/.claude-plugin/plugin.json b/plugins/glean/.claude-plugin/plugin.json index baffa1d..5eb8b65 100644 --- a/plugins/glean/.claude-plugin/plugin.json +++ b/plugins/glean/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glean-vnext", - "version": "0.2.29", + "version": "0.2.31", "description": "Glean plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/.codex-plugin/plugin.json b/plugins/glean/.codex-plugin/plugin.json index b42923a..72856ea 100644 --- a/plugins/glean/.codex-plugin/plugin.json +++ b/plugins/glean/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "glean-vnext", - "version": "0.2.29", + "version": "0.2.30", "description": "Glean Codex plugin for discovering skills and running tools.", "author": { "name": "Glean" diff --git a/plugins/glean/.cursor-plugin/plugin.json b/plugins/glean/.cursor-plugin/plugin.json index f99bdc9..6c01afc 100644 --- a/plugins/glean/.cursor-plugin/plugin.json +++ b/plugins/glean/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "glean-vnext", "displayName": "Glean vNext", - "version": "0.2.29", + "version": "0.2.30", "description": "Search and act across your company's apps — Jira, Slack, Salesforce, Google Workspace, and more — without leaving Cursor.", "author": { "name": "Glean" diff --git a/plugins/glean/.mcp.codex.json b/plugins/glean/.mcp.codex.json index c49dc45..52ebbef 100644 --- a/plugins/glean/.mcp.codex.json +++ b/plugins/glean/.mcp.codex.json @@ -6,7 +6,8 @@ "cwd": ".", "env": { "ENABLE_HITL": "true", - "HITL_TIMEOUT_MS": "300000" + "HITL_TIMEOUT_MS": "300000", + "GLEAN_SESSION_CAPTURE": "false" } } } diff --git a/plugins/glean/.mcp.json b/plugins/glean/.mcp.json index 1cb1410..458078a 100644 --- a/plugins/glean/.mcp.json +++ b/plugins/glean/.mcp.json @@ -5,7 +5,8 @@ "args": ["${CLAUDE_PLUGIN_ROOT}/start.sh"], "env": { "ENABLE_HITL": "true", - "HITL_TIMEOUT_MS": "300000" + "HITL_TIMEOUT_MS": "300000", + "GLEAN_SESSION_CAPTURE": "false" } } } diff --git a/plugins/glean/dist/index.js b/plugins/glean/dist/index.js index 103c20e..148a73a 100644 --- a/plugins/glean/dist/index.js +++ b/plugins/glean/dist/index.js @@ -3236,8 +3236,8 @@ var require_utils = __commonJS({ } return ind; } - function removeDotSegments(path8) { - let input = path8; + function removeDotSegments(path10) { + let input = path10; const output = []; let nextSlash = -1; let len = 0; @@ -3489,8 +3489,8 @@ var require_schemes = __commonJS({ wsComponent.secure = void 0; } if (wsComponent.resourceName) { - const [path8, query] = wsComponent.resourceName.split("?"); - wsComponent.path = path8 && path8 !== "/" ? path8 : void 0; + const [path10, query] = wsComponent.resourceName.split("?"); + wsComponent.path = path10 && path10 !== "/" ? path10 : void 0; wsComponent.query = query; wsComponent.resourceName = void 0; } @@ -6883,12 +6883,12 @@ var require_dist = __commonJS({ throw new Error(`Unknown format "${name}"`); return f; }; - function addFormats(ajv, list, fs8, exportName) { + function addFormats(ajv, list, fs10, exportName) { var _a3; var _b; (_a3 = (_b = ajv.opts.code).formats) !== null && _a3 !== void 0 ? _a3 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`; for (const f of list) - ajv.addFormat(f, fs8[f]); + ajv.addFormat(f, fs10[f]); } module.exports = exports = formatsPlugin; Object.defineProperty(exports, "__esModule", { value: true }); @@ -6973,17 +6973,17 @@ var require_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - function visit_(key, node, visitor, path8) { - const ctrl = callVisitor(key, node, visitor, path8); + function visit_(key, node, visitor, path10) { + const ctrl = callVisitor(key, node, visitor, path10); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path8, ctrl); - return visit_(key, ctrl, visitor, path8); + replaceNode(key, path10, ctrl); + return visit_(key, ctrl, visitor, path10); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path8 = Object.freeze(path8.concat(node)); + path10 = Object.freeze(path10.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = visit_(i, node.items[i], visitor, path8); + const ci = visit_(i, node.items[i], visitor, path10); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -6994,13 +6994,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path8 = Object.freeze(path8.concat(node)); - const ck = visit_("key", node.key, visitor, path8); + path10 = Object.freeze(path10.concat(node)); + const ck = visit_("key", node.key, visitor, path10); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = visit_("value", node.value, visitor, path8); + const cv = visit_("value", node.value, visitor, path10); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -7021,17 +7021,17 @@ var require_visit = __commonJS({ visitAsync.BREAK = BREAK; visitAsync.SKIP = SKIP; visitAsync.REMOVE = REMOVE; - async function visitAsync_(key, node, visitor, path8) { - const ctrl = await callVisitor(key, node, visitor, path8); + async function visitAsync_(key, node, visitor, path10) { + const ctrl = await callVisitor(key, node, visitor, path10); if (identity.isNode(ctrl) || identity.isPair(ctrl)) { - replaceNode(key, path8, ctrl); - return visitAsync_(key, ctrl, visitor, path8); + replaceNode(key, path10, ctrl); + return visitAsync_(key, ctrl, visitor, path10); } if (typeof ctrl !== "symbol") { if (identity.isCollection(node)) { - path8 = Object.freeze(path8.concat(node)); + path10 = Object.freeze(path10.concat(node)); for (let i = 0; i < node.items.length; ++i) { - const ci = await visitAsync_(i, node.items[i], visitor, path8); + const ci = await visitAsync_(i, node.items[i], visitor, path10); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -7042,13 +7042,13 @@ var require_visit = __commonJS({ } } } else if (identity.isPair(node)) { - path8 = Object.freeze(path8.concat(node)); - const ck = await visitAsync_("key", node.key, visitor, path8); + path10 = Object.freeze(path10.concat(node)); + const ck = await visitAsync_("key", node.key, visitor, path10); if (ck === BREAK) return BREAK; else if (ck === REMOVE) node.key = null; - const cv = await visitAsync_("value", node.value, visitor, path8); + const cv = await visitAsync_("value", node.value, visitor, path10); if (cv === BREAK) return BREAK; else if (cv === REMOVE) @@ -7075,23 +7075,23 @@ var require_visit = __commonJS({ } return visitor; } - function callVisitor(key, node, visitor, path8) { + function callVisitor(key, node, visitor, path10) { if (typeof visitor === "function") - return visitor(key, node, path8); + return visitor(key, node, path10); if (identity.isMap(node)) - return visitor.Map?.(key, node, path8); + return visitor.Map?.(key, node, path10); if (identity.isSeq(node)) - return visitor.Seq?.(key, node, path8); + return visitor.Seq?.(key, node, path10); if (identity.isPair(node)) - return visitor.Pair?.(key, node, path8); + return visitor.Pair?.(key, node, path10); if (identity.isScalar(node)) - return visitor.Scalar?.(key, node, path8); + return visitor.Scalar?.(key, node, path10); if (identity.isAlias(node)) - return visitor.Alias?.(key, node, path8); + return visitor.Alias?.(key, node, path10); return void 0; } - function replaceNode(key, path8, node) { - const parent = path8[path8.length - 1]; + function replaceNode(key, path10, node) { + const parent = path10[path10.length - 1]; if (identity.isCollection(parent)) { parent.items[key] = node; } else if (identity.isPair(parent)) { @@ -7701,10 +7701,10 @@ var require_Collection = __commonJS({ var createNode = require_createNode(); var identity = require_identity(); var Node = require_Node(); - function collectionFromPath(schema, path8, value) { + function collectionFromPath(schema, path10, value) { let v = value; - for (let i = path8.length - 1; i >= 0; --i) { - const k = path8[i]; + for (let i = path10.length - 1; i >= 0; --i) { + const k = path10[i]; if (typeof k === "number" && Number.isInteger(k) && k >= 0) { const a = []; a[k] = v; @@ -7723,7 +7723,7 @@ var require_Collection = __commonJS({ sourceObjects: /* @__PURE__ */ new Map() }); } - var isEmptyPath = (path8) => path8 == null || typeof path8 === "object" && !!path8[Symbol.iterator]().next().done; + var isEmptyPath = (path10) => path10 == null || typeof path10 === "object" && !!path10[Symbol.iterator]().next().done; var Collection = class extends Node.NodeBase { constructor(type, schema) { super(type); @@ -7753,11 +7753,11 @@ var require_Collection = __commonJS({ * be a Pair instance or a `{ key, value }` object, which may not have a key * that already exists in the map. */ - addIn(path8, value) { - if (isEmptyPath(path8)) + addIn(path10, value) { + if (isEmptyPath(path10)) this.add(value); else { - const [key, ...rest] = path8; + const [key, ...rest] = path10; const node = this.get(key, true); if (identity.isCollection(node)) node.addIn(rest, value); @@ -7771,8 +7771,8 @@ var require_Collection = __commonJS({ * Removes a value from the collection. * @returns `true` if the item was found and removed. */ - deleteIn(path8) { - const [key, ...rest] = path8; + deleteIn(path10) { + const [key, ...rest] = path10; if (rest.length === 0) return this.delete(key); const node = this.get(key, true); @@ -7786,8 +7786,8 @@ var require_Collection = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path8, keepScalar) { - const [key, ...rest] = path8; + getIn(path10, keepScalar) { + const [key, ...rest] = path10; const node = this.get(key, true); if (rest.length === 0) return !keepScalar && identity.isScalar(node) ? node.value : node; @@ -7805,8 +7805,8 @@ var require_Collection = __commonJS({ /** * Checks if the collection includes a value with the key `key`. */ - hasIn(path8) { - const [key, ...rest] = path8; + hasIn(path10) { + const [key, ...rest] = path10; if (rest.length === 0) return this.has(key); const node = this.get(key, true); @@ -7816,8 +7816,8 @@ var require_Collection = __commonJS({ * Sets a value in this collection. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path8, value) { - const [key, ...rest] = path8; + setIn(path10, value) { + const [key, ...rest] = path10; if (rest.length === 0) { this.set(key, value); } else { @@ -10332,9 +10332,9 @@ var require_Document = __commonJS({ this.contents.add(value); } /** Adds a value to the document. */ - addIn(path8, value) { + addIn(path10, value) { if (assertCollection(this.contents)) - this.contents.addIn(path8, value); + this.contents.addIn(path10, value); } /** * Create a new `Alias` node, ensuring that the target `node` has the required anchor. @@ -10409,14 +10409,14 @@ var require_Document = __commonJS({ * Removes a value from the document. * @returns `true` if the item was found and removed. */ - deleteIn(path8) { - if (Collection.isEmptyPath(path8)) { + deleteIn(path10) { + if (Collection.isEmptyPath(path10)) { if (this.contents == null) return false; this.contents = null; return true; } - return assertCollection(this.contents) ? this.contents.deleteIn(path8) : false; + return assertCollection(this.contents) ? this.contents.deleteIn(path10) : false; } /** * Returns item at `key`, or `undefined` if not found. By default unwraps @@ -10431,10 +10431,10 @@ var require_Document = __commonJS({ * scalar values from their surrounding node; to disable set `keepScalar` to * `true` (collections are always returned intact). */ - getIn(path8, keepScalar) { - if (Collection.isEmptyPath(path8)) + getIn(path10, keepScalar) { + if (Collection.isEmptyPath(path10)) return !keepScalar && identity.isScalar(this.contents) ? this.contents.value : this.contents; - return identity.isCollection(this.contents) ? this.contents.getIn(path8, keepScalar) : void 0; + return identity.isCollection(this.contents) ? this.contents.getIn(path10, keepScalar) : void 0; } /** * Checks if the document includes a value with the key `key`. @@ -10445,10 +10445,10 @@ var require_Document = __commonJS({ /** * Checks if the document includes a value at `path`. */ - hasIn(path8) { - if (Collection.isEmptyPath(path8)) + hasIn(path10) { + if (Collection.isEmptyPath(path10)) return this.contents !== void 0; - return identity.isCollection(this.contents) ? this.contents.hasIn(path8) : false; + return identity.isCollection(this.contents) ? this.contents.hasIn(path10) : false; } /** * Sets a value in this document. For `!!set`, `value` needs to be a @@ -10465,13 +10465,13 @@ var require_Document = __commonJS({ * Sets a value in this document. For `!!set`, `value` needs to be a * boolean to add/remove the item from the set. */ - setIn(path8, value) { - if (Collection.isEmptyPath(path8)) { + setIn(path10, value) { + if (Collection.isEmptyPath(path10)) { this.contents = value; } else if (this.contents == null) { - this.contents = Collection.collectionFromPath(this.schema, Array.from(path8), value); + this.contents = Collection.collectionFromPath(this.schema, Array.from(path10), value); } else if (assertCollection(this.contents)) { - this.contents.setIn(path8, value); + this.contents.setIn(path10, value); } } /** @@ -12431,9 +12431,9 @@ var require_cst_visit = __commonJS({ visit.BREAK = BREAK; visit.SKIP = SKIP; visit.REMOVE = REMOVE; - visit.itemAtPath = (cst, path8) => { + visit.itemAtPath = (cst, path10) => { let item = cst; - for (const [field, index] of path8) { + for (const [field, index] of path10) { const tok = item?.[field]; if (tok && "items" in tok) { item = tok.items[index]; @@ -12442,23 +12442,23 @@ var require_cst_visit = __commonJS({ } return item; }; - visit.parentCollection = (cst, path8) => { - const parent = visit.itemAtPath(cst, path8.slice(0, -1)); - const field = path8[path8.length - 1][0]; + visit.parentCollection = (cst, path10) => { + const parent = visit.itemAtPath(cst, path10.slice(0, -1)); + const field = path10[path10.length - 1][0]; const coll = parent?.[field]; if (coll && "items" in coll) return coll; throw new Error("Parent collection not found"); }; - function _visit(path8, item, visitor) { - let ctrl = visitor(item, path8); + function _visit(path10, item, visitor) { + let ctrl = visitor(item, path10); if (typeof ctrl === "symbol") return ctrl; for (const field of ["key", "value"]) { const token = item[field]; if (token && "items" in token) { for (let i = 0; i < token.items.length; ++i) { - const ci = _visit(Object.freeze(path8.concat([[field, i]])), token.items[i], visitor); + const ci = _visit(Object.freeze(path10.concat([[field, i]])), token.items[i], visitor); if (typeof ci === "number") i = ci - 1; else if (ci === BREAK) @@ -12469,10 +12469,10 @@ var require_cst_visit = __commonJS({ } } if (typeof ctrl === "function" && field === "key") - ctrl = ctrl(item, path8); + ctrl = ctrl(item, path10); } } - return typeof ctrl === "function" ? ctrl(item, path8) : ctrl; + return typeof ctrl === "function" ? ctrl(item, path10) : ctrl; } exports.visit = visit; } @@ -13774,14 +13774,14 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs8 = this.flowScalar(this.type); + const fs10 = this.flowScalar(this.type); if (atNextItem || it.value) { - map.items.push({ start, key: fs8, sep: [] }); + map.items.push({ start, key: fs10, sep: [] }); this.onKeyLine = true; } else if (it.sep) { - this.stack.push(fs8); + this.stack.push(fs10); } else { - Object.assign(it, { key: fs8, sep: [] }); + Object.assign(it, { key: fs10, sep: [] }); this.onKeyLine = true; } return; @@ -13909,13 +13909,13 @@ var require_parser = __commonJS({ case "scalar": case "single-quoted-scalar": case "double-quoted-scalar": { - const fs8 = this.flowScalar(this.type); + const fs10 = this.flowScalar(this.type); if (!it || it.value) - fc.items.push({ start: [], key: fs8, sep: [] }); + fc.items.push({ start: [], key: fs10, sep: [] }); else if (it.sep) - this.stack.push(fs8); + this.stack.push(fs10); else - Object.assign(it, { key: fs8, sep: [] }); + Object.assign(it, { key: fs10, sep: [] }); return; } case "flow-map-end": @@ -14466,10 +14466,10 @@ function mergeDefs(...defs) { function cloneDef(schema) { return mergeDefs(schema._zod.def); } -function getElementAtPath(obj, path8) { - if (!path8) +function getElementAtPath(obj, path10) { + if (!path10) return obj; - return path8.reduce((acc, key) => acc?.[key], obj); + return path10.reduce((acc, key) => acc?.[key], obj); } function promiseAllObject(promisesObj) { const keys = Object.keys(promisesObj); @@ -14878,11 +14878,11 @@ function explicitlyAborted(x, startIndex = 0) { } return false; } -function prefixIssues(path8, issues) { +function prefixIssues(path10, issues) { return issues.map((iss) => { var _a3; (_a3 = iss).path ?? (_a3.path = []); - iss.path.unshift(path8); + iss.path.unshift(path10); return iss; }); } @@ -15029,16 +15029,16 @@ function flattenError(error2, mapper = (issue2) => issue2.message) { } function formatError(error2, mapper = (issue2) => issue2.message) { const fieldErrors = { _errors: [] }; - const processError = (error3, path8 = []) => { + const processError = (error3, path10 = []) => { for (const issue2 of error3.issues) { if (issue2.code === "invalid_union" && issue2.errors.length) { - issue2.errors.map((issues) => processError({ issues }, [...path8, ...issue2.path])); + issue2.errors.map((issues) => processError({ issues }, [...path10, ...issue2.path])); } else if (issue2.code === "invalid_key") { - processError({ issues: issue2.issues }, [...path8, ...issue2.path]); + processError({ issues: issue2.issues }, [...path10, ...issue2.path]); } else if (issue2.code === "invalid_element") { - processError({ issues: issue2.issues }, [...path8, ...issue2.path]); + processError({ issues: issue2.issues }, [...path10, ...issue2.path]); } else { - const fullpath = [...path8, ...issue2.path]; + const fullpath = [...path10, ...issue2.path]; if (fullpath.length === 0) { fieldErrors._errors.push(mapper(issue2)); } else { @@ -22982,9 +22982,9 @@ var StdioServerTransport = class { }; // src/index.ts -import path7 from "node:path"; -import fs7 from "node:fs"; -import { homedir as homedir4 } from "node:os"; +import path9 from "node:path"; +import fs9 from "node:fs"; +import { homedir as homedir6 } from "node:os"; // node_modules/@modelcontextprotocol/sdk/dist/esm/experimental/tasks/client.js var ExperimentalClientTasks = class { @@ -25958,22 +25958,350 @@ function runToolAnnotations(enableHitl, clientSupportsElicitation) { return enableHitl && clientSupportsElicitation ? { readOnlyHint: true } : void 0; } -// src/url-config-store.ts +// src/tools/submit-feedback.ts +import fs6 from "node:fs"; +import path6 from "node:path"; +import { homedir as homedir3 } from "node:os"; + +// src/session-transcript.ts import fs5 from "node:fs"; import path5 from "node:path"; import { homedir as homedir2 } from "node:os"; +var DEFAULT_MAX_BYTES = 25 * 1024 * 1024; +function locateTranscript(sessionId, opts = {}) { + if (!sessionId) return null; + const projectsDir = opts.projectsDir ?? process.env.GLEAN_CLAUDE_PROJECTS_DIR ?? path5.join(homedir2(), ".claude", "projects"); + const claude = findClaudeTranscript(projectsDir, sessionId); + if (claude) return { path: claude, host: "claude-code" }; + const codexDir = opts.codexSessionsDir ?? process.env.GLEAN_CODEX_SESSIONS_DIR ?? path5.join(homedir2(), ".codex", "sessions"); + const codex = findCodexTranscript(codexDir, sessionId); + if (codex) return { path: codex, host: "codex" }; + return null; +} +function readTranscript(filePath, maxBytes = feedbackMaxBytes()) { + const totalBytes = fs5.statSync(filePath).size; + if (totalBytes <= maxBytes) { + const text = fs5.readFileSync(filePath, "utf-8"); + return { text, bytes: Buffer.byteLength(text), totalBytes, truncated: false }; + } + const fd = fs5.openSync(filePath, "r"); + try { + const buf = Buffer.alloc(maxBytes); + fs5.readSync(fd, buf, 0, maxBytes, totalBytes - maxBytes); + let text = buf.toString("utf-8"); + const nl = text.indexOf("\n"); + if (nl >= 0) text = text.slice(nl + 1); + return { text, bytes: Buffer.byteLength(text), totalBytes, truncated: true }; + } finally { + fs5.closeSync(fd); + } +} +function feedbackMaxBytes() { + const raw = process.env.GLEAN_FEEDBACK_MAX_BYTES; + if (!raw) return DEFAULT_MAX_BYTES; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_BYTES; +} +function findClaudeTranscript(projectsDir, sessionId) { + const fileName = `${sessionId}.jsonl`; + let entries; + try { + entries = fs5.readdirSync(projectsDir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidate = path5.join(projectsDir, entry.name, fileName); + if (isFile(candidate)) return candidate; + } + return null; +} +function findCodexTranscript(sessionsDir, sessionId) { + return walkForSuffix( + sessionsDir, + `-${sessionId}.jsonl`, + 4 + /*maxDepth*/ + ); +} +function walkForSuffix(dir, suffix, maxDepth) { + if (maxDepth < 0) return null; + let entries; + try { + entries = fs5.readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + const full = path5.join(dir, entry.name); + if (entry.isFile() && entry.name.endsWith(suffix)) return full; + if (entry.isDirectory()) { + const found = walkForSuffix(full, suffix, maxDepth - 1); + if (found) return found; + } + } + return null; +} +function isFile(p) { + try { + return fs5.statSync(p).isFile(); + } catch { + return false; + } +} + +// src/redact.ts +var placeholder = (kind) => `\xABredacted:${kind}\xBB`; +var REDACTORS = [ + // PEM / OpenSSH key blocks (private keys, certificates). + { + kind: "pem-block", + pattern: /-----BEGIN[^-]*-----[\s\S]*?-----END[^-]*-----/g, + replacement: placeholder("pem-block") + }, + // JSON Web Tokens (header.payload.signature). + { + kind: "jwt", + pattern: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, + replacement: placeholder("jwt") + }, + // OpenAI-style keys (sk-, sk-proj-, etc.). + { + kind: "openai-key", + pattern: /\bsk-[A-Za-z0-9_-]{16,}/g, + replacement: placeholder("openai-key") + }, + // AWS access key ids. + { + kind: "aws-access-key", + pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, + replacement: placeholder("aws-access-key") + }, + // GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_). + { + kind: "github-token", + pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}/g, + replacement: placeholder("github-token") + }, + // Slack tokens (xoxb-, xoxp-, xoxa-, xoxr-, xoxs-). + { + kind: "slack-token", + pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g, + replacement: placeholder("slack-token") + }, + // Google API keys. + { + kind: "google-api-key", + pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, + replacement: placeholder("google-api-key") + }, + // Credentials embedded in a URL authority (scheme://user:pass@host). + { + kind: "url-credentials", + pattern: /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/:@]+@/gi, + replacement: (_m, scheme) => `${scheme}${placeholder("url-credentials")}@` + }, + // Bearer tokens. + { + kind: "bearer-token", + pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{10,}/gi, + replacement: `Bearer ${placeholder("bearer-token")}` + }, + // Authorization headers (any scheme, e.g. "Basic "). Capture the whole + // value to end-of-line or a closing quote so the scheme AND the credential go. + { + kind: "authorization-header", + pattern: /\bAuthorization\s*[:=]\s*[^\r\n'"]+/gi, + replacement: `Authorization: ${placeholder("authorization-header")}` + }, + // Generic `key = value` / `key: value` secret assignments. The value class + // excludes the guillemets so a span already replaced above is not re-wrapped. + { + kind: "secret-assignment", + pattern: /\b(api[_-]?key|apikey|secret|token|password|passwd|pwd|access[_-]?token|client[_-]?secret)(["']?\s*[:=]\s*["']?)([^\s"',«»]{6,})/gi, + replacement: (_m, key, sep) => `${key}${sep}${placeholder("secret-assignment")}` + } + // PII extension point: a future pass (e.g. Presidio) for names/emails/phones + // would be appended here. Deliberately omitted — PII may be sent if it helps + // debugging (per the security review), so it is NOT redacted by default. +]; +function redactSecrets(text) { + const counts = {}; + let out = text; + for (const { kind, pattern, replacement } of REDACTORS) { + out = out.replace(pattern, (...args) => { + counts[kind] = (counts[kind] ?? 0) + 1; + return typeof replacement === "function" ? replacement(...args.slice(0, -2)) : replacement; + }); + } + return { text: out, counts }; +} + +// src/tools/submit-feedback.ts +var DEFAULT_CONSENT_TIMEOUT_MS = 3e5; +var CONSENT_MESSAGE = "Glean would like to attach this session's logs to your feedback.\nThis includes your prompts and the activity of OTHER tools/MCP servers in this session \u2014 not just Glean's \u2014 and would be sent to Glean to debug the plugin. Credentials are redacted on a best-effort basis before anything is stored.\nAllow capturing and sending this session?"; +var SUBMIT_FEEDBACK_TOOL = { + name: "submit_feedback", + description: "Record the user's feedback (thumbs up or down, with an optional comment) on Glean's help in this session. Call this after completing a task end-to-end, or when the user expresses satisfaction or dissatisfaction with Glean. When the admin has enabled session-log capture, submitting feedback will \u2014 after the user explicitly consents \u2014 also capture this session's transcript (the user's prompts and other tools'/MCP servers' activity) so Glean can debug multi-step skill orchestrations that run inside the host. Secrets are redacted best-effort before anything is stored.", + inputSchema: { + type: "object", + properties: { + vote: { + type: "string", + enum: ["up", "down"], + description: `The user's sentiment: "up" (helpful) or "down" (not helpful).` + }, + comment: { + type: "string", + description: "Optional free-text comment explaining the rating." + } + }, + required: ["vote"] + } +}; +function submitFeedbackAnnotations(clientSupportsElicitation) { + return clientSupportsElicitation ? { readOnlyHint: true } : void 0; +} +async function handleSubmitFeedback(mcpServer, args, log) { + const vote = args.vote; + if (vote !== "up" && vote !== "down") { + return textResult('vote must be "up" or "down"', true); + } + const comment = typeof args.comment === "string" ? args.comment : ""; + log?.("submit_feedback.vote", { vote, hasComment: comment.length > 0 }); + if (process.env.GLEAN_SESSION_CAPTURE !== "true") { + return textResult( + "Feedback recorded. Session-log capture is disabled by your admin, so no transcript was collected." + ); + } + const sessionId = resolveSessionId(); + const loc = locateTranscript(sessionId); + if (!loc) { + return textResult( + "Feedback recorded. No local session transcript was found for this host, so nothing was captured." + ); + } + if (userOptedOut()) { + log?.("submit_feedback.user-opted-out"); + return textResult( + "Feedback recorded. You have opted out of session-log capture, so no transcript was collected." + ); + } + if (!mcpServer.getClientCapabilities()?.elicitation) { + return textResult( + "Feedback recorded. This client can't show a consent prompt, so the session transcript was not captured." + ); + } + try { + const result = await mcpServer.elicitInput( + { + message: CONSENT_MESSAGE, + requestedSchema: { type: "object", properties: {} } + }, + { timeout: consentTimeoutMs() } + ); + log?.("submit_feedback.consent", { action: result.action }); + if (result.action !== "accept") { + return textResult( + `Feedback recorded. Session capture was ${result.action === "decline" ? "declined" : "cancelled"}; no transcript was collected.` + ); + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + log?.("submit_feedback.consent-failed", { msg: detail }); + return textResult( + `Feedback recorded. The consent prompt failed (${detail}); no transcript was captured.` + ); + } + try { + const read = readTranscript(loc.path); + const { text: redacted, counts } = redactSecrets(read.text); + const redactionTotal = Object.values(counts).reduce((a, b) => a + b, 0); + const artifactPath = writeArtifact({ + vote, + comment, + sessionId, + host: loc.host, + sourcePath: loc.path, + truncated: read.truncated, + redactionCounts: counts, + transcript: redacted + }); + log?.("submit_feedback.captured", { + host: loc.host, + bytes: Buffer.byteLength(redacted), + truncated: read.truncated, + redactions: redactionTotal + }); + log?.("submit_feedback.upload-stubbed", { artifactPath }); + return textResult( + `Feedback recorded and session captured (consent granted). +Host: ${loc.host} +Artifact: ${artifactPath} (${Buffer.byteLength(redacted)} bytes${read.truncated ? ", tail-truncated" : ""}) +Credential redactions: ${redactionTotal} +Note: upload to Glean is stubbed in this POC \u2014 the artifact is stored locally only.` + ); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + log?.("submit_feedback.capture-failed", { msg: detail }); + return textResult( + `Feedback recorded, but capturing the session transcript failed: ${detail}` + ); + } +} +function writeArtifact(artifact) { + const dir = path6.join(pluginDataDir(), "feedback"); + fs6.mkdirSync(dir, { recursive: true, mode: 448 }); + const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); + const file = path6.join(dir, `${artifact.sessionId}-${ts}.json`); + fs6.writeFileSync( + file, + JSON.stringify({ capturedAt: (/* @__PURE__ */ new Date()).toISOString(), ...artifact }, null, 2), + { mode: 384 } + ); + fs6.chmodSync(file, 384); + return file; +} +function userOptedOut() { + try { + return fs6.statSync(path6.join(pluginDataDir(), "feedback-capture-optout")).isFile(); + } catch { + return false; + } +} +function pluginDataDir() { + return process.env.PLUGIN_DATA_DIR || path6.join(homedir3(), ".glean"); +} +function consentTimeoutMs() { + const raw = process.env.HITL_TIMEOUT_MS; + if (!raw) return DEFAULT_CONSENT_TIMEOUT_MS; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_CONSENT_TIMEOUT_MS; +} +function textResult(text, isError = false) { + return { + content: [{ type: "text", text }], + ...isError ? { isError: true } : {} + }; +} + +// src/url-config-store.ts +import fs7 from "node:fs"; +import path7 from "node:path"; +import { homedir as homedir4 } from "node:os"; var CONFIG_FILENAME = "mcp-server-url.json"; var DIR_MODE2 = 448; var FILE_MODE2 = 384; function resolveConfigDir() { - return process.env.PLUGIN_DATA_DIR || path5.join(homedir2(), ".glean"); + return process.env.PLUGIN_DATA_DIR || path7.join(homedir4(), ".glean"); } function configFile() { - return path5.join(resolveConfigDir(), CONFIG_FILENAME); + return path7.join(resolveConfigDir(), CONFIG_FILENAME); } function loadServerUrl() { try { - const raw = fs5.readFileSync(configFile(), "utf-8"); + const raw = fs7.readFileSync(configFile(), "utf-8"); const data = JSON.parse(raw); if (typeof data.serverUrl !== "string" || !data.serverUrl) return void 0; return data.serverUrl; @@ -25983,39 +26311,39 @@ function loadServerUrl() { } function saveServerUrl(url2) { const filePath = configFile(); - const dir = path5.dirname(filePath); - fs5.mkdirSync(dir, { recursive: true, mode: DIR_MODE2 }); - fs5.chmodSync(dir, DIR_MODE2); + const dir = path7.dirname(filePath); + fs7.mkdirSync(dir, { recursive: true, mode: DIR_MODE2 }); + fs7.chmodSync(dir, DIR_MODE2); const data = { serverUrl: url2 }; - fs5.writeFileSync(filePath, JSON.stringify(data, null, 2), { + fs7.writeFileSync(filePath, JSON.stringify(data, null, 2), { encoding: "utf-8", mode: FILE_MODE2 }); - fs5.chmodSync(filePath, FILE_MODE2); + fs7.chmodSync(filePath, FILE_MODE2); } function clearServerUrl() { try { - fs5.rmSync(configFile(), { force: true }); + fs7.rmSync(configFile(), { force: true }); } catch { } } // src/remote-tools-cache-store.ts -import fs6 from "node:fs"; -import path6 from "node:path"; -import { homedir as homedir3 } from "node:os"; +import fs8 from "node:fs"; +import path8 from "node:path"; +import { homedir as homedir5 } from "node:os"; var CACHE_FILENAME = "remote-tools-cache.json"; var DIR_MODE3 = 448; var FILE_MODE3 = 384; function resolveCacheDir() { - return process.env.PLUGIN_DATA_DIR || path6.join(homedir3(), ".glean"); + return process.env.PLUGIN_DATA_DIR || path8.join(homedir5(), ".glean"); } function cacheFile() { - return path6.join(resolveCacheDir(), CACHE_FILENAME); + return path8.join(resolveCacheDir(), CACHE_FILENAME); } function readStore() { try { - const raw = fs6.readFileSync(cacheFile(), "utf-8"); + const raw = fs8.readFileSync(cacheFile(), "utf-8"); const data = JSON.parse(raw); if (data && typeof data === "object" && !Array.isArray(data)) { return data; @@ -26027,14 +26355,14 @@ function readStore() { } function writeStore(store) { const filePath = cacheFile(); - const dir = path6.dirname(filePath); - fs6.mkdirSync(dir, { recursive: true, mode: DIR_MODE3 }); - fs6.chmodSync(dir, DIR_MODE3); - fs6.writeFileSync(filePath, JSON.stringify(store, null, 2), { + const dir = path8.dirname(filePath); + fs8.mkdirSync(dir, { recursive: true, mode: DIR_MODE3 }); + fs8.chmodSync(dir, DIR_MODE3); + fs8.writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: FILE_MODE3 }); - fs6.chmodSync(filePath, FILE_MODE3); + fs8.chmodSync(filePath, FILE_MODE3); } function loadRemoteTools(serverUrl) { if (!serverUrl) return []; @@ -26057,14 +26385,14 @@ function saveRemoteTools(serverUrl, tools) { function clearRemoteTools(serverUrl) { try { if (!serverUrl) { - fs6.rmSync(cacheFile(), { force: true }); + fs8.rmSync(cacheFile(), { force: true }); return; } const store = readStore(); if (store[serverUrl] !== void 0) { delete store[serverUrl]; if (Object.keys(store).length === 0) { - fs6.rmSync(cacheFile(), { force: true }); + fs8.rmSync(cacheFile(), { force: true }); } else { writeStore(store); } @@ -26253,14 +26581,14 @@ var EMAIL_RESOLVE_FAILED_TEXT = `Double-check the email for typos and try again var SETUP_NEEDED_ERROR = "Glean is not configured yet. Call the `setup` tool first to provide your Glean Server URL before using find_skills or run_tool."; var AUTH_REDIRECT_TO_SETUP_TEXT = "[SETUP_REQUIRED]\n\nAuthentication is required. Call the `setup` tool (no arguments) to sign in to Glean, then retry this tool."; function resolveLogPath() { - const base = process.env.PLUGIN_DATA_DIR || path7.join(homedir4(), ".glean"); - return path7.join(base, "glean-server.log"); + const base = process.env.PLUGIN_DATA_DIR || path9.join(homedir6(), ".glean"); + return path9.join(base, "glean-server.log"); } var LOG_PATH = resolveLogPath(); try { - const logDir = path7.dirname(LOG_PATH); - fs7.mkdirSync(logDir, { recursive: true, mode: 448 }); - fs7.chmodSync(logDir, 448); + const logDir = path9.dirname(LOG_PATH); + fs9.mkdirSync(logDir, { recursive: true, mode: 448 }); + fs9.chmodSync(logDir, 448); } catch { } function logLine(label, detail) { @@ -26269,8 +26597,8 @@ function logLine(label, detail) { const line = `${ts} ${label}${suffix} `; try { - fs7.appendFileSync(LOG_PATH, line, { mode: 384 }); - fs7.chmodSync(LOG_PATH, 384); + fs9.appendFileSync(LOG_PATH, line, { mode: 384 }); + fs9.chmodSync(LOG_PATH, 384); } catch { } console.error(line.trimEnd()); @@ -26279,7 +26607,7 @@ function resolveSkillsBaseDir() { if (process.env.SKILLS_BASE_DIR) { return process.env.SKILLS_BASE_DIR; } - return path7.join("/tmp", "glean-skills-cache"); + return path9.join("/tmp", "glean-skills-cache"); } var server = new Server( { name: "glean", version: "1.0.0" }, @@ -26374,7 +26702,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { !!server.getClientCapabilities()?.elicitation ) }; - const tools = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL]; + const submitFeedback = { + ...SUBMIT_FEEDBACK_TOOL, + annotations: submitFeedbackAnnotations( + !!server.getClientCapabilities()?.elicitation + ) + }; + const tools = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL, submitFeedback]; const serverUrl = resolveServerUrl(); if (!serverUrl) { return { tools: [...tools, ...cachedRemoteTools] }; @@ -26783,6 +27117,8 @@ ${EMAIL_RESOLVE_FAILED_TEXT}` } return await advanceSetup(); } + case "submit_feedback": + return await handleSubmitFeedback(server, args, logLine); default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], diff --git a/src/index.ts b/src/index.ts index 7754707..4a52569 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,11 @@ import { } from "./auth-callback-server.js"; import { handleFindSkills } from "./tools/find-skills.js"; import { handleRunTool, runToolAnnotations } from "./tools/run-tool.js"; +import { + SUBMIT_FEEDBACK_TOOL, + handleSubmitFeedback, + submitFeedbackAnnotations, +} from "./tools/submit-feedback.js"; import { evictStaleSkills } from "./skill-writer.js"; import { loadServerUrl, @@ -275,7 +280,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { !!server.getClientCapabilities()?.elicitation, ), }; - const tools: Tool[] = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL]; + const submitFeedback: Tool = { + ...SUBMIT_FEEDBACK_TOOL, + annotations: submitFeedbackAnnotations( + !!server.getClientCapabilities()?.elicitation, + ), + }; + const tools: Tool[] = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL, submitFeedback]; // Pre-auth gate: tokens() is sync. When unauthenticated (or unconfigured) // skip the remote round-trip — but keep surfacing whatever we successfully @@ -782,6 +793,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return await advanceSetup(); } + case "submit_feedback": + // Local-only: no server URL / token gate. On consent it writes a redacted + // transcript artifact to disk; the upload to Glean is stubbed in this POC. + return await handleSubmitFeedback(server, args, logLine); + default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], diff --git a/src/redact.ts b/src/redact.ts new file mode 100644 index 0000000..61482c2 --- /dev/null +++ b/src/redact.ts @@ -0,0 +1,119 @@ +// Best-effort credential/secret scrubbing for captured session transcripts. +// +// The CISO requirement is to redact CREDENTIALS before a transcript leaves the +// machine; PII (names, emails, etc.) is intentionally left intact because it +// can aid debugging. This is a best-effort regex pass — not a guarantee — so +// the capture flow must still treat the source as potentially sensitive. A +// production pass would layer a dedicated PII/secret engine (e.g. Presidio) on +// top; see the PII extension point at the bottom of REDACTORS. + +export interface RedactionResult { + text: string; + // Number of matches replaced, keyed by redactor kind. Kinds with zero + // matches are omitted so the summary stays compact. + counts: Record; +} + +interface Redactor { + kind: string; + pattern: RegExp; // must be global (/g) so replace() visits every match + // Replacement may use capture groups; the literal placeholder marks where a + // secret was removed. The guillemets are excluded from value char-classes + // below so an already-redacted span is never re-wrapped by a later rule. + replacement: string | ((match: string, ...groups: string[]) => string); +} + +const placeholder = (kind: string) => `«redacted:${kind}»`; + +// Ordered most-structured first. Specific token shapes and multi-line blocks +// run before the generic key=value rule so the precise kind is recorded and +// the generic rule doesn't clobber it. +const REDACTORS: Redactor[] = [ + // PEM / OpenSSH key blocks (private keys, certificates). + { + kind: "pem-block", + pattern: /-----BEGIN[^-]*-----[\s\S]*?-----END[^-]*-----/g, + replacement: placeholder("pem-block"), + }, + // JSON Web Tokens (header.payload.signature). + { + kind: "jwt", + pattern: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, + replacement: placeholder("jwt"), + }, + // OpenAI-style keys (sk-, sk-proj-, etc.). + { + kind: "openai-key", + pattern: /\bsk-[A-Za-z0-9_-]{16,}/g, + replacement: placeholder("openai-key"), + }, + // AWS access key ids. + { + kind: "aws-access-key", + pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, + replacement: placeholder("aws-access-key"), + }, + // GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_). + { + kind: "github-token", + pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}/g, + replacement: placeholder("github-token"), + }, + // Slack tokens (xoxb-, xoxp-, xoxa-, xoxr-, xoxs-). + { + kind: "slack-token", + pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g, + replacement: placeholder("slack-token"), + }, + // Google API keys. + { + kind: "google-api-key", + pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g, + replacement: placeholder("google-api-key"), + }, + // Credentials embedded in a URL authority (scheme://user:pass@host). + { + kind: "url-credentials", + pattern: /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/:@]+@/gi, + replacement: (_m, scheme: string) => `${scheme}${placeholder("url-credentials")}@`, + }, + // Bearer tokens. + { + kind: "bearer-token", + pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{10,}/gi, + replacement: `Bearer ${placeholder("bearer-token")}`, + }, + // Authorization headers (any scheme, e.g. "Basic "). Capture the whole + // value to end-of-line or a closing quote so the scheme AND the credential go. + { + kind: "authorization-header", + pattern: /\bAuthorization\s*[:=]\s*[^\r\n'"]+/gi, + replacement: `Authorization: ${placeholder("authorization-header")}`, + }, + // Generic `key = value` / `key: value` secret assignments. The value class + // excludes the guillemets so a span already replaced above is not re-wrapped. + { + kind: "secret-assignment", + pattern: + /\b(api[_-]?key|apikey|secret|token|password|passwd|pwd|access[_-]?token|client[_-]?secret)(["']?\s*[:=]\s*["']?)([^\s"',«»]{6,})/gi, + replacement: (_m, key: string, sep: string) => + `${key}${sep}${placeholder("secret-assignment")}`, + }, + // PII extension point: a future pass (e.g. Presidio) for names/emails/phones + // would be appended here. Deliberately omitted — PII may be sent if it helps + // debugging (per the security review), so it is NOT redacted by default. +]; + +export function redactSecrets(text: string): RedactionResult { + const counts: Record = {}; + let out = text; + for (const { kind, pattern, replacement } of REDACTORS) { + out = out.replace(pattern, (...args: unknown[]) => { + counts[kind] = (counts[kind] ?? 0) + 1; + return typeof replacement === "function" + ? (replacement as (...a: string[]) => string)(...(args.slice(0, -2) as string[])) + : replacement; + }); + } + return { text: out, counts }; +} diff --git a/src/session-transcript.ts b/src/session-transcript.ts new file mode 100644 index 0000000..290a8b9 --- /dev/null +++ b/src/session-transcript.ts @@ -0,0 +1,154 @@ +// Locating and reading the HOST's session transcript from local disk. +// +// The plugin is a local stdio process; the host writes the full session +// transcript (the user's prompts + EVERY tool/MCP server's calls, not just +// Glean's) to a per-session file named by the session id. resolveSessionId() +// already yields that id (GLEAN_SESSION_ID), so we can find the file: +// Claude Code: ~/.claude/projects//.jsonl +// Codex: ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl +// Cursor exposes no session-id env var and stores chats in an opaque SQLite +// blob, so it is not addressable here — locateTranscript returns null and the +// caller skips capture gracefully. + +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; + +export type TranscriptHost = "claude-code" | "codex"; + +export interface TranscriptLocation { + path: string; + host: TranscriptHost; +} + +export interface TranscriptRead { + text: string; + bytes: number; // bytes returned (after any truncation) + totalBytes: number; // original file size on disk + truncated: boolean; +} + +interface LocateOptions { + projectsDir?: string; // default ~/.claude/projects + codexSessionsDir?: string; // default ~/.codex/sessions +} + +const DEFAULT_MAX_BYTES = 25 * 1024 * 1024; + +export function locateTranscript( + sessionId: string, + opts: LocateOptions = {}, +): TranscriptLocation | null { + if (!sessionId) return null; + + const projectsDir = + opts.projectsDir ?? + process.env.GLEAN_CLAUDE_PROJECTS_DIR ?? + path.join(homedir(), ".claude", "projects"); + const claude = findClaudeTranscript(projectsDir, sessionId); + if (claude) return { path: claude, host: "claude-code" }; + + const codexDir = + opts.codexSessionsDir ?? + process.env.GLEAN_CODEX_SESSIONS_DIR ?? + path.join(homedir(), ".codex", "sessions"); + const codex = findCodexTranscript(codexDir, sessionId); + if (codex) return { path: codex, host: "codex" }; + + return null; +} + +// Read the transcript, capping at maxBytes. Transcripts can be large; when +// oversize we keep the TAIL because the most recent activity is the most +// relevant to the feedback being given. +export function readTranscript( + filePath: string, + maxBytes: number = feedbackMaxBytes(), +): TranscriptRead { + const totalBytes = fs.statSync(filePath).size; + if (totalBytes <= maxBytes) { + const text = fs.readFileSync(filePath, "utf-8"); + return { text, bytes: Buffer.byteLength(text), totalBytes, truncated: false }; + } + + const fd = fs.openSync(filePath, "r"); + try { + const buf = Buffer.alloc(maxBytes); + fs.readSync(fd, buf, 0, maxBytes, totalBytes - maxBytes); + // Drop the leading partial line so the tail starts at a clean JSONL record. + let text = buf.toString("utf-8"); + const nl = text.indexOf("\n"); + if (nl >= 0) text = text.slice(nl + 1); + return { text, bytes: Buffer.byteLength(text), totalBytes, truncated: true }; + } finally { + fs.closeSync(fd); + } +} + +export function feedbackMaxBytes(): number { + const raw = process.env.GLEAN_FEEDBACK_MAX_BYTES; + if (!raw) return DEFAULT_MAX_BYTES; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_BYTES; +} + +// The UUID filename is globally unique, so scan project subdirs for it rather +// than reconstruct Claude Code's lossy cwd-encoding scheme. +function findClaudeTranscript( + projectsDir: string, + sessionId: string, +): string | null { + const fileName = `${sessionId}.jsonl`; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(projectsDir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidate = path.join(projectsDir, entry.name, fileName); + if (isFile(candidate)) return candidate; + } + return null; +} + +// Codex names files rollout--.jsonl under a YYYY/MM/DD tree; match on +// the trailing uuid segment. +function findCodexTranscript( + sessionsDir: string, + sessionId: string, +): string | null { + return walkForSuffix(sessionsDir, `-${sessionId}.jsonl`, 4 /*maxDepth*/); +} + +function walkForSuffix( + dir: string, + suffix: string, + maxDepth: number, +): string | null { + if (maxDepth < 0) return null; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isFile() && entry.name.endsWith(suffix)) return full; + if (entry.isDirectory()) { + const found = walkForSuffix(full, suffix, maxDepth - 1); + if (found) return found; + } + } + return null; +} + +function isFile(p: string): boolean { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } +} diff --git a/src/tools/submit-feedback.ts b/src/tools/submit-feedback.ts new file mode 100644 index 0000000..390b5b0 --- /dev/null +++ b/src/tools/submit-feedback.ts @@ -0,0 +1,234 @@ +// The plugin-owned `submit_feedback` tool. Records a thumbs up/down + optional +// comment (the lightweight feedback), and — when the admin has enabled capture +// and the user consents — attaches this session's transcript so Glean can debug +// multi-step skill orchestrations that run inside the host. +// +// Privacy controls (per the security review): +// admin opt-out -> GLEAN_SESSION_CAPTURE env gate (default off) +// user opt-out -> per-event consent elicitation + a persistent marker file +// credentials -> best-effort redaction before anything is written +// This POC writes a redacted artifact to local disk; the upload to Glean is +// stubbed. + +import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; +import { resolveSessionId } from "../session-id.js"; +import { locateTranscript, readTranscript } from "../session-transcript.js"; +import { redactSecrets } from "../redact.js"; + +// Matches index.ts's logLine signature; kept local so this tool doesn't depend +// on a shared logging type. +type LogFn = (label: string, detail?: Record) => void; + +const DEFAULT_CONSENT_TIMEOUT_MS = 300_000; + +const CONSENT_MESSAGE = + "Glean would like to attach this session's logs to your feedback.\n" + + "This includes your prompts and the activity of OTHER tools/MCP servers in " + + "this session — not just Glean's — and would be sent to Glean to debug the " + + "plugin. Credentials are redacted on a best-effort basis before anything is " + + "stored.\n" + + "Allow capturing and sending this session?"; + +export const SUBMIT_FEEDBACK_TOOL: Tool = { + name: "submit_feedback", + description: + "Record the user's feedback (thumbs up or down, with an optional comment) on " + + "Glean's help in this session. Call this after completing a task end-to-end, or " + + "when the user expresses satisfaction or dissatisfaction with Glean. When the " + + "admin has enabled session-log capture, submitting feedback will — after the " + + "user explicitly consents — also capture this session's transcript (the user's " + + "prompts and other tools'/MCP servers' activity) so Glean can debug multi-step " + + "skill orchestrations that run inside the host. Secrets are redacted best-effort " + + "before anything is stored.", + inputSchema: { + type: "object" as const, + properties: { + vote: { + type: "string", + enum: ["up", "down"], + description: + 'The user\'s sentiment: "up" (helpful) or "down" (not helpful).', + }, + comment: { + type: "string", + description: "Optional free-text comment explaining the rating.", + }, + }, + required: ["vote"], + }, +}; + +// Mirror runToolAnnotations: when our own consent elicitation is the gate, mark +// the tool readOnly so the host doesn't also raise its native confirmation and +// double-prompt the user. +export function submitFeedbackAnnotations( + clientSupportsElicitation: boolean, +): Tool["annotations"] { + return clientSupportsElicitation ? { readOnlyHint: true } : undefined; +} + +export async function handleSubmitFeedback( + mcpServer: Server, + args: Record, + log?: LogFn, +): Promise { + const vote = args.vote; + if (vote !== "up" && vote !== "down") { + return textResult('vote must be "up" or "down"', true); + } + const comment = typeof args.comment === "string" ? args.comment : ""; + + // The vote+comment is the lightweight feedback and is always acknowledged; + // everything below governs only the optional transcript capture. + log?.("submit_feedback.vote", { vote, hasComment: comment.length > 0 }); + + // (Admin opt-out) Default off; a deployment opts in via the launcher env. + if (process.env.GLEAN_SESSION_CAPTURE !== "true") { + return textResult( + "Feedback recorded. Session-log capture is disabled by your admin, so no transcript was collected.", + ); + } + + const sessionId = resolveSessionId(); + const loc = locateTranscript(sessionId); + if (!loc) { + return textResult( + "Feedback recorded. No local session transcript was found for this host, so nothing was captured.", + ); + } + + // (User opt-out, persistent) Skip without prompting. + if (userOptedOut()) { + log?.("submit_feedback.user-opted-out"); + return textResult( + "Feedback recorded. You have opted out of session-log capture, so no transcript was collected.", + ); + } + + // (User opt-out, per-event) Consent is mandatory: with no elicitation surface + // we cannot obtain it, so we must NOT capture. + if (!mcpServer.getClientCapabilities()?.elicitation) { + return textResult( + "Feedback recorded. This client can't show a consent prompt, so the session transcript was not captured.", + ); + } + try { + const result = await mcpServer.elicitInput( + { + message: CONSENT_MESSAGE, + requestedSchema: { type: "object", properties: {} } as any, + }, + { timeout: consentTimeoutMs() }, + ); + log?.("submit_feedback.consent", { action: result.action }); + if (result.action !== "accept") { + return textResult( + `Feedback recorded. Session capture was ${result.action === "decline" ? "declined" : "cancelled"}; no transcript was collected.`, + ); + } + } catch (err) { + // Fail CLOSED — a consent prompt that errors or times out must not capture. + const detail = err instanceof Error ? err.message : String(err); + log?.("submit_feedback.consent-failed", { msg: detail }); + return textResult( + `Feedback recorded. The consent prompt failed (${detail}); no transcript was captured.`, + ); + } + + // Consent granted — capture, redact, write the local artifact. + try { + const read = readTranscript(loc.path); + const { text: redacted, counts } = redactSecrets(read.text); + const redactionTotal = Object.values(counts).reduce((a, b) => a + b, 0); + const artifactPath = writeArtifact({ + vote, + comment, + sessionId, + host: loc.host, + sourcePath: loc.path, + truncated: read.truncated, + redactionCounts: counts, + transcript: redacted, + }); + log?.("submit_feedback.captured", { + host: loc.host, + bytes: Buffer.byteLength(redacted), + truncated: read.truncated, + redactions: redactionTotal, + }); + // Upload to Glean is STUBBED in this POC — the artifact stays local. + log?.("submit_feedback.upload-stubbed", { artifactPath }); + return textResult( + `Feedback recorded and session captured (consent granted).\n` + + `Host: ${loc.host}\n` + + `Artifact: ${artifactPath} (${Buffer.byteLength(redacted)} bytes${read.truncated ? ", tail-truncated" : ""})\n` + + `Credential redactions: ${redactionTotal}\n` + + `Note: upload to Glean is stubbed in this POC — the artifact is stored locally only.`, + ); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + log?.("submit_feedback.capture-failed", { msg: detail }); + return textResult( + `Feedback recorded, but capturing the session transcript failed: ${detail}`, + ); + } +} + +interface FeedbackArtifact { + vote: string; + comment: string; + sessionId: string; + host: string; + sourcePath: string; + truncated: boolean; + redactionCounts: Record; + transcript: string; +} + +function writeArtifact(artifact: FeedbackArtifact): string { + const dir = path.join(pluginDataDir(), "feedback"); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const file = path.join(dir, `${artifact.sessionId}-${ts}.json`); + fs.writeFileSync( + file, + JSON.stringify({ capturedAt: new Date().toISOString(), ...artifact }, null, 2), + { mode: 0o600 }, + ); + fs.chmodSync(file, 0o600); + return file; +} + +function userOptedOut(): boolean { + try { + return fs + .statSync(path.join(pluginDataDir(), "feedback-capture-optout")) + .isFile(); + } catch { + return false; + } +} + +function pluginDataDir(): string { + return process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); +} + +function consentTimeoutMs(): number { + const raw = process.env.HITL_TIMEOUT_MS; + if (!raw) return DEFAULT_CONSENT_TIMEOUT_MS; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_CONSENT_TIMEOUT_MS; +} + +function textResult(text: string, isError = false): CallToolResult { + return { + content: [{ type: "text", text }], + ...(isError ? { isError: true } : {}), + }; +} diff --git a/tests/redact.test.ts b/tests/redact.test.ts new file mode 100644 index 0000000..41378cc --- /dev/null +++ b/tests/redact.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { redactSecrets } from "../src/redact.js"; + +describe("redactSecrets", () => { + it("redacts an OpenAI-style key", () => { + const { text, counts } = redactSecrets("token sk-abcdef0123456789ABCDEFGHIJ done"); + expect(text).not.toContain("sk-abcdef0123456789"); + expect(text).toContain("«redacted:openai-key»"); + expect(counts["openai-key"]).toBe(1); + }); + + it("redacts an AWS access key id", () => { + const { text, counts } = redactSecrets("AKIAIOSFODNN7EXAMPLE"); + expect(text).toBe("«redacted:aws-access-key»"); + expect(counts["aws-access-key"]).toBe(1); + }); + + it("redacts a GitHub token", () => { + const { text, counts } = redactSecrets("ghp_1234567890abcdefghij1234567890"); + expect(text).toContain("«redacted:github-token»"); + expect(counts["github-token"]).toBe(1); + }); + + it("redacts a Slack token", () => { + // Assembled at runtime so the source has no coherent token literal (secret + // scanners flag inline Slack tokens even in test fixtures); the runtime + // value still matches the redactor's pattern. + const slackToken = `xoxb-${"1".repeat(12)}-${"abcd".repeat(4)}`; + const { counts } = redactSecrets(slackToken); + expect(counts["slack-token"]).toBe(1); + }); + + it("redacts a JWT", () => { + const jwt = + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const { text, counts } = redactSecrets(`auth ${jwt}`); + expect(text).not.toContain(jwt); + expect(counts["jwt"]).toBe(1); + }); + + it("redacts a PEM private key block", () => { + const pem = + "-----BEGIN RSA PRIVATE KEY-----\nMIIBmid\ndle+lines\n-----END RSA PRIVATE KEY-----"; + const { text, counts } = redactSecrets(`key:\n${pem}\nafter`); + expect(text).not.toContain("MIIBmid"); + expect(text).toContain("«redacted:pem-block»"); + expect(text).toContain("after"); + expect(counts["pem-block"]).toBe(1); + }); + + it("redacts a Bearer token", () => { + const { counts } = redactSecrets("Bearer abcdefghij1234567890"); + expect(counts["bearer-token"]).toBe(1); + }); + + it("redacts an Authorization header", () => { + const { text, counts } = redactSecrets("Authorization: Basic dXNlcjpwYXNzd29yZA=="); + expect(text).not.toContain("dXNlcjpwYXNzd29yZA"); + expect(counts["authorization-header"]).toBe(1); + }); + + it("redacts credentials embedded in a URL", () => { + const { text, counts } = redactSecrets("https://admin:s3cr3tPass@example.com/path"); + expect(text).toBe("https://«redacted:url-credentials»@example.com/path"); + expect(counts["url-credentials"]).toBe(1); + }); + + it("redacts a generic secret assignment", () => { + const { text, counts } = redactSecrets('password = "hunter2longvalue"'); + expect(text).not.toContain("hunter2longvalue"); + expect(counts["secret-assignment"]).toBe(1); + }); + + it("leaves ordinary text untouched and reports no counts", () => { + const input = "The user asked to refactor the login flow and ran the tests."; + const { text, counts } = redactSecrets(input); + expect(text).toBe(input); + expect(counts).toEqual({}); + }); + + it("does NOT redact PII such as emails (kept for debugging)", () => { + const input = "contact alice@example.com about the ticket"; + const { text } = redactSecrets(input); + expect(text).toContain("alice@example.com"); + }); + + it("counts multiple occurrences of the same kind", () => { + const { counts } = redactSecrets( + "sk-aaaaaaaaaaaaaaaaaaaa and sk-bbbbbbbbbbbbbbbbbbbb", + ); + expect(counts["openai-key"]).toBe(2); + }); +}); diff --git a/tests/session-transcript.test.ts b/tests/session-transcript.test.ts new file mode 100644 index 0000000..4f90943 --- /dev/null +++ b/tests/session-transcript.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { locateTranscript, readTranscript } from "../src/session-transcript.js"; + +describe("locateTranscript", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "transcript-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("finds a Claude Code transcript by session-id filename across project dirs", async () => { + const projectsDir = path.join(tmpDir, "projects"); + const projDir = path.join(projectsDir, "-Users-me-some-repo"); + await fs.mkdir(projDir, { recursive: true }); + const id = "a6821330-8bfe-48e9-be0f-96cda782ac26"; + const file = path.join(projDir, `${id}.jsonl`); + await fs.writeFile(file, '{"type":"user"}\n'); + + expect(locateTranscript(id, { projectsDir })).toEqual({ + path: file, + host: "claude-code", + }); + }); + + it("finds a Codex rollout transcript by trailing uuid under the date tree", async () => { + const codexDir = path.join(tmpDir, "sessions"); + const dayDir = path.join(codexDir, "2026", "06", "11"); + await fs.mkdir(dayDir, { recursive: true }); + const id = "019eb4da-6149-7392-89a2-4401106d9b06"; + const file = path.join(dayDir, `rollout-2026-06-11T09-34-31-${id}.jsonl`); + await fs.writeFile(file, '{"type":"session_meta"}\n'); + + expect( + locateTranscript(id, { + projectsDir: path.join(tmpDir, "none"), + codexSessionsDir: codexDir, + }), + ).toEqual({ path: file, host: "codex" }); + }); + + it("returns null when no transcript matches the session id", () => { + expect( + locateTranscript("missing-id", { + projectsDir: path.join(tmpDir, "projects"), + codexSessionsDir: path.join(tmpDir, "sessions"), + }), + ).toBeNull(); + }); + + it("returns null for an empty session id", () => { + expect(locateTranscript("")).toBeNull(); + }); +}); + +describe("readTranscript", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "transcript-read-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("reads the whole file when under the cap", async () => { + const file = path.join(tmpDir, "t.jsonl"); + await fs.writeFile(file, "AAAA\nBBBB\n"); + const read = readTranscript(file, 1000); + expect(read).toMatchObject({ + text: "AAAA\nBBBB\n", + truncated: false, + totalBytes: 10, + }); + }); + + it("keeps the tail and drops the partial leading line when oversize", async () => { + const file = path.join(tmpDir, "t.jsonl"); + await fs.writeFile(file, "AAAA\nBBBB\nCCCC\nDDDD\n"); // 20 bytes + const read = readTranscript(file, 8); + expect(read.truncated).toBe(true); + expect(read.totalBytes).toBe(20); + expect(read.text).toBe("DDDD\n"); + }); +}); diff --git a/tests/submit-feedback.test.ts b/tests/submit-feedback.test.ts new file mode 100644 index 0000000..4a6f8e3 --- /dev/null +++ b/tests/submit-feedback.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import fsSync from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { + handleSubmitFeedback, + submitFeedbackAnnotations, + SUBMIT_FEEDBACK_TOOL, +} from "../src/tools/submit-feedback.js"; + +function makeServer( + opts: { elicitation?: boolean; elicit?: ReturnType } = {}, +) { + return { + getClientCapabilities: vi + .fn() + .mockReturnValue(opts.elicitation === false ? {} : { elicitation: {} }), + getClientVersion: vi + .fn() + .mockReturnValue({ name: "claude-code", version: "1" }), + elicitInput: opts.elicit ?? vi.fn().mockResolvedValue({ action: "accept" }), + } as any; +} + +const SESSION_ID = "11111111-2222-3333-4444-555555555555"; +const SECRET_LINE = + '{"type":"assistant","content":"the key is sk-abcdef0123456789ABCDEFGHIJ"}'; +const OTHER_TOOL_LINE = + '{"type":"assistant","tool":"mcp__conductor__AskUserQuestion"}'; + +describe("handleSubmitFeedback", () => { + let tmpRoot: string; + let projectsDir: string; + let dataDir: string; + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "submit-feedback-test-")); + projectsDir = path.join(tmpRoot, "projects"); + dataDir = path.join(tmpRoot, "data"); + await fs.mkdir(dataDir, { recursive: true }); + const projDir = path.join(projectsDir, "-proj"); + await fs.mkdir(projDir, { recursive: true }); + await fs.writeFile( + path.join(projDir, `${SESSION_ID}.jsonl`), + `${OTHER_TOOL_LINE}\n${SECRET_LINE}\n`, + ); + vi.stubEnv("GLEAN_SESSION_ID", SESSION_ID); + vi.stubEnv("GLEAN_CLAUDE_PROJECTS_DIR", projectsDir); + vi.stubEnv("PLUGIN_DATA_DIR", dataDir); + }); + + afterEach(async () => { + await fs.rm(tmpRoot, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + async function readArtifacts(): Promise { + const dir = path.join(dataDir, "feedback"); + let names: string[]; + try { + names = await fs.readdir(dir); + } catch { + return []; + } + const out: any[] = []; + for (const n of names) { + if (n.endsWith(".json")) { + out.push(JSON.parse(await fs.readFile(path.join(dir, n), "utf-8"))); + } + } + return out; + } + + function textOf(result: { content: unknown[] }): string { + return (result.content[0] as { text: string }).text; + } + + it("rejects an invalid vote", async () => { + const result = await handleSubmitFeedback(makeServer(), { vote: "maybe" }); + expect(result.isError).toBe(true); + }); + + it("skips capture (no prompt) when the admin has not enabled it", async () => { + vi.stubEnv("GLEAN_SESSION_CAPTURE", "false"); + const elicit = vi.fn(); + const result = await handleSubmitFeedback( + makeServer({ elicitation: true, elicit }), + { vote: "down" }, + ); + expect(elicit).not.toHaveBeenCalled(); + expect(await readArtifacts()).toHaveLength(0); + expect(textOf(result)).toContain("disabled by your admin"); + }); + + it("captures, redacts, and writes an artifact on consent", async () => { + vi.stubEnv("GLEAN_SESSION_CAPTURE", "true"); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + + const result = await handleSubmitFeedback( + makeServer({ elicitation: true, elicit }), + { vote: "down", comment: "bad result" }, + ); + + expect(elicit).toHaveBeenCalledTimes(1); + const artifacts = await readArtifacts(); + expect(artifacts).toHaveLength(1); + const a = artifacts[0]; + expect(a.vote).toBe("down"); + expect(a.comment).toBe("bad result"); + expect(a.host).toBe("claude-code"); + // secret scrubbed; other tools' activity preserved (cross-tool capture proof) + expect(a.transcript).not.toContain("sk-abcdef0123456789"); + expect(a.transcript).toContain("mcp__conductor__AskUserQuestion"); + expect(a.redactionCounts["openai-key"]).toBe(1); + expect(textOf(result)).toContain("session captured"); + }); + + it("does NOT capture when the user declines consent", async () => { + vi.stubEnv("GLEAN_SESSION_CAPTURE", "true"); + const elicit = vi.fn().mockResolvedValue({ action: "decline" }); + const result = await handleSubmitFeedback( + makeServer({ elicitation: true, elicit }), + { vote: "down" }, + ); + expect(await readArtifacts()).toHaveLength(0); + expect(textOf(result)).toContain("declined"); + }); + + it("fails closed (no capture) when the consent prompt errors", async () => { + vi.stubEnv("GLEAN_SESSION_CAPTURE", "true"); + const elicit = vi.fn().mockRejectedValue(new Error("timed out")); + await handleSubmitFeedback(makeServer({ elicitation: true, elicit }), { + vote: "up", + }); + expect(await readArtifacts()).toHaveLength(0); + }); + + it("skips capture without prompting when the user has a persistent opt-out", async () => { + vi.stubEnv("GLEAN_SESSION_CAPTURE", "true"); + fsSync.writeFileSync(path.join(dataDir, "feedback-capture-optout"), ""); + const elicit = vi.fn(); + const result = await handleSubmitFeedback( + makeServer({ elicitation: true, elicit }), + { vote: "down" }, + ); + expect(elicit).not.toHaveBeenCalled(); + expect(await readArtifacts()).toHaveLength(0); + expect(textOf(result)).toContain("opted out"); + }); + + it("skips capture when the client cannot show a consent prompt", async () => { + vi.stubEnv("GLEAN_SESSION_CAPTURE", "true"); + const result = await handleSubmitFeedback( + makeServer({ elicitation: false }), + { vote: "down" }, + ); + expect(await readArtifacts()).toHaveLength(0); + expect(textOf(result)).toContain("can't show a consent prompt"); + }); + + it("records feedback but skips capture when no transcript is found", async () => { + vi.stubEnv("GLEAN_SESSION_CAPTURE", "true"); + vi.stubEnv("GLEAN_SESSION_ID", "no-such-session-id"); + const elicit = vi.fn(); + const result = await handleSubmitFeedback( + makeServer({ elicitation: true, elicit }), + { vote: "up" }, + ); + expect(elicit).not.toHaveBeenCalled(); + expect(textOf(result)).toContain("No local session transcript"); + }); +}); + +describe("submitFeedbackAnnotations", () => { + it("marks read-only when the client supports elicitation", () => { + expect(submitFeedbackAnnotations(true)).toEqual({ readOnlyHint: true }); + }); + it("leaves annotations unset otherwise", () => { + expect(submitFeedbackAnnotations(false)).toBeUndefined(); + }); +}); + +describe("SUBMIT_FEEDBACK_TOOL", () => { + it("requires a vote and offers an optional comment", () => { + expect(SUBMIT_FEEDBACK_TOOL.name).toBe("submit_feedback"); + expect((SUBMIT_FEEDBACK_TOOL.inputSchema as any).required).toEqual(["vote"]); + }); +});