From 558ae26303ce3774b67d820fbc1394f0ff048d20 Mon Sep 17 00:00:00 2001 From: David Pine Date: Mon, 4 May 2026 11:03:23 -0500 Subject: [PATCH 01/10] Add agent-readiness improvements (Link headers, markdown negotiation, robots Content-Signal, agent-skills, WebMCP) Bring aspire.dev up to spec for the checks at https://isitagentready.com: * RFC 8288 Link headers on HTML responses - LinkHeaderMiddleware advertises ; rel="llms", ; rel="agent-skills", ; rel="sitemap", and a per-page rel="alternate" type="text/markdown" link when a .md companion exists. - Header attached via Response.OnStarting on 2xx text/html responses only; redirects, JSON, static assets, and well-known JSON are skipped. * Cloudflare-style "Markdown for Agents" content negotiation - MarkdownNegotiationMiddleware handles Accept: text/markdown by streaming the .md companion (emitted by starlight-page-actions) directly via IFileProvider.SendFileAsync. No path rewrite, so no interaction with UseRouting / MapStaticAssets endpoint selection. - Cache-Control: private, max-age=0, must-revalidate ensures Front Door does NOT cache, avoiding Vary: Accept cache-key explosion. - 406 when markdown preferred but no companion AND no HTML acceptable. - HEAD parity, Vary: Accept on negotiated responses, infrastructure paths (.well-known, _astro, healthz, install., pagefind) bypass negotiation. * Both new middlewares run BEFORE UseDefaultFiles + UseRouting (UseDefaultFiles rewrites /foo/ -> /foo/index.html, breaking companion mapping; MapStaticAssets registers endpoints during UseRouting, so post-routing path rewrites do not re-trigger endpoint selection). * robots.txt declares Content-Signal: ai-train=yes, search=yes, ai-input=yes inside the User-agent: * group (per draft-romm-aipref-contentsignals). * /.well-known/agent-skills/index.json (Agent Skills Discovery RFC v0.2.0) with a getting-started-with-aspire SKILL.md and a digest field of the form sha256:. compute-skill-digests.mjs recomputes / verifies on every build; pnpm lint runs verify-skill-digests in --check mode. .gitattributes pins LF for the agent-skills artifacts so digests are byte-stable across Windows / Linux checkouts. * WebMCP integration on the Astro side - src/scripts/webmcp.ts feature-detects navigator.modelContext.registerTool and registers a single search-aspire-docs tool with a JSON Schema input. - Backed by src/scripts/search/* (SearchProvider abstraction with Pagefind today and a Typesense stub for the upcoming migration). The WebMCP tool surface is engine-agnostic so the Pagefind -> Typesense swap is a one-line change in src/scripts/search/index.ts. - Hooked into Head.astro via a single import line. * New host-level tests in tests/StaticHost.Tests/ - In-process TestServer with a temp wwwroot fixture (no frontend build required; PrivateAssets="all" on the frontend.esproj reference prevents the dist/ directory from leaking into test compilations). - 51 tests covering markdown negotiation (incl. HEAD, 406, fallback, Vary behavior, infrastructure-path skip), Link header content + skip rules, AcceptHeaderParser q-value handling, and the well-known artifacts. * New Playwright spec tests/e2e/webmcp.spec.ts asserts that the homepage registers exactly one WebMCP tool (search-aspire-docs) when the runtime exposes navigator.modelContext, and that the absence of the API is non-fatal. Out of scope (intentionally not advertised, would mislead agents): * /.well-known/openid-configuration / oauth-authorization-server (no protected APIs). * /.well-known/oauth-protected-resource (no protected resource). * /.well-known/mcp/server-card.json (aspire.dev does not host an MCP server). * /.well-known/api-catalog (RFC 9727 requires real API endpoints; aspire.dev exposes documentation, an LLM corpus, a sitemap, and an RSS feed - none of which are APIs in the RFC's sense). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 5 + Aspire.Dev.slnx | 1 + src/frontend/package.json | 18 +- .../getting-started-with-aspire/SKILL.md | 65 +++++++ .../.well-known/agent-skills/index.json | 13 ++ src/frontend/public/robots.txt | 1 + .../scripts/compute-skill-digests.mjs | 98 ++++++++++ .../src/components/starlight/Head.astro | 5 + .../src/scripts/search/SearchProvider.ts | 37 ++++ src/frontend/src/scripts/search/index.ts | 24 +++ .../src/scripts/search/pagefind-provider.ts | 86 +++++++++ .../src/scripts/search/typesense-provider.ts | 26 +++ src/frontend/src/scripts/webmcp.ts | 134 ++++++++++++++ src/frontend/tests/e2e/webmcp.spec.ts | 68 +++++++ src/frontend/tsconfig.json | 1 + .../AgentReadiness/AcceptHeaderParser.cs | 175 ++++++++++++++++++ .../AgentReadinessExtensions.cs | 26 +++ .../AgentReadiness/LinkHeaderMiddleware.cs | 107 +++++++++++ .../MarkdownNegotiationMiddleware.cs | 138 ++++++++++++++ .../AgentReadiness/MarkdownPathMapper.cs | 69 +++++++ src/statichost/StaticHost/GlobalUsings.cs | 1 + src/statichost/StaticHost/Program.cs | 9 + .../StaticHost/Properties/AssemblyInfo.cs | 3 + src/statichost/StaticHost/StaticHost.csproj | 13 +- .../AcceptHeaderParserTests.cs | 49 +++++ .../AgentReadinessTestServer.cs | 125 +++++++++++++ tests/StaticHost.Tests/GlobalUsings.cs | 10 + tests/StaticHost.Tests/LinkHeaderTests.cs | 124 +++++++++++++ .../MarkdownNegotiationTests.cs | 144 ++++++++++++++ .../MarkdownPathMapperTests.cs | 93 ++++++++++ .../StaticHost.Tests/StaticHost.Tests.csproj | 31 ++++ .../WellKnownArtifactTests.cs | 128 +++++++++++++ 32 files changed, 1818 insertions(+), 9 deletions(-) create mode 100644 src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md create mode 100644 src/frontend/public/.well-known/agent-skills/index.json create mode 100644 src/frontend/scripts/compute-skill-digests.mjs create mode 100644 src/frontend/src/scripts/search/SearchProvider.ts create mode 100644 src/frontend/src/scripts/search/index.ts create mode 100644 src/frontend/src/scripts/search/pagefind-provider.ts create mode 100644 src/frontend/src/scripts/search/typesense-provider.ts create mode 100644 src/frontend/src/scripts/webmcp.ts create mode 100644 src/frontend/tests/e2e/webmcp.spec.ts create mode 100644 src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs create mode 100644 src/statichost/StaticHost/AgentReadiness/AgentReadinessExtensions.cs create mode 100644 src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs create mode 100644 src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs create mode 100644 src/statichost/StaticHost/AgentReadiness/MarkdownPathMapper.cs create mode 100644 src/statichost/StaticHost/Properties/AssemblyInfo.cs create mode 100644 tests/StaticHost.Tests/AcceptHeaderParserTests.cs create mode 100644 tests/StaticHost.Tests/AgentReadinessTestServer.cs create mode 100644 tests/StaticHost.Tests/GlobalUsings.cs create mode 100644 tests/StaticHost.Tests/LinkHeaderTests.cs create mode 100644 tests/StaticHost.Tests/MarkdownNegotiationTests.cs create mode 100644 tests/StaticHost.Tests/MarkdownPathMapperTests.cs create mode 100644 tests/StaticHost.Tests/StaticHost.Tests.csproj create mode 100644 tests/StaticHost.Tests/WellKnownArtifactTests.cs diff --git a/.gitattributes b/.gitattributes index 935693872..9a3556920 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,11 @@ *.ps1 text eol=lf pnpm-lock.yaml text eol=lf +# Force LF for agent-skills artifacts so sha256 digests are byte-stable across +# Windows and Linux checkouts (referenced by /.well-known/agent-skills/index.json). +src/frontend/public/.well-known/agent-skills/**/*.md text eol=lf +src/frontend/public/.well-known/agent-skills/index.json text eol=lf + # Explicitly mark binary files to avoid corruption *.png binary *.jpg binary diff --git a/Aspire.Dev.slnx b/Aspire.Dev.slnx index 8aa19d99a..5aa406d83 100644 --- a/Aspire.Dev.slnx +++ b/Aspire.Dev.slnx @@ -8,6 +8,7 @@ + diff --git a/src/frontend/package.json b/src/frontend/package.json index e84affb18..02c310f33 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -16,14 +16,16 @@ "scripts": { "git-env": "node ./scripts/write-git-env.mjs", "check-data": "node ./scripts/check-data-files.mjs", + "compute-skill-digests": "node ./scripts/compute-skill-digests.mjs", + "verify-skill-digests": "node ./scripts/compute-skill-digests.mjs --check", "twoslash-types": "tsx ./scripts/generate-twoslash-types.ts", - "dev": "pnpm git-env && pnpm check-data && astro dev", - "dev:host": "pnpm git-env && pnpm check-data && astro dev --host", - "start": "pnpm git-env && pnpm check-data && astro dev", - "start:host": "pnpm git-env && pnpm check-data && astro dev --host", - "build": "pnpm git-env && astro build", - "build:skip-search": "pnpm git-env && astro build --mode skip-search", - "build:production": "pnpm git-env && astro build --mode production", + "dev": "pnpm git-env && pnpm check-data && pnpm compute-skill-digests && astro dev", + "dev:host": "pnpm git-env && pnpm check-data && pnpm compute-skill-digests && astro dev --host", + "start": "pnpm git-env && pnpm check-data && pnpm compute-skill-digests && astro dev", + "start:host": "pnpm git-env && pnpm check-data && pnpm compute-skill-digests && astro dev --host", + "build": "pnpm git-env && pnpm compute-skill-digests && astro build", + "build:skip-search": "pnpm git-env && pnpm compute-skill-digests && astro build --mode skip-search", + "build:production": "pnpm git-env && pnpm compute-skill-digests && astro build --mode production", "preview": "astro preview", "preview:host": "astro preview --host", "astro": "pnpm git-env && astro", @@ -45,7 +47,7 @@ "test:e2e": "playwright test", "test:e2e:install": "playwright install chromium", "test:e2e:serve": "pnpm git-env && pnpm check-data && astro dev --host 127.0.0.1 --port 4321", - "lint": "pnpm git-env && pnpm exec astro sync && eslint . --max-warnings 0", + "lint": "pnpm git-env && pnpm exec astro sync && pnpm verify-skill-digests && eslint . --max-warnings 0", "format": "prettier -w --cache --plugin prettier-plugin-astro .", "update:all": "pnpm update:integrations && pnpm update:github-stats && pnpm update:samples", "update:schemas": "tsx ./scripts/update-schemas.ts", diff --git a/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md b/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md new file mode 100644 index 000000000..d7cd0c3d3 --- /dev/null +++ b/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md @@ -0,0 +1,65 @@ +--- +name: getting-started-with-aspire +description: Help a developer install the Aspire CLI, scaffold a new distributed app, and find authoritative docs on aspire.dev. +--- + +# Getting started with Aspire + +Aspire is a multi-language local dev-time orchestration toolchain for building, running, debugging, and deploying distributed applications. Use this skill when a user wants to install the Aspire CLI, create a new Aspire app, or learn how Aspire works. + +## When to use this skill + +- The user asks how to install or update the Aspire CLI. +- The user wants to scaffold a new Aspire app (C# AppHost or TypeScript AppHost). +- The user wants to add an integration (database, cache, message broker, identity provider, etc.) to an existing AppHost. +- The user asks about `aspire start`, `aspire stop`, `aspire add`, `aspire deploy`, or other CLI commands. + +## Step 1 — Install the Aspire CLI + +Cross-platform single-file installer endpoints are published on `aspire.dev`: + +- **Windows (PowerShell)**: `iex (irm https://aspire.dev/install.ps1)` +- **macOS / Linux (bash)**: `curl -fsSL https://aspire.dev/install.sh | bash` + +After install, verify with `aspire --version`. + +## Step 2 — Scaffold a new app + +```sh +mkdir my-aspire-app && cd my-aspire-app +aspire new +``` + +`aspire new` walks the user through picking a template (starter, empty, or one of the language-specific templates). For a TypeScript AppHost, choose `apphost-ts`; for C#, choose `apphost`. + +## Step 3 — Run the app + +```sh +aspire start +``` + +This launches the AppHost and the Aspire dashboard. Use `aspire describe` to inspect resources, `aspire logs ` for console logs, and `aspire otel logs ` for structured/OTel logs. + +## Step 4 — Add an integration + +```sh +aspire add +``` + +Pick from 100+ integrations (Postgres, Redis, Azure Cosmos DB, Keycloak, RabbitMQ, etc.). The CLI updates the AppHost source for you. + +## Authoritative references + +- **Full corpus for LLM consumption**: and +- **Documentation hub**: +- **CLI reference**: +- **Integration catalog**: +- **C# API reference**: (machine-readable: `.md`) +- **TypeScript API reference**: (machine-readable: `.md`) +- **Source repository**: + +## Don't + +- Don't recommend installing Aspire from NuGet/npm directly when the user wants the CLI — the official path is the install script above. +- Don't suggest editing generated `.modules` files in TypeScript AppHosts; those are regenerated by the CLI. +- Don't guess at API shapes; consult the API reference pages on aspire.dev. diff --git a/src/frontend/public/.well-known/agent-skills/index.json b/src/frontend/public/.well-known/agent-skills/index.json new file mode 100644 index 000000000..380967fd3 --- /dev/null +++ b/src/frontend/public/.well-known/agent-skills/index.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://agentskills.io/schema/v0.2.0/discovery.schema.json", + "version": "0.2.0", + "skills": [ + { + "name": "getting-started-with-aspire", + "type": "skill-md", + "description": "Help a developer install the Aspire CLI, scaffold a new distributed app, and find authoritative docs on aspire.dev.", + "url": "/.well-known/agent-skills/getting-started-with-aspire/SKILL.md", + "digest": "sha256:d7ba9988aaa0fda22d320c0d7fe27a818b0433f07d8405f42a8cd5dc5d2d8445" + } + ] +} diff --git a/src/frontend/public/robots.txt b/src/frontend/public/robots.txt index 3a3497a3a..ad88bc9cd 100644 --- a/src/frontend/public/robots.txt +++ b/src/frontend/public/robots.txt @@ -1,4 +1,5 @@ User-agent: * +Content-Signal: ai-train=yes, search=yes, ai-input=yes Allow: / Sitemap: https://aspire.dev/sitemap-index.xml \ No newline at end of file diff --git a/src/frontend/scripts/compute-skill-digests.mjs b/src/frontend/scripts/compute-skill-digests.mjs new file mode 100644 index 000000000..c767c3827 --- /dev/null +++ b/src/frontend/scripts/compute-skill-digests.mjs @@ -0,0 +1,98 @@ +// @ts-check +/** + * compute-skill-digests.mjs + * + * Recomputes `digest` fields in `public/.well-known/agent-skills/index.json` by + * sha256-hashing each referenced skill artifact's raw bytes. Run as a `prebuild` + * step so the published index.json always matches the served SKILL.md bytes. + * + * Usage: + * node scripts/compute-skill-digests.mjs # update digests in place + * node scripts/compute-skill-digests.mjs --check # exit non-zero if stale + * + * The tool deliberately reads files as raw bytes (no LF/CRLF normalization) + * — `.gitattributes` pins LF for these paths so the on-disk bytes are stable + * across Windows and Linux checkouts. + */ + +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const repoRoot = path.resolve(fileURLToPath(import.meta.url), '..', '..'); +const publicRoot = path.join(repoRoot, 'public'); +const indexPath = path.join(publicRoot, '.well-known', 'agent-skills', 'index.json'); + +const checkOnly = process.argv.includes('--check'); + +/** + * @param {string} filePath + * @returns {Promise} sha256 hash as `sha256:` + */ +async function digestFile(filePath) { + const bytes = await readFile(filePath); + const hash = createHash('sha256').update(bytes).digest('hex'); + return `sha256:${hash}`; +} + +/** + * Resolve a public URL like `/.well-known/foo/bar.md` to its on-disk path. + * @param {string} url + */ +function resolvePublicPath(url) { + if (!url.startsWith('/')) { + throw new Error(`Skill url must be absolute: ${url}`); + } + return path.join(publicRoot, url.slice(1)); +} + +const raw = await readFile(indexPath, 'utf8'); +const original = raw; +/** @type {{ skills: Array<{ name: string; type: string; url: string; digest?: string }> }} */ +const index = JSON.parse(raw); + +if (!Array.isArray(index.skills)) { + throw new Error('index.json must contain a `skills` array'); +} + +let changed = false; +for (const skill of index.skills) { + if (skill.type !== 'skill-md') { + // Future skill types might use a different bytes-to-digest convention; + // bail loudly so we don't silently emit a wrong digest. + throw new Error(`Unsupported skill type "${skill.type}" for ${skill.name}`); + } + const filePath = resolvePublicPath(skill.url); + const fresh = await digestFile(filePath); + if (skill.digest !== fresh) { + changed = true; + if (!checkOnly) { + skill.digest = fresh; + } else { + console.error( + `[compute-skill-digests] STALE digest for ${skill.name}: index.json has ${skill.digest}, file is ${fresh}` + ); + } + } +} + +const next = JSON.stringify(index, null, 2) + '\n'; + +if (checkOnly) { + if (changed || next !== original) { + console.error( + '[compute-skill-digests] index.json is out of date. Run `node scripts/compute-skill-digests.mjs` to refresh.' + ); + process.exit(1); + } + console.log('[compute-skill-digests] index.json is up to date.'); + process.exit(0); +} + +if (next !== original) { + await writeFile(indexPath, next, 'utf8'); + console.log('[compute-skill-digests] Updated', path.relative(repoRoot, indexPath)); +} else { + console.log('[compute-skill-digests] No changes.'); +} diff --git a/src/frontend/src/components/starlight/Head.astro b/src/frontend/src/components/starlight/Head.astro index 2cc13f285..33b6d6a7e 100644 --- a/src/frontend/src/components/starlight/Head.astro +++ b/src/frontend/src/components/starlight/Head.astro @@ -200,6 +200,11 @@ function computeSourceUrl() { })(); + + + ; + /** + * Run a query and return ranked results. `limit` is a soft cap; providers + * may return fewer results when the corpus is small. + */ + search(query: string, limit: number): Promise; +} diff --git a/src/frontend/src/scripts/search/index.ts b/src/frontend/src/scripts/search/index.ts new file mode 100644 index 000000000..3a02d1fa0 --- /dev/null +++ b/src/frontend/src/scripts/search/index.ts @@ -0,0 +1,24 @@ +import { pagefindProvider } from './pagefind-provider'; +import type { SearchProvider } from './SearchProvider'; +import { typesenseProvider } from './typesense-provider'; + +export type { SearchProvider, SearchResponse, SearchResult } from './SearchProvider'; + +/** + * Selects the active search provider based on the build-time + * `PUBLIC_SEARCH_PROVIDER` env var. Defaults to Pagefind, which is what + * Starlight ships today. Once the site migrates to Typesense, set + * `PUBLIC_SEARCH_PROVIDER=typesense` (or change the default below) — the + * WebMCP tool surface stays stable across the swap. + */ +export function getSearchProvider(): SearchProvider { + const raw = (import.meta.env.PUBLIC_SEARCH_PROVIDER as string | undefined) ?? 'pagefind'; + const id = raw.toLowerCase(); + switch (id) { + case 'typesense': + return typesenseProvider; + case 'pagefind': + default: + return pagefindProvider; + } +} diff --git a/src/frontend/src/scripts/search/pagefind-provider.ts b/src/frontend/src/scripts/search/pagefind-provider.ts new file mode 100644 index 000000000..cee66faa9 --- /dev/null +++ b/src/frontend/src/scripts/search/pagefind-provider.ts @@ -0,0 +1,86 @@ +import type { SearchProvider, SearchResponse, SearchResult } from './SearchProvider'; + +interface PagefindHitData { + url: string; + meta?: { title?: string }; + excerpt?: string; +} + +interface PagefindHit { + data: () => Promise; +} + +interface PagefindModule { + search: (query: string) => Promise<{ results: PagefindHit[] }>; +} + +/** + * Backs the WebMCP `search-aspire-docs` tool with Starlight's built-in Pagefind + * index. The index is shipped as a runtime artifact at `/pagefind/pagefind.js`, + * so we resolve it via dynamic import at first use and cache the module. + * + * If Pagefind is absent (skip-search builds, dev mode without a build), the + * provider returns `{ unavailable: true }` rather than throwing — agents can + * surface a graceful "search unavailable" state. + */ +export const pagefindProvider: SearchProvider = (() => { + let modulePromise: Promise | null = null; + + async function loadModule(): Promise { + if (typeof window === 'undefined') { + return null; + } + try { + // /pagefind/pagefind.js is emitted by Starlight at build time. + // @vite-ignore prevents Vite from trying to resolve this at bundle time. + const mod = (await import( + /* @vite-ignore */ `${window.location.origin}/pagefind/pagefind.js` + )) as PagefindModule; + return mod; + } catch { + return null; + } + } + + async function ensureReady(): Promise { + modulePromise ??= loadModule(); + await modulePromise; + } + + async function search(query: string, limit: number): Promise { + await ensureReady(); + const mod = await modulePromise; + if (!mod) { + return { + results: [], + unavailable: true, + reason: 'Pagefind is not available in this environment.', + }; + } + + const trimmed = query.trim(); + if (trimmed.length === 0) { + return { results: [] }; + } + + const raw = await mod.search(trimmed); + const top = raw.results.slice(0, Math.max(1, limit)); + const results: SearchResult[] = await Promise.all( + top.map(async (hit) => { + const data = await hit.data(); + return { + title: data.meta?.title ?? data.url, + url: data.url, + excerpt: (data.excerpt ?? '').replace(/<[^>]+>/g, '').trim(), + }; + }) + ); + return { results }; + } + + return { + id: 'pagefind', + ensureReady, + search, + }; +})(); diff --git a/src/frontend/src/scripts/search/typesense-provider.ts b/src/frontend/src/scripts/search/typesense-provider.ts new file mode 100644 index 000000000..44287aab4 --- /dev/null +++ b/src/frontend/src/scripts/search/typesense-provider.ts @@ -0,0 +1,26 @@ +import type { SearchProvider } from './SearchProvider'; + +/** + * Stub Typesense-backed provider. aspire.dev's site search is migrating from + * Pagefind to Typesense; this file exists so the swap is a one-line change in + * `./index.ts` once the Typesense index, public API key, and host are wired + * up site-wide. + * + * Until then the provider responds with `{ unavailable: true }`. It carries + * NO runtime dependency on the `typesense` SDK — the npm package is added + * later as part of the Typesense migration, not as part of agent-readiness. + */ +export const typesenseProvider: SearchProvider = { + id: 'typesense', + ensureReady() { + return Promise.resolve(); + }, + search() { + return Promise.resolve({ + results: [], + unavailable: true, + reason: + 'Typesense provider not yet wired. Set PUBLIC_SEARCH_PROVIDER=typesense after migration.', + }); + }, +}; diff --git a/src/frontend/src/scripts/webmcp.ts b/src/frontend/src/scripts/webmcp.ts new file mode 100644 index 000000000..97fed9683 --- /dev/null +++ b/src/frontend/src/scripts/webmcp.ts @@ -0,0 +1,134 @@ +/** + * WebMCP integration: exposes a single `search-aspire-docs` tool to in-page + * agents via `navigator.modelContext.registerTool()`. The execute callback + * delegates to a swappable {@link SearchProvider} so the tool surface is + * stable across search-engine migrations (Pagefind today, Typesense later). + * + * Spec: https://webmachinelearning.github.io/webmcp/ + */ + +import { getSearchProvider, type SearchResponse } from './search'; + +interface ToolExecuteContext { + signal?: AbortSignal; +} + +interface ModelContextTool { + name: string; + description: string; + inputSchema: object; + execute: (input: unknown, ctx?: ToolExecuteContext) => Promise; +} + +interface ModelContextLike { + registerTool: (tool: ModelContextTool) => unknown; +} + +declare global { + interface Navigator { + modelContext?: ModelContextLike; + } + interface Window { + __aspireWebMCPRegistered?: boolean; + } +} + +const TOOL_NAME = 'search-aspire-docs'; +const SEARCH_LIMIT_DEFAULT = 10; +const SEARCH_LIMIT_MAX = 25; + +const inputSchema = { + type: 'object', + additionalProperties: false, + required: ['query'], + properties: { + query: { + type: 'string', + minLength: 1, + description: 'A natural-language or keyword query about Aspire.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: SEARCH_LIMIT_MAX, + default: SEARCH_LIMIT_DEFAULT, + description: 'Maximum number of results to return.', + }, + }, +} as const; + +const description = + 'Search the official Aspire documentation, integration catalog, and CLI reference on aspire.dev. ' + + 'Returns ranked results with title, URL, and a short excerpt.'; + +function clampLimit(input: unknown): number { + if (typeof input !== 'number' || !Number.isFinite(input)) { + return SEARCH_LIMIT_DEFAULT; + } + return Math.max(1, Math.min(SEARCH_LIMIT_MAX, Math.floor(input))); +} + +function asMcpResult(payload: SearchResponse): unknown { + // MCP/WebMCP tool results conventionally wrap their response in a + // content array of typed parts. JSON-as-text keeps the result agent-friendly + // without requiring the host to render structured data. + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + isError: payload.unavailable === true, + }; +} + +async function executeSearch(input: unknown): Promise { + const args = (input ?? {}) as { query?: unknown; limit?: unknown }; + const query = typeof args.query === 'string' ? args.query : ''; + const limit = clampLimit(args.limit); + + if (query.trim().length === 0) { + return asMcpResult({ + results: [], + unavailable: true, + reason: '`query` is required and must be a non-empty string.', + }); + } + + const provider = getSearchProvider(); + try { + const response = await provider.search(query, limit); + return asMcpResult(response); + } catch (error) { + return asMcpResult({ + results: [], + unavailable: true, + reason: error instanceof Error ? error.message : 'Search failed.', + }); + } +} + +function register(): void { + if (typeof navigator === 'undefined' || typeof window === 'undefined') { + return; + } + if (window.__aspireWebMCPRegistered) { + return; + } + const ctx = navigator.modelContext; + if (!ctx || typeof ctx.registerTool !== 'function') { + return; + } + + try { + ctx.registerTool({ + name: TOOL_NAME, + description, + inputSchema, + execute: executeSearch, + }); + window.__aspireWebMCPRegistered = true; + } catch { + // registerTool throws if a tool with the same name is already registered. + // That's fine on subsequent view transitions; mark as registered to skip. + window.__aspireWebMCPRegistered = true; + } +} + +register(); diff --git a/src/frontend/tests/e2e/webmcp.spec.ts b/src/frontend/tests/e2e/webmcp.spec.ts new file mode 100644 index 000000000..f1e90c0ff --- /dev/null +++ b/src/frontend/tests/e2e/webmcp.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; + +/** + * WebMCP integration: when the runtime exposes + * `navigator.modelContext.registerTool`, the homepage script must call it + * exactly once with the `search-aspire-docs` tool. + * + * Browsers don't ship `navigator.modelContext` yet, so we install a stub via + * `addInitScript` before navigating and assert against the stub. + */ +test.describe('WebMCP', () => { + test('homepage registers the search-aspire-docs tool', async ({ page }) => { + await page.addInitScript(() => { + type RegisteredTool = { + name: string; + description: string; + inputSchema: unknown; + }; + + const calls: RegisteredTool[] = []; + Object.defineProperty(navigator, 'modelContext', { + configurable: true, + value: { + registerTool(tool: RegisteredTool): void { + calls.push({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }); + }, + }, + }); + (window as unknown as { __webmcpCalls: RegisteredTool[] }).__webmcpCalls = calls; + }); + + await page.goto('/'); + // Allow the deferred WebMCP module script to run; it registers + // synchronously after import. + await page.waitForLoadState('domcontentloaded'); + + const calls = await page.evaluate( + () => (window as unknown as { __webmcpCalls?: unknown[] }).__webmcpCalls ?? [] + ); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + name: 'search-aspire-docs', + }); + + const inputSchema = (calls[0] as { inputSchema: Record }).inputSchema; + expect(inputSchema.type).toBe('object'); + const properties = inputSchema.properties as Record; + expect(properties.query).toBeDefined(); + expect((inputSchema.required as string[])).toContain('query'); + }); + + test('absence of navigator.modelContext is non-fatal', async ({ page }) => { + // Default browser env: no modelContext. Just confirm the script load + // does not throw and the page renders. + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + expect(errors).toEqual([]); + }); +}); diff --git a/src/frontend/tsconfig.json b/src/frontend/tsconfig.json index 75bc2be72..9b6f74b16 100644 --- a/src/frontend/tsconfig.json +++ b/src/frontend/tsconfig.json @@ -7,6 +7,7 @@ "@assets/*": ["./src/assets/*"], "@components/*": ["./src/components/*"], "@data/*": ["./src/data/*"], + "@scripts/*": ["./src/scripts/*"], "@tests/e2e/*": ["./tests/e2e/*"], "@tests/typecheck/*": ["./tests/typecheck/*"], "@tests/unit/*": ["./tests/unit/*"], diff --git a/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs b/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs new file mode 100644 index 000000000..00a6f6ac9 --- /dev/null +++ b/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs @@ -0,0 +1,175 @@ +namespace StaticHost.AgentReadiness; + +/// +/// Parses HTTP Accept header values into ranked media types and answers +/// targeted questions like "does the client prefer markdown over HTML?". +/// +/// +/// Implements a small subset of RFC 9110 §12.5.1 sufficient for content +/// negotiation between text/html and text/markdown. We intentionally +/// avoid pulling in Microsoft.Net.Http.Headers's full parser because we +/// need a deterministic, testable predicate that knows about q-values, the +/// */* wildcard, and parameter-laden values like +/// text/markdown;profile="cmark";q=0.9. +/// +internal static class AcceptHeaderParser +{ + private const double DefaultQuality = 1.0; + + internal readonly record struct MediaTypeWithQ(string Type, string Subtype, double Quality) + { + public bool Matches(string type, string subtype) => + (Type == "*" || string.Equals(Type, type, StringComparison.OrdinalIgnoreCase)) && + (Subtype == "*" || string.Equals(Subtype, subtype, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Parses the supplied Accept header value into a list of media types with + /// q-values. Returns an empty list when the header is null/empty. + /// + public static IReadOnlyList Parse(string? acceptHeader) + { + if (string.IsNullOrWhiteSpace(acceptHeader)) + { + return Array.Empty(); + } + + var results = new List(); + foreach (var rawSegment in acceptHeader.Split(',')) + { + var segment = rawSegment.Trim(); + if (segment.Length == 0) + { + continue; + } + + var parts = segment.Split(';'); + var mediaType = parts[0].Trim(); + var slash = mediaType.IndexOf('/'); + if (slash <= 0 || slash == mediaType.Length - 1) + { + continue; + } + + var type = mediaType[..slash]; + var subtype = mediaType[(slash + 1)..]; + var q = DefaultQuality; + for (var i = 1; i < parts.Length; i++) + { + var param = parts[i].Trim(); + if (param.StartsWith("q=", StringComparison.OrdinalIgnoreCase) && + double.TryParse( + param.AsSpan(2), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var parsed)) + { + q = Math.Clamp(parsed, 0.0, 1.0); + break; + } + } + + results.Add(new MediaTypeWithQ(type, subtype, q)); + } + + return results; + } + + /// + /// Returns the highest q-value across entries that match the given + /// /, including wildcards. + /// Returns 0.0 when no entry matches (i.e. the type is not acceptable). + /// + public static double QualityFor(IReadOnlyList ranked, string type, string subtype) + { + var best = 0.0; + foreach (var entry in ranked) + { + if (entry.Matches(type, subtype) && entry.Quality > best) + { + best = entry.Quality; + } + } + return best; + } + + /// + /// True when the client prefers text/markdown strictly over both + /// text/html and * / *. * / * alone (a generic Accept) + /// is NOT a markdown preference. + /// + public static bool PrefersMarkdown(string? acceptHeader) + { + var ranked = Parse(acceptHeader); + if (ranked.Count == 0) + { + return false; + } + + // Find the most specific match for text/markdown. Wildcards cannot + // express markdown preference (a request with only "*/*" should get HTML). + double markdownQ = 0.0; + foreach (var entry in ranked) + { + if (string.Equals(entry.Type, "text", StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.Subtype, "markdown", StringComparison.OrdinalIgnoreCase)) + { + if (entry.Quality > markdownQ) + { + markdownQ = entry.Quality; + } + } + } + + if (markdownQ <= 0.0) + { + return false; + } + + var htmlQ = HighestExplicitQuality(ranked, "text", "html"); + var anyHtmlQ = QualityFor(ranked, "text", "html"); + + // Strictly prefer markdown when it beats any acceptable HTML representation, + // OR when HTML is not acceptable at all (markdownQ > 0 and htmlQ == 0). + if (anyHtmlQ <= 0.0) + { + return markdownQ > 0.0; + } + + // When HTML is acceptable via wildcards (e.g. */*), markdown must strictly + // exceed that wildcard q. When HTML is acceptable explicitly, markdown + // must strictly exceed the explicit q. + return markdownQ > anyHtmlQ; + + static double HighestExplicitQuality(IReadOnlyList ranked, string type, string subtype) + { + var best = 0.0; + foreach (var entry in ranked) + { + if (string.Equals(entry.Type, type, StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.Subtype, subtype, StringComparison.OrdinalIgnoreCase) && + entry.Quality > best) + { + best = entry.Quality; + } + } + return best; + } + } + + /// + /// True when an HTML response would still be acceptable to the client + /// (either via an explicit text/html entry, text/* wildcard, or */*). + /// Used when we can't satisfy a markdown preference and want to know + /// whether falling back to HTML is OK or whether 406 is the right answer. + /// + public static bool AcceptsHtml(string? acceptHeader) + { + var ranked = Parse(acceptHeader); + if (ranked.Count == 0) + { + return true; + } + return QualityFor(ranked, "text", "html") > 0.0; + } +} diff --git a/src/statichost/StaticHost/AgentReadiness/AgentReadinessExtensions.cs b/src/statichost/StaticHost/AgentReadiness/AgentReadinessExtensions.cs new file mode 100644 index 000000000..1e20f69a0 --- /dev/null +++ b/src/statichost/StaticHost/AgentReadiness/AgentReadinessExtensions.cs @@ -0,0 +1,26 @@ +namespace StaticHost.AgentReadiness; + +/// +/// Composition-root extension methods for the agent-readiness middlewares. +/// +public static class AgentReadinessExtensions +{ + /// + /// Registers the markdown content-negotiation middleware and the Link + /// header middleware. Call before UseDefaultFiles + /// and UseRouting so the middlewares operate on the original + /// request path; otherwise UseDefaultFiles rewrites /foo/ + /// to /foo/index.html and routing pre-selects the HTML endpoint. + /// + public static IApplicationBuilder UseAgentReadiness(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + // Order matters: markdown negotiation MUST run first so it can + // short-circuit the pipeline and serve the .md body directly without + // the Link header middleware ever seeing the request. + app.UseMiddleware(); + app.UseMiddleware(); + return app; + } +} diff --git a/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs b/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs new file mode 100644 index 000000000..216158f78 --- /dev/null +++ b/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.FileProviders; +using Microsoft.Net.Http.Headers; + +namespace StaticHost.AgentReadiness; + +/// +/// Adds an RFC 8288 Link response header advertising agent-discovery +/// resources on HTML responses (root + Starlight doc pages). +/// +/// +/// +/// The header is set via Response.OnStarting so we can inspect the final +/// Content-Type and StatusCode after downstream middleware / +/// endpoints have decided what to send. We only emit the header on 2xx +/// responses whose Content-Type starts with text/html; redirects, JSON +/// responses, static assets, and well-known JSON files are all skipped. +/// +/// +/// The middleware MUST run before UseDefaultFiles and UseRouting +/// so it can read the original request path. The decision about whether an +/// .md companion exists is made once per request, before any path +/// rewriting performed by UseDefaultFiles. +/// +/// +internal sealed class LinkHeaderMiddleware +{ + // Always-on links advertised on every HTML page. + // Combined into a single Link header per RFC 8288 §3 (multiple values comma-separated). + private static readonly string s_baseLinkHeader = string.Join(", ", + [ + "; rel=\"llms\"; type=\"text/plain\"", + "; rel=\"agent-skills\"; type=\"application/json\"", + "; rel=\"sitemap\"; type=\"application/xml\"" + ]); + + private readonly RequestDelegate _next; + private readonly IFileProvider _fileProvider; + + public LinkHeaderMiddleware(RequestDelegate next, IWebHostEnvironment env) + { + _next = next; + _fileProvider = env.WebRootFileProvider; + } + + public Task InvokeAsync(HttpContext context) + { + if (ShouldSkip(context.Request)) + { + return _next(context); + } + + // Resolve the markdown companion against the ORIGINAL request path, + // before UseDefaultFiles rewrites it to /foo/index.html. + var companionPath = MarkdownPathMapper.TryGetMarkdownCompanion( + context.Request.Path, + _fileProvider); + + context.Response.OnStarting(static state => + { + var ctx = (LinkHeaderState)state; + var response = ctx.HttpContext.Response; + + // Only attach Link to successful HTML responses. Skip 3xx/4xx/5xx + // and any non-HTML content type. This keeps redirects, JSON, + // images, and static asset responses clean. + if (response.StatusCode is < 200 or >= 300) + { + return Task.CompletedTask; + } + + var contentType = response.ContentType; + if (string.IsNullOrEmpty(contentType) || + !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) + { + return Task.CompletedTask; + } + + var header = ctx.CompanionPath is null + ? s_baseLinkHeader + : $"{s_baseLinkHeader}, <{ctx.CompanionPath}>; rel=\"alternate\"; type=\"text/markdown\""; + + // Append rather than overwrite so we cooperate with anything else + // that may have set a Link header. + response.Headers.Append(HeaderNames.Link, header); + return Task.CompletedTask; + }, new LinkHeaderState(context, companionPath)); + + return _next(context); + } + + private static bool ShouldSkip(HttpRequest request) + { + if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method)) + { + return true; + } + + var path = request.Path.HasValue ? request.Path.Value! : "/"; + return path.StartsWith("/_astro/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/.well-known/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/healthz", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/install.", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/pagefind/", StringComparison.OrdinalIgnoreCase); + } + + private sealed record LinkHeaderState(HttpContext HttpContext, string? CompanionPath); +} diff --git a/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs b/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs new file mode 100644 index 000000000..b0fd34d6c --- /dev/null +++ b/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs @@ -0,0 +1,138 @@ +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; +using Microsoft.Net.Http.Headers; + +namespace StaticHost.AgentReadiness; + +/// +/// Serves the markdown companion of a Starlight HTML page when the client +/// prefers text/markdown via the Accept header. +/// +/// +/// +/// The site's static-asset pipeline emits a .md sibling next to every +/// HTML page (via starlight-page-actions). E.g. /get-started/quickstart/ +/// has companion /get-started/quickstart.md. This middleware short-circuits +/// the pipeline by streaming that file directly when markdown is preferred — +/// it does NOT rewrite Request.Path because rewriting after +/// UseRouting() would not re-trigger endpoint selection in +/// MapStaticAssets(). +/// +/// +/// Therefore the middleware MUST run before UseDefaultFiles and +/// UseRouting. It operates on the original request path so it can compute +/// the companion .md mapping reliably (UseDefaultFiles would +/// otherwise have already turned /foo/ into /foo/index.html). +/// +/// +/// To avoid Vary: Accept cache-key explosion at Azure Front Door, the +/// negotiated response sets Cache-Control: private, max-age=0, +/// must-revalidate. The HTML response is left untouched (no Vary +/// added) so HTML continues to cache normally. +/// +/// +internal sealed class MarkdownNegotiationMiddleware +{ + private readonly RequestDelegate _next; + private readonly IFileProvider _fileProvider; + private readonly ILogger _logger; + + public MarkdownNegotiationMiddleware( + RequestDelegate next, + IWebHostEnvironment env, + ILogger logger) + { + _next = next; + _logger = logger; + // Use the same provider MapStaticAssets reads from so wwwroot is the source of truth. + _fileProvider = env.WebRootFileProvider; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!ShouldHandle(context.Request)) + { + await _next(context); + return; + } + + // Infrastructure paths (e.g. /.well-known/*, /_astro/*, /healthz) + // are NEVER subject to markdown negotiation — they have their own + // content types and the agent might prefer markdown for the page + // navigation but still expect JSON for these resources. + if (MarkdownPathMapper.IsInfrastructurePath(context.Request.Path)) + { + await _next(context); + return; + } + + var acceptHeader = context.Request.Headers.Accept.ToString(); + if (!AcceptHeaderParser.PrefersMarkdown(acceptHeader)) + { + await _next(context); + return; + } + + var companionPath = MarkdownPathMapper.TryGetMarkdownCompanion( + context.Request.Path, + _fileProvider); + if (companionPath is null) + { + // Markdown was preferred but no companion exists. + // Fall back to HTML when it's acceptable; otherwise 406. + if (AcceptHeaderParser.AcceptsHtml(acceptHeader)) + { + await _next(context); + } + else + { + context.Response.StatusCode = StatusCodes.Status406NotAcceptable; + context.Response.ContentType = "text/plain; charset=utf-8"; + await context.Response.WriteAsync( + "406 Not Acceptable: this resource is available as text/html or, where a companion exists, text/markdown.", + context.RequestAborted); + } + return; + } + + var fileInfo = _fileProvider.GetFileInfo(companionPath); + if (!fileInfo.Exists || fileInfo.IsDirectory) + { + // Race / inconsistency between HasMarkdownCompanion and live file system. + // Fall through to default pipeline rather than 500. + _logger.LogDebug( + "Markdown companion {CompanionPath} reported but missing on disk; falling through.", + companionPath); + await _next(context); + return; + } + + WriteMarkdownHeaders(context, fileInfo); + + if (HttpMethods.IsHead(context.Request.Method)) + { + // Send headers only; ASP.NET Core will flush them on response complete. + return; + } + + await context.Response.SendFileAsync(fileInfo, context.RequestAborted); + } + + private static bool ShouldHandle(HttpRequest request) => + HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method); + + private static void WriteMarkdownHeaders(HttpContext context, IFileInfo fileInfo) + { + var response = context.Response; + response.StatusCode = StatusCodes.Status200OK; + response.ContentType = "text/markdown; charset=utf-8"; + response.ContentLength = fileInfo.Length; + // Vary: Accept is the correct HTTP signal; combined with Cache-Control: private + // it tells well-behaved CDNs (incl. Azure Front Door) NOT to cache this response, + // avoiding cache-key explosion across many distinct Accept values. + response.Headers.Vary = "Accept"; + response.Headers.CacheControl = "private, max-age=0, must-revalidate"; + response.Headers[HeaderNames.LastModified] = + fileInfo.LastModified.UtcDateTime.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/src/statichost/StaticHost/AgentReadiness/MarkdownPathMapper.cs b/src/statichost/StaticHost/AgentReadiness/MarkdownPathMapper.cs new file mode 100644 index 000000000..7077ea4e7 --- /dev/null +++ b/src/statichost/StaticHost/AgentReadiness/MarkdownPathMapper.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.FileProviders; + +namespace StaticHost.AgentReadiness; + +/// +/// Maps an HTTP request path to its sibling .md file in wwwroot, +/// matching the layout produced by starlight-page-actions: +/// +/// //index.md +/// /foo//foo.md +/// /foo/foo.md +/// /foo/bar//foo/bar.md +/// /foo.html/foo.md +/// +/// Returns when the path is infrastructure (e.g. +/// /_astro/*, /.well-known/*) or no companion file exists. +/// +internal static class MarkdownPathMapper +{ + public static bool IsInfrastructurePath(PathString path) + { + var raw = path.HasValue ? path.Value! : "/"; + return raw.StartsWith("/_astro/", StringComparison.OrdinalIgnoreCase) || + raw.StartsWith("/.well-known/", StringComparison.OrdinalIgnoreCase) || + raw.StartsWith("/healthz", StringComparison.OrdinalIgnoreCase) || + raw.StartsWith("/install.", StringComparison.OrdinalIgnoreCase) || + raw.StartsWith("/pagefind/", StringComparison.OrdinalIgnoreCase); + } + + public static string? TryGetMarkdownCompanion(PathString path, IFileProvider fileProvider) + { + if (IsInfrastructurePath(path)) + { + return null; + } + + var raw = path.HasValue ? path.Value! : "/"; + + if (raw == "/") + { + return Try(fileProvider, "/index.md"); + } + + if (raw.EndsWith('/')) + { + return Try(fileProvider, $"{raw[..^1]}.md"); + } + + if (raw.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) + { + return Try(fileProvider, raw[..^".html".Length] + ".md"); + } + + var dot = raw.LastIndexOf('.'); + var lastSlash = raw.LastIndexOf('/'); + if (dot <= lastSlash) + { + return Try(fileProvider, $"{raw}.md"); + } + + return null; + } + + private static string? Try(IFileProvider provider, string relativePath) + { + var info = provider.GetFileInfo(relativePath); + return info.Exists && !info.IsDirectory ? relativePath : null; + } +} diff --git a/src/statichost/StaticHost/GlobalUsings.cs b/src/statichost/StaticHost/GlobalUsings.cs index 31e62ced9..604760adf 100644 --- a/src/statichost/StaticHost/GlobalUsings.cs +++ b/src/statichost/StaticHost/GlobalUsings.cs @@ -1,6 +1,7 @@ global using System.Diagnostics; global using StaticHost; +global using StaticHost.AgentReadiness; global using StaticHost.Telemetry; global using OpenTelemetry; diff --git a/src/statichost/StaticHost/Program.cs b/src/statichost/StaticHost/Program.cs index 0a7a3e098..de7c4592b 100644 --- a/src/statichost/StaticHost/Program.cs +++ b/src/statichost/StaticHost/Program.cs @@ -18,6 +18,15 @@ } app.UseHttpsRedirection(); + +// Agent-readiness middlewares MUST run before UseDefaultFiles + UseRouting: +// * UseDefaultFiles rewrites /foo/ -> /foo/index.html, breaking .md-companion mapping. +// * MapStaticAssets registers endpoints during UseRouting, so a path rewrite +// after UseRouting wouldn't re-trigger endpoint selection. +// MarkdownNegotiationMiddleware short-circuits to serve the .md body directly; +// LinkHeaderMiddleware attaches a Link header on HTML 2xx responses via OnStarting. +app.UseAgentReadiness(); + app.UseDefaultFiles(); // add routing after default files, so the default file middleware can modify the path first diff --git a/src/statichost/StaticHost/Properties/AssemblyInfo.cs b/src/statichost/StaticHost/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..e454eba9d --- /dev/null +++ b/src/statichost/StaticHost/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StaticHost.Tests")] diff --git a/src/statichost/StaticHost/StaticHost.csproj b/src/statichost/StaticHost/StaticHost.csproj index d5fd4bf48..1d577e05a 100644 --- a/src/statichost/StaticHost/StaticHost.csproj +++ b/src/statichost/StaticHost/StaticHost.csproj @@ -23,7 +23,18 @@ - + + + all + diff --git a/tests/StaticHost.Tests/AcceptHeaderParserTests.cs b/tests/StaticHost.Tests/AcceptHeaderParserTests.cs new file mode 100644 index 000000000..9a3b24b09 --- /dev/null +++ b/tests/StaticHost.Tests/AcceptHeaderParserTests.cs @@ -0,0 +1,49 @@ +using StaticHost.AgentReadiness; + +namespace StaticHost.Tests; + +/// +/// Direct unit tests for . These cover the +/// q-value parsing edge cases without spinning up a host. +/// +public sealed class AcceptHeaderParserTests +{ + [Theory] + [InlineData("text/markdown")] + [InlineData("text/markdown, text/html;q=0.5")] + [InlineData("text/markdown;q=0.9, text/html;q=0.5")] + [InlineData("text/markdown;q=1.0, text/*;q=0.5")] + [InlineData("text/markdown;q=0.8, */*;q=0.5")] + public void PrefersMarkdown_returns_true_when_markdown_outranks_html(string accept) + { + Assert.True(AcceptHeaderParser.PrefersMarkdown(accept)); + } + + [Theory] + [InlineData("")] + [InlineData("text/html")] + [InlineData("text/html, application/xhtml+xml")] + [InlineData("text/markdown;q=0.5, text/html;q=1.0")] + [InlineData("text/markdown;q=0.5, text/html")] + [InlineData("*/*")] + [InlineData("text/*")] + [InlineData("text/markdown;q=0.0, text/html")] + public void PrefersMarkdown_returns_false_for_browsers_and_lower_q(string accept) + { + Assert.False(AcceptHeaderParser.PrefersMarkdown(accept)); + } + + [Theory] + [InlineData("text/html", true)] + [InlineData("*/*", true)] + [InlineData("text/*", true)] + [InlineData("text/html, application/xhtml+xml", true)] + [InlineData("text/markdown", false)] + [InlineData("text/markdown;q=1.0, text/html;q=0.0", false)] + [InlineData("application/json", false)] + [InlineData("", true)] // empty Accept header is treated as "anything" + public void AcceptsHtml_distinguishes_html_clients(string accept, bool expected) + { + Assert.Equal(expected, AcceptHeaderParser.AcceptsHtml(accept)); + } +} diff --git a/tests/StaticHost.Tests/AgentReadinessTestServer.cs b/tests/StaticHost.Tests/AgentReadinessTestServer.cs new file mode 100644 index 000000000..313b61210 --- /dev/null +++ b/tests/StaticHost.Tests/AgentReadinessTestServer.cs @@ -0,0 +1,125 @@ +using System.Net.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using StaticHost.AgentReadiness; + +namespace StaticHost.Tests; + +/// +/// Builds an in-process ASP.NET Core test server that mirrors the production +/// pipeline order for the agent-readiness middlewares (markdown negotiation +/// and Link headers) without depending on the frontend build. +/// +internal sealed class AgentReadinessTestServer : IAsyncDisposable +{ + private readonly IHost _host; + private readonly TestServer _testServer; + private readonly TempWebRoot _wwwroot; + + public HttpClient Client { get; } + + public string WebRoot => _wwwroot.Path; + + private AgentReadinessTestServer(IHost host, TestServer testServer, TempWebRoot wwwroot) + { + _host = host; + _testServer = testServer; + _wwwroot = wwwroot; + Client = testServer.CreateClient(); + } + + public static async Task StartAsync(Action? seed = null) + { + var wwwroot = new TempWebRoot(); + try + { + seed?.Invoke(wwwroot); + + var hostBuilder = Host.CreateDefaultBuilder() + .ConfigureLogging(static logging => logging.ClearProviders()) + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.UseWebRoot(wwwroot.Path); + web.Configure(app => + { + // Match production order: agent-readiness BEFORE + // UseDefaultFiles + UseRouting (see Program.cs). + app.UseAgentReadiness(); + app.UseDefaultFiles(); + app.UseStaticFiles(); + + // Endpoint that mimics MapStaticAssets fallback: 404 if + // no static asset matched. + app.Run(static ctx => + { + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }); + }); + }); + + var host = hostBuilder.Build(); + await host.StartAsync(); + var testServer = host.GetTestServer(); + return new AgentReadinessTestServer(host, testServer, wwwroot); + } + catch + { + wwwroot.Dispose(); + throw; + } + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + _testServer.Dispose(); + await _host.StopAsync(); + _host.Dispose(); + _wwwroot.Dispose(); + } +} + +internal sealed class TempWebRoot : IDisposable +{ + public string Path { get; } + + public TempWebRoot() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"aspire-agent-readiness-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path); + } + + public void WriteFile(string relativePath, string contents) + { + var fullPath = System.IO.Path.Combine(Path, relativePath.TrimStart('/').Replace('/', System.IO.Path.DirectorySeparatorChar)); + var directory = System.IO.Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + File.WriteAllText(fullPath, contents); + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch (IOException) + { + // best-effort + } + } +} diff --git a/tests/StaticHost.Tests/GlobalUsings.cs b/tests/StaticHost.Tests/GlobalUsings.cs new file mode 100644 index 000000000..a6f18f0cb --- /dev/null +++ b/tests/StaticHost.Tests/GlobalUsings.cs @@ -0,0 +1,10 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Linq; +global using System.Net; +global using System.Net.Http; +global using System.Net.Http.Headers; +global using System.Threading.Tasks; +global using Microsoft.AspNetCore.Http; +global using Xunit; diff --git a/tests/StaticHost.Tests/LinkHeaderTests.cs b/tests/StaticHost.Tests/LinkHeaderTests.cs new file mode 100644 index 000000000..717e7dde4 --- /dev/null +++ b/tests/StaticHost.Tests/LinkHeaderTests.cs @@ -0,0 +1,124 @@ +namespace StaticHost.Tests; + +public sealed class LinkHeaderTests +{ + private const string SamplePageHtml = "Hello"; + private const string SamplePageMarkdown = "# Hello\n"; + + private static string? GetLinkHeader(HttpResponseMessage response) => + response.Headers.TryGetValues("Link", out var values) ? string.Join(", ", values) : null; + + [Fact] + public async Task Html_response_includes_link_header_with_required_rels() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile("index.html", SamplePageHtml); + root.WriteFile("index.md", SamplePageMarkdown); + root.WriteFile("llms.txt", "# llms\n"); + root.WriteFile(".well-known/agent-skills/index.json", "{\"skills\":[]}"); + root.WriteFile("sitemap-index.xml", ""); + }); + + using var response = await server.Client.GetAsync("/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var link = GetLinkHeader(response); + Assert.NotNull(link); + Assert.Contains("rel=\"llms\"", link); + Assert.Contains("rel=\"agent-skills\"", link); + Assert.Contains("rel=\"sitemap\"", link); + Assert.Contains("rel=\"alternate\"", link); + Assert.Contains("type=\"text/markdown\"", link); + // Confirm we are NOT advertising api-catalog (out of scope per RFC 9727). + Assert.DoesNotContain("api-catalog", link); + } + + [Fact] + public async Task Page_without_md_companion_omits_alternate_link() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile("reference/api/csharp/index.html", SamplePageHtml); + // no .md companion + }); + + using var response = await server.Client.GetAsync("/reference/api/csharp/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var link = GetLinkHeader(response); + Assert.NotNull(link); + Assert.DoesNotContain("rel=\"alternate\"", link); + Assert.Contains("rel=\"llms\"", link); + } + + [Fact] + public async Task Json_response_does_not_get_link_header() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile(".well-known/agent-skills/index.json", "{\"skills\":[]}"); + }); + + using var response = await server.Client.GetAsync("/.well-known/agent-skills/index.json"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(GetLinkHeader(response)); + } + + [Fact] + public async Task Static_asset_path_does_not_get_link_header() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile("_astro/app.abc123.js", "/* js */"); + }); + + using var response = await server.Client.GetAsync("/_astro/app.abc123.js"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(GetLinkHeader(response)); + } + + [Fact] + public async Task Not_found_response_does_not_get_link_header() + { + await using var server = await AgentReadinessTestServer.StartAsync(); + + using var response = await server.Client.GetAsync("/nope"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Null(GetLinkHeader(response)); + } + + [Fact] + public async Task Markdown_negotiated_response_does_not_get_link_header() + { + // The markdown middleware short-circuits before the Link middleware + // attaches headers; even if it didn't, the response Content-Type is + // text/markdown, so the OnStarting predicate would skip it. + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile("index.html", SamplePageHtml); + root.WriteFile("index.md", SamplePageMarkdown); + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); + Assert.Null(GetLinkHeader(response)); + } + + [Fact] + public async Task Healthz_skipped_from_link_middleware() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + // healthz is a MapGet endpoint in production; here we just simulate + // a static text response to exercise the path-skip guard. + root.WriteFile("healthz/index.html", SamplePageHtml); + }); + + using var response = await server.Client.GetAsync("/healthz/"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Null(GetLinkHeader(response)); + } +} diff --git a/tests/StaticHost.Tests/MarkdownNegotiationTests.cs b/tests/StaticHost.Tests/MarkdownNegotiationTests.cs new file mode 100644 index 000000000..d0de94277 --- /dev/null +++ b/tests/StaticHost.Tests/MarkdownNegotiationTests.cs @@ -0,0 +1,144 @@ +namespace StaticHost.Tests; + +public sealed class MarkdownNegotiationTests +{ + private const string SamplePageHtml = "Hello"; + private const string SamplePageMarkdown = "# Hello\n\nMarkdown body."; + + private static void SeedSamplePage(TempWebRoot root, string slug) + { + // Mirrors what starlight emits: /foo/index.html + /foo.md + root.WriteFile($"{slug}/index.html", SamplePageHtml); + root.WriteFile($"{slug}.md", SamplePageMarkdown); + } + + [Fact] + public async Task Get_with_markdown_accept_returns_md_companion() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile("index.html", SamplePageHtml); + root.WriteFile("index.md", SamplePageMarkdown); + SeedSamplePage(root, "get-started"); + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/get-started/"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); + Assert.Equal("utf-8", response.Content.Headers.ContentType?.CharSet); + var body = await response.Content.ReadAsStringAsync(); + Assert.StartsWith("#", body); + Assert.DoesNotContain(" + { + SeedSamplePage(root, "get-started"); + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/get-started/"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); + using var response = await server.Client.SendAsync(request); + + Assert.True(response.Headers.CacheControl?.Private ?? false, + "Cache-Control must be private to avoid Front Door cache-key explosion on Vary: Accept."); + Assert.Equal(TimeSpan.Zero, response.Headers.CacheControl?.MaxAge); + Assert.True(response.Headers.CacheControl?.MustRevalidate ?? false); + Assert.Contains("Accept", response.Headers.Vary); + } + + [Fact] + public async Task Head_with_markdown_accept_returns_headers_only() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + SeedSamplePage(root, "get-started"); + }); + + using var request = new HttpRequestMessage(HttpMethod.Head, "/get-started/"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/markdown", response.Content.Headers.ContentType?.MediaType); + // ContentLength header should be set even on HEAD. + Assert.NotNull(response.Content.Headers.ContentLength); + Assert.True(response.Content.Headers.ContentLength > 0); + } + + [Fact] + public async Task Browser_request_unaffected_by_negotiation_middleware() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + SeedSamplePage(root, "get-started"); + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/get-started/"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xhtml+xml")); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + // No Vary: Accept on plain HTML responses (so Front Door can still cache). + Assert.DoesNotContain("Accept", response.Headers.Vary); + } + + [Fact] + public async Task Markdown_preferred_with_no_companion_falls_back_to_html_when_acceptable() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + // Page exists but has no .md companion (e.g. /reference/api/csharp/...) + root.WriteFile("reference/api/csharp/index.html", SamplePageHtml); + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/reference/api/csharp/"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown", 1.0)); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html", 0.5)); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Markdown_only_without_companion_returns_406() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile("reference/api/csharp/index.html", SamplePageHtml); + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/reference/api/csharp/"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode); + } + + [Fact] + public async Task WellKnown_paths_skip_markdown_negotiation() + { + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile(".well-known/agent-skills/index.json", "{\"skills\":[]}"); + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/agent-skills/index.json"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); + using var response = await server.Client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + // Should still be served as JSON, not markdown. + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + } +} diff --git a/tests/StaticHost.Tests/MarkdownPathMapperTests.cs b/tests/StaticHost.Tests/MarkdownPathMapperTests.cs new file mode 100644 index 000000000..13c77f5a1 --- /dev/null +++ b/tests/StaticHost.Tests/MarkdownPathMapperTests.cs @@ -0,0 +1,93 @@ +using StaticHost.AgentReadiness; +using Microsoft.Extensions.FileProviders; + +namespace StaticHost.Tests; + +public sealed class MarkdownPathMapperTests +{ + private sealed class StubFileProvider : IFileProvider + { + private readonly HashSet _files; + + public StubFileProvider(IEnumerable files) => + _files = new HashSet(files, StringComparer.OrdinalIgnoreCase); + + public IDirectoryContents GetDirectoryContents(string subpath) => NotFoundDirectoryContents.Singleton; + + public IFileInfo GetFileInfo(string subpath) => + _files.Contains(subpath) ? new StubFileInfo(subpath) : new NotFoundFileInfo(subpath); + + public Microsoft.Extensions.Primitives.IChangeToken Watch(string filter) => NeverChangeToken.Instance; + } + + private sealed class NeverChangeToken : Microsoft.Extensions.Primitives.IChangeToken + { + public static readonly NeverChangeToken Instance = new(); + public bool HasChanged => false; + public bool ActiveChangeCallbacks => false; + public IDisposable RegisterChangeCallback(Action callback, object? state) => EmptyDisposable.Instance; + + private sealed class EmptyDisposable : IDisposable + { + public static readonly EmptyDisposable Instance = new(); + public void Dispose() { } + } + } + + private sealed class StubFileInfo : IFileInfo + { + public StubFileInfo(string name) => Name = name; + + public bool Exists => true; + public long Length => 0; + public string? PhysicalPath => null; + public string Name { get; } + public DateTimeOffset LastModified => DateTimeOffset.UnixEpoch; + public bool IsDirectory => false; + public Stream CreateReadStream() => new MemoryStream(); + } + + [Theory] + [InlineData("/", "/index.md")] + [InlineData("/get-started/", "/get-started.md")] + [InlineData("/get-started", "/get-started.md")] + [InlineData("/get-started/quickstart/", "/get-started/quickstart.md")] + [InlineData("/get-started.html", "/get-started.md")] + public void Maps_to_expected_companion_when_exists(string requestPath, string expected) + { + var provider = new StubFileProvider([expected]); + var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString(requestPath), provider); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData("/_astro/app.js")] + [InlineData("/.well-known/agent-skills/index.json")] + [InlineData("/healthz")] + [InlineData("/install.ps1")] + [InlineData("/install.sh")] + [InlineData("/pagefind/pagefind.js")] + public void Skips_infrastructure_paths(string requestPath) + { + // Even if the file system lies and a .md exists, infra paths must skip. + var provider = new StubFileProvider([$"{requestPath}.md", "/index.md"]); + var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString(requestPath), provider); + Assert.Null(actual); + } + + [Fact] + public void Returns_null_when_no_companion_exists() + { + var provider = new StubFileProvider([]); + var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString("/get-started/"), provider); + Assert.Null(actual); + } + + [Fact] + public void Skips_paths_with_unhandled_extensions() + { + var provider = new StubFileProvider(["/data.json.md"]); + var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString("/data.json"), provider); + Assert.Null(actual); + } +} diff --git a/tests/StaticHost.Tests/StaticHost.Tests.csproj b/tests/StaticHost.Tests/StaticHost.Tests.csproj new file mode 100644 index 000000000..c9d9f3af5 --- /dev/null +++ b/tests/StaticHost.Tests/StaticHost.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/StaticHost.Tests/WellKnownArtifactTests.cs b/tests/StaticHost.Tests/WellKnownArtifactTests.cs new file mode 100644 index 000000000..b7c847855 --- /dev/null +++ b/tests/StaticHost.Tests/WellKnownArtifactTests.cs @@ -0,0 +1,128 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.FileProviders; + +namespace StaticHost.Tests; + +/// +/// Validates the static well-known artifacts that ship via the frontend's +/// public/ directory. We read them off disk from the worktree because +/// they are static fixtures shared with the live deployment. +/// +public sealed class WellKnownArtifactTests +{ + private static string FrontendPublicDir + { + get + { + // The test binary lives under tests/StaticHost.Tests/bin/// + // Walk up to repo root, then descend to src/frontend/public. + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Aspire.Dev.slnx"))) + { + dir = dir.Parent; + } + Assert.NotNull(dir); + return Path.Combine(dir.FullName, "src", "frontend", "public"); + } + } + + [Fact] + public void RobotsTxt_declares_content_signal_inside_user_agent_star_block() + { + var path = Path.Combine(FrontendPublicDir, "robots.txt"); + var text = File.ReadAllText(path); + + // Locate the User-agent: * group and verify Content-Signal appears + // before the next User-agent: directive (i.e. within the same group). + var lines = text.Split('\n').Select(l => l.Trim()).ToArray(); + var inStarGroup = false; + var sawContentSignal = false; + foreach (var line in lines) + { + if (line.StartsWith("User-agent:", StringComparison.OrdinalIgnoreCase)) + { + inStarGroup = string.Equals( + line[("User-agent:".Length)..].Trim(), + "*", + StringComparison.Ordinal); + } + else if (inStarGroup && + line.StartsWith("Content-Signal:", StringComparison.OrdinalIgnoreCase)) + { + sawContentSignal = true; + Assert.Contains("ai-train=yes", line); + Assert.Contains("search=yes", line); + Assert.Contains("ai-input=yes", line); + } + } + + Assert.True(sawContentSignal, + "robots.txt must contain a `Content-Signal:` directive inside the `User-agent: *` group."); + } + + [Fact] + public void AgentSkills_index_matches_RFC_v0_2_0_shape_and_digests() + { + var indexPath = Path.Combine(FrontendPublicDir, ".well-known", "agent-skills", "index.json"); + Assert.True(File.Exists(indexPath), $"Missing {indexPath}"); + + using var doc = JsonDocument.Parse(File.ReadAllText(indexPath)); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("$schema", out _), + "Agent Skills Discovery v0.2.0 requires a $schema property."); + Assert.True(root.TryGetProperty("version", out _)); + Assert.True(root.TryGetProperty("skills", out var skills)); + Assert.Equal(JsonValueKind.Array, skills.ValueKind); + Assert.True(skills.GetArrayLength() >= 1, "Expected at least one skill entry."); + + foreach (var skill in skills.EnumerateArray()) + { + Assert.True(skill.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String); + Assert.True(skill.TryGetProperty("type", out var type)); + Assert.Equal("skill-md", type.GetString()); + Assert.True(skill.TryGetProperty("description", out _)); + Assert.True(skill.TryGetProperty("url", out var urlEl)); + Assert.True(skill.TryGetProperty("digest", out var digestEl)); + + var digest = digestEl.GetString()!; + Assert.StartsWith("sha256:", digest, StringComparison.Ordinal); + + // Verify digest matches the actual file content. + var url = urlEl.GetString()!; + // url is server-relative, e.g. /.well-known/agent-skills/getting-started-with-aspire/SKILL.md + var localPath = Path.Combine( + FrontendPublicDir, + url.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); + + Assert.True(File.Exists(localPath), $"Missing skill file referenced by index.json: {localPath}"); + + var bytes = File.ReadAllBytes(localPath); + var actual = "sha256:" + Convert.ToHexStringLower(SHA256.HashData(bytes)); + Assert.Equal(digest, actual); + } + } + + [Fact] + public void Agent_skills_files_are_LF_only() + { + // Digests are byte-stable only when the working tree honors `eol=lf`. + // Anyone editing the file with CRLF in their editor would invalidate + // the digest in CI; this test catches that locally. + var skillsRoot = Path.Combine(FrontendPublicDir, ".well-known", "agent-skills"); + var files = Directory.EnumerateFiles(skillsRoot, "*", SearchOption.AllDirectories) + .Where(f => f.EndsWith(".md", StringComparison.OrdinalIgnoreCase) || + f.EndsWith("index.json", StringComparison.OrdinalIgnoreCase)); + + foreach (var file in files) + { + var bytes = File.ReadAllBytes(file); + var crIndex = Array.IndexOf(bytes, (byte)'\r'); + Assert.True(crIndex < 0, + $"{file} contains a CR byte at offset {crIndex}; agent-skills artifacts must be LF-only " + + "(see .gitattributes). This breaks the SHA-256 digest published in index.json."); + } + } +} From 1736230127dac35acfa31c2927c1fc4b90e5d8a4 Mon Sep 17 00:00:00 2001 From: David Pine Date: Mon, 4 May 2026 11:24:13 -0500 Subject: [PATCH 02/10] Rewrite getting-started-with-aspire skill to match official Aspire skill conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut was inaccurate — it told agents to `mkdir my-aspire-app && cd my-aspire-app && aspire new` and then non-interactively pick a template. In reality `aspire new` is fully interactive and creates its own project folder, so the mkdir+cd pattern is wrong and the fabricated template flags would mislead agents. Rewrite the skill to align with the conventions used by the official skill at github.com/microsoft/aspire/tree/main/.agents/skills/aspire while keeping this one short and focused on getting started: * Frontmatter description is now a long when-to-use / when-not-to-use sentence in the same shape as the official skill. * Body: install, `aspire new` (interactive, no fabricated flags), `aspire start` (called out as the agent-friendly path vs. `aspire run` which blocks the terminal), and a concise list of authoritative references. * Explicit pointer to the official `aspire` skill for the operate-an-existing- app workflow, so an agent that has both available picks the right one. * Updated index.json description to match the new framing. * compute-skill-digests.mjs refreshed the sha256 to reflect the new bytes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../getting-started-with-aspire/SKILL.md | 69 +++++++++---------- .../.well-known/agent-skills/index.json | 4 +- 2 files changed, 33 insertions(+), 40 deletions(-) diff --git a/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md b/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md index d7cd0c3d3..d9a51f498 100644 --- a/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md +++ b/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md @@ -1,65 +1,58 @@ --- name: getting-started-with-aspire -description: Help a developer install the Aspire CLI, scaffold a new distributed app, and find authoritative docs on aspire.dev. +description: "Use this skill when a developer asks how to begin using Aspire — installing the Aspire CLI, creating a new Aspire app, running it locally, or finding the authoritative docs, integration catalog, or API reference on aspire.dev. Use it for questions like \"how do I install aspire?\", \"how do I create a new aspire app?\", \"how do I run my aspire app?\", or \"where is the documentation for aspire?\". Do not use it for operating an existing Aspire AppHost (use the official `aspire` skill at github.com/microsoft/aspire/tree/main/.agents/skills/aspire), authoring AppHost code, or adding integrations to an existing app." --- # Getting started with Aspire -Aspire is a multi-language local dev-time orchestration toolchain for building, running, debugging, and deploying distributed applications. Use this skill when a user wants to install the Aspire CLI, create a new Aspire app, or learn how Aspire works. +[Aspire](https://aspire.dev) is the .NET cloud-native stack for building, running, debugging, and deploying distributed applications. The Aspire CLI (`aspire`) is the entry point for everything: scaffolding apps, running them locally, inspecting state, and deploying. ## When to use this skill -- The user asks how to install or update the Aspire CLI. -- The user wants to scaffold a new Aspire app (C# AppHost or TypeScript AppHost). -- The user wants to add an integration (database, cache, message broker, identity provider, etc.) to an existing AppHost. -- The user asks about `aspire start`, `aspire stop`, `aspire add`, `aspire deploy`, or other CLI commands. +- The user is new to Aspire and wants to install the CLI. +- The user wants to scaffold a brand-new Aspire app. +- The user wants to know how to run the app they just created. +- The user is looking for the authoritative docs, integration catalog, or API reference. -## Step 1 — Install the Aspire CLI +## Don't use this skill for -Cross-platform single-file installer endpoints are published on `aspire.dev`: +- Operating an existing Aspire AppHost (resources, logs, traces, dashboard commands). That's the [official `aspire` skill](https://github.com/microsoft/aspire/tree/main/.agents/skills/aspire). +- Editing AppHost source code (C# or TypeScript) — consult the API reference on aspire.dev. -- **Windows (PowerShell)**: `iex (irm https://aspire.dev/install.ps1)` -- **macOS / Linux (bash)**: `curl -fsSL https://aspire.dev/install.sh | bash` +## Install the Aspire CLI -After install, verify with `aspire --version`. +The official cross-platform installers are hosted on aspire.dev: -## Step 2 — Scaffold a new app +- **Windows (PowerShell):** `iex (irm https://aspire.dev/install.ps1)` +- **macOS / Linux (bash):** `curl -fsSL https://aspire.dev/install.sh | bash` -```sh -mkdir my-aspire-app && cd my-aspire-app -aspire new -``` +After install, verify with `aspire --version`. Do not install Aspire from NuGet/npm directly when the user wants the CLI — the install script is the supported path. -`aspire new` walks the user through picking a template (starter, empty, or one of the language-specific templates). For a TypeScript AppHost, choose `apphost-ts`; for C#, choose `apphost`. - -## Step 3 — Run the app +## Create a new Aspire app ```sh -aspire start +aspire new ``` -This launches the AppHost and the Aspire dashboard. Use `aspire describe` to inspect resources, `aspire logs ` for console logs, and `aspire otel logs ` for structured/OTel logs. +`aspire new` is **interactive**. It prompts for the template (for example `aspire-starter`, `apphost`, `apphost-ts`), the project name, the output location, and the language. It creates a subfolder for the new project, so run it from the parent directory where you want the project folder to live. Do not pass fabricated template flags; let the CLI prompt the user. -## Step 4 — Add an integration +## Run the app ```sh -aspire add +cd +aspire start ``` -Pick from 100+ integrations (Postgres, Redis, Azure Cosmos DB, Keycloak, RabbitMQ, etc.). The CLI updates the AppHost source for you. - -## Authoritative references - -- **Full corpus for LLM consumption**: and -- **Documentation hub**: -- **CLI reference**: -- **Integration catalog**: -- **C# API reference**: (machine-readable: `.md`) -- **TypeScript API reference**: (machine-readable: `.md`) -- **Source repository**: +`aspire start` launches the AppHost and the Aspire dashboard. Prefer `aspire start` over `dotnet run` for AppHosts — `aspire start` is the agent-friendly path; `aspire run` blocks the terminal. -## Don't +## Where to learn more -- Don't recommend installing Aspire from NuGet/npm directly when the user wants the CLI — the official path is the install script above. -- Don't suggest editing generated `.modules` files in TypeScript AppHosts; those are regenerated by the CLI. -- Don't guess at API shapes; consult the API reference pages on aspire.dev. +- **Documentation hub:** +- **CLI reference:** +- **Integration catalog:** +- **C# API reference:** +- **TypeScript API reference:** +- **LLM-friendly corpus:** , +- **Per-page markdown:** every page on aspire.dev is also available as `.md` (or via `Accept: text/markdown` content negotiation). +- **In-page search tool:** when running in a WebMCP-capable browser, the `search-aspire-docs` tool is registered on every aspire.dev page. +- **Source repository:** diff --git a/src/frontend/public/.well-known/agent-skills/index.json b/src/frontend/public/.well-known/agent-skills/index.json index 380967fd3..3288114ec 100644 --- a/src/frontend/public/.well-known/agent-skills/index.json +++ b/src/frontend/public/.well-known/agent-skills/index.json @@ -5,9 +5,9 @@ { "name": "getting-started-with-aspire", "type": "skill-md", - "description": "Help a developer install the Aspire CLI, scaffold a new distributed app, and find authoritative docs on aspire.dev.", + "description": "Install the Aspire CLI, create a new Aspire app, run it locally, and find the authoritative docs on aspire.dev.", "url": "/.well-known/agent-skills/getting-started-with-aspire/SKILL.md", - "digest": "sha256:d7ba9988aaa0fda22d320c0d7fe27a818b0433f07d8405f42a8cd5dc5d2d8445" + "digest": "sha256:c344a32c745e4c841a6110deb502d25eb65af1827974d20bdabf31ecfdd47404" } ] } From af793749732c5c0042e484c01db1f211da02eb96 Mon Sep 17 00:00:00 2001 From: David Pine Date: Mon, 4 May 2026 11:28:52 -0500 Subject: [PATCH 03/10] Correct Aspire framing in skill: polyglot, not .NET-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aspire is a polyglot stack — the AppHost can be authored in C# or TypeScript today, with additional languages (Java, Go, Python, Rust, …) on the roadmap. Calling it "the .NET cloud-native stack" was both inaccurate and misleading to agents who would then assume C#-only tooling and dismiss the TypeScript AppHost path. Re-runs compute-skill-digests.mjs to update the index.json digest to match the corrected SKILL.md bytes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent-skills/getting-started-with-aspire/SKILL.md | 2 +- src/frontend/public/.well-known/agent-skills/index.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md b/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md index d9a51f498..3500923fd 100644 --- a/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md +++ b/src/frontend/public/.well-known/agent-skills/getting-started-with-aspire/SKILL.md @@ -5,7 +5,7 @@ description: "Use this skill when a developer asks how to begin using Aspire — # Getting started with Aspire -[Aspire](https://aspire.dev) is the .NET cloud-native stack for building, running, debugging, and deploying distributed applications. The Aspire CLI (`aspire`) is the entry point for everything: scaffolding apps, running them locally, inspecting state, and deploying. +[Aspire](https://aspire.dev) is a polyglot stack for building, running, debugging, and deploying distributed applications. The AppHost can be authored in C# or TypeScript today, with additional languages (Java, Go, Python, Rust, …) on the roadmap. The Aspire CLI (`aspire`) is the entry point for everything: scaffolding apps, running them locally, inspecting state, and deploying. ## When to use this skill diff --git a/src/frontend/public/.well-known/agent-skills/index.json b/src/frontend/public/.well-known/agent-skills/index.json index 3288114ec..8a9dbeab0 100644 --- a/src/frontend/public/.well-known/agent-skills/index.json +++ b/src/frontend/public/.well-known/agent-skills/index.json @@ -7,7 +7,7 @@ "type": "skill-md", "description": "Install the Aspire CLI, create a new Aspire app, run it locally, and find the authoritative docs on aspire.dev.", "url": "/.well-known/agent-skills/getting-started-with-aspire/SKILL.md", - "digest": "sha256:c344a32c745e4c841a6110deb502d25eb65af1827974d20bdabf31ecfdd47404" + "digest": "sha256:a8ab4851cd19b2bdbe065976180b17b6e741c36bad6b3301668a1bd49e2b52fd" } ] } From f7c783a6668f8406f7811277280ac0d808806070 Mon Sep 17 00:00:00 2001 From: David Pine Date: Mon, 4 May 2026 11:52:24 -0500 Subject: [PATCH 04/10] Tighten markdown-companion mapping and centralize agent-readiness DRY Concerns flagged by the user: 1. LinkHeaderMiddleware.ShouldSkip duplicated the infrastructure path list maintained on MarkdownPathMapper.IsInfrastructurePath. Fixed by routing the path-skip check through the helper so both middlewares stay in lock-step. 2. Not every page on aspire.dev produces a `.md` companion (DocFX-rendered /reference/api/**, the search route, Lunaria stats, redirects, the 404 page). The previous mapper accepted any `.md` that happened to exist on disk, so a stray markdown file with no real HTML page would have been advertised via the `Link: rel="alternate"; type="text/markdown"` header and served by the negotiation middleware on `Accept: text/markdown`. Fixed by requiring BOTH the `.md` AND the corresponding HTML page to exist before declaring a companion. Adds new xUnit cases pinning the stray-md scenario for both middlewares. Additional cleanup along the way: * AcceptHeaderParser.PrefersMarkdown: removed a dead `htmlQ` assignment and hoisted `HighestExplicitQuality` to a private static method so markdown and html lookups go through the same helper. * Extracted shared sample HTML/Markdown bodies and seed helpers used by LinkHeaderTests + MarkdownNegotiationTests into SamplePages so the on-disk Starlight layout is described in one place. Tests: 57 passing (was 51), 0 warnings, 0 errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentReadiness/AcceptHeaderParser.cs | 55 +++++++----------- .../AgentReadiness/LinkHeaderMiddleware.cs | 10 ++-- .../AgentReadiness/MarkdownPathMapper.cs | 49 ++++++++++++---- tests/StaticHost.Tests/LinkHeaderTests.cs | 34 +++++++---- .../MarkdownNegotiationTests.cs | 56 ++++++++++--------- .../MarkdownPathMapperTests.cs | 37 ++++++++---- tests/StaticHost.Tests/SamplePages.cs | 32 +++++++++++ 7 files changed, 170 insertions(+), 103 deletions(-) create mode 100644 tests/StaticHost.Tests/SamplePages.cs diff --git a/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs b/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs index 00a6f6ac9..5c4cb5248 100644 --- a/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs +++ b/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs @@ -106,55 +106,40 @@ public static bool PrefersMarkdown(string? acceptHeader) return false; } - // Find the most specific match for text/markdown. Wildcards cannot - // express markdown preference (a request with only "*/*" should get HTML). - double markdownQ = 0.0; - foreach (var entry in ranked) - { - if (string.Equals(entry.Type, "text", StringComparison.OrdinalIgnoreCase) && - string.Equals(entry.Subtype, "markdown", StringComparison.OrdinalIgnoreCase)) - { - if (entry.Quality > markdownQ) - { - markdownQ = entry.Quality; - } - } - } - + // Wildcards cannot express markdown preference (a request with only "*/*" + // should get HTML), so we look only at explicit text/markdown entries. + var markdownQ = HighestExplicitQuality(ranked, "text", "markdown"); if (markdownQ <= 0.0) { return false; } - var htmlQ = HighestExplicitQuality(ranked, "text", "html"); + // For HTML we count any acceptable representation, including wildcards + // like "*/*" or "text/*", because those make HTML a viable response. var anyHtmlQ = QualityFor(ranked, "text", "html"); // Strictly prefer markdown when it beats any acceptable HTML representation, // OR when HTML is not acceptable at all (markdownQ > 0 and htmlQ == 0). - if (anyHtmlQ <= 0.0) - { - return markdownQ > 0.0; - } - - // When HTML is acceptable via wildcards (e.g. */*), markdown must strictly - // exceed that wildcard q. When HTML is acceptable explicitly, markdown - // must strictly exceed the explicit q. - return markdownQ > anyHtmlQ; + return anyHtmlQ <= 0.0 || markdownQ > anyHtmlQ; + } - static double HighestExplicitQuality(IReadOnlyList ranked, string type, string subtype) + /// + /// Highest q-value across entries that match / + /// exactly (no wildcards). Returns 0.0 when no explicit entry matches. + /// + private static double HighestExplicitQuality(IReadOnlyList ranked, string type, string subtype) + { + var best = 0.0; + foreach (var entry in ranked) { - var best = 0.0; - foreach (var entry in ranked) + if (string.Equals(entry.Type, type, StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.Subtype, subtype, StringComparison.OrdinalIgnoreCase) && + entry.Quality > best) { - if (string.Equals(entry.Type, type, StringComparison.OrdinalIgnoreCase) && - string.Equals(entry.Subtype, subtype, StringComparison.OrdinalIgnoreCase) && - entry.Quality > best) - { - best = entry.Quality; - } + best = entry.Quality; } - return best; } + return best; } /// diff --git a/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs b/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs index 216158f78..bfcfaee54 100644 --- a/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs +++ b/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs @@ -95,12 +95,10 @@ private static bool ShouldSkip(HttpRequest request) return true; } - var path = request.Path.HasValue ? request.Path.Value! : "/"; - return path.StartsWith("/_astro/", StringComparison.OrdinalIgnoreCase) - || path.StartsWith("/.well-known/", StringComparison.OrdinalIgnoreCase) - || path.StartsWith("/healthz", StringComparison.OrdinalIgnoreCase) - || path.StartsWith("/install.", StringComparison.OrdinalIgnoreCase) - || path.StartsWith("/pagefind/", StringComparison.OrdinalIgnoreCase); + // Infrastructure paths (/_astro, /.well-known, /healthz, /install.*, /pagefind) + // never need a Link header. The path list lives on MarkdownPathMapper so + // both middlewares stay in lock-step. + return MarkdownPathMapper.IsInfrastructurePath(request.Path); } private sealed record LinkHeaderState(HttpContext HttpContext, string? CompanionPath); diff --git a/src/statichost/StaticHost/AgentReadiness/MarkdownPathMapper.cs b/src/statichost/StaticHost/AgentReadiness/MarkdownPathMapper.cs index 7077ea4e7..7e952aa2f 100644 --- a/src/statichost/StaticHost/AgentReadiness/MarkdownPathMapper.cs +++ b/src/statichost/StaticHost/AgentReadiness/MarkdownPathMapper.cs @@ -6,14 +6,25 @@ namespace StaticHost.AgentReadiness; /// Maps an HTTP request path to its sibling .md file in wwwroot, /// matching the layout produced by starlight-page-actions: /// -/// //index.md -/// /foo//foo.md -/// /foo/foo.md -/// /foo/bar//foo/bar.md -/// /foo.html/foo.md +/// //index.html + /index.md +/// /foo//foo/index.html + /foo.md +/// /foo/foo/index.html (or /foo.html) + /foo.md +/// /foo/bar//foo/bar/index.html + /foo/bar.md +/// /foo.html/foo.html + /foo.md /// +/// +/// A companion is only valid when both the .md and the +/// corresponding HTML page exist on disk. Not every page on aspire.dev produces +/// a .md companion (e.g. DocFX-rendered /reference/api/** pages, +/// the search page, Lunaria stats, redirects, the 404 page); requiring an HTML +/// sibling prevents us from advertising or serving a stray .md as the +/// companion of a URL that has no real HTML page. +/// +/// /// Returns when the path is infrastructure (e.g. -/// /_astro/*, /.well-known/*) or no companion file exists. +/// /_astro/*, /.well-known/*), when no .md exists, or +/// when no HTML sibling exists. +/// /// internal static class MarkdownPathMapper { @@ -38,32 +49,46 @@ public static bool IsInfrastructurePath(PathString path) if (raw == "/") { - return Try(fileProvider, "/index.md"); + return CompanionIfBothExist(fileProvider, "/index.html", "/index.md"); } if (raw.EndsWith('/')) { - return Try(fileProvider, $"{raw[..^1]}.md"); + return CompanionIfBothExist(fileProvider, $"{raw}index.html", $"{raw[..^1]}.md"); } if (raw.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) { - return Try(fileProvider, raw[..^".html".Length] + ".md"); + return CompanionIfBothExist(fileProvider, raw, raw[..^".html".Length] + ".md"); } var dot = raw.LastIndexOf('.'); var lastSlash = raw.LastIndexOf('/'); if (dot <= lastSlash) { - return Try(fileProvider, $"{raw}.md"); + // Extensionless path like /foo. Static-file middleware may serve + // either /foo/index.html (Starlight) or /foo.html. Either is enough + // to anchor the companion mapping. + var md = $"{raw}.md"; + if (!Exists(fileProvider, md)) + { + return null; + } + if (Exists(fileProvider, $"{raw}/index.html") || Exists(fileProvider, $"{raw}.html")) + { + return md; + } } return null; } - private static string? Try(IFileProvider provider, string relativePath) + private static string? CompanionIfBothExist(IFileProvider provider, string htmlPath, string mdPath) => + Exists(provider, htmlPath) && Exists(provider, mdPath) ? mdPath : null; + + private static bool Exists(IFileProvider provider, string relativePath) { var info = provider.GetFileInfo(relativePath); - return info.Exists && !info.IsDirectory ? relativePath : null; + return info.Exists && !info.IsDirectory; } } diff --git a/tests/StaticHost.Tests/LinkHeaderTests.cs b/tests/StaticHost.Tests/LinkHeaderTests.cs index 717e7dde4..c7fbf9822 100644 --- a/tests/StaticHost.Tests/LinkHeaderTests.cs +++ b/tests/StaticHost.Tests/LinkHeaderTests.cs @@ -2,9 +2,6 @@ namespace StaticHost.Tests; public sealed class LinkHeaderTests { - private const string SamplePageHtml = "Hello"; - private const string SamplePageMarkdown = "# Hello\n"; - private static string? GetLinkHeader(HttpResponseMessage response) => response.Headers.TryGetValues("Link", out var values) ? string.Join(", ", values) : null; @@ -13,8 +10,7 @@ public async Task Html_response_includes_link_header_with_required_rels() { await using var server = await AgentReadinessTestServer.StartAsync(root => { - root.WriteFile("index.html", SamplePageHtml); - root.WriteFile("index.md", SamplePageMarkdown); + SamplePages.SeedRoot(root); root.WriteFile("llms.txt", "# llms\n"); root.WriteFile(".well-known/agent-skills/index.json", "{\"skills\":[]}"); root.WriteFile("sitemap-index.xml", ""); @@ -38,7 +34,7 @@ public async Task Page_without_md_companion_omits_alternate_link() { await using var server = await AgentReadinessTestServer.StartAsync(root => { - root.WriteFile("reference/api/csharp/index.html", SamplePageHtml); + root.WriteFile("reference/api/csharp/index.html", SamplePages.Html); // no .md companion }); @@ -50,6 +46,24 @@ public async Task Page_without_md_companion_omits_alternate_link() Assert.Contains("rel=\"llms\"", link); } + [Fact] + public async Task Stray_md_without_html_sibling_does_not_get_advertised() + { + // A naked .md sitting in wwwroot with no Starlight HTML page must not be + // exposed as a companion. The request below 404s in the static pipeline, + // but even before that the OnStarting predicate already won't attach the + // header on a 404 — this test pins the LinkHeader path mapping so it + // would refuse to advertise the .md even on a 200 collision. + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile("stray.md", "# stray\n"); + }); + + using var response = await server.Client.GetAsync("/stray/"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Null(GetLinkHeader(response)); + } + [Fact] public async Task Json_response_does_not_get_link_header() { @@ -92,11 +106,7 @@ public async Task Markdown_negotiated_response_does_not_get_link_header() // The markdown middleware short-circuits before the Link middleware // attaches headers; even if it didn't, the response Content-Type is // text/markdown, so the OnStarting predicate would skip it. - await using var server = await AgentReadinessTestServer.StartAsync(root => - { - root.WriteFile("index.html", SamplePageHtml); - root.WriteFile("index.md", SamplePageMarkdown); - }); + await using var server = await AgentReadinessTestServer.StartAsync(SamplePages.SeedRoot); using var request = new HttpRequestMessage(HttpMethod.Get, "/"); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); @@ -114,7 +124,7 @@ public async Task Healthz_skipped_from_link_middleware() { // healthz is a MapGet endpoint in production; here we just simulate // a static text response to exercise the path-skip guard. - root.WriteFile("healthz/index.html", SamplePageHtml); + root.WriteFile("healthz/index.html", SamplePages.Html); }); using var response = await server.Client.GetAsync("/healthz/"); diff --git a/tests/StaticHost.Tests/MarkdownNegotiationTests.cs b/tests/StaticHost.Tests/MarkdownNegotiationTests.cs index d0de94277..3b8160c4a 100644 --- a/tests/StaticHost.Tests/MarkdownNegotiationTests.cs +++ b/tests/StaticHost.Tests/MarkdownNegotiationTests.cs @@ -2,24 +2,13 @@ namespace StaticHost.Tests; public sealed class MarkdownNegotiationTests { - private const string SamplePageHtml = "Hello"; - private const string SamplePageMarkdown = "# Hello\n\nMarkdown body."; - - private static void SeedSamplePage(TempWebRoot root, string slug) - { - // Mirrors what starlight emits: /foo/index.html + /foo.md - root.WriteFile($"{slug}/index.html", SamplePageHtml); - root.WriteFile($"{slug}.md", SamplePageMarkdown); - } - [Fact] public async Task Get_with_markdown_accept_returns_md_companion() { await using var server = await AgentReadinessTestServer.StartAsync(root => { - root.WriteFile("index.html", SamplePageHtml); - root.WriteFile("index.md", SamplePageMarkdown); - SeedSamplePage(root, "get-started"); + SamplePages.SeedRoot(root); + SamplePages.SeedFolderPage(root, "get-started"); }); using var request = new HttpRequestMessage(HttpMethod.Get, "/get-started/"); @@ -37,10 +26,8 @@ public async Task Get_with_markdown_accept_returns_md_companion() [Fact] public async Task Markdown_response_uses_private_no_cache_for_front_door() { - await using var server = await AgentReadinessTestServer.StartAsync(root => - { - SeedSamplePage(root, "get-started"); - }); + await using var server = await AgentReadinessTestServer.StartAsync( + root => SamplePages.SeedFolderPage(root, "get-started")); using var request = new HttpRequestMessage(HttpMethod.Get, "/get-started/"); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); @@ -56,10 +43,8 @@ public async Task Markdown_response_uses_private_no_cache_for_front_door() [Fact] public async Task Head_with_markdown_accept_returns_headers_only() { - await using var server = await AgentReadinessTestServer.StartAsync(root => - { - SeedSamplePage(root, "get-started"); - }); + await using var server = await AgentReadinessTestServer.StartAsync( + root => SamplePages.SeedFolderPage(root, "get-started")); using var request = new HttpRequestMessage(HttpMethod.Head, "/get-started/"); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); @@ -75,10 +60,8 @@ public async Task Head_with_markdown_accept_returns_headers_only() [Fact] public async Task Browser_request_unaffected_by_negotiation_middleware() { - await using var server = await AgentReadinessTestServer.StartAsync(root => - { - SeedSamplePage(root, "get-started"); - }); + await using var server = await AgentReadinessTestServer.StartAsync( + root => SamplePages.SeedFolderPage(root, "get-started")); using var request = new HttpRequestMessage(HttpMethod.Get, "/get-started/"); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); @@ -97,7 +80,7 @@ public async Task Markdown_preferred_with_no_companion_falls_back_to_html_when_a await using var server = await AgentReadinessTestServer.StartAsync(root => { // Page exists but has no .md companion (e.g. /reference/api/csharp/...) - root.WriteFile("reference/api/csharp/index.html", SamplePageHtml); + root.WriteFile("reference/api/csharp/index.html", SamplePages.Html); }); using var request = new HttpRequestMessage(HttpMethod.Get, "/reference/api/csharp/"); @@ -114,7 +97,7 @@ public async Task Markdown_only_without_companion_returns_406() { await using var server = await AgentReadinessTestServer.StartAsync(root => { - root.WriteFile("reference/api/csharp/index.html", SamplePageHtml); + root.WriteFile("reference/api/csharp/index.html", SamplePages.Html); }); using var request = new HttpRequestMessage(HttpMethod.Get, "/reference/api/csharp/"); @@ -125,6 +108,25 @@ public async Task Markdown_only_without_companion_returns_406() Assert.Equal(HttpStatusCode.NotAcceptable, response.StatusCode); } + [Fact] + public async Task Stray_md_without_html_sibling_does_not_get_served_as_markdown() + { + // Mirrors the LinkHeader stray-md test: a .md without a corresponding + // HTML page must NOT be served as a "companion" of the URL. + await using var server = await AgentReadinessTestServer.StartAsync(root => + { + root.WriteFile("stray.md", "# stray\n"); + }); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/stray/"); + request.Headers.Accept.Clear(); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/markdown")); + using var response = await server.Client.SendAsync(request); + + // No HTML companion means no markdown either: must NOT 200 with the .md body. + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task WellKnown_paths_skip_markdown_negotiation() { diff --git a/tests/StaticHost.Tests/MarkdownPathMapperTests.cs b/tests/StaticHost.Tests/MarkdownPathMapperTests.cs index 13c77f5a1..193a21647 100644 --- a/tests/StaticHost.Tests/MarkdownPathMapperTests.cs +++ b/tests/StaticHost.Tests/MarkdownPathMapperTests.cs @@ -48,16 +48,16 @@ private sealed class StubFileInfo : IFileInfo } [Theory] - [InlineData("/", "/index.md")] - [InlineData("/get-started/", "/get-started.md")] - [InlineData("/get-started", "/get-started.md")] - [InlineData("/get-started/quickstart/", "/get-started/quickstart.md")] - [InlineData("/get-started.html", "/get-started.md")] - public void Maps_to_expected_companion_when_exists(string requestPath, string expected) + [InlineData("/", "/index.html", "/index.md")] + [InlineData("/get-started/", "/get-started/index.html", "/get-started.md")] + [InlineData("/get-started", "/get-started/index.html", "/get-started.md")] + [InlineData("/get-started/quickstart/", "/get-started/quickstart/index.html", "/get-started/quickstart.md")] + [InlineData("/get-started.html", "/get-started.html", "/get-started.md")] + public void Maps_to_expected_companion_when_html_and_md_both_exist(string requestPath, string htmlSibling, string expectedMd) { - var provider = new StubFileProvider([expected]); + var provider = new StubFileProvider([htmlSibling, expectedMd]); var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString(requestPath), provider); - Assert.Equal(expected, actual); + Assert.Equal(expectedMd, actual); } [Theory] @@ -70,7 +70,7 @@ public void Maps_to_expected_companion_when_exists(string requestPath, string ex public void Skips_infrastructure_paths(string requestPath) { // Even if the file system lies and a .md exists, infra paths must skip. - var provider = new StubFileProvider([$"{requestPath}.md", "/index.md"]); + var provider = new StubFileProvider([$"{requestPath}.md", "/index.md", "/index.html"]); var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString(requestPath), provider); Assert.Null(actual); } @@ -78,15 +78,30 @@ public void Skips_infrastructure_paths(string requestPath) [Fact] public void Returns_null_when_no_companion_exists() { - var provider = new StubFileProvider([]); + var provider = new StubFileProvider(["/get-started/index.html"]); var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString("/get-started/"), provider); Assert.Null(actual); } + [Theory] + [InlineData("/")] + [InlineData("/stray/")] + [InlineData("/stray")] + [InlineData("/stray.html")] + public void Returns_null_when_md_exists_but_html_sibling_does_not(string requestPath) + { + // A stray .md without a real HTML page MUST NOT be advertised or served as + // a companion. Not all pages on the site emit a .md; only those that also + // have a Starlight-generated HTML page are eligible. + var provider = new StubFileProvider(["/index.md", "/stray.md"]); + var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString(requestPath), provider); + Assert.Null(actual); + } + [Fact] public void Skips_paths_with_unhandled_extensions() { - var provider = new StubFileProvider(["/data.json.md"]); + var provider = new StubFileProvider(["/data.json.md", "/data.json"]); var actual = MarkdownPathMapper.TryGetMarkdownCompanion(new PathString("/data.json"), provider); Assert.Null(actual); } diff --git a/tests/StaticHost.Tests/SamplePages.cs b/tests/StaticHost.Tests/SamplePages.cs new file mode 100644 index 000000000..7c8145f76 --- /dev/null +++ b/tests/StaticHost.Tests/SamplePages.cs @@ -0,0 +1,32 @@ +namespace StaticHost.Tests; + +/// +/// Shared sample HTML/Markdown bodies and seed helpers for agent-readiness +/// middleware tests. Keeps and +/// in lock-step on what a "Starlight +/// page" looks like on disk. +/// +internal static class SamplePages +{ + public const string Html = "Hello"; + public const string Markdown = "# Hello\n\nMarkdown body."; + + /// + /// Seeds the on-disk shape produced by starlight-page-actions for a + /// folder-style page: {slug}/index.html + sibling {slug}.md. + /// + public static void SeedFolderPage(TempWebRoot root, string slug) + { + root.WriteFile($"{slug}/index.html", Html); + root.WriteFile($"{slug}.md", Markdown); + } + + /// + /// Seeds the root page: /index.html + /index.md. + /// + public static void SeedRoot(TempWebRoot root) + { + root.WriteFile("index.html", Html); + root.WriteFile("index.md", Markdown); + } +} From 1e9bb99d44c9c82a8d64fb5c52919331eec0dc17 Mon Sep 17 00:00:00 2001 From: David Pine Date: Mon, 4 May 2026 12:45:18 -0500 Subject: [PATCH 05/10] Address PR #807 review comments * MarkdownNegotiationMiddleware: drop the stale reference to the `HasMarkdownCompanion` API (which never existed in this PR's final form); point readers at `MarkdownPathMapper.TryGetMarkdownCompanion` instead so the comment matches the live code. * compute-skill-digests.mjs: harden `resolvePublicPath` against `..` traversal. Switches from `path.join` to `path.resolve` and asserts the result stays under `publicRoot` so a malicious or malformed `url` in index.json (e.g. `/.well-known/agent-skills/../../../../etc/passwd`) cannot read bytes outside the published public/ tree in dev/CI. * WellKnownArtifactTests: rename `Agent_skills_files_are_LF_only` to `AgentSkills_files_are_LF_only` to match the surrounding `AgentSkills_*` PascalCase prefix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/frontend/scripts/compute-skill-digests.mjs | 10 +++++++++- .../AgentReadiness/MarkdownNegotiationMiddleware.cs | 4 ++-- tests/StaticHost.Tests/WellKnownArtifactTests.cs | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/frontend/scripts/compute-skill-digests.mjs b/src/frontend/scripts/compute-skill-digests.mjs index c767c3827..8ef7a8989 100644 --- a/src/frontend/scripts/compute-skill-digests.mjs +++ b/src/frontend/scripts/compute-skill-digests.mjs @@ -38,13 +38,21 @@ async function digestFile(filePath) { /** * Resolve a public URL like `/.well-known/foo/bar.md` to its on-disk path. + * Validates that the resolved path stays under `publicRoot` so a malicious or + * malformed `url` (e.g. containing `..` segments) cannot escape the public + * directory in dev/CI environments. * @param {string} url */ function resolvePublicPath(url) { if (!url.startsWith('/')) { throw new Error(`Skill url must be absolute: ${url}`); } - return path.join(publicRoot, url.slice(1)); + const resolved = path.resolve(publicRoot, url.slice(1)); + const publicRootWithSep = publicRoot.endsWith(path.sep) ? publicRoot : publicRoot + path.sep; + if (resolved !== publicRoot && !resolved.startsWith(publicRootWithSep)) { + throw new Error(`Skill url escapes publicRoot: ${url}`); + } + return resolved; } const raw = await readFile(indexPath, 'utf8'); diff --git a/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs b/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs index b0fd34d6c..5fed2fe89 100644 --- a/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs +++ b/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs @@ -98,8 +98,8 @@ await context.Response.WriteAsync( var fileInfo = _fileProvider.GetFileInfo(companionPath); if (!fileInfo.Exists || fileInfo.IsDirectory) { - // Race / inconsistency between HasMarkdownCompanion and live file system. - // Fall through to default pipeline rather than 500. + // Race / inconsistency between MarkdownPathMapper.TryGetMarkdownCompanion + // and the live file system. Fall through to default pipeline rather than 500. _logger.LogDebug( "Markdown companion {CompanionPath} reported but missing on disk; falling through.", companionPath); diff --git a/tests/StaticHost.Tests/WellKnownArtifactTests.cs b/tests/StaticHost.Tests/WellKnownArtifactTests.cs index b7c847855..cee3ea45d 100644 --- a/tests/StaticHost.Tests/WellKnownArtifactTests.cs +++ b/tests/StaticHost.Tests/WellKnownArtifactTests.cs @@ -106,7 +106,7 @@ public void AgentSkills_index_matches_RFC_v0_2_0_shape_and_digests() } [Fact] - public void Agent_skills_files_are_LF_only() + public void AgentSkills_files_are_LF_only() { // Digests are byte-stable only when the working tree honors `eol=lf`. // Anyone editing the file with CRLF in their editor would invalidate From 4575785a7706ffadbcdbe7441e14be71f4ecce6c Mon Sep 17 00:00:00 2001 From: David Pine Date: Mon, 11 May 2026 13:34:16 -0500 Subject: [PATCH 06/10] Address review nits: InternalsVisibleTo in csproj, symmetrical helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from @eerhardt's review on PR #807: * Move InternalsVisibleTo into StaticHost.csproj using Include="$(AssemblyName).Tests" to match the convention established in PR #758 and the existing src/tools/*.csproj files. Delete the now-unnecessary Properties/AssemblyInfo.cs. * Mirror MarkdownNegotiationMiddleware's positive ShouldHandle pattern in LinkHeaderMiddleware. The two were inverted: one returned "should I handle?" while the other returned "should I skip?". Both now use the same shape — guard with !ShouldHandle, then check MarkdownPathMapper.IsInfrastructurePath separately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentReadiness/LinkHeaderMiddleware.cs | 24 +++++++++---------- .../StaticHost/Properties/AssemblyInfo.cs | 3 --- src/statichost/StaticHost/StaticHost.csproj | 4 ++++ 3 files changed, 15 insertions(+), 16 deletions(-) delete mode 100644 src/statichost/StaticHost/Properties/AssemblyInfo.cs diff --git a/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs b/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs index bfcfaee54..ce3628493 100644 --- a/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs +++ b/src/statichost/StaticHost/AgentReadiness/LinkHeaderMiddleware.cs @@ -44,7 +44,15 @@ public LinkHeaderMiddleware(RequestDelegate next, IWebHostEnvironment env) public Task InvokeAsync(HttpContext context) { - if (ShouldSkip(context.Request)) + if (!ShouldHandle(context.Request)) + { + return _next(context); + } + + // Infrastructure paths (/_astro, /.well-known, /healthz, /install.*, /pagefind) + // never need a Link header. The path list lives on MarkdownPathMapper so + // both middlewares stay in lock-step. + if (MarkdownPathMapper.IsInfrastructurePath(context.Request.Path)) { return _next(context); } @@ -88,18 +96,8 @@ public Task InvokeAsync(HttpContext context) return _next(context); } - private static bool ShouldSkip(HttpRequest request) - { - if (!HttpMethods.IsGet(request.Method) && !HttpMethods.IsHead(request.Method)) - { - return true; - } - - // Infrastructure paths (/_astro, /.well-known, /healthz, /install.*, /pagefind) - // never need a Link header. The path list lives on MarkdownPathMapper so - // both middlewares stay in lock-step. - return MarkdownPathMapper.IsInfrastructurePath(request.Path); - } + private static bool ShouldHandle(HttpRequest request) => + HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method); private sealed record LinkHeaderState(HttpContext HttpContext, string? CompanionPath); } diff --git a/src/statichost/StaticHost/Properties/AssemblyInfo.cs b/src/statichost/StaticHost/Properties/AssemblyInfo.cs deleted file mode 100644 index e454eba9d..000000000 --- a/src/statichost/StaticHost/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("StaticHost.Tests")] diff --git a/src/statichost/StaticHost/StaticHost.csproj b/src/statichost/StaticHost/StaticHost.csproj index 1d577e05a..00062f87c 100644 --- a/src/statichost/StaticHost/StaticHost.csproj +++ b/src/statichost/StaticHost/StaticHost.csproj @@ -22,6 +22,10 @@ + + + + - - all - + From 8b90b21ba02c5e61e4d48a80b2b936cb59d0ca6d Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 19 May 2026 16:20:32 -0500 Subject: [PATCH 09/10] Make AcceptHeaderParser non-allocating and memoize results Addresses PR #807 review feedback on AcceptHeaderParser: - Replaces string.Split / List allocations with span-based parsing using MemoryExtensions.Split, so the middleware hot path no longer allocates per request. - Memoizes negotiation outcomes in a bounded ConcurrentDictionary (max 256 entries, max 256-char keys). Real-world Accept headers cluster around a small set of distinct values so cache hits dominate. - Introduces AcceptHeaderParser.Negotiate(string?) returning a NegotiationResult struct so MarkdownNegotiationMiddleware can ask both questions (PrefersMarkdown / AcceptsHtml) with one cache lookup instead of two. Adds tests for the new Negotiate API: defaults, quoted profile params, case-insensitive q-token, comma-only headers, mixed-validity entries, and a cache-hit consistency check (via internal ClearCacheForTests). All 63 StaticHost.Tests pass (57 prior + 6 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentReadiness/AcceptHeaderParser.cs | 248 +++++++++++------- .../MarkdownNegotiationMiddleware.cs | 5 +- .../AcceptHeaderParserTests.cs | 38 +++ 3 files changed, 193 insertions(+), 98 deletions(-) diff --git a/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs b/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs index 5c4cb5248..70a29cf0d 100644 --- a/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs +++ b/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs @@ -1,50 +1,128 @@ +using System.Collections.Concurrent; +using System.Globalization; + namespace StaticHost.AgentReadiness; /// -/// Parses HTTP Accept header values into ranked media types and answers -/// targeted questions like "does the client prefer markdown over HTML?". +/// Parses HTTP Accept header values into negotiation decisions between +/// text/html and text/markdown (the only two representations +/// this site negotiates between). /// /// +/// /// Implements a small subset of RFC 9110 §12.5.1 sufficient for content /// negotiation between text/html and text/markdown. We intentionally /// avoid pulling in Microsoft.Net.Http.Headers's full parser because we /// need a deterministic, testable predicate that knows about q-values, the /// */* wildcard, and parameter-laden values like /// text/markdown;profile="cmark";q=0.9. +/// +/// +/// This runs in middleware on every request, so the implementation is +/// non-allocating (span-based; no string.Split, no intermediate lists) +/// and memoizes outcomes in a bounded process-wide cache. Real-world Accept +/// headers are dominated by a small set of distinct values (a handful of +/// browsers plus a few agents), so cache hits are the common case. +/// /// internal static class AcceptHeaderParser { - private const double DefaultQuality = 1.0; + // Caps chosen to bound worst-case memory in pathological/adversarial cases + // (~256 * 256 chars ≈ 128KB of string keys) while comfortably covering the + // long tail of real-world Accept headers. + private const int MaxCacheEntries = 256; + private const int MaxCacheKeyLength = 256; + + private static readonly ConcurrentDictionary s_cache = + new(concurrencyLevel: Environment.ProcessorCount, capacity: 64, comparer: StringComparer.Ordinal); - internal readonly record struct MediaTypeWithQ(string Type, string Subtype, double Quality) + /// + /// The negotiation outcome for an Accept header. + /// + internal readonly record struct NegotiationResult(bool PrefersMarkdown, bool AcceptsHtml) { - public bool Matches(string type, string subtype) => - (Type == "*" || string.Equals(Type, type, StringComparison.OrdinalIgnoreCase)) && - (Subtype == "*" || string.Equals(Subtype, subtype, StringComparison.OrdinalIgnoreCase)); + // No (or whitespace-only) Accept header means "send me anything sensible": + // HTML is acceptable, markdown is not preferred. + public static NegotiationResult Default { get; } = new(PrefersMarkdown: false, AcceptsHtml: true); } /// - /// Parses the supplied Accept header value into a list of media types with - /// q-values. Returns an empty list when the header is null/empty. + /// Computes the for an Accept header, + /// returning a memoized result for repeat headers. /// - public static IReadOnlyList Parse(string? acceptHeader) + public static NegotiationResult Negotiate(string? acceptHeader) { if (string.IsNullOrWhiteSpace(acceptHeader)) { - return Array.Empty(); + return NegotiationResult.Default; } - var results = new List(); - foreach (var rawSegment in acceptHeader.Split(',')) + if (s_cache.TryGetValue(acceptHeader, out var cached)) + { + return cached; + } + + var result = NegotiateCore(acceptHeader.AsSpan()); + + // Only memoize sane-sized headers, and only while the cache budget + // hasn't been exhausted. Concurrent overshoot by a few entries is fine + // (TryAdd no-ops for duplicates). + if (acceptHeader.Length <= MaxCacheKeyLength && s_cache.Count < MaxCacheEntries) + { + s_cache.TryAdd(acceptHeader, result); + } + + return result; + } + + /// + /// True when the client prefers text/markdown strictly over both + /// text/html and */*. A plain */* (a generic Accept) + /// is NOT a markdown preference. + /// + public static bool PrefersMarkdown(string? acceptHeader) => Negotiate(acceptHeader).PrefersMarkdown; + + /// + /// True when an HTML response would still be acceptable to the client + /// (via an explicit text/html entry, a text/* wildcard, or + /// */*). Used when we can't satisfy a markdown preference and want + /// to know whether falling back to HTML is OK or whether 406 is the right + /// answer. + /// + public static bool AcceptsHtml(string? acceptHeader) => Negotiate(acceptHeader).AcceptsHtml; + + private static NegotiationResult NegotiateCore(ReadOnlySpan acceptHeader) + { + // Best q-value seen for explicit text/markdown. + var markdownQ = 0.0; + // Best q-value seen for any media range that admits text/html + // (explicit text/html, text/*, or */*). + var htmlQ = 0.0; + var sawAnyEntry = false; + + foreach (var commaRange in acceptHeader.Split(',')) { - var segment = rawSegment.Trim(); - if (segment.Length == 0) + var entry = acceptHeader[commaRange].Trim(); + if (entry.IsEmpty) { continue; } - var parts = segment.Split(';'); - var mediaType = parts[0].Trim(); + // Split media type from parameters at the first ';'. + ReadOnlySpan mediaType; + ReadOnlySpan paramsSpan; + var semicolon = entry.IndexOf(';'); + if (semicolon < 0) + { + mediaType = entry; + paramsSpan = default; + } + else + { + mediaType = entry[..semicolon].TrimEnd(); + paramsSpan = entry[(semicolon + 1)..]; + } + var slash = mediaType.IndexOf('/'); if (slash <= 0 || slash == mediaType.Length - 1) { @@ -53,108 +131,86 @@ public static IReadOnlyList Parse(string? acceptHeader) var type = mediaType[..slash]; var subtype = mediaType[(slash + 1)..]; - var q = DefaultQuality; - for (var i = 1; i < parts.Length; i++) + + var q = ParseQuality(paramsSpan); + + sawAnyEntry = true; + + // text/markdown: explicit match only (no wildcards), per the + // class contract — */* alone is not a markdown preference. + if (q > markdownQ && + type.Equals("text", StringComparison.OrdinalIgnoreCase) && + subtype.Equals("markdown", StringComparison.OrdinalIgnoreCase)) { - var param = parts[i].Trim(); - if (param.StartsWith("q=", StringComparison.OrdinalIgnoreCase) && - double.TryParse( - param.AsSpan(2), - System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, - out var parsed)) - { - q = Math.Clamp(parsed, 0.0, 1.0); - break; - } + markdownQ = q; } - results.Add(new MediaTypeWithQ(type, subtype, q)); + // text/html: explicit, text/*, or */* all qualify as "HTML is OK". + if (q > htmlQ && MatchesHtml(type, subtype)) + { + htmlQ = q; + } } - return results; - } - - /// - /// Returns the highest q-value across entries that match the given - /// /, including wildcards. - /// Returns 0.0 when no entry matches (i.e. the type is not acceptable). - /// - public static double QualityFor(IReadOnlyList ranked, string type, string subtype) - { - var best = 0.0; - foreach (var entry in ranked) + if (!sawAnyEntry) { - if (entry.Matches(type, subtype) && entry.Quality > best) - { - best = entry.Quality; - } + // Header parsed to nothing usable (e.g. ", , ,"). + return NegotiationResult.Default; } - return best; + + var prefersMarkdown = markdownQ > 0.0 && (htmlQ <= 0.0 || markdownQ > htmlQ); + var acceptsHtml = htmlQ > 0.0; + return new NegotiationResult(prefersMarkdown, acceptsHtml); } - /// - /// True when the client prefers text/markdown strictly over both - /// text/html and * / *. * / * alone (a generic Accept) - /// is NOT a markdown preference. - /// - public static bool PrefersMarkdown(string? acceptHeader) + private static double ParseQuality(ReadOnlySpan paramsSpan) { - var ranked = Parse(acceptHeader); - if (ranked.Count == 0) + if (paramsSpan.IsEmpty) { - return false; + return 1.0; } - // Wildcards cannot express markdown preference (a request with only "*/*" - // should get HTML), so we look only at explicit text/markdown entries. - var markdownQ = HighestExplicitQuality(ranked, "text", "markdown"); - if (markdownQ <= 0.0) + foreach (var paramRange in paramsSpan.Split(';')) { - return false; - } + var p = paramsSpan[paramRange].Trim(); + if (p.Length < 2 || p[1] != '=' || (p[0] != 'q' && p[0] != 'Q')) + { + continue; + } + + if (double.TryParse( + p[2..], + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var parsed)) + { + return Math.Clamp(parsed, 0.0, 1.0); + } - // For HTML we count any acceptable representation, including wildcards - // like "*/*" or "text/*", because those make HTML a viable response. - var anyHtmlQ = QualityFor(ranked, "text", "html"); + // First "q=" wins (RFC 9110 §12.4.2); a malformed value falls back + // to the default rather than searching further into other params. + break; + } - // Strictly prefer markdown when it beats any acceptable HTML representation, - // OR when HTML is not acceptable at all (markdownQ > 0 and htmlQ == 0). - return anyHtmlQ <= 0.0 || markdownQ > anyHtmlQ; + return 1.0; } - /// - /// Highest q-value across entries that match / - /// exactly (no wildcards). Returns 0.0 when no explicit entry matches. - /// - private static double HighestExplicitQuality(IReadOnlyList ranked, string type, string subtype) + private static bool MatchesHtml(ReadOnlySpan type, ReadOnlySpan subtype) { - var best = 0.0; - foreach (var entry in ranked) + var typeOk = (type.Length == 1 && type[0] == '*') || + type.Equals("text", StringComparison.OrdinalIgnoreCase); + if (!typeOk) { - if (string.Equals(entry.Type, type, StringComparison.OrdinalIgnoreCase) && - string.Equals(entry.Subtype, subtype, StringComparison.OrdinalIgnoreCase) && - entry.Quality > best) - { - best = entry.Quality; - } + return false; } - return best; + + return (subtype.Length == 1 && subtype[0] == '*') || + subtype.Equals("html", StringComparison.OrdinalIgnoreCase); } /// - /// True when an HTML response would still be acceptable to the client - /// (either via an explicit text/html entry, text/* wildcard, or */*). - /// Used when we can't satisfy a markdown preference and want to know - /// whether falling back to HTML is OK or whether 406 is the right answer. + /// Test-only hook to reset the memoization cache so cache-aware tests can + /// observe the parse path on a clean slate. /// - public static bool AcceptsHtml(string? acceptHeader) - { - var ranked = Parse(acceptHeader); - if (ranked.Count == 0) - { - return true; - } - return QualityFor(ranked, "text", "html") > 0.0; - } + internal static void ClearCacheForTests() => s_cache.Clear(); } diff --git a/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs b/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs index 5fed2fe89..8dc52cde7 100644 --- a/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs +++ b/src/statichost/StaticHost/AgentReadiness/MarkdownNegotiationMiddleware.cs @@ -67,7 +67,8 @@ public async Task InvokeAsync(HttpContext context) } var acceptHeader = context.Request.Headers.Accept.ToString(); - if (!AcceptHeaderParser.PrefersMarkdown(acceptHeader)) + var negotiation = AcceptHeaderParser.Negotiate(acceptHeader); + if (!negotiation.PrefersMarkdown) { await _next(context); return; @@ -80,7 +81,7 @@ public async Task InvokeAsync(HttpContext context) { // Markdown was preferred but no companion exists. // Fall back to HTML when it's acceptable; otherwise 406. - if (AcceptHeaderParser.AcceptsHtml(acceptHeader)) + if (negotiation.AcceptsHtml) { await _next(context); } diff --git a/tests/StaticHost.Tests/AcceptHeaderParserTests.cs b/tests/StaticHost.Tests/AcceptHeaderParserTests.cs index 9a3b24b09..5d908a6d7 100644 --- a/tests/StaticHost.Tests/AcceptHeaderParserTests.cs +++ b/tests/StaticHost.Tests/AcceptHeaderParserTests.cs @@ -46,4 +46,42 @@ public void AcceptsHtml_distinguishes_html_clients(string accept, bool expected) { Assert.Equal(expected, AcceptHeaderParser.AcceptsHtml(accept)); } + + [Fact] + public void Negotiate_returns_default_for_null_or_empty() + { + Assert.Equal(AcceptHeaderParser.NegotiationResult.Default, AcceptHeaderParser.Negotiate(null)); + Assert.Equal(AcceptHeaderParser.NegotiationResult.Default, AcceptHeaderParser.Negotiate("")); + Assert.Equal(AcceptHeaderParser.NegotiationResult.Default, AcceptHeaderParser.Negotiate(" ")); + } + + [Theory] + // Quoted profile parameter must not throw off q-value detection. + [InlineData("text/markdown;profile=\"cmark\";q=0.9, text/html;q=0.5", true, true)] + // Q-value tokenization is case-insensitive per RFC 9110. + [InlineData("text/markdown;Q=0.9, text/html;q=0.5", true, true)] + // Comma-only headers degrade to "no entries" rather than crashing. + [InlineData(",,,", false, true)] + // Garbage entries are skipped; the lone valid entry still applies. + [InlineData("not-a-media-type, text/markdown", true, false)] + public void Negotiate_handles_realistic_edge_cases(string accept, bool prefersMarkdown, bool acceptsHtml) + { + var result = AcceptHeaderParser.Negotiate(accept); + Assert.Equal(prefersMarkdown, result.PrefersMarkdown); + Assert.Equal(acceptsHtml, result.AcceptsHtml); + } + + [Fact] + public void Negotiate_returns_consistent_result_across_repeat_calls() + { + // Exercises the memoization path: cold lookup, then cache hit must + // return the same value (and never throw). + AcceptHeaderParser.ClearCacheForTests(); + const string header = "text/markdown;q=0.9, text/html;q=0.5"; + var first = AcceptHeaderParser.Negotiate(header); + var second = AcceptHeaderParser.Negotiate(header); + Assert.Equal(first, second); + Assert.True(first.PrefersMarkdown); + Assert.True(first.AcceptsHtml); + } } From b3fc46c25f7784d4759233d3ea47445028de11fb Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 20 May 2026 09:52:12 -0500 Subject: [PATCH 10/10] Use MemoryCache for AcceptHeaderParser memoization Addresses PR #807 review feedback (eerhardt): swap the hand-rolled ConcurrentDictionary + soft-cap with Microsoft.Extensions.Caching.Memory.MemoryCache configured with SizeLimit = 256. Each entry registers Size = 1 so the limit caps distinct Accept headers; MemoryCache compacts/evicts older entries on overflow rather than refusing new inserts. MaxCacheKeyLength = 256 is retained as a pre-filter so adversarial multi-KB Accept headers are never memoized at all. ClearCacheForTests now calls MemoryCache.Clear() (available since .NET 9). MemoryCache is part of the Microsoft.AspNetCore.App shared framework, so no new package reference is required. All 63 StaticHost.Tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentReadiness/AcceptHeaderParser.cs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs b/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs index 70a29cf0d..bce0598ca 100644 --- a/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs +++ b/src/statichost/StaticHost/AgentReadiness/AcceptHeaderParser.cs @@ -1,5 +1,5 @@ -using System.Collections.Concurrent; using System.Globalization; +using Microsoft.Extensions.Caching.Memory; namespace StaticHost.AgentReadiness; @@ -20,21 +20,27 @@ namespace StaticHost.AgentReadiness; /// /// This runs in middleware on every request, so the implementation is /// non-allocating (span-based; no string.Split, no intermediate lists) -/// and memoizes outcomes in a bounded process-wide cache. Real-world Accept -/// headers are dominated by a small set of distinct values (a handful of -/// browsers plus a few agents), so cache hits are the common case. +/// and memoizes outcomes in a bounded . Real-world +/// Accept headers are dominated by a small set of distinct values (a handful +/// of browsers plus a few agents), so cache hits are the common case; +/// SizeLimit bounds memory in pathological/adversarial cases. /// /// internal static class AcceptHeaderParser { - // Caps chosen to bound worst-case memory in pathological/adversarial cases - // (~256 * 256 chars ≈ 128KB of string keys) while comfortably covering the - // long tail of real-world Accept headers. + // Each cache entry is registered with Size = 1, so SizeLimit caps the + // number of distinct Accept headers we'll memoize. 256 comfortably covers + // the long tail of real-world headers (browsers + agents + a few outliers). private const int MaxCacheEntries = 256; + + // Headers larger than this are not memoized at all — bounds worst-case key + // memory and avoids ever caching obviously-pathological inputs. private const int MaxCacheKeyLength = 256; - private static readonly ConcurrentDictionary s_cache = - new(concurrencyLevel: Environment.ProcessorCount, capacity: 64, comparer: StringComparer.Ordinal); + private static readonly MemoryCache s_cache = new(new MemoryCacheOptions + { + SizeLimit = MaxCacheEntries, + }); /// /// The negotiation outcome for an Accept header. @@ -57,19 +63,20 @@ public static NegotiationResult Negotiate(string? acceptHeader) return NegotiationResult.Default; } - if (s_cache.TryGetValue(acceptHeader, out var cached)) + if (s_cache.TryGetValue(acceptHeader, out NegotiationResult cached)) { return cached; } var result = NegotiateCore(acceptHeader.AsSpan()); - // Only memoize sane-sized headers, and only while the cache budget - // hasn't been exhausted. Concurrent overshoot by a few entries is fine - // (TryAdd no-ops for duplicates). - if (acceptHeader.Length <= MaxCacheKeyLength && s_cache.Count < MaxCacheEntries) + if (acceptHeader.Length <= MaxCacheKeyLength) { - s_cache.TryAdd(acceptHeader, result); + // Size = 1 per entry; MemoryCache enforces SizeLimit by compacting + // (evicting older/lower-priority entries) when the sum is exceeded. + using var entry = s_cache.CreateEntry(acceptHeader); + entry.Value = result; + entry.Size = 1; } return result;