diff --git a/.gitignore b/.gitignore index 793dce0..01e8ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules/ # Generated build output (published to the target repos by CI) dist/ +# glean-vnext runtime MCP server bundle (regenerated by build:bundle / prebuild) +sources/glean-vnext/dist/ # OS files .DS_Store diff --git a/docs/glean-vnext-developer-testing.md b/docs/glean-vnext-developer-testing.md new file mode 100644 index 0000000..2e217f6 --- /dev/null +++ b/docs/glean-vnext-developer-testing.md @@ -0,0 +1,114 @@ +# Glean-vnext — Developer Testing Doc + +Merging the glean-vnext runtime MCP server into the existing `glean` emitted plugin. + +## Draft PRs +- **pluginpack** (enables this): https://github.com/gleanwork/pluginpack/pull/11 — `feat: per-target MCP config overrides + plugin-root files` (branch `eshwar/per-target-mcp-and-plugin-root-files`) +- **agent-plugins** (this work): https://github.com/gleanwork/agent-plugins/pull/3 — `feat: fold glean-vnext runtime MCP server into the glean plugin` (branch `eshwar/glean-vnext-fold-into-glean`) + +--- + +## What was done + +The glean-vnext runtime MCP server moved out of `glean-experimental-plugins` into `agent-plugins` as a **source plugin** at `sources/glean-vnext/`, and is **folded into the existing `glean` emitted plugin** via the `from:` array for claude/cursor/codex. The shipped `glean` plugin now **coexists** with the portable skills: + +- It bundles a **local stdio MCP server** named `glean-local` (exposes `find_skills`, `run_tool`, `setup`), the `glean_run` skill, and an auto-approve `PreToolUse` hook. +- Users may **still connect a remote Glean MCP server**; the local server is named `glean-local` to avoid clashing with a remote `glean`. + +### Source plugin layout (`sources/glean-vnext/`) +| File | Purpose | +|---|---| +| `plugin.pluginpack.json` | `files` map → `dist/index.js`, `start.mjs`, `package.json` copied to plugin root | +| `.mcp.json` | base MCP config: server `glean-local`, `node ${CLAUDE_PLUGIN_ROOT}/start.mjs` (claude/cursor) | +| `targets/codex/.mcp.json` | per-target override: `node ./start.mjs` + `cwd "."` (codex) | +| `start.mjs` | launcher that boots the bundled server | +| `package.json` | runtime manifest (`"type": "module"`) | +| `src/` (15 TS files) | server source (auth, tools, remote-client, skill-writer, …) | +| `build.mjs` | esbuild: `src/index.ts` → `dist/index.js` (single-file ESM bundle) | +| `tests/` (14 vitest files) | server unit tests (189 tests) | +| `skills/glean_run/SKILL.md` | the `glean_run` skill | +| `hooks/auto-approve-run-tool.mjs` + `hooks.json` | Claude Code PreToolUse auto-approve for `run_tool` | + +### Config + build changes +- **`pluginpack.config.ts`**: `glean-vnext` added to each target's `glean` `from:`; `hooks` added to claude/cursor `components` (codex unchanged — no hooks). +- **`package.json`**: `build:bundle` (esbuild) chained into `prebuild` (runs before `pluginpack build`); `test:bundle` / `typecheck:bundle`; deps `@modelcontextprotocol/sdk`, `yaml`, `esbuild`, `tsx`, `typescript`, `vitest`, `@types/node`; CVE `overrides` mirroring the experimental repo. +- **Hook**: server env lookup now checks `mcpServers["glean-local"]` first (was `glean`), fallback to legacy `glean` — fixes a production bug introduced by the rename. +- `.gitignore`: `sources/glean-vnext/dist/` (bundle is a build artifact regenerated by `prebuild`). + +--- + +## ⚠️ Release blocker + +agent-plugins depends on **`@gleanwork/pluginpack@^0.7.0`** (published), which lacks the new `files` + per-target `.mcp.json` override features. The work in PR #3 requires **`@gleanwork/pluginpack@^0.8.0`** (PR #11) once published. + +**Until 0.8.0 is published, build locally:** +```bash +cd ../pluginpack && npm install && npm run build && npm link +cd ../agent-plugins && npm install && npm link @gleanwork/pluginpack +``` +**Footgun:** `npm install` clobbers the link (replaces the symlink with the real 0.7.0). Re-run `npm link @gleanwork/pluginpack` after any install, or `npm test` will silently drop to baseline (vnext's `files`/MCP ignored — file counts fall to 66/62/53 instead of 69/65/56). + +**Before merging PR #3:** publish pluginpack 0.8.0, then bump agent-plugins `package.json` `@gleanwork/pluginpack` to `^0.8.0` and drop the `npm link` step. + +--- + +## How to test — automated + +```bash +cd /path/to/agent-plugins +npm link @gleanwork/pluginpack # until 0.8.0 published (see release blocker) +npm test # prebuild → build:bundle → pluginpack build → validate +npm run test:bundle # 189 server unit tests (vitest) +npm run typecheck:bundle # tsc --noEmit on the server +``` +**Expected:** `npm test` green (Validation passed ×3; 69/65/56 managed files cursor/claude/codex); `test:bundle` 189/189; typecheck clean. + +### Verify emitted output +```bash +# claude: .mcp.json uses ${CLAUDE_PLUGIN_ROOT}/start.mjs +cat dist/claude/plugins/glean/.mcp.json +# codex: .mcp.json uses ./start.mjs + cwd "." (per-target override) +cat dist/codex/plugins/glean/.mcp.json +# runtime files shipped at plugin root +ls dist/claude/plugins/glean/{start.mjs,dist/index.js,package.json,hooks/auto-approve-run-tool.mjs} +# glean_run skill alongside the 18 portable skills +ls dist/claude/plugins/glean/skills +# codex manifest references the MCP config +grep mcpServers dist/codex/plugins/glean/.codex-plugin/plugin.json +``` + +--- + +## How to test — manual (host-by-host) + +Point each host at the emitted `dist//` directory and exercise the local server. + +### Claude Code +```bash +cd /path/to/agent-plugins/dist/claude +claude plugin marketplace add . # or symlink the glean plugin dir +``` +- Tools appear as `mcp__glean-local__find_skills` / `run_tool` / `setup`. +- Run `setup` first to authenticate (OAuth to your Glean tenant). +- Invoke the `glean_run` skill; it orchestrates `find_skills` + `run_tool`. +- **Hook check:** with `ENABLE_HITL=true` (set in `.mcp.json`), calling `run_tool` should be auto-approved (no native "allow this tool?" prompt) — the HITL elicitation is the single gate. With `ENABLE_HITL` off, the normal permission flow runs. + +### Cursor +Add `dist/cursor` as a plugin via Cursor's plugin UI. Same tool names; the local `glean-local` server boots via `start.mjs`. Run `setup`, then `find_skills`/`run_tool`. + +### Codex +Install from `dist/codex` (`.agents/plugins/marketplace.json`): +```bash +codex mcp list # should show glean-local +``` +`codex mcp` lists `glean-local`; `.mcp.json` uses `./start.mjs` + `cwd "."` (codex doesn't expand `${CLAUDE_PLUGIN_ROOT}`). Run `setup` → `find_skills` → `run_tool`. No hooks for codex (by design). + +### Regression check (coexist) +Confirm the 18 portable skills still work and that a separately-connected **remote** `glean` MCP server still resolves under its own name — the local `glean-local` must not collide with it. + +--- + +## Architecture pointers +- pluginpack branch: per-target `.mcp.json` override replaces the base config for that target (full `mcpServers` object). Only the `.mcp.json` **file form** supports overrides; the `mcpServers` manifest form does not. +- The bundle is a single-file ESM esbuild output (inlines `@modelcontextprotocol/sdk` + `yaml`) so the shipped tree has no scoped-package paths (the plugin-install validator rejects `@` in zip entry paths). +- See `pluginpack-dataflow.md` for the full source-plugin → emitted-plugin pipeline and the detailed vnext transformation example. diff --git a/package-lock.json b/package-lock.json index 192bc95..0c9ff5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,19 @@ "name": "@gleanwork/agent-plugins", "version": "3.1.0", "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "yaml": "^2.7.0" + }, "devDependencies": { "@gleanwork/pluginpack": "^0.7.0", "@release-it/conventional-changelog": "^11.0.1", - "release-it": "^20.2.0" + "@types/node": "^22.0.0", + "esbuild": "^0.28.1", + "release-it": "^20.2.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" }, "engines": { "node": ">=24" @@ -44,6 +53,448 @@ } } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@gleanwork/pluginpack": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@gleanwork/pluginpack/-/pluginpack-0.7.0.tgz", @@ -64,6 +515,18 @@ "node": ">=22.12.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.15", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", + "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", @@ -408,23 +871,79 @@ } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "license": "MIT", @@ -649,6 +1168,356 @@ "release-it": "^18.0.0 || ^19.0.0 || ^20.0.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", @@ -691,6 +1560,41 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -705,6 +1609,134 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/agent-base": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-8.0.0.tgz", @@ -715,6 +1747,39 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -745,6 +1810,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -785,6 +1860,30 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -821,6 +1920,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/c12": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz", @@ -850,6 +1958,62 @@ } } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -870,6 +2034,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -1005,11 +2179,23 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/content-type": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1146,6 +2332,55 @@ "node": ">=18" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-7.0.0.tgz", @@ -1160,7 +2395,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1174,6 +2408,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1242,6 +2486,15 @@ "quickjs-wasi": "^0.0.1" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -1275,6 +2528,120 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -1321,6 +2688,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1344,6 +2721,117 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -1364,6 +2852,12 @@ "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1398,6 +2892,22 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -1441,6 +2951,69 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -1454,6 +3027,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-uri": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-7.0.0.tgz", @@ -1521,6 +3131,18 @@ "node": ">= 6" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -1559,6 +3181,39 @@ "uglify-js": "^3.1.4" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", @@ -1572,6 +3227,26 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-8.0.0.tgz", @@ -1604,7 +3279,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1621,19 +3295,26 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1748,6 +3429,12 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-ssh": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.1.tgz", @@ -1787,6 +3474,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/issue-parser": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", @@ -1814,6 +3507,22 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", @@ -1828,6 +3537,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-with-bigint": { "version": "3.5.8", "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", @@ -1904,6 +3625,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -1924,6 +3652,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -1937,6 +3697,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1965,7 +3737,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1975,7 +3746,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -2015,7 +3785,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mute-stream": { @@ -2028,6 +3797,34 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -2108,6 +3905,27 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -2115,6 +3933,27 @@ "dev": true, "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -2253,6 +4092,34 @@ "node": ">=14.13.0" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2260,6 +4127,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -2267,6 +4144,13 @@ "dev": true, "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -2280,6 +4164,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", @@ -2292,6 +4185,35 @@ "pathe": "^2.0.3" } }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -2312,6 +4234,19 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-agent": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-7.0.0.tgz", @@ -2349,6 +4284,22 @@ "dev": true, "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2377,6 +4328,34 @@ "dev": true, "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -2478,6 +4457,15 @@ "node": ">=10" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -2516,6 +4504,67 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -2578,7 +4627,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/section-matter": { @@ -2608,6 +4656,157 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2672,6 +4871,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -2715,6 +4924,29 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", @@ -2781,6 +5013,26 @@ "node": ">=0.10.0" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -2839,6 +5091,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2852,6 +5134,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2859,6 +5150,25 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -2872,6 +5182,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -2879,6 +5207,20 @@ "dev": true, "license": "MIT" }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -2903,6 +5245,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/universal-user-agent": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", @@ -2910,6 +5259,15 @@ "dev": true, "license": "ISC" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/url-join": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", @@ -2938,6 +5296,237 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/walk-up-path": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", @@ -2948,6 +5537,38 @@ "node": "20 || >=22" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard-match": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/wildcard-match/-/wildcard-match-5.1.4.tgz", @@ -2991,6 +5612,12 @@ "dev": true, "license": "MIT" }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/wsl-utils": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", @@ -3008,6 +5635,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", @@ -3035,11 +5677,19 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 0c99215..976b116 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,35 @@ "type": "module", "scripts": { "build": "pluginpack build", + "build:bundle": "node sources/glean-vnext/build.mjs", "clean": "pluginpack clean", "prune": "pluginpack prune", + "typecheck:bundle": "tsc --noEmit -p sources/glean-vnext/tsconfig.json", + "test:bundle": "vitest run --root sources/glean-vnext", "validate": "pluginpack validate --target claude --dir dist/claude && pluginpack validate --target cursor --dir dist/cursor && pluginpack validate --target codex --dir dist/codex", "test": "npm run build && npm run validate", - "prebuild": "node scripts/sync-changelog.mjs", + "prebuild": "node scripts/sync-changelog.mjs && npm run build:bundle", "release": "release-it" }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "yaml": "^2.7.0" + }, "devDependencies": { "@gleanwork/pluginpack": "^0.7.0", "@release-it/conventional-changelog": "^11.0.1", - "release-it": "^20.2.0" + "@types/node": "^22.0.0", + "esbuild": "^0.28.1", + "release-it": "^20.2.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^3.0.0" + }, + "//overrides": "Pin hono>=4.12.25 (CVE-2026-54290, transitive from @modelcontextprotocol/sdk); pin esbuild/vite to clear CVE blocks that prevented install. Mirrors glean-experimental-plugins overrides.", + "overrides": { + "hono": "^4.12.25", + "esbuild": "$esbuild", + "vite": "7.3.5" }, "engines": { "node": ">=24" diff --git a/pluginpack.config.ts b/pluginpack.config.ts index 4d94c2f..7fb7dca 100644 --- a/pluginpack.config.ts +++ b/pluginpack.config.ts @@ -34,8 +34,8 @@ export default defineConfig({ }, plugins: { glean: { - from: ["glean-lib", "shared", "claude"], - components: ["skills", "agents"], + from: ["glean-lib", "shared", "claude", "glean-vnext"], + components: ["skills", "agents", "hooks"], displayName: "Glean", description: "Official Glean plugin — search documents, Slack, and email; explore code across repos; find experts and stakeholders; prep for meetings and onboarding.", @@ -62,8 +62,8 @@ export default defineConfig({ }, plugins: { glean: { - from: ["glean-lib", "shared", "cursor"], - components: ["skills", "agents", "rules", "assets"], + from: ["glean-lib", "shared", "cursor", "glean-vnext"], + components: ["skills", "agents", "rules", "assets", "hooks"], displayName: "Glean", description: "Official Glean plugin — search documents, Slack, and email; explore code across repos; find experts and stakeholders; prep for meetings and onboarding.", @@ -95,7 +95,7 @@ export default defineConfig({ }, plugins: { glean: { - from: ["glean-lib", "codex", "codex-assets"], + from: ["glean-lib", "codex", "codex-assets", "glean-vnext"], components: ["skills", "assets"], description: "Official Glean plugin — search documents, Slack, and email; explore code across repos; find experts and stakeholders; prep for meetings and onboarding.", diff --git a/sources/glean-vnext/.mcp.json b/sources/glean-vnext/.mcp.json new file mode 100644 index 0000000..6d4a8c6 --- /dev/null +++ b/sources/glean-vnext/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "glean-local": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/start.mjs"], + "env": { + "ENABLE_HITL": "true", + "HITL_TIMEOUT_MS": "300000" + } + } + } +} diff --git a/sources/glean-vnext/build.mjs b/sources/glean-vnext/build.mjs new file mode 100644 index 0000000..bc0e8b9 --- /dev/null +++ b/sources/glean-vnext/build.mjs @@ -0,0 +1,55 @@ +// Build the plugin's MCP server into a single-file ESM bundle. +// +// Why bundle: Cowork's plugin-install validator rejects zip entries whose +// paths contain `@`, which appears in every scoped npm package's directory +// name (`node_modules/@modelcontextprotocol/...`). Inlining every dep into +// one `dist/index.js` means the shipped tree has no scoped-package paths. +// +// Bundle shape: +// - platform=node, format=esm so Node can load it with `node dist/index.js` +// and no `--experimental-*` flags, matching our package.json type:module +// - bundle=true with packages='bundled' so every import except Node +// builtins gets inlined +// - external: the `node:*` builtins (explicit for clarity; esbuild on +// platform=node treats bare `node:*` as external by default but we pin +// it so this doesn't regress silently) +// - no sourcemap or minification — the bundle is checked into git and +// should stay readable for debugging + +import { build } from "esbuild"; +import { builtinModules } from "node:module"; + +const nodeBuiltins = [ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), +]; + +await build({ + entryPoints: ["sources/glean-vnext/src/index.ts"], + outfile: "sources/glean-vnext/dist/index.js", + bundle: true, + platform: "node", + format: "esm", + target: "node20", + // Not setting `packages` — esbuild only accepts `"external"` here, which + // would ship every dep as a runtime lookup (defeating the purpose). The + // default when `bundle:true` is to inline every import whose specifier + // isn't in `external`, which is exactly what we want. + external: nodeBuiltins, + // Some transitive deps (e.g. `yaml`) ship CJS that does `require("node:*")` + // at module-eval time. esbuild inlines that CJS under an ESM shim that + // does NOT provide a `require`, so imports blow up with "Dynamic require + // of X is not supported". Prepending a `createRequire`-based shim gives + // the inlined CJS a working `require` for Node builtins. + banner: { + js: `import { createRequire as __glean_createRequire } from "node:module";\nconst require = __glean_createRequire(import.meta.url);`, + }, + minify: false, + legalComments: "linked", + logLevel: "info", + // The SDK and some transitive deps still ship CJS under their "require" + // export condition. We're emitting ESM and asking esbuild to resolve + // through each package's "import" condition first. + conditions: ["import", "node", "default"], + mainFields: ["module", "main"], +}); diff --git a/sources/glean-vnext/hooks/auto-approve-run-tool.mjs b/sources/glean-vnext/hooks/auto-approve-run-tool.mjs new file mode 100644 index 0000000..fab53d5 --- /dev/null +++ b/sources/glean-vnext/hooks/auto-approve-run-tool.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// PreToolUse hook for Claude Code. +// +// When HITL is enabled, run_tool is gated by the plugin's own elicitation +// prompt, so Claude Code's separate native "allow this tool?" prompt is +// redundant (the double-prompt). With ENABLE_HITL=true we auto-approve the +// run_tool call, leaving the HITL elicitation as the single gate. +// +// Safety: run_tool is read-only ONLY while HITL gates it. This hook runs only +// under Claude Code, which always advertises the elicitation capability, so +// ENABLE_HITL=true means run_tool's HITL prompt is active — never an ungated +// write. When ENABLE_HITL is not "true" the hook does nothing and the normal +// permission flow runs. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +function readStdin() { + try { + return fs.readFileSync(0, "utf-8"); + } catch { + return ""; + } +} + +let input = {}; +try { + input = JSON.parse(readStdin()); +} catch { + // Malformed/empty input: do nothing, let the normal permission flow run. +} + +const toolName = String(input.tool_name ?? ""); +const bareName = toolName.split("__").pop() ?? ""; +// Scope strictly to this plugin's run_tool — the tool name carries the glean +// plugin/server prefix (e.g. mcp__plugin_glean-vnext_glean__run_tool). +if (!toolName.includes("glean") || bareName !== "run_tool") { + process.exit(0); +} + +// The hook process does not inherit the MCP server's env, so read the flag +// from the plugin's own .mcp.json. +let env = {}; +try { + const root = process.env.CLAUDE_PLUGIN_ROOT ?? "."; + const cfg = JSON.parse(fs.readFileSync(path.join(root, ".mcp.json"), "utf-8")); + // The server is named "glean-local" in the shipped .mcp.json (to avoid + // clashing with a user-connected remote "glean" server). Keep a fallback to + // the legacy "glean" key for backwards compatibility with older installs. + env = + cfg?.mcpServers?.["glean-local"]?.env ?? cfg?.mcpServers?.glean?.env ?? {}; +} catch { + // No readable config: do nothing. +} + +if (env.ENABLE_HITL === "true") { + // Record Claude Code's live permission mode so the MCP server can skip its + // own elicitation gate when the user launched with + // --dangerously-skip-permissions (permission_mode "bypassPermissions"). + // Written on every run_tool call and keyed by session id, so it is always + // fresh for the call that immediately follows and never leaks across + // sessions. The base dir and session-id sanitization MUST match + // run-tool.ts (permissionModeMarkerPath) and start.sh's PLUGIN_DATA_DIR: + // CLAUDE_PLUGIN_DATA when set, else ~/.glean. Best-effort — marker I/O must + // never break the approval decision below. + try { + const permissionMode = String(input.permission_mode ?? ""); + const sessionId = String(input.session_id ?? "") + .replace(/[^a-zA-Z0-9_-]/g, "-") + .slice(0, 64); + if (permissionMode && sessionId) { + const base = + process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".glean"); + const dir = path.join(base, "glean-hitl-mode"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, `${sessionId}.json`), + JSON.stringify({ permission_mode: permissionMode, ts: Date.now() }), + ); + } + } catch { + // Ignore: a failed marker write just means the server keeps prompting. + } + + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "allow", + permissionDecisionReason: + "Glean run_tool is gated by its own HITL elicitation prompt; suppressing the redundant native prompt while ENABLE_HITL is on.", + }, + }), + ); +} +process.exit(0); diff --git a/sources/glean-vnext/hooks/hooks.json b/sources/glean-vnext/hooks/hooks.json new file mode 100644 index 0000000..f135e7b --- /dev/null +++ b/sources/glean-vnext/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "mcp__.*glean.*run_tool", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/auto-approve-run-tool.mjs\"" + } + ] + } + ] + } +} diff --git a/sources/glean-vnext/package.json b/sources/glean-vnext/package.json new file mode 100644 index 0000000..40bed82 --- /dev/null +++ b/sources/glean-vnext/package.json @@ -0,0 +1,9 @@ +{ + "name": "glean", + "type": "module", + "private": true, + "engines": { + "node": ">=22 <26" + }, + "description": "Runtime manifest for the packaged Glean plugin. Lives alongside dist/index.js so Node loads it as an ES module. Dev tooling (build, test, typecheck) lives at the repo root in the top-level package.json." +} diff --git a/sources/glean-vnext/plugin.pluginpack.json b/sources/glean-vnext/plugin.pluginpack.json new file mode 100644 index 0000000..d239f75 --- /dev/null +++ b/sources/glean-vnext/plugin.pluginpack.json @@ -0,0 +1,8 @@ +{ + "description": "Glean runtime MCP server — a local stdio server (find_skills, run_tool, setup), the glean_run skill, and an auto-approve hook. Folded into the glean emitted plugin so it coexists with the portable skills; a user may still connect a remote Glean MCP server separately. The local server is named glean-local to avoid clashing with a remote glean server.", + "files": { + "dist/index.js": "dist/index.js", + "start.mjs": "start.mjs", + "package.json": "package.json" + } +} diff --git a/sources/glean-vnext/skills/glean_run/SKILL.md b/sources/glean-vnext/skills/glean_run/SKILL.md new file mode 100644 index 0000000..9fa90e1 --- /dev/null +++ b/sources/glean-vnext/skills/glean_run/SKILL.md @@ -0,0 +1,124 @@ +--- +name: glean_run +description: Discover and run Glean skills for enterprise app tasks +argument-hint: +allowed-tools: + - Read(path="//**/glean-skills-cache/**") +--- + +# Glean Run + +Discover and use Glean skills to help with enterprise app tasks (Jira, Slack, +Google Workspace, Salesforce, etc.) or actions you don't already have a tool for. +Where possible, aim to complete the user's request end-to-end rather than just +listing available skills. + +## Authentication + +Authentication is handled exclusively by the `setup` tool. If any other tool +returns a response containing `[SETUP_REQUIRED]`, the user needs to +(re-)authenticate via `setup`. + +When this happens: +1. Call `setup` (no arguments). + - If no Server URL is configured, `setup` returns `[SETUP_REQUIRED]` with + instructions. Relay them, ask the user for their work email, then call + `setup` again with `email` set to what they provided. + - Once a Server URL is configured, `setup` opens the Glean sign-in page in + the browser and waits for sign-in. +2. Once `setup` returns "Glean setup is complete", retry the original tool + call. + +Do not treat `[SETUP_REQUIRED]` as an error or try to work around it any +other way. + +## Step 0: Verify Setup + +Call `setup` (with no arguments). If the connection isn't ready, `setup` +returns instructions — follow them and call `setup` again; it guides the whole +flow. Once it returns "Glean setup is complete", proceed to Step 1. + +## Step 1: Plan tool usage + +A small set of popular tools is directly available, and no discovery is +needed to use them. Discover is complementary and recommended if the +direct tools cannot satisfy the user request end to end. + +### Calling `find_skills` + +If no arguments were provided and the task can't be inferred from conversation +context, ask the user what they'd like to do before proceeding. + +Call `find_skills` with the task descriptions. + +Break the request into small, task-atomic queries — keep only the core action, +dropping the surrounding context (recipients, timing, reasons, constraints) — +and pass each as a separate entry in `queries`. + +``` +find_skills({ + queries: [ + "", + "" + ] +}) +``` + +The response is an XML index of discovered skills with file paths. + +You can call `find_skills` multiple times — e.g. to discover skills for +individual sub-tasks as you work through a broad request. + +## Step 2: Read Skill Instructions + +Browse the returned skills and select the one most relevant to the user's +request. Read its `SKILL.md` file for detailed instructions. Skills typically +contain guidance on how to use their tools, but the tools can also be called +as independent units. + +## Step 3: Read Tool Schemas + +Read each tool's JSON file (e.g. `tools/TOOL_NAME.json`) to get the exact +`server_id`, `name`, and `inputSchema` with parameter names and types. + +**Never guess parameter names** - always read the tool JSON file first. + +## Step 4: Execute Tools + +Call `run_tool` with the `server_id`, `tool_name` (from the `name` field in the +JSON), and `arguments` matching the `inputSchema` exactly. + +``` +run_tool({ + server_id: "composio/jira-pack", + tool_name: "jirasearch", + arguments: { query: "project = PROJ AND status = Open" } +}) +``` + +### Long-form arguments via `file_args` + +For long-form content — drafted Slack messages, Confluence pages, doc +bodies, etc. — write the draft to a local file first, then reference it +via `file_args` instead of passing it as a huge inline string. The plugin +reads each file and substitutes its UTF-8 contents into the named key in +`arguments` before calling the remote tool. + +``` +run_tool({ + server_id: "...", + tool_name: "slack_post_message", + arguments: { channel: "C123" }, + file_args: { text: "/tmp/glean-drafts/announce.md" } +}) +``` + +Constraints: +- Paths must be absolute. +- A key in `file_args` must not also appear in `arguments`. +- Each file must be ≤ 1 MB (override via `GLEAN_FILE_ARG_MAX_BYTES`). + +## Rules + +- Always read tool JSON files before calling `run_tool` - never guess parameters +- If discovery returns no relevant skills, tell the user what was searched diff --git a/sources/glean-vnext/src/auth-callback-server.ts b/sources/glean-vnext/src/auth-callback-server.ts new file mode 100644 index 0000000..c786658 --- /dev/null +++ b/sources/glean-vnext/src/auth-callback-server.ts @@ -0,0 +1,126 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +// OAuth redirect target. We bind a FIXED loopback port and register this exact +// URL via DCR. Glean exact-matches the redirect_uri against the registered +// client (it does NOT wildcard the loopback port), so the port must stay +// stable across runs — an ephemeral port would drift and fail to match. +// Running in-context means the browser reaches this server directly, which is +// what lets us drop the old paste-back flow. +const CALLBACK_PATH = "/glean-cli-callback"; + +// Fixed loopback port for the OAuth redirect. Overridable via +// GLEAN_CALLBACK_PORT for environments where 29107 is already taken; the port +// is registered via DCR, so any stable value works. +function callbackPort(): number { + const raw = process.env.GLEAN_CALLBACK_PORT; + const n = raw ? Number.parseInt(raw, 10) : NaN; + return Number.isInteger(n) && n >= 0 ? n : 29107; +} + +export function getCallbackUrl(): string { + return `http://127.0.0.1:${callbackPort()}${CALLBACK_PATH}`; +} + +export interface CallbackHandle { + /** Redirect URI the browser will land on. */ + url: string; + /** Resolves with the authorization `code` once the browser redirect lands. */ + code: Promise; +} + +interface ActiveServer { + server: http.Server; + handle: CallbackHandle; +} + +let active: ActiveServer | undefined; + +// Set by the OAuth provider once the authorize URL (and its `state`) is known, +// just before the browser opens. Validated against the redirect to guard +// against CSRF. Reset on close. +let expectedState: string | undefined; + +export function setExpectedState(state: string | undefined): void { + expectedState = state; +} + +/** + * Bind a loopback HTTP server on an ephemeral port and return its concrete URL + * plus a promise that resolves with the OAuth `code`. Idempotent within a flow: + * repeated calls return the same handle until closeCallbackServer() is called. + */ +export function startCallbackServer(): Promise { + if (active) return Promise.resolve(active.handle); + + let resolveCode!: (code: string) => void; + let rejectCode!: (err: Error) => void; + const code = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + + const server = http.createServer((req, res) => { + const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + if (reqUrl.pathname !== CALLBACK_PATH) { + res.writeHead(404); + res.end(); + return; + } + + if (expectedState !== undefined) { + const returnedState = reqUrl.searchParams.get("state"); + if (returnedState !== expectedState) { + res.writeHead(403, { "Content-Type": "text/html" }); + res.end( + "

