From 733c8cd0095660a98cdaa5be910eb91011f8b75d Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 16 Jul 2026 09:03:29 +1000 Subject: [PATCH 01/46] Fixing some stuff with learning hub and app automations (#2291) --- website/astro.config.mjs | 1 + .../using-automations-in-copilot-app.md | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 1df5c300..253dace8 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -76,6 +76,7 @@ export default defineConfig({ items: [ "learning-hub/github-copilot-app", "learning-hub/working-with-canvas-extensions", + "learning-hub/using-automations-in-copilot-app", "learning-hub/what-are-agents-skills-instructions", "learning-hub/agents-and-subagents", "learning-hub/understanding-copilot-context", diff --git a/website/src/content/docs/learning-hub/using-automations-in-copilot-app.md b/website/src/content/docs/learning-hub/using-automations-in-copilot-app.md index 86dfea88..c5918e1b 100644 --- a/website/src/content/docs/learning-hub/using-automations-in-copilot-app.md +++ b/website/src/content/docs/learning-hub/using-automations-in-copilot-app.md @@ -1,10 +1,10 @@ --- -title: 'Using Automations in the GitHub Copilot app' -description: 'A practical guide to getting started with Copilot app automations using templates, iterative refinement, and real-world examples.' +title: "Using Automations in the GitHub Copilot app" +description: "A practical guide to getting started with Copilot app automations using templates, iterative refinement, and real-world examples." authors: - GitHub Copilot Learning Hub Team lastUpdated: 2026-06-17 -estimatedReadingTime: '10 minutes' +estimatedReadingTime: "10 minutes" tags: - copilot-app - automations @@ -61,13 +61,15 @@ If your first version is only 70% right, that is normal. The fastest path is to Here is a real in-app automation used on the `github/awesome-copilot` repository: -| Field | Value | -|---|---| -| **Name** | Awesome Copilot daily PR summary | -| **Interval** | Daily at 09:00 | -| **Mode** | Autopilot | +| Field | Value | +| ---------------- | --------------------------------------------------------------------------------------------------------- | +| **Name** | Awesome Copilot daily PR summary | +| **Interval** | Daily at 09:00 | +| **Mode** | Autopilot | | **What it does** | Pulls open PRs via `gh api`, filters to updates in the last 24 hours, and returns a concise summary table | +[![Create Automation](https://img.shields.io/badge/automation-daily_pr_summary-blue?logo=github-copilot)](ghapp://automations/new?name=Awesome%20Copilot%20daily%20PR%20summary&trigger=daily&time=09%3A00&prompt=Pulls%20open%20PRs%20via%20gh%20api%2C%20filters%20to%20updates%20in%20the%20last%2024%20hours%2C%20and%20returns%20a%20concise%20summary%20table) + Why this works well: - It is narrowly scoped (one repo, one reporting goal). From e4a1f57fd9d8c22d2a345d498fe6fde306c6456e Mon Sep 17 00:00:00 2001 From: Willie Yao Date: Thu, 16 Jul 2026 07:06:49 +0800 Subject: [PATCH 02/46] feat: add convert-excel-to-md, convert-pdf-to-md, and convert-word-to-md skills (#2294) * feat: add convert-excel-to-md, convert-pdf-to-md, and convert-word-to-md skills Add three new agent skills that convert common document formats to Markdown using bundled Python scripts powered by MarkItDown: - convert-excel-to-md: Converts .xlsx workbooks to Markdown with per-sheet tables and embedded image extraction via a bundled Python script. - convert-pdf-to-md: Converts .pdf documents to Markdown with text/table extraction and embedded image extraction via PyMuPDF. - convert-word-to-md: Converts .docx documents to Markdown with proper image extraction replacing MarkItDown's base64 placeholders. Each skill includes: - SKILL.md with detailed usage instructions, output structure docs, and a troubleshooting table - scripts/ with the conversion Python script and requirements.txt - references/setup.md with environment setup instructions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove Markdown image syntax from convert-word-to-md SKILL.md The CI valid-refs linter flagged the literal Markdown image syntax containing a data URI as an invalid file reference. Replaced it with a plain text description of the placeholder format. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update pip install command to use scripts/requirements.txt for setup * fix: clarify installation instructions for requirements-file in setup documentation * fix: correct indentation in image extraction function * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * feat: add comprehensive documentation and setup instructions for convert-to-md skills * fix: add trailing slashes to skill paths in plugin.json * fix: reorder skills in plugin.json for consistency * fix: update plugin.json and README.md for clarity and consistency * feat: add convert-to-md plugin and update related documentation * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: enhance conversion skills to handle mixed file types and improve error handling --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 6 + docs/README.plugins.md | 1 + docs/README.skills.md | 3 + .../convert-to-md/.github/plugin/plugin.json | 24 ++ plugins/convert-to-md/README.md | 47 +++ skills/convert-excel-to-md/SKILL.md | 131 +++++++ .../convert-excel-to-md/references/setup.md | 73 ++++ .../scripts/convert_excel_to_md.py | 354 ++++++++++++++++++ .../scripts/requirements.txt | 1 + skills/convert-pdf-to-md/SKILL.md | 130 +++++++ skills/convert-pdf-to-md/references/setup.md | 71 ++++ .../scripts/convert_pdf_to_md.py | 321 ++++++++++++++++ .../scripts/requirements.txt | 2 + skills/convert-word-to-md/SKILL.md | 129 +++++++ skills/convert-word-to-md/references/setup.md | 66 ++++ .../scripts/convert_word_to_md.py | 301 +++++++++++++++ .../scripts/requirements.txt | 1 + 17 files changed, 1661 insertions(+) create mode 100644 plugins/convert-to-md/.github/plugin/plugin.json create mode 100644 plugins/convert-to-md/README.md create mode 100644 skills/convert-excel-to-md/SKILL.md create mode 100644 skills/convert-excel-to-md/references/setup.md create mode 100644 skills/convert-excel-to-md/scripts/convert_excel_to_md.py create mode 100644 skills/convert-excel-to-md/scripts/requirements.txt create mode 100644 skills/convert-pdf-to-md/SKILL.md create mode 100644 skills/convert-pdf-to-md/references/setup.md create mode 100644 skills/convert-pdf-to-md/scripts/convert_pdf_to_md.py create mode 100644 skills/convert-pdf-to-md/scripts/requirements.txt create mode 100644 skills/convert-word-to-md/SKILL.md create mode 100644 skills/convert-word-to-md/references/setup.md create mode 100644 skills/convert-word-to-md/scripts/convert_word_to_md.py create mode 100644 skills/convert-word-to-md/scripts/requirements.txt diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index d3fba171..e91a7607 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -253,6 +253,12 @@ "description": "Coding agents hallucinate APIs. ContextMatic gives them curated, versioned API and SDK docs. Ask your agent to \"integrate the payments API\" and it guesses — falling back on outdated training data and generic patterns that don't match your actual SDK. ContextMatic solves this by giving the agent deterministic, version-aware, SDK-native context at the exact moment it's needed.", "version": "0.1.0" }, + { + "name": "convert-to-md", + "source": "plugins/convert-to-md", + "description": "A collection of Copilot skills that convert common document formats into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Just tell Copilot what you need — the right skill is invoked automatically and the conversion happens behind the scenes.", + "version": "1.0.1" + }, { "name": "copilot-goal-skill", "description": "Goal-driven task orchestration with independent verification. Interviews the user to define a clear goal, then loops between a Builder subagent (does the work) and an Inspector subagent (judges the result with fresh context). The Inspector never trusts the Builder. Output is auditable in git commits from each subagent actions. Use when the user says \"achieve this goal\", \"make this work\", \"implement until done\", or wants verified autonomous task completion with independent quality review.", diff --git a/docs/README.plugins.md b/docs/README.plugins.md index ca23476d..bff4a4b4 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -40,6 +40,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [cms-development](../plugins/cms-development/README.md) | Skills for CMS development across themes, plugins, admin tooling, media workflows, markdown rendering, and static export pipelines. | 3 items | cms, content-management-system, wordpress, shopify, drupal, theme, plugin, media, static-site | | [context-engineering](../plugins/context-engineering/README.md) | Tools and techniques for maximizing GitHub Copilot effectiveness through better context management. Includes guidelines for structuring code, an agent for planning multi-file changes, and prompts for context-aware development. | 4 items | context, productivity, refactoring, best-practices, architecture | | [context-matic](../plugins/context-matic/README.md) | Coding agents hallucinate APIs. ContextMatic gives them curated, versioned API and SDK docs. Ask your agent to "integrate the payments API" and it guesses — falling back on outdated training data and generic patterns that don't match your actual SDK. ContextMatic solves this by giving the agent deterministic, version-aware, SDK-native context at the exact moment it's needed. | 2 items | api-context, api-integration, mcp, sdk, apimatic, third-party-apis, sdks | +| [convert-to-md](../plugins/convert-to-md/README.md) | A collection of Copilot skills that convert common document formats into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Just tell Copilot what you need — the right skill is invoked automatically and the conversion happens behind the scenes. | 3 items | skills, configuration, copilot, convert-word-to-md, convert-excel-to-md, convert-pdf-to-md | | [copilot-sdk](../plugins/copilot-sdk/README.md) | Build applications with the GitHub Copilot SDK across multiple programming languages. Includes comprehensive instructions for C#, Go, Node.js/TypeScript, and Python to help you create AI-powered applications. | 1 items | copilot-sdk, sdk, csharp, go, nodejs, typescript, python, ai, github-copilot | | [csharp-dotnet-development](../plugins/csharp-dotnet-development/README.md) | Essential prompts, instructions, and chat modes for C# and .NET development including testing, documentation, and best practices. | 9 items | csharp, dotnet, aspnet, testing | | [database-data-management](../plugins/database-data-management/README.md) | Database administration, SQL optimization, and data management tools for PostgreSQL, SQL Server, and general database development best practices. | 6 items | database, sql, postgresql, sql-server, dba, optimization, queries, data-management | diff --git a/docs/README.skills.md b/docs/README.skills.md index 63ddc1e5..fb94c86e 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -100,7 +100,10 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [context-map](../skills/context-map/SKILL.md)
`gh skills install github/awesome-copilot context-map` | Generate a map of all files relevant to a task before making changes | None | | [conventional-branch](../skills/conventional-branch/SKILL.md)
`gh skills install github/awesome-copilot conventional-branch` | Create Git branches following the Conventional Branch specification (feature/, bugfix/, hotfix/, release/, chore/). Use when creating a new branch, naming a branch, or checking whether a branch name complies with the spec. | None | | [conventional-commit](../skills/conventional-commit/SKILL.md)
`gh skills install github/awesome-copilot conventional-commit` | Prompt and workflow for generating conventional commit messages using a structured XML format. Guides users to create standardized, descriptive commit messages in line with the Conventional Commits specification, including instructions, examples, and validation. | None | +| [convert-excel-to-md](../skills/convert-excel-to-md/SKILL.md)
`gh skills install github/awesome-copilot convert-excel-to-md` | Converts Excel (.xlsx) workbooks into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Use this skill whenever the user shares, references, or asks about a .xlsx file — even if they don't say "convert" or "markdown" explicitly. This includes requests to "read", "summarize", "review", "extract data from", "compare", "chart", or "analyze" a spreadsheet, workbook, budget, data export, or tracker. Always run the bundled conversion script to produce Markdown first; do not attempt to parse .xlsx content directly or write ad-hoc extraction code. Also use this skill for batch requests involving a whole folder of Excel workbooks. IMPORTANT: When the user references a folder or set of documents containing multiple file types (.pdf, .docx, .xlsx), invoke ALL three sibling skills — convert-pdf-to-md, convert-word-to-md, and convert-excel-to-md — so no file type is silently skipped. | `references/setup.md`
`scripts/convert_excel_to_md.py`
`scripts/requirements.txt` | +| [convert-pdf-to-md](../skills/convert-pdf-to-md/SKILL.md)
`gh skills install github/awesome-copilot convert-pdf-to-md` | Converts PDF (.pdf) documents into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Use this skill whenever the user shares, references, or asks about a .pdf file — even if they don't say "convert" or "markdown" explicitly. This includes requests to "read", "summarize", "review", "extract data from", "compare", or "analyze" a PDF report, paper, invoice, form, contract, or scanned document. Always run the bundled conversion script to produce Markdown first; do not attempt to parse PDF content directly or write ad-hoc extraction code. Also use this skill for batch requests involving a whole folder of PDF documents. IMPORTANT: When the user references a folder or set of documents containing multiple file types (.pdf, .docx, .xlsx), invoke ALL three sibling skills — convert-pdf-to-md, convert-word-to-md, and convert-excel-to-md — so no file type is silently skipped. | `references/setup.md`
`scripts/convert_pdf_to_md.py`
`scripts/requirements.txt` | | [convert-plaintext-to-md](../skills/convert-plaintext-to-md/SKILL.md)
`gh skills install github/awesome-copilot convert-plaintext-to-md` | Convert a text-based document to markdown following instructions from prompt, or if a documented option is passed, follow the instructions for that option. | None | +| [convert-word-to-md](../skills/convert-word-to-md/SKILL.md)
`gh skills install github/awesome-copilot convert-word-to-md` | Converts Word (.docx) documents into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Use this skill whenever the user shares, references, or asks about a .docx file — even if they don't say "convert" or "markdown" explicitly. This includes requests to "read", "summarize", "review", "extract data from", "compare", or "analyze" a Word document, resume, report, contract, or proposal. Always run the bundled conversion script to produce Markdown first; do not attempt to parse .docx content directly or write ad-hoc conversion code. Also use this skill for batch requests involving a whole folder of Word documents. IMPORTANT: When the user references a folder or set of documents containing multiple file types (.pdf, .docx, .xlsx), invoke ALL three sibling skills — convert-pdf-to-md, convert-word-to-md, and convert-excel-to-md — so no file type is silently skipped. | `references/setup.md`
`scripts/convert_word_to_md.py`
`scripts/requirements.txt` | | [copilot-cli-quickstart](../skills/copilot-cli-quickstart/SKILL.md)
`gh skills install github/awesome-copilot copilot-cli-quickstart` | Use this skill when someone wants to learn GitHub Copilot CLI from scratch. Offers interactive step-by-step tutorials with separate Developer and Non-Developer tracks, plus on-demand Q&A. Just say "start tutorial" or ask a question! Note: This skill targets GitHub Copilot CLI specifically and uses CLI-specific tools (ask_user, sql, fetch_copilot_cli_documentation). | None | | [copilot-instructions-blueprint-generator](../skills/copilot-instructions-blueprint-generator/SKILL.md)
`gh skills install github/awesome-copilot copilot-instructions-blueprint-generator` | Technology-agnostic blueprint generator for creating comprehensive copilot-instructions.md files that guide GitHub Copilot to produce code consistent with project standards, architecture patterns, and exact technology versions by analyzing existing codebase patterns and avoiding assumptions. | None | | [copilot-pr-autopilot](../skills/copilot-pr-autopilot/SKILL.md)
`gh skills install github/awesome-copilot copilot-pr-autopilot` | Copilot left 14 review comments on your PR — half are nits. Hours of fix → reply → resolve → re-request, and each round lands MORE comments. This skill runs loop engineering: auto-triggers Copilot Code Review via GraphQL (no @copilot mention), triages every open thread (Copilot, humans, advanced-security) with a fix / decline / escalate rubric, dispatches parallel fix sub-agents that obey the repo build/test/lint conventions, commits per iteration, replies+resolves citing the pushed SHA, then re-triggers until HEAD is reviewed with zero threads awaiting the agent's reply (remaining open threads are explicit hand-offs to the human — escalated declines, design tradeoffs). You merge a clean PR; the bot runs it. Trigger phrases: "address copilot comments", "run a copilot review loop", "fix this PR", "iterate on copilot feedback". Repo-agnostic, gh CLI + PowerShell. Full autopilot needs repo Triage/Write; external PR authors get single-iteration mode plus manual re-trigger (UI 🔄 or substantive-commit push). | `references/01-request-review.md`
`references/02-wait.md`
`references/03-list-threads.md`
`references/04-triage.md`
`references/05-fix.md`
`references/06-build-test.md`
`references/07-commit-push.md`
`references/08-reply-resolve.md`
`references/09-convergence.md`
`references/10-cleanup.md`
`references/api-quirks.md`
`references/orchestration.md`
`scripts/01-request-review.ps1`
`scripts/02-check-review-status.ps1`
`scripts/03-list-open-threads.ps1`
`scripts/08-reply-and-resolve.ps1`
`scripts/09-review-round.ps1`
`scripts/10-cleanup-outdated.ps1`
`scripts/_lib.ps1`
`templates` | diff --git a/plugins/convert-to-md/.github/plugin/plugin.json b/plugins/convert-to-md/.github/plugin/plugin.json new file mode 100644 index 00000000..91fd9b51 --- /dev/null +++ b/plugins/convert-to-md/.github/plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "convert-to-md", + "description": "A collection of Copilot skills that convert common document formats into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Just tell Copilot what you need — the right skill is invoked automatically and the conversion happens behind the scenes.", + "version": "1.0.1", + "keywords": [ + "skills", + "configuration", + "copilot", + "convert-word-to-md", + "convert-excel-to-md", + "convert-pdf-to-md" + ], + "author": { + "name": "Willie Yao", + "url": "https://github.com/1YaoWei0" + }, + "repository": "https://github.com/github/awesome-copilot", + "license": "MIT", + "skills": [ + "./skills/convert-excel-to-md/", + "./skills/convert-pdf-to-md/", + "./skills/convert-word-to-md/" + ] +} diff --git a/plugins/convert-to-md/README.md b/plugins/convert-to-md/README.md new file mode 100644 index 00000000..5e075419 --- /dev/null +++ b/plugins/convert-to-md/README.md @@ -0,0 +1,47 @@ +# Convert to Markdown Plugin + +A collection of Copilot skills that convert common document formats into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Just tell Copilot what you need — the right skill is invoked automatically and the conversion happens behind the scenes. + +## Installation + +```bash +copilot plugin install convert-to-md@awesome-copilot +``` + +## What's Included + +This plugin includes Word, Excel, and PDF conversion skills, detailed below. + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## Skills + +### convert-word-to-md + +Converts Word (`.docx`) documents to Markdown. Use it any time you want to read, summarize, review, compare, or extract information from a `.docx` file — even if you don't say "convert" explicitly. + +> "Summarize this Word document." +> "Extract all the action items from report.docx." +> "Compare these two contracts." + +### convert-excel-to-md + +Converts Excel (`.xlsx`) workbooks to Markdown, rendering each sheet as a table. Use it any time you want to analyze, query, or summarize data in a spreadsheet — single file or a whole folder at once. + +> "What are the top 5 rows by revenue in this spreadsheet?" +> "Summarize all the worksheets in this workbook." +> "Process every Excel file in this folder." + +### convert-pdf-to-md + +Converts PDF (`.pdf`) documents to Markdown, extracting both text and embedded images. Use it any time you want to read, summarize, or pull data from a PDF report, invoice, paper, or form. + +> "Summarize this PDF." +> "Extract all the dates mentioned in this contract." +> "Process all the PDFs in this folder." + +## License + +MIT diff --git a/skills/convert-excel-to-md/SKILL.md b/skills/convert-excel-to-md/SKILL.md new file mode 100644 index 00000000..00d465f4 --- /dev/null +++ b/skills/convert-excel-to-md/SKILL.md @@ -0,0 +1,131 @@ +--- +name: convert-excel-to-md +description: 'Converts Excel (.xlsx) workbooks into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Use this skill whenever the user shares, references, or asks about a .xlsx file — even if they don''t say "convert" or "markdown" explicitly. This includes requests to "read", "summarize", "review", "extract data from", "compare", "chart", or "analyze" a spreadsheet, workbook, budget, data export, or tracker. Always run the bundled conversion script to produce Markdown first; do not attempt to parse .xlsx content directly or write ad-hoc extraction code. Also use this skill for batch requests involving a whole folder of Excel workbooks. IMPORTANT: When the user references a folder or set of documents containing multiple file types (.pdf, .docx, .xlsx), invoke ALL three sibling skills — convert-pdf-to-md, convert-word-to-md, and convert-excel-to-md — so no file type is silently skipped.' +--- + +# Convert Excel to Markdown + +## When to use this skill + +Trigger this skill any time there is a `.xlsx` file that needs to be +understood or processed — for example, a user attaches a spreadsheet and +asks questions about it, wants a summary of the data, wants specific rows or +values pulled out, or wants multiple workbooks in a folder processed +together. Excel's native `.xlsx` format is a zipped XML bundle that is not +reliably readable as plain text, so always convert it to Markdown first +using the script in this skill rather than trying to open or parse the file +directly. + +This skill only supports `.xlsx`. If asked to convert a legacy `.xls` file, +tell the user it isn't supported and ask them to re-save it as `.xlsx` +(Excel: File > Save As > Excel Workbook (.xlsx)) first. + +**Mixed file types:** When the user references a folder or set of documents +containing multiple supported file types (`.pdf`, `.docx`, `.xlsx`), this +skill handles only `.xlsx` files. The agent MUST also invoke the sibling +skills in parallel: +- `convert-pdf-to-md` for any `.pdf` files +- `convert-word-to-md` for any `.docx` files + +Never process a folder and silently skip a supported file type. All three +skills must be invoked together when mixed types are present. + +## Setup (once per environment) + +Before the first conversion in a given environment, follow +[`references/setup.md`](references/setup.md) step by step to ensure Python, +pip, and the `markitdown` package are installed. Do this proactively rather +than guessing whether the environment is ready — the script itself will +also fail with a clear pointer back to that file if `markitdown` turns out +to be missing, so it's safe to just try the conversion first if you're +reasonably confident setup was already done. + +## Usage + +The conversion script lives at `scripts/convert_excel_to_md.py`. + +**Output structure:** MarkItDown's XLSX converter renders each sheet as its +own `## ` Markdown table — it has no support for embedded images +at all. This script separately extracts real embedded images (raster +pictures, not charts) and maps them to the sheet they belong to, writing a +self-contained folder per document: + +``` +/ + img/ + sheet001__img001. + sheet002__img001. + ... + .md (each sheet's images appear right after its table, + under a "#### Images in this sheet" heading) +``` + +This is per-sheet placement, not exact cell position — the finest +granularity MarkItDown's stable output anchors (the `## ` +headings) allow. If a workbook has no embedded images, no `img/` folder or +image sections are created. Native Excel **charts** are not extracted as +images (only actual embedded pictures are — charts would need to be +rendered by Excel/LibreOffice, which this lightweight skill does not do). + +**Single file:** + +```powershell +python scripts\convert_excel_to_md.py "C:\path\to\workbook.xlsx" +``` + +This creates a `workbook\` folder next to the source file (containing +`workbook.md` and, if present, `workbook\img\`). To control the destination +folder explicitly: + +```powershell +python scripts\convert_excel_to_md.py "C:\path\to\workbook.xlsx" -o "C:\path\to\output_folder" +``` + +**A folder of workbooks (batch mode):** + +```powershell +python scripts\convert_excel_to_md.py "C:\path\to\folder" +``` + +Add `--recursive` to also include subfolders: + +```powershell +python scripts\convert_excel_to_md.py "C:\path\to\folder" --recursive +``` + +Each `.xlsx` found gets its own `\` output folder next to it by +default. Pass `-o "C:\path\to\output_parent"` to collect all the generated +`\` folders under a separate parent directory instead (subfolder +structure is preserved when combined with `--recursive`). + +After conversion, read the resulting `.md` file(s) to perform the actual +analysis the user asked for — the script's job is only to produce accurate +Markdown (and images), not to interpret the content. + +## Deciding where output goes + +**Default — always output next to the source file.** The `/` folder +is created in the same directory as the source `.xlsx`. This is the required +default for every case. Do NOT override it unless the user explicitly asks +for a different location. + +**Only use `-o` when** the user explicitly provides an output path (e.g., +"save the output to `C:\output`", "put the results in `D:\work`"). Do NOT +pass `-o` based on the agent's current working directory, the session state +folder, or any implied location. + +**If the source file path cannot be fully resolved** — for example, the +user provides only a filename with no directory, or the path is ambiguous — +use `ask_user` to confirm the full absolute path before running the +conversion. Never guess or assume the directory. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `ModuleNotFoundError: No module named 'markitdown'` / exit code 2 | MarkItDown not installed | Follow `references/setup.md` | +| `ERROR: Unsupported file type '.xls'` / exit code 3 | Legacy `.xls`, not `.xlsx` | Ask the user to re-save as `.xlsx` | +| `ERROR: Input path not found` / exit code 3 | Wrong path, or file moved | Confirm the correct path with the user | +| `FAILED -> ...` in batch output | That specific file is corrupt, password-protected, or otherwise unreadable | Report which file(s) failed; other files in the batch still succeed | +| `NOTE: skipped N non-.xlsx file(s)` | Folder contains non-Excel files | Expected — those files are intentionally ignored | +| A sheet's charts don't appear as images | Charts are chart objects, not embedded pictures — this skill only extracts real embedded raster images | Expected; mention this limitation if the user specifically needs chart images | diff --git a/skills/convert-excel-to-md/references/setup.md b/skills/convert-excel-to-md/references/setup.md new file mode 100644 index 00000000..f3e49e15 --- /dev/null +++ b/skills/convert-excel-to-md/references/setup.md @@ -0,0 +1,73 @@ +# Environment Setup for convert-excel-to-md + +Follow these steps exactly, in order, before running `scripts/convert_excel_to_md.py` +for the first time in a given environment. Don't skip steps or improvise +alternatives — they're written to be deterministic and safe to re-run. + +## 1. Check Python is available (3.10+) + +```powershell +python --version +``` + +- If this fails (command not found), install Python 3.10 or newer: + - Windows: `winget install --id Python.Python.3.12 -e` + - macOS: `brew install python@3.12` + - Linux (Debian/Ubuntu): `sudo apt-get update && sudo apt-get install -y python3 python3-pip python-is-python3` +- If the reported version is older than 3.10, install a newer Python using + the same command above (MarkItDown requires 3.10+). + +## 2. Check pip is available + +```powershell +python -m pip --version +``` + +- If this fails, bootstrap pip: + +```powershell +python -m ensurepip --upgrade +``` + +## 3. Install MarkItDown with Excel (.xlsx) support + +Use the `scripts/requirements.txt` file bundled with this skill to install a pinned, +known-good version of the dependency: + +```powershell +python -m pip install -r scripts/requirements.txt +``` + +This pulls in `markitdown[xlsx]` (MarkItDown's XLSX table conversion +dependencies, which include `pandas` and `openpyxl`). No extra package is needed for image extraction — this +skill's script reads embedded images directly from the `.xlsx` zip +structure using Python's built-in `zipfile` and `xml` modules. + +## 4. Verify the install + +```powershell +python -c "from markitdown import MarkItDown; print('markitdown OK')" +``` + +Expect to see `markitdown OK` printed with no errors. If you see +`ModuleNotFoundError: No module named 'markitdown'`, repeat step 3 — pip may +be installing into a different Python environment than the one being +invoked (check `python -m pip --version` shows the same path as `python +--version`'s interpreter). + +## Notes + +- This setup only needs to be done once per environment/virtual environment, + not once per conversion. +- `convert_excel_to_md.py` itself also checks for `markitdown` at startup + and prints a pointer back to this file if it's missing, so re-running + setup is safe and idempotent. +- Only `.xlsx` is supported by this skill. Legacy binary `.xls` files are + out of scope (a completely different, harder-to-parse file format) — ask + the user to re-save the file as `.xlsx` (Excel: File > Save As > Excel + Workbook (.xlsx)) if one is encountered. +- Chart objects (as opposed to embedded pictures) are not extracted as + images — only raster pictures actually embedded in the workbook's + `xl/media` folder are. Native Excel charts would need to be rendered by + Excel/LibreOffice to become images, which this lightweight skill does not + attempt. diff --git a/skills/convert-excel-to-md/scripts/convert_excel_to_md.py b/skills/convert-excel-to-md/scripts/convert_excel_to_md.py new file mode 100644 index 00000000..6f7b562a --- /dev/null +++ b/skills/convert-excel-to-md/scripts/convert_excel_to_md.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Convert Excel (.xlsx) workbooks to Markdown using Microsoft's MarkItDown, +with embedded images extracted to real files and placed under the correct +sheet (MarkItDown's XLSX converter only extracts sheet data as tables -- it +has no support for embedded images at all). + +Usage: + python convert_excel_to_md.py [-o OUTPUT] [--recursive] + + may be either: + - a path to a single .xlsx file, or + - a path to a directory (batch mode: every .xlsx file directly inside it + is converted; pass --recursive to also descend into subdirectories). + +Output: + For each source .xlsx (named ".xlsx"), a folder is created + containing the Markdown and its images, in this layout: + + / + img/ + Sheet1_img001. + Sheet2_img001. + ... + .md + + MarkItDown renders each sheet as its own "## " section with a + Markdown table. This script independently maps embedded images to the + sheet they belong to (via the .xlsx zip's drawing relationships) and + inserts a "#### Images in this sheet" block right after that sheet's + table, before the next "## " heading. This is per-sheet placement (not + exact cell position), which is the finest granularity MarkItDown's stable + output anchors allow. + + - Single file mode: the "/" folder is created next to the source + file, or at -o/--output (treated as the exact destination folder) if + given. + - Batch/directory mode: a "/" folder is created next to each source + file, or under -o/--output (treated as a parent directory, created if + missing) if given, preserving relative subfolder structure when + --recursive is used. + - If a workbook has no embedded images, no "img/" folder or "Images in + this sheet" sections are created. + +Exit codes: + 0 - all requested conversions succeeded + 1 - one or more conversions failed (partial success in batch mode) + 2 - required dependency ("markitdown") is not installed + 3 - invalid input (path not found, or single-file input is not .xlsx) +""" +import argparse +import posixpath +import re +import shutil +import sys +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + +EXIT_OK = 0 +EXIT_CONVERSION_FAILED = 1 +EXIT_MISSING_DEPENDENCY = 2 +EXIT_INVALID_INPUT = 3 + +_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +_MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +_R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" + +# Matches MarkItDown's per-sheet heading, e.g. "## Sheet1" +_SHEET_HEADER_RE = re.compile(r"^## (.+)$", re.MULTILINE) + + +def _import_markitdown(): + """Import MarkItDown, failing with a clear, actionable message if absent.""" + try: + from markitdown import MarkItDown + return MarkItDown + except ImportError: + print( + "ERROR: The 'markitdown' package is not installed.\n" + "See references/setup.md for this skill, or run:\n" + ' pip install "markitdown[xlsx]"', + file=sys.stderr, + ) + sys.exit(EXIT_MISSING_DEPENDENCY) + + +def _normalize_rel_path(base_dir: str, target: str) -> str: + """Resolve a (possibly relative, e.g. '../media/image1.png') relationship + target against the directory containing the part that referenced it.""" + if target.startswith("/"): + return target.lstrip("/") + return posixpath.normpath(posixpath.join(base_dir, target)) + + +def _sheet_name_to_media(xlsx_path: Path): + """Return {sheet_name: [media_zip_path, ...]} in per-sheet document + order, by walking workbook.xml -> worksheet -> drawing -> media + relationships. Returns {} if anything is missing/malformed (falls back + gracefully -- images just won't be extracted for that sheet).""" + try: + with zipfile.ZipFile(xlsx_path) as z: + names = set(z.namelist()) + if "xl/workbook.xml" not in names or "xl/_rels/workbook.xml.rels" not in names: + return {} + workbook_xml = z.read("xl/workbook.xml") + workbook_rels_xml = z.read("xl/_rels/workbook.xml.rels") + + sheet_rid = {} + for sheet_el in ET.fromstring(workbook_xml).iter(f"{{{_MAIN_NS}}}sheet"): + name = sheet_el.get("name") + rid = sheet_el.get(f"{{{_R_NS}}}id") + if name and rid: + sheet_rid[name] = rid + + rid_target = {} + for rel in ET.fromstring(workbook_rels_xml).findall(f"{{{_REL_NS}}}Relationship"): + rid_target[rel.get("Id")] = rel.get("Target") + + result = {} + for sheet_name, rid in sheet_rid.items(): + target = rid_target.get(rid) + if not target: + continue + # workbook.xml.rels targets are typically relative to "xl/", + # but OOXML allows package-absolute targets (leading "/") too. + sheet_path = _normalize_rel_path("xl", target) + if sheet_path not in names or "/" not in sheet_path: + continue + sheet_dir, sheet_file = sheet_path.rsplit("/", 1) + sheet_rels_path = f"{sheet_dir}/_rels/{sheet_file}.rels" + if sheet_rels_path not in names: + continue + + drawing_rid = None + for d in ET.fromstring(z.read(sheet_path)).iter(f"{{{_MAIN_NS}}}drawing"): + drawing_rid = d.get(f"{{{_R_NS}}}id") + break + if not drawing_rid: + continue + + drawing_target = None + for rel in ET.fromstring(z.read(sheet_rels_path)).findall(f"{{{_REL_NS}}}Relationship"): + if rel.get("Id") == drawing_rid: + drawing_target = rel.get("Target") + break + if not drawing_target: + continue + drawing_path = _normalize_rel_path(sheet_dir, drawing_target) + if drawing_path not in names or "/" not in drawing_path: + continue + drawing_dir, drawing_file = drawing_path.rsplit("/", 1) + drawing_rels_path = f"{drawing_dir}/_rels/{drawing_file}.rels" + if drawing_rels_path not in names: + continue + + drawing_rel_map = {} + for rel in ET.fromstring(z.read(drawing_rels_path)).findall(f"{{{_REL_NS}}}Relationship"): + drawing_rel_map[rel.get("Id")] = rel.get("Target") + + media_paths = [] + for blip in ET.fromstring(z.read(drawing_path)).iter(f"{{{_A_NS}}}blip"): + embed_rid = blip.get(f"{{{_R_NS}}}embed") + if not embed_rid: + continue + rel_target = drawing_rel_map.get(embed_rid) + if not rel_target: + continue + media_path = _normalize_rel_path(drawing_dir, rel_target) + if media_path in names: + media_paths.append(media_path) + + if media_paths: + result[sheet_name] = media_paths + return result + except (zipfile.BadZipFile, KeyError, OSError, ET.ParseError): + return {} + + +def _sanitize_filename_part(name: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", name).strip("_") + return safe or "sheet" + + +def extract_images(xlsx_path: Path, img_dir: Path): + """Extract embedded images from xlsx_path, grouped by sheet name. + Returns {sheet_name: [filename, ...]} in per-sheet order. Files are + named '_img{N:03d}.'.""" + sheet_media = _sheet_name_to_media(xlsx_path) + if not sheet_media: + return {} + + written = {} + with zipfile.ZipFile(xlsx_path) as z: + names_in_zip = set(z.namelist()) + for sheet_idx, (sheet_name, media_paths) in enumerate(sheet_media.items(), start=1): + safe_name = f"sheet{sheet_idx:03d}_{_sanitize_filename_part(sheet_name)}" + files = [] + for idx, media_path in enumerate(media_paths, start=1): + if media_path not in names_in_zip: + print(f"WARNING: {media_path} not found in {xlsx_path}", file=sys.stderr) + continue + ext = Path(media_path).suffix.lstrip(".").lower() or "bin" + if ext == "jpg": + ext = "jpeg" + out_name = f"{safe_name}_img{idx:03d}.{ext}" + img_dir.mkdir(parents=True, exist_ok=True) + (img_dir / out_name).write_bytes(z.read(media_path)) + files.append(out_name) + if files: + written[sheet_name] = files + return written + + +def insert_sheet_images(markdown_text: str, sheet_images) -> str: + """Insert a '#### Images in this sheet' block right after each sheet's + section (before the next '## ' heading or end of text). If a sheet has + no images, or no '## ' headings are found at all, the text is returned + unchanged for that part.""" + if not sheet_images: + return markdown_text + matches = list(_SHEET_HEADER_RE.finditer(markdown_text)) + if not matches: + return markdown_text + + pieces = [] + last_end = 0 + for i, m in enumerate(matches): + sheet_name = m.group(1).removesuffix("\r") + start = m.start() + end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown_text) + pieces.append(markdown_text[last_end:start]) + section = markdown_text[start:end].rstrip("\n") + images = sheet_images.get(sheet_name) + if images: + section += "\n\n#### Images in this sheet\n\n" + section += "\n".join(f"![{name}](img/{name})" for name in images) + pieces.append(section + "\n\n") + last_end = end + pieces.append(markdown_text[last_end:]) + return "".join(pieces).rstrip() + "\n" + + +def convert_one(md, source: Path, dest_dir: Path) -> bool: + """Convert a single .xlsx file to a '/' folder containing the + Markdown file and an 'img/' folder of extracted images. Returns True on + success.""" + try: + result = md.convert(str(source)) + except Exception as exc: # noqa: BLE001 - surface any conversion error + print(f"FAILED {source} -> {exc}", file=sys.stderr) + return False + + try: + img_dir = dest_dir / "img" + if dest_dir.exists(): + if img_dir.exists(): + shutil.rmtree(img_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + sheet_images = extract_images(source, img_dir) + text = insert_sheet_images(result.text_content, sheet_images) + md_path = dest_dir / f"{source.stem}.md" + md_path.write_text(text, encoding="utf-8") + except OSError as exc: + print(f"FAILED {source} -> could not write output in {dest_dir}: {exc}", file=sys.stderr) + return False + + img_count = sum(len(v) for v in sheet_images.values()) + img_note = f", {img_count} image(s)" if img_count else "" + print(f"OK {source} -> {md_path}{img_note}") + return True + + +def find_xlsx_files(root: Path, recursive: bool): + """Return (xlsx_files, skipped_count) for files directly/recursively under root.""" + pattern_iter = root.rglob("*") if recursive else root.iterdir() + xlsx_files = [] + skipped = 0 + for entry in pattern_iter: + if entry.is_dir(): + continue + if entry.suffix.lower() == ".xlsx": + xlsx_files.append(entry) + else: + skipped += 1 + return sorted(xlsx_files), skipped + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("input", help="Path to a .xlsx file or a directory of .xlsx files") + parser.add_argument( + "-o", "--output", + help=( + "Destination folder for the '/' output (single-file mode), " + "or parent directory under which each '/' output folder is " + "created (batch mode)" + ), + ) + parser.add_argument( + "--recursive", action="store_true", + help="When input is a directory, also search subdirectories", + ) + args = parser.parse_args() + + #MarkItDown = _import_markitdown() + #md = MarkItDown() + + source = Path(args.input) + if not source.exists(): + print(f"ERROR: Input path not found: {source}", file=sys.stderr) + return EXIT_INVALID_INPUT + + if source.is_file() and source.suffix.lower() != ".xlsx": + print( + f"ERROR: Unsupported file type '{source.suffix}'. " + "This skill only converts .xlsx files.", + file=sys.stderr, + ) + return EXIT_INVALID_INPUT + + MarkItDown = _import_markitdown() + md = MarkItDown() + + if source.is_file(): + dest_dir = Path(args.output) if args.output else source.parent / source.stem + return EXIT_OK if convert_one(md, source, dest_dir) else EXIT_CONVERSION_FAILED + + # Directory / batch mode + xlsx_files, skipped = find_xlsx_files(source, args.recursive) + if skipped: + print(f"NOTE: skipped {skipped} non-.xlsx file(s) in {source}") + if not xlsx_files: + print(f"ERROR: No .xlsx files found under {source}", file=sys.stderr) + return EXIT_INVALID_INPUT + + out_dir = Path(args.output) if args.output else None + success_count = 0 + for xlsx_path in xlsx_files: + if out_dir is not None: + rel = xlsx_path.relative_to(source) + dest_dir = out_dir / rel.parent / xlsx_path.stem + else: + dest_dir = xlsx_path.parent / xlsx_path.stem + if convert_one(md, xlsx_path, dest_dir): + success_count += 1 + + total = len(xlsx_files) + print(f"\nConverted {success_count}/{total} file(s).") + return EXIT_OK if success_count == total else EXIT_CONVERSION_FAILED + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/convert-excel-to-md/scripts/requirements.txt b/skills/convert-excel-to-md/scripts/requirements.txt new file mode 100644 index 00000000..be2934a4 --- /dev/null +++ b/skills/convert-excel-to-md/scripts/requirements.txt @@ -0,0 +1 @@ +markitdown[xlsx]>=0.1.0 diff --git a/skills/convert-pdf-to-md/SKILL.md b/skills/convert-pdf-to-md/SKILL.md new file mode 100644 index 00000000..e9f79ec5 --- /dev/null +++ b/skills/convert-pdf-to-md/SKILL.md @@ -0,0 +1,130 @@ +--- +name: convert-pdf-to-md +description: 'Converts PDF (.pdf) documents into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Use this skill whenever the user shares, references, or asks about a .pdf file — even if they don''t say "convert" or "markdown" explicitly. This includes requests to "read", "summarize", "review", "extract data from", "compare", or "analyze" a PDF report, paper, invoice, form, contract, or scanned document. Always run the bundled conversion script to produce Markdown first; do not attempt to parse PDF content directly or write ad-hoc extraction code. Also use this skill for batch requests involving a whole folder of PDF documents. IMPORTANT: When the user references a folder or set of documents containing multiple file types (.pdf, .docx, .xlsx), invoke ALL three sibling skills — convert-pdf-to-md, convert-word-to-md, and convert-excel-to-md — so no file type is silently skipped.' +--- + +# Convert PDF to Markdown + +## When to use this skill + +Trigger this skill any time there is a `.pdf` file that needs to be +understood or processed — for example, a user attaches a PDF and asks +questions about it, wants a summary, wants specific data or tables pulled +out, or wants multiple PDFs in a folder processed together. PDF is a +layout/print format, not reliably readable as plain text, so always convert +it to Markdown first using the script in this skill rather than trying to +open or parse the file directly. + +This skill only supports `.pdf` — that's MarkItDown's only PDF-family +format, so there's no legacy format to worry about here (unlike Word's +`.doc` or Excel's `.xls`). + +**Mixed file types:** When the user references a folder or set of documents +containing multiple supported file types (`.pdf`, `.docx`, `.xlsx`), this +skill handles only `.pdf` files. The agent MUST also invoke the sibling +skills in parallel: +- `convert-word-to-md` for any `.docx` files +- `convert-excel-to-md` for any `.xlsx` files + +Never process a folder and silently skip a supported file type. All three +skills must be invoked together when mixed types are present. + +## Setup (once per environment) + +Before the first conversion in a given environment, follow +[`references/setup.md`](references/setup.md) step by step to ensure Python, +pip, `markitdown`, and `pymupdf` (for image extraction) are installed. Do +this proactively rather than guessing whether the environment is ready — the +script itself will also fail with a clear pointer back to that file if a +dependency turns out to be missing, so it's safe to just try the conversion +first if you're reasonably confident setup was already done. + +## Usage + +The conversion script lives at `scripts/convert_pdf_to_md.py`. + +**Output structure:** MarkItDown's PDF converter extracts text and tables +only — it has no concept of embedded images at all. This script separately +extracts real embedded images via PyMuPDF and writes a self-contained folder +per document: + +``` +/ + img/ + page001_img001. + page002_img001. + ... + .md +``` + +Because MarkItDown's PDF text does not preserve reliable per-page markers, +there's no safe way to know exactly where inline an image belongs. Rather +than risk misplacing images next to the wrong paragraph, the script appends +a `## Extracted Images` section at the end of the Markdown, with a +`### Page N` subheading per page that has images — read this section +separately from the main body text. If the document has no embedded images, +no `img/` folder or `Extracted Images` section is created. + +**Single file:** + +```powershell +python scripts\convert_pdf_to_md.py "C:\path\to\document.pdf" +``` + +This creates a `document\` folder next to the source file (containing +`document.md` and, if present, `document\img\`). To control the destination +folder explicitly: + +```powershell +python scripts\convert_pdf_to_md.py "C:\path\to\document.pdf" -o "C:\path\to\output_folder" +``` + +**A folder of PDFs (batch mode):** + +```powershell +python scripts\convert_pdf_to_md.py "C:\path\to\folder" +``` + +Add `--recursive` to also include subfolders: + +```powershell +python scripts\convert_pdf_to_md.py "C:\path\to\folder" --recursive +``` + +Each `.pdf` found gets its own `\` output folder next to it by +default. Pass `-o "C:\path\to\output_parent"` to collect all the generated +`\` folders under a separate parent directory instead (subfolder +structure is preserved when combined with `--recursive`). + +After conversion, read the resulting `.md` file(s) to perform the actual +analysis the user asked for — the script's job is only to produce accurate +Markdown (and images), not to interpret the content. + +## Deciding where output goes + +**Default — always output next to the source file.** The `/` folder +is created in the same directory as the source `.pdf`. This is the required +default for every case. Do NOT override it unless the user explicitly asks +for a different location. + +**Only use `-o` when** the user explicitly provides an output path (e.g., +"save the output to `C:\output`", "put the results in `D:\work`"). Do NOT +pass `-o` based on the agent's current working directory, the session state +folder, or any implied location. + +**If the source file path cannot be fully resolved** — for example, the +user provides only a filename with no directory, or the path is ambiguous — +use `ask_user` to confirm the full absolute path before running the +conversion. Never guess or assume the directory. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `ModuleNotFoundError: No module named 'markitdown'` or `'fitz'` / exit code 2 | MarkItDown or PyMuPDF not installed | Follow `references/setup.md` | +| `ERROR: Unsupported file type '...'` / exit code 3 | Not a `.pdf` file | Ask the user for the correct file, or if it's `.doc`/`.docx`/`.xlsx`, use the matching sibling skill instead | +| `ERROR: Input path not found` / exit code 3 | Wrong path, or file moved | Confirm the correct path with the user | +| `FAILED -> ...` in batch output | That specific file is corrupt, password-protected, or otherwise unreadable | Report which file(s) failed; other files in the batch still succeed | +| `NOTE: skipped N non-.pdf file(s)` | Folder contains non-PDF files | Expected — those files are intentionally ignored | +| Markdown body is empty or near-empty despite images being extracted | The PDF is scanned/image-only with no embedded text layer; MarkItDown does not perform OCR | Tell the user OCR isn't supported — the extracted page images are still available for them to view | +| Images appear in an appendix instead of inline with the text | Deliberate limitation — MarkItDown's PDF text has no reliable per-page markers to place images inline | Expected behavior; cross-reference the `### Page N` heading with the surrounding text context if needed | diff --git a/skills/convert-pdf-to-md/references/setup.md b/skills/convert-pdf-to-md/references/setup.md new file mode 100644 index 00000000..4c8a95e6 --- /dev/null +++ b/skills/convert-pdf-to-md/references/setup.md @@ -0,0 +1,71 @@ +# Environment Setup for convert-pdf-to-md + +Follow these steps exactly, in order, before running `scripts/convert_pdf_to_md.py` +for the first time in a given environment. Don't skip steps or improvise +alternatives — they're written to be deterministic and safe to re-run. + +## 1. Check Python is available (3.10+) + +```powershell +python --version +``` + +- If this fails (command not found), install Python 3.10 or newer: + - Windows: `winget install --id Python.Python.3.12 -e` + - macOS: `brew install python@3.12` + - Linux (Debian/Ubuntu): `sudo apt-get update && sudo apt-get install -y python3 python3-pip python-is-python3` +- If the reported version is older than 3.10, install a newer Python using + the same command above (MarkItDown requires 3.10+). + +## 2. Check pip is available + +```powershell +python -m pip --version +``` + +- If this fails, bootstrap pip: + +```powershell +python -m ensurepip --upgrade +``` + +## 3. Install MarkItDown with PDF support, plus PyMuPDF for image extraction + +Use the `scripts/requirements.txt` file bundled with this skill to install pinned, +known-good versions of the dependencies: + +```powershell +python -m pip install -r scripts/requirements.txt +``` + +This pulls in `markitdown[pdf]` and `pymupdf>=1.24.0`. PyMuPDF (imported as `fitz`) +is required separately because MarkItDown's PDF +converter only extracts text and tables — it has no support for embedded +images at all, so this skill's script extracts them itself. + +## 4. Verify the install + +```powershell +python -c "from markitdown import MarkItDown; import fitz; print('markitdown + pymupdf OK')" +``` + +Expect to see `markitdown + pymupdf OK` printed with no errors. If you see a +`ModuleNotFoundError`, repeat step 3 — pip may be installing into a +different Python environment than the one being invoked (check +`python -m pip --version` shows the same path as `python --version`'s +interpreter). + +## Notes + +- This setup only needs to be done once per environment/virtual environment, + not once per conversion. +- `convert_pdf_to_md.py` itself also checks for `markitdown` and `fitz` at + startup and prints a pointer back to this file if either is missing, so + re-running setup is safe and idempotent. +- Only `.pdf` is supported by this skill — it's MarkItDown's only PDF-family + format, so there's no legacy-format equivalent to worry about (unlike + Word's `.doc` or Excel's `.xls`). +- Scanned/image-only PDFs (no embedded text layer) will produce little or + no text from MarkItDown, since it does not perform OCR. The images + themselves will still be extracted and appended, but the text body may be + empty or near-empty in that case — mention this to the user if it happens. diff --git a/skills/convert-pdf-to-md/scripts/convert_pdf_to_md.py b/skills/convert-pdf-to-md/scripts/convert_pdf_to_md.py new file mode 100644 index 00000000..8beb5b9f --- /dev/null +++ b/skills/convert-pdf-to-md/scripts/convert_pdf_to_md.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Convert PDF documents to Markdown using Microsoft's MarkItDown, with +embedded images extracted to real files via PyMuPDF (MarkItDown's PDF +converter only extracts text/tables -- it does not detect or emit anything +for embedded images at all). + +Usage: + python convert_pdf_to_md.py [-o OUTPUT] [--recursive] + + may be either: + - a path to a single .pdf file, or + - a path to a directory (batch mode: every .pdf file directly inside it + is converted; pass --recursive to also descend into subdirectories). + +Output: + For each source .pdf (named ".pdf"), a folder is created containing + the Markdown and its images, in this layout: + + / + img/ + page001_img001. + page001_img002. + page002_img001. + ... + .md + + IMPORTANT: MarkItDown's PDF text extraction does not preserve reliable + per-page markers in the returned Markdown (pages are simply joined + together, or in some cases returned as a single unmarked block of text). + That means there is no safe way to know exactly where, inline, an image + should go. Rather than guess and risk misplacing an image next to the + wrong paragraph, this script appends a clearly labeled "## Extracted + Images" section at the end of the Markdown, with a "### Page N" + subheading per page that contains images. This is a deliberate, honest + tradeoff -- read the images section separately from the main body text. + + - Single file mode: the "/" folder is created next to the source + file, or at -o/--output (treated as the exact destination folder) if + given. + - Batch/directory mode: a "/" folder is created next to each source + file, or under -o/--output (treated as a parent directory, created if + missing) if given, preserving relative subfolder structure when + --recursive is used. + - If a document has no embedded images, no "img/" folder or "Extracted + Images" section is created. + +Exit codes: + 0 - all requested conversions succeeded + 1 - one or more conversions failed (partial success in batch mode) + 2 - a required dependency ("markitdown" or "pymupdf") is not installed + 3 - invalid input (path not found, or single-file input is not .pdf) +""" +import argparse +import sys +import hashlib +import shutil +from pathlib import Path + +EXIT_OK = 0 +EXIT_CONVERSION_FAILED = 1 +EXIT_MISSING_DEPENDENCY = 2 +EXIT_INVALID_INPUT = 3 + + +def _import_markitdown(): + """Import MarkItDown, failing with a clear, actionable message if absent.""" + try: + from markitdown import MarkItDown + return MarkItDown + except ImportError: + print( + "ERROR: The 'markitdown' package is not installed.\n" + "See references/setup.md for this skill, or run:\n" + ' pip install "markitdown[pdf]"', + file=sys.stderr, + ) + sys.exit(EXIT_MISSING_DEPENDENCY) + + +def _import_fitz(): + """Import PyMuPDF (module name 'fitz'), failing with a clear message if absent.""" + try: + import fitz + import hashlib + return fitz + except ImportError: + print( + "ERROR: The 'pymupdf' package is not installed (needed for image " + "extraction).\nSee references/setup.md for this skill, or run:\n" + " pip install pymupdf", + file=sys.stderr, + ) + sys.exit(EXIT_MISSING_DEPENDENCY) + + +def extract_images(fitz, pdf_path: Path, img_dir: Path): + """Extract embedded images from pdf_path, grouped by 1-based page number. + Returns {page_num: [filename, ...]} in per-page image order. Files are + named 'page{P:03d}_img{N:03d}.'. Corrupt/unreadable images are + skipped with a warning rather than aborting the whole conversion. + + Two sources are combined and deduplicated: + 1. Image XObjects via page.get_images(full=True) -- covers most embedded + images in modern PDFs. + 2. Inline image blocks via page.get_text("dict") -- covers images stored + directly in the page content stream, which get_images() misses entirely. + Deduplication is by image bytes hash so the same raster is never written twice + on the same page regardless of which source reported it.""" + written_by_page = {} + try: + doc = fitz.open(str(pdf_path)) + except Exception as exc: # noqa: BLE001 + print(f"WARNING: could not open {pdf_path} for image extraction: {exc}", file=sys.stderr) + return written_by_page + + try: + for page_index in range(len(doc)): + page = doc[page_index] + page_label = page_index + 1 + seen_hashes: set = set() + raw_images: list[tuple[bytes, str]] = [] # (image_bytes, ext) + + # --- Source 1: XObject images --- + try: + xobjects = page.get_images(full=True) + except Exception as exc: # noqa: BLE001 + print( + f"WARNING: failed to enumerate XObject images on page {page_label} " + f"of {pdf_path}: {exc}", + file=sys.stderr, + ) + xobjects = [] + + for img in xobjects: + xref = img[0] + try: + base_image = doc.extract_image(xref) + except Exception as exc: # noqa: BLE001 + print( + f"WARNING: failed to extract XObject image xref={xref} on page " + f"{page_label} of {pdf_path}: {exc}", + file=sys.stderr, + ) + continue + img_bytes = base_image.get("image") or b"" + if not img_bytes: + continue + ext = (base_image.get("ext") or "png").lower() + raw_images.append((img_bytes, ext)) + + # --- Source 2: Inline images via get_text("dict") --- + try: + blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_IMAGES).get("blocks", []) + except Exception as exc: # noqa: BLE001 + print( + f"WARNING: failed to extract text/image dict on page {page_label} " + f"of {pdf_path}: {exc}", + file=sys.stderr, + ) + blocks = [] + + for block in blocks: + # Image blocks have type == 1 + if block.get("type") != 1: + continue + img_bytes = block.get("image") or b"" + if not img_bytes: + continue + # Derive extension from the block's "ext" key (fitz sets this) + ext = (block.get("ext") or "png").lower() + raw_images.append((img_bytes, ext)) + + # --- Write deduplicated images --- + page_files = [] + img_idx = 1 + for img_bytes, ext in raw_images: + h = hashlib.sha256(img_bytes).digest() + if h in seen_hashes: + continue + seen_hashes.add(h) + out_name = f"page{page_label:03d}_img{img_idx:03d}.{ext}" + img_dir.mkdir(parents=True, exist_ok=True) + (img_dir / out_name).write_bytes(img_bytes) + page_files.append(out_name) + img_idx += 1 + + if page_files: + written_by_page[page_label] = page_files + finally: + doc.close() + + return written_by_page + + +def build_image_appendix(written_by_page) -> str: + """Build the '## Extracted Images' appendix text. Returns "" if empty.""" + if not written_by_page: + return "" + lines = ["", "## Extracted Images", ""] + for page_num in sorted(written_by_page): + lines.append(f"### Page {page_num}") + lines.append("") + for name in written_by_page[page_num]: + lines.append(f"![{name}](img/{name})") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def convert_one(md, fitz, source: Path, dest_dir: Path) -> bool: + """Convert a single .pdf file to a '/' folder containing the + Markdown file and an 'img/' folder of extracted images. Returns True on + success.""" + try: + result = md.convert(str(source)) + except Exception as exc: # noqa: BLE001 - surface any conversion error + print(f"FAILED {source} -> {exc}", file=sys.stderr) + return False + + try: + if dest_dir.exists(): + shutil.rmtree(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + written_by_page = extract_images(fitz, source, dest_dir / "img") + appendix = build_image_appendix(written_by_page) + text = result.text_content.rstrip("\n") + full_text = f"{text}\n{appendix}" if appendix else f"{text}\n" + md_path = dest_dir / f"{source.stem}.md" + md_path.write_text(full_text, encoding="utf-8") + except OSError as exc: + print(f"FAILED {source} -> could not write output in {dest_dir}: {exc}", file=sys.stderr) + return False + + img_count = sum(len(v) for v in written_by_page.values()) + img_note = f", {img_count} image(s)" if img_count else "" + print(f"OK {source} -> {md_path}{img_note}") + return True + + +def find_pdf_files(root: Path, recursive: bool): + """Return (pdf_files, skipped_count) for files directly/recursively under root.""" + pattern_iter = root.rglob("*") if recursive else root.iterdir() + pdf_files = [] + skipped = 0 + for entry in pattern_iter: + if entry.is_dir(): + continue + if entry.suffix.lower() == ".pdf": + pdf_files.append(entry) + else: + skipped += 1 + return sorted(pdf_files), skipped + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("input", help="Path to a .pdf file or a directory of .pdf files") + parser.add_argument( + "-o", "--output", + help=( + "Destination folder for the '/' output (single-file mode), " + "or parent directory under which each '/' output folder is " + "created (batch mode)" + ), + ) + parser.add_argument( + "--recursive", action="store_true", + help="When input is a directory, also search subdirectories", + ) + args = parser.parse_args() + + #MarkItDown = _import_markitdown() + #fitz = _import_fitz() + #md = MarkItDown() + + source = Path(args.input) + if not source.exists(): + print(f"ERROR: Input path not found: {source}", file=sys.stderr) + return EXIT_INVALID_INPUT + + if source.is_file() and source.suffix.lower() != ".pdf": + print( + f"ERROR: Unsupported file type '{source.suffix}'. " + "This skill only converts .pdf files.", + file=sys.stderr, + ) + return EXIT_INVALID_INPUT + + MarkItDown = _import_markitdown() + fitz = _import_fitz() + md = MarkItDown() + + if source.is_file(): + dest_dir = Path(args.output) if args.output else source.parent / source.stem + return EXIT_OK if convert_one(md, fitz, source, dest_dir) else EXIT_CONVERSION_FAILED + + # Directory / batch mode + pdf_files, skipped = find_pdf_files(source, args.recursive) + if skipped: + print(f"NOTE: skipped {skipped} non-.pdf file(s) in {source}") + if not pdf_files: + print(f"ERROR: No .pdf files found under {source}", file=sys.stderr) + return EXIT_INVALID_INPUT + + out_dir = Path(args.output) if args.output else None + success_count = 0 + for pdf_path in pdf_files: + if out_dir is not None: + rel = pdf_path.relative_to(source) + dest_dir = out_dir / rel.parent / pdf_path.stem + else: + dest_dir = pdf_path.parent / pdf_path.stem + if convert_one(md, fitz, pdf_path, dest_dir): + success_count += 1 + + total = len(pdf_files) + print(f"\nConverted {success_count}/{total} file(s).") + return EXIT_OK if success_count == total else EXIT_CONVERSION_FAILED + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/convert-pdf-to-md/scripts/requirements.txt b/skills/convert-pdf-to-md/scripts/requirements.txt new file mode 100644 index 00000000..ae70f025 --- /dev/null +++ b/skills/convert-pdf-to-md/scripts/requirements.txt @@ -0,0 +1,2 @@ +markitdown[pdf]>=0.1.0 +pymupdf>=1.24.0 diff --git a/skills/convert-word-to-md/SKILL.md b/skills/convert-word-to-md/SKILL.md new file mode 100644 index 00000000..482778ea --- /dev/null +++ b/skills/convert-word-to-md/SKILL.md @@ -0,0 +1,129 @@ +--- +name: convert-word-to-md +description: 'Converts Word (.docx) documents into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Use this skill whenever the user shares, references, or asks about a .docx file — even if they don''t say "convert" or "markdown" explicitly. This includes requests to "read", "summarize", "review", "extract data from", "compare", or "analyze" a Word document, resume, report, contract, or proposal. Always run the bundled conversion script to produce Markdown first; do not attempt to parse .docx content directly or write ad-hoc conversion code. Also use this skill for batch requests involving a whole folder of Word documents. IMPORTANT: When the user references a folder or set of documents containing multiple file types (.pdf, .docx, .xlsx), invoke ALL three sibling skills — convert-pdf-to-md, convert-word-to-md, and convert-excel-to-md — so no file type is silently skipped.' +--- + +# Convert Word to Markdown + +## When to use this skill + +Trigger this skill any time there is a `.docx` file that needs to be +understood or processed — for example, a user attaches a Word document and +asks questions about it, wants a summary, wants specific data pulled out, or +wants multiple Word documents in a folder processed together. Word's native +`.docx` format is a zipped XML bundle that is not reliably readable as plain +text, so always convert it to Markdown first using the script in this +skill rather than trying to open or parse the file directly. + +This skill only supports `.docx`. If asked to convert a legacy `.doc` file, +tell the user it isn't supported and ask them to re-save it as `.docx` +(Word: File > Save As > Word Document (.docx)) first. + +**Mixed file types:** When the user references a folder or set of documents +containing multiple supported file types (`.pdf`, `.docx`, `.xlsx`), this +skill handles only `.docx` files. The agent MUST also invoke the sibling +skills in parallel: +- `convert-pdf-to-md` for any `.pdf` files +- `convert-excel-to-md` for any `.xlsx` files + +Never process a folder and silently skip a supported file type. All three +skills must be invoked together when mixed types are present. + +## Setup (once per environment) + +Before the first conversion in a given environment, follow +[`references/setup.md`](references/setup.md) step by step to ensure Python, +pip, and the `markitdown` package are installed. Do this proactively rather +than guessing whether the environment is ready — the script itself will +also fail with a clear pointer back to that file if `markitdown` turns out +to be missing, so it's safe to just try the conversion first if you're +reasonably confident setup was already done. + +## Usage + +The conversion script lives at `scripts/convert_word_to_md.py`. + +**Output structure:** MarkItDown embeds images as a truncated `data:image/png;base64...` URI +placeholder (not real image data), so the script +extracts real images directly from the `.docx` and writes a self-contained +folder per document instead of a single loose `.md` file: + +``` +/ + img/ + img001. + img002. + ... + .md (image references are relative: img/imgNNN.ext) +``` + +If the document has no embedded images, no `img/` folder is created. + +**Single file:** + +```powershell +# Windows +python scripts\convert_word_to_md.py "C:\path\to\document.docx" +``` + +```bash +# macOS / Linux +python scripts/convert_word_to_md.py "/path/to/document.docx" +``` + +This creates a `document\` folder next to the source file (containing +`document.md` and, if present, `document\img\`). To control the destination +folder explicitly: + +```powershell +python scripts\convert_word_to_md.py "C:\path\to\document.docx" -o "C:\path\to\output_folder" +``` + +**A folder of Word documents (batch mode):** + +```powershell +python scripts\convert_word_to_md.py "C:\path\to\folder" +``` + +Add `--recursive` to also include subfolders: + +```powershell +python scripts\convert_word_to_md.py "C:\path\to\folder" --recursive +``` + +Each `.docx` found gets its own `\` output folder next to it by +default. Pass `-o "C:\path\to\output_parent"` to collect all the generated +`\` folders under a separate parent directory instead (subfolder +structure is preserved when combined with `--recursive`). + +After conversion, read the resulting `.md` file(s) to perform the actual +analysis the user asked for — the script's job is only to produce accurate +Markdown (and images), not to interpret the content. + +## Deciding where output goes + +**Default — always output next to the source file.** The `/` folder +is created in the same directory as the source `.docx`. This is the required +default for every case. Do NOT override it unless the user explicitly asks +for a different location. + +**Only use `-o` when** the user explicitly provides an output path (e.g., +"save the output to `C:\output`", "put the results in `D:\work`"). Do NOT +pass `-o` based on the agent's current working directory, the session state +folder, or any implied location. + +**If the source file path cannot be fully resolved** — for example, the +user provides only a filename with no directory, or the path is ambiguous — +use `ask_user` to confirm the full absolute path before running the +conversion. Never guess or assume the directory. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `ModuleNotFoundError: No module named 'markitdown'` / exit code 2 | MarkItDown not installed | Follow `references/setup.md` | +| `ERROR: Unsupported file type '.doc'` / exit code 3 | Legacy `.doc`, not `.docx` | Ask the user to re-save as `.docx` | +| `ERROR: Input path not found` / exit code 3 | Wrong path, or file moved | Confirm the correct path with the user | +| `FAILED -> ...` in batch output | That specific file is corrupt, password-protected, or otherwise unreadable | Report which file(s) failed; other files in the batch still succeed | +| `NOTE: skipped N non-.docx file(s)` | Folder contains non-Word files | Expected — those files are intentionally ignored | +| `WARNING: found N image placeholder(s) ... but extracted M image file(s)` | Mismatch between MarkItDown's placeholder count and images found in `word/media/` (unusual/malformed docx) | Placeholders are left unreplaced rather than risk wrong images; inspect the source file's media manually if images are needed | diff --git a/skills/convert-word-to-md/references/setup.md b/skills/convert-word-to-md/references/setup.md new file mode 100644 index 00000000..c8fac5ea --- /dev/null +++ b/skills/convert-word-to-md/references/setup.md @@ -0,0 +1,66 @@ +# Environment Setup for convert-word-to-md + +Follow these steps exactly, in order, before running `scripts/convert_word_to_md.py` +for the first time in a given environment. Don't skip steps or improvise +alternatives — they're written to be deterministic and safe to re-run. + +## 1. Check Python is available (3.10+) + +```powershell +python --version +``` + +- If this fails (command not found), install Python 3.10 or newer: + - Windows: `winget install --id Python.Python.3.12 -e` + - macOS: `brew install python@3.12` + - Linux (Debian/Ubuntu): `sudo apt-get update && sudo apt-get install -y python3 python3-pip python-is-python3` +- If the reported version is older than 3.10, install a newer Python using + the same command above (MarkItDown requires 3.10+). + +## 2. Check pip is available + +```powershell +python -m pip --version +``` + +- If this fails, bootstrap pip: + +```powershell +python -m ensurepip --upgrade +``` + +## 3. Install MarkItDown with Word (.docx) support + +Use the `scripts/requirements.txt` file bundled with this skill to install a pinned, +known-good version of the dependency: + +```powershell +python -m pip install -r scripts/requirements.txt +``` + +This pulls in `markitdown[docx]` (MarkItDown's Word conversion dependency, which +includes `mammoth` for `.docx` file parsing). No extra package is needed — this +skill's script uses MarkItDown's built-in Word converter. + +## 4. Verify the install + +```powershell +python -c "from markitdown import MarkItDown; print('markitdown OK')" +``` + +Expect to see `markitdown OK` printed with no errors. If you see +`ModuleNotFoundError: No module named 'markitdown'`, repeat step 3 — pip may +be installing into a different Python environment than the one being +invoked (check `python -m pip --version` shows the same path as `python +--version`'s interpreter). + +## Notes + +- This setup only needs to be done once per environment/virtual environment, + not once per conversion. +- `convert_word_to_md.py` itself also checks for `markitdown` at startup and + prints a pointer back to this file if it's missing, so re-running setup is + safe and idempotent. +- Only `.docx` is supported by this skill. Legacy binary `.doc` files are + out of scope — ask the user to re-save the file as `.docx` (e.g., via + Word's "Save As") if one is encountered. diff --git a/skills/convert-word-to-md/scripts/convert_word_to_md.py b/skills/convert-word-to-md/scripts/convert_word_to_md.py new file mode 100644 index 00000000..4723fa63 --- /dev/null +++ b/skills/convert-word-to-md/scripts/convert_word_to_md.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Convert Word (.docx) documents to Markdown using Microsoft's MarkItDown, +with embedded images extracted to real files (MarkItDown only emits a +truncated `data:image/...;base64...` placeholder, not real image data). + +Usage: + python convert_word_to_md.py [-o OUTPUT] [--recursive] + + may be either: + - a path to a single .docx file, or + - a path to a directory (batch mode: every .docx file directly inside it + is converted; pass --recursive to also descend into subdirectories). + +Output: + For each source .docx (named ".docx"), a folder is created + containing the Markdown and its images, in this layout: + + / + img/ + img001. + img002. + ... + .md (image references are relative: img/imgNNN.ext) + + - Single file mode: the "/" folder is created next to the source + file, or at -o/--output (treated as the exact destination folder) if + given. + - Batch/directory mode: a "/" folder is created next to each source + file, or under -o/--output (treated as a parent directory, created if + missing) if given, preserving relative subfolder structure when + --recursive is used. + - If a document has no embedded images, no "img/" folder is created. + +Exit codes: + 0 - all requested conversions succeeded + 1 - one or more conversions failed (partial success in batch mode) + 2 - required dependency ("markitdown") is not installed + 3 - invalid input (path not found, or single-file input is not .docx) +""" +import argparse +import re +import shutil +import sys +import zipfile +from pathlib import Path +from xml.etree import ElementTree as ET + +EXIT_OK = 0 +EXIT_CONVERSION_FAILED = 1 +EXIT_MISSING_DEPENDENCY = 2 +EXIT_INVALID_INPUT = 3 + +_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +_R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" + +# MarkItDown embeds images as a literal truncated placeholder, e.g. +# ![alt](data:image/png;base64...) -- NOT real base64 data. This pattern +# matches that placeholder so it can be swapped for a real relative path. +_PLACEHOLDER_IMAGE_RE = re.compile( + r'!\[([^\]]*)\]\(data:image/[a-zA-Z0-9.+-]+;base64[^)]*\)' +) + + +def _import_markitdown(): + """Import MarkItDown, failing with a clear, actionable message if absent.""" + try: + from markitdown import MarkItDown + return MarkItDown + except ImportError: + print( + "ERROR: The 'markitdown' package is not installed.\n" + "See references/setup.md for this skill, or run:\n" + ' pip install "markitdown[docx]"', + file=sys.stderr, + ) + sys.exit(EXIT_MISSING_DEPENDENCY) + + +def _document_order_media(docx_path: Path): + """Return [(rel_id, media_zip_path), ...] in the order images appear in + word/document.xml (via r:embed / r:id), resolved through + word/_rels/document.xml.rels. Returns [] if the document has no body + part or no images (e.g. malformed docx falls back gracefully).""" + try: + with zipfile.ZipFile(docx_path) as z: + if "word/document.xml" not in z.namelist() or \ + "word/_rels/document.xml.rels" not in z.namelist(): + return [] + rels_xml = z.read("word/_rels/document.xml.rels") + doc_xml = z.read("word/document.xml") + except (zipfile.BadZipFile, KeyError, OSError): + return [] + + try: + rels_root = ET.fromstring(rels_xml) + doc_root = ET.fromstring(doc_xml) + except ET.ParseError: + return [] + + rel_map = {} + for rel in rels_root.findall(f"{{{_REL_NS}}}Relationship"): + rel_map[rel.get("Id")] = rel.get("Target") + + ordered_rel_ids = [] + for elem in doc_root.iter(): + tag = elem.tag.rsplit("}", 1)[-1] + if tag == "blip": + rid = elem.get(f"{{{_R_NS}}}embed") + elif tag == "imagedata": + rid = elem.get(f"{{{_R_NS}}}id") + else: + rid = None + if rid: + ordered_rel_ids.append(rid) + ordered_media = [] + for rid in ordered_rel_ids: + target = rel_map.get(rid) + if not target or "media/" not in target: + continue + import posixpath + media_path = ( + target.lstrip("/") + if target.startswith("/") + else posixpath.normpath( + target if target.startswith("word/") else posixpath.join("word", target) + ) + ) + ordered_media.append((rid, media_path)) + return ordered_media + + +def _extract_images(docx_path: Path, img_dir: Path): + """Extract embedded images from docx_path into img_dir as img001.ext, + img002.ext, ... in document order. Returns the list of written filenames + (relative to img_dir), in that same order.""" + ordered_media = _document_order_media(docx_path) + if not ordered_media: + return [] + + written = [] + with zipfile.ZipFile(docx_path) as z: + names_in_zip = set(z.namelist()) + for idx, (rid, media_path) in enumerate(ordered_media, start=1): + if media_path not in names_in_zip: + print(f"WARNING: {media_path} (rel {rid}) not found in {docx_path}", file=sys.stderr) + continue + ext = Path(media_path).suffix.lstrip(".").lower() or "bin" + if ext == "jpg": + ext = "jpeg" + out_name = f"img{idx:03d}.{ext}" + img_dir.mkdir(parents=True, exist_ok=True) + (img_dir / out_name).write_bytes(z.read(media_path)) + written.append(out_name) + return written + + +def _rewrite_image_refs(markdown_text: str, image_files) -> str: + """Replace MarkItDown's truncated base64 image placeholders with real + relative img/imgNNN.ext references, in left-to-right order. If the + counts don't match (unexpected), the placeholders are left as-is rather + than risk mismatched references.""" + matches = list(_PLACEHOLDER_IMAGE_RE.finditer(markdown_text)) + if not matches: + return markdown_text + if len(matches) != len(image_files): + print( + f"WARNING: found {len(matches)} image placeholder(s) in markdown but " + f"extracted {len(image_files)} image file(s); leaving placeholders " + "unreplaced to avoid mismatched references.", + file=sys.stderr, + ) + return markdown_text + + counter = {"i": 0} + + def _replace(m): + name = image_files[counter["i"]] + counter["i"] += 1 + return f"![{m.group(1)}](img/{name})" + + return _PLACEHOLDER_IMAGE_RE.sub(_replace, markdown_text) + + +def convert_one(md, source: Path, dest_dir: Path) -> bool: + """Convert a single .docx file to a "/" folder containing the + Markdown file and an "img/" folder of extracted images. Returns True on + success.""" + try: + result = md.convert(str(source)) + except ImportError as exc: + print( + f"ERROR: A required dependency for converting '{source.name}' is not installed.\n" + f" {exc}\n" + "See references/setup.md for this skill, or run:\n" + ' pip install "markitdown[docx]"', + file=sys.stderr, + ) + sys.exit(EXIT_MISSING_DEPENDENCY) + except Exception as exc: # noqa: BLE001 - surface any conversion error + print(f"FAILED {source} -> {exc}", file=sys.stderr) + return False + + try: + if dest_dir.exists(): + shutil.rmtree(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + image_files = _extract_images(source, dest_dir / "img") + text = _rewrite_image_refs(result.text_content, image_files) + md_path = dest_dir / f"{source.stem}.md" + md_path.write_text(text, encoding="utf-8") + except OSError as exc: + print(f"FAILED {source} -> could not write output in {dest_dir}: {exc}", file=sys.stderr) + return False + + img_note = f", {len(image_files)} image(s)" if image_files else "" + print(f"OK {source} -> {md_path}{img_note}") + return True + + +def find_docx_files(root: Path, recursive: bool): + """Return (docx_files, skipped_count) for files directly/recursively under root.""" + pattern_iter = root.rglob("*") if recursive else root.iterdir() + docx_files = [] + skipped = 0 + for entry in pattern_iter: + if entry.is_dir(): + continue + if entry.suffix.lower() == ".docx": + docx_files.append(entry) + else: + skipped += 1 + return sorted(docx_files), skipped + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("input", help="Path to a .docx file or a directory of .docx files") + parser.add_argument( + "-o", "--output", + help=( + "Destination folder for the '/' output (single-file mode), " + "or parent directory under which each '/' output folder is " + "created (batch mode)" + ), + ) + parser.add_argument( + "--recursive", action="store_true", + help="When input is a directory, also search subdirectories", + ) + args = parser.parse_args() + + #MarkItDown = _import_markitdown() + #md = MarkItDown() + + source = Path(args.input) + if not source.exists(): + print(f"ERROR: Input path not found: {source}", file=sys.stderr) + return EXIT_INVALID_INPUT + + if source.is_file() and source.suffix.lower() != ".docx": + print( + f"ERROR: Unsupported file type '{source.suffix}'. " + "This skill only converts .docx files.", + file=sys.stderr, + ) + return EXIT_INVALID_INPUT + + MarkItDown = _import_markitdown() + md = MarkItDown() + + if source.is_file(): + dest_dir = Path(args.output) if args.output else source.parent / source.stem + return EXIT_OK if convert_one(md, source, dest_dir) else EXIT_CONVERSION_FAILED + + # Directory / batch mode + docx_files, skipped = find_docx_files(source, args.recursive) + if skipped: + print(f"NOTE: skipped {skipped} non-.docx file(s) in {source}") + if not docx_files: + print(f"ERROR: No .docx files found under {source}", file=sys.stderr) + return EXIT_INVALID_INPUT + + out_dir = Path(args.output) if args.output else None + success_count = 0 + for docx_path in docx_files: + if out_dir is not None: + rel = docx_path.relative_to(source) + dest_dir = out_dir / rel.parent / docx_path.stem + else: + dest_dir = docx_path.parent / docx_path.stem + if convert_one(md, docx_path, dest_dir): + success_count += 1 + + total = len(docx_files) + print(f"\nConverted {success_count}/{total} file(s).") + return EXIT_OK if success_count == total else EXIT_CONVERSION_FAILED + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/convert-word-to-md/scripts/requirements.txt b/skills/convert-word-to-md/scripts/requirements.txt new file mode 100644 index 00000000..47a07c70 --- /dev/null +++ b/skills/convert-word-to-md/scripts/requirements.txt @@ -0,0 +1 @@ +markitdown[docx]>=0.1.0 From 191309165e54ae1aab978fbdc01c727bce511694 Mon Sep 17 00:00:00 2001 From: "Alex Yang [MSFT]" Date: Wed, 15 Jul 2026 17:29:48 -0700 Subject: [PATCH 03/46] =?UTF-8?q?Add=20Azure=20Connector=20Namespaces=20ca?= =?UTF-8?q?nvas=20extension=20=F0=9F=A4=96=F0=9F=A4=96=F0=9F=A4=96=20(#225?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add MCP Connectors (connector-namespaces) canvas extension A Copilot CLI canvas extension for browsing and adding MCP connectors from an Azure Connector Namespace into a Copilot session. Sign-in is dependency-free (OAuth 2.0 auth-code + PKCE via the Azure CLI public client, loopback redirect); network access is restricted to the public Azure Resource Manager endpoint. MIT licensed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sanitize connector icon brand colors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Connector Namespace playground actions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Replace sandbox skill with native tool Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Clarify connector disconnect action Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Color disconnect action red Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Add connector extension plugin manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Address connector canvas review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Remove legacy connector canvas manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Address remaining connector review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Harden connector review fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Harden connector convergence Address the latest connector review batch across config persistence, executable trust, reauthentication, JSON-RPC transport, smoke safety, and accessibility. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Use native HTTP connector configs Persist Connector Namespace MCP servers as direct HTTPS entries with API-key headers, remove the stdio unwrap proxy, and exercise the native Streamable HTTP path in smoke coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Secure persisted connector state Create the Connector Namespace artifacts directory and saved gateway config with private permissions, and align the reduced-motion regression notes with the static fallback behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf --------- Co-authored-by: Alex Yang Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.github/plugin/plugin.json | 19 + extensions/connector-namespaces/LICENSE | 21 + extensions/connector-namespaces/README.md | 85 + extensions/connector-namespaces/armClient.mjs | 444 +++++ .../assets/preview-connected.png | Bin 0 -> 22914 bytes .../connector-namespaces/assets/preview.png | Bin 0 -> 32685 bytes extensions/connector-namespaces/catalog.mjs | 70 + .../connector-namespaces/categories.mjs | 15 + .../copilot-extension.json | 4 + .../connector-namespaces/createPage.mjs | 403 +++++ extensions/connector-namespaces/extension.mjs | 101 ++ extensions/connector-namespaces/install.mjs | 1007 +++++++++++ .../install.reauth.test.mjs | 558 ++++++ .../connector-namespaces/install.test.mjs | 253 +++ .../mcp-http-probe.test.mjs | 58 + extensions/connector-namespaces/package.json | 19 + .../connector-namespaces/preview/.gitignore | 2 + .../connector-namespaces/preview/README.md | 112 ++ .../connector-namespaces/preview/fixtures.mjs | 117 ++ .../connector-namespaces/preview/server.mjs | 151 ++ .../connector-namespaces/preview/shots.mjs | 96 + extensions/connector-namespaces/renderer.mjs | 1563 +++++++++++++++++ .../connector-namespaces/renderer.test.mjs | 393 +++++ .../review-fixes.test.mjs | 139 ++ extensions/connector-namespaces/sandbox.mjs | 49 + .../connector-namespaces/sandbox.test.mjs | 54 + extensions/connector-namespaces/server.mjs | 616 +++++++ .../connector-namespaces/server.test.mjs | 215 +++ extensions/connector-namespaces/state.mjs | 115 ++ .../connector-namespaces/state.test.mjs | 32 + .../connector-namespaces/test/README.md | 128 ++ .../connector-namespaces/test/mcp-probe.mjs | 173 ++ .../connector-namespaces/test/safe-tools.mjs | 168 ++ .../test/safe-tools.test.mjs | 150 ++ .../connector-namespaces/test/smoke.mjs | 328 ++++ 35 files changed, 7658 insertions(+) create mode 100644 extensions/connector-namespaces/.github/plugin/plugin.json create mode 100644 extensions/connector-namespaces/LICENSE create mode 100644 extensions/connector-namespaces/README.md create mode 100644 extensions/connector-namespaces/armClient.mjs create mode 100644 extensions/connector-namespaces/assets/preview-connected.png create mode 100644 extensions/connector-namespaces/assets/preview.png create mode 100644 extensions/connector-namespaces/catalog.mjs create mode 100644 extensions/connector-namespaces/categories.mjs create mode 100644 extensions/connector-namespaces/copilot-extension.json create mode 100644 extensions/connector-namespaces/createPage.mjs create mode 100644 extensions/connector-namespaces/extension.mjs create mode 100644 extensions/connector-namespaces/install.mjs create mode 100644 extensions/connector-namespaces/install.reauth.test.mjs create mode 100644 extensions/connector-namespaces/install.test.mjs create mode 100644 extensions/connector-namespaces/mcp-http-probe.test.mjs create mode 100644 extensions/connector-namespaces/package.json create mode 100644 extensions/connector-namespaces/preview/.gitignore create mode 100644 extensions/connector-namespaces/preview/README.md create mode 100644 extensions/connector-namespaces/preview/fixtures.mjs create mode 100644 extensions/connector-namespaces/preview/server.mjs create mode 100644 extensions/connector-namespaces/preview/shots.mjs create mode 100644 extensions/connector-namespaces/renderer.mjs create mode 100644 extensions/connector-namespaces/renderer.test.mjs create mode 100644 extensions/connector-namespaces/review-fixes.test.mjs create mode 100644 extensions/connector-namespaces/sandbox.mjs create mode 100644 extensions/connector-namespaces/sandbox.test.mjs create mode 100644 extensions/connector-namespaces/server.mjs create mode 100644 extensions/connector-namespaces/server.test.mjs create mode 100644 extensions/connector-namespaces/state.mjs create mode 100644 extensions/connector-namespaces/state.test.mjs create mode 100644 extensions/connector-namespaces/test/README.md create mode 100644 extensions/connector-namespaces/test/mcp-probe.mjs create mode 100644 extensions/connector-namespaces/test/safe-tools.mjs create mode 100644 extensions/connector-namespaces/test/safe-tools.test.mjs create mode 100644 extensions/connector-namespaces/test/smoke.mjs diff --git a/extensions/connector-namespaces/.github/plugin/plugin.json b/extensions/connector-namespaces/.github/plugin/plugin.json new file mode 100644 index 00000000..18b8c116 --- /dev/null +++ b/extensions/connector-namespaces/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "connector-namespaces", + "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", + "version": "1.1.0", + "author": { + "name": "Alex Yang", + "url": "https://github.com/alexyaang" + }, + "keywords": [ + "azure", + "connector-namespace", + "mcp", + "mcp-connectors", + "model-context-protocol", + "tool-discovery" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/connector-namespaces/LICENSE b/extensions/connector-namespaces/LICENSE new file mode 100644 index 00000000..22aed37e --- /dev/null +++ b/extensions/connector-namespaces/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/README.md new file mode 100644 index 00000000..f7346247 --- /dev/null +++ b/extensions/connector-namespaces/README.md @@ -0,0 +1,85 @@ +# MCP Connectors — Copilot CLI Canvas Extension + +A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP +connectors from an Azure **Connector Namespace** directly inside a Copilot CLI +session. Search by name or category, sign in to a connector, then restart the +session to make its tools available to the agent. + +> The canvas talks to public Azure Resource Manager (`management.azure.com`) +> using the signed-in Azure CLI account. The extension does not register its own +> Entra application or persist Azure credentials. + +## Prerequisites + +- **GitHub Copilot CLI** (the host that loads canvas extensions). +- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a + short-lived ARM access token and refreshes it through the same broker. +- **An Azure subscription with a Connector Namespace** — resource type + `Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is + a preview resource provider; you must have access to it for the catalog to + load. Without it the extension installs fine but has nothing to show. + +## Install + +Install it from the public Awesome Copilot repository: + +``` +install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces +``` + +For a reproducible install, swap `main` for a reviewed commit SHA from this +repository. + +The destination **scope** is chosen at install time: + +- **user** (default) — installs globally for you at + `$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a + personal tool. +- **project** — installs into the current repo. +- **session** — scoped to a single CLI session. + +## Usage + +1. Open the **MCP Connectors** canvas from Copilot CLI. +2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an + Azure **subscription** and a **Connector Namespace**. The choice is saved for + future sessions (change it any time via **Change namespace**). +3. Browse or filter the connector catalog, then **Connect**. A browser tab + opens for Microsoft sign-in; complete it and the canvas updates on its own. +4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open + that server directly in the namespace MCP playground. +5. Restart the Copilot CLI session so the agent can load the connected tools. + +The extension registers the native `connector_namespaces_open_playground` tool, +so GitHub Copilot can open a named connector from **My MCPs** without installing +an additional Agent Skill. + +## How it works + +- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and + native `connector_namespaces_open_playground` tool. +- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves + the canvas UI and the JSON/OAuth endpoints the iframe calls. +- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM + base only, SSRF-guarded path segments). +- `catalog.mjs` — fetches and curates the connector list for a namespace. +- `install.mjs` — the connect/install pipeline (managed-API connection, consent, + rollback on cancel, and native HTTPS MCP config registration). +- `renderer.mjs` — all canvas HTML/CSS/client JS. +- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs. +- `state.mjs` — saved namespace and connector state. + +## Privacy & security + +- ARM tokens come from `az account get-access-token`, stay in process memory, + and are never logged or written by the extension. Azure CLI owns sign-in and + credential storage. +- All servers bind to loopback (`127.0.0.1`) and are never exposed externally. +- ARM requests go only to `https://management.azure.com/`; path segments are + validated to prevent SSRF-style host smuggling. +- The minted gateway API key is stored in the selected Copilot MCP config and + sent to its validated HTTPS endpoint as the `X-API-Key` header. + +## License + +[MIT](./LICENSE) © Microsoft Corporation. diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/armClient.mjs new file mode 100644 index 00000000..dc0f9fc4 --- /dev/null +++ b/extensions/connector-namespaces/armClient.mjs @@ -0,0 +1,444 @@ +// ARM API client — fetches real connector data with Azure CLI credentials. + +import { exec, execFile } from "node:child_process"; +import { constants as fsConstants, promises as fs } from "node:fs"; +import { homedir, platform } from "node:os"; +import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { promisify } from "node:util"; + +const API_VERSION = "2026-05-01-preview"; +const RG_API_VERSION = "2021-04-01"; +const MSI_API_VERSION = "2023-01-31"; +const SUBS_API_VERSION = "2020-01-01"; + +const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); +const ARM_RESOURCE = "https://management.azure.com/"; +const EXPIRY_SKEW_MS = 5 * 60 * 1000; +const LEGACY_AUTH_CACHE = join( + process.env.COPILOT_HOME || join(homedir(), ".copilot"), + "extensions", + "connector-namespaces", + "artifacts", + "auth-cache.json", +); + +let s_auth = null; // { token, expiresAt } +let s_authInFlight = null; +let s_legacyAuthCacheRemoved = false; + +export function parseAzureCliToken(stdout) { + let data; + try { + data = JSON.parse(stdout); + } catch { + throw new Error("Azure CLI returned invalid token JSON."); + } + const token = data?.accessToken; + const epochSeconds = Number(data?.expires_on); + const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0 + ? epochSeconds * 1000 + : Date.parse(data?.expiresOn); + if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) { + throw new Error("Azure CLI returned an incomplete ARM token."); + } + return { token, expiresAt }; +} + +async function removeLegacyAuthCache() { + if (s_legacyAuthCacheRemoved) return; + try { + await fs.unlink(LEGACY_AUTH_CACHE); + } catch (error) { + if (error?.code !== "ENOENT") { + throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`); + } + } + s_legacyAuthCacheRemoved = true; +} + +function windowsSystemExecutable(name) { + const systemRoot = process.env.SystemRoot; + if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name); + throw new Error(`Could not resolve the Windows system executable ${name}.`); +} + +async function trustedExecutablePath(path, expectedName, workspaceRoot = process.cwd()) { + if (!isAbsolute(path) || /["\r\n]/.test(path)) return null; + let candidate; + let workspace; + try { + [candidate, workspace] = await Promise.all([ + fs.realpath(path), + fs.realpath(workspaceRoot), + ]); + if (!(await fs.stat(candidate)).isFile()) return null; + if (platform() !== "win32") await fs.access(candidate, fsConstants.X_OK); + } catch { + return null; + } + const insensitive = platform() === "win32"; + const normalize = (value) => insensitive ? value.toLowerCase() : value; + const normalizedCandidate = normalize(resolve(candidate)); + const normalizedWorkspace = normalize(resolve(workspace)); + const workspacePrefix = normalizedWorkspace.endsWith(sep) + ? normalizedWorkspace + : `${normalizedWorkspace}${sep}`; + if ( + normalize(basename(candidate)) !== normalize(expectedName) || + normalizedCandidate === normalizedWorkspace || + normalizedCandidate.startsWith(workspacePrefix) + ) return null; + return candidate; +} + +async function resolveWindowsAzureCli() { + const trustedCwd = homedir(); + const { stdout } = await execFileAsync( + windowsSystemExecutable("where.exe"), + ["az.cmd"], + { cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, + ); + for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) { + if (/[%]/.test(path)) continue; + const candidate = await trustedExecutablePath(path, "az.cmd"); + if (candidate) return candidate; + } + throw new Error("Azure CLI was not found outside the current workspace."); +} + +export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) { + for (const directory of pathValue.split(delimiter)) { + const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot); + if (candidate) return candidate; + } + throw new Error("Azure CLI was not found outside the current workspace."); +} + +export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) { + const candidates = platform() === "win32" + ? [windowsSystemExecutable(name)] + : [join("/usr/bin", name), join("/bin", name), join("/usr/local/bin", name)]; + for (const path of candidates) { + const candidate = await trustedExecutablePath(path, name, workspaceRoot); + if (candidate) return candidate; + } + throw new Error(`Could not resolve the trusted system executable ${name}.`); +} + +async function acquireToken() { + await removeLegacyAuthCache(); + try { + const windows = platform() === "win32"; + const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli(); + const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 }; + const { stdout } = windows + ? await execAsync( + `"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`, + { ...options, shell: windowsSystemExecutable("cmd.exe") }, + ) + : await execFileAsync( + azureCli, + ["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"], + options, + ); + s_auth = parseAzureCliToken(stdout); + return s_auth.token; + } catch (error) { + const detail = String(error?.stderr || error?.message || "").trim(); + throw new Error( + `Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`, + ); + } +} + +export async function getToken() { + if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token; + if (s_authInFlight) return s_authInFlight; + s_authInFlight = acquireToken().finally(() => { + s_authInFlight = null; + }); + return s_authInFlight; +} + +/** + * List all enabled Azure subscriptions the user has access to. + */ +// The set of enabled subscriptions is stable for a session, so cache it — the +// first /setup pays the ARM round-trip once and every "Change namespace" +// afterwards serves from memory. +let s_subsCache = null; // { subs, expiresAt } +const SUBS_TTL_MS = 30 * 60 * 1000; + +export async function listSubscriptions() { + const now = Date.now(); + if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs; + const token = await getToken(); + const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`; + const raw = await paginateAll(url, token); + const subs = raw + .filter((s) => s.state === "Enabled") + .map((s) => ({ id: s.subscriptionId, name: s.displayName, tenantId: s.tenantId, state: s.state })); + s_subsCache = { subs, expiresAt: now + SUBS_TTL_MS }; + return subs; +} + +// ARM resource identifiers are a restricted charset (letters, digits and a few +// punctuation chars). Validating each path segment against this allowlist before +// it enters a URL rejects anything containing "/", "?", "#", "@" or ":" — the +// characters that could otherwise alter the request path or redirect the host — +// and acts as a taint barrier so config/file-derived names cannot reach fetch +// unvalidated. +const ARM_SEGMENT = /^[A-Za-z0-9._()-]{1,256}$/; + +export function armSegment(value) { + const s = String(value); + if (s === "." || s === ".." || !ARM_SEGMENT.test(s)) { + throw new Error(`Invalid ARM resource identifier: ${s}`); + } + return s; +} + +function buildBaseUrl(subscriptionId, resourceGroup, gatewayName) { + return `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourceGroups/${armSegment(resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(gatewayName)}`; +} + +// Hard host allowlist: every request this client makes targets ARM and only +// ARM. The trailing slash matters — it blocks suffix/userinfo bypasses such as +// "https://management.azure.com.evil.com/" and "https://management.azure.com@evil.com/", +// neither of which starts with this exact prefix. This guards the paginated +// nextLink (a server-supplied value) which does not pass through armSegment. +const ARM_BASE = "https://management.azure.com/"; + +// Returns the URL only if it targets ARM, otherwise throws. Used by callers +// (e.g. install.mjs) that build ARM URLs before handing them here. +export function assertArmHost(rawUrl) { + const url = String(rawUrl); + if (!url.startsWith(ARM_BASE)) { + throw new Error(`Refusing to call non-ARM URL: ${url}`); + } + return url; +} + +async function armFetch(url, token) { + // Guard the exact value handed to fetch so a tainted path segment or a + // server-supplied nextLink can never redirect the call off ARM. + if (!url.startsWith(ARM_BASE)) { + throw new Error(`Refusing to call non-ARM URL: ${url}`); + } + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`ARM ${res.status}: ${body.slice(0, 300)}`); + } + return res.json(); +} + +// ARM normally returns distinct nextLink URLs that terminate, but a buggy or +// hostile endpoint could return a repeating/self-referential nextLink. Guard +// against an unbounded loop with a seen-set and a hard page cap. +const MAX_PAGES = 1000; + +async function paginateAll(url, token) { + const results = []; + const seen = new Set(); + let nextUrl = url; + let pages = 0; + while (nextUrl) { + if (seen.has(nextUrl) || pages >= MAX_PAGES) break; + seen.add(nextUrl); + pages++; + const data = await armFetch(nextUrl, token); + if (data.value) results.push(...data.value); + nextUrl = data.nextLink || null; + } + return results; +} + +/** + * List connector gateways in a subscription. + * Uses $top=10 and stops after the first page for speed. + * Pass fetchAll=true to paginate through everything. + */ +export async function listConnectorGateways(subscriptionId, { fetchAll = false } = {}) { + const token = await getToken(); + const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.Web/connectorGateways?api-version=${API_VERSION}&$top=10`; + if (fetchAll) return { items: await paginateAll(url, token), hasMore: false }; + // First page only — much faster + const data = await armFetch(url, token); + const items = data.value || []; + return { items, hasMore: !!data.nextLink }; +} + +/** + * List managed APIs (traditional connectors) + */ +export async function listManagedApis(subscriptionId, resourceGroup, gatewayName) { + const token = await getToken(); + const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedApis?api-version=${API_VERSION}`; + return paginateAll(url, token); +} + +/** + * List managed hosted MCP servers + */ +export async function listManagedHostedMcpServers(subscriptionId, resourceGroup, gatewayName) { + const token = await getToken(); + const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedHostedMcpServers?api-version=${API_VERSION}`; + return paginateAll(url, token); +} + +/** + * List managed MCP operations + */ +export async function listManagedMcpOperations(subscriptionId, resourceGroup, gatewayName) { + const token = await getToken(); + const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedMcpOperations?api-version=${API_VERSION}`; + return paginateAll(url, token); +} + +// --------------------------------------------------------------------------- +// Create connector namespace (provisioning flow) +// --------------------------------------------------------------------------- + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Write helper (PUT/PATCH/DELETE) that mirrors armFetch's host guard but keeps +// the parsed error body so callers can surface ARM's message verbatim. +async function armWrite(method, url, body, extraHeaders = {}) { + if (!url.startsWith(ARM_BASE)) { + throw new Error(`Refusing to call non-ARM URL: ${url}`); + } + const token = await getToken(); + const headers = { Authorization: `Bearer ${token}` }; + Object.assign(headers, extraHeaders); + if (body !== undefined) headers["Content-Type"] = "application/json"; + const res = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let parsed; + try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; } + if (!res.ok) { + const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`; + const err = new Error(`ARM ${method} ${res.status}: ${String(msg).slice(0, 300)}`); + err.status = res.status; + throw err; + } + return parsed; +} + +/** + * List resource groups in a subscription (sorted by name). + */ +export async function listResourceGroups(subscriptionId) { + const token = await getToken(); + const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups?api-version=${RG_API_VERSION}`; + const items = await paginateAll(url, token); + return items + .map((rg) => ({ name: rg.name, location: rg.location })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Create a resource group without updating an existing group on a name race. + */ +export async function createResourceGroup(subscriptionId, name, location) { + const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups/${armSegment(name)}?api-version=${RG_API_VERSION}`; + return armWrite("PUT", url, { location }, { "If-None-Match": "*" }); +} + +/** + * List user-assigned managed identities across a subscription (sorted by name). + */ +export async function listUserAssignedIdentities(subscriptionId) { + const token = await getToken(); + const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=${MSI_API_VERSION}`; + const items = await paginateAll(url, token); + return items + .map((id) => { + const parts = String(id.id).split("/"); + const rgIdx = parts.findIndex((p) => p.toLowerCase() === "resourcegroups"); + return { + id: id.id, + name: id.name, + resourceGroup: rgIdx >= 0 ? parts[rgIdx + 1] || "" : "", + location: id.location || "", + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Check whether a connector namespace name is free in the given resource group. + * Returns true when available (ARM 404), false when taken (200). Uses fetch + * directly so the 404 isn't thrown the way armFetch would. + */ +export async function checkConnectorGatewayNameAvailable(subscriptionId, resourceGroup, gatewayName) { + const token = await getToken(); + const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + if (res.status === 404) return true; + if (res.ok) return false; + const body = await res.text(); + throw new Error(`ARM ${res.status}: ${body.slice(0, 200)}`); +} + +// ARM `identity` block — mirrors the portal's buildIdentityPayload so the PUT +// body is always explicit ({ type: "None" } when nothing is configured). +export function buildGatewayIdentity(enableSystem, userAssignedIds = []) { + const hasUser = userAssignedIds.length > 0; + const type = enableSystem && hasUser + ? "SystemAssigned,UserAssigned" + : enableSystem + ? "SystemAssigned" + : hasUser + ? "UserAssigned" + : "None"; + const identity = { type }; + if (hasUser) { + identity.userAssignedIdentities = Object.fromEntries(userAssignedIds.map((id) => [id, {}])); + } + return identity; +} + +export async function waitForProvisioning(initialResult, gatewayName, fetchLatest, { + maxPolls = 60, + delay = () => sleep(3000), +} = {}) { + let result = initialResult; + let state; + for (let poll = 0; poll <= maxPolls; poll++) { + state = result?.properties?.provisioningState; + if (state === "Succeeded") return result; + if (state === "Failed" || state === "Canceled") { + throw new Error(`Provisioning ${state} for "${gatewayName}".`); + } + if (poll === maxPolls) break; + await delay(); + result = await fetchLatest(); + } + throw new Error(`Provisioning timed out for "${gatewayName}" (last state: ${state ?? "unknown"}).`); +} + +/** + * Create a connector namespace and poll until the + * provisioningState reaches a terminal value. Throws on Failed/Canceled. + * Returns the final resource object. + */ +export async function createConnectorGateway(subscriptionId, resourceGroup, gatewayName, { location, identity }) { + const token = await getToken(); + const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`; + const body = { location, properties: {}, identity }; + const result = await armWrite("PUT", url, body, { "If-None-Match": "*" }); + // ~3 min ceiling (60 * 3s). A 202 may have no body, so every state other + // than explicit Succeeded enters the polling path. + return waitForProvisioning(result, gatewayName, () => armFetch(url, token)); +} diff --git a/extensions/connector-namespaces/assets/preview-connected.png b/extensions/connector-namespaces/assets/preview-connected.png new file mode 100644 index 0000000000000000000000000000000000000000..4050d8787eb92152429a831f3f0ca06176ff74d1 GIT binary patch literal 22914 zcmd43bx@nl|2CQ$lmexcV#OXxfwq+5ZY@r+;x5H4xF$&Z6e;d*1&S01l;9SixVr`t zECiQ8uplS>{@&lrnR(~D=ggTo@0|R(?7h49-n*aAwbykm;h)qLNFP0a1ONa?l@w() z0f4(>0KmQAhj;NMgeLJh0KgM~lI#a<->lswGGp!8uV=S>+?`Cx;djX&Jk9GC<^L*6 z#rjoMVBD-=D}rq&H{hj#)|zB&QUBA0Qq?kNihzaW8lJ0!(&jQ%pmdW%291+ULh^k} zW~{p6-71Thl~fB7K7k}wKBUKm*f?>GAFcZ(oJ&_fK@4Q^}q z0e~+b1M%Z}&vTa$0QeNseg^Y1-Kjf}vTCtHC) z_`<^WqOR_$>P258U0rzt`LjVM@FSy z@|qSOOSN40+Qw2va8NY*<0=SM4UzI%*yecygGxq98TFo&fEY!ut7tg>tA5#uz%V}c z=~UcK)T@jHU!)5-U%ya4xU}*vr!t39wEaktz$xbYcZQ1U8{F*y{ z#1#Mdoz`{Wa>RGv&*&gH?n&?M0NF-BiYl^UX1*ySwk`R(ZwT((47`D&5fJAD<-3-) zm4>;CjSb^gk6s1%g}fCyjz==`i=~9H|Frdl)Wy2T4+>d1*moMkv{KT={V+g2y%w() zB{({22o*oVORHY#UKCxlosX=@3Ch%+-OeU-eCs}*A%d~5E3=sASy|r9-b52W?U|cv z-}aduaVaz3Uv19LOqCQe6XW%>i?wP4Pfff80BE=+N$I0=_a*g=R)mhJn@WDhBX25b zuJ@owDbUKGrpfAEH{|8zB&BL^0rej zG7-ZoiaE|j!2nG<&3x^u=^!gtn^C64gmzA9 z?LN?!No5$$;gjNU!913<`<=Bvop$74PP+};6%f0zZa@EP;}fEZl^E(yh^^=M+sv~&?F$ALGypTzP zjkU1FM^w|(A~!-W%Xfwsz+9<}bSPoT$!{0Ao_4sWEvz zsj31Qsv%YkMWrK3jHupv-*FmU#+$(M06)Z`St&$Ovp@6UFcU4p3~7EbV8MN<$3Sv^ zO!n;t7xZ95V%w);LZ=UDK8_qS|8-n9v6xXEb4wbf)VJAch3oZGp3VF+E#Su!>e{!u zv?mUFJG-voQT)`j%}Mpv>{C5M%lQ}u7nA4Xj^NCR4ai(Zo6GEF@wugK`P`hgh%ITo z%>w{Qoa5Q?33aIxE~i!8*+HwvaTp1%xPX}r-j?6Sd0ceNX0F=8PbJv5+YXy5WJFxV zir;=Oq1~zXZ$rCytuuNF4rmY!YAvNI*s!U)){IUkAB2DPsTtvcPo@d8RQKk5lM(O= zNZE2;HXR#p$x2qPgCKuf>vd$(Wq;!wT^EUzTdEG61))7dh<+iFzgbzvkFar=Gk0zO($QfKkDuY~ zT~VBL3+zgL43)^*K`-+c2PjQjt=xw5!aa2{VmUTj@#~3GQj^~B7wqLt^_)q@SmKQ4 zz`@HHKe~8kd?rb$z6<#BHQ!&c`(np2;aJGB`LL2R@fKSHF|9n%R2p?~Pc4t~x8M5~ zzR;p!x%(5mj!#UwnQ*pu>lOV<-dBGz1)YC;^+d$HQ1y}%F&HgrRI*1DlqE9wG(U`L zvhAJ$FP=I`&YhMBJ1&jv#n8EjWZG2DiGt<8;q?nKttGr32_#Ry7WvIz97D6u_L>%` zpV0_*G-mHG(vPUdw$;8?z=z0BNraETbu3?P)s7b^faB>ziHM0koH;m-@_mo^$W#ZF z&5|3>_k-U_sRf;k!NuV6R1T)YO_k;FY=KyC!xs9>8&f`X-UL>iOF0_YMpxo(-rblf z7AhVw%c<|`dQob<)nB!*aHE8Dp-L5ZX@ zxUKu;kq#{YaG8JA3%41`^4o#bN0fP-AFc=?{k8rJu!ztL2;AdV)G#sO1OVE-24|(z z$p8RYXvNa3#T`KVEWy`%0KhASWjvM{AQ#3z<9`1!0PutUe<~gbQkMJv{WaNe>$K*1 zrb!q)Wsw)1cSvTsV8r37y2Ohn)G9EJ9nOVq2g)t#g*%)@5(3&O4?ir&4Q<5z@^BWp z@&eZG2Y_&;v|kTYbE@S@1c3(91W^ZSuYKenb220RG1JIh_l?-^uRT=ArH#XmxE%&8 zV~3u~Nd@M;2LLK~m`HzRy}jUnY5y_!9P}tQ%IWrK;luV}mBU(z>z+ne(!oH@$_!ki z*=N>kftK_=7Jh-sDQB+KrGgECZ6UQ^H+Q$KnyWd=G ziRu||PZ5E$(3p=|UJbKjRVKYO9~5*<9Jh6oo2O7lx`pZy367aDZMC40fqE8n1u{*9 zWkzGs{|b_&pa(Ndtg=Q%D5ZQOyvMGZ0I6^EM-M#cr5==n#<`uc=e~rw!LtMm<&N3p zL;{Inm&+qw$~mv8ewEc#stbu|_8(=}4;!$H>mX1`pt;$m)xM}`qm&|hh1c5=Giu-y zot12(9t9mHc%jMD$>G&?u~xz;djpZJJe}m3?=&*=MbpjEv2U|;X+A%hS+K0_Sc|62 z)OF!FXj0l-ms37a|4w7`gftm!I`_NTP|A5`f>o=B&YJOeM(o}a)bETa(|&xxT4^KU zx`XKTXM)d?(16a3RZuK3+(9LRhD}yx??z9sMRVp=jVQG$Emx$$FzW+KH@<|L3iphj)qn*_05J*bIr^xA@cfU8hKTu3-(2Gg5eXv#`CaYWiKBG3R%`lv40W6_Z+MC6@y63M7P-#1lY9Bz~9gERN$seaor&~uePeM~(66iUN zB2MGe8tk_2#)4^uZmXhRcE<0GZlutnxs)D}Gixyh`Gd5vIkwejQwqcSO;(o^a>?q> zwW9AjH+wkMA<*Fgkht|mRKu!XaB&EwVYC~2-PJm^^^2AG+;;{IzjynA%*sr;j9Nww zs9zGb_R44>7o%3CK4Fc@5YlltqYRcE8g%l97M@g|fI2-v^j~xp?Q=@FHwx zu9?##GfSUgKjYOWlTACE!_M3M%d~>LNyeB7UAf?}n)>((;J|2lv08xL$82!oX6c80 z%s2H!m}A6~+IF&Mldr!wr-VJYhO*PAvwy?ZP@svTVX7^;%l?;Gj47KZN9%m5e3a> z73s$GH)S>{ zM)72EE|?f|=02m=9g2#boDl^*rVlfG4aZ@gX{BN3PKUeM*Z&1x%+SOq{RGxpo)JYp zK~*07O1pAZNl@DLq1Wi&LAl%a6M1};-n3)hmf(D5PxSc@P2D;&(KyVKo|CP2P?W3% z14>`CINO&f8tH}aF#6<oL)noKAG5h_JNN|hU24I|iyBCnl8u;U0$;wL9PS>} zVAv!j#lMl6ADH7A|IzavrpR6@#HPY^cBlctl6u5eIC~FF%4V4 zM*8Wk1d+>ywQz>{vX8~$+8b}~4; zfGYhTY3cpwB+6Ewska;p3F)Grr7uf|)=P@k3JoijhVvE$lo_vqzYKzV+0M!^&SHau z{h(Jnt!pp1-K9%$kLJzB*Jgtq&QkQ9uPfHN2LoRXLOjrmFIu$TH9vTfz^hpxE!{Jm zk#2sXU6fxA{j7g=Pg$!sBv`f(A;qeel%#9OGLbU$ocbxfMN<%Hk^4A4;n9i&M85Mp zJ(4YSQ_fX;dm=>3>P$f{`MaVKC`j7wd%4GqRXWo!@B00)=o#sFJ#dY|0CAnf?ZO0s<=zY}^enH!6FtBR#W8oTePCMf+w@)71w)U03j6coysDu2-I?BRXNtbT zGLJViWGtpn)i*>WV}#NNeQYYp!-9(kTDg5JJGS|I0pA;&?2ezi$V9k$Ns6GG`>bB5 zm%zoI`@Oe%x{~`=AI_has&I@RHxaZ zoxvvhr~8#E%IdiJ$^z5D#j!KSV)4^5!X8IHd5CSS6S5~s13>es$!)DCJg`)gJ{u(GIgQDJ<2uB)De0YZoQ;9K>%Rs++Xp5OjIGy z+{qU~-Bp@jzCaJ>Mm!uVV;262=gtV|Lbd-F5bgg0x&5a&OC68jiYG(D!U*xd2M=gT z2<~*3>+YZiQ|ajFcDA>7y}@mDC<~K2YUlsB2~gkt@&bnAOr~>gVCLn8*45QDHX6XF z=bQxZbf17)tFh1dY*bWLheL>|TNdLYy{v^-zI9QWJp^o@zjBSD7jf_UPOhV)b9YwJ z)3c`A;%Fl~HB#Uoe5K$seAAjYm|?Y7Y*MWu!NG@tAYl6N1|CZf5~`}Iwmw_4Abo}( z!wM<{@FN4GRN!u9cuIL3C`#jhflAfT(7;5TXn%FH&HW%L**{g$J) z>+nPV8S$NVLVwU2*TGUCZqL02TRkdCLF0dO#B}SnJ6DDIU}}o!Ph@w2U2L43c+Q)d z7#K8qpw;d?Sl?=0ixESB4ZjK{s-L^+Cc2=K5vBH9nZvr!SKQW%73*yJv}`R+ zJ#;RMRhA0ssN|f(vK4Xei_to6&-8jd2ryGhr|bIL8j|5MysJxcWxWS`<-Q#A#^t6xG^dpN!7}^L4Zbn)eD@T3T9`_K+Rl z2(2kLtA5Xzes394L8qsxdX6k?ejOrwK*vjIs%<70f#~k(*+8S~rLG6fZrl%A&rY@V zu|#d|MRHm)%Eq^zA*p(hNx@qdfN>>(^78pnY|yTkftF`U;|nBq-{3bev%QuDmw$7S zdwX%#cH2sCM9X6l9QJe)I$;g2YHD?hC*tPfu-&KuqGYAGmRvhYEqkKkO+M}^OWs}` zfB*i_?+=%0%Umr#JAL~PenqtFNvVHO$S!$*>Go#1EAGkmOsgcPBhd3^`}%c^mku9; z$qH>7uMc9E@)%iAaVGt9=hasN;JRBY20vFm}>fllQUU;G*=4Qs#5H`scGVp^BkC< z2b~PxyB7Cq1MDp{SG2sEfsZVX`dS1i*=Z(}2(zvB3?>h{C)6bW=`1|_T2oUa0X}U& zW*JGod;2zv*LooL_6q$j1T41lXN^g}P2JZesDID4Xp*V=m?_<{z*@u&^XrOwer9D3 z=4P#=WM^Ew|1uFL*!ZHr$ab>!Vvx}FK}B!-3JvS8fr0*hC7;&DWW8xRih}h=Je)jJ zZ>6}UxPxb*;I{J9)_+LMi9VY^SV1*n0 z`qhF+!RK@{G>)3|Yu2>rZF=qAAlAjavu-+5wpZa>`bczb+eTdwK}^I)QHDmGV3C}O z>5c&I?=;~TWy4+_oQl(M{onv;-8k)l6<=7j3#k=?)<#-wym|#uQO{y~Bx_+8a`maO zwLW5VY(Vp3-xBk?>NP&$uY3DBmJWHq3`DlwYeQQryl{3-6+$9Hu9S%`hc9ZTjzV{R zOOoSBzw*cjYI^Iu5*p9=dZK0`u%OUkjK=w1)cqNt-xHwpT+*m7I572?Ezs*t7yKDc z!9K7;nSQtVncczgX3Lzh*`EKHw}sq?!2T%`TFNrgJ<;mQ#NL8qO4~djO4WctU1fSb=8HAc zX9a^NO@Us$Fst03A}mc|ldQKOxO&~%qlX73gKhqRYXSYcVgBq(_~4ensw{bPzP7r$ zdW!OHZcT*@)T2_$z4*yTx;~duQ{sqEBY*J~r{}M2D}yr-D_b(=QjM^y{D2Y<{~^rB zdNm|QrC0bpST9;n4C8qM+gB4zSNZz&>%OvurKLp5l;-qQ(g+0@E}9FP*qfFO?^uqg zxB&brFVEt)*90%8iTgouNsoP6vt6~+YtPWZj+TNzx1((nAduuIV3rhBFL;K)$KU$f ztI^xHoSYEX;|p}I4~Ly$QfeWRJ3BUqsW6GQLsN1~NQ|}FaM4rp)Kz0kC$PC8zkvKs{uZpjSplgvT-xVG zF;7i7n{K>%q0qNptQ%ibzUEu&d_0zLRToK-xM|1-v`L7HM13@i4$BVz8C92E%ekv` zTY1qwU+b7UM8Btwl8P2W_;z}XBh-ECrG5wIx&j~T^eR!re4jiboHqpqIb_be8C-gq z23)6-Ejy4@h|r@@2v4yj7mn0$u3kt2Lj7_{z}~a7 zXtQ?EYV=>%T>b?nL-YZ!f^9`3bYgdyYJ`ip27BuOUdmk!6e33xe)0&glUIuwuT=pP zE%|7VVK3a9nk3p#l1{i+-UJ z7=ZixN}D~=bnMzqs1&2eOpqXOeH+;5V63Q6Jp*Av8rv)|8N zEH7wE{~XTBusD#9vc8w!8ho<+XM4T#w^I0L%9Vkh@t@6PqiivJF(A zTR+WrGhfz%&-(Y??Orp)Y!L>gQ8#QPk^NfqU5m!cb670)?zi`Lb|tyFW+o=ty|u>W zcY2AKUV_@50Q}!y#6?_DGvcL0Zs90ezM|AKYxGqb|RF#n4`A;jp>-1>PZX&%&9 znWE^;%}wRhcX%u+ARzFcO7{r}2KE8-DuaTCUK@u@BJMvwz2@hia;Oc&PV3>n93E$r zJcrsl+u3o`kaTo(V7`7*x}cB2BjFAwL1KJ2ulj8aPdy%X<3Bcja(EK%0@{DDX4*5U zK-d4y!2c5^n=a~WryYN2aPUKXd)nu#z3`TGG+M}QwJSI{c%?IpxHo!K^7>>lR_bQX zxgOuCs|4NKzi;DrywMMfo|u^6vmNPUEl`M6Q&VG<3cM<-ge@#A;CpZPa>OIZq@-se zQJ65PtCiL6$Pw1wSSBg|^FyiYNh#Hguc943|IG#P+uzUgid#uY^H!J2TtK6AoL`g>r|GRio+m zoj)o*BsUA()4Fr?5I;jC5lRIG^3l9BB-HPWH(?*&kvx2OxS_WzA44CwWn~TSytJl` zehUD6>;5N$e}P!44fbvP;y5)FNl&r*+SV?c>8O)|OpL&Ak*bbsIwe}%uZxCB>S}`A zw(Sf2RW$sc;l9&o@bgBuG8m+kQQn_kjPv^a;+FBOX;U#u0+wGP7GUPzd*rk`ba4bU zYHo9=f6lV1|@3TNJjbwzm^rKLT=6gp;B2}NEph%LZZ#JUN^yoEaz|0wDu zcdk)nTi2)_czAw=V*`gNn>mg)gxGyfP33@li57qT3P4ccLe*7_>WNE)|JeaCfw z0$tHXX;rDBDfV;@*F1Y-K=^Pf6VsL_H)G1#VT3q7MD&L4RrYEW?pO(%P4zlg{o12> zYsO1P0N@4?48{%IT;qD9>FQlqq|vh$UZN(xzURRDCAMavOFyQ^dmaG>y~StoEMbFz z|6V|g^M?zMN9KR--=kkm3d^W5U^exHcvA3Ayc$YQL8RKVyf-XB+-@u$bBG>%E?yGa zJpsK%53g>-GZxJZEVJ!rRPKGfLkVbar+NFeptcqp8+5g{=`OVDyIWR2hvtm~jwoYhAhe7-LGXM7i(|^$vn0B`d4642TJ>DKfz>1I3J%+D}#9fUWTs!ReAN^PK zU2xL9vCJO0*L!h{#t$HHEm~+|6yM8_7nwHm?+0xz7aKkB(O(L|wuQaaD3I?5P(FKx z*Ztd<|54DiLWZi)31+Ik+KBvXL4s|Zr{J6Y|EVA83$%h3zJ&;8 zFBLh~hTBA{RS*Kq`7TFqFv`4Rjc7=~~pMhep$ z-6pnS_=b4yUQ|=cpnI0w-&|!(_SMo*R}B>PL(@aRhFilwyg3#U9e@ZXpIR0m<;rD0vI2nDqH)pWlpM7c_ zEeRcEfb%<<^$aD{*LhgI`(aImq+6$9)K}Rh6rD|!Dcy$m6fOh;3n}o~nS6FU?&C?9 z!P-LZrdDwmUHNtPF8ABOj}8i5&xITfTk+TL2K>LukZ~wPLriQCaNwT!*J4 z72o61?bkvsoGkUzEP;RdJZ_lifdbv58n=fKg%^3=ICaxk`{HjjjEv}bB&{Q0a~dCC zs;EB{bCjf`vT-e+E`FCqYgxj1)iz(5d&HgbnNQELZYB(35O4D_{3-i$EhaxFU5hzx z2)w|vH_?df#Q(&b_Mb~7=X}y~{G7i$FA3|k;w>yHYT}AlZ^J@dh$?kbb@*vi?TKR8 znI|do>0Yw^%GeEUAW6WSG zmwJ|P)vsS$on%r;>WPVos7a~Yi_zQoQ9*AjaATdLMr$aK<&O{vmps8vTemXG zxN1AdYX%E92L*Y#=8JoUnp^&!Ha0!{qdP9vc9V-Xz1NEm=YOpw=&4^?(yUAmVg?2V zmYRJ=GX%;C3*{tLm6YPu)pNw>b4As5Z%W=+H_<<>>gubCi>=FfGAX8;viDjnGc8V7 zBa4QKdgjwn6MES^(S*4=r7u1`4kYqYtFd*CDr>?!n5z0o&zLatfQ0k&M7+{jA@N(6 z(s|xZdQQ!0i7^@5K4raABsfI{97Jd!j7Gn=>O2e@=V}d9mc!=$9p0dihp<(PtIgw! zbM>H0Evd=Sp4H7CiXeYb*YDC+cN&&~091H>MD=T;*OL?SVcDGU8B2u_DwV)AZV#mb z`h zv{_*}C?)o^Hw_^%&0y@e7Izky@-IZX7|;s-uy8tD&>tc{ILXQRsw%=YZ-0`}3z7lD3X8jnkmI)!OZx z(kgutH!?g;f7f8d*dVRBAiq9fyF7I_l>AR6ykr2!C3OQUCN(tjtdsw{PVv6Qing;P zni~4Eqo>&}WP)}zmk;K?b&4TzgM9P%ema&PI|Bb&@+f82>$Ojq3n=!@L|GR=<*$OC z6p<l=p zH8%jXK0>$54ticIh)15w9J8>n&V8nrp&`V#?@{(L3C(e0Z*o&f;o1GQxI_+;`2 zUD2Jy4gY1Z7cbvwzEX|!^^*9mrv^2Q#QtIa9;5pC;2)qE~Ity@^g2$@13aLR{%6f z9@frDs-`su&cg2HHtd4!8|x0YOfW|k#og}*=AZ|Ok8zLcDs$=Pm5=(OI3Ez6wkFZO zu82^fQx)=7rep4?8k}VGb>5JY5b{9qDIudM9u)?z!HRoBP7It@g={2z;KRVt^*e)| zDTG{jd+$Av@MRp#ZEd^euxNY2XV4)-PEo~O5vjU1Z6|7ag3yS3m>!p5lTl?MV{-Jb zt%G=KRMr#5r!+KA+3qQ*j$#9XEIU@M2EmEw@}nBvn4f*H=nCa;VeId2N9suhaAqRD zr3j=jswn=WnTPE6adUD#S;m&zt$%uS0p^D+t|!bQyvF>=8;G+{F^AzrP5H?j9<>ZE zz8*UgMe8KYIY-8ru?&&K*%JdZC%nV>ckjlc=;BPk%kD$6Bjn?XrQ1L3z3O)ATzdZA z@;9RmbWf=flFSxqZNF!9=ofK+9+d66>pwHqqorZs`-3#py86S;u1=zUh4PV~%^`_t z^lPvyUBKO#nb!BWRq?1V`lY^Qc|MXv4>~(bT`YC@C2BwyP(7n?+=|Y>sRNqX2iOg; z^jFE>ru0-w`dyFN5(cMyO?dQ^%diwzDHPQ3SOu^X6d4(f7_83JWpm8U2< zfp~kiwId;s&GdOdlLvksS@ACY>`i+%Qr#`SP85E|Pxwi@D;qm|^D1 zAU8k6-meZy_s+or1$Do-M@DWq$gyS{!_~#@)oOLMExi|#>&7068dVSc91r&6eO*$1 z*ybZhx4}A`?l#$fZv(y&AogRGOU(55eJOWzc}eKL!p;#Z;Jv!%-;@=iM#=Ukk`a*LPJ+rAGN##H73oG!R8z zqYd3Lb6}gKHfG)TC;!a_94&fS^sLSoDMjinW#TsB7Jq^j-Cp_Z8KIc@cvF`wGwN5j zkWCqW1+P~&J&s+#MFz3C7xpt3tRJV*UCTtDNirOiZ$e*-zmm>+z2r!^?_fv9 z%z4vG%gTmrJaRLQhxf(*n`k7s6b|^-KAyVjMNCCMWDIlKm#DHQvvE<+nHp#KEO`!p z;AqusmXg+j%_-^QXGTyp#*VnLey#7Ev?7$zUtaSyPmC)@dGHe#U1=M(vMNxR8@q=3tT*h~UeWezl4jZzus zbp4L0XEM34i1y{-{;)6XE-gRI*C?=+J`dfiYq>s!Gn!==_miQwhyOg|VLKn%l8=Sg z)EUaP8Naz*9)|3BMd$Fgf`4qiGCIQLPeAKzDca&+UjDfv1t4fe+}FR8v&7Jgyr}pd zMNG|QnnX5|Ek@12z{zxiNHIh0SLg@DPTHQQL{K{*M*1^6W``)bQZu6sXFtF()dC~( zw{eA*=azpKv0Qs^=6sghzp7_-((VmW=^+*K1qGkx)XiJ~lXm%mit_iHKKAEC)gwTT zXb*L9UXKNth4%0Q&*39#cxiuGb>9T@=Owa9ClSQpu14%tIb(+7;C_M53sJ8Np&k{I zhY^#w2ZPpV_36m*qN)!af4HzlN8~f5P|!+tH5MK1hNPK1lAu=5ArkV+@GnbUie4z? zvsr7f&->5;9314z=>ERh&m8!N%Ly~LV%l~euqo{=*tgM!Kj@O&Hy9-qTKs3{ndJNu z<<7BiKse*$%ITJC6-0|?;;v{K+%kO~OQ$>fz4YBB@RSZE5Pv))#nUg7Hz9hwJGYU) zyDPN4KqZiy%(Ts5!n~I#FP0n=bq#wmG8E7F&d9+dd-qNHKNT6d)0F}Za3ST@x2^~xYuTU2Z;URGX0E0xUQ-y9_z5Y;~392M>I zE~fmo1W5PvbFhkHasbuFw}aL0X%(^&<)rip8L64F1BT%&{?~p- z-@Lfm%7pk16x@3#r|{ndTEJ6%f{&q*OE`uB`nhdSzel? zToe_4`Fnpz&PWQ~>jx=bJK^<6|P>p zUCw#Ub&_MgFpIvQVsi4oDE_vI>z?JCcf#d9hK9}=lFSar)^Blwq+mbpwu=#b>&UG9 zX-NN=L6qNncTRFL)YYG&7zUWdD0RArs~tiZJ;~1o8JEIjdBNSCMD>4xwUK7 z>?XBsw~_guPbWzHp>c9^(~g$csyCDmC@UuiF>N&@k(^x%ZeEy?x^?Wtu^Zi!Aw~Lm zp0~jmpGfK8MFKCpW%6P~>;SJNP(OQCu3shn>U6eJZLlhNx8FyVfdac1pJ!&;l#pA+ zpE(>Wj_JdH{y=P*nZ((m2QRR%7!gHyf$$RTy!F8K#`526r1#_}LM(z;ZjBgy9ijM> zz{e%8S(8L|^^_$#Jkhy~cYe6_GWhfrOY@|Q?9qCJACwSTzzx^XXfk0iw~BT zmevdBsX-U19UffA?<&w>nwpl$%N4O`g+RuJm#VoK_2FDbi;skW z9B|ezt{OvKkgy;td`F7t8od zcox5#9^dOt%caYIY$$bAy<^`g(tJv>d1r|E0l+vtj4AMZD4kF0qF;TH`eubJNWaEz z{4a4!Wc}jbu-n2q&_zevqT5;;dOrk>y*A-r@)6~=i5z#}{42~&x z)hz3!XsB~}K`Q#@V{Ix8Sh8HEfYnNHC9lWHnQ*{R(0X6|Y6g_>)~w+>gl8hEp>hMC;bWa-IqXgCQ${p;_)`=@bq2pwxGd{z+rbFUZNA~U-= zUVzdF58U1o4%h{?Om4Oxo~jj0Lft1Uv&e@3<2It6jS^4#g}vwc5Sr69F+}>{p6=(M zbSlX3lSYaC`q*i}nEJ}f3SK-zk7QHabC@d8z+Z@53M!Bt-|tFB*w_4xHGyG&h?D)& zGg>ZxM#-_c?LBkq^Q`*=l`|E4qA+3lS&ui|N|j<+79jJD{v9o!O%#otWp502E`nnL zpK0(zi)vqK{deM{m{1N8FhUm>X$|%#oJHsq@D5|_`P6b-8I@4} z^Ri`V8n~BS9pb6Iz6;~pv>Tu&vemMg<%z`r%Y=x|WLfxRHVE`gB2~)j<%V}_h`CgD zTI4pcyhEm? z&ZflQ=(DxjDt}d84X??K`d38~NE}mj?IE*Oqb5YE0Fjjg^VX={=>%z)jMK2--WY>< zXESTER!dbkQckQ5bXo(lQkq>xWnyHKn@jgwh`COnAwtQG!)1^tcZMMAoZ}MW8m+w6 zn@<`EJEaBxv{^aGqu=C+l%L;z^*yx2|DARV1iH*5>YU}rHmpXY;&(354 zG`m(0<49dgzBwi1uymy(WBA`;ecSc$plXH9-xC=Q(ob;spECj(BVB%uvKrrXWW@CY zT=!J^q{_G(w$M=OOM%q;K*iC19tRQmlbH`(QlG^xu^tm(T%gLCSGLcJ9NZXjOoa;2 z*`VhiSX|x)Zha;*nF`ifioY@gJXS!(s=A^ znAS_aH^K%}?X87mRX}iR+Vy`FqY9X0lXUY`oa#in$`B4_KQ_b@zin9hXfnYIip7C_(Rqz4MeA&YYx zAg3=7DwD`D^0N`6YWhfPQrw&L6g{nKaw)kZ(HeD1!Sl)N+1)Rd#3138F-aR{sRi#H z+gEeHUxW5|_3l9GJJLD+hyTQ}kO5*}!r|gcBGorgBUg4@BSJ8OxE{%-WH^3ai$Xaw z{i%JP+Q=5&bbz>MY#n@OCBRpvdv+P$0{>ty!M_#Lm=KaTC<@s&%(VR3=@fod}qXVj%3rQhyd@UT-Pd@ zT?}=3>)By|Gfoel z|Kint;Ezydj)`_ocoxn4`>N#s=Xd0ZGj;If=zsmcfd8#`@lWo~{%cZPk0X2(@MCH8 z$MGcG2-u`s_FG`fFvHV3^RI-3SGjU31peuArpcS!s$n0I3j{IWP7>BH>^0d$jyR0t zS<2AIcf-`@uTLOf^F)JW)cvHDnZL?6ODghb&t8h-nbxOiohxGA7iOQ*ROkYzv1Gpq zLl*Eny-e4qt(AxA!cr{Vh7BmJJCn+nvCNH1{Uje^aw6fMi8qoHn$%|-c>vqGZS zD@~&ef#IVeVM(F*v4~`eB0~R0t&q^4B2Yso} z)eF93>PkIgrZGq6A23Y8HXqeI6OZT%r?iep{U^&AtP4s4q2hZ=&cF90sFr!`6u&hT zW#Ms3domNc0G$1tTt9w_7QKyetD>t*5-M$PE%0c3p$-c05g4ZfA{aF zoX{97q~#>?)vi}!m3-_*6H-tw)2*jR3XZ$>i_f?3OVj#k$;SN_G~I4WjxlHj ze93E{O_Z0yTMJ_)FBNTK@X+Ygv2z<5K~V6Tv*YNc6-e5%Gd@<$_f3fq=fHsCBM8^U z(((MLi_|#HuCY_pqj!Th8XMa{%3sQQ;&FDuRo550yPR^^JSE!L;ZzCsOZ!MF{7o*j z1a3-ebVHB0ak}ARF!iTgdSso|Am|OrTCo(2UxzEA-WtuwL#(0min^^1Y~=kpIZ8&s zz1EhpwH1&Z6kGQ1Dx)b#;RMA3M~Y8+B{SjgU2;D&{&qTKQ(aqE2cj|CT&j|=rl3eH z`nf$AQDDO=o5eXOVWW>HXd3$`DWXUf+=W^&@w?(BbLAlbV1|N>+{!+mp^voTfbTcs zwnlxU<4X?0r@BAWHS=X}xc%Ocbo7{)HMT5=K%~1kRUT5*hGd#?RrfVii%4zh5nCj% zMXs(ocodwz8JeCp6#t!8`;pFM6g1hHswR&(t&Cj~yTMeY*6&JM9D~XPuZMwnhmhy? z)a$&2%6q+g^+-wkiJv!QzZBWMgS3-*C9ES5PZOVbpB6yagToq_S1n$ULdD zPM%klui3Qwu+|mb)3(-G4@n=Brz{!OD_Bc#d(AifBCVB!Lw#iBXhIrSzjy2WMohdS z2{m`?HiR%Xah%j&?@*leWL@qoKn`-Nu)lRGwQmFPo8upU(Vy=4d$6YV3uF42$QT35 z1jbxOsq*cDE5?m5-Km+u`{c=ETCAw#>=}iS5aCPq_0G%H;j*Bs#N9mwC-VQJoimMQ zYhB~G+a67CCq130DdwSui1vm?$vq*4Qp!OQ8ft6|QB#KqSGA?m#_6i6p{3=77NJPg z6x8gtl#-C3F^>siEJf~4?^<`QyUv&M=`0_7-Ftu9@3Y_k`|RiU|6f17G-}6_N15FRXU2rD9qFtG!viR#wAJBo0&a6E%OiE&UA`!tOFo$tsjyAz+J;q zio_#Yo}qL>p(9SVHRN0*$Sc)@zf=k=97@>=A26JxJxg2#Kb-|TrAeyxa%zJ^QfMW9 ziKvuQMyuvC9&nv?wZpBssUOJlKJ9~ zd(y#n#|83AbfUP2>w}jNM%xQEF9cfUg9M6ZQR!7cH(Uk4+pxQORlCp{m<^XUw>WUM z=GJ3tz2}!cOFh?4)g`ngOlJ*qx3Aor3cGm_&7YWTNY+j(8RDNrI<>c~UZ}lYWCb}z zl%I&9^|N0XrhF1nvKB9Uz4pyD9FXe)0Q^FNWZ~~T=EMiR0|Jqvi|^0PN_H&c zv4BfC_E;@>#K0w@JhCHb{=1%?*J-!z+rpV#>kv%LXx#Tb3&Q9-EDs z-KPY&tp=z>S_kxeLy$l9|HOoy*agV2P)ur4_lQVf_e=LafW`Ir9Ea81+avPL#r+6q z#}Xia$j0eDsOHl<&D)Mag8kOcPHir)I;Iyg&IDYlOjdb0r~-`SB;|tD$o_}-7fYE= zg~53bL!V?`Z51qkphQ#^va(8D!T_<7C)K@97_{L?O5`o+Ehni`PaN~3&c>Q_-==)t z3+$c5^U@dl#-o4n&<$L6>p|4Xp1_I&GaK*aU^Ndh9qgzIZ`re3oNTkzd|3BiPPOo| zt(!&jhBJi<4`UIoIoW?FTk8TfD+8ruW0kH2)0}ve?t*sgt>~CVS>b!Am3X0S8-d71iB^%>%Z*jE-Ip9vL0sM z#Nk9U3!H&Ko-EEMvyjr#Oo5w6_`Ig?&NGV6r6b-MzYWSxC>90wxw=BEgti>)g$Sivk=4bz5TFsfWBNm%guqLt zLT{R7!l4{$yhptYnyQR9tKgpLO3Wx3<2&${LcQe2hybBSBI*j;?@_e72NwM>KE7E7(e`QJ^5tr+s&{#m z&@tM9N%mBEXFY|5PQao}*n3NnUO$<;RgT2yhj!&S`%-YWjh?bV3na_ z{0`cO)rQ>i@aKSLrY%)B`a}~41#60*Yu>Mj;XCkjhjfj0UqH0Rf8Zk+5UC$mO90rw zEwCLFJE}l3^R*1cs3$|^^n;l7>&HitkM^}_KHgZ(dhBBfImwcQPozs4XWvtY_s}ES z0)k(jKzn8{#dxnVdA81CR&HfMSmVGh*yKO0ATE@_N{wdiTi3pFh>C*s`KpiFYt;^) z?FBM`qc6#3Zwm#OZJ@3j>o=od^My>f){Kn0oEOtG(MC^}RTFOM>^-$tP^M|&`g$?% z&J(Xe0k^y8MKDI1QDINuPcWK3*uEtuYny*e!IZEW6(gyIG_-8$1!eD^`?cFa(Oa!u zF70KOwD9KvgdkBN#?xIV5Bha+ad9IPc8*8hjz-%@(ZXA1i!*orSWQ}&F z)Jr^*(5tLmv=ZBr1zpte%~AE%%o`}4H1T|M-SKYVk_O&D9I6$mNeNja=@QAdtH|Yb zviM8dYX^kuWG@tHu>ERR8HCnx*3HKb~!q7Djy1CE_7(g#$Vv-CBI+Rv-ZSpvYty3S@g*uIMfBN7=A z#_@)7jh-2uSr1mSFr}#lfERgjzx07=>8EG8Wu{fpuR1~OtwV4GozBSFlAu%CipUne zlj-U}Cn&lnE`%*@mwu`5+pz;c(+KRUe~u5|nYn0xX;=6M;oE&De_c>sm!49ah{*o$ z!tB7Eo71;jk2^U?BoGK_5r6?hWS{UD av{%Fg!nM3x7b6c`5wSp7n-n75@BRn&z>mZL literal 0 HcmV?d00001 diff --git a/extensions/connector-namespaces/assets/preview.png b/extensions/connector-namespaces/assets/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..259e27941361c56176347838b4c592b18bff62d3 GIT binary patch literal 32685 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 E11p{EHvj+t literal 0 HcmV?d00001 diff --git a/extensions/connector-namespaces/catalog.mjs b/extensions/connector-namespaces/catalog.mjs new file mode 100644 index 00000000..79f4165c --- /dev/null +++ b/extensions/connector-namespaces/catalog.mjs @@ -0,0 +1,70 @@ +// Catalog — fetches MCP connectors from the gateway. +// +// The gateway exposes ~1600 managed APIs (the full Logic Apps connector +// catalog). MCP servers are a small subset (~43) and there is no `kind` or +// capability flag that distinguishes them. The only reliable signal is the +// string "mcp" appearing in the API's name OR its display name — and those are +// genuinely independent signals: `workiqsharepoint` has no "mcp" in its name +// (display name "Work IQ SharePoint MCP"), while `hginsightsmcp` has "mcp" in +// its name but a display name of "HG Insights Connect". Matching either keeps +// the full set without an allowlist that has to be hand-maintained. + +import { listManagedApis } from "./armClient.mjs"; +import { CATEGORY } from "./categories.mjs"; + +function isMcpServer(api) { + const name = api.name || ""; + const displayName = api.properties?.generalInformation?.displayName || ""; + return /mcp/i.test(name) || /mcp/i.test(displayName); +} + +// Microsoft first-party servers (a365*/d365*/workiq* names, or a Microsoft- +// branded display name) group under "Microsoft"; everything else is a partner +// server. Derived rather than hardcoded so new servers categorize themselves. +function categoryFor(name, displayName) { + const n = (name || "").toLowerCase(); + const d = (displayName || "").toLowerCase(); + const isMicrosoft = + /^(a365|d365|workiq)/.test(n) || + d.startsWith("microsoft") || + d.startsWith("work iq") || + d.startsWith("dynamics 365"); + return isMicrosoft ? CATEGORY.microsoft : CATEGORY.partner; +} + +let cachedCatalog = null; +let cacheKey = null; + +export function invalidateCache() { + cachedCatalog = null; + cacheKey = null; +} + +export async function fetchCatalog(subscriptionId, resourceGroup, gatewayName) { + const key = `${subscriptionId}/${resourceGroup}/${gatewayName}`; + if (cachedCatalog && cacheKey === key) return cachedCatalog; + + const apis = await listManagedApis(subscriptionId, resourceGroup, gatewayName); + + const catalog = apis + .filter(isMcpServer) + .map((a) => { + const props = a.properties || {}; + const general = props.generalInformation || {}; + const metadata = props.metadata || {}; + const displayName = general.displayName || a.name; + return { + id: a.name, + apiName: a.name, + displayName, + description: general.description || "", + iconUri: general.iconUri || "", + brandColor: metadata.brandColor || "", + category: categoryFor(a.name, displayName), + }; + }); + + cachedCatalog = catalog; + cacheKey = key; + return catalog; +} diff --git a/extensions/connector-namespaces/categories.mjs b/extensions/connector-namespaces/categories.mjs new file mode 100644 index 00000000..57cd9592 --- /dev/null +++ b/extensions/connector-namespaces/categories.mjs @@ -0,0 +1,15 @@ +// Catalog category values. +// +// `category` is a routing key, not display text: the renderer partitions catalog +// items into the Microsoft vs Partners sections by comparing against these exact +// values (see renderCatalogHtml in renderer.mjs). Kept as a frozen enum here, +// rather than free-form strings scattered across the producer, renderer, and +// tests, so the routing contract lives in one place. +// +// Zero-dependency on purpose: renderer.mjs imports this, and renderer.test.mjs +// loads renderer.mjs as a pure string-rendering gate. Sourcing the enum from +// catalog.mjs instead would drag armClient.mjs (the ARM SDK) into that gate. +export const CATEGORY = Object.freeze({ + microsoft: "Microsoft", + partner: "Partners", +}); diff --git a/extensions/connector-namespaces/copilot-extension.json b/extensions/connector-namespaces/copilot-extension.json new file mode 100644 index 00000000..3949dbe4 --- /dev/null +++ b/extensions/connector-namespaces/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "connector-namespaces", + "version": 1 +} diff --git a/extensions/connector-namespaces/createPage.mjs b/extensions/connector-namespaces/createPage.mjs new file mode 100644 index 00000000..3eb86659 --- /dev/null +++ b/extensions/connector-namespaces/createPage.mjs @@ -0,0 +1,403 @@ +// Renderer for the "Create connector namespace" wizard page. Mirrors the +// portal's CreateConnectorGatewayPage: subscription -> resource group +// (existing or new) -> region -> name (live availability) -> managed identity +// (system + user-assigned) -> real ARM provisioning. + +import { baseStyles, brandMark } from "./renderer.mjs"; + +function esc(s) { + return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +// Connector namespace regions — kept in sync with the portal's +// CONNECTOR_NAMESPACE_REGIONS list (constants.ts). +const REGIONS = [ + ["australiaeast", "Australia East"], ["brazilsouth", "Brazil South"], + ["canadacentral", "Canada Central"], ["canadaeast", "Canada East"], + ["centralindia", "Central India"], ["centralus", "Central US"], + ["eastasia", "East Asia"], ["eastus", "East US"], ["eastus2", "East US 2"], + ["francecentral", "France Central"], ["germanywestcentral", "Germany West Central"], + ["italynorth", "Italy North"], ["japaneast", "Japan East"], + ["koreacentral", "Korea Central"], ["northcentralus", "North Central US"], + ["northeurope", "North Europe"], ["norwayeast", "Norway East"], + ["polandcentral", "Poland Central"], ["southafricanorth", "South Africa North"], + ["southcentralus", "South Central US"], ["southindia", "South India"], + ["southeastasia", "Southeast Asia"], ["spaincentral", "Spain Central"], + ["swedencentral", "Sweden Central"], ["switzerlandnorth", "Switzerland North"], + ["uaenorth", "UAE North"], ["uksouth", "UK South"], + ["westcentralus", "West Central US"], ["westus2", "West US 2"], + ["westus3", "West US 3"], +]; + +const DEFAULT_REGION = "eastus"; + +export function renderCreateNamespaceHtml(subscriptions, preselectedSub = "", capabilityToken = "") { + const subOptions = subscriptions.map((s) => + `` + ).join(""); + + const regionOptions = REGIONS.map(([v, l]) => + `` + ).join(""); + + return ` + +Create Connector Namespace${baseStyles()} + + +
+