Error

Invalid OAuth state parameter.

", + ); + rejectCode(new Error("OAuth state mismatch — possible CSRF attack")); + return; + } + } + + const codeParam = reqUrl.searchParams.get("code"); + if (!codeParam) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + "

Error

No authorization code received.

", + ); + rejectCode(new Error("no authorization code in OAuth callback")); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "

Signed in to Glean

Authentication complete. You can close this tab and return to your chat; setup will finish automatically.

", + ); + resolveCode(codeParam); + }); + + return new Promise((resolve, reject) => { + // Bind errors (e.g. EADDRINUSE — the fixed port is already taken) reject + // the start; later runtime errors surface on the `code` promise instead. + server.once("error", reject); + server.listen(callbackPort(), "127.0.0.1", () => { + server.removeListener("error", reject); + server.on("error", (err) => rejectCode(err)); + // Reflect the actually-bound port. Equals callbackPort() for the fixed + // production port; differs only when port 0 is configured (tests). + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${port}${CALLBACK_PATH}`; + const handle: CallbackHandle = { url, code }; + active = { server, handle }; + console.error(`[auth] Callback server listening on ${url}`); + resolve(handle); + }); + }); +} + +export function closeCallbackServer(): void { + if (active) { + active.server.close(); + active.server.closeAllConnections?.(); + active = undefined; + } + expectedState = undefined; +} diff --git a/sources/glean-vnext/src/auth-provider.ts b/sources/glean-vnext/src/auth-provider.ts new file mode 100644 index 0000000..4c47d0b --- /dev/null +++ b/sources/glean-vnext/src/auth-provider.ts @@ -0,0 +1,180 @@ +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { execFile, spawn } from "node:child_process"; +import { platform } from "node:os"; +import { getCallbackUrl, setExpectedState } from "./auth-callback-server.js"; +import { clearCredentials, loadCredentials, saveCredentials } from "./token-store.js"; + +export type InvalidationScope = "all" | "client" | "tokens" | "verifier"; + +/** + * Open `url` in the user's default browser. Used for the self-open sign-in + * path when the client does not support URL-mode elicitation (where the client + * itself opens the URL after consent). + */ +export function openBrowser(url: string): void { + if (platform() === "win32") { + // Open via `cmd /c start`, which routes through ShellExecute -> the default + // browser. The catch: cmd.exe treats a bare `&` as a command separator, so + // the OAuth authorize URL would be truncated at the first `&` -- dropping + // client_id and everything after it, which the server rejects as + // invalid_client. We escape every `&` as `^&` and pass the args verbatim + // (windowsVerbatimArguments) so Node doesn't re-wrap them in quotes, inside + // which cmd stops honoring the `^` escape. cmd then un-escapes `^&` back to + // a literal `&`, so the browser receives the full URL intact. The empty + // `""` is start's window-title arg; `/b` avoids spawning a console window. + spawn("cmd", ["/c", "start", '""', "/b", url.replace(/&/g, "^&")], { + detached: true, + stdio: "ignore", + windowsVerbatimArguments: true, + }).unref(); + } else { + const cmd = platform() === "darwin" ? "open" : "xdg-open"; + execFile(cmd, [url]); + } +} + +export class GleanOAuthClientProvider implements OAuthClientProvider { + private _clientInfo: OAuthClientInformationMixed | undefined; + private _tokens: OAuthTokens | undefined; + private _codeVerifier = ""; + private _pendingAuthCode: string | undefined; + // True between issuing an authorize URL and either receiving tokens or + // explicitly invalidating. Used to detect when a previous auth URL didn't + // complete — likely because the server rejected the (stale) client_id. + private _authUrlPending = false; + + authorizationUrl: string | undefined; + + /** + * Optional hook invoked whenever the in-memory token state changes — + * either tokens were saved (auth completed) or invalidated (logout / + * refresh failure). Used by the plugin to push a tools/list_changed + * notification so the host re-fetches the dynamic tool surface. + */ + onTokensChanged?: (tokens: OAuthTokens | undefined) => void; + + constructor() { + const stored = loadCredentials(); + if (stored) { + this._tokens = stored.tokens as OAuthTokens | undefined; + this._clientInfo = stored.clientInfo as OAuthClientInformationMixed | undefined; + } + } + + get redirectUrl(): string { + return getCallbackUrl(); + } + + get clientMetadata(): OAuthClientMetadata { + return { + redirect_uris: [getCallbackUrl()], + client_name: "Glean Claude Code Plugin", + }; + } + + clientInformation(): OAuthClientInformationMixed | undefined { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformationMixed): void { + this._clientInfo = info; + saveCredentials(this._tokens, this._clientInfo); + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + this._authUrlPending = false; + saveCredentials(this._tokens, this._clientInfo); + this.onTokensChanged?.(tokens); + } + + async invalidateCredentials(scope: InvalidationScope): Promise { + console.error(`[auth] Invalidating credentials: scope=${scope}`); + const tokensClearedBefore = this._tokens === undefined; + switch (scope) { + case "all": + this._tokens = undefined; + this._clientInfo = undefined; + this._codeVerifier = ""; + this._authUrlPending = false; + clearCredentials(); + break; + case "client": + this._clientInfo = undefined; + saveCredentials(this._tokens, undefined); + break; + case "tokens": + this._tokens = undefined; + saveCredentials(undefined, this._clientInfo); + break; + case "verifier": + this._codeVerifier = ""; + break; + } + if ( + (scope === "all" || scope === "tokens") && + !tokensClearedBefore + ) { + this.onTokensChanged?.(undefined); + } + } + + // True if we previously issued an authorize URL but never received tokens — + // implying the URL was likely rejected by the server (e.g. stale client_id). + needsFreshClient(): boolean { + return ( + this._authUrlPending && + !this._tokens?.access_token && + this._pendingAuthCode === undefined + ); + } + + get pendingAuthCode(): string | undefined { + return this._pendingAuthCode; + } + + setPendingAuthCode(code: string): void { + this._pendingAuthCode = code; + } + + clearPendingAuth(): void { + this._pendingAuthCode = undefined; + this.authorizationUrl = undefined; + } + + // Called by the SDK when a 401 kicks off the OAuth flow. We do NOT open a + // browser or redirect here — the setup orchestrator owns presenting the + // sign-in URL (URL-mode elicitation, or self-open as a fallback) and awaiting + // the loopback callback. All this does is record the authorize URL (which + // propagates out as AuthRequiredError) and hand the loopback server the + // `state` value to validate the redirect against. + async redirectToAuthorization(authorizationUrl: URL): Promise { + this.authorizationUrl = authorizationUrl.toString(); + this._authUrlPending = true; + setExpectedState(authorizationUrl.searchParams.get("state") ?? undefined); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + return this._codeVerifier; + } + + async validateResourceURL(_serverUrl: string | URL, resource?: string): Promise { + if (resource) { + return new URL(resource); + } + return undefined; + } +} diff --git a/sources/glean-vnext/src/config-search.ts b/sources/glean-vnext/src/config-search.ts new file mode 100644 index 0000000..902abac --- /dev/null +++ b/sources/glean-vnext/src/config-search.ts @@ -0,0 +1,92 @@ +const CONFIG_SEARCH_URL = "https://app.glean.com/config/search"; + +// Shape of the deployment config returned by the config/search endpoint. +interface DeploymentConfig { + queryURL?: string; + centralURL?: string; + isMultiTenant?: boolean; +} + +interface ConfigSearchResponse { + search_config?: DeploymentConfig; +} + +export type ResolveResult = + | { ok: true; queryUrl: string } + | { ok: false; error: string }; + +const emailPattern = /^[^@\s]+@([^@\s]+\.[^@\s]+)$/; + +export async function resolveServerUrlFromEmail( + email: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const trimmed = email.trim(); + const match = emailPattern.exec(trimmed); + if (!match) { + return { ok: false, error: `"${email}" is not a valid email address.` }; + } + const emailDomain = match[1].toLowerCase(); + + let resp: Response; + try { + resp = await fetchImpl(CONFIG_SEARCH_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ email: trimmed, emailDomain, isGleanApp: true }), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + ok: false, + error: `Could not reach Glean to look up your instance: ${msg}`, + }; + } + + if (!resp.ok) { + return { + ok: false, + error: `Glean instance lookup failed (HTTP ${resp.status}).`, + }; + } + + let data: ConfigSearchResponse; + try { + data = (await resp.json()) as ConfigSearchResponse; + } catch { + return { + ok: false, + error: "Glean instance lookup returned an unexpected response.", + }; + } + + const cfg = data.search_config; + const queryUrl = cfg?.queryURL; + + // No usable instance if there's no queryURL, or the endpoint returned the + // shared central URL for an unknown domain or typo in email + if ( + !queryUrl || + (cfg?.centralURL && + cfg.isMultiTenant !== true && + sameOrigin(queryUrl, cfg.centralURL)) + ) { + return { + ok: false, + error: `No Glean instance is registered for "${emailDomain}".`, + }; + } + + return { ok: true, queryUrl }; +} + +function sameOrigin(a: string, b: string): boolean { + try { + return new URL(a).origin === new URL(b).origin; + } catch { + return false; + } +} diff --git a/sources/glean-vnext/src/index.ts b/sources/glean-vnext/src/index.ts new file mode 100644 index 0000000..aa07a4e --- /dev/null +++ b/sources/glean-vnext/src/index.ts @@ -0,0 +1,850 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + type Tool, + type CallToolResult, +} from "@modelcontextprotocol/sdk/types.js"; +import path from "node:path"; +import fs from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { + AuthRequiredError, + createRemoteClient, + type RemoteClientOptions, +} from "./remote-client.js"; +import { GleanOAuthClientProvider, openBrowser } from "./auth-provider.js"; +import { + startCallbackServer, + closeCallbackServer, +} from "./auth-callback-server.js"; +import { handleFindSkills } from "./tools/find-skills.js"; +import { + handleRunTool, + isCursorClient, + runToolAnnotations, +} from "./tools/run-tool.js"; +import { evictStaleSkills } from "./skill-writer.js"; +import { + loadServerUrl, + saveServerUrl, + clearServerUrl, +} from "./url-config-store.js"; +import { clearCredentials } from "./token-store.js"; +import { + loadRemoteTools, + saveRemoteTools, + clearRemoteTools, +} from "./remote-tools-cache-store.js"; +import { + REMOTE_TOOLS_ALLOWLIST, + dispatchRemoteTool, + fetchAllowedRemoteTools, + type DispatchContext, +} from "./tools/remote-passthrough.js"; +import { resolveSessionId } from "./session-id.js"; +import { resolveServerUrlFromEmail } from "./config-search.js"; + +function readEnv(...keys: string[]): string | undefined { + for (const key of keys) { + const v = process.env[key]; + if (v === undefined || v === "") continue; + if (v.startsWith("${")) continue; + return v; + } + return undefined; +} + +function resolveServerUrl(): string | undefined { + const fromEnv = readEnv("GLEAN_MCP_SERVER_URL"); + if (fromEnv) return fromEnv; + return loadServerUrl(); +} + +function normalizeServerUrl(raw: string): string { + const parsed = new URL(raw); + return `${parsed.origin}/mcp/gateway/proxy`; +} + +const SETUP_REQUIRED_TEXT = + `[SETUP_REQUIRED]\n\n` + + `To connect, enter your work email (e.g. you@acme.com) and we'll find ` + + `your Glean instance automatically.\n`; + +// A failed lookup asks the user to retry with a corrected email. +// Admins who must set the URL manually use +// the server_url parameter (documented in the README) +const EMAIL_RESOLVE_FAILED_TEXT = + `Double-check the email for typos and try again with the corrected email.`; + +const 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."; + +// Returned by every non-setup tool when auth is missing or expired. The +// agent should respond by calling `setup` (which drives the OAuth sign-in +// via elicitation + the loopback callback), then retry the original tool +// call. Centralising auth in `setup` keeps the OAuth flow out of every +// other tool. +const 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(): string { + const base = process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); + return path.join(base, "glean-server.log"); +} + +const LOG_PATH = resolveLogPath(); +try { + const logDir = path.dirname(LOG_PATH); + fs.mkdirSync(logDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(logDir, 0o700); +} catch { + /* ignore */ +} + +function logLine(label: string, detail?: Record): void { + const ts = new Date().toISOString(); + const suffix = detail ? ` ${JSON.stringify(detail)}` : ""; + const line = `${ts} ${label}${suffix}\n`; + try { + fs.appendFileSync(LOG_PATH, line, { mode: 0o600 }); + fs.chmodSync(LOG_PATH, 0o600); + } catch { + /* ignore */ + } + console.error(line.trimEnd()); +} + +function resolveSkillsBaseDir(): string { + if (process.env.SKILLS_BASE_DIR) { + return process.env.SKILLS_BASE_DIR; + } + return path.join(tmpdir(), "glean-skills-cache"); +} + +const server = new Server( + { name: "glean", version: "1.0.0" }, + { capabilities: { tools: { listChanged: true } } }, +); + +let oauthProvider: GleanOAuthClientProvider | undefined; + +// Cache of the last successful remote tools/list fetch. Persists for the +// lifetime of the process — and across restarts via the on-disk +// remote-tools-cache-store keyed by server URL — so a transient +// auth/network blip or a fresh process spawn doesn't make `chat` / +// `search` / `read_document` disappear from the surface. Cleared on +// `setup({reset})` and on `setup({server_url})` switching instances. +// Empty until `setup` (or any prior process for this URL) has driven a +// successful tool fetch. +let cachedRemoteTools: Tool[] = loadRemoteTools(resolveServerUrl() ?? ""); + +function getOAuthProvider(): GleanOAuthClientProvider { + if (!oauthProvider) { + oauthProvider = new GleanOAuthClientProvider(); + // Wire onTokensChanged on every fresh provider instance — after a + // setup({reset}) we recreate the provider, and the new one needs the + // same tools/list_changed signal so the host re-fetches the dynamic + // surface as soon as auth flips. + oauthProvider.onTokensChanged = () => { + server.sendToolListChanged().catch(() => { + // Transport not connected yet, or notification serialization + // failed — the agent still sees the right tools on its next + // list_tools call. + }); + }; + } + return oauthProvider; +} + +function getRemoteClientOpts(): RemoteClientOptions { + return { authProvider: getOAuthProvider() }; +} + +const FIND_SKILLS_TOOL: Tool = { + name: "find_skills", + annotations: { readOnlyHint: true }, + description: + "Discover available Glean skills and their resolved tool dependencies. " + + "This is a search engine over skill blueprints and the tools they expose: " + + "match on the core action, not the full request. " + + "Call this tool FIRST whenever the user's request cannot be fulfilled by your " + + "current tools — especially for tasks involving enterprise apps (Jira, Slack, " + + "Google Workspace, Salesforce, etc.) or any action you don't already have a " + + "tool for. Before calling, break the user's request into small, task-atomic " + + "queries — keep only the action and drop the surrounding context (recipients, " + + "timing, reasons, constraints) — and pass each as a separate entry in the " + + "'queries' array. For example, for \"Send an email to X for tomorrow's demo " + + "meeting as leadership will be visiting\", the single query is \"send an email\". " + + "Discovered skills are written to local files and an XML skill " + + "index with usage instructions is returned. " + + "If a returned skill lists no tools and its playbook does not let you " + + "complete the task, first check whether tools already in scope can do it — " + + "tools from other skills in this response, tools from earlier find_skills " + + "calls, or tools you can already call directly. If none fit, call find_skills " + + "again with reworded or additional queries. " + + "If a previously-cached skill file referenced from memory or instructions " + + "is missing on disk, call find_skills again to re-fetch it before failing. " + + "To use a returned skill: (1) pick the most relevant from the returned " + + "skills; (2) read its SKILL.md for instructions; (3) read each tool's JSON " + + "file (tools/TOOL_NAME.json) for the exact server_id, name, and inputSchema " + + "(exact parameter names and types); (4) call run_tool with the server_id, " + + "tool_name (from the name field), and arguments matching the inputSchema. " + + "Never guess parameter names — read the tool JSON file first.", + inputSchema: { + type: "object" as const, + properties: { + queries: { + type: "array", + items: { type: "string" }, + maxItems: 3, + description: + "Atomic sub-task descriptions broken down from the user's request. " + + "Each query should describe one specific action (e.g., 'search emails', " + + "'create calendar event').", + }, + }, + required: ["queries"], + }, +}; + +const RUN_TOOL_TOOL: Tool = { + name: "run_tool", + description: + "Execute a tool on a downstream MCP server. Before calling this tool, " + + "you MUST read the tool's JSON file from the find_skills output to get " + + "the exact server_id, tool_name, and input_schema. Pass arguments that match " + + "the input_schema exactly — do not guess parameter names.", + inputSchema: { + type: "object" as const, + properties: { + server_id: { + type: "string", + description: "The ID of the downstream MCP server.", + }, + tool_name: { + type: "string", + description: "The name of the tool to invoke.", + }, + arguments: { + type: "object", + description: "Optional arguments to pass to the downstream tool.", + }, + file_args: { + type: "object", + description: + "Optional map from argument name to absolute local file path. " + + "The plugin reads each file and substitutes its contents into the " + + "corresponding key in `arguments` before calling the remote tool. " + + "If the target parameter is typed as an object or array in the " + + "tool's inputSchema, the file is parsed as JSON and injected as " + + "structured data; otherwise its contents are injected as a UTF-8 " + + "string. Use this to keep large values out of the inline call — " + + "long-form text (Slack message bodies, Confluence pages, doc " + + "contents) or a large structured argument (e.g. an agent spec). " + + "Paths must be absolute. Each file must be ≤ 1 MB (override " + + "via GLEAN_FILE_ARG_MAX_BYTES). A key in `file_args` must not " + + "also appear in `arguments`.", + additionalProperties: { type: "string" }, + }, + }, + required: ["server_id", "tool_name"], + }, +}; + +const SETUP_TOOL: Tool = { + name: "setup", + annotations: { readOnlyHint: true }, + description: + "Check or configure the Glean connection. Setup completes in three " + + "stages: (1) resolve and save the Server URL, (2) authenticate, " + + "(3) fetch the remote tool catalog. Call with no arguments to advance " + + "through the next missing stage. Call with email to look up and " + + "(re)configure user's Glean instance. Call with reset=true to clear " + + "all configuration.", + inputSchema: { + type: "object" as const, + properties: { + email: { + type: "string", + description: + "User's work email (e.g. you@acme.com). Used to look up and " + + "configure your Glean Server instance (QE) URL automatically.", + }, + server_url: { + type: "string", + description: + "Advanced. Sets the Glean Server (QE) URL directly instead of " + + "resolving it from email. Do not suggest this to users — email " + + "is the preferred path. Documented in the README for technical " + + "users who ask for it explicitly.", + }, + reset: { + type: "boolean", + description: "Clear cached URL, credentials, and remote tool cache.", + }, + }, + required: [], + }, +}; + +server.setRequestHandler(ListToolsRequestSchema, async () => { + const runTool: Tool = { + ...RUN_TOOL_TOOL, + annotations: runToolAnnotations( + process.env.ENABLE_HITL === "true", + !!server.getClientCapabilities()?.elicitation, + isCursorClient(server), + ), + }; + const staticTools: Tool[] = [FIND_SKILLS_TOOL, runTool, SETUP_TOOL]; + + // One structured line on every return path, so "why don't my tools appear?" + // is answerable from the log alone: `static` is constant, `names` lists the + // dynamic tools we actually surfaced (freshly fetched or served from cache), + // and `state` names the path we took. The allow-list only ever drops tools + // outside our fixed set, so a missing allow-listed name (e.g. `chat`) means + // the backend never returned it. Only tool *names*, counts and the state + // tag are logged — never argument values, which can carry PII/secrets. + const serve = (state: string, dynamic: Tool[]): { tools: Tool[] } => { + logLine("tools-list.served", { + static: staticTools.length, + dynamic: dynamic.length, + names: dynamic.map((t) => t.name), + state, + }); + return { tools: [...staticTools, ...dynamic] }; + }; + + // Pre-auth gate: tokens() is sync. When unauthenticated (or unconfigured) + // skip the remote round-trip — but keep surfacing whatever we successfully + // fetched earlier in this process so a token expiry doesn't make the + // dynamic surface vanish. Calls to non-setup tools route through + // [SETUP_REQUIRED] / setup when URL or tokens are missing; only setup + // emits [AUTHENTICATION_REQUIRED] during the sign-in step. + const serverUrl = resolveServerUrl(); + if (!serverUrl) { + return serve("unconfigured", cachedRemoteTools); + } + const provider = getOAuthProvider(); + if (!provider.tokens()) { + return serve("unauthenticated", cachedRemoteTools); + } + + let remoteClient; + try { + remoteClient = await createRemoteClient( + serverUrl, + getRemoteClientOpts(), + `tools-list-${process.pid}`, + ); + } catch (err) { + // Auth expired mid-session, network blip, schema parse error — serve + // static + last-known dynamic tools. Agent isn't blocked. + const msg = err instanceof Error ? err.message : String(err); + logLine("connect.backend-error", { label: "tools/list", msg }); + return serve("connect-error", cachedRemoteTools); + } + + try { + const remoteTools = await fetchAllowedRemoteTools(remoteClient); + cachedRemoteTools = remoteTools; + saveRemoteTools(serverUrl, remoteTools); + return serve("fetched", remoteTools); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logLine("tools-list.fetch-failed", { label: "tools/list", msg }); + return serve("fetch-failed", cachedRemoteTools); + } finally { + await remoteClient.close(); + } +}); + +// How long to wait for the user to complete the browser sign-in (open the +// authorize URL, sign in, redirect to the loopback) before giving up. The +// loopback capture is the real wait; this bounds the blocking setup call. +const SIGN_IN_WAIT_MS = 300_000; + +type RemoteClient = Awaited>; + +function withTimeout(p: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`timed out after ${ms}ms`)), + ms, + ); + p.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (e) => { + clearTimeout(timer); + reject(e); + }, + ); + }); +} + +function backendErrorResult(label: string, err: unknown): CallToolResult { + const msg = err instanceof Error ? err.message : String(err); + logLine("connect.backend-error", { label, msg }); + return { + content: [ + { type: "text", text: `Failed to connect to Glean backend: ${msg}` }, + ], + isError: true, + }; +} + +// Connect to the Glean backend, driving the OAuth sign-in if needed. The +// loopback callback server captures the authorization code in-context (no +// paste-back). The redirect URI is the fixed loopback URL the provider +// reports, so DCR + the authorize request use it directly. +async function connectWithSignIn( + serverUrl: string, +): Promise< + { ok: true; client: RemoteClient } | { ok: false; result: CallToolResult } +> { + const provider = getOAuthProvider(); + + // Happy path: already authenticated — connect directly, no callback server. + if (provider.tokens()) { + try { + const client = await createRemoteClient( + serverUrl, + getRemoteClientOpts(), + `setup-${process.pid}`, + ); + return { ok: true, client }; + } catch (err) { + if (!(err instanceof AuthRequiredError)) { + return { ok: false, result: backendErrorResult("setup", err) }; + } + // Tokens expired — fall through to the sign-in path below. + } + } + + let handle; + try { + handle = await startCallbackServer(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logLine("setup.callback-server-failed", { msg }); + return { + ok: false, + result: { + content: [ + { + type: "text", + text: `Could not start the local sign-in listener: ${msg}. Retry setup.`, + }, + ], + isError: true, + }, + }; + } + + try { + let authUrl: string; + try { + const client = await createRemoteClient( + serverUrl, + getRemoteClientOpts(), + `setup-${process.pid}`, + ); + // Unexpectedly connected without needing auth — done. + return { ok: true, client }; + } catch (err) { + if (!(err instanceof AuthRequiredError)) { + return { ok: false, result: backendErrorResult("setup", err) }; + } + authUrl = err.authUrl; + } + + // Open the Glean sign-in page; the loopback (already listening) captures + // the redirect automatically once the user signs in. + openBrowser(authUrl); + + let code: string; + try { + code = await withTimeout(handle.code, SIGN_IN_WAIT_MS); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logLine("setup.sign-in-wait-failed", { msg }); + return { + ok: false, + result: { + content: [ + { + type: "text", + text: + "Timed out waiting for sign-in to complete. Run setup again " + + "to retry.", + }, + ], + isError: true, + }, + }; + } + + provider.setPendingAuthCode(code); + try { + const client = await createRemoteClient( + serverUrl, + getRemoteClientOpts(), + `setup-${process.pid}`, + ); + return { ok: true, client }; + } catch (err) { + logLine("setup.finish-auth-failed", { + msg: err instanceof Error ? err.message : String(err), + }); + return { ok: false, result: backendErrorResult("setup", err) }; + } + } finally { + closeCallbackServer(); + } +} + +/** + * Drive the setup flow forward until either complete (URL ✓ + tokens ✓ + + * dynamic tools fetched ✓) or blocked on a user action. Used both by + * `setup()` with no args and as the tail of `setup({server_url})`. + */ +async function advanceSetup(): Promise { + const serverUrl = resolveServerUrl(); + if (!serverUrl) { + return { content: [{ type: "text", text: SETUP_REQUIRED_TEXT }] }; + } + + const conn = await connectWithSignIn(serverUrl); + if (!conn.ok) return conn.result; + const remoteClient = conn.client; + + try { + const remoteTools = await fetchAllowedRemoteTools(remoteClient); + cachedRemoteTools = remoteTools; + saveRemoteTools(serverUrl, remoteTools); + const toolNames = remoteTools.map((t) => t.name).join(", ") || "(none)"; + return { + content: [ + { + type: "text", + text: + `Glean setup is complete.\n` + + `Server URL: ${serverUrl}\n` + + `Authenticated: yes\n` + + `Remote tools: ${toolNames}\n\n` + + `You can now use find_skills, run_tool, and any of the listed ` + + `remote tools.`, + }, + ], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logLine("setup.fetch-tools-failed", { msg }); + return { + content: [ + { + type: "text", + text: + `Authenticated, but failed to fetch the remote tool catalog: ${msg}.\n` + + `Server URL: ${serverUrl}\n\n` + + `Try calling setup again to retry, or setup({reset:true}) to ` + + `start over.`, + }, + ], + isError: true, + }; + } finally { + await remoteClient.close(); + } +} + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params; + + // Allow-listed remote tools (chat/search/read_document) — only valid once + // setup has provided a server URL. Auth is handled by dispatchRemoteTool + // via the standard [AUTHENTICATION_REQUIRED] flow. + if (REMOTE_TOOLS_ALLOWLIST.has(name)) { + const serverUrl = resolveServerUrl(); + if (!serverUrl) { + return { + content: [{ type: "text", text: SETUP_NEEDED_ERROR }], + isError: true, + }; + } + // Pre-check tokens so an unauth'd call doesn't reach + // dispatchRemoteTool → createRemoteClient → SDK 401 → + // redirectToAuthorization (which opens the browser). Only `setup` + // is allowed to drive the OAuth flow. + if (!getOAuthProvider().tokens()) { + return { + content: [{ type: "text", text: AUTH_REDIRECT_TO_SETUP_TEXT }], + }; + } + const dispatchCtx: DispatchContext = { + serverUrl, + remoteClientOpts: getRemoteClientOpts(), + authRedirectText: AUTH_REDIRECT_TO_SETUP_TEXT, + logLine, + }; + return await dispatchRemoteTool(name, args, dispatchCtx); + } + + switch (name) { + case "find_skills": { + const serverUrl = resolveServerUrl(); + if (!serverUrl) { + return { + content: [{ type: "text", text: SETUP_NEEDED_ERROR }], + isError: true, + }; + } + + // Pre-check tokens before connecting so an unauth'd call doesn't + // trip the SDK's 401 → redirectToAuthorization path (which opens a + // browser tab). Only `setup` should ever drive OAuth. + if (!getOAuthProvider().tokens()) { + return { + content: [{ type: "text", text: AUTH_REDIRECT_TO_SETUP_TEXT }], + }; + } + + const sessionId = resolveSessionId(); + + const skillsBaseDir = resolveSkillsBaseDir(); + + let remoteClient; + try { + remoteClient = await createRemoteClient( + serverUrl, + getRemoteClientOpts(), + sessionId, + ); + } catch (err) { + if (err instanceof AuthRequiredError) { + return { + content: [{ type: "text", text: AUTH_REDIRECT_TO_SETUP_TEXT }], + }; + } + const msg = err instanceof Error ? err.message : String(err); + logLine("connect.backend-error", { label: "find_skills", msg }); + return { + content: [ + { + type: "text", + text: `Failed to connect to Glean backend: ${msg}`, + }, + ], + isError: true, + }; + } + try { + const text = await handleFindSkills( + remoteClient, + skillsBaseDir, + args, + ); + return { content: [{ type: "text", text }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`find_skills: execution failed: ${msg}`); + return { + content: [{ type: "text", text: `find_skills failed: ${msg}` }], + isError: true, + }; + } finally { + await remoteClient.close(); + } + } + + case "run_tool": { + const serverUrl = resolveServerUrl(); + if (!serverUrl) { + return { + content: [{ type: "text", text: SETUP_NEEDED_ERROR }], + isError: true, + }; + } + + // Pre-check tokens before connecting so an unauth'd call doesn't + // trip the SDK's 401 → redirectToAuthorization path (which opens a + // browser tab). Only `setup` should ever drive OAuth. + if (!getOAuthProvider().tokens()) { + return { + content: [{ type: "text", text: AUTH_REDIRECT_TO_SETUP_TEXT }], + }; + } + + const sessionId = resolveSessionId(); + + let remoteClient; + try { + remoteClient = await createRemoteClient( + serverUrl, + getRemoteClientOpts(), + sessionId, + ); + } catch (err) { + if (err instanceof AuthRequiredError) { + return { + content: [{ type: "text", text: AUTH_REDIRECT_TO_SETUP_TEXT }], + }; + } + const msg = err instanceof Error ? err.message : String(err); + logLine("connect.backend-error", { label: "run_tool", msg }); + return { + content: [ + { + type: "text", + text: `Failed to connect to Glean backend: ${msg}`, + }, + ], + isError: true, + }; + } + try { + const skillsBaseDir = resolveSkillsBaseDir(); + return await handleRunTool(remoteClient, server, skillsBaseDir, args); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`run_tool: execution failed: ${msg}`); + return { + content: [{ type: "text", text: `run_tool failed: ${msg}` }], + isError: true, + }; + } finally { + await remoteClient.close(); + } + } + + case "setup": { + logLine("client.capabilities", { + elicitation: server.getClientCapabilities()?.elicitation ?? null, + clientInfo: server.getClientVersion() ?? null, + }); + if (args.reset === true) { + clearServerUrl(); + clearCredentials(); + clearRemoteTools(); + oauthProvider = undefined; + cachedRemoteTools = []; + logLine("setup.reset"); + // Fire-and-forget — tools list is shorter without the dynamic + // surface; the host should re-fetch on its next idle cycle. + server.sendToolListChanged().catch(() => { + /* transport may not be connected yet; harmless */ + }); + return { + content: [ + { + type: "text", + text: + "Glean configuration has been reset. Call setup again with " + + "your email to reconfigure.", + }, + ], + }; + } + + // An explicit server_url wins; otherwise derive the QE URL from the + // user's email via the public config/search lookup. + let rawUrl = + typeof args.server_url === "string" ? args.server_url.trim() : ""; + const email = typeof args.email === "string" ? args.email.trim() : ""; + + if (!rawUrl && email) { + const resolved = await resolveServerUrlFromEmail(email); + if (!resolved.ok) { + logLine("setup.email-resolve-failed", { email, error: resolved.error }); + return { + content: [ + { + type: "text", + text: `${resolved.error}\n\n${EMAIL_RESOLVE_FAILED_TEXT}`, + }, + ], + isError: true, + }; + } + rawUrl = resolved.queryUrl; + logLine("setup.email-resolved", { email, queryUrl: rawUrl }); + } + + if (rawUrl) { + let normalized: string; + try { + normalized = normalizeServerUrl(rawUrl); + } catch { + return { + content: [ + { + type: "text", + text: + `Invalid URL: "${rawUrl}". Please provide the Server instance (QE) URL ` + + `(e.g. https://acme-be.glean.com), or pass your email instead.`, + }, + ], + isError: true, + }; + } + + try { + saveServerUrl(normalized); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [ + { type: "text", text: `Failed to save configuration: ${msg}` }, + ], + isError: true, + }; + } + + // New instance — clear stale auth state. The on-disk remote-tool + // cache for the previous URL is left intact (so switching back is + // instant); we just rehydrate from whatever cache exists for the + // newly configured URL — empty for a first-time server. + clearCredentials(); + oauthProvider = undefined; + cachedRemoteTools = loadRemoteTools(normalized); + logLine("setup.configured", { serverUrl: normalized }); + // Fall through to advanceSetup, which will now find URL ✓ and try + // to drive auth + tool fetch in the same call. + } + + return await advanceSetup(); + } + + default: + return { + content: [{ type: "text", text: `Unknown tool: ${name}` }], + isError: true, + }; + } +}); + +async function main() { + // Run once per session at MCP server startup. + const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; + try { + await evictStaleSkills(resolveSkillsBaseDir(), ONE_WEEK_MS, logLine); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logLine("evict-stale-skills.failed", { msg }); + } + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/sources/glean-vnext/src/remote-client.ts b/sources/glean-vnext/src/remote-client.ts new file mode 100644 index 0000000..954cf52 --- /dev/null +++ b/sources/glean-vnext/src/remote-client.ts @@ -0,0 +1,221 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { GleanOAuthClientProvider } from "./auth-provider.js"; + +const GLEAN_PLUGIN = "GLEAN_PLUGIN"; + +// The MCP SDK applies DEFAULT_REQUEST_TIMEOUT_MSEC (60s) to every request when +// callTool is invoked without an explicit timeout. Downstream Glean tools can +// legitimately take longer than 60s, so we override the per-call timeout. +// Configurable via GLEAN_REMOTE_TOOL_TIMEOUT_MS; falls back to a generous +// default that mirrors HITL_TIMEOUT_MS. +const DEFAULT_REMOTE_TOOL_TIMEOUT_MS = 300_000; + +export function remoteToolTimeoutMs(): number { + const raw = process.env.GLEAN_REMOTE_TOOL_TIMEOUT_MS; + if (!raw) return DEFAULT_REMOTE_TOOL_TIMEOUT_MS; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_REMOTE_TOOL_TIMEOUT_MS; +} + +function encodeVarint(value: number): number[] { + const bytes: number[] = []; + while (value > 0x7f) { + bytes.push((value & 0x7f) | 0x80); + value >>>= 7; + } + bytes.push(value); + return bytes; +} + +function encodeStringField(fieldNumber: number, value: string): number[] { + if (!value) return []; + const encoded = new TextEncoder().encode(value); + const tag = (fieldNumber << 3) | 2; + return [tag, ...encodeVarint(encoded.length), ...encoded]; +} + +function encodeMessageField(fieldNumber: number, inner: number[]): number[] { + const tag = (fieldNumber << 3) | 2; + return [tag, ...encodeVarint(inner.length), ...inner]; +} + +// Hand-rolled proto encoder for mcp.GatewayRequestMetadata. We don't currently +// have proto bindings in this repo, so the shape is hardcoded here. Cross- +// language parity is enforced by tests/remote-client.test.ts (byte-equality +// against the Go proto.Marshal output captured in scio's +// proxy_tools_provider_test.go::TestPluginGatewayRequestMetadata_NoSession). +// +// Proto layout (keep in sync with go/core/api/mcp/api.proto): +// workflow_id = field 1 (string) +// chat_session_id = field 2 (string) +// source_info = field 4 (core.SourceInfo) +// platform = field 2 (string) +// client_initiator = field 3 (string) +export function buildGatewayMetadataHeader(chatSessionId?: string): string { + const sourceInfo = [ + ...encodeStringField(2, GLEAN_PLUGIN), + ...encodeStringField(3, GLEAN_PLUGIN), + ]; + + const message = [ + ...encodeStringField(1, GLEAN_PLUGIN), + ...(chatSessionId ? encodeStringField(2, chatSessionId) : []), + ...encodeMessageField(4, sourceInfo), + ]; + + return Buffer.from(new Uint8Array(message)).toString("base64"); +} + +function loggingFetch( + input: string | URL | Request, + init?: RequestInit, +): Promise { + const method = init?.method ?? "GET"; + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + console.error(`[fetch] ${method} ${url}`); + return fetch(input, init).then( + (response) => { + console.error( + `[fetch] ${method} ${url} → ${response.status} ${response.statusText}`, + ); + return response; + }, + (err) => { + const msg = err instanceof Error ? err.message : String(err); + const cause = + err instanceof Error && err.cause instanceof Error + ? err.cause.message + : String(err?.cause ?? ""); + console.error(`[fetch] ${method} ${url} → NETWORK ERROR: ${msg}`); + if (cause) { + console.error(`[fetch] cause: ${cause}`); + } + throw err; + }, + ); +} + +export interface RemoteClientOptions { + authProvider?: GleanOAuthClientProvider; +} + +export class AuthRequiredError extends Error { + constructor(public readonly authUrl: string) { + super("Authentication required"); + } +} + +let pendingTransport: StreamableHTTPClientTransport | undefined; + +function buildTransport( + serverUrl: string, + opts: RemoteClientOptions, + chatSessionId?: string, +): StreamableHTTPClientTransport { + const parsedUrl = new URL(serverUrl); + const headers: Record = { + "X-Glean-Internal-Service": "true", + "X-Glean-Gateway-Request-Metadata": buildGatewayMetadataHeader(chatSessionId), + }; + + const scParam = parsedUrl.searchParams.get("sc"); + if (scParam) { + headers["X-Glean-Request-ScParams"] = scParam; + } + + const transportOpts: ConstructorParameters[1] = { + requestInit: { headers }, + fetch: loggingFetch, + }; + + if (opts.authProvider) { + transportOpts.authProvider = opts.authProvider; + } + + return new StreamableHTTPClientTransport(parsedUrl, transportOpts); +} + +export async function createRemoteClient( + serverUrl: string, + opts: RemoteClientOptions, + chatSessionId?: string, +): Promise { + const authProvider = opts.authProvider; + + // Complete a pending auth flow if the user has authenticated in the browser. + // Two shapes: + // (a) In-process — pendingTransport was set when a prior connect in this + // same process threw UnauthorizedError. Use it directly. + // (b) Cross-process — server was reaped after the first auth attempt. + // authProvider restored code_verifier + authorizationUrl from disk on + // construction, and the caller injected a pasted pendingAuthCode. + // Build a fresh transport for the exchange. + if (authProvider?.pendingAuthCode) { + const transportForAuth = + pendingTransport ?? buildTransport(serverUrl, opts, chatSessionId); + console.error("[auth] Auth code received, exchanging for tokens..."); + try { + await transportForAuth.finishAuth(authProvider.pendingAuthCode); + authProvider.clearPendingAuth(); + pendingTransport = undefined; + console.error("[auth] Token exchange complete, reconnecting..."); + return createRemoteClient(serverUrl, opts, chatSessionId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[auth] Code exchange failed: ${msg} — discarding stale auth state`); + authProvider.clearPendingAuth(); + pendingTransport = undefined; + await authProvider.invalidateCredentials("all"); + return createRemoteClient(serverUrl, opts, chatSessionId); + } + } + + // DCR recovery: we previously issued an authorize URL but never received + // tokens. The URL was likely rejected by the server (most commonly: the + // cached DCR client was deleted server-side). Force a fresh DCR so the next + // URL we generate uses a valid, server-known client_id. + if (authProvider?.needsFreshClient()) { + console.error("[auth] Previous auth URL didn't complete — forcing fresh DCR"); + await authProvider.invalidateCredentials("all"); + } + + const client = new Client( + { name: "glean", version: "1.0.0" }, + { capabilities: {} }, + ); + + const transport = buildTransport(serverUrl, opts, chatSessionId); + + try { + await client.connect(transport); + } catch (error) { + if (error instanceof UnauthorizedError && authProvider?.authorizationUrl) { + pendingTransport = transport; + throw new AuthRequiredError(authProvider.authorizationUrl); + } + throw error; + } + + return client; +} + +export async function callRemoteTool( + client: Client, + name: string, + args: Record, +): Promise { + // Pass an explicit timeout so the call isn't capped at the SDK's 60s default. + // `undefined` for resultSchema keeps the SDK's CallToolResultSchema default. + const result = await client.callTool({ name, arguments: args }, undefined, { + timeout: remoteToolTimeoutMs(), + }); + if (!("content" in result)) { + return { content: [] }; + } + return result as CallToolResult; +} diff --git a/sources/glean-vnext/src/remote-tools-cache-store.ts b/sources/glean-vnext/src/remote-tools-cache-store.ts new file mode 100644 index 0000000..fa2900b --- /dev/null +++ b/sources/glean-vnext/src/remote-tools-cache-store.ts @@ -0,0 +1,89 @@ +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +const CACHE_FILENAME = "remote-tools-cache.json"; +const DIR_MODE = 0o700; +const FILE_MODE = 0o600; + +function resolveCacheDir(): string { + return process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); +} + +function cacheFile(): string { + return path.join(resolveCacheDir(), CACHE_FILENAME); +} + +interface CacheEntry { + tools: Tool[]; + fetchedAt: string; +} + +type Store = Record; + +function readStore(): Store { + try { + const raw = fs.readFileSync(cacheFile(), "utf-8"); + const data = JSON.parse(raw); + if (data && typeof data === "object" && !Array.isArray(data)) { + return data as Store; + } + return {}; + } catch { + return {}; + } +} + +function writeStore(store: Store): void { + const filePath = cacheFile(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE }); + fs.chmodSync(dir, DIR_MODE); + fs.writeFileSync(filePath, JSON.stringify(store, null, 2), { + encoding: "utf-8", + mode: FILE_MODE, + }); + fs.chmodSync(filePath, FILE_MODE); +} + +export function loadRemoteTools(serverUrl: string): Tool[] { + if (!serverUrl) return []; + const store = readStore(); + const entry = store[serverUrl]; + if (!entry || !Array.isArray(entry.tools)) return []; + return entry.tools; +} + +export function saveRemoteTools(serverUrl: string, tools: Tool[]): void { + if (!serverUrl) return; + try { + const store = readStore(); + store[serverUrl] = { tools, fetchedAt: new Date().toISOString() }; + writeStore(store); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[remote-tools-cache] Failed to persist: ${msg}`); + } +} + +export function clearRemoteTools(serverUrl?: string): void { + try { + if (!serverUrl) { + fs.rmSync(cacheFile(), { force: true }); + return; + } + const store = readStore(); + if (store[serverUrl] !== undefined) { + delete store[serverUrl]; + if (Object.keys(store).length === 0) { + fs.rmSync(cacheFile(), { force: true }); + } else { + writeStore(store); + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[remote-tools-cache] Failed to clear: ${msg}`); + } +} diff --git a/sources/glean-vnext/src/session-id.ts b/sources/glean-vnext/src/session-id.ts new file mode 100644 index 0000000..d8c0f93 --- /dev/null +++ b/sources/glean-vnext/src/session-id.ts @@ -0,0 +1,28 @@ +import { randomUUID } from "node:crypto"; + +let fallbackSessionId: string | undefined; + +/** + * Resolves the chat session id from GLEAN_SESSION_ID, which the host-aware + * launcher (start.mjs) exports after reading whatever variable the current host + * uses for the session/conversation id. The plugin itself stays host-agnostic + * and never reads host-specific env vars. + * + * When no session id is provided (hosts that expose none, e.g. Cursor), a + * plain RFC 4122 UUID is generated once per process so every call from this + * process still shares one stable, non-hallucinated id. The fallback carries + * no prefix so it stays a valid GUID if the backend ever validates + * chat_session_id as one. + */ +export function resolveSessionId(): string { + const fromHost = process.env.GLEAN_SESSION_ID?.trim(); + // Ignore empty/whitespace-only values and any un-interpolated "${VAR}" + // placeholder a launcher might pass through verbatim. + if (fromHost && !fromHost.startsWith("${")) { + return fromHost; + } + if (!fallbackSessionId) { + fallbackSessionId = randomUUID(); + } + return fallbackSessionId; +} diff --git a/sources/glean-vnext/src/skill-writer.ts b/sources/glean-vnext/src/skill-writer.ts new file mode 100644 index 0000000..08a3cb0 --- /dev/null +++ b/sources/glean-vnext/src/skill-writer.ts @@ -0,0 +1,156 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import yaml from "yaml"; +import type { SkillsMap, SkillIndex } from "./types.js"; + +function isInsideDir(filePath: string, dir: string): boolean { + const resolved = path.resolve(filePath); + return resolved.startsWith(path.resolve(dir) + path.sep); +} + +/** + * Parses YAML frontmatter from a SKILL.md string, returning key-value pairs + * for top-level scalar fields (name, description, etc.). + */ +function parseFrontmatter(content: string): Record { + // Extract the YAML block between --- delimiters, allowing CRLF line endings. + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) { + return {}; + } + const result: Record = {}; + try { + const parsed = yaml.parse(match[1]); + if (parsed && typeof parsed === "object") { + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "string") { + result[key] = value; + } + } + } + } catch { + return {}; + } + return result; +} + +type LogFn = (label: string, detail?: Record) => void; + +/** + * Remove cached skill subdirectories whose mtime is older than `maxAgeMs`. + * `writeSkillsToDisk` rm-then-mkdir's a skill dir on every refetch, so dir + * mtime is a reliable last-refresh signal. Safe to evict aggressively — + * find_skills re-fetches on demand if the agent references a skill whose + * files were removed. + */ +export async function evictStaleSkills( + baseDir: string, + maxAgeMs: number, + log?: LogFn, + now: number = Date.now(), +): Promise { + let entries; + try { + entries = await fs.readdir(baseDir, { withFileTypes: true }); + } catch { + return; + } + const cutoff = now - maxAgeMs; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory()) return; + const skillDir = path.resolve(baseDir, entry.name); + if (!isInsideDir(skillDir, baseDir)) return; + try { + const stat = await fs.stat(skillDir); + if (stat.mtimeMs < cutoff) { + await fs.rm(skillDir, { recursive: true, force: true }); + log?.("evict-stale-skill", { skill: entry.name }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log?.("evict-stale-skill.failed", { skill: entry.name, msg }); + } + }), + ); +} + +export async function writeSkillsToDisk( + skills: SkillsMap, + baseDir: string, +): Promise { + const index: SkillIndex[] = []; + + for (const [skillName, fileMap] of Object.entries(skills)) { + const skillDir = path.resolve(baseDir, skillName); + if (!isInsideDir(skillDir, baseDir)) { + continue; + } + + // Delete and re-create so re-fetched skills never serve stale files. + await fs.rm(skillDir, { recursive: true, force: true }); + await fs.mkdir(skillDir, { recursive: true }); + + const writtenFiles: string[] = []; + + for (const [filePath, content] of Object.entries(fileMap)) { + const fullPath = path.resolve(skillDir, filePath); + if (!isInsideDir(fullPath, skillDir)) { + continue; + } + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + const text = + typeof content === "string" ? content : JSON.stringify(content); + await fs.writeFile(fullPath, text, "utf-8"); + writtenFiles.push(fullPath); + } + + const rawSkillMd = fileMap["SKILL.md"] ?? ""; + const skillMdContent = typeof rawSkillMd === "string" ? rawSkillMd : ""; + const frontmatter = parseFrontmatter(skillMdContent); + + index.push({ + name: frontmatter.name ?? skillName, + description: frontmatter.description ?? "", + skillDir, + files: writtenFiles, + }); + } + + return index; +} + +export function formatAvailableSkillsPrompt(index: SkillIndex[]): string { + if (index.length === 0) { + return ""; + } + + const skillEntries = index.map((entry) => { + const skillMd = entry.files.find((f) => f.endsWith("/SKILL.md")); + const fileLines = skillMd + ? `\n \n ` + : ""; + + return [ + ` `, + ` ${fileLines}`, + ` `, + ].join("\n"); + }); + + // Usage instructions live in the find_skills tool description (advertised + // once at tools/list) rather than being re-emitted in every response. + return [ + "", + ...skillEntries, + "", + ].join("\n"); +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/sources/glean-vnext/src/token-store.ts b/sources/glean-vnext/src/token-store.ts new file mode 100644 index 0000000..e41c2fa --- /dev/null +++ b/sources/glean-vnext/src/token-store.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; + +const CREDENTIALS_FILENAME = "mcp-credentials.json"; +const DIR_MODE = 0o700; +const FILE_MODE = 0o600; + +function resolveCredentialsDir(): string { + return process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); +} + +function credentialsFile(): string { + return path.join(resolveCredentialsDir(), CREDENTIALS_FILENAME); +} + +interface StoredCredentials { + tokens?: unknown; + clientInfo?: unknown; +} + +export function loadCredentials(): StoredCredentials | undefined { + try { + const raw = fs.readFileSync(credentialsFile(), "utf-8"); + return JSON.parse(raw) as StoredCredentials; + } catch { + return undefined; + } +} + +export function saveCredentials(tokens: unknown, clientInfo: unknown): void { + try { + const filePath = credentialsFile(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE }); + fs.chmodSync(dir, DIR_MODE); + const data: StoredCredentials = { tokens, clientInfo }; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { + encoding: "utf-8", + mode: FILE_MODE, + }); + fs.chmodSync(filePath, FILE_MODE); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[auth] Failed to persist credentials: ${msg}`); + } +} + +export function clearCredentials(): void { + try { + fs.rmSync(credentialsFile(), { force: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[auth] Failed to clear credentials: ${msg}`); + } +} diff --git a/sources/glean-vnext/src/tools/approval-args.ts b/sources/glean-vnext/src/tools/approval-args.ts new file mode 100644 index 0000000..de6be3b --- /dev/null +++ b/sources/glean-vnext/src/tools/approval-args.ts @@ -0,0 +1,137 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { resolveSessionId } from "../session-id.js"; + +// The argument section of the approval prompt is capped to this many lines so +// the Accept/Decline buttons stay in view. When a spill file is needed, one of +// these lines is the file path (so up to maxArgSectionLines-1 arguments show). +const maxArgSectionLines = 8; +// Per-argument inline width before a value is cut and marked (truncated). +const maxApprovalArgChars = 120; + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function isEmptyArgs(args: unknown): boolean { + return ( + args == null || + (typeof args === "object" && + !Array.isArray(args) && + Object.keys(args as object).length === 0) + ); +} + +// Render one argument as a single line. Multi-line strings are collapsed to +// spaces; values past the inline width are cut and suffixed with "(truncated)". +// `truncated` is true whenever the inline form is not the faithful full value, +// so the caller knows to spill the full content to a file. +function compactArgLine( + key: string, + value: unknown, +): { line: string; truncated: boolean } { + let rendered: string; + let truncated = false; + + if (typeof value === "string") { + const collapsed = value.replace(/\s+/g, " ").trim(); + if (value.includes("\n") || collapsed.length > maxApprovalArgChars) { + truncated = true; + } + rendered = + collapsed.length > maxApprovalArgChars + ? `${collapsed.slice(0, maxApprovalArgChars)}… (truncated)` + : collapsed; + } else if (value !== null && typeof value === "object") { + const json = safeJson(value); + if (json.length > maxApprovalArgChars) { + rendered = `${json.slice(0, maxApprovalArgChars)}… (truncated)`; + truncated = true; + } else { + rendered = json; + } + } else { + rendered = String(value); + } + + return { line: `${key.toUpperCase()}: ${rendered}`, truncated }; +} + +// Build the compact, viewport-friendly argument lines for the approval prompt. +// Caps the number of lines and sets needsFile when anything was truncated or +// any argument was omitted, so the caller can spill the full set to a file. +export function buildCompactArgs(args: unknown): { + lines: string[]; + needsFile: boolean; +} { + if (isEmptyArgs(args)) { + return { lines: ["(none)"], needsFile: false }; + } + if (typeof args !== "object" || Array.isArray(args)) { + const { line, truncated } = compactArgLine("value", args); + return { lines: [line], needsFile: truncated }; + } + + const entries = Object.entries(args as Record); + const rendered = entries.map(([key, value]) => compactArgLine(key, value)); + const anyTruncated = rendered.some((r) => r.truncated); + const needsFile = entries.length > maxArgSectionLines || anyTruncated; + // Reserve one line for the file path when spilling. + const inlineCount = needsFile ? maxArgSectionLines - 1 : maxArgSectionLines; + const lines = rendered.slice(0, inlineCount).map((r) => r.line); + return { lines, needsFile }; +} + +// Full, untruncated rendering for the spill file. Markdown so it reads well +// when opened: string values are written verbatim (so any Markdown — tables, +// headings — renders), and nested values are pretty-printed JSON in a code +// block. +export function formatArgumentsForFile( + toolName: string, + args: unknown, +): string { + const out: string[] = [`# Approval request: ${toolName}`, ""]; + if (isEmptyArgs(args)) { + out.push("_(no arguments)_", ""); + return out.join("\n"); + } + if (typeof args !== "object" || Array.isArray(args)) { + out.push("```json", JSON.stringify(args, null, 2), "```", ""); + return out.join("\n"); + } + for (const [key, value] of Object.entries(args as Record)) { + out.push(`## ${key}`, ""); + if (typeof value === "string") { + out.push(value, ""); + } else { + out.push("```json", JSON.stringify(value, null, 2), "```", ""); + } + } + return out.join("\n"); +} + +// The full arguments are written to a per-session file under the plugin's data +// dir (CLAUDE_PLUGIN_DATA, set by start.mjs as PLUGIN_DATA_DIR). The file is +// scoped to the chat session id so parallel sessions don't overwrite each +// other; within a session it is intentionally overwritten on each approval — +// only the most recent prompt's arguments need to be inspectable. +export async function writeApprovalArgsFile( + toolName: string, + args: unknown, +): Promise { + const base = + process.env.PLUGIN_DATA_DIR || + process.env.CLAUDE_PLUGIN_DATA || + os.tmpdir(); + const sessionId = resolveSessionId().replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64); + const dir = path.join(base, "glean-approvals", sessionId); + await fs.mkdir(dir, { recursive: true }); + const file = path.join(dir, "glean-approval-args.md"); + await fs.writeFile(file, formatArgumentsForFile(toolName, args), "utf-8"); + return file; +} diff --git a/sources/glean-vnext/src/tools/find-skills.ts b/sources/glean-vnext/src/tools/find-skills.ts new file mode 100644 index 0000000..313b3bd --- /dev/null +++ b/sources/glean-vnext/src/tools/find-skills.ts @@ -0,0 +1,38 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { callRemoteTool } from "../remote-client.js"; +import { writeSkillsToDisk, formatAvailableSkillsPrompt } from "../skill-writer.js"; +import type { SkillsMap } from "../types.js"; + +export async function handleFindSkills( + remoteClient: Client, + skillsBaseDir: string, + args: Record, +): Promise { + const toolArgs: Record = {}; + if (Array.isArray(args.queries)) { + toolArgs.queries = args.queries; + } else if (typeof args.query === "string") { + toolArgs.queries = [args.query]; + } + + const result = await callRemoteTool(remoteClient, "find_skills", toolArgs); + + const textContent = result.content.find((c) => c.type === "text"); + if (!textContent || textContent.type !== "text") { + return ""; + } + + if (result.isError) { + throw new Error(textContent.text || "find_skills failed"); + } + + const parsed = JSON.parse(textContent.text) as { skills?: SkillsMap }; + if (!parsed.skills || typeof parsed.skills !== "object") { + console.error( + `find_skills: unexpected response shape, keys: ${Object.keys(parsed).join(", ")}`, + ); + return ""; + } + const index = await writeSkillsToDisk(parsed.skills, skillsBaseDir); + return formatAvailableSkillsPrompt(index); +} diff --git a/sources/glean-vnext/src/tools/remote-passthrough.ts b/sources/glean-vnext/src/tools/remote-passthrough.ts new file mode 100644 index 0000000..5fd8e76 --- /dev/null +++ b/sources/glean-vnext/src/tools/remote-passthrough.ts @@ -0,0 +1,146 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { + AuthRequiredError, + callRemoteTool, + createRemoteClient, + type RemoteClientOptions, +} from "../remote-client.js"; + +// Remote tools promoted to first-class local tools once setup completes. +// Anything the remote MCP server exposes that is not in this set is dropped +// by fetchAllowedRemoteTools and rejected by dispatchRemoteTool. +export const REMOTE_TOOLS_ALLOWLIST: ReadonlySet = new Set([ + "search", + "read_document", + "chat", + "memory", + "memory_schema", + "user_activity", + "employee_search", +]); + +type ToolInputSchema = Tool["inputSchema"]; + +/** + * Normalize the remote tool's input schema into the shape MCP expects for + * a registered tool (object root with `properties`/`required`). Promoted + * tools forward whatever the agent supplies straight through; no + * plugin-only keys are spliced in. + */ +export function augmentSchemaForLocal(schema: unknown): ToolInputSchema { + const base = + schema && typeof schema === "object" && !Array.isArray(schema) + ? structuredClone(schema as Record) + : {}; + + const properties = (base.properties as Record) ?? {}; + const required = Array.isArray(base.required) + ? (base.required as string[]) + : []; + + return { + ...base, + type: "object", + properties, + required, + } as ToolInputSchema; +} + +/** + * List tools on the remote MCP server, filter to the allow-list, and return + * each surviving tool with its input schema augmented for local exposure. + * + * Walks pagination cursors to exhaustion in case the remote ever paginates. + */ +export async function fetchAllowedRemoteTools( + remoteClient: Client, +): Promise { + const collected: Tool[] = []; + let cursor: string | undefined; + do { + const page = await remoteClient.listTools( + cursor ? { cursor } : undefined, + ); + for (const tool of page.tools) { + if (!REMOTE_TOOLS_ALLOWLIST.has(tool.name)) continue; + collected.push({ + ...tool, + inputSchema: augmentSchemaForLocal(tool.inputSchema), + } as Tool); + } + cursor = typeof page.nextCursor === "string" ? page.nextCursor : undefined; + } while (cursor); + return collected; +} + +export interface DispatchContext { + serverUrl: string; + remoteClientOpts: RemoteClientOptions; + authRedirectText: string; + logLine: (label: string, detail?: Record) => void; +} + +/** + * Dispatch a call to an allow-listed remote MCP tool. Opens a remote + * client, calls through, and returns the unwrapped CallToolResult. When + * auth is missing we return a "call setup first" envelope rather than + * driving OAuth here — that's the setup tool's job. + */ +export async function dispatchRemoteTool( + toolName: string, + args: Record, + ctx: DispatchContext, +): Promise { + let remoteClient: Client; + try { + remoteClient = await createRemoteClient( + ctx.serverUrl, + ctx.remoteClientOpts, + ); + } catch (err) { + if (err instanceof AuthRequiredError) { + return { + content: [{ type: "text", text: ctx.authRedirectText }], + }; + } + const msg = err instanceof Error ? err.message : String(err); + ctx.logLine("connect.backend-error", { label: toolName, msg }); + return { + content: [ + { type: "text", text: `Failed to connect to Glean backend: ${msg}` }, + ], + isError: true, + }; + } + + try { + const result = await callRemoteTool(remoteClient, toolName, args); + if (result.isError) { + ctx.logLine("dispatch.remote-isError", { + label: toolName, + rawResult: JSON.stringify(result).slice(0, 8000), + }); + } + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const detail: Record = { label: toolName, msg }; + if (err && typeof err === "object") { + const anyErr = err as Record; + if (anyErr.code !== undefined) detail.code = anyErr.code; + if (anyErr.data !== undefined) detail.data = anyErr.data; + if (err instanceof Error && err.cause !== undefined) { + detail.cause = + err.cause instanceof Error ? err.cause.message : err.cause; + } + } + ctx.logLine("dispatch.execution-failed", detail); + return { + content: [{ type: "text", text: `${toolName} failed: ${msg}` }], + isError: true, + }; + } finally { + await remoteClient.close(); + } +} diff --git a/sources/glean-vnext/src/tools/run-tool.ts b/sources/glean-vnext/src/tools/run-tool.ts new file mode 100644 index 0000000..00c4f73 --- /dev/null +++ b/sources/glean-vnext/src/tools/run-tool.ts @@ -0,0 +1,452 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { callRemoteTool } from "../remote-client.js"; +import { buildCompactArgs, writeApprovalArgsFile } from "./approval-args.js"; +import { resolveSessionId } from "../session-id.js"; + +const DEFAULT_FILE_ARG_MAX_BYTES = 1 * 1024 * 1024; + +// How long a user has to respond to an approval prompt. The MCP SDK's own +// request timeout is 60s and, on expiry, elicitInput REJECTS — so unless we +// pass an explicit (longer) value the prompt errors out from under the user. +const defaultHitlTimeoutMs = 300_000; + +export class FileArgsError extends Error { + constructor(message: string) { + super(message); + this.name = "FileArgsError"; + } +} + +// A downstream tool parameter's JSON Schema, narrowed to the bits we use. +// `type` may be a single string or an array (e.g. ["object", "null"]). +interface ParamSchema { + type?: string | string[]; +} +interface ToolInputSchema { + properties?: Record; +} + +// The set of JSON Schema types declared for a top-level parameter. file_args +// keys always map to top-level argument names, so a direct properties lookup +// is sufficient — no need to walk nested schemas. +function declaredParamTypes( + inputSchema: ToolInputSchema | undefined, + argName: string, +): Set { + const t = inputSchema?.properties?.[argName]?.type; + if (typeof t === "string") return new Set([t]); + if (Array.isArray(t)) { + return new Set(t.filter((x): x is string => typeof x === "string")); + } + return new Set(); +} + +function fileArgsMaxBytes(): number { + const raw = process.env.GLEAN_FILE_ARG_MAX_BYTES; + if (!raw) return DEFAULT_FILE_ARG_MAX_BYTES; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_FILE_ARG_MAX_BYTES; +} + +function hitlTimeoutMs(): number { + const raw = process.env.HITL_TIMEOUT_MS; + if (!raw) return defaultHitlTimeoutMs; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultHitlTimeoutMs; +} + +/** + * Reads each `file_args` entry from disk and merges its content into + * `baseArgs` under the given key. The downstream tool's `inputSchema` decides + * how the content is injected: a parameter typed `object`/`array` is JSON- + * parsed into structured data (a raw string would fail the downstream schema + * with "Expected object, given string"), while everything else — the common + * case of long-form text bodies — is injected verbatim as a UTF-8 string. + * Throws FileArgsError on any validation failure so the caller can surface the + * message verbatim to the model. + */ +export async function resolveFileArgs( + fileArgs: unknown, + baseArgs: Record, + inputSchema?: ToolInputSchema, +): Promise> { + if (fileArgs === undefined || fileArgs === null) return baseArgs; + if ( + typeof fileArgs !== "object" || + Array.isArray(fileArgs) + ) { + throw new FileArgsError( + "file_args must be an object mapping arg name to absolute file path", + ); + } + + const entries = Object.entries(fileArgs as Record); + if (entries.length === 0) return baseArgs; + + const merged: Record = { ...baseArgs }; + const maxBytes = fileArgsMaxBytes(); + + for (const [argName, filePathRaw] of entries) { + if (typeof filePathRaw !== "string" || filePathRaw === "") { + throw new FileArgsError( + `file_args.${argName} must be a non-empty string path`, + ); + } + if (!path.isAbsolute(filePathRaw)) { + throw new FileArgsError( + `file_args.${argName} must be an absolute path; got "${filePathRaw}"`, + ); + } + if (argName in baseArgs) { + throw new FileArgsError( + `file_args.${argName} conflicts with arguments.${argName}; remove one`, + ); + } + + let stat; + try { + stat = await fs.stat(filePathRaw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new FileArgsError( + `file_args.${argName}: cannot read "${filePathRaw}": ${msg}`, + ); + } + if (!stat.isFile()) { + throw new FileArgsError( + `file_args.${argName}: "${filePathRaw}" is not a regular file`, + ); + } + if (stat.size > maxBytes) { + throw new FileArgsError( + `file_args.${argName}: "${filePathRaw}" is ${stat.size} bytes, exceeds ${maxBytes} byte limit (set GLEAN_FILE_ARG_MAX_BYTES to override)`, + ); + } + + const content = await fs.readFile(filePathRaw, "utf-8"); + const types = declaredParamTypes(inputSchema, argName); + if (types.has("object") || types.has("array")) { + try { + merged[argName] = JSON.parse(content); + } catch (err) { + // A union like ["string", "object"] can legitimately take raw text, so + // keep the string. A pure object/array param cannot — fail with a clear + // message before the opaque downstream "Expected object, given string". + if (types.has("string")) { + merged[argName] = content; + } else { + const msg = err instanceof Error ? err.message : String(err); + throw new FileArgsError( + `file_args.${argName}: "${filePathRaw}" must contain valid JSON for the object/array-typed parameter, but parsing failed: ${msg}`, + ); + } + } + } else { + merged[argName] = content; + } + } + + return merged; +} + +interface ToolMetadata { + requires_approval?: boolean; + name?: string; + description?: string; + server_id?: string; + inputSchema?: ToolInputSchema; +} + +async function findToolJson( + skillsBaseDir: string, + toolName: string, +): Promise { + try { + const skillDirs = await fs.readdir(skillsBaseDir, { withFileTypes: true }); + for (const dir of skillDirs) { + if (!dir.isDirectory()) continue; + const toolPath = path.join(skillsBaseDir, dir.name, "tools", `${toolName}.json`); + try { + const content = await fs.readFile(toolPath, "utf-8"); + return JSON.parse(content) as ToolMetadata; + } catch { + continue; + } + } + } catch { + // Skills dir doesn't exist or can't be read + } + return null; +} + +// A stdio server's only client signal is clientInfo.name. Cursor reports +// "cursor-vscode" and already renders the tool name + arguments in its own +// expandable UI, so its approval prompt only needs a one-line review ask. +export function isCursorClient(mcpServer: Server): boolean { + return (mcpServer.getClientVersion()?.name ?? "") + .toLowerCase() + .startsWith("cursor"); +} + +// Plain text, NOT Markdown: Claude Code does not reliably render Markdown in +// elicitation prompts. Kept short (a few lines) so the Accept/Decline buttons +// stay in view; full argument detail spills to a file when it can't fit. +async function buildApprovalMessage( + mcpServer: Server, + toolName: string, + args: unknown, +): Promise { + if (isCursorClient(mcpServer)) { + return `Review the tool and arguments shown above, click on Submit to allow and Cancel to deny.`; + } + + const { lines, needsFile } = buildCompactArgs(args); + // Indent argument lines under "Arguments:" so the structural labels stay + // distinct from values; keys are uppercased (in compactArgLine) so a key + // reads distinctly from its value — plain-text cues that cost no vertical + // space. + const message = [ + `Action: ${toolName}`, + "Arguments:", + ...lines.map((line) => ` ${line}`), + ]; + if (needsFile) { + // Best-effort: a failed spill (e.g. a sandbox blocking writes outside the + // project dir) must never break the approval gate, so fall back to a note. + try { + const filePath = await writeApprovalArgsFile(toolName, args); + message.push(` Full arguments: ${filePath}`); + } catch { + message.push(" (some arguments truncated; full-args file unavailable)"); + } + } + return message.join("\n"); +} + +// A WeakSet so a short-lived server in tests doesn't leak, +// and so the burn happens exactly once per server instance. +const elicitationIdPrimed = new WeakSet(); +function primeElicitationCancellation(mcpServer: Server): void { + if (elicitationIdPrimed.has(mcpServer)) return; + elicitationIdPrimed.add(mcpServer); + void mcpServer.request({ method: "ping" }, EmptyResultSchema).catch(() => { + // Ping rejection is fine: request id 0 is already consumed by this call + }); +} + +// Path to the per-session permission-mode marker the PreToolUse hook writes +// immediately before each run_tool call (see hooks/auto-approve-run-tool.mjs). +// This resolution MUST match the hook's exactly. The hook cannot see the +// server-only PLUGIN_DATA_DIR that start.sh derives, so both sides key off +// CLAUDE_PLUGIN_DATA (falling back to ~/.glean) — the one anchor available to +// both processes. Under start.sh, PLUGIN_DATA_DIR resolves to this same path. +function permissionModeMarkerPath(): string { + const base = + process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".glean"); + const sessionId = resolveSessionId() + .replace(/[^a-zA-Z0-9_-]/g, "-") + .slice(0, 64); + return path.join(base, "glean-hitl-mode", `${sessionId}.json`); +} + +// Claude Code's live permission mode for THIS session, as captured by the hook +// on the current call. Returns null when the marker is missing, unreadable, or +// malformed — the caller treats null as "unknown" and keeps the approval gate, +// so any failure fails toward prompting, never toward a silent bypass. +// +// Resume safety: the PreToolUse hook rewrites this marker with the CURRENT mode +// on every run_tool call (see hooks/auto-approve-run-tool.mjs), and PreToolUse +// always runs before the tool executes, so the value read here is the one +// written for this exact call. A session first launched with +// --dangerously-skip-permissions and later resumed WITHOUT it (same session id) +// therefore has its stale bypass marker overwritten with the resumed mode on +// the resumed session's first run_tool call, re-engaging the gate. +async function currentPermissionMode(): Promise { + try { + const raw = await fs.readFile(permissionModeMarkerPath(), "utf-8"); + const parsed = JSON.parse(raw) as { permission_mode?: unknown }; + return typeof parsed.permission_mode === "string" + ? parsed.permission_mode + : null; + } catch { + return null; + } +} + +export async function handleRunTool( + remoteClient: Client, + mcpServer: Server, + skillsBaseDir: string, + args: Record, +): Promise { + const serverId = args.server_id; + const toolName = args.tool_name; + + if (typeof serverId !== "string" || typeof toolName !== "string") { + return { + content: [ + { type: "text", text: "server_id and tool_name are required strings" }, + ], + isError: true, + }; + } + + // Load the downstream tool's metadata once, up front: its inputSchema drives + // file_args JSON-parsing (object/array params) and its requires_approval + // drives the HITL gate. Both paths must see it regardless of ENABLE_HITL. + const toolMeta = await findToolJson(skillsBaseDir, toolName); + + // Resolve file_args up front so the approval prompt shows the COMPLETE input + // (file-sourced values included, not just the inline `arguments`), and so an + // unreadable file_args path fails before we prompt the user. + const baseArgs = + args.arguments != null && typeof args.arguments === "object" + ? (args.arguments as Record) + : {}; + let resolvedArgs: Record; + try { + resolvedArgs = await resolveFileArgs( + args.file_args, + baseArgs, + toolMeta?.inputSchema, + ); + } catch (err) { + if (err instanceof FileArgsError) { + return { + content: [{ type: "text", text: err.message }], + isError: true, + }; + } + throw err; + } + + const hitlEnabled = process.env.ENABLE_HITL === "true"; + // Cursor is gated by its OWN native run-tool approval, not our elicitation. + // We omit run_tool's readOnlyHint for Cursor (see runToolAnnotations), so + // Cursor prompts the user before it executes run_tool. Firing our elicitation + // on top would be a redundant second gate — and worse, Cursor 3.12.x silently + // drops server-initiated elicitations on the auto-run lane, hanging for the + // full HITL timeout. So skip our gate for Cursor and let its native prompt + // (already shown before this call) be the single approval. + if ( + hitlEnabled && + toolMeta?.requires_approval && + !isCursorClient(mcpServer) && + mcpServer.getClientCapabilities()?.elicitation + ) { + // In bypassPermissions mode (`claude --dangerously-skip-permissions`) the + // user has opted out of every approval prompt for the session, so our own + // elicitation gate is just a redundant popup — skip it and execute + // directly. The mode comes from the PreToolUse hook, which writes it keyed + // by session id immediately before this call, so it reflects the current + // call and never leaks across sessions. Any other or unknown mode keeps the + // gate. Only bypassPermissions is skipped (deliberately narrow). + const bypass = (await currentPermissionMode()) === "bypassPermissions"; + if (!bypass) { + const message = await buildApprovalMessage( + mcpServer, + toolName, + resolvedArgs, + ); + const timeout = hitlTimeoutMs(); + + // Make a dummy empty request to burn JSON-RPC request id 0 + primeElicitationCancellation(mcpServer); + + try { + const result = await mcpServer.elicitInput( + { + message, + requestedSchema: { type: "object", properties: {} } as any, + }, + { timeout }, + ); + + if (result.action !== "accept") { + return { + content: [ + { + type: "text", + text: `Action ${toolName} was ${result.action === "decline" ? "declined" : "cancelled"} by the user.`, + }, + ], + }; + } + } catch (err) { + // Fail CLOSED. An approval gate that executes the action when the + // prompt times out or errors defeats its own purpose — and the SDK + // rejects elicitInput precisely on request timeout. + const detail = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text", + text: `Action ${toolName} was not approved — the approval request failed (${detail}). The action was NOT executed. Ask the user to confirm, then retry.`, + }, + ], + isError: true, + }; + } + } + } + + return callRemoteTool( + remoteClient, + "run_tool", + buildRemoteArgs(serverId, toolName, resolvedArgs), + ); +} + +/** + * Assemble the payload for the backend `run_tool` meta-tool. `arguments` is + * ALWAYS included, even when empty: the downstream MCP `tools/call` validates + * `params.arguments` as an object, and an absent field serializes to `null`, + * which strict downstream servers reject ("Expected: object, given: null"). + * Sending an explicit `{}` for no-argument tools matches what the MCP SDK + * does for direct tool calls. + */ +export function buildRemoteArgs( + serverId: string, + toolName: string, + resolvedArgs: Record, +): Record { + return { + server_id: serverId, + tool_name: toolName, + arguments: resolvedArgs, + }; +} + +/** + * Annotations for the `run_tool` meta-tool. When HITL is active for an + * elicitation-capable client, our own approval prompt is the gate, so we mark + * the tool `readOnlyHint` to suppress the client's native run-tool confirmation + * and avoid a double prompt. Without HITL there is no gate of our own, so we + * leave annotations unset and let the client decide. + * + * TEMP (Cursor): Cursor 3.12.x silently drops the server-initiated elicitation + * for a `run_tool` marked `readOnlyHint` (it lands on the auto-run lane), so the + * approval banner never shows and the call hangs to the HITL timeout. For Cursor + * we therefore flip the whole strategy: do NOT advertise `readOnlyHint` (so + * Cursor shows its OWN native run-tool approval before executing), and skip our + * elicitation entirely (see handleRunTool) so Cursor's native prompt is the + * single gate. Claude Code is unaffected: it keeps `readOnlyHint` (its native + * prompt stays suppressed) and our elicitation remains its gate. + */ +export function runToolAnnotations( + enableHitl: boolean, + clientSupportsElicitation: boolean, + isCursor: boolean, +): Tool["annotations"] { + return enableHitl && clientSupportsElicitation && !isCursor + ? { readOnlyHint: true } + : undefined; +} diff --git a/sources/glean-vnext/src/types.ts b/sources/glean-vnext/src/types.ts new file mode 100644 index 0000000..9dfa92a --- /dev/null +++ b/sources/glean-vnext/src/types.ts @@ -0,0 +1,17 @@ +/** + * Wire format from find_skills: a flat map of slash-separated file paths to + * file contents (e.g. {"SKILL.md": "...", "tools/FOO.json": "..."}). + */ +export type SkillDirectoryMap = Record; + +/** + * Wire format from find_skills: a map of skill names to their file maps. + */ +export type SkillsMap = Record; + +export interface SkillIndex { + name: string; + description: string; + skillDir: string; + files: string[]; +} diff --git a/sources/glean-vnext/src/url-config-store.ts b/sources/glean-vnext/src/url-config-store.ts new file mode 100644 index 0000000..df46d7e --- /dev/null +++ b/sources/glean-vnext/src/url-config-store.ts @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; + +const CONFIG_FILENAME = "mcp-server-url.json"; +const DIR_MODE = 0o700; +const FILE_MODE = 0o600; + +function resolveConfigDir(): string { + return process.env.PLUGIN_DATA_DIR || path.join(homedir(), ".glean"); +} + +function configFile(): string { + return path.join(resolveConfigDir(), CONFIG_FILENAME); +} + +interface StoredConfig { + serverUrl: string; +} + +export function loadServerUrl(): string | undefined { + try { + const raw = fs.readFileSync(configFile(), "utf-8"); + const data = JSON.parse(raw) as StoredConfig; + if (typeof data.serverUrl !== "string" || !data.serverUrl) return undefined; + return data.serverUrl; + } catch { + return undefined; + } +} + +export function saveServerUrl(url: string): void { + const filePath = configFile(); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE }); + fs.chmodSync(dir, DIR_MODE); + const data: StoredConfig = { serverUrl: url }; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { + encoding: "utf-8", + mode: FILE_MODE, + }); + fs.chmodSync(filePath, FILE_MODE); +} + +export function clearServerUrl(): void { + try { + fs.rmSync(configFile(), { force: true }); + } catch { + /* ignore */ + } +} diff --git a/sources/glean-vnext/start.mjs b/sources/glean-vnext/start.mjs new file mode 100644 index 0000000..84e549b --- /dev/null +++ b/sources/glean-vnext/start.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// @ts-check +// Invoked by the plugin host (Claude Code, Codex, or Cursor) to launch the Glean +// MCP server. The plugin ships a single-file esbuild output at dist/index.js with +// every non-builtin inlined — no node_modules next to it. This script handles +// env sanitation before launching the plugin proper. +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +// Treat empty strings and un-interpolated "${VAR}" placeholders (which a host +// may pass through verbatim when a variable is unset) as "not set" — matching +// the bundle's own readEnv / resolveSessionId guards. +/** @param {string | undefined} v @returns {string | undefined} */ +function val(v) { + if (v === undefined) return undefined; + const t = v.trim(); + if (t === "" || t.startsWith("${")) return undefined; + return t; +} + +const launchCwd = process.cwd(); + +// Resolve where credentials, caches, and config are stored. +// CLAUDE_PLUGIN_DATA is the managed lifecycle dir provided by the plugin host. +const pluginDataDir = + val(process.env.CLAUDE_PLUGIN_DATA) ?? + path.join(os.homedir() || os.tmpdir(), ".glean"); +process.env.PLUGIN_DATA_DIR = pluginDataDir; + +// Discovered skill files are written under the data dir by default, so the +// skills cache tracks PLUGIN_DATA_DIR instead of being resolved separately. +let skillsBaseDir = path.join(pluginDataDir, "glean-skills-cache"); + +// Opt-in: when USE_CLAUDE_PROJECT_DIR=1, route the skills cache under the launch +// project's .claude/tmp/ so the glean_run skill's allowed-tools Read glob can +// match cache files via a path anchored to the project root. projectDir is the +// git repo root for the launch cwd, falling back to the launch cwd when it is +// not inside a git repo (or git is unavailable). +if (process.env.USE_CLAUDE_PROJECT_DIR === "1") { + let projectDir = launchCwd; + try { + const top = execFileSync( + "git", + ["-C", launchCwd, "rev-parse", "--show-toplevel"], + { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }, + ).trim(); + if (top) projectDir = top; + } catch { + /* not a git repo or git missing: keep the launch cwd fallback */ + } + skillsBaseDir = path.join(projectDir, ".claude", "tmp", "glean-skills-cache"); +} +process.env.SKILLS_BASE_DIR = skillsBaseDir; + +// Resolve the chat session id host-side. Host-awareness lives here, not in the +// plugin: the launcher reads whatever variable this host exposes and exports the +// normalized GLEAN_SESSION_ID that the Node bundle reads. Claude Code exposes +// CLAUDE_CODE_SESSION_ID; Codex exposes the conversation id as CODEX_THREAD_ID. +// Hosts that expose no session id (Cursor) leave it unset, and the plugin falls +// back to a generated per-process id. +const sessionId = + val(process.env.CLAUDE_CODE_SESSION_ID) ?? val(process.env.CODEX_THREAD_ID); +if (sessionId !== undefined) { + process.env.GLEAN_SESSION_ID = sessionId; +} + +// Boot the server in-process. Import via a file URL resolved against this +// module so the dynamic specifier works regardless of cwd and on Windows paths. +await import(new URL("./dist/index.js", import.meta.url).href); diff --git a/sources/glean-vnext/targets/codex/.mcp.json b/sources/glean-vnext/targets/codex/.mcp.json new file mode 100644 index 0000000..2785e10 --- /dev/null +++ b/sources/glean-vnext/targets/codex/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "glean-local": { + "command": "node", + "args": ["./start.mjs"], + "cwd": ".", + "env": { + "ENABLE_HITL": "true", + "HITL_TIMEOUT_MS": "300000" + } + } + } +} diff --git a/sources/glean-vnext/tests/auth-callback-server.test.ts b/sources/glean-vnext/tests/auth-callback-server.test.ts new file mode 100644 index 0000000..184e0bf --- /dev/null +++ b/sources/glean-vnext/tests/auth-callback-server.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + startCallbackServer, + closeCallbackServer, + setExpectedState, +} from "../src/auth-callback-server.js"; + +// Use an ephemeral port in tests so the suite never collides with a +// locally-running Glean plugin (which binds the default 29107). The handle +// reports the actually-bound port, which is what the tests fetch against. +process.env.GLEAN_CALLBACK_PORT = "0"; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +// Tests share a fixed loopback port, so they run sequentially and wait briefly +// for the port to be released between cases. +afterEach(async () => { + closeCallbackServer(); + setExpectedState(undefined); + await sleep(50); +}); + +describe("auth-callback-server", () => { + it("binds a loopback callback URL at /glean-cli-callback", async () => { + const { url } = await startCallbackServer(); + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/glean-cli-callback$/); + }); + + it("resolves with the authorization code", async () => { + const { url, code } = await startCallbackServer(); + const res = await fetch(`${url}?code=abc123`); + + expect(res.status).toBe(200); + expect(await code).toBe("abc123"); + }); + + it("rejects when code is missing", async () => { + const { url, code } = await startCallbackServer(); + code.catch(() => {}); + const res = await fetch(url); + + expect(res.status).toBe(400); + await expect(code).rejects.toThrow("no authorization code"); + }); + + it("accepts callback when state matches", async () => { + setExpectedState("expected-state"); + const { url, code } = await startCallbackServer(); + const res = await fetch(`${url}?code=abc&state=expected-state`); + + expect(res.status).toBe(200); + expect(await code).toBe("abc"); + }); + + it("rejects callback when state does not match", async () => { + setExpectedState("expected-state"); + const { url, code } = await startCallbackServer(); + code.catch(() => {}); + const res = await fetch(`${url}?code=abc&state=wrong-state`); + + expect(res.status).toBe(403); + await expect(code).rejects.toThrow("state mismatch"); + }); + + it("skips state validation when no expected state is set", async () => { + const { url, code } = await startCallbackServer(); + const res = await fetch(`${url}?code=abc&state=any-state`); + + expect(res.status).toBe(200); + expect(await code).toBe("abc"); + }); + + it("returns 404 for non-callback paths", async () => { + const { url, code } = await startCallbackServer(); + const base = url.replace("/glean-cli-callback", ""); + const res = await fetch(`${base}/other`); + + expect(res.status).toBe(404); + + // Clean up: a valid request resolves the code promise. + await fetch(`${url}?code=cleanup`); + await code; + }); + + it("is idempotent within a flow: repeated start returns the same handle", async () => { + const first = await startCallbackServer(); + const second = await startCallbackServer(); + expect(second.url).toBe(first.url); + }); +}); diff --git a/sources/glean-vnext/tests/auth-provider.test.ts b/sources/glean-vnext/tests/auth-provider.test.ts new file mode 100644 index 0000000..ba181f0 --- /dev/null +++ b/sources/glean-vnext/tests/auth-provider.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "auth-provider-test-")); +vi.mock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => tmpDir }; +}); + +vi.mock("node:child_process", () => ({ + exec: vi.fn(), + execFile: vi.fn(), + spawn: vi.fn(() => ({ unref: vi.fn() })), +})); + +vi.mock("../src/auth-callback-server.js", () => ({ + getCallbackUrl: () => "http://127.0.0.1:29107/glean-cli-callback", + setExpectedState: vi.fn(), +})); + +const { GleanOAuthClientProvider } = await import("../src/auth-provider.js"); +const { setExpectedState } = await import("../src/auth-callback-server.js"); + +describe("GleanOAuthClientProvider", () => { + const gleanDir = path.join(tmpDir, ".glean"); + + beforeEach(() => { + delete process.env.PLUGIN_DATA_DIR; + fs.rmSync(gleanDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + afterEach(() => { + fs.rmSync(gleanDir, { recursive: true, force: true }); + }); + + it("returns undefined tokens when no credentials file exists", () => { + const provider = new GleanOAuthClientProvider(); + expect(provider.tokens()).toBeUndefined(); + expect(provider.clientInformation()).toBeUndefined(); + }); + + it("loads persisted tokens on construction", () => { + fs.mkdirSync(gleanDir, { recursive: true }); + fs.writeFileSync( + path.join(gleanDir, "mcp-credentials.json"), + JSON.stringify({ + tokens: { access_token: "saved_tok", token_type: "Bearer" }, + clientInfo: { client_id: "saved_cid" }, + }), + ); + + const provider = new GleanOAuthClientProvider(); + + expect(provider.tokens()).toEqual({ + access_token: "saved_tok", + token_type: "Bearer", + }); + expect(provider.clientInformation()).toEqual({ client_id: "saved_cid" }); + }); + + it("saveTokens persists to disk", () => { + const provider = new GleanOAuthClientProvider(); + const tokens = { access_token: "new_tok", token_type: "Bearer" } as any; + + provider.saveTokens(tokens); + + expect(provider.tokens()).toEqual(tokens); + const raw = JSON.parse( + fs.readFileSync(path.join(gleanDir, "mcp-credentials.json"), "utf-8"), + ); + expect(raw.tokens.access_token).toBe("new_tok"); + }); + + it("saveClientInformation persists to disk", () => { + const provider = new GleanOAuthClientProvider(); + const info = { client_id: "cid", client_secret: "sec" } as any; + + provider.saveClientInformation(info); + + expect(provider.clientInformation()).toEqual(info); + const raw = JSON.parse( + fs.readFileSync(path.join(gleanDir, "mcp-credentials.json"), "utf-8"), + ); + expect(raw.clientInfo.client_id).toBe("cid"); + }); + + it("clearPendingAuth resets auth state", () => { + const provider = new GleanOAuthClientProvider(); + provider.authorizationUrl = "https://example.com/auth"; + + provider.clearPendingAuth(); + + expect(provider.pendingAuthCode).toBeUndefined(); + expect(provider.authorizationUrl).toBeUndefined(); + }); + + it("saveCodeVerifier and codeVerifier round-trip", () => { + const provider = new GleanOAuthClientProvider(); + provider.saveCodeVerifier("verifier_abc"); + expect(provider.codeVerifier()).toBe("verifier_abc"); + }); + + it("redirectUrl returns the fixed loopback callback URL", () => { + const provider = new GleanOAuthClientProvider(); + expect(provider.redirectUrl).toBe( + "http://127.0.0.1:29107/glean-cli-callback", + ); + expect(provider.clientMetadata.redirect_uris).toEqual([ + "http://127.0.0.1:29107/glean-cli-callback", + ]); + }); + + it("clientMetadata includes redirect URI and client name", () => { + const provider = new GleanOAuthClientProvider(); + const meta = provider.clientMetadata; + expect(meta.client_name).toBe("Glean Claude Code Plugin"); + expect(meta.redirect_uris).toHaveLength(1); + expect(meta.redirect_uris![0]).toMatch(/127\.0\.0\.1/); + }); + + it("redirectToAuthorization records the URL and hands state to the loopback", async () => { + const provider = new GleanOAuthClientProvider(); + await provider.redirectToAuthorization( + new URL("https://example.com/oauth/authorize?state=s1"), + ); + expect(provider.authorizationUrl).toBe( + "https://example.com/oauth/authorize?state=s1", + ); + expect(setExpectedState).toHaveBeenCalledWith("s1"); + }); + + it("invalidateCredentials('all') clears all in-memory state and deletes file", async () => { + const provider = new GleanOAuthClientProvider(); + provider.saveTokens({ access_token: "tok", token_type: "Bearer" } as any); + provider.saveClientInformation({ client_id: "cid" } as any); + provider.saveCodeVerifier("verifier"); + await provider.redirectToAuthorization(new URL("https://example.com/oauth/authorize?state=s1")); + expect(fs.existsSync(path.join(gleanDir, "mcp-credentials.json"))).toBe(true); + + await provider.invalidateCredentials("all"); + + expect(provider.tokens()).toBeUndefined(); + expect(provider.clientInformation()).toBeUndefined(); + expect(provider.codeVerifier()).toBe(""); + expect(provider.needsFreshClient()).toBe(false); + expect(fs.existsSync(path.join(gleanDir, "mcp-credentials.json"))).toBe(false); + }); + + it("invalidateCredentials('client') drops client but keeps tokens", async () => { + const provider = new GleanOAuthClientProvider(); + provider.saveTokens({ access_token: "tok" } as any); + provider.saveClientInformation({ client_id: "cid" } as any); + await provider.invalidateCredentials("client"); + expect(provider.tokens()).toEqual({ access_token: "tok" }); + expect(provider.clientInformation()).toBeUndefined(); + }); + + it("invalidateCredentials('tokens') drops tokens but keeps client", async () => { + const provider = new GleanOAuthClientProvider(); + provider.saveTokens({ access_token: "tok" } as any); + provider.saveClientInformation({ client_id: "cid" } as any); + await provider.invalidateCredentials("tokens"); + expect(provider.tokens()).toBeUndefined(); + expect(provider.clientInformation()).toEqual({ client_id: "cid" }); + }); + + it("invalidateCredentials('verifier') resets codeVerifier only", async () => { + const provider = new GleanOAuthClientProvider(); + provider.saveTokens({ access_token: "tok" } as any); + provider.saveCodeVerifier("verifier"); + await provider.invalidateCredentials("verifier"); + expect(provider.codeVerifier()).toBe(""); + expect(provider.tokens()).toEqual({ access_token: "tok" }); + }); + + it("needsFreshClient is false initially", () => { + const provider = new GleanOAuthClientProvider(); + expect(provider.needsFreshClient()).toBe(false); + }); + + it("needsFreshClient becomes true after issuing an authorize URL without tokens", async () => { + const provider = new GleanOAuthClientProvider(); + provider.saveClientInformation({ client_id: "cid" } as any); + await provider.redirectToAuthorization(new URL("https://example.com/oauth/authorize?state=s1")); + expect(provider.needsFreshClient()).toBe(true); + }); + + it("needsFreshClient is false once tokens are saved", async () => { + const provider = new GleanOAuthClientProvider(); + await provider.redirectToAuthorization(new URL("https://example.com/oauth/authorize?state=s1")); + expect(provider.needsFreshClient()).toBe(true); + provider.saveTokens({ access_token: "tok" } as any); + expect(provider.needsFreshClient()).toBe(false); + }); + + it("needsFreshClient is false while a pendingAuthCode is waiting to be exchanged", async () => { + const provider = new GleanOAuthClientProvider(); + await provider.redirectToAuthorization(new URL("https://example.com/oauth/authorize?state=s1")); + provider.setPendingAuthCode("code_xyz"); + expect(provider.needsFreshClient()).toBe(false); + }); + + it("needsFreshClient resets to false after invalidateCredentials('all')", async () => { + const provider = new GleanOAuthClientProvider(); + await provider.redirectToAuthorization(new URL("https://example.com/oauth/authorize?state=s1")); + expect(provider.needsFreshClient()).toBe(true); + await provider.invalidateCredentials("all"); + expect(provider.needsFreshClient()).toBe(false); + }); + + it("setPendingAuthCode stores code for retrieval", () => { + const provider = new GleanOAuthClientProvider(); + provider.setPendingAuthCode("code_abc"); + expect(provider.pendingAuthCode).toBe("code_abc"); + }); + + it("fires onTokensChanged on saveTokens with the new tokens", () => { + const provider = new GleanOAuthClientProvider(); + const observed: Array = []; + provider.onTokensChanged = (t) => observed.push(t); + provider.saveTokens({ access_token: "tok", token_type: "Bearer" }); + expect(observed).toEqual([{ access_token: "tok", token_type: "Bearer" }]); + }); + + it("fires onTokensChanged with undefined when invalidateCredentials clears tokens", async () => { + const provider = new GleanOAuthClientProvider(); + provider.saveTokens({ access_token: "tok", token_type: "Bearer" }); + const observed: Array = []; + provider.onTokensChanged = (t) => observed.push(t); + await provider.invalidateCredentials("all"); + expect(observed).toEqual([undefined]); + }); + + it("does not fire onTokensChanged on invalidateCredentials when there were no tokens", async () => { + const provider = new GleanOAuthClientProvider(); + const observed: Array = []; + provider.onTokensChanged = (t) => observed.push(t); + await provider.invalidateCredentials("all"); + expect(observed).toEqual([]); + }); + + it("does not fire onTokensChanged on invalidateCredentials('client')", async () => { + const provider = new GleanOAuthClientProvider(); + provider.saveTokens({ access_token: "tok", token_type: "Bearer" }); + const observed: Array = []; + provider.onTokensChanged = (t) => observed.push(t); + await provider.invalidateCredentials("client"); + expect(observed).toEqual([]); + }); +}); diff --git a/sources/glean-vnext/tests/auto-approve-hook.test.ts b/sources/glean-vnext/tests/auto-approve-hook.test.ts new file mode 100644 index 0000000..4377668 --- /dev/null +++ b/sources/glean-vnext/tests/auto-approve-hook.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const HOOK = path.resolve( + here, + "../hooks/auto-approve-run-tool.mjs", +); + +interface HookResult { + out: string; + // Parsed contents of the single permission-mode marker the hook wrote, or + // null when none was written. markerFiles lists the filenames present. + marker: { permission_mode?: string; ts?: number } | null; + markerFiles: string[]; +} + +async function runHook( + toolName: string, + env: Record, + extraInput: Record = {}, + seed?: { sessionId: string; mode: string }, +): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "approve-hook-")); + await fs.writeFile( + path.join(root, ".mcp.json"), + JSON.stringify({ mcpServers: { "glean-local": { env } } }), + ); + // Isolate the marker under a throwaway CLAUDE_PLUGIN_DATA so the hook never + // touches the developer's real ~/.glean during tests. + const dataDir = path.join(root, "plugin-data"); + // Optionally pre-seed a leftover marker (e.g. from a prior + // --dangerously-skip-permissions session) to prove the hook overwrites it. + if (seed) { + const dir = path.join(dataDir, "glean-hitl-mode"); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, `${seed.sessionId}.json`), + JSON.stringify({ permission_mode: seed.mode, ts: 0 }), + ); + } + try { + const out = await new Promise((resolve, reject) => { + const child = spawn("node", [HOOK], { + env: { + ...process.env, + CLAUDE_PLUGIN_ROOT: root, + CLAUDE_PLUGIN_DATA: dataDir, + }, + }); + let o = ""; + child.stdout.on("data", (d) => (o += d.toString())); + child.on("error", reject); + child.on("close", () => resolve(o)); + child.stdin.write(JSON.stringify({ tool_name: toolName, ...extraInput })); + child.stdin.end(); + }); + + let markerFiles: string[] = []; + let marker: HookResult["marker"] = null; + try { + const dir = path.join(dataDir, "glean-hitl-mode"); + markerFiles = await fs.readdir(dir); + if (markerFiles.length) { + marker = JSON.parse( + await fs.readFile(path.join(dir, markerFiles[0]), "utf-8"), + ); + } + } catch { + // No marker directory: nothing was written. + } + return { out, marker, markerFiles }; + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +const glean = (tool: string) => `mcp__plugin_glean-vnext_glean__${tool}`; +const hitlOn = { ENABLE_HITL: "true" }; +const hitlOff = { ENABLE_HITL: "false" }; +const bypass = { permission_mode: "bypassPermissions", session_id: "sess-1" }; + +describe("auto-approve-run-tool hook (Claude Code PreToolUse)", () => { + it("allows glean run_tool when HITL is on", async () => { + const { out } = await runHook(glean("run_tool"), hitlOn); + expect(JSON.parse(out).hookSpecificOutput.permissionDecision).toBe("allow"); + }); + + it("never allows when HITL is off (safety)", async () => { + const { out } = await runHook(glean("run_tool"), hitlOff); + expect(out.trim()).toBe(""); + }); + + it("ignores a non-glean run_tool (scoped to this plugin)", async () => { + const { out } = await runHook("mcp__other-server__run_tool", hitlOn); + expect(out.trim()).toBe(""); + }); + + it("ignores glean tools other than run_tool (e.g. find_skills)", async () => { + const { out } = await runHook(glean("find_skills"), hitlOn); + expect(out.trim()).toBe(""); + }); +}); + +describe("auto-approve-run-tool hook (permission-mode marker)", () => { + it("records the permission_mode marker for run_tool when HITL is on", async () => { + const { marker, markerFiles } = await runHook( + glean("run_tool"), + hitlOn, + bypass, + ); + expect(marker).toMatchObject({ permission_mode: "bypassPermissions" }); + expect(typeof marker?.ts).toBe("number"); + expect(markerFiles).toContain("sess-1.json"); + }); + + it("keys the marker file by session id (parallel sessions don't collide)", async () => { + const { markerFiles } = await runHook(glean("run_tool"), hitlOn, { + permission_mode: "default", + session_id: "other-session", + }); + expect(markerFiles).toEqual(["other-session.json"]); + }); + + it("does not write a marker when HITL is off", async () => { + const { out, marker } = await runHook(glean("run_tool"), hitlOff, bypass); + expect(out.trim()).toBe(""); + expect(marker).toBeNull(); + }); + + it("does not write a marker for a non-glean run_tool", async () => { + const { marker } = await runHook( + "mcp__other-server__run_tool", + hitlOn, + bypass, + ); + expect(marker).toBeNull(); + }); + + it("writes no marker when permission_mode is absent from the payload", async () => { + const { out, marker } = await runHook(glean("run_tool"), hitlOn, { + session_id: "sess-1", + }); + // Still auto-approves, just has no mode to record. + expect(JSON.parse(out).hookSpecificOutput.permissionDecision).toBe("allow"); + expect(marker).toBeNull(); + }); + + it("sanitizes the session id used for the marker filename", async () => { + const { markerFiles } = await runHook(glean("run_tool"), hitlOn, { + permission_mode: "default", + session_id: "weird/../id with spaces", + }); + expect(markerFiles).toHaveLength(1); + expect(markerFiles[0]).toMatch(/^[a-zA-Z0-9_-]+\.json$/); + }); + + it("overwrites a leftover bypass marker when the session is resumed without the flag", async () => { + // Session was first launched with --dangerously-skip-permissions (leftover + // marker = bypassPermissions), then resumed WITHOUT the flag (current mode + // = default). The hook rewrites the same per-session marker, clearing the + // stale bypass so the server re-engages its gate on this call. + const { marker } = await runHook( + glean("run_tool"), + hitlOn, + { permission_mode: "default", session_id: "sess-1" }, + { sessionId: "sess-1", mode: "bypassPermissions" }, + ); + expect(marker).toMatchObject({ permission_mode: "default" }); + }); +}); diff --git a/sources/glean-vnext/tests/config-search.test.ts b/sources/glean-vnext/tests/config-search.test.ts new file mode 100644 index 0000000..207df68 --- /dev/null +++ b/sources/glean-vnext/tests/config-search.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from "vitest"; +import { resolveServerUrlFromEmail } from "../src/config-search.js"; + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { + ok, + status, + json: async () => body, + } as unknown as Response; +} + +describe("resolveServerUrlFromEmail", () => { + it("returns the tenant queryURL for a recognized domain", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + search_config: { + queryURL: "https://acme-be.glean.com/", + centralURL: "https://apps-be.glean.com/", + isMultiTenant: false, + }, + }), + ); + + const result = await resolveServerUrlFromEmail( + "you@acme.com", + fetchImpl as unknown as typeof fetch, + ); + + expect(result).toEqual({ ok: true, queryUrl: "https://acme-be.glean.com/" }); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe("https://app.glean.com/config/search"); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + email: "you@acme.com", + emailDomain: "acme.com", + isGleanApp: true, + }); + }); + + it("accepts a multi-tenant deployment served from the central URL", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + search_config: { + queryURL: "https://apps-be.glean.com/", + centralURL: "https://apps-be.glean.com/", + isMultiTenant: true, + }, + }), + ); + + const result = await resolveServerUrlFromEmail( + "you@multitenant.example", + fetchImpl as unknown as typeof fetch, + ); + + expect(result).toEqual({ ok: true, queryUrl: "https://apps-be.glean.com/" }); + }); + + it("rejects an unrecognized domain that falls back to the central URL", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + search_config: { + queryURL: "https://apps-be.glean.com/", + centralURL: "https://apps-be.glean.com/", + isMultiTenant: false, + }, + }), + ); + + const result = await resolveServerUrlFromEmail( + "you@unknown-domain.example", + fetchImpl as unknown as typeof fetch, + ); + + expect(result.ok).toBe(false); + }); + + it("rejects a malformed email without calling the endpoint", async () => { + const fetchImpl = vi.fn(); + const result = await resolveServerUrlFromEmail( + "not-an-email", + fetchImpl as unknown as typeof fetch, + ); + + expect(result.ok).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("rejects when the endpoint returns no queryURL", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ search_config: { centralURL: "https://apps-be.glean.com/" } }), + ); + + const result = await resolveServerUrlFromEmail( + "you@acme.com", + fetchImpl as unknown as typeof fetch, + ); + + expect(result.ok).toBe(false); + }); + + it("surfaces a non-OK HTTP status as an error", async () => { + const fetchImpl = vi.fn(async () => jsonResponse({}, false, 500)); + + const result = await resolveServerUrlFromEmail( + "you@acme.com", + fetchImpl as unknown as typeof fetch, + ); + + expect(result.ok).toBe(false); + }); + + it("surfaces a network failure as an error", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("connection refused"); + }); + + const result = await resolveServerUrlFromEmail( + "you@acme.com", + fetchImpl as unknown as typeof fetch, + ); + + expect(result.ok).toBe(false); + }); +}); diff --git a/sources/glean-vnext/tests/find-skills.test.ts b/sources/glean-vnext/tests/find-skills.test.ts new file mode 100644 index 0000000..4c9de80 --- /dev/null +++ b/sources/glean-vnext/tests/find-skills.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { handleFindSkills } from "../src/tools/find-skills.js"; +import type { SkillsMap } from "../src/types.js"; + +function createMockClient(skills: SkillsMap) { + return { + callTool: vi.fn().mockResolvedValue({ + content: [ + { + type: "text", + text: JSON.stringify({ skills }), + }, + ], + }), + close: vi.fn(), + } as any; +} + +describe("handleFindSkills", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), "find-skills-test-"), + ); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("calls find_skills and writes skill files", async () => { + const mockClient = createMockClient({ + "search-jira": { + "SKILL.md": + "---\nname: search-jira\ndescription: Search Jira issues\n---\n# Search Jira", + "tools/jirasearch.json": JSON.stringify({ + server_id: "composio/jira-pack", + tool_name: "jirasearch", + description: "Search Jira", + input_schema: {}, + }), + }, + }); + + const result = await handleFindSkills(mockClient, tmpDir, {}); + + expect(mockClient.callTool).toHaveBeenCalledWith( + { + name: "find_skills", + arguments: {}, + }, + undefined, + expect.objectContaining({ timeout: expect.any(Number) }), + ); + + expect(result).toContain(""); + expect(result).toContain('name="search-jira"'); + + const skillContent = await fs.readFile( + path.join(tmpDir, "search-jira", "SKILL.md"), + "utf-8", + ); + expect(skillContent).toContain("# Search Jira"); + }); + + it("passes query argument as queries array", async () => { + const mockClient = createMockClient({}); + + await handleFindSkills(mockClient, tmpDir, { + query: "create a calendar event", + }); + + expect(mockClient.callTool).toHaveBeenCalledWith( + { + name: "find_skills", + arguments: { queries: ["create a calendar event"] }, + }, + undefined, + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); + + it("passes queries array when provided", async () => { + const mockClient = createMockClient({}); + + await handleFindSkills(mockClient, tmpDir, { + queries: ["search emails", "create calendar event"], + }); + + expect(mockClient.callTool).toHaveBeenCalledWith( + { + name: "find_skills", + arguments: { queries: ["search emails", "create calendar event"] }, + }, + undefined, + expect.objectContaining({ timeout: expect.any(Number) }), + ); + }); + + it("returns empty XML when response has no skills field", async () => { + const mockClient = { + callTool: vi.fn().mockResolvedValue({ + content: [{ type: "text", text: JSON.stringify({ unexpected: true }) }], + }), + close: vi.fn(), + } as any; + + const result = await handleFindSkills(mockClient, tmpDir, {}); + expect(result).toBe(""); + }); + + it("returns empty XML for no skills", async () => { + const mockClient = createMockClient({}); + + const result = await handleFindSkills(mockClient, tmpDir, {}); + + expect(result).toBe(""); + }); + + it("handles missing text content gracefully", async () => { + const mockClient = { + callTool: vi.fn().mockResolvedValue({ content: [] }), + close: vi.fn(), + } as any; + + const result = await handleFindSkills(mockClient, tmpDir, {}); + + expect(result).toBe(""); + }); + + it("throws with upstream message when find_skills returns an error", async () => { + const mockClient = { + callTool: vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "backend unavailable" }], + isError: true, + }), + close: vi.fn(), + } as any; + + await expect( + handleFindSkills(mockClient, tmpDir, {}), + ).rejects.toThrow("backend unavailable"); + }); +}); diff --git a/sources/glean-vnext/tests/open-browser.test.ts b/sources/glean-vnext/tests/open-browser.test.ts new file mode 100644 index 0000000..d89d99d --- /dev/null +++ b/sources/glean-vnext/tests/open-browser.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const platformMock = vi.fn(); +vi.mock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, platform: () => platformMock() }; +}); + +const spawnMock = vi.fn(() => ({ unref: vi.fn() })); +const execFileMock = vi.fn(); +vi.mock("node:child_process", () => ({ + spawn: spawnMock, + execFile: execFileMock, +})); + +const { openBrowser } = await import("../src/auth-provider.js"); + +// A realistic authorize URL: response_type is first, then client_id after the +// first `&` — the exact shape the MCP SDK produces. +const authUrl = + "https://acme-be.glean.com/oauth/authorize?response_type=code" + + "&client_id=Glean_Claude_Cod_f345b80b" + + "&redirect_uri=http%3A%2F%2F127.0.0.1%3A29107%2Fglean-cli-callback" + + "&code_challenge=abc123&code_challenge_method=S256&state=s1"; + +describe("openBrowser", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("escapes `&` as `^&` so the full authorize URL survives cmd on Windows", () => { + platformMock.mockReturnValue("win32"); + + openBrowser(authUrl); + + expect(spawnMock).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnMock.mock.calls[0] as [ + string, + string[], + { windowsVerbatimArguments?: boolean }, + ]; + expect(command).toMatch(/^cmd$/i); + + // The URL is the last arg. Every `&` must be escaped to `^&` so cmd does + // not treat it as a command separator and truncate the URL. + const urlArg = args[args.length - 1]; + expect(urlArg).toBe(authUrl.replace(/&/g, "^&")); + // Guard the actual regression: client_id lives after the first `&`, so it + // must survive, and no un-escaped `&` may remain in the argument. + expect(urlArg).toContain("client_id=Glean_Claude_Cod_f345b80b"); + expect(urlArg.replace(/\^&/g, "").includes("&")).toBe(false); + // Verbatim args are required so Node doesn't re-quote and break the `^`. + expect(options.windowsVerbatimArguments).toBe(true); + }); + + it("opens with execFile on macOS", () => { + platformMock.mockReturnValue("darwin"); + + openBrowser(authUrl); + + expect(execFileMock).toHaveBeenCalledWith("open", [authUrl]); + }); + + it("opens with xdg-open on Linux", () => { + platformMock.mockReturnValue("linux"); + + openBrowser(authUrl); + + expect(execFileMock).toHaveBeenCalledWith("xdg-open", [authUrl]); + }); +}); diff --git a/sources/glean-vnext/tests/remote-client.test.ts b/sources/glean-vnext/tests/remote-client.test.ts new file mode 100644 index 0000000..2975974 --- /dev/null +++ b/sources/glean-vnext/tests/remote-client.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, afterEach } from "vitest"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + buildGatewayMetadataHeader, + callRemoteTool, + remoteToolTimeoutMs, +} from "../src/remote-client.js"; + +describe("buildGatewayMetadataHeader", () => { + it("produces stable output without session ID", () => { + const a = buildGatewayMetadataHeader(); + const b = buildGatewayMetadataHeader(); + expect(a).toBe(b); + expect(a.length).toBeGreaterThan(0); + }); + + it("produces different output with session ID", () => { + const without = buildGatewayMetadataHeader(); + const with1 = buildGatewayMetadataHeader("session-abc"); + const with2 = buildGatewayMetadataHeader("session-xyz"); + expect(with1).not.toBe(without); + expect(with1).not.toBe(with2); + }); + + it("output is valid base64", () => { + const header = buildGatewayMetadataHeader("test-session"); + const decoded = Buffer.from(header, "base64"); + expect(decoded.length).toBeGreaterThan(0); + expect(Buffer.from(decoded).toString("base64")).toBe(header); + }); + + it("encodes workflow_id and source_info without session", () => { + const header = buildGatewayMetadataHeader(); + const bytes = Buffer.from(header, "base64"); + + // The proto bytes should contain "GLEAN_PLUGIN" (workflow_id, platform, + // client_initiator) but no session ID string. + const content = bytes.toString("utf-8"); + const matches = content.match(/GLEAN_PLUGIN/g); + expect(matches).not.toBeNull(); + expect(matches!.length).toBe(3); // workflow_id + platform + client_initiator + }); + + // Cross-language parity: must match Go's proto.Marshal output captured in + // scio's go/core/mcp/server/proxy_tools_provider_test.go:: + // TestPluginGatewayRequestMetadata_NoSession. If this constant drifts, the + // hand-rolled TS encoder has diverged from the proto schema — re-derive by + // running the Go test and copying the asserted base64 here. + it("matches Go proto.Marshal output (cross-language parity)", () => { + expect(buildGatewayMetadataHeader()).toBe( + "CgxHTEVBTl9QTFVHSU4iHBIMR0xFQU5fUExVR0lOGgxHTEVBTl9QTFVHSU4=", + ); + }); + + it("encodes session ID into the proto bytes", () => { + const header = buildGatewayMetadataHeader("my-session-42"); + const bytes = Buffer.from(header, "base64"); + const content = bytes.toString("utf-8"); + expect(content).toContain("my-session-42"); + }); +}); + +describe("remoteToolTimeoutMs", () => { + const KEY = "GLEAN_REMOTE_TOOL_TIMEOUT_MS"; + const original = process.env[KEY]; + + afterEach(() => { + if (original === undefined) delete process.env[KEY]; + else process.env[KEY] = original; + }); + + it("defaults to 300000ms when unset", () => { + delete process.env[KEY]; + expect(remoteToolTimeoutMs()).toBe(300_000); + }); + + it("honors a valid positive override", () => { + process.env[KEY] = "120000"; + expect(remoteToolTimeoutMs()).toBe(120_000); + }); + + it("falls back to default on a non-numeric value", () => { + process.env[KEY] = "not-a-number"; + expect(remoteToolTimeoutMs()).toBe(300_000); + }); + + it("falls back to default on zero or negative values", () => { + process.env[KEY] = "0"; + expect(remoteToolTimeoutMs()).toBe(300_000); + process.env[KEY] = "-5"; + expect(remoteToolTimeoutMs()).toBe(300_000); + }); +}); + +describe("callRemoteTool", () => { + const KEY = "GLEAN_REMOTE_TOOL_TIMEOUT_MS"; + const original = process.env[KEY]; + + afterEach(() => { + if (original === undefined) delete process.env[KEY]; + else process.env[KEY] = original; + }); + + it("passes the configured timeout to client.callTool", async () => { + const calls: Array<{ params: unknown; schema: unknown; options: unknown }> = + []; + const fakeClient = { + callTool: async (params: unknown, schema: unknown, options: unknown) => { + calls.push({ params, schema, options }); + return { content: [{ type: "text", text: "ok" }] }; + }, + } as unknown as Client; + + process.env[KEY] = "12345"; + const result = await callRemoteTool(fakeClient, "some_tool", { a: 1 }); + + expect(calls).toHaveLength(1); + expect(calls[0].params).toEqual({ + name: "some_tool", + arguments: { a: 1 }, + }); + expect(calls[0].options).toEqual({ timeout: 12345 }); + expect(result.content).toEqual([{ type: "text", text: "ok" }]); + }); + + it("normalizes a result with no content field", async () => { + const fakeClient = { + callTool: async () => ({}), + } as unknown as Client; + + const result = await callRemoteTool(fakeClient, "some_tool", {}); + expect(result).toEqual({ content: [] }); + }); +}); diff --git a/sources/glean-vnext/tests/remote-passthrough.test.ts b/sources/glean-vnext/tests/remote-passthrough.test.ts new file mode 100644 index 0000000..5e30d34 --- /dev/null +++ b/sources/glean-vnext/tests/remote-passthrough.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + REMOTE_TOOLS_ALLOWLIST, + augmentSchemaForLocal, + dispatchRemoteTool, + fetchAllowedRemoteTools, + type DispatchContext, +} from "../src/tools/remote-passthrough.js"; + +vi.mock("../src/remote-client.js", async () => { + const actual = await vi.importActual( + "../src/remote-client.js", + ); + return { + ...actual, + createRemoteClient: vi.fn(), + callRemoteTool: vi.fn(), + }; +}); + +const remoteClientModule = await import("../src/remote-client.js"); +const { createRemoteClient, callRemoteTool, AuthRequiredError } = + remoteClientModule; + +const mockedCreate = vi.mocked(createRemoteClient); +const mockedCall = vi.mocked(callRemoteTool); + +function makeCtx(overrides: Partial = {}): DispatchContext { + return { + serverUrl: "https://example/mcp", + remoteClientOpts: {}, + authRedirectText: + "[SETUP_REQUIRED]\n\nAuthentication is required. Call the `setup` tool " + + "(no arguments) to sign in to Glean, then retry this tool.", + logLine: vi.fn(), + ...overrides, + }; +} + +describe("REMOTE_TOOLS_ALLOWLIST", () => { + it("contains the promoted Glean remote tools", () => { + expect([...REMOTE_TOOLS_ALLOWLIST].sort()).toEqual([ + "chat", + "employee_search", + "memory", + "memory_schema", + "read_document", + "search", + "user_activity", + ]); + }); +}); + +describe("augmentSchemaForLocal", () => { + it("preserves the remote schema as-is and does not splice plugin-only keys", () => { + const result = augmentSchemaForLocal({ + type: "object", + properties: { messages: { type: "array" } }, + required: ["messages"], + }); + + expect(result.properties).toMatchObject({ + messages: { type: "array" }, + }); + expect(result.properties).not.toHaveProperty("callback_url"); + expect(result.required).toEqual(["messages"]); + }); + + it("handles a schema with no properties or required", () => { + const result = augmentSchemaForLocal({ type: "object" }); + + expect(result.type).toBe("object"); + expect(result.properties).toEqual({}); + expect(result.required).toEqual([]); + }); + + it("handles non-object schema input by returning a fresh object schema", () => { + const result = augmentSchemaForLocal(undefined); + + expect(result.type).toBe("object"); + expect(result.properties).toEqual({}); + expect(result.required).toEqual([]); + }); + + it("does not mutate the input schema", () => { + const input = { + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + }; + const before = JSON.stringify(input); + augmentSchemaForLocal(input); + expect(JSON.stringify(input)).toBe(before); + }); +}); + +describe("fetchAllowedRemoteTools", () => { + function clientWithPages(pages: Array<{ tools: any[]; nextCursor?: string }>) { + const calls: Array<{ cursor?: string }> = []; + const listTools = vi.fn(async (params?: { cursor?: string }) => { + calls.push({ cursor: params?.cursor }); + return pages.shift() ?? { tools: [] }; + }); + return { listTools, calls } as any; + } + + it("filters to allow-listed tools and augments their schemas", async () => { + const client = clientWithPages([ + { + tools: [ + { name: "not_allowed", description: "Excluded", inputSchema: { type: "object", properties: {}, required: [] } }, + { name: "weird_unknown", description: "x", inputSchema: { type: "object" } }, + { name: "search", description: "Search", inputSchema: { type: "object", properties: { q: { type: "string" } }, required: ["q"] } }, + ], + }, + ]); + + const tools = await fetchAllowedRemoteTools(client); + + expect(tools.map((t) => t.name).sort()).toEqual(["search"]); + const search = tools.find((t) => t.name === "search")!; + expect(search.description).toBe("Search"); + expect(search.inputSchema.properties).toMatchObject({ + q: { type: "string" }, + }); + expect(search.inputSchema.properties).not.toHaveProperty("callback_url"); + expect(search.inputSchema.required).toEqual(["q"]); + }); + + it("walks nextCursor pagination to exhaustion", async () => { + const client = clientWithPages([ + { + tools: [ + { name: "search", description: "Search", inputSchema: { type: "object" } }, + ], + nextCursor: "page2", + }, + { + tools: [ + { name: "read_document", description: "Read", inputSchema: { type: "object" } }, + ], + }, + ]); + + const tools = await fetchAllowedRemoteTools(client); + + expect(tools.map((t) => t.name).sort()).toEqual(["read_document", "search"]); + expect(client.listTools).toHaveBeenCalledTimes(2); + expect(client.calls[0].cursor).toBeUndefined(); + expect(client.calls[1].cursor).toBe("page2"); + }); + + it("returns empty array when no allow-listed tools exist", async () => { + const client = clientWithPages([ + { tools: [{ name: "irrelevant", description: "", inputSchema: { type: "object" } }] }, + ]); + + const tools = await fetchAllowedRemoteTools(client); + expect(tools).toEqual([]); + }); +}); + +describe("dispatchRemoteTool", () => { + beforeEach(() => { + mockedCreate.mockReset(); + mockedCall.mockReset(); + }); + + function makeRemote(): any { + return { close: vi.fn().mockResolvedValue(undefined) }; + } + + it("returns the unwrapped CallToolResult from the remote", async () => { + const remote = makeRemote(); + mockedCreate.mockResolvedValue(remote); + mockedCall.mockResolvedValue({ + content: [{ type: "text", text: "remote response" }], + }); + + const ctx = makeCtx(); + const result = await dispatchRemoteTool( + "search", + { q: "hello" }, + ctx, + ); + + expect(result).toEqual({ + content: [{ type: "text", text: "remote response" }], + }); + }); + + it("returns setup-redirect envelope on AuthRequiredError", async () => { + mockedCreate.mockRejectedValue(new AuthRequiredError("https://signin.example/auth")); + + const ctx = makeCtx(); + const result = await dispatchRemoteTool("chat", {}, ctx); + + expect(result.isError).toBeUndefined(); + expect((result.content[0] as any).text).toContain("[SETUP_REQUIRED]"); + expect((result.content[0] as any).text).toContain("setup"); + }); + + it("returns isError envelope on other createRemoteClient failures", async () => { + mockedCreate.mockRejectedValue(new Error("ECONNREFUSED")); + + const ctx = makeCtx(); + const result = await dispatchRemoteTool("chat", {}, ctx); + + expect(result.isError).toBe(true); + expect((result.content[0] as any).text).toContain("ECONNREFUSED"); + expect(ctx.logLine).toHaveBeenCalledWith( + "connect.backend-error", + expect.objectContaining({ label: "chat" }), + ); + }); + + it("closes the remote client when callRemoteTool throws", async () => { + const remote = makeRemote(); + mockedCreate.mockResolvedValue(remote); + mockedCall.mockRejectedValue(new Error("boom")); + + const ctx = makeCtx(); + const result = await dispatchRemoteTool( + "chat", + {}, + ctx, + ); + + expect(result.isError).toBe(true); + expect((result.content[0] as any).text).toContain("boom"); + expect(remote.close).toHaveBeenCalled(); + expect(ctx.logLine).toHaveBeenCalledWith( + "dispatch.execution-failed", + expect.objectContaining({ label: "chat", msg: "boom" }), + ); + }); +}); diff --git a/sources/glean-vnext/tests/remote-tools-cache-store.test.ts b/sources/glean-vnext/tests/remote-tools-cache-store.test.ts new file mode 100644 index 0000000..0c0987f --- /dev/null +++ b/sources/glean-vnext/tests/remote-tools-cache-store.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "remote-tools-cache-store-test-"), +); + +vi.stubEnv("PLUGIN_DATA_DIR", tmpDir); + +const { loadRemoteTools, saveRemoteTools, clearRemoteTools } = await import( + "../src/remote-tools-cache-store.js" +); + +afterAll(() => { + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +const cacheFile = path.join(tmpDir, "remote-tools-cache.json"); + +const URL_A = "https://acme-be.glean.com/mcp/gateway/proxy"; +const URL_B = "https://other-be.glean.com/mcp/gateway/proxy"; + +const toolA: Tool = { + name: "chat", + description: "chat tool", + inputSchema: { type: "object", properties: {} }, +}; +const toolB: Tool = { + name: "search", + description: "search tool", + inputSchema: { type: "object", properties: {} }, +}; + +describe("remote-tools-cache-store", () => { + beforeEach(() => { + try { + fs.rmSync(cacheFile, { force: true }); + } catch { + /* ignore */ + } + }); + + afterEach(() => { + try { + fs.rmSync(cacheFile, { force: true }); + } catch { + /* ignore */ + } + }); + + it("returns [] when cache file does not exist", () => { + expect(loadRemoteTools(URL_A)).toEqual([]); + }); + + it("round-trips tools for a server URL", () => { + saveRemoteTools(URL_A, [toolA, toolB]); + const loaded = loadRemoteTools(URL_A); + expect(loaded).toHaveLength(2); + expect(loaded[0].name).toBe("chat"); + expect(loaded[1].name).toBe("search"); + }); + + it("keeps separate entries per server URL", () => { + saveRemoteTools(URL_A, [toolA]); + saveRemoteTools(URL_B, [toolB]); + expect(loadRemoteTools(URL_A).map((t) => t.name)).toEqual(["chat"]); + expect(loadRemoteTools(URL_B).map((t) => t.name)).toEqual(["search"]); + }); + + it("overwrites the entry for the same URL", () => { + saveRemoteTools(URL_A, [toolA]); + saveRemoteTools(URL_A, [toolB]); + expect(loadRemoteTools(URL_A).map((t) => t.name)).toEqual(["search"]); + }); + + it("records a fetchedAt ISO timestamp", () => { + saveRemoteTools(URL_A, [toolA]); + const raw = JSON.parse(fs.readFileSync(cacheFile, "utf-8")); + expect(typeof raw[URL_A].fetchedAt).toBe("string"); + expect(() => new Date(raw[URL_A].fetchedAt)).not.toThrow(); + }); + + it("returns [] for an unknown URL when other entries exist", () => { + saveRemoteTools(URL_A, [toolA]); + expect(loadRemoteTools(URL_B)).toEqual([]); + }); + + it("returns [] when serverUrl is empty", () => { + saveRemoteTools(URL_A, [toolA]); + expect(loadRemoteTools("")).toEqual([]); + }); + + it("saveRemoteTools is a no-op when serverUrl is empty", () => { + saveRemoteTools("", [toolA]); + expect(fs.existsSync(cacheFile)).toBe(false); + }); + + it("clearRemoteTools(url) removes only that entry", () => { + saveRemoteTools(URL_A, [toolA]); + saveRemoteTools(URL_B, [toolB]); + clearRemoteTools(URL_A); + expect(loadRemoteTools(URL_A)).toEqual([]); + expect(loadRemoteTools(URL_B).map((t) => t.name)).toEqual(["search"]); + }); + + it("clearRemoteTools(url) deletes the file when the last entry is removed", () => { + saveRemoteTools(URL_A, [toolA]); + clearRemoteTools(URL_A); + expect(fs.existsSync(cacheFile)).toBe(false); + }); + + it("clearRemoteTools() with no arg wipes the whole file", () => { + saveRemoteTools(URL_A, [toolA]); + saveRemoteTools(URL_B, [toolB]); + clearRemoteTools(); + expect(fs.existsSync(cacheFile)).toBe(false); + expect(loadRemoteTools(URL_A)).toEqual([]); + expect(loadRemoteTools(URL_B)).toEqual([]); + }); + + it("clearRemoteTools is a no-op when file does not exist", () => { + expect(() => clearRemoteTools()).not.toThrow(); + expect(() => clearRemoteTools(URL_A)).not.toThrow(); + }); + + it("returns [] for malformed JSON", () => { + fs.writeFileSync(cacheFile, "not json{{{", { encoding: "utf-8" }); + expect(loadRemoteTools(URL_A)).toEqual([]); + }); + + it("returns [] when entry tools is not an array", () => { + fs.writeFileSync( + cacheFile, + JSON.stringify({ [URL_A]: { tools: "oops", fetchedAt: "now" } }), + { encoding: "utf-8" }, + ); + expect(loadRemoteTools(URL_A)).toEqual([]); + }); + + it("sets restrictive file permissions", () => { + saveRemoteTools(URL_A, [toolA]); + const stat = fs.statSync(cacheFile); + expect(stat.mode & 0o777).toBe(0o600); + }); +}); diff --git a/sources/glean-vnext/tests/run-tool.test.ts b/sources/glean-vnext/tests/run-tool.test.ts new file mode 100644 index 0000000..b0361ab --- /dev/null +++ b/sources/glean-vnext/tests/run-tool.test.ts @@ -0,0 +1,730 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { + resolveFileArgs, + buildRemoteArgs, + FileArgsError, + handleRunTool, + runToolAnnotations, +} from "../src/tools/run-tool.js"; +import { + buildCompactArgs, + formatArgumentsForFile, +} from "../src/tools/approval-args.js"; + +describe("resolveFileArgs", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "file-args-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + delete process.env.GLEAN_FILE_ARG_MAX_BYTES; + }); + + it("returns base args unchanged when file_args is undefined", async () => { + const result = await resolveFileArgs(undefined, { a: 1 }); + expect(result).toEqual({ a: 1 }); + }); + + it("returns base args unchanged when file_args is null", async () => { + const result = await resolveFileArgs(null, { a: 1 }); + expect(result).toEqual({ a: 1 }); + }); + + it("returns base args unchanged when file_args is empty", async () => { + const result = await resolveFileArgs({}, { a: 1 }); + expect(result).toEqual({ a: 1 }); + }); + + it("reads file content and merges into args", async () => { + const file = path.join(tmpDir, "draft.md"); + await fs.writeFile(file, "# Hello world\n\nlong content"); + + const result = await resolveFileArgs({ body: file }, { channel: "C1" }); + + expect(result).toEqual({ + channel: "C1", + body: "# Hello world\n\nlong content", + }); + }); + + it("supports multiple file_args entries", async () => { + const a = path.join(tmpDir, "a.md"); + const b = path.join(tmpDir, "b.md"); + await fs.writeFile(a, "content A"); + await fs.writeFile(b, "content B"); + + const result = await resolveFileArgs( + { body: a, summary: b }, + { channel: "C1" }, + ); + + expect(result).toEqual({ + channel: "C1", + body: "content A", + summary: "content B", + }); + }); + + it("throws when file_args is not an object", async () => { + await expect(resolveFileArgs("nope", {})).rejects.toThrow(FileArgsError); + await expect(resolveFileArgs("nope", {})).rejects.toThrow( + /must be an object/, + ); + }); + + it("throws when file_args is an array", async () => { + await expect(resolveFileArgs(["a", "b"], {})).rejects.toThrow( + /must be an object/, + ); + }); + + it("throws when path is not a string", async () => { + await expect( + resolveFileArgs({ body: 42 }, {}), + ).rejects.toThrow(/non-empty string/); + }); + + it("throws when path is empty string", async () => { + await expect( + resolveFileArgs({ body: "" }, {}), + ).rejects.toThrow(/non-empty string/); + }); + + it("throws when path is not absolute", async () => { + await expect( + resolveFileArgs({ body: "draft.md" }, {}), + ).rejects.toThrow(/absolute/); + }); + + it("throws when file does not exist", async () => { + await expect( + resolveFileArgs({ body: path.join(tmpDir, "nope.md") }, {}), + ).rejects.toThrow(FileArgsError); + }); + + it("throws when path is a directory", async () => { + await expect( + resolveFileArgs({ body: tmpDir }, {}), + ).rejects.toThrow(/regular file/); + }); + + it("throws when file exceeds size cap", async () => { + process.env.GLEAN_FILE_ARG_MAX_BYTES = "10"; + const file = path.join(tmpDir, "big.md"); + await fs.writeFile(file, "this is more than 10 bytes"); + + await expect(resolveFileArgs({ body: file }, {})).rejects.toThrow( + /exceeds.*limit/, + ); + }); + + it("respects GLEAN_FILE_ARG_MAX_BYTES override", async () => { + process.env.GLEAN_FILE_ARG_MAX_BYTES = "100"; + const file = path.join(tmpDir, "ok.md"); + await fs.writeFile(file, "small content"); + + const result = await resolveFileArgs({ body: file }, {}); + expect(result.body).toBe("small content"); + }); + + it("throws when file_args key conflicts with arguments key", async () => { + const file = path.join(tmpDir, "draft.md"); + await fs.writeFile(file, "content"); + + await expect( + resolveFileArgs({ body: file }, { body: "existing" }), + ).rejects.toThrow(/conflicts/); + }); + + it("parses JSON into an object when the param type is object", async () => { + const file = path.join(tmpDir, "spec.json"); + await fs.writeFile(file, '{"name":"agent","steps":[1,2,3]}'); + + const result = await resolveFileArgs({ spec: file }, {}, { + properties: { spec: { type: "object" } }, + }); + + expect(result.spec).toEqual({ name: "agent", steps: [1, 2, 3] }); + }); + + it("parses JSON into an array when the param type is array", async () => { + const file = path.join(tmpDir, "items.json"); + await fs.writeFile(file, '["a","b"]'); + + const result = await resolveFileArgs({ items: file }, {}, { + properties: { items: { type: "array" } }, + }); + + expect(result.items).toEqual(["a", "b"]); + }); + + it("parses when type is given as an array including object", async () => { + const file = path.join(tmpDir, "spec.json"); + await fs.writeFile(file, '{"k":1}'); + + const result = await resolveFileArgs({ spec: file }, {}, { + properties: { spec: { type: ["object", "null"] } }, + }); + + expect(result.spec).toEqual({ k: 1 }); + }); + + it("keeps raw string for a string param even when content is JSON", async () => { + const file = path.join(tmpDir, "body.json"); + await fs.writeFile(file, '{"looks":"like json"}'); + + const result = await resolveFileArgs({ body: file }, {}, { + properties: { body: { type: "string" } }, + }); + + expect(result.body).toBe('{"looks":"like json"}'); + }); + + it("keeps raw string when no schema is provided (backward compat)", async () => { + const file = path.join(tmpDir, "body.json"); + await fs.writeFile(file, '{"a":1}'); + + const result = await resolveFileArgs({ body: file }, {}); + + expect(result.body).toBe('{"a":1}'); + }); + + it("throws a clear error when an object-typed param gets invalid JSON", async () => { + const file = path.join(tmpDir, "spec.json"); + await fs.writeFile(file, "not json {"); + + await expect( + resolveFileArgs({ spec: file }, {}, { + properties: { spec: { type: "object" } }, + }), + ).rejects.toThrow(/must contain valid JSON/); + }); + + it("falls back to the raw string for a string|object union with invalid JSON", async () => { + const file = path.join(tmpDir, "val.txt"); + await fs.writeFile(file, "plain text"); + + const result = await resolveFileArgs({ val: file }, {}, { + properties: { val: { type: ["string", "object"] } }, + }); + + expect(result.val).toBe("plain text"); + }); +}); + +describe("buildRemoteArgs", () => { + // Regression: a no-argument downstream call (e.g. slack_read_user_profile) + // must forward `arguments: {}`, not omit the key. An absent field serializes + // to null downstream, which strict MCP servers reject. + it("always includes arguments, even when empty", () => { + expect(buildRemoteArgs("srv", "tool", {})).toEqual({ + server_id: "srv", + tool_name: "tool", + arguments: {}, + }); + }); + + it("forwards populated arguments unchanged", () => { + expect( + buildRemoteArgs("srv", "tool", { response_format: "detailed" }), + ).toEqual({ + server_id: "srv", + tool_name: "tool", + arguments: { response_format: "detailed" }, + }); + }); +}); + +function makeRemote() { + return { + callTool: vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "ok" }], + }), + close: vi.fn(), + } as any; +} + +function makeServer(opts: { + elicitation?: boolean; + clientName?: string; + elicit?: ReturnType; + request?: ReturnType; +}) { + return { + getClientCapabilities: vi + .fn() + .mockReturnValue(opts.elicitation ? { elicitation: {} } : {}), + getClientVersion: vi + .fn() + .mockReturnValue({ name: opts.clientName ?? "claude-code", version: "1" }), + elicitInput: opts.elicit ?? vi.fn().mockResolvedValue({ action: "accept" }), + // Used by primeElicitationCancellation to burn request id 0. + request: opts.request ?? vi.fn().mockResolvedValue({}), + } as any; +} + +async function writeToolJson( + baseDir: string, + toolName: string, + meta: Record, +) { + const toolsDir = path.join(baseDir, "some-skill", "tools"); + await fs.mkdir(toolsDir, { recursive: true }); + await fs.writeFile( + path.join(toolsDir, `${toolName}.json`), + JSON.stringify(meta), + "utf-8", + ); +} + +// Mirrors the marker the PreToolUse hook writes: /glean-hitl-mode/ +// .json. The server reads it via CLAUDE_PLUGIN_DATA + GLEAN_SESSION_ID. +async function writeModeMarker( + dataDir: string, + sessionId: string, + mode: string, +) { + const dir = path.join(dataDir, "glean-hitl-mode"); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, `${sessionId}.json`), + JSON.stringify({ permission_mode: mode, ts: Date.now() }), + "utf-8", + ); +} + +describe("handleRunTool (HITL)", () => { + let tmpDir: string; + const baseArgs = { + server_id: "composio/jira-pack", + tool_name: "jirasearch", + arguments: { project: "ABC", summary: "fix login" }, + }; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "run-tool-hitl-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it("does not elicit when the client lacks elicitation capability", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const server = makeServer({ elicitation: false }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(remote.callTool).toHaveBeenCalledTimes(1); + }); + + it("does not elicit when the tool does not require approval", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const server = makeServer({ elicitation: true }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: false }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(server.elicitInput).not.toHaveBeenCalled(); + expect(remote.callTool).toHaveBeenCalledTimes(1); + }); + + it("does NOT elicit for Cursor — its native prompt is the gate; executes directly", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn(); + const server = makeServer({ + elicitation: true, + clientName: "cursor-vscode", + elicit, + }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + // Cursor: readOnlyHint is omitted (native prompt shows), so our elicitation + // is skipped and the tool runs directly. + expect(elicit).not.toHaveBeenCalled(); + expect(remote.callTool).toHaveBeenCalledTimes(1); + }); + + it("prompts with action name + arguments and forwards on accept", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "jirasearch", { + requires_approval: true, + description: "Search Jira issues", + }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + const [params, options] = elicit.mock.calls[0]; + expect(params.message).toContain("Action: jirasearch"); + expect(params.message).toContain("PROJECT: ABC"); + expect(params.message).not.toContain("Server:"); + expect(params.message).not.toContain("Search Jira issues"); + expect(params.message).not.toContain("**"); + expect(options.timeout).toBe(300_000); + expect(remote.callTool).toHaveBeenCalledTimes(1); + }); + + it("pings to burn request id 0 before the first elicitation (so timeout cancellation is honored), once per server", async () => { + // The MCP SDK's _oncancel drops notifications/cancelled with a falsy + // requestId, so an elicitation that lands on request id 0 never gets + // dismissed on timeout. We burn id 0 with a ping before the first prompt. + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const request = vi.fn().mockResolvedValue({}); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit, request }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + await handleRunTool(remote, server, tmpDir, baseArgs); + + // Ping fired exactly once for this server, and it is a ping. + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0][0]).toEqual({ method: "ping" }); + // Both prompts still ran. + expect(elicit).toHaveBeenCalledTimes(2); + }); + + it("does not ping when the tool requires no approval (no elicitation)", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const request = vi.fn().mockResolvedValue({}); + const server = makeServer({ elicitation: true, request }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: false }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(request).not.toHaveBeenCalled(); + }); + + it("honors the HITL_TIMEOUT_MS override", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + vi.stubEnv("HITL_TIMEOUT_MS", "5000"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(elicit.mock.calls[0][1].timeout).toBe(5000); + }); + + it("falls back to the default timeout for invalid HITL_TIMEOUT_MS", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + for (const bad of ["0", "-1", "abc", ""]) { + vi.stubEnv("HITL_TIMEOUT_MS", bad); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(elicit.mock.calls[0][1].timeout).toBe(300_000); + } + }); + + it("does not execute when the user declines", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "decline" }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(remote.callTool).not.toHaveBeenCalled(); + expect((result.content[0] as { text: string }).text).toContain("declined"); + }); + + it("fails closed and does NOT execute when elicitation times out", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockRejectedValue(new Error("Request timed out")); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + + const result = await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(remote.callTool).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("not approved"); + expect(text).toContain("NOT executed"); + }); + + it("spills large arguments to a file and keeps the prompt short", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "create_doc", { requires_approval: true }); + + const bigBody = "| A | B |\n|---|---|\n" + "| x | y |\n".repeat(50); + await handleRunTool(remote, server, tmpDir, { + server_id: "s", + tool_name: "create_doc", + arguments: { title: "Report", body: bigBody }, + }); + + const message = elicit.mock.calls[0][0].message as string; + expect(message).toContain("Action: create_doc"); + expect(message).toContain("TITLE: Report"); + expect(message.split("\n").length).toBeLessThanOrEqual(10); + + const fileLine = message + .split("\n") + .find((l) => l.includes("Full arguments: ")); + expect(fileLine).toBeDefined(); + const marker = "Full arguments: "; + const filePath = fileLine!.slice(fileLine!.indexOf(marker) + marker.length).trim(); + const fileContent = await fs.readFile(filePath, "utf-8"); + expect(fileContent).toContain(bigBody); + expect(fileContent).toContain("## body"); + await fs.rm(filePath, { force: true }); + }); + + it("surfaces file_args content in the approval prompt", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "create_doc", { requires_approval: true }); + const bodyFile = path.join(tmpDir, "draft.md"); + await fs.writeFile(bodyFile, "FILE_SOURCED_BODY", "utf-8"); + + await handleRunTool(remote, server, tmpDir, { + server_id: "s", + tool_name: "create_doc", + arguments: { title: "Doc" }, + file_args: { body: bodyFile }, + }); + + const message = elicit.mock.calls[0][0].message as string; + expect(message).toContain("TITLE: Doc"); + expect(message).toContain("BODY: FILE_SOURCED_BODY"); // file-sourced arg shown + expect(remote.callTool).toHaveBeenCalledTimes(1); // executed on accept + }); + + it("parses an object-typed file_arg from the tool schema and forwards it as structured data", async () => { + vi.stubEnv("ENABLE_HITL", "false"); + const remote = makeRemote(); + const server = makeServer({ elicitation: false }); + await writeToolJson(tmpDir, "save_agent", { + requires_approval: false, + inputSchema: { properties: { spec: { type: "object" } } }, + }); + const specFile = path.join(tmpDir, "spec.json"); + await fs.writeFile(specFile, '{"name":"my-agent","steps":[1,2]}', "utf-8"); + + await handleRunTool(remote, server, tmpDir, { + server_id: "default", + tool_name: "save_agent", + arguments: {}, + file_args: { spec: specFile }, + }); + + const call = remote.callTool.mock.calls[0][0]; + expect(call.name).toBe("run_tool"); + expect(call.arguments.arguments.spec).toEqual({ + name: "my-agent", + steps: [1, 2], + }); + }); + + it("fails before prompting when a file_args path is unreadable", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + await writeToolJson(tmpDir, "create_doc", { requires_approval: true }); + + const result = await handleRunTool(remote, server, tmpDir, { + server_id: "s", + tool_name: "create_doc", + arguments: {}, + file_args: { body: "/no/such/abs/path.md" }, + }); + + expect(result.isError).toBe(true); + expect(elicit).not.toHaveBeenCalled(); // no prompt for unreadable input + expect(remote.callTool).not.toHaveBeenCalled(); + }); + + it("skips the elicitation gate and executes directly in bypassPermissions mode", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + vi.stubEnv("CLAUDE_PLUGIN_DATA", tmpDir); + vi.stubEnv("GLEAN_SESSION_ID", "sess-bypass"); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + await writeModeMarker(tmpDir, "sess-bypass", "bypassPermissions"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(elicit).not.toHaveBeenCalled(); + expect(remote.callTool).toHaveBeenCalledTimes(1); + }); + + it("still elicits when the session's permission mode is not bypass", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + vi.stubEnv("CLAUDE_PLUGIN_DATA", tmpDir); + vi.stubEnv("GLEAN_SESSION_ID", "sess-default"); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + await writeModeMarker(tmpDir, "sess-default", "default"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(elicit).toHaveBeenCalledTimes(1); + expect(remote.callTool).toHaveBeenCalledTimes(1); + }); + + it("still elicits when no permission-mode marker exists (fails toward the gate)", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + vi.stubEnv("CLAUDE_PLUGIN_DATA", tmpDir); + vi.stubEnv("GLEAN_SESSION_ID", "sess-none"); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + // Deliberately write no marker. + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(elicit).toHaveBeenCalledTimes(1); + }); + + it("ignores a bypass marker written for a different session (no cross-session leak)", async () => { + vi.stubEnv("ENABLE_HITL", "true"); + vi.stubEnv("CLAUDE_PLUGIN_DATA", tmpDir); + vi.stubEnv("GLEAN_SESSION_ID", "sess-A"); + await writeToolJson(tmpDir, "jirasearch", { requires_approval: true }); + // Another concurrent session opted into bypass; ours did not. + await writeModeMarker(tmpDir, "sess-B", "bypassPermissions"); + const remote = makeRemote(); + const elicit = vi.fn().mockResolvedValue({ action: "accept" }); + const server = makeServer({ elicitation: true, elicit }); + + await handleRunTool(remote, server, tmpDir, baseArgs); + + expect(elicit).toHaveBeenCalledTimes(1); // gate preserved for THIS session + }); +}); + +describe("buildCompactArgs", () => { + it("returns (none) for empty or null args, no file", () => { + expect(buildCompactArgs(null)).toEqual({ lines: ["(none)"], needsFile: false }); + expect(buildCompactArgs({})).toEqual({ lines: ["(none)"], needsFile: false }); + }); + + it("renders short scalars inline, uppercased keys, one line each, no file", () => { + expect(buildCompactArgs({ project: "ABC", limit: 10, dryRun: true })).toEqual({ + lines: ["PROJECT: ABC", "LIMIT: 10", "DRYRUN: true"], + needsFile: false, + }); + }); + + it("collapses a multi-line string to a single line and flags a file", () => { + const { lines, needsFile } = buildCompactArgs({ + body: "# Title\n\n| A | B |\n|---|---|\n| 1 | 2 |", + }); + expect(lines).toHaveLength(1); + expect(lines[0]).not.toContain("\n"); + expect(lines[0].startsWith("BODY: ")).toBe(true); + expect(needsFile).toBe(true); + }); + + it("truncates a long single-line string with a (truncated) suffix and flags a file", () => { + const { lines, needsFile } = buildCompactArgs({ note: "x".repeat(300) }); + expect(lines[0].endsWith("(truncated)")).toBe(true); + expect(lines[0].length).toBeLessThan(150); + expect(needsFile).toBe(true); + }); + + it("caps inline arg lines at the budget and flags a file when omitted", () => { + const { lines, needsFile } = buildCompactArgs({ + a: 1, + b: 2, + c: 3, + d: 4, + e: 5, + f: 6, + g: 7, + h: 8, + i: 9, + }); + expect(lines).toHaveLength(7); + expect(needsFile).toBe(true); + }); + + it("renders a small nested object as compact JSON inline", () => { + expect(buildCompactArgs({ filters: { status: ["open", "wip"] } })).toEqual({ + lines: ['FILTERS: {"status":["open","wip"]}'], + needsFile: false, + }); + }); +}); + +describe("formatArgumentsForFile", () => { + it("writes string values verbatim and nested values as JSON blocks", () => { + const out = formatArgumentsForFile("create_doc", { + title: "Hi", + filters: { status: ["open"] }, + }); + expect(out).toContain("# Approval request: create_doc"); + expect(out).toContain("## title"); + expect(out).toContain("Hi"); + expect(out).toContain("## filters"); + expect(out).toContain('"status"'); + }); + + it("preserves multi-line Markdown content verbatim (so it renders when opened)", () => { + const table = "| A | B |\n|---|---|\n| 1 | 2 |"; + const out = formatArgumentsForFile("x", { body: table }); + expect(out).toContain(table); + expect(out).not.toContain("\\n"); + }); +}); + +describe("runToolAnnotations", () => { + it("marks run_tool read-only when HITL gates an elicitation-capable non-Cursor client", () => { + expect(runToolAnnotations(true, true, false)).toEqual({ + readOnlyHint: true, + }); + }); + + it("leaves annotations unset when HITL is disabled", () => { + expect(runToolAnnotations(false, true, false)).toBeUndefined(); + }); + + it("leaves annotations unset when the client cannot elicit", () => { + expect(runToolAnnotations(true, false, false)).toBeUndefined(); + }); + + it("does NOT advertise readOnlyHint to Cursor (its elicitation only renders on the attended lane)", () => { + expect(runToolAnnotations(true, true, true)).toBeUndefined(); + }); +}); diff --git a/sources/glean-vnext/tests/session-id.test.ts b/sources/glean-vnext/tests/session-id.test.ts new file mode 100644 index 0000000..ebe5311 --- /dev/null +++ b/sources/glean-vnext/tests/session-id.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { resolveSessionId } from "../src/session-id.js"; + +describe("resolveSessionId", () => { + const saved = process.env.GLEAN_SESSION_ID; + + afterEach(() => { + if (saved === undefined) { + delete process.env.GLEAN_SESSION_ID; + } else { + process.env.GLEAN_SESSION_ID = saved; + } + }); + + it("returns GLEAN_SESSION_ID when the launcher set it", () => { + process.env.GLEAN_SESSION_ID = "host-session-123"; + expect(resolveSessionId()).toBe("host-session-123"); + }); + + it("trims surrounding whitespace", () => { + process.env.GLEAN_SESSION_ID = " host-session-123 "; + expect(resolveSessionId()).toBe("host-session-123"); + }); + + it("ignores a whitespace-only value and falls back", () => { + process.env.GLEAN_SESSION_ID = " "; + expect(resolveSessionId()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + + it("ignores an un-interpolated ${...} placeholder and falls back", () => { + process.env.GLEAN_SESSION_ID = "${GLEAN_SESSION_ID}"; + expect(resolveSessionId()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + + it("falls back to a stable generated UUID when unset", () => { + delete process.env.GLEAN_SESSION_ID; + const first = resolveSessionId(); + expect(first).toBe(resolveSessionId()); + expect(first).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); +}); diff --git a/sources/glean-vnext/tests/skill-writer.test.ts b/sources/glean-vnext/tests/skill-writer.test.ts new file mode 100644 index 0000000..f70a8a0 --- /dev/null +++ b/sources/glean-vnext/tests/skill-writer.test.ts @@ -0,0 +1,336 @@ +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 { + writeSkillsToDisk, + formatAvailableSkillsPrompt, + evictStaleSkills, +} from "../src/skill-writer.js"; +import type { SkillsMap, SkillIndex } from "../src/types.js"; + +describe("writeSkillsToDisk", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "skill-writer-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("writes all files from the flat map", async () => { + const skills: SkillsMap = { + "search-jira": { + "SKILL.md": + "---\nname: search-jira\ndescription: Search Jira issues\n---\n# Search Jira\nUse this skill to search Jira.", + "tools/jirasearch.json": JSON.stringify({ + server_id: "composio/jira-pack", + tool_name: "jirasearch", + description: "Search Jira issues", + input_schema: { + type: "object", + properties: { query: { type: "string" } }, + }, + }), + }, + }; + + const index = await writeSkillsToDisk(skills, tmpDir); + + expect(index).toHaveLength(1); + expect(index[0].name).toBe("search-jira"); + expect(index[0].description).toBe("Search Jira issues"); + expect(index[0].files).toHaveLength(2); + + expect( + await fs.readFile( + path.join(tmpDir, "search-jira", "SKILL.md"), + "utf-8", + ), + ).toContain("# Search Jira"); + + const toolJson = JSON.parse( + await fs.readFile( + path.join(tmpDir, "search-jira", "tools", "jirasearch.json"), + "utf-8", + ), + ); + expect(toolJson.server_id).toBe("composio/jira-pack"); + expect(toolJson.input_schema.properties.query.type).toBe("string"); + }); + + it("creates nested directories from slash-separated paths", async () => { + const skills: SkillsMap = { + "code-review": { + "SKILL.md": + "---\nname: code-review\ndescription: Review code\n---\n# Code Review", + "templates/review.md": "## Template\nReview checklist", + "config.yaml": "threshold: 0.8", + }, + }; + + const index = await writeSkillsToDisk(skills, tmpDir); + + expect(index[0].files).toHaveLength(3); + expect( + await fs.readFile( + path.join(tmpDir, "code-review", "templates", "review.md"), + "utf-8", + ), + ).toBe("## Template\nReview checklist"); + expect( + await fs.readFile( + path.join(tmpDir, "code-review", "config.yaml"), + "utf-8", + ), + ).toBe("threshold: 0.8"); + }); + + it("parses frontmatter and falls back to directory key when missing", async () => { + const skills: SkillsMap = { + "gcal-event-creation": { + "SKILL.md": + "---\nname: gcal-event-creation\ndescription: Create Google Calendar events\nmetadata:\n author: glean\n---\n# Calendar", + }, + "no-frontmatter": { + "SKILL.md": "# No frontmatter here", + }, + }; + + const index = await writeSkillsToDisk(skills, tmpDir); + + expect(index[0].name).toBe("gcal-event-creation"); + expect(index[0].description).toBe("Create Google Calendar events"); + + expect(index[1].name).toBe("no-frontmatter"); + expect(index[1].description).toBe(""); + }); + + it("parses YAML block scalar descriptions", async () => { + const skills: SkillsMap = { + "block-scalar": { + "SKILL.md": + "---\nname: block-scalar\ndescription: >\n This is a long description\n spanning multiple lines\n---\n# Content", + }, + }; + + const index = await writeSkillsToDisk(skills, tmpDir); + + expect(index[0].name).toBe("block-scalar"); + expect(index[0].description).toContain("This is a long description"); + expect(index[0].description).toContain("spanning multiple lines"); + }); + + it("parses YAML literal block scalar descriptions", async () => { + const skills: SkillsMap = { + "literal-scalar": { + "SKILL.md": + "---\nname: literal-scalar\ndescription: |\n Line one\n Line two\n---\n# Content", + }, + }; + + const index = await writeSkillsToDisk(skills, tmpDir); + + expect(index[0].name).toBe("literal-scalar"); + expect(index[0].description).toContain("Line one"); + expect(index[0].description).toContain("Line two"); + }); + + it("parses YAML with value on next line", async () => { + const skills: SkillsMap = { + "next-line": { + "SKILL.md": + "---\nname: next-line\ndescription:\n The value on next line\n---\n# Content", + }, + }; + + const index = await writeSkillsToDisk(skills, tmpDir); + + expect(index[0].name).toBe("next-line"); + expect(index[0].description).toBe("The value on next line"); + }); + + it("parses frontmatter with CRLF line endings", async () => { + const skills: SkillsMap = { + "crlf-skill": { + "SKILL.md": + "---\r\nname: crlf-skill\r\ndescription: CRLF description\r\n---\r\n# Content", + }, + }; + + const index = await writeSkillsToDisk(skills, tmpDir); + + expect(index[0].name).toBe("crlf-skill"); + expect(index[0].description).toBe("CRLF description"); + }); + + it("prevents path traversal in skill names and file paths", async () => { + const skills: SkillsMap = { + "../../etc/malicious": { + "SKILL.md": "pwned", + }, + "safe-skill": { + "SKILL.md": "---\nname: safe-skill\ndescription: Safe\n---\n# Safe", + "../../etc/passwd": "malicious content", + "legit.md": "safe content", + }, + }; + + const index = await writeSkillsToDisk(skills, tmpDir); + + expect(index).toHaveLength(1); + expect(index[0].name).toBe("safe-skill"); + expect(index[0].files).toHaveLength(2); + expect( + await fs.readFile( + path.join(tmpDir, "safe-skill", "legit.md"), + "utf-8", + ), + ).toBe("safe content"); + await expect( + fs.access(path.join(tmpDir, "..", "etc", "passwd")), + ).rejects.toThrow(); + }); +}); + +describe("evictStaleSkills", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "evict-stale-test-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("removes directories older than the cutoff and keeps fresh ones", async () => { + const oldDir = path.join(tmpDir, "stale"); + const freshDir = path.join(tmpDir, "fresh"); + await fs.mkdir(oldDir); + await fs.writeFile(path.join(oldDir, "SKILL.md"), "old"); + await fs.mkdir(freshDir); + await fs.writeFile(path.join(freshDir, "SKILL.md"), "new"); + + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + await fs.utimes(oldDir, eightDaysAgo / 1000, eightDaysAgo / 1000); + + await evictStaleSkills(tmpDir, 7 * 24 * 60 * 60 * 1000); + + await expect(fs.access(oldDir)).rejects.toThrow(); + await expect(fs.access(freshDir)).resolves.toBeUndefined(); + }); + + it("is a no-op when the base directory does not exist", async () => { + const missing = path.join(tmpDir, "does-not-exist"); + await expect( + evictStaleSkills(missing, 7 * 24 * 60 * 60 * 1000), + ).resolves.toBeUndefined(); + }); + + it("ignores non-directory entries", async () => { + const filePath = path.join(tmpDir, "stray.txt"); + await fs.writeFile(filePath, "not a skill"); + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + await fs.utimes(filePath, eightDaysAgo / 1000, eightDaysAgo / 1000); + + await evictStaleSkills(tmpDir, 7 * 24 * 60 * 60 * 1000); + + await expect(fs.access(filePath)).resolves.toBeUndefined(); + }); + + it("keeps a directory whose mtime is exactly at the cutoff (strict <)", async () => { + const boundaryDir = path.join(tmpDir, "boundary"); + await fs.mkdir(boundaryDir); + const maxAgeMs = 7 * 24 * 60 * 60 * 1000; + // Truncate to whole seconds so utimes stores the mtime exactly — filesystems + // that only support second granularity would otherwise truncate the value + // below the cutoff and incorrectly evict the directory. + const now = Math.floor(Date.now() / 1000) * 1000; + const cutoffSec = (now - maxAgeMs) / 1000; + await fs.utimes(boundaryDir, cutoffSec, cutoffSec); + + await evictStaleSkills(tmpDir, maxAgeMs, undefined, now); + + await expect(fs.access(boundaryDir)).resolves.toBeUndefined(); + }); + + it("invokes the log callback for each evicted skill", async () => { + const staleDir = path.join(tmpDir, "stale-logged"); + await fs.mkdir(staleDir); + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + await fs.utimes(staleDir, eightDaysAgo / 1000, eightDaysAgo / 1000); + + const calls: { label: string; detail?: Record }[] = []; + await evictStaleSkills( + tmpDir, + 7 * 24 * 60 * 60 * 1000, + (label, detail) => { + calls.push({ label, detail }); + }, + ); + + expect( + calls.some( + (c) => + c.label === "evict-stale-skill" && + c.detail?.skill === "stale-logged", + ), + ).toBe(true); + }); +}); + +describe("formatAvailableSkillsPrompt", () => { + it("formats skills with file references (no instructions block)", () => { + const index: SkillIndex[] = [ + { + name: "search-jira", + description: "Search Jira issues", + skillDir: "/tmp/skills/search-jira", + files: [ + "/tmp/skills/search-jira/SKILL.md", + "/tmp/skills/search-jira/tools/jirasearch.json", + ], + }, + { + name: "create-event", + description: "Create calendar events", + skillDir: "/tmp/skills/create-event", + files: ["/tmp/skills/create-event/SKILL.md"], + }, + ]; + + const result = formatAvailableSkillsPrompt(index); + + expect(result).toContain(""); + expect(result).not.toContain(""); + expect(result).not.toContain("Browse the skills below"); + expect(result).toContain('name="search-jira"'); + expect(result).toContain('description="Search Jira issues"'); + expect(result).toContain('path="/tmp/skills/search-jira/SKILL.md"'); + expect(result).not.toContain( + 'path="/tmp/skills/search-jira/tools/jirasearch.json"', + ); + expect(result).toContain('name="create-event"'); + expect(result).toContain(""); + }); + + it("escapes XML special characters", () => { + const index: SkillIndex[] = [ + { + name: "test", + description: 'Has "quotes" & ', + skillDir: "/tmp/test", + files: [], + }, + ]; + + const result = formatAvailableSkillsPrompt(index); + + expect(result).toContain("&"); + expect(result).toContain("<angles>"); + expect(result).toContain(""quotes""); + }); +}); diff --git a/sources/glean-vnext/tests/token-store.test.ts b/sources/glean-vnext/tests/token-store.test.ts new file mode 100644 index 0000000..14fd6cc --- /dev/null +++ b/sources/glean-vnext/tests/token-store.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// Mock homedir before importing token-store so it uses a temp directory +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "token-store-test-")); +vi.mock("node:os", async () => { + const actual = await vi.importActual("node:os"); + return { ...actual, homedir: () => tmpDir }; +}); + +const { clearCredentials, loadCredentials, saveCredentials } = await import( + "../src/token-store.js" +); + +describe("token-store", () => { + const gleanDir = path.join(tmpDir, ".glean"); + const credFile = path.join(gleanDir, "mcp-credentials.json"); + + beforeEach(() => { + fs.rmSync(gleanDir, { recursive: true, force: true }); + }); + + afterEach(() => { + fs.rmSync(gleanDir, { recursive: true, force: true }); + }); + + it("returns undefined when credentials file does not exist", () => { + expect(loadCredentials()).toBeUndefined(); + }); + + it("saves and loads credentials round-trip", () => { + const tokens = { access_token: "tok_123", token_type: "Bearer" }; + const clientInfo = { client_id: "cid_456" }; + + saveCredentials(tokens, clientInfo); + const loaded = loadCredentials(); + + expect(loaded).toEqual({ tokens, clientInfo }); + }); + + it("creates ~/.glean/ directory on first save", () => { + expect(fs.existsSync(gleanDir)).toBe(false); + + saveCredentials({ access_token: "x" }, undefined); + + expect(fs.existsSync(gleanDir)).toBe(true); + }); + + it("sets credentials file to mode 0600", () => { + saveCredentials({ access_token: "x" }, undefined); + + const stat = fs.statSync(credFile); + const mode = stat.mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("returns undefined for corrupted JSON", () => { + fs.mkdirSync(gleanDir, { recursive: true }); + fs.writeFileSync(credFile, "not-json{{{", "utf-8"); + + expect(loadCredentials()).toBeUndefined(); + }); + + it("overwrites existing credentials on save", () => { + saveCredentials({ access_token: "old" }, { client_id: "old" }); + saveCredentials({ access_token: "new" }, { client_id: "new" }); + + const loaded = loadCredentials(); + expect(loaded).toEqual({ + tokens: { access_token: "new" }, + clientInfo: { client_id: "new" }, + }); + }); + + it("clearCredentials removes the persisted file", () => { + saveCredentials({ access_token: "x" }, { client_id: "y" }); + expect(fs.existsSync(credFile)).toBe(true); + + clearCredentials(); + + expect(fs.existsSync(credFile)).toBe(false); + expect(loadCredentials()).toBeUndefined(); + }); + + it("clearCredentials is a no-op when file does not exist", () => { + expect(fs.existsSync(credFile)).toBe(false); + expect(() => clearCredentials()).not.toThrow(); + }); +}); diff --git a/sources/glean-vnext/tests/url-config-store.test.ts b/sources/glean-vnext/tests/url-config-store.test.ts new file mode 100644 index 0000000..b7c9a5f --- /dev/null +++ b/sources/glean-vnext/tests/url-config-store.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "url-config-store-test-")); + +vi.stubEnv("PLUGIN_DATA_DIR", tmpDir); + +const { loadServerUrl, saveServerUrl, clearServerUrl } = await import( + "../src/url-config-store.js" +); + +afterAll(() => { + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("url-config-store", () => { + const configFile = path.join(tmpDir, "mcp-server-url.json"); + + beforeEach(() => { + try { + fs.rmSync(configFile, { force: true }); + } catch { + /* ignore */ + } + }); + + afterEach(() => { + try { + fs.rmSync(configFile, { force: true }); + } catch { + /* ignore */ + } + }); + + it("returns undefined when config file does not exist", () => { + expect(loadServerUrl()).toBeUndefined(); + }); + + it("round-trips a saved URL", () => { + saveServerUrl("https://acme-be.glean.com/mcp/gateway/proxy"); + expect(loadServerUrl()).toBe("https://acme-be.glean.com/mcp/gateway/proxy"); + }); + + it("overwrites previous value", () => { + saveServerUrl("https://old.glean.com/mcp/gateway/proxy"); + saveServerUrl("https://new.glean.com/mcp/gateway/proxy"); + expect(loadServerUrl()).toBe("https://new.glean.com/mcp/gateway/proxy"); + }); + + it("clearServerUrl removes the file", () => { + saveServerUrl("https://acme-be.glean.com/mcp/gateway/proxy"); + clearServerUrl(); + expect(loadServerUrl()).toBeUndefined(); + }); + + it("clearServerUrl is a no-op when file does not exist", () => { + expect(() => clearServerUrl()).not.toThrow(); + }); + + it("returns undefined for malformed JSON", () => { + fs.writeFileSync(configFile, "not json{{{", { encoding: "utf-8" }); + expect(loadServerUrl()).toBeUndefined(); + }); + + it("returns undefined when serverUrl is empty string", () => { + fs.writeFileSync(configFile, JSON.stringify({ serverUrl: "" }), { + encoding: "utf-8", + }); + expect(loadServerUrl()).toBeUndefined(); + }); + + it("returns undefined when serverUrl is not a string", () => { + fs.writeFileSync(configFile, JSON.stringify({ serverUrl: 12345 }), { + encoding: "utf-8", + }); + expect(loadServerUrl()).toBeUndefined(); + }); + + it("sets restrictive file permissions", () => { + saveServerUrl("https://acme-be.glean.com/mcp/gateway/proxy"); + const stat = fs.statSync(configFile); + expect(stat.mode & 0o777).toBe(0o600); + }); +}); diff --git a/sources/glean-vnext/tsconfig.json b/sources/glean-vnext/tsconfig.json new file mode 100644 index 0000000..bc74857 --- /dev/null +++ b/sources/glean-vnext/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src"] +}