${brandMark(28, "create")}Create connector namespace

+
Provisions a real Azure connector namespace (Microsoft.Web/connectorGateways) in your subscription.
+
+ +
+ + +
+ +
+ +
+ + +
+ + +
Pick the resource group the namespace will live in.
+
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+ +
+ +
+ + +
+ +
+ +`; +} diff --git a/extensions/connector-namespaces/extension.mjs b/extensions/connector-namespaces/extension.mjs new file mode 100644 index 00000000..fe6f502e --- /dev/null +++ b/extensions/connector-namespaces/extension.mjs @@ -0,0 +1,101 @@ +// Canvas extension entry point — MCP Connectors browser. + +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { getServerConfig, startServer, stopServer } from "./server.mjs"; +import { getSavedConfig, loadSavedConfig, saveConfig } from "./state.mjs"; +import { fetchCatalog } from "./catalog.mjs"; +import { getInstalledState, openInBrowser, setWorkspaceRoot } from "./install.mjs"; +import { buildSandboxUrl, resolveSandboxConnector } from "./sandbox.mjs"; + +// Load any previously saved connector namespace config on startup +loadSavedConfig(); + +async function openPlayground(server, instanceId) { + const config = instanceId ? getServerConfig(instanceId) : getSavedConfig(); + if (!config) return { opened: false, reason: "no_namespace_configured" }; + const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName); + const installedState = await getInstalledState(config); + const resolved = resolveSandboxConnector(catalog, installedState, server); + if (!resolved.connector) return { opened: false, ...resolved }; + const url = buildSandboxUrl(config, resolved.connector.id); + await openInBrowser(url); + return { opened: true, server: resolved.connector, url }; +} + +const session = await joinSession({ + tools: [ + { + name: "connector_namespaces_open_playground", + description: "Open a named connector from My MCPs in the Azure Connector Namespace playground.", + parameters: { + type: "object", + properties: { + server: { + type: "string", + description: "Connector display name or server ID from My MCPs", + }, + }, + required: ["server"], + }, + handler: async ({ server }) => JSON.stringify(await openPlayground(server)), + }, + ], + canvases: [ + createCanvas({ + id: "connector-namespaces", + displayName: "MCP Connectors", + description: "Browse, connect, and open MCP connectors in the Azure Connector Namespace Sandbox.", + inputSchema: { + type: "object", + properties: { + subscriptionId: { type: "string", description: "Azure subscription ID (optional \u2014 if omitted, uses saved config or shows picker)" }, + resourceGroup: { type: "string", description: "Resource group name" }, + gatewayName: { type: "string", description: "Connector namespace name" }, + }, + }, + actions: [ + { + name: "open_sandbox", + description: "Open a named connector from My MCPs in the Azure Connector Namespace Sandbox", + inputSchema: { + type: "object", + properties: { + server: { + type: "string", + description: "Connector display name or server ID from My MCPs", + }, + }, + required: ["server"], + }, + handler: async (ctx) => openPlayground(ctx.input.server, ctx.instanceId), + }, + ], + open: async (ctx) => { + let config; + // If explicit input provided, use it and save for future + if (ctx.input && ctx.input.subscriptionId && ctx.input.resourceGroup && ctx.input.gatewayName) { + config = { + subscriptionId: ctx.input.subscriptionId, + resourceGroup: ctx.input.resourceGroup, + gatewayName: ctx.input.gatewayName, + }; + saveConfig(config); + } + // A saved config seeds a new panel only. Rehydrating an existing + // panel keeps its active namespace even if another panel changed + // the persisted default. + const entry = await startServer( + ctx.instanceId, + config ? { config } : { defaultConfig: getSavedConfig() }, + ); + return { title: "MCP Connectors", url: entry.url }; + }, + onClose: async (ctx) => { + await stopServer(ctx.instanceId); + }, + }), + ], +}); + +// Tell the install pipeline where the workspace .mcp.json lives (if any). +setWorkspaceRoot(session.workspacePath); diff --git a/extensions/connector-namespaces/install.mjs b/extensions/connector-namespaces/install.mjs new file mode 100644 index 00000000..74264d6f --- /dev/null +++ b/extensions/connector-namespaces/install.mjs @@ -0,0 +1,1007 @@ +// Install flow — creates connection, handles OAuth, creates MCP server config, +// mints API key, and writes to ~/.copilot/mcp-config.json. + +import { promises as fs } from "node:fs"; +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { homedir, platform } from "node:os"; +import { dirname, join } from "node:path"; +import { getToken, assertArmHost, armSegment, resolveSystemExecutable } from "./armClient.mjs"; + +const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); + +// Two scopes the Copilot CLI reads MCP servers from: +// profile -> ~/.copilot/mcp-config.json (private, follows you everywhere) +// workspace -> /.mcp.json (shared with the repo, git-tracked) +const PROFILE_MCP_PATH = join(COPILOT_HOME, "mcp-config.json"); +const PENDING_CLEANUP_DIR = join(COPILOT_HOME, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); +let s_workspaceRoot = null; + +export function setWorkspaceRoot(path) { + s_workspaceRoot = path || null; +} + +export function getWorkspaceRoot() { + return s_workspaceRoot; +} + +function mcpConfigPath(scope) { + if (scope === "workspace") { + if (!s_workspaceRoot) throw new Error("No workspace folder is available for this session."); + return join(s_workspaceRoot, ".mcp.json"); + } + return PROFILE_MCP_PATH; +} + +const CONFIG_LOCK_TIMEOUT_MS = 10_000; +const CONFIG_LOCK_STALE_MS = 30_000; + +// Serialize in-process writes, then hold an exclusive lock file so separate +// Copilot sessions cannot overwrite each other's MCP config changes. +let s_configLock = Promise.resolve(); +async function acquireConfigLock(path) { + const lockPath = `${path}.lock`; + await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const deadline = Date.now() + CONFIG_LOCK_TIMEOUT_MS; + for (;;) { + const owner = `${process.pid}:${randomBytes(12).toString("hex")}\n`; + try { + const handle = await fs.open(lockPath, "wx", 0o600); + await handle.writeFile(owner, "utf8"); + return async () => { + await handle.close(); + try { + if (await fs.readFile(lockPath, "utf8") === owner) { + await fs.unlink(lockPath); + } + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + }; + } catch (error) { + if (error?.code !== "EEXIST") throw error; + try { + const stat = await fs.stat(lockPath); + if (Date.now() - stat.mtimeMs > CONFIG_LOCK_STALE_MS) { + const stalePath = `${lockPath}.${process.pid}.${randomBytes(6).toString("hex")}.stale`; + await fs.rename(lockPath, stalePath); + await fs.unlink(stalePath); + continue; + } + } catch (statError) { + if (statError?.code === "ENOENT") continue; + throw statError; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for the MCP config lock at ${lockPath}.`); + } + await sleep(50); + } + } +} + +export async function waitForConnected(config, connName, options = {}) { + const maxPolls = options.maxPolls ?? 20; + const delay = options.delay ?? sleep; + const getStatus = options.getStatus ?? getConnectionStatus; + let status = "Unknown"; + for (let i = 0; i < maxPolls; i++) { + status = await getStatus(config, connName); + if (status === "Connected") return status; + if (i + 1 < maxPolls) await delay(1000); + } + throw new Error(`Connection ended in state "${status}".`); +} + +function withConfigLock(path, fn) { + const run = s_configLock.then(async () => { + const release = await acquireConfigLock(path); + try { + return await fn(); + } finally { + await release(); + } + }, async () => { + const release = await acquireConfigLock(path); + try { + return await fn(); + } finally { + await release(); + } + }); + s_configLock = run.then(() => {}, () => {}); + return run; +} + +async function readPendingCleanups(gateway, apiName) { + let names; + try { + names = await fs.readdir(PENDING_CLEANUP_DIR); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + + const records = []; + for (const name of names) { + if (!name.endsWith(".json")) continue; + const path = join(PENDING_CLEANUP_DIR, name); + let record; + try { + record = JSON.parse(await fs.readFile(path, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") continue; + throw error; + } + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new Error("Pending connector cleanup data is invalid."); + } + if (record.gatewayId === gateway && record.apiName === apiName) { + records.push({ ...record, path }); + } + } + return records; +} + +async function getPendingCleanup(gateway, apiName) { + const records = await readPendingCleanups(gateway, apiName); + if (!records.length) return null; + return { + configNames: records.flatMap((record) => Array.isArray(record.configNames) ? record.configNames : []), + connectionNames: records.flatMap((record) => Array.isArray(record.connectionNames) ? record.connectionNames : []), + journalFiles: records.map((record) => record.path), + }; +} + +async function savePendingCleanup(record) { + await fs.mkdir(PENDING_CLEANUP_DIR, { recursive: true, mode: 0o700 }); + await fs.chmod(PENDING_CLEANUP_DIR, 0o700).catch(() => {}); + const id = randomBytes(16).toString("hex"); + const tempPath = join(PENDING_CLEANUP_DIR, `${id}.tmp`); + const path = join(PENDING_CLEANUP_DIR, `${id}.json`); + await fs.writeFile(tempPath, JSON.stringify(record, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { + await fs.chmod(tempPath, 0o600).catch(() => {}); + await fs.rename(tempPath, path); + return path; + } catch (error) { + try { + await fs.unlink(tempPath); + } catch (cleanupError) { + if (cleanupError?.code !== "ENOENT") { + throw new AggregateError([error, cleanupError], "Failed to save connector cleanup retry data."); + } + } + throw error; + } +} + +async function clearPendingCleanups(paths) { + for (const path of new Set(paths)) { + try { + await fs.unlink(path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } +} + +// Validate the MCP endpoint URL before persisting it alongside an API key. The +// value comes from an authenticated ARM read of the user's own gateway, so this +// is defense in depth: require https, reject embedded credentials, and block +// obvious internal/link-local hosts. +export function assertSafeMcpTarget(rawUrl) { + let u; + try { u = new URL(rawUrl); } catch { throw new Error("MCP endpoint URL is not a valid URL."); } + if (u.protocol !== "https:") throw new Error("MCP endpoint URL must use https."); + if (u.username || u.password) throw new Error("MCP endpoint URL must not embed credentials."); + const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + const isIpv6 = host.includes(":"); + const blocked = + host === "localhost" || host.endsWith(".localhost") || + host === "metadata.google.internal" || host === "0.0.0.0" || + (isIpv6 && (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd"))) || + /^(127|10)\./.test(host) || + /^169\.254\./.test(host) || + /^192\.168\./.test(host) || + /^172\.(1[6-9]|2\d|3[01])\./.test(host); + if (blocked) throw new Error(`MCP endpoint URL host is not allowed: ${host}`); +} + +// --------------------------------------------------------------------------- +// ARM helpers (using the shared token) +// --------------------------------------------------------------------------- + +// ARM occasionally answers with a transient 5xx/429 (backend blip, throttling) +// that clears on a retry. A single one of these shouldn't nuke a whole connect +// flow, so arm() retries them a few times with backoff before surfacing. +const ARM_TRANSIENT = new Set([429, 500, 502, 503, 504]); +const ARM_MAX_ATTEMPTS = 3; +const ARM_BACKOFF_MS = 500; + +async function arm(method, url, body) { + const token = await getToken(); + const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + const fullUrl = url.startsWith("http") ? url : `https://management.azure.com${url}`; + // Guard the exact value handed to fetch so a tainted path segment can never + // redirect the call off ARM. assertArmHost throws unless fullUrl targets + // https://management.azure.com/. + const safeUrl = assertArmHost(fullUrl); + + for (let attempt = 1; ; attempt++) { + const res = await fetch(safeUrl, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let parsed; + try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; } + if (res.ok) return parsed; + + const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`; + const err = new Error(`ARM ${method} ${res.status}: ${msg}`); + err.status = res.status; + + // Every ARM call we make is idempotent (GET/PUT/DELETE) or a list-shaped + // POST (listConsentLinks, listApiKey) that returns the same value on + // retry, so retrying a transient failure can't spawn duplicate side + // effects. A 500 is never treated like a 404 elsewhere, so a blip can't + // trigger teardown of a live resource. + if (!ARM_TRANSIENT.has(res.status) || attempt >= ARM_MAX_ATTEMPTS) throw err; + await sleep(ARM_BACKOFF_MS * Math.pow(3, attempt - 1)); // 0.5s, then 1.5s + } +} + +// DELETE that tolerates "already gone" (404) but surfaces every other failure +// instead of silently swallowing it. +async function armDelete(url) { + try { + return await arm("DELETE", url); + } catch (e) { + if (e.status === 404) return undefined; + throw e; + } +} + +// --------------------------------------------------------------------------- +// Naming helpers +// --------------------------------------------------------------------------- + +function shortId() { return randomBytes(3).toString("hex"); } +function sanitize(s) { return String(s).replace(/[^a-zA-Z0-9]+/g, "").slice(0, 24) || "x"; } +function generateName(displayName) { return `${sanitize(displayName)}-${shortId()}`; } +function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } + +// --------------------------------------------------------------------------- +// Connector metadata (connection parameters + agentic operation id) +// --------------------------------------------------------------------------- + +const MANAGED_API_VERSION = "2022-09-01-preview"; +const metaCache = new Map(); // apiName + swagger requirement -> Promise + +export function loadConnectorMeta(config, apiName, location, requireSwagger = true) { + const sub = armSegment(config.subscriptionId); + const cacheKey = `${sub}:${location}:${apiName}:${requireSwagger}`; + if (metaCache.has(cacheKey)) return metaCache.get(cacheKey); + const promise = (async () => { + const base = `/subscriptions/${sub}/providers/Microsoft.Web/locations/${armSegment(location)}/managedApis/${armSegment(apiName)}`; + const metaRequest = arm("GET", `${base}?api-version=${MANAGED_API_VERSION}`); + const swaggerRequest = requireSwagger + ? arm("GET", `${base}?api-version=${MANAGED_API_VERSION}&export=true`) + : Promise.resolve(undefined); + const [meta, swagger] = await Promise.all([metaRequest, swaggerRequest]); + return { + connectionParameters: meta?.properties?.connectionParameters ?? null, + connectionParameterSets: meta?.properties?.connectionParameterSets ?? null, + opId: swagger ? getMcpServerOperationId(swagger) : undefined, + }; + })(); + // Cache the in-flight promise so a fast Connect click reuses the prewarm + // fetch instead of starting a second swagger export. Evict on hard failure + // so a transient error doesn't poison the cache. + promise.catch(() => metaCache.delete(cacheKey)); + metaCache.set(cacheKey, promise); + return promise; +} + +// Fire-and-forget pre-warm so the slow swagger fetch happens while the user is +// reading the catalog, not when they click Connect. Concurrency is bounded so a +// large catalog (~43 MCP servers, each 2 ARM GETs) doesn't burst ~86 parallel +// requests on open and trip rate limits. Items are warmed in catalog order, so +// the servers nearest the top of the view warm first. +export function prewarmMeta(config, apiNames, location) { + Promise.resolve(location || getGatewayLocation(config)).then(async (loc) => { + const sub = armSegment(config.subscriptionId); + const pending = apiNames.filter((name) => !metaCache.has(`${sub}:${loc}:${name}:true`)); + const CONCURRENCY = 5; + let next = 0; + const worker = async () => { + while (next < pending.length) { + const apiName = pending[next++]; + await loadConnectorMeta(config, apiName, loc).catch(() => {}); + } + }; + const poolSize = Math.min(CONCURRENCY, pending.length); + await Promise.all(Array.from({ length: poolSize }, worker)); + }).catch(() => {}); +} + +function getMcpServerOperationId(swagger) { + if (!swagger?.paths) return undefined; + for (const methods of Object.values(swagger.paths)) { + if (!methods || typeof methods !== "object") continue; + const post = methods.post; + if (!post?.operationId) continue; + const tags = (post.tags ?? []).map((t) => String(t).toLowerCase()); + if (tags.includes("deprecated")) continue; + if (tags.includes("agentic")) return post.operationId; + } + return undefined; +} + +// Find the OAuth connection parameter name. The consent call 500s if we send a +// parameterName the connector doesn't declare, so derive it from metadata. +function findOAuthParam(meta, redirectUrl) { + const fallback = { parameterName: "token", redirectUrl }; + let params; + if (meta?.connectionParameterSets?.values?.length) { + params = meta.connectionParameterSets.values[0].parameters; + } else { + params = meta?.connectionParameters; + } + if (!params) return fallback; + for (const [name, param] of Object.entries(params)) { + if (param?.type === "oauthSetting" || param?.oAuthSettings) { + return { parameterName: name, redirectUrl }; + } + } + return fallback; +} + +// --------------------------------------------------------------------------- +// JWT decode (to get user oid/tid for access policy) +// --------------------------------------------------------------------------- + +function decodeJwtPayload(token) { + const parts = token.split("."); + if (parts.length < 2) throw new Error("Invalid JWT"); + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); + return JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf8")); +} + +async function getUserContext() { + const token = await getToken(); + const claims = decodeJwtPayload(token); + return { objectId: claims.oid, tenantId: claims.tid }; +} + +// --------------------------------------------------------------------------- +// Connection management +// --------------------------------------------------------------------------- + +function gatewayId(config) { + return `/subscriptions/${armSegment(config.subscriptionId)}/resourceGroups/${armSegment(config.resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(config.gatewayName)}`; +} + +const API_VERSION = "2026-05-01-preview"; + +const s_locationCache = new Map(); // gatewayId -> location (immutable per gateway) + +export async function getGatewayLocation(config) { + const id = gatewayId(config); + const cached = s_locationCache.get(id); + if (cached) return cached; + const gw = await arm("GET", `${id}?api-version=${API_VERSION}`); + const loc = (gw.location ?? "").toLowerCase().replace(/\s+/g, ""); + if (loc) s_locationCache.set(id, loc); + return loc; +} + +export async function createConnection(config, apiName, displayName, location) { + const connName = generateName(displayName); + await arm("PUT", `${gatewayId(config)}/connections/${connName}?api-version=${API_VERSION}`, { + location, + properties: { displayName, connectorName: apiName }, + }); + // Grant current user access policy + try { + const { objectId, tenantId } = await getUserContext(); + await arm("PUT", `${gatewayId(config)}/connections/${connName}/accessPolicies/user-${shortId()}?api-version=${API_VERSION}`, { + location, + properties: { principal: { type: "ActiveDirectory", identity: { objectId, tenantId } } }, + }); + } catch { /* non-fatal */ } + return connName; +} + +export async function getConsentUrl(config, connName, callbackUrl, oauthParam) { + const param = oauthParam || { parameterName: "token", redirectUrl: callbackUrl }; + const res = await arm("POST", `${gatewayId(config)}/connections/${armSegment(connName)}/listConsentLinks?api-version=${API_VERSION}`, { + parameters: [{ parameterName: param.parameterName, redirectUrl: param.redirectUrl }], + }); + return res?.value?.[0]?.link || null; +} + +export async function getConnectionStatus(config, connName) { + const conn = await arm("GET", `${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); + return conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; +} + +export async function createMcpServerConfig(config, apiName, displayName, connName, location, opId, configName = generateName(displayName)) { + if (!opId) { + throw new Error(`Cannot configure "${displayName}" as an MCP server: no agentic operation was found in the connector's definition. The connector may not expose an MCP-streamable endpoint, or its swagger failed to load.`); + } + const created = await arm("PUT", `${gatewayId(config)}/mcpserverConfigs/${configName}?api-version=${API_VERSION}`, { + kind: "ManagedMcpServer", + location, + properties: { + description: displayName, + state: "Enabled", + disableApiKeyAuth: false, + // TextOnlyContent must stay false: when true the dataplane wraps tools/list and + // tools/call responses in a base64 "$content" envelope that spec-compliant MCP + // clients cannot parse, so zero tools load. + settings: { TextOnlyContent: false }, + connectors: [{ + name: apiName, + connectionName: connName, + displayName, + operations: [{ name: opId, displayName, description: "" }], + }], + }, + }); + return { configName, endpointUrl: created?.properties?.mcpEndpointUrl || null }; +} + +export async function mintApiKey(config, configName) { + const notAfter = new Date(Date.now() + 365 * 24 * 3600_000).toISOString(); + const res = await arm("POST", `${gatewayId(config)}/listApiKey?api-version=${API_VERSION}`, { + keyType: "Primary", + notAfter, + scope: configName, + }); + return res.key; +} + +export async function getMcpEndpointUrl(config, configName) { + const cfg = await arm("GET", `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`); + return cfg?.properties?.mcpEndpointUrl || null; +} + +// --------------------------------------------------------------------------- +// MCP config writer +// --------------------------------------------------------------------------- + +async function readMcpConfigAt(path) { + try { + const raw = await fs.readFile(path, "utf8"); + let parsed = JSON.parse(raw); + // Reject arrays and primitives before treating this as a config object. + // JSON.parse can return either (a hand-edited "[]" or a bare number), + // and both break the write path: a string key set on an array is + // silently dropped by JSON.stringify (the new entry would vanish), and + // a primitive throws on property assignment. Fall back to a fresh + // object so writeMcpEntry always persists. + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) parsed = {}; + if (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers)) parsed.mcpServers = {}; + return parsed; + } catch (e) { + if (e.code === "ENOENT") return { mcpServers: {} }; + throw e; + } +} + +async function writeMcpConfigAt(path, cfg) { + const directory = dirname(path); + const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + await fs.chmod(directory, 0o700).catch(() => {}); + try { + await fs.writeFile(temporary, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + await fs.chmod(temporary, 0o600).catch(() => {}); + await fs.rename(temporary, path); + await fs.chmod(path, 0o600).catch(() => {}); + } finally { + await fs.unlink(temporary).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + } +} + +export async function writeMcpEntry(name, url, key, scope = "profile", meta = null) { + assertSafeMcpTarget(url); + const path = mcpConfigPath(scope); + return withConfigLock(path, async () => { + const cfg = await readMcpConfigAt(path); + cfg.mcpServers[name] = { + url, + headers: { "X-API-Key": key }, + }; + // Stamp ARM provenance as a sibling metadata key. The underscore prefix + // marks it as "metadata, not part of the MCP launch spec" — the CLI + // tolerates and preserves unknown sibling keys across restarts. + if (meta) cfg.mcpServers[name]._connectorNamespace = meta; + await writeMcpConfigAt(path, cfg); + }); +} + +// Remove the entry from whichever scope(s) it lives in. +export async function removeMcpEntry(name) { + let removed = false; + for (const scope of ["profile", "workspace"]) { + let path; + try { path = mcpConfigPath(scope); } catch { continue; } + const removedAtPath = await withConfigLock(path, async () => { + const cfg = await readMcpConfigAt(path); + if (Object.prototype.hasOwnProperty.call(cfg.mcpServers, name)) { + delete cfg.mcpServers[name]; + await writeMcpConfigAt(path, cfg); + return true; + } + return false; + }); + removed ||= removedAtPath; + } + return removed; +} + +async function deleteMcpServerConfigs(config, configNames) { + for (const configName of configNames) { + const url = `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`; + await armDelete(url); + let pending = true; + for (let i = 0; i < 20; i++) { + try { + await arm("GET", url); + } catch (error) { + if (error?.status === 404) { + pending = false; + break; + } + throw error; + } + await sleep(750); + } + if (pending) throw new Error(`Timed out waiting for connector configuration "${configName}" deletion.`); + } +} + +async function cleanupConnectorResources(config, apiName, configNames, connectionNames, priorJournalFiles = []) { + configNames = [...new Set(configNames.filter(Boolean))]; + connectionNames = [...new Set(connectionNames.filter(Boolean))]; + const gateway = gatewayId(config); + const journalFile = await savePendingCleanup({ gatewayId: gateway, apiName, configNames, connectionNames }); + const journalFiles = [...priorJournalFiles, journalFile]; + + await deleteMcpServerConfigs(config, configNames); + for (const connectionName of connectionNames) { + await armDelete(`${gateway}/connections/${armSegment(connectionName)}?api-version=${API_VERSION}`); + } + for (const configName of configNames) { + await removeMcpEntry(configName); + } + await clearPendingCleanups(journalFiles); +} + +// Remove an installed connector: delete its mcpserverConfig, its connection, +// and its CLI entry. apiName is resolved against the current installed state. +export async function uninstallConnector(config, apiName) { + const state = await getInstalledState(config); + const entry = state[apiName]; + const gateway = gatewayId(config); + const pending = await getPendingCleanup(gateway, apiName); + if (!entry && !pending) return { ok: true, removed: false }; + + const candidates = entry ? (entry._candidates ?? [entry]) : []; + const configNames = [ + ...(pending?.configNames ?? []), + ...candidates.map((candidate) => candidate.configName), + ]; + const connectionNames = [ + ...(pending?.connectionNames ?? []), + ...candidates.map((candidate) => candidate.connectionName), + ]; + await cleanupConnectorResources(config, apiName, configNames, connectionNames, pending?.journalFiles); + + return { ok: true, removed: true }; +} + +// Local-only remove: drop just the CLI mcp entry, leaving the namespace +// resources (mcpserverConfig + connection) intact. This is the default +// "Remove" action — it unwires the connector from Copilot without deleting +// anything on Azure. Fast and local; no armDelete, no convergence poll. +export async function removeLocalEntry(config, apiName) { + const state = await getInstalledState(config); + const entry = state[apiName]; + if (!entry) return { ok: true, removed: false }; + const candidates = entry._candidates ?? [entry]; + for (const candidate of candidates) { + if (candidate.inCli && candidate.configName) await removeMcpEntry(candidate.configName); + } + return { ok: true, removed: true }; +} + +// Best-effort rollback of a connection created during an install the user then +// cancelled. At that point no mcpserverConfig exists yet, so uninstallConnector +// can't see it — delete the orphaned connection directly so the tile honestly +// returns to "Connect" and we don't leak a half-made connection on the namespace. +export async function deleteConnection(config, connName) { + if (!connName) return { ok: true, removed: false }; + await armDelete(`${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); + return { ok: true, removed: true }; +} + +async function throwAfterCleanup(error, cleanups) { + for (const cleanup of cleanups) { + try { + await cleanup(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + `${error.message} Cleanup also failed: ${cleanupError.message}`, + ); + } + } + throw error; +} + +// --------------------------------------------------------------------------- +// Full install pipeline +// --------------------------------------------------------------------------- + +function oauthCallbackUrl(callbackBase, connName, capabilityToken = "") { + const url = new URL(`${callbackBase}${encodeURIComponent(connName)}`); + if (capabilityToken) { + url.searchParams.set("cn_token", capabilityToken); + } + return url.toString(); +} + +export async function installConnector(config, apiName, displayName, callbackBase, scope = "profile", capabilityToken = "") { + const pending = await getPendingCleanup(gatewayId(config), apiName); + if (pending) { + await cleanupConnectorResources( + config, + apiName, + pending.configNames, + pending.connectionNames, + pending.journalFiles, + ); + } + const location = await getGatewayLocation(config); + const meta = await loadConnectorMeta(config, apiName, location); + + // 1. Create connection + const connName = await createConnection(config, apiName, displayName, location); + let finishStarted = false; + try { + // The OAuth redirect must carry the connName so the loopback callback keys + // pendingOAuth by the same value the client polls on. + const callbackUrl = oauthCallbackUrl(callbackBase, connName, capabilityToken); + + // 2. Quick wait for the connection to converge — some connectors come up + // Connected without any OAuth (e.g. service principal / key based). + await sleep(800); + const status = await getConnectionStatus(config, connName); + if (status === "Connected") { + finishStarted = true; + return await finishInstall(config, apiName, displayName, connName, location, scope); + } + + // 3. Needs OAuth — derive the correct consent parameter from metadata. + const oauthParam = findOAuthParam(meta, callbackUrl); + const consentUrl = await getConsentUrl(config, connName, callbackUrl, oauthParam); + if (consentUrl) { + return { needsConsent: true, consentUrl, connName, location, freshConnection: true }; + } + + // 4. No consent link and not Connected — try to finish anyway. + finishStarted = true; + return await finishInstall(config, apiName, displayName, connName, location, scope); + } catch (error) { + if (finishStarted) throw error; + return throwAfterCleanup(error, [() => deleteConnection(config, connName)]); + } +} + +export async function finishInstall(config, apiName, displayName, connName, location, scope = "profile") { + let configName; + try { + if (!location) location = await getGatewayLocation(config); + const meta = await loadConnectorMeta(config, apiName, location); + + // Poll connection status up to ~20s for Connected. + const status = await waitForConnected(config, connName); + + // Create MCP server config (endpoint URL comes back on the PUT response). + let endpointUrl; + configName = generateName(displayName); + ({ endpointUrl } = await createMcpServerConfig(config, apiName, displayName, connName, location, meta.opId, configName)); + + // Endpoint URL can lag — poll the config a few times if missing. + for (let i = 0; !endpointUrl && i < 5; i++) { + await sleep(1000); + endpointUrl = await getMcpEndpointUrl(config, configName); + } + if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); + + // Mint key and write the CLI entry, stamped with ARM provenance so the + // install can be recognised regardless of which connector namespace is + // active when state is next derived. + const key = await mintApiKey(config, configName); + const gw = gatewayId(config); + await writeMcpEntry(configName, endpointUrl, key, scope, { + gatewayId: gw, + mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, + connectionId: `${gw}/connections/${armSegment(connName)}`, + apiName, + }); + + return { ok: true, configName, connName, endpointUrl, scope }; + } catch (error) { + const cleanups = []; + if (configName) { + cleanups.push(() => deleteMcpServerConfigs(config, [configName])); + } + cleanups.push(() => deleteConnection(config, connName)); + return throwAfterCleanup(error, cleanups); + } +} + +// --------------------------------------------------------------------------- +// Re-authenticate pipeline (reuse the EXISTING connection + config) +// --------------------------------------------------------------------------- + +// Re-run consent for a connector that's already installed, WITHOUT minting a new +// connection or a new mcpserverConfig. This is what the "Re-authenticate" button +// hits; wiring it to the plain install path is exactly what spawned duplicate +// configs. We resolve the selected install-state candidate (post phase-1 +// selection, that's the config the local session actually points at), re-consent +// its existing connection, and rebind THAT config locally. +// +// Falls back to a fresh installConnector only when there's genuinely nothing to +// re-auth against: no known connection, or the stored connection was deleted +// server-side (listConsentLinks 404s). In the 404 case we drop the orphaned +// config + local entry first so the fallback install can't leave a duplicate. +export async function reauthConnector(config, apiName, displayName, callbackBase, scope = "profile", capabilityToken = "") { + return reauthConnectorWithAttempts(config, apiName, displayName, callbackBase, scope, capabilityToken, new Set()); +} + +async function reauthConnectorWithAttempts(config, apiName, displayName, callbackBase, scope, capabilityToken, attemptedConfigNames) { + const state = await getInstalledState(config); + const selected = state[apiName]; + const candidates = selected?._candidates ?? (selected ? [selected] : []); + const entry = candidates.find((candidate) => !attemptedConfigNames.has(candidate.configName)); + const connName = entry?.connectionName; + + // Nothing installed to re-auth against — treat it as a first-time Connect. + if (!connName) { + return installConnector(config, apiName, displayName, callbackBase, scope, capabilityToken); + } + + const location = await getGatewayLocation(config); + const meta = await loadConnectorMeta(config, apiName, location, false); + const callbackUrl = oauthCallbackUrl(callbackBase, connName, capabilityToken); + const oauthParam = findOAuthParam(meta, callbackUrl); + + let consentUrl; + try { + consentUrl = await getConsentUrl(config, connName, callbackUrl, oauthParam); + } catch (err) { + // The stored connection is gone (deleted in the portal). Clean up the + // now-orphaned config + local entry, then fall through to a clean + // install so we don't strand a dead "Re-authenticate" tile. + if (err.status === 404) { + attemptedConfigNames.add(entry.configName); + const siblingUsesConnection = candidates.some( + (candidate) => candidate.configName !== entry.configName && candidate.connectionName === connName, + ); + await cleanupConnectorResources( + config, + apiName, + entry.configName ? [entry.configName] : [], + siblingUsesConnection ? [] : [connName], + ); + return reauthConnectorWithAttempts( + config, + apiName, + displayName, + callbackBase, + scope, + capabilityToken, + attemptedConfigNames, + ); + } + throw err; + } + + // Re-consent the existing connection; finish rebinds the SAME config. + // configName is carried through so the finish step never creates a new one. + if (consentUrl) { + return { needsConsent: true, consentUrl, connName, location, configName: entry.configName, reauth: true, freshConnection: false }; + } + + // Already consentable without a redirect — just rebind the existing config. + return finishReauth(config, apiName, displayName, connName, entry.configName, location, scope); +} + +// Finish a re-auth: rebind an EXISTING mcpserverConfig to the local CLI. Unlike +// finishInstall this never calls createMcpServerConfig, so a re-auth can't spawn +// a duplicate — it reuses configName, mints a fresh key, and rewrites the entry. +export async function finishReauth(config, apiName, displayName, connName, configName, location, scope = "profile") { + // Defensive: with no config to bind there's nothing to reuse — fall back to + // a normal finish (which creates one). Shouldn't happen on the reauth path. + if (!configName) { + return finishInstall(config, apiName, displayName, connName, location, scope); + } + + // Wait for the re-consented connection to converge (up to ~20s). + const status = await waitForConnected(config, connName); + + // Reuse the existing config's endpoint — poll a few times if it lags. + let endpointUrl = await getMcpEndpointUrl(config, configName); + for (let i = 0; !endpointUrl && i < 5; i++) { + await sleep(1000); + endpointUrl = await getMcpEndpointUrl(config, configName); + } + if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); + + const key = await mintApiKey(config, configName); + const gw = gatewayId(config); + await writeMcpEntry(configName, endpointUrl, key, scope, { + gatewayId: gw, + mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, + connectionId: `${gw}/connections/${armSegment(connName)}`, + apiName, + }); + + return { ok: true, configName, connName, endpointUrl, scope, reauth: true }; +} + +// --------------------------------------------------------------------------- +// Installed-state derivation (source of truth = the gateway + CLI config) +// --------------------------------------------------------------------------- + +export async function getInstalledState(config) { + const wsPath = s_workspaceRoot ? join(s_workspaceRoot, ".mcp.json") : null; + const [configsRes, connectionsRes, profileCfg, workspaceCfg] = await Promise.all([ + arm("GET", `${gatewayId(config)}/mcpserverConfigs?api-version=${API_VERSION}`), + arm("GET", `${gatewayId(config)}/connections?api-version=${API_VERSION}`), + readMcpConfigAt(PROFILE_MCP_PATH), + wsPath ? readMcpConfigAt(wsPath) : Promise.resolve({ mcpServers: {} }), + ]); + + const connByName = new Map(); + for (const c of connectionsRes.value ?? []) connByName.set(c.name, c); + + const profileKeys = new Set(Object.keys(profileCfg.mcpServers ?? {})); + const workspaceKeys = new Set(Object.keys(workspaceCfg.mcpServers ?? {})); + + return deriveInstalledState(configsRes.value ?? [], connByName, profileKeys, workspaceKeys, wsPath); +} + +// Pure derivation, split out so it can be unit-tested without live ARM. +// A single apiName can have MULTIPLE gateway configs (a portal-side add, a +// duplicate Connect, a re-auth that minted a fresh config). Collect every +// config per apiName, then pick ONE deterministically instead of letting ARM +// list order decide (last-wins) — that overwrite is what stranded a tile on +// "Re-authenticate" while a sibling config was actually Connected. +export function deriveInstalledState(configs, connByName, profileKeys, workspaceKeys, wsPath) { + const candidatesByApi = {}; + for (const cfg of configs ?? []) { + const connector = cfg.properties?.connectors?.[0]; + const apiName = connector?.name; + if (!apiName) continue; + const connName = connector?.connectionName; + const conn = connName ? connByName.get(connName) : null; + const connectionStatus = conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; + const inWorkspace = workspaceKeys.has(cfg.name); + const inProfile = profileKeys.has(cfg.name); + (candidatesByApi[apiName] ??= []).push({ + installed: true, + configName: cfg.name, + connectionName: connName || null, + connectionStatus, + inCli: inProfile || inWorkspace, + cliScope: inWorkspace ? "workspace" : (inProfile ? "profile" : null), + cliPath: inWorkspace ? wsPath : (inProfile ? PROFILE_MCP_PATH : null), + }); + } + + // Prefer the config the local session actually points at, and prefer a + // Connected one: inCli && Connected > inCli > Connected > any. Config name + // breaks ties so ARM list order cannot change the selected resource. Keeps the flat + // one-entry-per-apiName shape the renderer + tests expect. + const rank = (e) => (e.inCli ? 2 : 0) + (e.connectionStatus === "Connected" ? 1 : 0); + const byApi = {}; + for (const [apiName, list] of Object.entries(candidatesByApi)) { + list.sort((a, b) => rank(b) - rank(a) || a.configName.localeCompare(b.configName)); + const best = list[0]; + // Internal-only signal for logging; the renderer ignores unknown fields. + byApi[apiName] = list.length > 1 ? { ...best, _configCount: list.length, _candidates: list } : best; + } + return byApi; +} + +// --------------------------------------------------------------------------- +// Browser opener +// --------------------------------------------------------------------------- + +async function launchDetached(command, args) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + +export async function openInBrowser(url) { + // Only ever hand an http(s) URL to the OS shell — guards against the + // consent URL being anything that could be reinterpreted as a command. + let safe; + try { + const u = new URL(url); + if (u.protocol !== "http:" && u.protocol !== "https:") return; + safe = u.toString(); + } catch { + return; + } + const p = platform(); + if (p === "win32") { + // rundll32 hands the URL to the default protocol handler as a single + // literal argv with no shell parsing — avoids cmd.exe `start` metachar + // and quoting pitfalls. + await launchDetached(await resolveSystemExecutable("rundll32.exe"), ["url.dll,FileProtocolHandler", safe]); + } else if (p === "darwin") { + await launchDetached(await resolveSystemExecutable("open"), [safe]); + } else { + await launchDetached(await resolveSystemExecutable("xdg-open"), [safe]); + } +} + +// --------------------------------------------------------------------------- +// Config file opener +// --------------------------------------------------------------------------- + +// Hand a local file path to the OS so it opens in the user's default handler +// for that type (typically their editor for .json). Single literal argv on +// every platform — no shell, so a path with spaces or metachars is safe. +async function openPath(filePath) { + const p = platform(); + if (p === "win32") { + // FileProtocolHandler also accepts plain file paths and routes them to + // the registered default app, same no-shell guarantee as openInBrowser. + await launchDetached(await resolveSystemExecutable("rundll32.exe"), ["url.dll,FileProtocolHandler", filePath]); + } else if (p === "darwin") { + await launchDetached(await resolveSystemExecutable("open"), [filePath]); + } else { + await launchDetached(await resolveSystemExecutable("xdg-open"), [filePath]); + } +} + +// Open the MCP config this canvas writes to (the profile scope — +// ~/.copilot/mcp-config.json). Creates an empty, correctly-shaped config if +// none exists yet so the editor never opens a missing file. Returns the path +// either way so the UI can show where it lives even if the OS open is a no-op. +export async function openMcpConfigFile() { + const path = PROFILE_MCP_PATH; + try { + await fs.access(path); + } catch { + try { + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile(path, JSON.stringify({ mcpServers: {} }, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + await fs.chmod(path, 0o600).catch(() => {}); + } catch (err) { + return { ok: false, path, error: err.message }; + } + } + await openPath(path); + return { ok: true, path }; +} diff --git a/extensions/connector-namespaces/install.reauth.test.mjs b/extensions/connector-namespaces/install.reauth.test.mjs new file mode 100644 index 00000000..50da0470 --- /dev/null +++ b/extensions/connector-namespaces/install.reauth.test.mjs @@ -0,0 +1,558 @@ +// Phase 2 regression: Re-authenticate must re-consent the EXISTING connection and +// mint NO new resources. +// +// Before the fix, the "Re-authenticate" button ran the full install path, so it +// created a fresh connection + a fresh mcpserverConfig on every click. A teammate +// saw a new Dynamics config appear on the namespace each time they re-authed, while +// the panel stayed stuck on "Re-authenticate". This test stubs ARM and proves +// reauthConnector adopts the local session's connection and issues ZERO PUTs. +// +// Run: node --test extensions/connector-namespaces/install.reauth.test.mjs + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { tmpdir } from "node:os"; + +// Isolate COPILOT_HOME before importing install.mjs because its paths are bound at +// module-eval time. Put a fake Azure CLI on PATH so getToken() stays offline, and +// seed a profile config so the local entry reads as inCli. +const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-")); +process.env.COPILOT_HOME = TMP; +process.env.USERPROFILE = TMP; // homedir() on Windows +process.env.HOME = TMP; // homedir() on posix + +const binDir = join(TMP, "bin"); +mkdirSync(binDir, { recursive: true }); +const tokenJson = JSON.stringify({ accessToken: "fake-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }); +writeFileSync(join(binDir, "az"), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(tokenJson)});\n`); +chmodSync(join(binDir, "az"), 0o755); +writeFileSync(join(binDir, "az.cmd"), `@echo ${tokenJson}\r\n`); +process.env.PATH = `${binDir}${delimiter}${process.env.PATH || ""}`; + +const legacyAuthCache = join(TMP, "extensions", "connector-namespaces", "artifacts", "auth-cache.json"); +mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true }); +writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" })); + +writeFileSync( + join(TMP, "mcp-config.json"), + JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), +); + +// Dynamic import AFTER the env is set. A static top-level import would be hoisted +// and evaluate install.mjs (binding the paths to the real home) before the env +// assignments run. +const { + deleteConnection, + finishInstall, + getInstalledState, + installConnector, + loadConnectorMeta, + reauthConnector, + removeMcpEntry, + uninstallConnector, +} = await import("./install.mjs"); + +after(() => { + try { + rmSync(TMP, { recursive: true, force: true }); + } catch { + /* best-effort temp cleanup */ + } +}); + +test("re-authenticate re-consents the existing connection and mints no new resources", async (t) => { + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; + + // Two configs for one apiName — the bug scenario. configA is a portal-added + // sibling that is NOT in the local CLI; configB is the one the local session + // points at. Both connections are Connected, so selection turns on inCli: + // deriveInstalledState must pick configB, and the re-consent must target conn-b. + const configA = { name: "docusign-aaa", properties: { connectors: [{ name: "docusign", connectionName: "conn-a" }] } }; + const configB = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; + const connA = { name: "conn-a", properties: { statuses: [{ status: "Connected" }] } }; + const connB = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; + + const calls = []; + const realFetch = globalThis.fetch; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + calls.push({ method, url }); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + + if (method === "POST" && url.includes("/listConsentLinks")) return ok({ value: [{ link: "https://consent.example/redir" }] }); + if (url.includes("/managedApis/") && !url.includes("export=true")) { + return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } }); + } + if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [configA, configB] }); + if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connA, connB] }); + if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + }); + + const result = await reauthConnector(config, "docusign", "DocuSign", "https://cb/?c="); + assert.equal(existsSync(legacyAuthCache), false, "the legacy refresh-token cache must be removed without reading it"); + + // Adopts the existing connection, stops at consent, carries the selected config + // through so finish never mints a new one. + assert.equal(result.needsConsent, true); + assert.equal(result.reauth, true); + assert.equal(result.freshConnection, false); + assert.equal(result.connName, "conn-b"); // the inCli config's connection + assert.equal(result.configName, "docusign-bbb"); // never a fresh generateName() + + // The core guarantee: nothing was minted. createConnection and + // createMcpServerConfig are the only PUTs on the install path; re-auth issues none. + const puts = calls.filter((c) => c.method === "PUT"); + assert.deepEqual(puts, [], `expected zero PUTs, saw: ${puts.map((p) => p.url).join(", ")}`); + + // And it re-consented the SELECTED connection, not the portal sibling. + const consent = calls.find((c) => c.url.includes("/listConsentLinks")); + assert.ok(consent && consent.url.includes("/connections/conn-b/"), "consent must target conn-b"); + assert.ok( + !calls.some((c) => c.url.includes("/connections/conn-a/listConsentLinks")), + "must not touch the sibling connection conn-a", + ); + assert.ok(!calls.some((c) => c.url.includes("export=true")), "reauth must not request unused swagger"); +}); + +test("missing selected connection re-evaluates a valid duplicate before installing", async (t) => { + const configPath = join(TMP, "mcp-config.json"); + writeFileSync( + configPath, + JSON.stringify({ mcpServers: { "api-dead": { type: "http", url: "https://example.com/mcp" } } }), + ); + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; + const dead = { name: "api-dead", properties: { connectors: [{ name: "shared-api", connectionName: "conn-dead" }] } }; + const live = { name: "api-live", properties: { connectors: [{ name: "shared-api", connectionName: "conn-live" }] } }; + const calls = []; + const realFetch = globalThis.fetch; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + calls.push({ method, url }); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [dead, live] }); + if (method === "GET" && /\/connections\?/.test(url)) { + return ok({ value: [ + { name: "conn-dead", properties: { statuses: [{ status: "Unknown" }] } }, + { name: "conn-live", properties: { statuses: [{ status: "Connected" }] } }, + ] }); + } + if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); + if (method === "GET" && url.includes("/managedApis/shared-api")) { + return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } }); + } + if (method === "POST" && url.includes("/connections/conn-dead/listConsentLinks")) { + return { ok: false, status: 404, text: async () => "gone" }; + } + if (method === "POST" && url.includes("/connections/conn-live/listConsentLinks")) { + return ok({ value: [{ link: "https://consent.example/live" }] }); + } + if (method === "DELETE" && url.includes("/mcpserverConfigs/api-dead")) return ok({}); + if (method === "GET" && url.includes("/mcpserverConfigs/api-dead")) { + return { ok: false, status: 404, text: async () => "gone" }; + } + if (method === "DELETE" && url.includes("/connections/conn-dead")) return ok({}); + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); + }); + + const result = await reauthConnector(config, "shared-api", "Shared API", "https://cb/?c="); + assert.equal(result.needsConsent, true); + assert.equal(result.configName, "api-live"); + assert.equal(result.connName, "conn-live"); + assert.ok(calls.some((call) => call.url.includes("/connections/conn-dead/listConsentLinks"))); + assert.ok(calls.some((call) => call.url.includes("/connections/conn-live/listConsentLinks"))); + assert.equal(calls.some((call) => call.method === "PUT"), false, "valid siblings must prevent a fresh install"); +}); + +test("cross-process MCP config writes preserve every entry", async () => { + const configPath = join(TMP, "mcp-config.json"); + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); + const installUrl = new URL("./install.mjs", import.meta.url).href; + const names = Array.from({ length: 8 }, (_, index) => `parallel-${index}`); + + const runWriter = (name) => new Promise((resolve, reject) => { + const metadata = { + gatewayId: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway", + mcpServerConfigId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/mcpserverConfigs/${name}`, + connectionId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/connections/${name}`, + apiName: name, + }; + const script = [ + `import { writeMcpEntry } from ${JSON.stringify(installUrl)};`, + `await writeMcpEntry(${JSON.stringify(name)}, ${JSON.stringify(`https://example.com/${name}`)}, ${JSON.stringify(`key-${name}`)}, "profile", ${JSON.stringify(metadata)});`, + ].join("\n"); + const child = spawn(process.execPath, ["--input-type=module", "--eval", script], { + env: { ...process.env, COPILOT_HOME: TMP, HOME: TMP, USERPROFILE: TMP }, + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`config writer exited ${code}: ${stderr}`)); + }); + }); + + await Promise.all(names.map(runWriter)); + const stored = JSON.parse(readFileSync(configPath, "utf8")).mcpServers; + assert.deepEqual(Object.keys(stored).sort(), [...names].sort()); + for (const name of names) { + assert.equal(stored[name].url, `https://example.com/${name}`); + assert.equal(stored[name].headers["X-API-Key"], `key-${name}`); + assert.deepEqual(Object.keys(stored[name]).sort(), ["_connectorNamespace", "headers", "url"]); + assert.equal(stored[name]._connectorNamespace.apiName, name); + assert.match(stored[name]._connectorNamespace.gatewayId, /connectorGateways\/gateway$/); + } + assert.equal(existsSync(`${configPath}.lock`), false); + + await Promise.all(names.map((name) => removeMcpEntry(name))); + assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")).mcpServers, {}); +}); + +test("connector metadata failures are evicted and retried", async (t) => { + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; + const realFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async (urlArg) => { + const url = String(urlArg); + assert.ok(!url.includes("export=true"), "swagger must not be requested when it is not required"); + calls++; + if (calls === 1) return { ok: false, status: 400, text: async () => "temporary metadata failure" }; + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ properties: { connectionParameters: {} } }), + }; + }; + t.after(() => { + globalThis.fetch = realFetch; + }); + + await assert.rejects(loadConnectorMeta(config, "retry-meta", "eastus", false), /metadata failure/); + const meta = await loadConnectorMeta(config, "retry-meta", "eastus", false); + assert.equal(calls, 2); + assert.deepEqual(meta.connectionParameters, {}); +}); + +test("uninstall surfaces connection deletion failures", async (t) => { + writeFileSync( + join(TMP, "mcp-config.json"), + JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), + ); + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; + const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; + const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; + const realFetch = globalThis.fetch; + const operations = []; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + operations.push(`${method} ${url}`); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); + if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] }); + if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); + if (method === "GET" && url.includes("/mcpserverConfigs/")) { + return { ok: false, status: 404, text: async () => "gone" }; + } + if (method === "DELETE" && url.includes("/connections/")) { + return { ok: false, status: 400, text: async () => "delete denied" }; + } + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + }); + + await assert.rejects(uninstallConnector(config, "docusign"), /delete denied/); + const configDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/mcpserverConfigs/")); + const connectionDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/connections/")); + assert.ok(configDelete !== -1 && configDelete < connectionDelete, "configs must be confirmed deleted before their connections"); + + const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); + assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 1, "failed deletion must persist enough state to retry"); + + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] }); + if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); + if (method === "DELETE") return ok({}); + if (method === "GET" && url.includes("/mcpserverConfigs/")) { + return { ok: false, status: 404, text: async () => "gone" }; + } + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + assert.deepEqual(await uninstallConnector(config, "docusign"), { ok: true, removed: true }); + assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 0, "successful retry must clear the cleanup journal"); +}); + +test("uninstall surfaces convergence polling failures", async (t) => { + writeFileSync( + join(TMP, "mcp-config.json"), + JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), + ); + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; + const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; + const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; + const realFetch = globalThis.fetch; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); + if (method === "GET" && url.includes("/mcpserverConfigs/")) { + return { ok: false, status: 400, text: async () => "poll denied" }; + } + if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] }); + if (method === "DELETE") return ok({}); + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + }); + + await assert.rejects(uninstallConnector(config, "docusign"), /poll denied/); +}); + +test("concurrent failed uninstalls retain independent retry records", async (t) => { + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "concurrent-gw" }; + const configs = [ + { name: "alpha-config", properties: { connectors: [{ name: "alpha", connectionName: "alpha-conn" }] } }, + { name: "beta-config", properties: { connectors: [{ name: "beta", connectionName: "beta-conn" }] } }, + ]; + const connections = [ + { name: "alpha-conn", properties: { statuses: [{ status: "Connected" }] } }, + { name: "beta-conn", properties: { statuses: [{ status: "Connected" }] } }, + ]; + const realFetch = globalThis.fetch; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: configs }); + if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: connections }); + if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); + if (method === "GET" && url.includes("/mcpserverConfigs/")) { + return { ok: false, status: 404, text: async () => "gone" }; + } + if (method === "DELETE" && url.includes("/connections/")) { + return { ok: false, status: 400, text: async () => "delete denied" }; + } + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + }); + + const results = await Promise.allSettled([ + uninstallConnector(config, "alpha"), + uninstallConnector(config, "beta"), + ]); + assert.deepEqual(results.map((result) => result.status), ["rejected", "rejected"]); + + const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); + const paths = readdirSync(pendingCleanup) + .filter((name) => name.endsWith(".json")) + .map((name) => join(pendingCleanup, name)); + const records = paths.map((path) => ({ path, ...JSON.parse(readFileSync(path, "utf8")) })) + .filter((record) => record.gatewayId.includes("/connectorGateways/concurrent-gw")); + assert.deepEqual(new Set(records.map((record) => record.apiName)), new Set(["alpha", "beta"])); + for (const record of records) unlinkSync(record.path); +}); + +test("local MCP config read failures block cleanup", async () => { + const configPath = join(TMP, "mcp-config.json"); + writeFileSync(configPath, "{invalid json"); + try { + await assert.rejects(removeMcpEntry("docusign-bbb"), SyntaxError); + } finally { + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); + } +}); + +test("installed state propagates local MCP config read failures", async (t) => { + const configPath = join(TMP, "mcp-config.json"); + writeFileSync(configPath, "{invalid json"); + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "state-fail-gw" }; + const realFetch = globalThis.fetch; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] }); + if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); + }); + + await assert.rejects(getInstalledState(config), SyntaxError); +}); + +test("missing-connection reauth journals cleanup and the next install retries it", async (t) => { + const configPath = join(TMP, "mcp-config.json"); + writeFileSync( + configPath, + JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }), + ); + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "missing-conn-gw" }; + const remoteConfig = { + name: "missing-config", + properties: { connectors: [{ name: "missing-api", connectionName: "missing-conn" }] }, + }; + const realFetch = globalThis.fetch; + let retrying = false; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); + if (method === "GET" && url.includes("/mcpserverConfigs/missing-config")) { + return { ok: false, status: 404, text: async () => "gone" }; + } + if (method === "DELETE" && url.includes("/connections/missing-conn")) { + return { ok: false, status: 404, text: async () => "gone" }; + } + if (retrying && method === "GET" && url.includes("/managedApis/missing-api")) { + return { ok: false, status: 400, text: async () => "stop after cleanup" }; + } + if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); + if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); + if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); + if (method === "GET" && url.includes("/managedApis/missing-api")) return ok({ properties: {} }); + if (method === "POST" && url.includes("/connections/missing-conn/listConsentLinks")) { + writeFileSync(configPath, "{invalid json"); + return { ok: false, status: 404, text: async () => "connection gone" }; + } + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); + }); + + await assert.rejects( + reauthConnector(config, "missing-api", "Missing API", "https://cb/?c="), + SyntaxError, + ); + + const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); + const matchingRecords = () => readdirSync(pendingCleanup) + .filter((name) => name.endsWith(".json")) + .map((name) => JSON.parse(readFileSync(join(pendingCleanup, name), "utf8"))) + .filter((record) => record.gatewayId.includes("/connectorGateways/missing-conn-gw") && record.apiName === "missing-api"); + assert.equal(matchingRecords().length, 1, "failed reauth cleanup must retain retry data"); + + writeFileSync( + configPath, + JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }), + ); + retrying = true; + await assert.rejects( + installConnector(config, "missing-api", "Missing API", "https://cb/?c="), + /stop after cleanup/, + ); + assert.equal(matchingRecords().length, 0, "the next install must consume successful pending cleanup"); + const localConfig = JSON.parse(readFileSync(configPath, "utf8")); + assert.equal(localConfig.mcpServers["missing-config"], undefined, "pending cleanup must remove the stale local entry"); +}); + +test("fresh-connection rollback surfaces deletion failures", async (t) => { + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; + const realFetch = globalThis.fetch; + globalThis.fetch = async () => ({ ok: false, status: 400, text: async () => "rollback denied" }); + t.after(() => { + globalThis.fetch = realFetch; + }); + + await assert.rejects(deleteConnection(config, "fresh-conn"), /rollback denied/); +}); + +test("finish status failures roll back the fresh connection", async (t) => { + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; + const realFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + calls.push({ method, url }); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) { + return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } }); + } + if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} }); + if (method === "GET" && url.includes("/connections/fresh-status?")) { + return { ok: false, status: 400, text: async () => "status denied" }; + } + if (method === "DELETE" && url.includes("/connections/fresh-status?")) return ok({}); + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + }); + + await assert.rejects( + finishInstall(config, "status-fail", "Status Fail", "fresh-status", "eastus"), + /status denied/, + ); + assert.ok(calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/fresh-status?"))); +}); + +test("failed config cleanup preserves its referenced connection", async (t) => { + const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; + const realFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (urlArg, opts = {}) => { + const url = String(urlArg); + const method = (opts.method || "GET").toUpperCase(); + calls.push({ method, url }); + const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); + if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) { + return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } }); + } + if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} }); + if (method === "GET" && url.includes("/connections/fresh-config?")) { + return ok({ properties: { statuses: [{ status: "Connected" }] } }); + } + if (method === "PUT" && url.includes("/mcpserverConfigs/")) { + return ok({ properties: { mcpEndpointUrl: "https://example.com/mcp" } }); + } + if (method === "POST" && url.includes("/listApiKey?")) { + return { ok: false, status: 400, text: async () => "key denied" }; + } + if (method === "DELETE" && url.includes("/mcpserverConfigs/")) { + return { ok: false, status: 400, text: async () => "config cleanup denied" }; + } + if (method === "DELETE" && url.includes("/connections/")) return ok({}); + throw new Error(`unexpected ARM call: ${method} ${url}`); + }; + t.after(() => { + globalThis.fetch = realFetch; + }); + + await assert.rejects( + finishInstall(config, "cleanup-order", "Cleanup Order", "fresh-config", "eastus"), + /config cleanup denied/, + ); + assert.ok( + !calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/")), + "a surviving config must keep its referenced connection", + ); +}); diff --git a/extensions/connector-namespaces/install.test.mjs b/extensions/connector-namespaces/install.test.mjs new file mode 100644 index 00000000..5e51de54 --- /dev/null +++ b/extensions/connector-namespaces/install.test.mjs @@ -0,0 +1,253 @@ +// Regression guards for install-state selection. +// +// Run: node --test extensions/connector-namespaces/install.test.mjs +// +// These exist because getInstalledState used to collapse N gateway configs for +// one apiName down to a single tile via ARM list order (last-wins). A portal +// add, a duplicate Connect, or a re-auth would mint a sibling config; whichever +// ARM happened to return last owned the tile, so a tile could show +// "Re-authenticate" while a different config for the same connector was already +// Connected. deriveInstalledState now picks deterministically: +// inCli && Connected > inCli > Connected > any, configName wins ties. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { deriveInstalledState, getConsentUrl, getConnectionStatus, getMcpEndpointUrl, waitForConnected } from "./install.mjs"; + +// removeLocalEntry does file I/O (getInstalledState reads ARM + mcp configs, +// removeMcpEntry edits them) and calls both as same-module functions, so there +// is no import seam to stub. The invariant that matters — the default "Remove" +// only unlinks the CLI entry and NEVER deletes the Azure resource — is a +// source contract, so we assert it against the function body the same way +// renderer.test.mjs guards its CSS/HTML strings. +function functionBody(source, name) { + const exported = source.indexOf(`export async function ${name}(`); + const start = exported !== -1 ? exported : source.indexOf(`async function ${name}(`); + if (start === -1) return null; + const open = source.indexOf("{", start); + if (open === -1) return null; + let depth = 0; + for (let i = open; i < source.length; i++) { + const ch = source[i]; + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) return source.slice(open + 1, i); + } + } + return null; +} + +const installSource = readFileSync(fileURLToPath(new URL("./install.mjs", import.meta.url)), "utf8"); + +// Build a fake ARM mcpserverConfig list entry. +function cfg(name, apiName, connName) { + return { name, properties: { connectors: [{ name: apiName, connectionName: connName }] } }; +} + +// Build a connName -> connection map with a given status. +function conns(...entries) { + const m = new Map(); + for (const [connName, status] of entries) { + m.set(connName, { name: connName, properties: { statuses: [{ status }] } }); + } + return m; +} + +test("picks inCli+Connected over a not-inCli sibling that appears LAST (not last-wins)", () => { + // good config is FIRST; a broken sibling is LAST. Old last-wins would pick + // the last one — the fix must pick the good one regardless of order. + const configs = [ + cfg("good", "shared-api", "connGood"), + cfg("bad", "shared-api", "connBad"), + ]; + const connByName = conns(["connGood", "Connected"], ["connBad", "Unknown"]); + const profileKeys = new Set(["good"]); // only the good config is in the CLI + const state = deriveInstalledState(configs, connByName, profileKeys, new Set(), null); + + assert.equal(state["shared-api"].configName, "good"); + assert.equal(state["shared-api"].connectionName, "connGood"); + assert.equal(state["shared-api"].connectionStatus, "Connected"); + assert.equal(state["shared-api"].inCli, true); + assert.equal(state["shared-api"]._configCount, 2); + assert.deepEqual(state["shared-api"]._candidates.map((item) => item.configName), ["good", "bad"]); +}); + +test("inCli beats a Connected-but-not-inCli sibling", () => { + // A is the config the local session points at but not yet Connected; B is + // Connected on ARM but not in the CLI. Prefer A so remove/re-auth act on the + // resource the user's session actually uses. + const configs = [ + cfg("a-incli", "api", "connA"), + cfg("b-connected", "api", "connB"), + ]; + const connByName = conns(["connA", "Unknown"], ["connB", "Connected"]); + const state = deriveInstalledState(configs, connByName, new Set(["a-incli"]), new Set(), null); + + assert.equal(state["api"].configName, "a-incli"); + assert.equal(state["api"].inCli, true); +}); + +test("inCli && Connected beats inCli-only", () => { + const configs = [ + cfg("incli-unknown", "api", "connU"), + cfg("incli-connected", "api", "connC"), + ]; + const connByName = conns(["connU", "Unknown"], ["connC", "Connected"]); + const state = deriveInstalledState(configs, connByName, new Set(["incli-unknown", "incli-connected"]), new Set(), null); + + assert.equal(state["api"].configName, "incli-connected"); + assert.equal(state["api"].connectionStatus, "Connected"); +}); + +test("config name breaks equal-rank ties independently of ARM list order", () => { + const configs = [ + cfg("z-config", "api", "connZ"), + cfg("a-config", "api", "connA"), + ]; + const connByName = conns(["connZ", "Connected"], ["connA", "Connected"]); + const local = new Set(["z-config", "a-config"]); + + const forward = deriveInstalledState(configs, connByName, local, new Set(), null); + const reverse = deriveInstalledState([...configs].reverse(), connByName, local, new Set(), null); + + assert.equal(forward.api.configName, "a-config"); + assert.equal(reverse.api.configName, "a-config"); +}); + +test("connection convergence reports non-connected terminal results as failures", async () => { + const states = ["Connecting", "Error"]; + const delays = []; + await assert.rejects( + waitForConnected({}, "conn", { + maxPolls: 2, + getStatus: async () => states.shift(), + delay: async (ms) => delays.push(ms), + }), + /Connection ended in state "Error"/, + ); + assert.deepEqual(delays, [1000]); + assert.equal( + await waitForConnected({}, "conn", { + getStatus: async () => "Connected", + delay: async () => assert.fail("connected state must not sleep"), + }), + "Connected", + ); +}); + +test("single config passes through with no _configCount", () => { + const configs = [cfg("only", "api", "conn1")]; + const connByName = conns(["conn1", "Connected"]); + const state = deriveInstalledState(configs, connByName, new Set(["only"]), new Set(), null); + + assert.equal(state["api"].configName, "only"); + assert.equal(state["api"]._configCount, undefined); +}); + +test("workspace membership counts as inCli and sets scope/path", () => { + const configs = [cfg("ws", "api", "conn1")]; + const connByName = conns(["conn1", "Connected"]); + const state = deriveInstalledState(configs, connByName, new Set(), new Set(["ws"]), "/repo/.mcp.json"); + + assert.equal(state["api"].inCli, true); + assert.equal(state["api"].cliScope, "workspace"); + assert.equal(state["api"].cliPath, "/repo/.mcp.json"); +}); + +test("connectionStatus falls back to overallStatus then Unknown", () => { + const configs = [cfg("c1", "api1", "connOverall"), cfg("c2", "api2", "connMissing")]; + const connByName = new Map([ + ["connOverall", { name: "connOverall", properties: { overallStatus: "Connected" } }], + ]); + const state = deriveInstalledState(configs, connByName, new Set(), new Set(), null); + + assert.equal(state["api1"].connectionStatus, "Connected"); // from overallStatus + assert.equal(state["api2"].connectionStatus, "Unknown"); // no connection at all +}); + +test("configs with no connector are skipped", () => { + const configs = [ + { name: "broken", properties: { connectors: [] } }, + cfg("ok", "api", "conn1"), + ]; + const connByName = conns(["conn1", "Connected"]); + const state = deriveInstalledState(configs, connByName, new Set(["ok"]), new Set(), null); + + assert.equal(Object.keys(state).length, 1); + assert.equal(state["api"].configName, "ok"); +}); + +test("removeLocalEntry unlinks the local CLI entry via removeMcpEntry", () => { + const body = functionBody(installSource, "removeLocalEntry"); + assert.ok(body, "removeLocalEntry function not found in install.mjs"); + assert.match(body, /removeMcpEntry\s*\(/, "removeLocalEntry must call removeMcpEntry to drop the CLI entry"); + assert.match(body, /entry\._candidates/, "removeLocalEntry must process duplicate CLI configs"); + assert.match(body, /candidate\.inCli/, "removeLocalEntry must unlink every local candidate"); +}); + +test("uninstallConnector deletes every duplicate namespace config", () => { + const body = functionBody(installSource, "uninstallConnector"); + const cleanup = functionBody(installSource, "cleanupConnectorResources"); + assert.ok(body, "uninstallConnector function not found in install.mjs"); + assert.ok(cleanup, "cleanupConnectorResources function not found in install.mjs"); + assert.match(body, /entry\._candidates/, "namespace deletion must process duplicate configs"); + assert.match(body, /cleanupConnectorResources\s*\(/, "uninstall must delegate all collected candidates to shared cleanup"); + assert.match(cleanup, /deleteMcpServerConfigs\(config, configNames\)/); + assert.match(cleanup, /for \(const connectionName of connectionNames\)/); + assert.match(cleanup, /for \(const configName of configNames\)/); +}); + +test("removeLocalEntry never deletes the namespace resource (no armDelete)", () => { + const body = functionBody(installSource, "removeLocalEntry"); + assert.ok(body, "removeLocalEntry function not found in install.mjs"); + // The default Remove must stay local-only. If someone routes it through + // uninstallConnector or adds an ARM delete, this fails — which is the point. + assert.doesNotMatch(body, /armDelete\s*\(/, "removeLocalEntry must not call armDelete"); + assert.doesNotMatch(body, /uninstallConnector\s*\(/, "removeLocalEntry must not delegate to uninstallConnector"); +}); + +// --- ARM path-injection guard (client-reachable read sinks) --- +// +// finishInstall/finishReauth feed client-supplied body.connName / body.configName +// into getConsentUrl, getConnectionStatus, and getMcpEndpointUrl, which build ARM +// URLs. Those names must pass through armSegment() so a traversal / query payload +// can't escape the intended resource path (SSRF / path injection). armSegment +// throws synchronously while the URL is built, before any token or network call, +// so these run fully offline and deterministic. A valid config is used so the +// gatewayId() wrap doesn't throw first — only the bad NAME should reject. +const validConfig = { subscriptionId: "s", resourceGroup: "r", gatewayName: "g" }; +const badNames = ["../../evil", "evil/../../secret", "x?injected=1"]; + +test("getConnectionStatus rejects traversal/injection connName before any ARM call", async () => { + for (const bad of badNames) { + await assert.rejects( + () => getConnectionStatus(validConfig, bad), + /Invalid ARM resource identifier/, + `getConnectionStatus should reject connName ${JSON.stringify(bad)}`, + ); + } +}); + +test("getConsentUrl rejects traversal/injection connName before any ARM call", async () => { + for (const bad of badNames) { + await assert.rejects( + () => getConsentUrl(validConfig, bad, "http://127.0.0.1:0/auth/callback/x"), + /Invalid ARM resource identifier/, + `getConsentUrl should reject connName ${JSON.stringify(bad)}`, + ); + } +}); + +test("getMcpEndpointUrl rejects traversal/injection configName before any ARM call", async () => { + for (const bad of badNames) { + await assert.rejects( + () => getMcpEndpointUrl(validConfig, bad), + /Invalid ARM resource identifier/, + `getMcpEndpointUrl should reject configName ${JSON.stringify(bad)}`, + ); + } +}); diff --git a/extensions/connector-namespaces/mcp-http-probe.test.mjs b/extensions/connector-namespaces/mcp-http-probe.test.mjs new file mode 100644 index 00000000..009f1086 --- /dev/null +++ b/extensions/connector-namespaces/mcp-http-probe.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; +import { probe } from "./test/mcp-probe.mjs"; + +test("native HTTP probe carries API key and MCP session through an SSE handshake", async (t) => { + const apiKeys = []; + const sessionIds = []; + const methods = []; + const server = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const message = JSON.parse(Buffer.concat(chunks).toString("utf8")); + apiKeys.push(req.headers["x-api-key"]); + sessionIds.push(req.headers["mcp-session-id"] || null); + methods.push(message.method); + + if (message.method === "notifications/initialized") { + res.writeHead(202); + res.end(); + return; + } + + let result; + if (message.method === "initialize") { + res.setHeader("Mcp-Session-Id", "session-1"); + result = { + protocolVersion: "2025-06-18", + serverInfo: { name: "test-server", version: "1.0.0" }, + capabilities: { tools: {} }, + }; + } else if (message.method === "tools/list") { + result = { tools: [{ name: "ListTeams", inputSchema: { type: "object" } }] }; + } else { + result = { content: [{ type: "text", text: "ok" }] }; + } + + res.setHeader("Content-Type", "text/event-stream"); + res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n\n`); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const { port } = server.address(); + const result = await probe({ + apiName: "WorkIQTeams", + displayName: "WorkIQ Teams", + url: `http://127.0.0.1:${port}/mcp`, + key: "secret", + }); + + assert.equal(result.ok, true); + assert.equal(result.toolCount, 1); + assert.equal(result.toolCalled, "ListTeams"); + assert.deepEqual(methods, ["initialize", "notifications/initialized", "tools/list", "tools/call"]); + assert.deepEqual(apiKeys, ["secret", "secret", "secret", "secret"]); + assert.deepEqual(sessionIds, [null, "session-1", "session-1", "session-1"]); +}); diff --git a/extensions/connector-namespaces/package.json b/extensions/connector-namespaces/package.json new file mode 100644 index 00000000..ac0c28b0 --- /dev/null +++ b/extensions/connector-namespaces/package.json @@ -0,0 +1,19 @@ +{ + "name": "connector-namespaces", + "version": "1.1.0", + "type": "module", + "main": "extension.mjs", + "description": "Browse, connect, and open MCP connectors from your Azure Connector Namespace in Sandbox.", + "keywords": [ + "azure", + "connector-namespace", + "mcp", + "mcp-connectors", + "model-context-protocol", + "tool-discovery" + ], + "license": "MIT", + "dependencies": { + "@github/copilot-sdk": "1.0.6" + } +} diff --git a/extensions/connector-namespaces/preview/.gitignore b/extensions/connector-namespaces/preview/.gitignore new file mode 100644 index 00000000..0bac4c45 --- /dev/null +++ b/extensions/connector-namespaces/preview/.gitignore @@ -0,0 +1,2 @@ +# Screenshot evidence is throwaway, regenerated on demand by shots.mjs. +shots/ diff --git a/extensions/connector-namespaces/preview/README.md b/extensions/connector-namespaces/preview/README.md new file mode 100644 index 00000000..14b839b1 --- /dev/null +++ b/extensions/connector-namespaces/preview/README.md @@ -0,0 +1,112 @@ +# connector-namespaces preview harness + +A standalone way to **see** every canvas state without launching the Copilot +app. It imports the real, pure renderer functions from `../renderer.mjs` and +serves each state on a fixed loopback port, with every `/api/*` endpoint stubbed +so you can force the states that keep regressing (the connecting spinner and the +"Restart your Copilot session…" banner). + +This exists because those two bugs have each shipped multiple times: + +- the sign-in spinner freezing (an unscoped `animation:none` leaking out of the + reduced-motion block), and +- the restart-banner dismiss button doing nothing (a CSS specificity bug that + let `.restart-banner{display:flex}` beat `[hidden]`). + +Both are static CSS facts, so the **deterministic gate is `../renderer.test.mjs`** +(run with `node --test`). This harness is the human-visual layer on top of it: +load a state in a browser, or capture screenshots with `agent-browser`. + +## Run the preview server + +```sh +node extensions/connector-namespaces/preview/server.mjs +``` + +It binds to `http://127.0.0.1:7331`. Open that URL in any browser. The server is +a plain HTTP process (not the JSON-RPC extension provider), so it logs every hit +to stdout — that's expected and fine here. + +### State routes + +| URL | State | +| --- | --- | +| `/` or `/catalog` | Configured catalog (mock gateway + connectors) | +| `/setup` | First-run gateway picker (`renderSetupHtml`) | +| `/error` | Error screen (`renderErrorHtml`) | + +### State-forcing query flags (on the catalog route) + +The catalog page hydrates from `/api/state` on load, so loading one of these +sets the state the very next `/api/state` returns: + +| Flag | Effect | +| --- | --- | +| `/?restart=1` | `/api/state` returns `pendingRestart:true` → restart banner visible on load | +| `/?installed=1` | One connector shows as already installed/connected | + +Flags combine, e.g. `/?installed=1&restart=1`. + +> The active state is a single module-level flag (last catalog load wins). It's a +> single-user preview, so just load the page you want, then it's sticky until the +> next catalog load. + +### Stubbed endpoints + +`/api/state`, `/api/gateways`, `/api/select-gateway`, `/api/install` (returns +`needsConsent` to force the connecting spinner), `/api/finish-install`, +`/api/ack-restart` (the dismiss action), `/oauth-status` (stays pending so the +modal spinner keeps animating), `/api/uninstall`, `/api/rollback-connection`, +and `/api/open-url` (a deliberate **no-op** here — it must never actually launch +a browser tab). + +## Capture screenshots (optional) + +The screenshot driver uses [`agent-browser`](https://www.npmjs.com/package/agent-browser), +the same headless-Chromium verification tool that `arikbidny/ralph-copilot-cli` +uses. It is **not** required — if it isn't installed the driver prints an install +hint and exits 0. + +Install it once: + +```sh +npm i -g agent-browser && agent-browser install +``` + +Then, with the server running in another terminal: + +```sh +node extensions/connector-namespaces/preview/shots.mjs +``` + +Screenshots are written to `preview/shots/`: + +- `catalog.png`, `catalog-restart-banner.png`, `catalog-installed.png`, + `setup.png`, `error.png` — the static states. +- `connecting-spinner.png` — after clicking **Connect**; verify the `.si-spin` + ring is mid-rotation, not frozen. +- `banner-before-dismiss.png` / `banner-after-dismiss.png` — verify the banner is + present in the first and **gone** in the second. + +`preview/shots/` is throwaway visual evidence; it is not committed. + +## Files + +| File | Purpose | +| --- | --- | +| `server.mjs` | Standalone preview server (fixed port 7331) | +| `fixtures.mjs` | Deterministic mock subscriptions / gateways / catalog / state | +| `shots.mjs` | `agent-browser` screenshot driver (degrades gracefully) | + +## Relationship to the test guard + +`shots.mjs` proves a state *looks* right today and is handy when chasing a new +bug. It cannot prove an animation is *running* from a single frame. The +regression gate that actually blocks the recurring bugs is the CSS-structure +assertion in `../renderer.test.mjs`: + +```sh +node --test extensions/connector-namespaces/renderer.test.mjs +``` + +Keep that green; use this harness to eyeball changes. diff --git a/extensions/connector-namespaces/preview/fixtures.mjs b/extensions/connector-namespaces/preview/fixtures.mjs new file mode 100644 index 00000000..0fce100b --- /dev/null +++ b/extensions/connector-namespaces/preview/fixtures.mjs @@ -0,0 +1,117 @@ +// Deterministic fixtures for the standalone canvas preview server. +// +// These mirror the exact response shapes the inline client script in +// renderer.mjs expects, so the preview server can drive every canvas state +// (setup / catalog / error / connecting-spinner / restart-banner) with no +// Copilot app, no ARM, and no real OAuth. Keep these shapes in sync with the +// fetch() handlers in renderer.mjs if those response contracts change. + +import { CATEGORY } from "../categories.mjs"; + +export const subscriptions = [ + { id: "00000000-0000-0000-0000-000000000001", name: "Contoso Production" }, + { id: "11111111-1111-1111-1111-111111111111", name: "Contoso Dev/Test" }, +]; + +// /api/gateways?subscriptionId=... -> { gateways: [{ id, name, location }], hasMore } +// The client splits id on "/" and reads the segment after "resourceGroups", +// so the id must contain a resourceGroups segment. +export const gateways = [ + { + id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-connectors/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/contoso-ns", + name: "contoso-ns", + location: "eastus", + }, + { + id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-shared/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/shared-ns", + name: "shared-ns", + location: "westus2", + }, +]; + +// Active namespace shown in the catalog header (config.gatewayName / resourceGroup). +export const config = { + subscriptionId: "00000000-0000-0000-0000-000000000001", + gatewayName: "contoso-ns", + resourceGroup: "rg-connectors", +}; + +// Catalog tiles. Shape per item: { category, displayName, apiName, description, +// iconUri?, brandColor? }. At least one item must be connectable so the +// connect -> spinner flow can be exercised. +// +// The renderer routes items by category: exactly `category === CATEGORY.microsoft` +// lands in the Microsoft section, everything else in Partners. Keep a mix of +// both here so the preview exercises the full 3-section layout (My MCPs / +// Microsoft / Partners) rather than dumping every tile into one section. +export const catalog = [ + { + category: CATEGORY.microsoft, + displayName: "Microsoft Teams", + apiName: "teams", + description: "Send messages, manage chats and channels.", + brandColor: "#5059c9", + }, + { + category: CATEGORY.microsoft, + displayName: "Outlook Mail", + apiName: "outlook", + description: "Read, send, and organize email.", + brandColor: "#0a66c2", + }, + { + category: CATEGORY.microsoft, + displayName: "SharePoint", + apiName: "sharepoint", + description: "Browse sites, lists, and documents.", + brandColor: "#038387", + }, + { + category: CATEGORY.partner, + displayName: "GitHub", + apiName: "github", + description: "Manage repos, issues, and pull requests.", + brandColor: "#24292e", + }, + { + category: CATEGORY.partner, + displayName: "Stripe", + apiName: "stripe", + description: "Payments, customers, and invoices.", + brandColor: "#635bff", + }, +]; + +// /api/state -> { state: { apiName: InstallState }, pendingRestart } +// InstallState: { installed, connectionStatus, inCli, cliPath?, cliScope? } +// Default state: nothing installed, no pending restart. The catalog renders +// every tile with a "Connect" button. +export const stateEmpty = { + state: {}, + pendingRestart: false, +}; + +// One connector already added (shows "Added" + Remove), restart pending so the +// banner is visible on load. Drives both the "added" tile and the banner state. +export const stateInstalledRestart = { + state: { + sharepoint: { + installed: true, + connectionStatus: "Connected", + inCli: true, + cliPath: "~/.copilot/mcp-config.json", + cliScope: "profile", + }, + }, + pendingRestart: true, +}; + +// Install response that forces the connecting flow. needsConsent keeps the +// sign-in modal (with the .si-spin spinner) open; /oauth-status then stays +// pending so the spinner keeps animating for a screenshot. +export const installNeedsConsent = { + needsConsent: true, + connName: "preview-conn", + consentUrl: "http://127.0.0.1:7331/fake-consent", + location: "eastus", +}; diff --git a/extensions/connector-namespaces/preview/server.mjs b/extensions/connector-namespaces/preview/server.mjs new file mode 100644 index 00000000..d56a14c6 --- /dev/null +++ b/extensions/connector-namespaces/preview/server.mjs @@ -0,0 +1,151 @@ +// Standalone preview server for the connector-namespaces connector catalog. +// +// Renders every canvas state with no Copilot app, no ARM, and no real OAuth by +// importing the *pure* HTML builders from renderer.mjs and stubbing every +// /api/* endpoint the inline client script calls. Point any browser (or the +// agent-browser driver in shots.mjs) at it to see exactly what ships. +// +// Run: node extensions/connector-namespaces/preview/server.mjs +// Then open http://127.0.0.1:7331/ (catalog), /setup, /error. +// +// This process is NOT the JSON-RPC extension provider, so console.log here is +// fine and intentional — it is how you watch which stubbed endpoints get hit. + +import { createServer } from "node:http"; + +import { + renderCatalogHtml, + renderSetupHtml, + renderErrorHtml, +} from "../renderer.mjs"; +import * as fixtures from "./fixtures.mjs"; + +const HOST = "127.0.0.1"; +const PORT = 7331; +const INSTANCE = "preview"; + +// Whatever /api/state should report next. The catalog route updates this from +// its query flags so a page load can force the banner / "added" tile on, and a +// real Connect click flips pendingRestart on via showRestartBanner(). +let activeState = fixtures.stateEmpty; + +function selectState(query) { + const restart = query.get("restart") === "1"; + const installed = query.get("installed") === "1"; + if (restart && installed) return fixtures.stateInstalledRestart; + if (installed) return { state: fixtures.stateInstalledRestart.state, pendingRestart: false }; + if (restart) return { state: {}, pendingRestart: true }; + return fixtures.stateEmpty; +} + +function sendHtml(res, body) { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(body); +} + +function sendJson(res, obj, status = 200) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(obj)); +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + return {}; + } +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://${HOST}:${PORT}`); + const path = url.pathname; + const q = url.searchParams; + // Strip CR/LF/tab so a crafted request line can't forge extra log entries. + console.log(`${req.method} ${req.url}`.replace(/[\r\n\t]/g, " ")); + + // --- Page routes --------------------------------------------------------- + if (req.method === "GET" && (path === "/" || path === "/catalog")) { + activeState = selectState(q); + return sendHtml( + res, + renderCatalogHtml(INSTANCE, fixtures.catalog, { + filter: q.get("filter") || "", + category: q.get("category") || "all", + source: q.get("source") || "", + config: fixtures.config, + }), + ); + } + if (req.method === "GET" && path === "/setup") { + return sendHtml(res, renderSetupHtml(fixtures.subscriptions)); + } + if (req.method === "GET" && path === "/error") { + return sendHtml(res, renderErrorHtml(q.get("message") || "Something went wrong loading connectors.")); + } + if (req.method === "GET" && path === "/fake-consent") { + return sendHtml(res, "ConsentFake Microsoft consent page (preview). Close this tab."); + } + + // --- Stubbed API endpoints ---------------------------------------------- + if (req.method === "GET" && path === "/api/state") { + return sendJson(res, activeState); + } + if (req.method === "GET" && path === "/api/gateways") { + return sendJson(res, { gateways: fixtures.gateways, hasMore: false }); + } + if (req.method === "GET" && path === "/oauth-status") { + // Stay pending forever so the connecting spinner keeps animating for a + // screenshot. Flip to { done: true } if you want the full success flow. + return sendJson(res, { done: false }); + } + + if (req.method === "POST") { + await readBody(req); + switch (path) { + case "/api/select-gateway": + return sendJson(res, { ok: true }); + case "/api/install": + return sendJson(res, fixtures.installNeedsConsent); + case "/api/finish-install": + activeState = { ...activeState, pendingRestart: true }; + return sendJson(res, { ok: true }); + case "/api/ack-restart": + activeState = { ...activeState, pendingRestart: false }; + return sendJson(res, { ok: true }); + case "/api/uninstall": + return sendJson(res, { ok: true }); + case "/api/open-url": + // Preview no-op: do NOT actually launch a browser tab. + return sendJson(res, { ok: true }); + case "/api/rollback-connection": + return sendJson(res, { ok: true }); + default: + return sendJson(res, { error: `unstubbed POST ${path}` }, 404); + } + } + + res.statusCode = 404; + res.end("not found"); +}); + +server.on("error", (err) => { + if (err.code === "EADDRINUSE") { + console.error(`Port ${PORT} is already in use. Stop the other process or change PORT in server.mjs.`); + process.exit(1); + } + throw err; +}); + +server.listen(PORT, HOST, () => { + console.log(`canvas preview server: http://${HOST}:${PORT}/`); + console.log(" / catalog (empty state)"); + console.log(" /?restart=1 catalog with restart banner visible"); + console.log(" /?installed=1 catalog with one connector added"); + console.log(" /setup namespace picker"); + console.log(" /error error state"); + console.log("Press Ctrl+C to stop."); +}); diff --git a/extensions/connector-namespaces/preview/shots.mjs b/extensions/connector-namespaces/preview/shots.mjs new file mode 100644 index 00000000..aab8f17b --- /dev/null +++ b/extensions/connector-namespaces/preview/shots.mjs @@ -0,0 +1,96 @@ +// agent-browser screenshot driver for the canvas preview server. +// +// Captures every canvas state to ./shots/ and drives the two interaction flows +// that keep regressing: +// 1. catalog -> click Connect -> sign-in modal with the spinning .si-spin +// 2. restart banner visible -> click dismiss -> banner gone +// +// Requires the preview server to be running: +// node extensions/connector-namespaces/preview/server.mjs +// And agent-browser installed: +// npm i -g agent-browser && agent-browser install +// +// If agent-browser is not installed, this script prints how to install it and +// exits 0 (so it never breaks an unattended run). This is a visual-evidence +// helper; the deterministic regression gate is renderer.test.mjs. + +import { spawnSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SHOTS = join(HERE, "shots"); +const BASE = "http://127.0.0.1:7331"; + +function hasAgentBrowser() { + const probe = spawnSync("agent-browser", ["--version"], { encoding: "utf8", shell: true }); + return probe.status === 0; +} + +function ab(args) { + const r = spawnSync("agent-browser", args, { encoding: "utf8", shell: true }); + if (r.status !== 0) { + console.error(`agent-browser ${args.join(" ")} failed:\n${r.stderr || r.stdout}`); + } + return r; +} + +function serverUp() { + // Node 18+ has global fetch. Confirm the preview server is reachable. + return fetch(`${BASE}/api/state`).then(() => true).catch(() => false); +} + +async function main() { + if (!hasAgentBrowser()) { + console.log("agent-browser is not installed -> skipping screenshots."); + console.log("Install it with: npm i -g agent-browser && agent-browser install"); + console.log("Then re-run: node extensions/connector-namespaces/preview/shots.mjs"); + process.exit(0); + } + + if (!(await serverUp())) { + console.error("preview server is not reachable at " + BASE); + console.error("start it first: node extensions/connector-namespaces/preview/server.mjs"); + process.exit(1); + } + + mkdirSync(SHOTS, { recursive: true }); + + // Static states. + const states = [ + ["catalog", `${BASE}/`], + ["catalog-restart-banner", `${BASE}/?restart=1`], + ["catalog-installed", `${BASE}/?installed=1`], + ["setup", `${BASE}/setup`], + ["error", `${BASE}/error`], + ]; + for (const [name, target] of states) { + ab(["open", target]); + ab(["screenshot", join(SHOTS, `${name}.png`)]); + console.log(`captured ${name}`); + } + + // Flow 1: connect -> connecting spinner. The preview /api/install returns + // needsConsent and /oauth-status stays pending, so the .si-spin modal + // spinner keeps animating. Best-effort selector; adjust if markup changes. + ab(["open", `${BASE}/`]); + ab(["click", ".item-add[data-api]"]); + ab(["screenshot", join(SHOTS, "connecting-spinner.png")]); + console.log("captured connecting-spinner (verify the spinner is mid-rotation)"); + + // Flow 2: banner -> dismiss -> gone. Screenshot before and after the click + // so a frozen/broken dismiss button is visible as a diff. + ab(["open", `${BASE}/?restart=1`]); + ab(["screenshot", join(SHOTS, "banner-before-dismiss.png")]); + ab(["click", ".restart-banner .rb-dismiss"]); + ab(["screenshot", join(SHOTS, "banner-after-dismiss.png")]); + console.log("captured banner-before-dismiss / banner-after-dismiss (after should have no banner)"); + + console.log(`\nshots written to ${SHOTS}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/extensions/connector-namespaces/renderer.mjs b/extensions/connector-namespaces/renderer.mjs new file mode 100644 index 00000000..e4ddf609 --- /dev/null +++ b/extensions/connector-namespaces/renderer.mjs @@ -0,0 +1,1563 @@ +// Renderers for the connector namespace picker and connector catalog pages. +// Styled to match the reference connector extension UI. + +import { CATEGORY } from "./categories.mjs"; +import { buildSandboxUrl } from "./sandbox.mjs"; + +const CONNECT_ICON = ''; + +// Official Azure Connector Namespace mark — a gray viewfinder frame wrapping +// two interlocking blue-gradient chain links. Path + gradient data is lifted +// verbatim from the portal's ConnectorNamespaceIcon brand asset. idSuffix keeps +// the gradient element IDs unique when the mark renders more than once per page. +export function brandMark(size = 28, idSuffix = "m") { + const g0 = `cn-g0-${idSuffix}`; + const g1 = `cn-g1-${idSuffix}`; + return ``; +} + +export function baseStyles() { + return ``; +} + +// --------------------------------------------------------------------------- +// Setup / Namespace Picker +// --------------------------------------------------------------------------- + +export function renderSetupHtml(subscriptions, notice = "", capabilityToken = "") { + const subOptions = subscriptions.map((s) => + `` + ).join(""); + + return ` + +Select Connector Namespace${baseStyles()} + +
+

${brandMark(30, "setup")}Select a Connector Namespace

+
Choose which connector namespace to browse. This choice is saved for future sessions.
+
+${notice ? `
${esc(notice)}
` : ""} +
+ +
+
+ + +
+ +
+
Select a subscription to see available connector namespaces.
+
+`; +} + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +const CSS_HEX_COLOR = /^#[0-9a-fA-F]{6}$/; + +function iconBackgroundStyle(brandColor) { + const color = String(brandColor || "").trim(); + return CSS_HEX_COLOR.test(color) ? ` style="background:${color}22"` : ""; +} + +export function renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, capabilityToken = "") { + const renderItem = (c) => { + // Items carry their home grid so hydrateState can move them into + // "My MCPs" when added and back to Microsoft/Partner on remove. + const home = c.category === CATEGORY.microsoft ? "microsoft" : "partner"; + const icon = c.iconUri + ? `
` + : `
${esc(c.displayName.charAt(0))}
`; + // Button state is hydrated client-side from /api/state on load. + const btn = ``; + const haystack = esc((c.displayName + " " + (c.description || "")).toLowerCase()); + const sandboxUrl = esc(buildSandboxUrl(config, c.apiName)); + return `
${icon}
${esc(c.displayName)}
${esc(c.description)}
${btn}
`; + }; + + const byName = (a, b) => a.displayName.localeCompare(b.displayName); + const microsoft = catalog.filter((c) => c.category === CATEGORY.microsoft).sort(byName); + const partner = catalog.filter((c) => c.category !== CATEGORY.microsoft).sort(byName); + + const section = (key, title, rows, { collapsed, hidden }) => { + const cls = ["section", "collapsible"]; + if (collapsed) cls.push("collapsed"); + if (hidden) cls.push("is-hidden"); + const n = rows.length; + return `
` + + `` + + `
${rows.map(renderItem).join("")}
` + + `
`; + }; + + // Server paints the first-run layout: My MCPs hidden+empty (filled by + // hydrateState), Microsoft expanded so there's something to browse, Partner + // collapsed. updateSections() flips to the steady layout on the first hydrate + // if anything is already added. + let sectionsHtml = + section("mine", "My MCPs", [], { collapsed: false, hidden: true }) + + section("microsoft", "Microsoft", microsoft, { collapsed: false, hidden: microsoft.length === 0 }) + + section("partner", "Partners", partner, { collapsed: true, hidden: partner.length === 0 }); + + if (!catalog.length) { + sectionsHtml = `
No connectors available.
`; + } + + return ` + +Connectors${baseStyles()} + +
+
+

${brandMark(24, "cat")}Connectors

+
+
Namespace ${esc(config.gatewayName)} · RG ${esc(config.resourceGroup)}
+
+ + + +
+
+
+ + + + +
+ +${sectionsHtml} + +`; +} + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +export function renderErrorHtml(message) { + return ` +Error${baseStyles()} +

Error

+
${esc(message)}
+`; +} + +// --------------------------------------------------------------------------- +function esc(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); +} diff --git a/extensions/connector-namespaces/renderer.test.mjs b/extensions/connector-namespaces/renderer.test.mjs new file mode 100644 index 00000000..d0c464e4 --- /dev/null +++ b/extensions/connector-namespaces/renderer.test.mjs @@ -0,0 +1,393 @@ +// Regression guards for the connector-catalog renderer. +// +// Run: node --test extensions/connector-namespaces/renderer.test.mjs +// +// These tests exist because two UX bugs kept coming back: +// 1. A `@media (prefers-reduced-motion: reduce)` rule froze functional +// loaders without a visible fallback. Reduced motion now stops the +// animation while forcing each loader into a visible static busy state; +// nearby text continues to communicate progress. +// 2. The "Restart your Copilot session" banner ignoring Dismiss. The real +// root cause was CSS specificity: `.restart-banner{display:flex}` is an +// author rule with the same (0,1,0) specificity as the UA +// `[hidden]{display:none}` rule, so it overrode the hidden attribute and +// `restartBanner.hidden=true` did nothing. The fix is a global +// `[hidden]{display:none !important}` reset. A client-side +// `restartDismissed` flag also keeps a late hydrateState() from re-showing +// it. The guards below fail if either the CSS reset or the JS gate +// disappears. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { baseStyles, renderCatalogHtml, renderSetupHtml } from "./renderer.mjs"; +import { renderCreateNamespaceHtml } from "./createPage.mjs"; +import { CATEGORY } from "./categories.mjs"; + +// Pull the balanced body of the prefers-reduced-motion media block out of a +// stylesheet string (non-greedy regex can't handle the nested rule braces). +// CSS comments are stripped so the guards test declarations rather than prose. +function reducedMotionBlock(css) { + const start = css.indexOf("@media (prefers-reduced-motion: reduce)"); + if (start === -1) return null; + const open = css.indexOf("{", start); + if (open === -1) return null; + let depth = 0; + for (let i = open; i < css.length; i++) { + if (css[i] === "{") depth++; + else if (css[i] === "}" && --depth === 0) { + return css.slice(open + 1, i).replace(/\/\*[\s\S]*?\*\//g, ""); + } + } + return null; +} + +function catalogHtml() { + return renderCatalogHtml("test-instance", [], { + filter: "", + category: "all", + source: "", + config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" }, + }); +} + +test("setup subscription label names its select", () => { + const html = renderSetupHtml([], "", "token"); + assert.match(html, /